diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 4fd255842e4..6c682e673e4 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -5,3 +5,4 @@ self-hosted-runner: - blacksmith-2vcpu-ubuntu-2204-arm - blacksmith-4vcpu-ubuntu-2204-arm - blacksmith-8vcpu-ubuntu-2204 + - ubuntu-slim diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index 269bdce4ba5..ee6594aecb2 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -123,24 +123,26 @@ jobs: with: ref: refs/pull/${{ github.event.pull_request.number }}/merge - e2e-test: + e2e-tests: name: E2E Tests - needs: [install-and-build, unit-test, typecheck, lint] - if: | - always() && - needs.install-and-build.result == 'success' && - needs.unit-test.result != 'failure' && - needs.typecheck.result != 'failure' && - needs.lint.result != 'failure' - uses: ./.github/workflows/playwright-test-reusable.yml + needs: [install-and-build] + if: needs.install-and-build.outputs.non_python_changed == 'true' + uses: ./.github/workflows/playwright-test-ci.yml secrets: inherit - e2e-checks: - name: E2E - Checks - runs-on: ubuntu-latest - needs: [e2e-test] + # This job is required by GitHub branch protection rules. + # PRs cannot be merged unless this job passes. + # If you add/remove jobs that should block merging, update the 'needs' array below. + required-checks: + name: Required Checks + needs: [install-and-build, unit-test, typecheck, lint, e2e-tests] if: always() + runs-on: ubuntu-slim steps: - - name: Fail if E2E tests failed - if: needs.e2e-test.result == 'failure' + - name: Fail if any required job failed or was skipped unexpectedly + # The non_python_changed check allows jobs to be skipped for python-only changes, + # since those jobs don't run when only python files are modified. + if: | + contains(needs.*.result, 'failure') || + (needs.install-and-build.outputs.non_python_changed == 'true' && contains(needs.*.result, 'skipped')) run: exit 1 diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 16caecc15ca..59749374713 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -42,18 +42,6 @@ on: required: false type: string - pull_request: - types: - - opened - - ready_for_review - paths: - - '.github/workflows/docker-build-push.yml' - - '.github/scripts/docker/docker-config.mjs' - - '.github/scripts/docker/docker-tags.mjs' - - 'docker/images/n8n/Dockerfile' - - 'docker/images/runners/Dockerfile' - - 'docker/images/runners/Dockerfile.distroless' - jobs: determine-build-context: name: Determine Build Context @@ -83,7 +71,7 @@ jobs: name: Build App, then Build and Push Docker Image (${{ matrix.platform }}) needs: determine-build-context runs-on: ${{ matrix.runner }} - timeout-minutes: 15 + timeout-minutes: 25 strategy: matrix: ${{ fromJSON(needs.determine-build-context.outputs.build_matrix) }} outputs: diff --git a/.github/workflows/playwright-nightly.yml b/.github/workflows/playwright-nightly.yml deleted file mode 100644 index f458f3d118c..00000000000 --- a/.github/workflows/playwright-nightly.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Playwright Tests - Nightly - -on: - schedule: - - cron: '0 4 * * *' - workflow_dispatch: - inputs: - image: - description: 'Docker image to test against' - required: false - default: 'n8nio/n8n:nightly' - type: string - push: - branches: - - ci-containers-nightly - -jobs: - test-configurations: - strategy: - fail-fast: false - matrix: - config: [standard, postgres] - name: Test ${{ matrix.config }} - uses: ./.github/workflows/playwright-test-reusable.yml - with: - test-mode: docker-pull - docker-image: ${{ github.event.inputs.image || 'n8nio/n8n:nightly' }} - test-command: pnpm --filter=n8n-playwright test:container:${{ matrix.config }} - secrets: inherit diff --git a/.github/workflows/playwright-test-ci.yml b/.github/workflows/playwright-test-ci.yml new file mode 100644 index 00000000000..b438301908d --- /dev/null +++ b/.github/workflows/playwright-test-ci.yml @@ -0,0 +1,55 @@ +name: E2E Tests for CI + +on: + workflow_call: + +jobs: + # Multi-main: postgres + redis + caddy + 2 mains + 1 worker + # Only runs for internal PRs (not community/fork PRs) + multi-main-ui: + name: 'Multi-Main: UI' + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: ./.github/workflows/playwright-test-reusable.yml + with: + test-mode: docker-build + test-command: pnpm --filter=n8n-playwright test:container:multi-main:ui + shards: '[1, 2, 3, 4, 5, 6, 7, 8]' + runner: blacksmith-4vcpu-ubuntu-2204 + workers: '1' + secrets: inherit + + multi-main-isolated: + name: 'Multi-Main: Isolated' + if: ${{ !github.event.pull_request.head.repo.fork }} + uses: ./.github/workflows/playwright-test-reusable.yml + with: + test-mode: docker-build + test-command: pnpm --filter=n8n-playwright test:container:multi-main:isolated + shards: '[1]' + runner: blacksmith-4vcpu-ubuntu-2204 + workers: '1' + secrets: inherit + + # Community PR tests: Local mode with SQLite (no container building, no secrets required) + # Runs on GitHub-hosted runners without Currents reporting + community-ui: + name: 'Community: UI' + if: ${{ github.event.pull_request.head.repo.fork }} + uses: ./.github/workflows/playwright-test-reusable.yml + with: + test-mode: local + test-command: pnpm --filter=n8n-playwright test:local + shards: '[1, 2, 3, 4, 5]' + runner: ubuntu-latest + workers: '2' + + community-isolated: + name: 'Community: Isolated' + if: ${{ github.event.pull_request.head.repo.fork }} + uses: ./.github/workflows/playwright-test-reusable.yml + with: + test-mode: local + test-command: pnpm --filter=n8n-playwright test:local:isolated + shards: '[1]' + runner: ubuntu-latest + workers: '1' diff --git a/.github/workflows/playwright-test-coverage.yml b/.github/workflows/playwright-test-coverage.yml index d10cf8d664d..cdcadef6f5d 100644 --- a/.github/workflows/playwright-test-coverage.yml +++ b/.github/workflows/playwright-test-coverage.yml @@ -22,15 +22,21 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup-nodejs-blacksmith - with: - build-command: pnpm turbo build:playwright + env: + INCLUDE_TEST_CONTROLLER: 'true' - - name: Build with Coverage - run: pnpm --filter n8n-editor-ui build:coverage + - name: Build Docker Image with Coverage + run: pnpm build:docker:coverage + env: + INCLUDE_TEST_CONTROLLER: 'true' - - name: Run Coverage Tests + - name: Install Browsers + run: pnpm turbo install-browsers:ci + + - name: Run Container Coverage Tests + id: coverage-tests run: | - pnpm --filter n8n-playwright test:local \ + pnpm --filter n8n-playwright test:container:standard \ --workers=${{ env.PLAYWRIGHT_WORKERS }} env: BUILD_WITH_COVERAGE: 'true' @@ -40,11 +46,23 @@ jobs: QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }} - name: Generate Coverage Report + if: always() && steps.coverage-tests.outcome != 'skipped' run: pnpm --filter n8n-playwright coverage:report - - name: Upload Coverage Report + - name: Upload Coverage Report Artifact + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-report path: packages/testing/playwright/coverage/ retention-days: 14 + + - name: Upload E2E Coverage to Codecov + if: always() + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/testing/playwright/coverage/lcov.info + flags: frontend-e2e + name: playwright-e2e + fail_ci_if_error: false diff --git a/.github/workflows/playwright-test-docker-build.yml b/.github/workflows/playwright-test-docker-build.yml deleted file mode 100644 index 4661f05330f..00000000000 --- a/.github/workflows/playwright-test-docker-build.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Run Playwright Tests (Docker Build) -# This workflow is used to run Playwright tests in a Docker container built from the current branch - -on: - workflow_call: - workflow_dispatch: - -jobs: - build-and-test: - uses: ./.github/workflows/playwright-test-reusable.yml - with: - test-mode: docker-build - test-command: pnpm --filter=n8n-playwright test:container:standard - secrets: inherit diff --git a/.github/workflows/playwright-test-reusable.yml b/.github/workflows/playwright-test-reusable.yml index 5759de3862a..d51a4534b90 100644 --- a/.github/workflows/playwright-test-reusable.yml +++ b/.github/workflows/playwright-test-reusable.yml @@ -32,6 +32,11 @@ on: required: false default: '' type: string + runner: + description: 'GitHub runner to use' + required: false + default: 'blacksmith-2vcpu-ubuntu-2204' + type: string secrets: CURRENTS_RECORD_KEY: @@ -42,6 +47,10 @@ on: required: false QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: required: false + N8N_LICENSE_ACTIVATION_KEY: + required: false + N8N_ENCRYPTION_KEY: + required: false env: PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/ms-playwright-cache @@ -49,10 +58,13 @@ env: # Disable Ryuk to avoid issues with Docker since it needs privileged access, containers are cleaned on teardown anyway TESTCONTAINERS_RYUK_DISABLED: true PLAYWRIGHT_WORKERS: ${{ inputs.workers || '2' }} # Configurable workers, defaults to 2 to reduce resource contention + # Must match CI's COVERAGE_ENABLED to ensure Turbo cache hits (it's a globalEnv in turbo.json) + COVERAGE_ENABLED: 'true' jobs: test: - runs-on: blacksmith-2vcpu-ubuntu-2204 + runs-on: ${{ inputs.runner }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -101,3 +113,4 @@ jobs: QA_PERFORMANCE_METRICS_WEBHOOK_URL: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_URL }} QA_PERFORMANCE_METRICS_WEBHOOK_USER: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_USER }} QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD: ${{ secrets.QA_PERFORMANCE_METRICS_WEBHOOK_PASSWORD }} + N8N_LICENSE_ACTIVATION_KEY: ${{ secrets.N8N_LICENSE_ACTIVATION_KEY }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 0e91e310bc9..8174a49841d 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -43,7 +43,7 @@ jobs: pnpm add --global wrangler - name: Deploy - uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 id: cloudflare_deployment with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/sync-public-api-docs.yml b/.github/workflows/sync-public-api-docs.yml index 6e505c7847d..4ba97452a63 100644 --- a/.github/workflows/sync-public-api-docs.yml +++ b/.github/workflows/sync-public-api-docs.yml @@ -137,7 +137,7 @@ jobs: if: steps.verify_file.outputs.file_exists == 'true' # Pin v7.0.8 - uses: peter-evans/create-pull-request@18e469570b1cf0dfc11d60ec121099f8ff3e617a + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 with: token: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/update-node-popularity.yml b/.github/workflows/update-node-popularity.yml index d8a46bcf652..9f42729310b 100644 --- a/.github/workflows/update-node-popularity.yml +++ b/.github/workflows/update-node-popularity.yml @@ -56,5 +56,5 @@ jobs: branch: update-node-popularity base: master delete-branch: true - author: n8n Bot <191478365+n8n-bot@users.noreply.github.com> - committer: n8n Bot <191478365+n8n-bot@users.noreply.github.com> + author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d8d08a2b4..fdd192fd868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,74 @@ +# [1.123.0](https://github.com/n8n-io/n8n/compare/n8n@1.122.0...n8n@1.123.0) (2025-12-01) + + +### Bug Fixes + +* **AI Agent Node:** Unify memory management for streaming/non-streaming ([#22243](https://github.com/n8n-io/n8n/issues/22243)) ([df81c77](https://github.com/n8n-io/n8n/commit/df81c77a248136b141e2324036cc8ecdc41f3590)) +* **ai-builder:** Fix import of multiple nodes with maxNode, add validation ([#22348](https://github.com/n8n-io/n8n/issues/22348)) ([4319da6](https://github.com/n8n-io/n8n/commit/4319da6f1cc1421285ada04551969a170ca5c518)) +* **ai-builder:** Keep existing pin data when modifying the workflow ([#22266](https://github.com/n8n-io/n8n/issues/22266)) ([83ea8e1](https://github.com/n8n-io/n8n/commit/83ea8e1f91c5ee07fee89e39743b3c39bbd479db)) +* Bump node-forge and body-parser ([#22418](https://github.com/n8n-io/n8n/issues/22418)) ([3c0e809](https://github.com/n8n-io/n8n/commit/3c0e809e416569c64d3c5e96c4c5f5f84b15e255)) +* **core, editor:** Support inclusive and exclusive destination node mode in the frontend and API ([#22244](https://github.com/n8n-io/n8n/issues/22244)) ([344c909](https://github.com/n8n-io/n8n/commit/344c90940e7e75ddaeba5e23c0953feebb7aa98e)) +* **core:** Add missing workflowId when creating base subworkflowworkflow additional data ([#22312](https://github.com/n8n-io/n8n/issues/22312)) ([8f6c3b2](https://github.com/n8n-io/n8n/commit/8f6c3b2dce25fde6c64a04b85179d829d1d45feb)) +* **core:** Add response validation for requests during DCR ([#22076](https://github.com/n8n-io/n8n/issues/22076)) ([9ab9d1c](https://github.com/n8n-io/n8n/commit/9ab9d1c8f1fd4e65e9a7924f5d7bc3925b024261)) +* **core:** Defer requirements check for Python runner in internal mode ([#22448](https://github.com/n8n-io/n8n/issues/22448)) ([97d8b39](https://github.com/n8n-io/n8n/commit/97d8b393261eb8306d8cc33bb4883fc7be3ee1b2)) +* **core:** Introduce batch workflow rule to fix subworkflow with wait node detection ([#22447](https://github.com/n8n-io/n8n/issues/22447)) ([e0bc441](https://github.com/n8n-io/n8n/commit/e0bc4416ea1709cf5468f580074d46cd43cff09d)) +* **core:** Mark `''` and `[]` as empty when filtering numbers ([#22347](https://github.com/n8n-io/n8n/issues/22347)) ([a4d2cfa](https://github.com/n8n-io/n8n/commit/a4d2cfae4af2a2080b0e610ea3298e24238871ae)) +* **core:** OIDC form breaks when enabling OIDC while SAML is active ([#22463](https://github.com/n8n-io/n8n/issues/22463)) ([613b088](https://github.com/n8n-io/n8n/commit/613b08843d6542de2e3e98ee2a0535535294ce1b)) +* **core:** Prevent credentials saving if missing required properties ([#22213](https://github.com/n8n-io/n8n/issues/22213)) ([6d88007](https://github.com/n8n-io/n8n/commit/6d88007f014b1dba779f9217d5f3cf5fab854c3e)) +* **core:** Send prod workflow succeeded for wfs in projects ([#22223](https://github.com/n8n-io/n8n/issues/22223)) ([7068fe2](https://github.com/n8n-io/n8n/commit/7068fe2510882c438cbd73bede40dbbb1a1eebbb)) +* Detect workflow calling itself expression in migration report ([#22516](https://github.com/n8n-io/n8n/issues/22516)) ([1ddb634](https://github.com/n8n-io/n8n/commit/1ddb6344a5dbddd6385f1d68d94314fa5ab72cb1)) +* **editor, core:** Fix display for binary data metadata origin ([#22270](https://github.com/n8n-io/n8n/issues/22270)) ([df1aa13](https://github.com/n8n-io/n8n/commit/df1aa130cb3a417b5795b13af50e8c57f5137aff)) +* **editor:** Avoid clearing EULA license key when closing dialog ([#22424](https://github.com/n8n-io/n8n/issues/22424)) ([fe05ea4](https://github.com/n8n-io/n8n/commit/fe05ea4df8b415f600bb8d29bf29544683f70c3a)) +* **editor:** Eval trigger node with data table works when underlying data changes ([#22389](https://github.com/n8n-io/n8n/issues/22389)) ([6f1b09e](https://github.com/n8n-io/n8n/commit/6f1b09eeda8554abc207e0640fee8cc500cea3c6)) +* **editor:** Fix editor pan / zoom when inserting nodes ([#22401](https://github.com/n8n-io/n8n/issues/22401)) ([f435430](https://github.com/n8n-io/n8n/commit/f4354301ecb0cbb0d6386d4f32c2641845cfc8fe)) +* **editor:** Fix issue for blinking/jumping UI when switching projects ([#22256](https://github.com/n8n-io/n8n/issues/22256)) ([6185550](https://github.com/n8n-io/n8n/commit/6185550971e133beb9092d3695eecfa6e66e6113)) +* **editor:** Fix sub-nodes connection labels counters ([#21549](https://github.com/n8n-io/n8n/issues/21549)) ([d366cb4](https://github.com/n8n-io/n8n/commit/d366cb4f37eacc422cafc7795180837a3f1087e8)) +* **editor:** Missing duplicate workflow action on workflow list ([#22230](https://github.com/n8n-io/n8n/issues/22230)) ([000cccb](https://github.com/n8n-io/n8n/commit/000cccb62700144fd41ed70e2177de1c3cb32c31)) +* **editor:** Preserve paired item data in pinned data ([#21417](https://github.com/n8n-io/n8n/issues/21417)) ([254a53e](https://github.com/n8n-io/n8n/commit/254a53e7bb8acdd3202841b0ec85a87907e4379b)) +* **editor:** Remove unwanted outlines when waiting for webhook event in light mode in new NDV ([#22425](https://github.com/n8n-io/n8n/issues/22425)) ([4d68b6c](https://github.com/n8n-io/n8n/commit/4d68b6c6987d7193fd35aa1c41958252df56ab02)) +* **editor:** Standardize CSS class naming conventions across new design system components ([#22551](https://github.com/n8n-io/n8n/issues/22551)) ([c82d95a](https://github.com/n8n-io/n8n/commit/c82d95aecbbdf3379929ca99fb474909f08bfbf4)) +* **editor:** Wording for migration rule detail table headers ([#22398](https://github.com/n8n-io/n8n/issues/22398)) ([cd9f565](https://github.com/n8n-io/n8n/commit/cd9f56569dcf1e65df2d4f72b7007514276dac01)) +* Enable streaming version of Respond To Webhook node ([#22350](https://github.com/n8n-io/n8n/issues/22350)) ([dc407c2](https://github.com/n8n-io/n8n/commit/dc407c25fd3e9cc60704ae53c9508ee738c24efb)) +* **Git Node:** Throw an error if the repository path is blocked ([#22253](https://github.com/n8n-io/n8n/issues/22253)) ([a49b179](https://github.com/n8n-io/n8n/commit/a49b179e891c0b3501b6784c82af72b5a8c5a02d)) +* **Gmail Node:** Use Reply-To header when replying to a message ([#22145](https://github.com/n8n-io/n8n/issues/22145)) ([2a3cba7](https://github.com/n8n-io/n8n/commit/2a3cba74ee8716cc94c96b8849bda02bc88dbbcb)) +* **HTTP Node:** Handle Azure Storage Shared Key ([#22136](https://github.com/n8n-io/n8n/issues/22136)) ([b581dbc](https://github.com/n8n-io/n8n/commit/b581dbc23262c4f96f262e23d9274765cb54e09c)) +* **HTTP Request Node:** Detect and handle non-UTF-8 response encodings ([#20889](https://github.com/n8n-io/n8n/issues/20889)) ([6068fb3](https://github.com/n8n-io/n8n/commit/6068fb3b2008ed6e4cbbd01057bca280c91f021b)) +* Improve insights tests execution failure visibility ([#22538](https://github.com/n8n-io/n8n/issues/22538)) ([f937c0f](https://github.com/n8n-io/n8n/commit/f937c0fb5089dedab01361f67e39e7280d964d8a)) +* **MCP Client Node:** Fix selecting PKCE auth flow for some servers and request scopes from `scopes_requested` during DCR ([#22405](https://github.com/n8n-io/n8n/issues/22405)) ([6765d15](https://github.com/n8n-io/n8n/commit/6765d15ead1e0226078d4a6adbbf7d36e7a827fb)) +* Pin n8n packages to workspace version ([#22460](https://github.com/n8n-io/n8n/issues/22460)) ([75b2cd0](https://github.com/n8n-io/n8n/commit/75b2cd0de6b02b392be2b1e4f61a243da7bfe5b8)) +* **PostgreSQL Node:** Input items with array being modified ([#22426](https://github.com/n8n-io/n8n/issues/22426)) ([42cda59](https://github.com/n8n-io/n8n/commit/42cda59ee5f175b2b9f887b8a5fbdd60fda8184b)) +* Renovate will now bump versions ([#22245](https://github.com/n8n-io/n8n/issues/22245)) ([17ea0dd](https://github.com/n8n-io/n8n/commit/17ea0dd4662beecc61ec7fbf4bb45abeab69de54)) +* Show correct date range in insight overview ([3130d20](https://github.com/n8n-io/n8n/commit/3130d205331124d08299c54db5a31a0a6781e5b3)) +* **Slack Node:** Sort messages manually ([#21822](https://github.com/n8n-io/n8n/issues/21822)) ([52b93ed](https://github.com/n8n-io/n8n/commit/52b93ed5b237e031c4e079ad3e620c0943fb8cda)) +* **Sort Node:** Periodic error when using Code sort type ([#22409](https://github.com/n8n-io/n8n/issues/22409)) ([2360d87](https://github.com/n8n-io/n8n/commit/2360d8719a529b61989da9b215a6c0a7da861087)) +* Support resolving $fromAI in vector store tools ([#22457](https://github.com/n8n-io/n8n/issues/22457)) ([d72d68c](https://github.com/n8n-io/n8n/commit/d72d68c7191576501e8b1a387a0d8c1366c83c05)) +* Update base image dependencies to latest security patches ([#22275](https://github.com/n8n-io/n8n/issues/22275)) ([5fd1702](https://github.com/n8n-io/n8n/commit/5fd1702429eb67bd13d5e0978faf93015c1d527f)) +* When chat session is reset, update session id ([#22288](https://github.com/n8n-io/n8n/issues/22288)) ([7d82c3c](https://github.com/n8n-io/n8n/commit/7d82c3c02a4f61941e714de76c450d3e9c21ad98)) + + +### Features + +* Add entries to context menu to copy webhook's urls ([#21769](https://github.com/n8n-io/n8n/issues/21769)) ([6e344f0](https://github.com/n8n-io/n8n/commit/6e344f0f2968a8367358761ca7606787c375cbcc)) +* Add the time saved node ([#22269](https://github.com/n8n-io/n8n/issues/22269)) ([b83c43f](https://github.com/n8n-io/n8n/commit/b83c43f8dd9d16932a3c45da908ee30053624f62)) +* Add time saved mode workflow setting ([#22343](https://github.com/n8n-io/n8n/issues/22343)) ([b2f78d7](https://github.com/n8n-io/n8n/commit/b2f78d7c7aba592a70c08751f2ef0a004a0be60f)) +* **ai-builder:** Using templates to improve generation ([#22521](https://github.com/n8n-io/n8n/issues/22521)) ([7186dcf](https://github.com/n8n-io/n8n/commit/7186dcfe7ee184319aefce4ae2c09ead6dece418)) +* Allow configuring workflow for time saved capture by node ([#22386](https://github.com/n8n-io/n8n/issues/22386)) ([4adfced](https://github.com/n8n-io/n8n/commit/4adfced9373ca2b4da57ec9a28cbae63c6e615f8)) +* **Azure AI Search Node:** Add clear index option to Azure AI Search vector store ([#22183](https://github.com/n8n-io/n8n/issues/22183)) ([5cb594d](https://github.com/n8n-io/n8n/commit/5cb594d7efcc52c834855d13850735939e7d7086)) +* **core:** Add get version public endpoint ([#22407](https://github.com/n8n-io/n8n/issues/22407)) ([b6f8050](https://github.com/n8n-io/n8n/commit/b6f8050dfab80647fa2ed336c23566654918f936)) +* **core:** Deactivate crashed workflows ([#21888](https://github.com/n8n-io/n8n/issues/21888)) ([710a654](https://github.com/n8n-io/n8n/commit/710a6548d444fe740158333ce362fec62c8d3075)) +* **core:** Inject hooks into applicable trigger node properties for the node UI ([#22290](https://github.com/n8n-io/n8n/issues/22290)) ([92dca5f](https://github.com/n8n-io/n8n/commit/92dca5f739c535d8145fd54be8235e441e2c08ba)) +* **core:** Introduce `database` mode for binary data storage ([#22162](https://github.com/n8n-io/n8n/issues/22162)) ([934b9a7](https://github.com/n8n-io/n8n/commit/934b9a7346c713c4f3ced9fb7b972eb231affc15)) +* **core:** Use new workflow history in mcp tools ([#22384](https://github.com/n8n-io/n8n/issues/22384)) ([32c2909](https://github.com/n8n-io/n8n/commit/32c2909d729375408db13696357895a5ec28d28a)) +* **Form Node:** Allow users to set a default value for form fields ([#22200](https://github.com/n8n-io/n8n/issues/22200)) ([b0fc88b](https://github.com/n8n-io/n8n/commit/b0fc88b437e376b8283ed6bddcca63bac74c1aae)) +* **Gemini Node:** Add support for Nano Banana Pro model ([#22254](https://github.com/n8n-io/n8n/issues/22254)) ([7a4cc40](https://github.com/n8n-io/n8n/commit/7a4cc40dc85998790d57e336ab3384905912222c)) +* **Guardrails Node:** Require Chat model only for LLM checks ([#22241](https://github.com/n8n-io/n8n/issues/22241)) ([c1dade7](https://github.com/n8n-io/n8n/commit/c1dade7ad397d0aed2245554d32906f774860a3f)) +* Reduce unauthentication information in settings endpoint further ([#22106](https://github.com/n8n-io/n8n/issues/22106)) ([83809e6](https://github.com/n8n-io/n8n/commit/83809e62ad0f3d22447549b03173f38f93f523a1)) + + + # [1.122.0](https://github.com/n8n-io/n8n/compare/n8n@1.121.0...n8n@1.122.0) (2025-11-24) diff --git a/codecov.yml b/codecov.yml index 6d99dd68526..5114e8e9437 100644 --- a/codecov.yml +++ b/codecov.yml @@ -13,9 +13,15 @@ github_checks: annotations: false flags: - tests: - paths: - - '**' + backend-unit: + carryforward: true + backend-integration: + carryforward: true + nodes-unit: + carryforward: true + frontend: + carryforward: true + frontend-e2e: carryforward: true component_management: diff --git a/cubic.yaml b/cubic.yaml new file mode 100644 index 00000000000..ff99b6be1fa --- /dev/null +++ b/cubic.yaml @@ -0,0 +1,115 @@ +# yaml-language-server: $schema=https://cubic.dev/schema/cubic-repository-config.schema.json + +# cubic.yaml +# This file configures AI review behavior, ignore patterns, PR descriptions, and custom rules. +# Place this file in your repository root to version-control your AI review settings. +# Settings defined here take precedence over UI-configured settings. +# See https://docs.cubic.dev/configure/cubic-yaml for documentation. + +version: 1 +reviews: + enabled: true + sensitivity: medium + incremental_commits: true + show_ai_feedback_buttons: false + custom_instructions: |- + ## Step 1: Fetch Current Guidelines + + 1. Fetch the current [CONTRIBUTING.md](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) from the repository's main branch + 2. Navigate to the "Community PR Guidelines" section + 3. Use ONLY this live version - ignore any cached/embedded rules + + ## Step 2: Review Process + + Evaluate the PR against the rules in the fetched Community PR Guidelines. + + ## Step 3: Test Requirement Interpretation + + BE REASONABLE when evaluating test coverage: + + **PASS if:** + + - Core functionality has tests + - Critical paths are tested + - Coverage is reasonable (not necessarily 100%) + + **DO NOT require tests for:** + + - Exports, types, configs + - Metadata files + - Version files + + Approve if reasonably tested. Let humans handle edge cases. + custom_rules: + - name: Prefer Typeguards over Type casting + description: |- + - Rule Statement: + Never use `as` keyword for type narrowing; instead, prefer type guards, type annotations, or the `satisfies` keyword. + + Only use `as` for the following legitimate cases: + - DOM element assertions (e.g., `document.getElementById('foo') as HTMLButtonElement`) + - Event type assertions (e.g., `e as MouseEvent`) + - Const assertions (`as const`) + - Type widening (e.g., `(['a', 'b'] as string[]).includes('c')`) + - Generic constraints where no alternative exists + + For type narrowing, use type guard functions. For type checking without losing inference, prefer `satisfies`. For simple type specification, prefer type annotations (`const x: A = y`) over casting (`const x = y as A`). + + - Detection Criteria: + - Identify all usages of the `as` keyword in TypeScript code that perform type assertions (e.g., `expr as SomeType`). + - Exclude any usages in tests such as unit test files (*.test.ts or *.spec.ts) or e2e tests under cypress/ folder. + - Exclude usages where the assertion matches `as const`. + - Exclude assertions where: + - The assertion widens a literal/union type to a broader type (e.g., string literal array to string[]) + - The left-hand side is a call to a DOM API (e.g., `document.getElementById`, `document.querySelector`, `document.createElement`) + - The assertion is applied to a variable named `e` or `event` and the type being asserted ends with `Event` + - Flag other usages and suggest alternatives: type guards for narrowing, type annotations for simple typing, or `satisfies` for type verification. + - Setting type of empty value (for example `[] as string[]`) + + - Example Violation: + ```typescript + function handleShape(shape: Shape) { + // Direct type casting without a type guard + const rect = shape as Rectangle; + return rect.width * rect.height; + } + ``` + + - Example Allowed: + ```typescript + // DOM element assertion + const button = document.getElementById('submit') as HTMLButtonElement; + + // Event assertion + function handle(e: Event) { + const mouse = e as MouseEvent; + console.log(mouse.clientX); + } + + // as const usage + const config = { mode: "readonly" } as const; + + // setting types of empty array + const arr = [] as string[] + ``` + - name: Tests + description: |- + BE REASONABLE when evaluating test coverage: + + **PASS if:** + + - Core functionality has tests + - Critical paths are tested + - Coverage is reasonable (not necessarily 100%) + + **DO NOT require tests for:** + + - Exports, types, configs + - Metadata files + - Version files +pr_descriptions: + generate: false + instructions: Each PR is supposed to have a limited scope. In your review, focus on changes made in the PR and avoid pointing out problems you found in the code that already existed. +issues: + fix_with_cubic_buttons: true + diff --git a/docker/images/runners/Dockerfile b/docker/images/runners/Dockerfile index f680ec753af..93a81e051a0 100644 --- a/docker/images/runners/Dockerfile +++ b/docker/images/runners/Dockerfile @@ -120,7 +120,8 @@ COPY --from=node-alpine /usr/local/bin/node /usr/local/bin/node # libstdc++ is required by Node # libc6-compat is required by task-runner-launcher -RUN apk add --no-cache ca-certificates tini libstdc++ libc6-compat +RUN apk add --no-cache ca-certificates tini libstdc++ libc6-compat && \ + apk del apk-tools # Bring corepack and pnpm over, to make the image easier to extend COPY --from=node-alpine /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack diff --git a/lefthook.yml b/lefthook.yml index a01d0d1df78..90393a16f44 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,18 +28,7 @@ pre-commit: - rebase workspace_deps_check: glob: '**/package.json' - run: | - if grep -l '"workspace:\^"' {staged_files} 2>/dev/null; then - echo "" - echo "ERROR: Found 'workspace:^' in package.json files." - echo "" - echo "Use 'workspace:*' instead to pin exact versions." - echo "Using 'workspace:^' causes npm to resolve semver ranges when users" - echo "install from npm, which can lead to version mismatches between" - echo "@n8n/* packages and break n8n startup." - echo "" - exit 1 - fi + run: node scripts/check-workspace-deps.mjs {staged_files} skip: - merge - rebase diff --git a/package.json b/package.json index 05010480cb2..130a7e2b211 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:n8n": "node scripts/build-n8n.mjs", "build:deploy": "node scripts/build-n8n.mjs", "build:docker": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs", + "build:docker:coverage": "BUILD_WITH_COVERAGE=true node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs", "build:docker:scan": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && node scripts/scan-n8n-image.mjs", "build:docker:test": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && turbo run test:container:standard --filter=n8n-playwright", "typecheck": "turbo typecheck", @@ -21,7 +22,9 @@ "dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui", "dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", "dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"", + "dev:fe:e2e": "run-p start dev:fe:editor", "dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui", + "dev:e2e": "pnpm --filter=n8n-playwright dev --ui", "clean": "turbo run clean", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "format": "turbo run format && node scripts/format.mjs", diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/README.md b/packages/@n8n/ai-workflow-builder.ee/evaluations/README.md index 06dac6f3618..ad1b1fd2ef6 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/README.md +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/README.md @@ -104,21 +104,29 @@ The Langsmith integration provides two key components: #### 6. Pairwise Evaluation -Pairwise evaluation provides a simpler, criteria-based approach to workflow evaluation. Instead of using the complex multi-metric evaluation system, it evaluates workflows against a custom set of "do" and "don't" rules defined in the dataset. +Pairwise evaluation provides a criteria-based approach to workflow evaluation with hierarchical scoring and multi-judge consensus. It evaluates workflows against a custom set of "do" and "don't" rules defined in the dataset. **Evaluator (`chains/pairwise-evaluator.ts`):** - Evaluates workflows against a checklist of criteria (dos and don'ts) - Uses an LLM to determine if each criterion passes or fails - Requires evidence-based justification for each decision -- Calculates a simple pass/fail score (passes / total rules) +- Returns `primaryPass` (true only if ALL criteria pass) and `diagnosticScore` (ratio of passes) **Runner (`langsmith/pairwise-runner.ts`):** - Generates workflows from prompts in the dataset -- Applies pairwise evaluation to each generated workflow -- Reports three metrics to Langsmith: - - `pairwise_score`: Overall score (0-1) - - `pairwise_passed_count`: Number of criteria passed - - `pairwise_failed_count`: Number of criteria violated +- Runs multiple LLM judges in parallel for each evaluation (configurable via `--judges`) +- Aggregates judge results using majority vote +- Supports filtering by `notion_id` metadata for single-example runs +- Reports five metrics to Langsmith: + - `pairwise_primary`: Majority vote result (0 or 1) + - `pairwise_diagnostic`: Average diagnostic score across judges + - `pairwise_judges_passed`: Count of judges that passed + - `pairwise_total_violations`: Sum of all violations + - `pairwise_total_passes`: Sum of all passes + +**Logger (`utils/logger.ts`):** +- Simple evaluation logger with verbose mode support +- Controls output verbosity via `--verbose` flag **Dataset Format:** The pairwise evaluation expects a Langsmith dataset with examples containing: @@ -217,6 +225,9 @@ GENERATE_TEST_CASES=true pnpm eval # With custom concurrency EVALUATION_CONCURRENCY=10 pnpm eval + +# With feature flags enabled +pnpm eval --multi-agent --template-examples ``` ### Langsmith Evaluation @@ -229,11 +240,59 @@ export LANGSMITH_DATASET_NAME=your_dataset_name # Run evaluation pnpm eval:langsmith + +# With feature flags enabled +pnpm eval:langsmith --multi-agent ``` ### Pairwise Evaluation -Pairwise evaluation uses a dataset with custom do/don't criteria for each prompt. +Pairwise evaluation uses a dataset with custom do/don't criteria for each prompt. It implements a hierarchical scoring system with multiple LLM judges per evaluation. + +#### CLI Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--prompt ` | Run local evaluation with this prompt (no LangSmith required) | - | +| `--dos ` | Newline-separated "do" rules for local evaluation | - | +| `--donts ` | Newline-separated "don't" rules for local evaluation | - | +| `--notion-id ` | Filter to a single example by its `notion_id` metadata | (all examples) | +| `--max-examples ` | Limit number of examples to evaluate (useful for testing) | (no limit) | +| `--repetitions ` | Number of times to repeat the entire evaluation | 1 | +| `--generations ` | Number of workflow generations per prompt (for variance reduction) | 1 | +| `--judges ` | Number of LLM judges per evaluation | 3 | +| `--concurrency ` | Number of prompts to evaluate in parallel | 5 | +| `--name ` | Custom experiment name in LangSmith | `pairwise-evals` | +| `--output-dir ` | Save generated workflows and evaluation results to this directory | - | +| `--verbose`, `-v` | Enable verbose logging (shows judge details, violations, etc.) | false | +| `--multi-agent` | Enable multi-agent architecture (see [Feature Flags](#feature-flags)) | false | +| `--template-examples` | Enable template-based examples (see [Feature Flags](#feature-flags)) | false | + +#### Local Mode (No LangSmith Required) + +Run a single pairwise evaluation locally without needing a LangSmith account: + +```bash +# Basic local evaluation +pnpm eval:pairwise --prompt "Create a workflow that sends Slack messages" --dos "Use Slack node" + +# With don'ts and multiple judges +pnpm eval:pairwise \ + --prompt "Create a workflow that fetches data from an API" \ + --dos "Use HTTP Request node\nHandle errors" \ + --donts "Don't hardcode URLs" \ + --judges 5 \ + --verbose +``` + +Local mode is useful for: +- Testing prompts before adding them to a dataset +- Quick iteration on evaluation criteria +- Running evaluations without LangSmith setup + +#### LangSmith Mode + +For dataset-based evaluation with experiment tracking: ```bash # Set required environment variables @@ -242,14 +301,104 @@ export LANGSMITH_API_KEY=your_api_key # Run pairwise evaluation (uses default dataset: notion-pairwise-workflows) pnpm eval:pairwise +# Run a single example by notion_id +pnpm eval:pairwise --notion-id 30d29454-b397-4a35-8e0b-74a2302fa81a + +# Run with 3 repetitions and 5 judges, custom experiment name +pnpm eval:pairwise --repetitions 3 --judges 5 --name "my-experiment" + +# Enable verbose logging to see all judge details +pnpm eval:pairwise --notion-id abc123 --verbose + # Use a custom dataset LANGSMITH_DATASET_NAME=my-pairwise-dataset pnpm eval:pairwise # Limit to specific number of examples (useful for testing) -EVAL_MAX_EXAMPLES=2 pnpm eval:pairwise +pnpm eval:pairwise --max-examples 2 +``` -# Run with multiple repetitions -pnpm eval:pairwise --repetitions 3 +#### Multi-Generation Evaluation + +The `--generations` flag enables multiple workflow generations per prompt, providing a **Generation Correctness** metric: + +```bash +# Run 3 generations per prompt with 3 judges each +pnpm eval:pairwise --generations 3 --judges 3 --verbose + +# Example output: +# Gen 1: 2/3 judges → ✓ PASS (diag=85%) +# Gen 2: 1/3 judges → ✗ FAIL (diag=60%) +# Gen 3: 3/3 judges → ✓ PASS (diag=95%) +# 📊 [#1] 2/3 gens → PASS (gen_corr=0.67, diag=80%) +``` + +**Generation Correctness** = (# passing generations) / total generations: +- With `--generations 3`: Values are 0, 0.33, 0.67, or 1 +- With `--generations 5`: Values are 0, 0.2, 0.4, 0.6, 0.8, or 1 + +#### Hierarchical Scoring System + +The pairwise evaluation uses a multi-level scoring hierarchy: + +| Level | Primary Score | Secondary Score | +|-------|--------------|-----------------| +| Individual do/don't | Binary (true/false) | 0 or 1 | +| 1 LLM judge | false if ANY criterion fails | Average of criteria scores | +| N judges on 1 generation | Majority vote (≥50% pass) | Average diagnostic across judges | +| N generations on 1 prompt | (# passing gens) / N | Average diagnostic across generations | +| Full dataset | Average across prompts | Average diagnostic across all | + +This approach reduces variance from LLM non-determinism by using multiple judges and generations. + +#### Saving Artifacts with --output-dir + +The `--output-dir` flag saves all generated workflows and evaluation results to disk: + +```bash +# Save artifacts to ./eval-output directory +pnpm eval:pairwise --generations 3 --output-dir ./eval-output --verbose +``` + +**Output structure:** +``` +eval-output/ +├── prompt-1/ +│ ├── prompt.txt # Original prompt text +│ ├── criteria.json # dos/donts criteria +│ ├── gen-1/ +│ │ ├── workflow.json # Importable n8n workflow +│ │ └── evaluation.json # Judge results for this generation +│ ├── gen-2/ +│ │ ├── workflow.json +│ │ └── evaluation.json +│ └── gen-3/ +│ ├── workflow.json +│ └── evaluation.json +├── prompt-2/ +│ └── ... +└── summary.json # Overall results summary +``` + +**workflow.json**: Directly importable into n8n (File → Import from file) + +**evaluation.json**: Contains per-judge results including violations and passes: +```json +{ + "generationIndex": 1, + "majorityPass": false, + "primaryPasses": 1, + "numJudges": 3, + "diagnosticScore": 0.35, + "judges": [ + { + "judgeIndex": 1, + "primaryPass": false, + "diagnosticScore": 0.30, + "violations": [{"rule": "...", "justification": "..."}], + "passes": [{"rule": "...", "justification": "..."}] + } + ] +} ``` ## Configuration @@ -282,10 +431,77 @@ The evaluation will fail with a clear error message if `nodes.json` is missing. - `USE_LANGSMITH_EVAL` - Set to "true" to use Langsmith mode - `USE_PAIRWISE_EVAL` - Set to "true" to use pairwise evaluation mode - `LANGSMITH_DATASET_NAME` - Override default dataset name -- `EVAL_MAX_EXAMPLES` - Limit number of examples to evaluate (useful for testing) - `EVALUATION_CONCURRENCY` - Number of parallel test executions (default: 5) - `GENERATE_TEST_CASES` - Set to "true" to generate additional test cases - `LLM_MODEL` - Model identifier for metadata tracking +- `EVAL_FEATURE_MULTI_AGENT` - Set to "true" to enable multi-agent mode +- `EVAL_FEATURE_TEMPLATE_EXAMPLES` - Set to "true" to enable template examples + +### Feature Flags + +Feature flags control experimental or optional behaviors in the AI Workflow Builder agent during evaluations. They can be set via environment variables or CLI arguments. + +#### Available Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `multiAgent` | Enables multi-agent architecture with specialized sub-agents (supervisor, builder, configurator, discovery) | `false` | +| `templateExamples` | Enables template-based examples in agent prompts | `false` | + +#### Setting Feature Flags + +**Via Environment Variables:** +```bash +# Enable multi-agent mode +EVAL_FEATURE_MULTI_AGENT=true pnpm eval + +# Enable template examples +EVAL_FEATURE_TEMPLATE_EXAMPLES=true pnpm eval:pairwise + +# Enable both +EVAL_FEATURE_MULTI_AGENT=true EVAL_FEATURE_TEMPLATE_EXAMPLES=true pnpm eval:langsmith +``` + +**Via CLI Arguments:** +```bash +# Enable multi-agent mode +pnpm eval --multi-agent + +# Enable template examples +pnpm eval:pairwise --template-examples + +# Enable both +pnpm eval:langsmith --multi-agent --template-examples +``` + +#### Usage Across Evaluation Modes + +Feature flags work consistently across all evaluation modes: + +**CLI Evaluation:** +```bash +pnpm eval --multi-agent --template-examples +``` + +**Langsmith Evaluation:** +```bash +pnpm eval:langsmith --multi-agent +``` + +**Pairwise Evaluation (LangSmith mode):** +```bash +pnpm eval:pairwise --multi-agent --template-examples +``` + +**Pairwise Evaluation (Local mode):** +```bash +pnpm eval:pairwise --prompt "Create a Slack workflow" --dos "Use Slack node" --multi-agent +``` + +When feature flags are enabled, they are logged at the start of the evaluation: +``` +➔ Feature flags enabled: multiAgent, templateExamples +``` ## Output @@ -304,14 +520,22 @@ The evaluation will fail with a clear error message if `nodes.json` is missing. ### Pairwise Evaluation Output - Results are stored in Langsmith dashboard -- Experiment name format: `pairwise-evals-[uuid]` -- Metrics reported: - - `pairwise_score`: Overall pass rate (0-1) - - `pairwise_passed_count`: Number of criteria that passed - - `pairwise_failed_count`: Number of criteria that were violated +- Experiment name format: `-[uuid]` (default: `pairwise-evals-[uuid]`) +- Metrics reported (single generation mode): + - `pairwise_primary`: Binary pass/fail based on majority vote (0 or 1) + - `pairwise_diagnostic`: Average diagnostic score across judges (0-1) + - `pairwise_judges_passed`: Number of judges that returned primaryPass=true + - `pairwise_total_violations`: Sum of violations across all judges + - `pairwise_total_passes`: Sum of passes across all judges +- Additional metrics reported (multi-generation mode with `--generations N`): + - `pairwise_generation_correctness`: (# passing generations) / N (0, 0.33, 0.67, 1 for N=3) + - `pairwise_aggregated_diagnostic`: Average diagnostic score across all generations + - `pairwise_generations_passed`: Count of generations that passed majority vote + - `pairwise_total_judge_calls`: Total judge invocations (generations × judges) - Each result includes detailed comments with: - - List of violations with justifications - - List of passes with justifications + - Majority vote summary + - List of violations with justifications (per judge) + - List of passes (per judge) ## Adding New Test Cases diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.test.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.test.ts index ea0434c0f45..2c4c98bf73a 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.test.ts @@ -52,7 +52,8 @@ describe('evaluateWorkflowPairwise', () => { expect(result).toEqual({ ...mockResult, - score: 1, + primaryPass: true, + diagnosticScore: 1, }); expect(baseEvaluator.createEvaluatorChain).toHaveBeenCalledWith( mockLlm, @@ -69,7 +70,7 @@ describe('evaluateWorkflowPairwise', () => { ); }); - it('should calculate score correctly with violations', async () => { + it('should calculate diagnosticScore correctly with violations', async () => { const mockResult = { violations: [{ rule: "Don't do that", justification: 'Did it' }], passes: [{ rule: 'Do this', justification: 'Done' }], @@ -79,10 +80,11 @@ describe('evaluateWorkflowPairwise', () => { const result = await evaluateWorkflowPairwise(mockLlm, input); - expect(result.score).toBe(0.5); + expect(result.primaryPass).toBe(false); + expect(result.diagnosticScore).toBe(0.5); }); - it('should return score 0 when no rules evaluated', async () => { + it('should return diagnosticScore 0 when no rules evaluated', async () => { const mockResult = { violations: [], passes: [], @@ -92,6 +94,7 @@ describe('evaluateWorkflowPairwise', () => { const result = await evaluateWorkflowPairwise(mockLlm, input); - expect(result.score).toBe(0); + expect(result.primaryPass).toBe(true); + expect(result.diagnosticScore).toBe(0); }); }); diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.ts index bbe28a3192e..9b38808c0bb 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/chains/pairwise-evaluator.ts @@ -32,7 +32,10 @@ const pairwiseEvaluationLLMResultSchema = z.object({ }); export type PairwiseEvaluationResult = z.infer & { - score: number; + /** True only if ALL criteria passed (no violations) */ + primaryPass: boolean; + /** Ratio of passed criteria to total criteria (0-1) */ + diagnosticScore: number; }; const EVALUATOR_SYSTEM_PROMPT = `You are an expert n8n workflow auditor. Your task is to strictly evaluate a candidate workflow against a provided set of requirements. @@ -96,10 +99,12 @@ export async function evaluateWorkflowPairwise( }); const totalRules = result.passes.length + result.violations.length; - const score = totalRules > 0 ? result.passes.length / totalRules : 0; + const diagnosticScore = totalRules > 0 ? result.passes.length / totalRules : 0; + const primaryPass = result.violations.length === 0; return { ...result, - score, + primaryPass, + diagnosticScore, }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/runner.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/runner.ts index 320d438fdce..26d8e28eabe 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/runner.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/runner.ts @@ -2,6 +2,7 @@ import pLimit from 'p-limit'; import pc from 'picocolors'; import { createProgressBar, updateProgress, displayResults, displayError } from './display.js'; +import type { BuilderFeatureFlags } from '../../src/workflow-builder-agent.js'; import { basicTestCases, generateTestCases } from '../chains/test-case-generator.js'; import { setupTestEnvironment, @@ -25,6 +26,7 @@ type CliEvaluationOptions = { testCaseFilter?: string; // Optional test case ID to run only a specific test testCases?: TestCase[]; // Optional array of test cases to run (if not provided, uses defaults and generation) repetitions?: number; // Number of times to run each test (e.g. for cache warming analysis) + featureFlags?: BuilderFeatureFlags; // Optional feature flags to pass to the agent (e.g. templateExamples, multiAgent) }; /** @@ -32,12 +34,20 @@ type CliEvaluationOptions = { * Supports concurrency control via EVALUATION_CONCURRENCY environment variable */ export async function runCliEvaluation(options: CliEvaluationOptions = {}): Promise { - const { repetitions = 1, testCaseFilter } = options; + const { repetitions = 1, testCaseFilter, featureFlags } = options; console.log(formatHeader('AI Workflow Builder Full Evaluation', 70)); if (repetitions > 1) { console.log(pc.yellow(`➔ Each test will be run ${repetitions} times for cache analysis`)); } + if (featureFlags) { + const enabledFlags = Object.entries(featureFlags) + .filter(([, v]) => v === true) + .map(([k]) => k); + if (enabledFlags.length > 0) { + console.log(pc.green(`➔ Feature flags enabled: ${enabledFlags.join(', ')}`)); + } + } console.log(); try { // Setup test environment @@ -105,7 +115,9 @@ export async function runCliEvaluation(options: CliEvaluationOptions = {}): Prom // Create a dedicated agent for this test to avoid state conflicts const testAgent = createAgent(parsedNodeTypes, llm, tracer); - const result = await runSingleTest(testAgent, llm, testCase, parsedNodeTypes); + const result = await runSingleTest(testAgent, llm, testCase, parsedNodeTypes, { + featureFlags, + }); testResults[testCase.id] = result.error ? 'fail' : 'pass'; completed++; diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/core/environment.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/core/environment.ts index 0eb7e6582ad..bf4b5a637c1 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/core/environment.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/core/environment.ts @@ -5,6 +5,7 @@ import { Client } from 'langsmith/client'; import type { INodeTypeDescription } from 'n8n-workflow'; import { anthropicClaudeSonnet45 } from '../../src/llm-config.js'; +import type { BuilderFeatureFlags } from '../../src/workflow-builder-agent.js'; import { WorkflowBuilderAgent } from '../../src/workflow-builder-agent.js'; import { loadNodesFromFile } from '../load-nodes.js'; @@ -76,20 +77,22 @@ export async function setupTestEnvironment(): Promise { * @param parsedNodeTypes - Array of parsed node type descriptions * @param llm - Language model instance * @param tracer - Optional LangChain tracer + * @param featureFlags - Optional feature flags * @returns Configured WorkflowBuilderAgent */ export function createAgent( parsedNodeTypes: INodeTypeDescription[], llm: BaseChatModel, tracer?: LangChainTracer, + featureFlags?: BuilderFeatureFlags, ): WorkflowBuilderAgent { return new WorkflowBuilderAgent({ parsedNodeTypes, llmSimpleTask: llm, llmComplexTask: llm, checkpointer: new MemorySaver(), - enableMultiAgent: true, tracer, + featureFlags, }); } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts index 3cdfbe0d488..742f059d259 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/core/test-runner.ts @@ -1,7 +1,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { INodeTypeDescription } from 'n8n-workflow'; -import type { WorkflowBuilderAgent } from '../../src/workflow-builder-agent'; +import type { BuilderFeatureFlags, WorkflowBuilderAgent } from '../../src/workflow-builder-agent'; import { evaluateWorkflow } from '../chains/workflow-evaluator'; import { programmaticEvaluation } from '../programmatic/programmatic-evaluation'; import type { EvaluationInput, TestCase } from '../types/evaluation'; @@ -69,12 +69,22 @@ export function createErrorResult(testCase: TestCase, error: unknown): TestResul }; } +export interface RunSingleTestOptions { + agent: WorkflowBuilderAgent; + llm: BaseChatModel; + testCase: TestCase; + nodeTypes: INodeTypeDescription[]; + userId?: string; + featureFlags?: BuilderFeatureFlags; +} + /** * Runs a single test case by generating a workflow and evaluating it * @param agent - The workflow builder agent to use * @param llm - Language model for evaluation * @param testCase - Test case to execute - * @param userId - User ID for the session + * @param nodeTypes - Array of node type descriptions + * @params opts - userId, User ID for the session and featureFlags, Optional feature flags to pass to the agent * @returns Test result with generated workflow and evaluation */ export async function runSingleTest( @@ -82,12 +92,18 @@ export async function runSingleTest( llm: BaseChatModel, testCase: TestCase, nodeTypes: INodeTypeDescription[], - userId: string = 'test-user', + opts?: { userId?: string; featureFlags?: BuilderFeatureFlags }, ): Promise { + const userId = opts?.userId ?? 'test-user'; try { // Generate workflow const startTime = Date.now(); - await consumeGenerator(agent.chat(getChatPayload(testCase.prompt, testCase.id), userId)); + await consumeGenerator( + agent.chat( + getChatPayload('single-eval', testCase.prompt, testCase.id, opts?.featureFlags), + userId, + ), + ); const generationTime = Date.now() - startTime; // Get generated workflow with validation diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts index 10c6aac5f9c..a872220804e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/index.ts @@ -1,15 +1,54 @@ +import type { BuilderFeatureFlags } from '@/workflow-builder-agent'; + import { runCliEvaluation } from './cli/runner.js'; -import { runPairwiseLangsmithEvaluation } from './langsmith/pairwise-runner.js'; +import { + runLocalPairwiseEvaluation, + runPairwiseLangsmithEvaluation, +} from './langsmith/pairwise-runner.js'; import { runLangsmithEvaluation } from './langsmith/runner.js'; import { loadTestCasesFromCsv } from './utils/csv-prompt-loader.js'; // Re-export for external use if needed export { runCliEvaluation } from './cli/runner.js'; export { runLangsmithEvaluation } from './langsmith/runner.js'; -export { runPairwiseLangsmithEvaluation } from './langsmith/pairwise-runner.js'; +export { + runLocalPairwiseEvaluation, + runPairwiseLangsmithEvaluation, +} from './langsmith/pairwise-runner.js'; export { runSingleTest } from './core/test-runner.js'; export { setupTestEnvironment, createAgent } from './core/environment.js'; +/** Parse an integer flag with default value */ +function getIntFlag(flag: string, defaultValue: number, max?: number): number { + const arg = getFlagValue(flag); + if (!arg) return defaultValue; + const parsed = parseInt(arg, 10); + if (Number.isNaN(parsed) || parsed < 1) return defaultValue; + return max ? Math.min(parsed, max) : parsed; +} + +/** Parse all CLI arguments */ +function parseCliArgs() { + return { + testCaseId: process.argv.includes('--test-case') + ? process.argv[process.argv.indexOf('--test-case') + 1] + : undefined, + promptsCsvPath: getFlagValue('--prompts-csv') ?? process.env.PROMPTS_CSV_FILE, + repetitions: getIntFlag('--repetitions', 1), + notionId: getFlagValue('--notion-id'), + numJudges: getIntFlag('--judges', 3), + numGenerations: getIntFlag('--generations', 1, 10), + concurrency: getIntFlag('--concurrency', 5), + maxExamples: getIntFlag('--max-examples', 0), // 0 means no limit + verbose: process.argv.includes('--verbose') || process.argv.includes('-v'), + experimentName: getFlagValue('--name'), + outputDir: getFlagValue('--output-dir'), + prompt: getFlagValue('--prompt'), + dos: getFlagValue('--dos'), + donts: getFlagValue('--donts'), + }; +} + /** * Main entry point for evaluation * Determines which evaluation mode to run based on environment variables @@ -17,32 +56,54 @@ export { setupTestEnvironment, createAgent } from './core/environment.js'; async function main(): Promise { const useLangsmith = process.env.USE_LANGSMITH_EVAL === 'true'; const usePairwiseEval = process.env.USE_PAIRWISE_EVAL === 'true'; + const args = parseCliArgs(); - // Parse command line arguments for single test case - const testCaseId = process.argv.includes('--test-case') - ? process.argv[process.argv.indexOf('--test-case') + 1] - : undefined; - - // Parse command line argument for CSV prompts file path - const promptsCsvPath = getFlagValue('--prompts-csv') ?? process.env.PROMPTS_CSV_FILE; - - if (promptsCsvPath && (useLangsmith || usePairwiseEval)) { + if (args.promptsCsvPath && (useLangsmith || usePairwiseEval)) { console.warn('CSV-driven evaluations are only supported in CLI mode. Ignoring --prompts-csv.'); } - // Parse command line arguments for a number of repetitions (applies to both modes) - const repetitionsArg = process.argv.includes('--repetitions') - ? parseInt(process.argv[process.argv.indexOf('--repetitions') + 1], 10) - : 1; - const repetitions = Number.isNaN(repetitionsArg) ? 1 : repetitionsArg; + // Parse feature flags from environment variables or CLI arguments + const featureFlags = parseFeatureFlags(); if (usePairwiseEval) { - await runPairwiseLangsmithEvaluation(repetitions); + if (args.prompt) { + // Local mode - run single evaluation without LangSmith + await runLocalPairwiseEvaluation({ + prompt: args.prompt, + criteria: { dos: args.dos ?? '', donts: args.donts ?? '' }, + numJudges: args.numJudges, + numGenerations: args.numGenerations, + verbose: args.verbose, + outputDir: args.outputDir, + featureFlags, + }); + } else { + // LangSmith mode + await runPairwiseLangsmithEvaluation({ + repetitions: args.repetitions, + notionId: args.notionId, + numJudges: args.numJudges, + numGenerations: args.numGenerations, + verbose: args.verbose, + experimentName: args.experimentName, + outputDir: args.outputDir, + concurrency: args.concurrency, + maxExamples: args.maxExamples || undefined, + featureFlags, + }); + } } else if (useLangsmith) { - await runLangsmithEvaluation(repetitions); + await runLangsmithEvaluation(args.repetitions, featureFlags); } else { - const csvTestCases = promptsCsvPath ? loadTestCasesFromCsv(promptsCsvPath) : undefined; - await runCliEvaluation({ testCases: csvTestCases, testCaseFilter: testCaseId, repetitions }); + const csvTestCases = args.promptsCsvPath + ? loadTestCasesFromCsv(args.promptsCsvPath) + : undefined; + await runCliEvaluation({ + testCases: csvTestCases, + testCaseFilter: args.testCaseId, + repetitions: args.repetitions, + featureFlags, + }); } } @@ -68,6 +129,36 @@ function getFlagValue(flag: string): string | undefined { return undefined; } +/** + * Parse feature flags from environment variables or CLI arguments. + * Environment variables: + * - EVAL_FEATURE_TEMPLATE_EXAMPLES=true - Enable template examples feature + * - EVAL_FEATURE_MULTI_AGENT=true - Enable multi-agent feature + * CLI arguments: + * - --template-examples - Enable template examples feature + * - --multi-agent - Enable multi-agent feature + */ +function parseFeatureFlags(): BuilderFeatureFlags | undefined { + const templateExamplesFromEnv = process.env.EVAL_FEATURE_TEMPLATE_EXAMPLES === 'true'; + const multiAgentFromEnv = process.env.EVAL_FEATURE_MULTI_AGENT === 'true'; + + const templateExamplesFromCli = process.argv.includes('--template-examples'); + const multiAgentFromCli = process.argv.includes('--multi-agent'); + + const templateExamples = templateExamplesFromEnv || templateExamplesFromCli; + const multiAgent = multiAgentFromEnv || multiAgentFromCli; + + // Only return feature flags object if at least one flag is set + if (templateExamples || multiAgent) { + return { + templateExamples: templateExamples || undefined, + multiAgent: multiAgent || undefined, + }; + } + + return undefined; +} + // Run if called directly if (require.main === module) { main().catch(console.error); diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/pairwise-runner.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/pairwise-runner.ts index 7146569acb2..3053ebc8d8c 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/pairwise-runner.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/pairwise-runner.ts @@ -1,16 +1,27 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; +import * as fs from 'fs'; import { evaluate } from 'langsmith/evaluation'; import type { EvaluationResult as LangsmithEvaluationResult } from 'langsmith/evaluation'; import type { Run, Example } from 'langsmith/schemas'; import type { INodeTypeDescription } from 'n8n-workflow'; +import * as path from 'path'; import pc from 'picocolors'; import type { SimpleWorkflow } from '../../src/types/workflow'; -import { evaluateWorkflowPairwise } from '../chains/pairwise-evaluator'; +import type { BuilderFeatureFlags } from '../../src/workflow-builder-agent'; +import { + evaluateWorkflowPairwise, + type PairwiseEvaluationResult, +} from '../chains/pairwise-evaluator'; import { setupTestEnvironment, createAgent } from '../core/environment'; import { generateRunId, isWorkflowStateValues } from '../types/langsmith'; import { consumeGenerator, formatHeader, getChatPayload } from '../utils/evaluation-helpers'; +import { createLogger, type EvalLogger } from '../utils/logger'; + +// ============================================================================ +// Types +// ============================================================================ interface PairwiseDatasetInput { evals: { @@ -20,10 +31,34 @@ interface PairwiseDatasetInput { prompt: string; } +interface GenerationResult { + workflow: SimpleWorkflow; + judgeResults: PairwiseEvaluationResult[]; + primaryPasses: number; + majorityPass: boolean; + avgDiagnosticScore: number; +} + +interface MultiGenerationAggregation { + /** Generation correctness: (# passing generations) / total generations */ + generationCorrectness: number; + /** Average diagnostic score across all generations */ + aggregatedDiagnosticScore: number; + /** Number of generations that passed majority vote */ + passingGenerations: number; + /** Total number of generations run */ + totalGenerations: number; + /** Detailed results for each generation */ + generationDetails: GenerationResult[]; +} + interface PairwiseGeneratorOutput { workflow: SimpleWorkflow; evalCriteria: PairwiseDatasetInput['evals']; prompt: string; + evaluationResults: LangsmithEvaluationResult[]; + /** Multi-generation aggregation data (present when numGenerations > 1) */ + multiGenerationAggregation?: MultiGenerationAggregation; } function isPairwiseGeneratorOutput(outputs: unknown): outputs is PairwiseGeneratorOutput { @@ -33,98 +68,616 @@ function isPairwiseGeneratorOutput(outputs: unknown): outputs is PairwiseGenerat if (!obj.workflow || typeof obj.workflow !== 'object') return false; if (!obj.evalCriteria || typeof obj.evalCriteria !== 'object') return false; + if (!obj.evaluationResults || !Array.isArray(obj.evaluationResults)) return false; return true; } +// ============================================================================ +// Helpers +// ============================================================================ + +const DEFAULT_NUM_JUDGES = 3; +const DEFAULT_NUM_GENERATIONS = 1; +const DEFAULT_EXPERIMENT_NAME = 'pairwise-evals'; + +// ============================================================================ +// Artifact Saver +// ============================================================================ + +interface ArtifactSaver { + savePrompt(promptId: string, prompt: string, criteria: { dos: string; donts: string }): void; + saveGeneration(promptId: string, genIndex: number, result: GenerationResult): void; + saveSummary(results: Array<{ promptId: string; aggregation: MultiGenerationAggregation }>): void; +} + +/** Creates an artifact saver if outputDir is provided, otherwise returns null */ +function createArtifactSaver(outputDir: string | undefined, log: EvalLogger): ArtifactSaver | null { + if (!outputDir) return null; + + // Create output directory if it doesn't exist + fs.mkdirSync(outputDir, { recursive: true }); + + return { + savePrompt(promptId: string, prompt: string, criteria: { dos: string; donts: string }): void { + const promptDir = path.join(outputDir, `prompt-${promptId}`); + fs.mkdirSync(promptDir, { recursive: true }); + + // Save prompt text + fs.writeFileSync(path.join(promptDir, 'prompt.txt'), prompt, 'utf-8'); + + // Save criteria + fs.writeFileSync( + path.join(promptDir, 'criteria.json'), + JSON.stringify(criteria, null, 2), + 'utf-8', + ); + + log.verbose(` 📁 Saved prompt artifacts to ${promptDir}`); + }, + + saveGeneration(promptId: string, genIndex: number, result: GenerationResult): void { + const genDir = path.join(outputDir, `prompt-${promptId}`, `gen-${genIndex + 1}`); + fs.mkdirSync(genDir, { recursive: true }); + + // Save workflow as importable n8n JSON + const workflowForExport = { + name: result.workflow.name ?? `Generated Workflow - Gen ${genIndex + 1}`, + nodes: result.workflow.nodes ?? [], + connections: result.workflow.connections ?? {}, + }; + fs.writeFileSync( + path.join(genDir, 'workflow.json'), + JSON.stringify(workflowForExport, null, 2), + 'utf-8', + ); + + // Save evaluation results + const evalResult = { + generationIndex: genIndex + 1, + majorityPass: result.majorityPass, + primaryPasses: result.primaryPasses, + numJudges: result.judgeResults.length, + diagnosticScore: result.avgDiagnosticScore, + judges: result.judgeResults.map((jr, i) => ({ + judgeIndex: i + 1, + primaryPass: jr.primaryPass, + diagnosticScore: jr.diagnosticScore, + violations: jr.violations, + passes: jr.passes, + })), + }; + fs.writeFileSync( + path.join(genDir, 'evaluation.json'), + JSON.stringify(evalResult, null, 2), + 'utf-8', + ); + + log.verbose(` 📁 Saved gen-${genIndex + 1} artifacts to ${genDir}`); + }, + + saveSummary( + results: Array<{ promptId: string; aggregation: MultiGenerationAggregation }>, + ): void { + const summary = { + timestamp: new Date().toISOString(), + totalPrompts: results.length, + results: results.map((r) => ({ + promptId: r.promptId, + generationCorrectness: r.aggregation.generationCorrectness, + aggregatedDiagnosticScore: r.aggregation.aggregatedDiagnosticScore, + passingGenerations: r.aggregation.passingGenerations, + totalGenerations: r.aggregation.totalGenerations, + })), + averageGenerationCorrectness: + results.reduce((sum, r) => sum + r.aggregation.generationCorrectness, 0) / results.length, + averageDiagnosticScore: + results.reduce((sum, r) => sum + r.aggregation.aggregatedDiagnosticScore, 0) / + results.length, + }; + fs.writeFileSync( + path.join(outputDir, 'summary.json'), + JSON.stringify(summary, null, 2), + 'utf-8', + ); + + log.info(`📁 Saved summary to ${path.join(outputDir, 'summary.json')}`); + }, + }; +} + +/** Calculate minimum judges needed for majority (e.g., 2 for 3 judges, 3 for 5 judges) */ +function getMajorityThreshold(numJudges: number): number { + return Math.ceil(numJudges / 2); +} + +/** Extract notion_id from metadata if present */ +function getNotionId(metadata: unknown): string | undefined { + if (typeof metadata === 'object' && metadata !== null && 'notion_id' in metadata) { + const id = (metadata as { notion_id: unknown }).notion_id; + return typeof id === 'string' ? id : undefined; + } + return undefined; +} + +/** Build LangSmith-compatible evaluation results from judge panel output */ +function buildLangsmithResults( + judgeResults: PairwiseEvaluationResult[], + numJudges: number, + primaryPasses: number, + majorityPass: boolean, + avgDiagnosticScore: number, +): LangsmithEvaluationResult[] { + const allViolations = judgeResults.flatMap((r, i) => + r.violations.map((v) => `[Judge ${i + 1}] ${v.rule}: ${v.justification}`), + ); + const allPasses = judgeResults.flatMap((r, i) => + r.passes.map((p) => `[Judge ${i + 1}] ${p.rule}`), + ); + + const comment = [ + `Majority vote: ${primaryPasses}/${numJudges} judges passed`, + allViolations.length > 0 ? `\nViolations:\n${allViolations.join('\n')}` : '', + allPasses.length > 0 ? `\nPasses:\n${allPasses.join('\n')}` : '', + ] + .filter(Boolean) + .join(''); + + return [ + { key: 'pairwise_primary', score: majorityPass ? 1 : 0, comment }, + { + key: 'pairwise_diagnostic', + score: avgDiagnosticScore, + comment: `Average diagnostic score across ${numJudges} judges`, + }, + { + key: 'pairwise_judges_passed', + score: primaryPasses, + comment: `${primaryPasses} of ${numJudges} judges returned primaryPass=true`, + }, + { + key: 'pairwise_total_violations', + score: judgeResults.reduce((sum, r) => sum + r.violations.length, 0), + }, + { + key: 'pairwise_total_passes', + score: judgeResults.reduce((sum, r) => sum + r.passes.length, 0), + }, + ]; +} + +/** Build LangSmith-compatible evaluation results for multi-generation aggregation */ +function buildMultiGenerationLangsmithResults( + aggregation: MultiGenerationAggregation, + numJudges: number, +): LangsmithEvaluationResult[] { + const { generationCorrectness, aggregatedDiagnosticScore, passingGenerations, totalGenerations } = + aggregation; + + // Build detailed comment with per-generation breakdown + const genBreakdown = aggregation.generationDetails + .map( + (g, i) => + `Gen ${i + 1}: ${g.majorityPass ? 'PASS' : 'FAIL'} (${g.primaryPasses}/${numJudges} judges, ${(g.avgDiagnosticScore * 100).toFixed(0)}%)`, + ) + .join('\n'); + + const comment = [ + `Generation Correctness: ${passingGenerations}/${totalGenerations} generations passed`, + `\nPer-generation breakdown:\n${genBreakdown}`, + ].join(''); + + // Use first generation for backward-compatible metrics + const firstGen = aggregation.generationDetails[0]; + + // Aggregate counts across all generations + const totalJudgesPassed = aggregation.generationDetails.reduce( + (sum, g) => sum + g.primaryPasses, + 0, + ); + const totalViolations = aggregation.generationDetails.reduce( + (sum, g) => sum + g.judgeResults.reduce((jSum, r) => jSum + r.violations.length, 0), + 0, + ); + const totalPasses = aggregation.generationDetails.reduce( + (sum, g) => sum + g.judgeResults.reduce((jSum, r) => jSum + r.passes.length, 0), + 0, + ); + + return [ + // Primary aggregated metrics (new) + { + key: 'pairwise_generation_correctness', + score: generationCorrectness, + comment: `${passingGenerations} of ${totalGenerations} generations passed majority vote`, + }, + { + key: 'pairwise_aggregated_diagnostic', + score: aggregatedDiagnosticScore, + comment: `Average diagnostic score across ${totalGenerations} generations`, + }, + // Legacy metrics (backward compat, use first generation) + { + key: 'pairwise_primary', + score: firstGen.majorityPass ? 1 : 0, + comment: `First generation: ${firstGen.primaryPasses}/${numJudges} judges passed`, + }, + { + key: 'pairwise_diagnostic', + score: firstGen.avgDiagnosticScore, + comment: 'First generation diagnostic score', + }, + // Summary metrics + { + key: 'pairwise_generations_passed', + score: passingGenerations, + comment, + }, + { + key: 'pairwise_total_judge_calls', + score: totalGenerations * numJudges, + comment: `${totalGenerations} generations x ${numJudges} judges`, + }, + // Aggregated detail metrics (across all generations) + { + key: 'pairwise_judges_passed', + score: totalJudgesPassed, + comment: `${totalJudgesPassed} of ${totalGenerations * numJudges} total judge calls passed`, + }, + { + key: 'pairwise_total_violations', + score: totalViolations, + comment: `Total violations across all ${totalGenerations} generations`, + }, + { + key: 'pairwise_total_passes', + score: totalPasses, + comment: `Total criteria passes across all ${totalGenerations} generations`, + }, + ]; +} + +/** Run a single generation and its judge panel */ +async function runSingleGeneration( + parsedNodeTypes: INodeTypeDescription[], + llm: BaseChatModel, + numJudges: number, + inputs: PairwiseDatasetInput, + generationIndex: number, + log: EvalLogger, + featureFlags?: BuilderFeatureFlags, + tracer?: LangChainTracer, +): Promise { + const startTime = Date.now(); + const runId = generateRunId(); + + // Create dedicated agent for this generation + const agent = createAgent(parsedNodeTypes, llm, tracer, featureFlags); + + // Generate workflow + await consumeGenerator( + agent.chat( + getChatPayload('pairewise-gen', inputs.prompt, runId, featureFlags), + `pairwise-gen-${generationIndex}`, + ), + ); + + const state = await agent.getState(runId, `pairwise-gen-${generationIndex}`); + + if (!state.values || !isWorkflowStateValues(state.values)) { + throw new Error(`Invalid workflow state for generation ${generationIndex + 1}`); + } + + const workflow = state.values.workflowJSON; + const genTime = (Date.now() - startTime) / 1000; + + log.verbose( + ` Gen ${generationIndex + 1}: Workflow done (${workflow?.nodes?.length ?? 0} nodes) [${genTime.toFixed(1)}s]`, + ); + + // Run judges for this generation in parallel + const judgeResults = await Promise.all( + Array.from({ length: numJudges }, async () => { + return await evaluateWorkflowPairwise(llm, { + workflowJSON: workflow, + evalCriteria: inputs.evals, + }); + }), + ); + + // Aggregate judge results for this generation + const primaryPasses = judgeResults.filter((r) => r.primaryPass).length; + const majorityPass = primaryPasses >= getMajorityThreshold(numJudges); + const avgDiagnosticScore = + judgeResults.reduce((sum, r) => sum + r.diagnosticScore, 0) / numJudges; + + // Log per-generation judge results + const totalViolations = judgeResults.reduce((sum, r) => sum + r.violations.length, 0); + log.verbose( + ` Gen ${generationIndex + 1}: ${primaryPasses}/${numJudges} judges → ` + + `${majorityPass ? '✓ PASS' : '✗ FAIL'} (diag=${(avgDiagnosticScore * 100).toFixed(0)}%` + + `${totalViolations > 0 ? `, ${totalViolations} violations` : ''})`, + ); + + return { + workflow, + judgeResults, + primaryPasses, + majorityPass, + avgDiagnosticScore, + }; +} + +// ============================================================================ +// Workflow Generator (for LangSmith) +// ============================================================================ + +// Counter to track generations across repetitions +let generationCounter = 0; + +/** Creates a generator function that produces workflows and runs judge evaluation */ function createPairwiseWorkflowGenerator( parsedNodeTypes: INodeTypeDescription[], llm: BaseChatModel, + numJudges: number, + numGenerations: number, + log: EvalLogger, + artifactSaver: ArtifactSaver | null, + featureFlags?: BuilderFeatureFlags, tracer?: LangChainTracer, ) { return async (inputs: PairwiseDatasetInput) => { - const runId = generateRunId(); + const startTime = Date.now(); + generationCounter++; + const currentEvalNumber = generationCounter; + const promptId = String(currentEvalNumber); + const promptPreview = inputs.prompt.slice(0, 60).replace(/\n/g, ' '); - // Create agent for this run - const agent = createAgent(parsedNodeTypes, llm, tracer); + log.verbose( + `\n🔄 [#${currentEvalNumber}] "${promptPreview}${inputs.prompt.length > 60 ? '...' : ''}"`, + ); + log.verbose(` Running ${numGenerations} generation(s) x ${numJudges} judges...`); - // Use the prompt from the dataset - await consumeGenerator( - agent.chat(getChatPayload(inputs.prompt, runId), 'langsmith-pairwise-eval-user'), + // Save prompt artifacts if output dir is configured + artifactSaver?.savePrompt(promptId, inputs.prompt, inputs.evals); + + // Run all generations in parallel + const generationResults = await Promise.all( + Array.from({ length: numGenerations }, async (_, i) => { + return await runSingleGeneration( + parsedNodeTypes, + llm, + numJudges, + inputs, + i, + log, + featureFlags, + tracer, + ); + }), ); - // Get generated workflow - const state = await agent.getState(runId, 'langsmith-pairwise-eval-user'); - - if (!state.values || !isWorkflowStateValues(state.values)) { - throw new Error('Invalid workflow state'); + // Save generation artifacts + if (artifactSaver) { + for (let i = 0; i < generationResults.length; i++) { + artifactSaver.saveGeneration(promptId, i, generationResults[i]); + } } + // Aggregate across generations + const passingGenerations = generationResults.filter((g) => g.majorityPass).length; + const generationCorrectness = passingGenerations / numGenerations; + const aggregatedDiagnosticScore = + generationResults.reduce((sum, g) => sum + g.avgDiagnosticScore, 0) / numGenerations; + + const totalTime = (Date.now() - startTime) / 1000; + + // Log aggregated result + console.log( + pc.dim( + ` 📊 [#${currentEvalNumber}] ${passingGenerations}/${numGenerations} gens → ` + + `${generationCorrectness >= 0.5 ? pc.green('PASS') : pc.red('FAIL')} ` + + `(gen_corr=${generationCorrectness.toFixed(2)}, diag=${(aggregatedDiagnosticScore * 100).toFixed(0)}%) ` + + `[${totalTime.toFixed(1)}s]`, + ), + ); + + // Build aggregation object + const multiGenerationAggregation: MultiGenerationAggregation = { + generationCorrectness, + aggregatedDiagnosticScore, + passingGenerations, + totalGenerations: numGenerations, + generationDetails: generationResults, + }; + + // Choose appropriate results builder based on numGenerations + const evaluationResults = + numGenerations > 1 + ? buildMultiGenerationLangsmithResults(multiGenerationAggregation, numJudges) + : buildLangsmithResults( + generationResults[0].judgeResults, + numJudges, + generationResults[0].primaryPasses, + generationResults[0].majorityPass, + generationResults[0].avgDiagnosticScore, + ); + return { - workflow: state.values.workflowJSON, + workflow: generationResults[0].workflow, evalCriteria: inputs.evals, prompt: inputs.prompt, + evaluationResults, + multiGenerationAggregation: numGenerations > 1 ? multiGenerationAggregation : undefined, }; }; } -function createPairwiseLangsmithEvaluator(llm: BaseChatModel) { +// ============================================================================ +// LangSmith Evaluator +// ============================================================================ + +/** + * Simple evaluator that extracts pre-computed results from the generator output. + * The actual judge evaluation is done in createPairwiseWorkflowGenerator for better parallelism. + */ +function createPairwiseLangsmithEvaluator() { return async (rootRun: Run, _example?: Example): Promise => { const outputs = rootRun.outputs; if (!isPairwiseGeneratorOutput(outputs)) { return [ { - key: 'pairwise_score', + key: 'pairwise_primary', score: 0, - comment: 'Missing or invalid workflow/evaluation criteria in outputs', + comment: 'Invalid output - missing evaluation results', }, + { key: 'pairwise_diagnostic', score: 0 }, ]; } - const result = await evaluateWorkflowPairwise(llm, { - workflowJSON: outputs.workflow, - evalCriteria: outputs.evalCriteria, - }); - - const violationsText = - result.violations.length > 0 - ? `Violations:\n${result.violations.map((v) => `- ${v.rule}: ${v.justification}`).join('\n')}` - : ''; - - const passesText = - result.passes.length > 0 - ? `Passes:\n${result.passes.map((p) => `- ${p.rule}: ${p.justification}`).join('\n')}` - : ''; - - const comment = [violationsText, passesText].filter(Boolean).join('\n\n'); - - return [ - { - key: 'pairwise_score', - score: result.score, - comment: comment || 'No comments', - }, - { - key: 'pairwise_passed_count', - score: result.passes.length, - }, - { - key: 'pairwise_failed_count', - score: result.violations.length, - }, - ]; + // Just pass through the pre-computed results from the generator + return outputs.evaluationResults; }; } -export async function runPairwiseLangsmithEvaluation(repetitions: number = 1): Promise { +/** Filter examples by notion_id or limit count */ +function filterExamples( + allExamples: Example[], + notionId: string | undefined, + maxExamples: number | undefined, + log: EvalLogger, +): Example[] { + if (notionId) { + log.warn(`🔍 Filtering by notion_id: ${notionId}`); + const filtered = allExamples.filter((e) => getNotionId(e.metadata) === notionId); + + if (filtered.length === 0) { + log.error(`❌ No example found with notion_id: ${notionId}`); + const availableIds = allExamples.map((e) => getNotionId(e.metadata)).filter(Boolean); + log.dim(`Available: ${availableIds.join(', ')}`); + process.exit(1); + } + + log.success(`✅ Found ${filtered.length} example(s)`); + log.verbose(`Metadata: ${JSON.stringify(filtered[0].metadata, null, 2)}`); + return filtered; + } + + if (maxExamples && maxExamples > 0) { + log.warn(`➔ Limiting to ${maxExamples} example(s)`); + return allExamples.slice(0, maxExamples); + } + + return allExamples; +} + +/** Create repeated data array for LangSmith evaluation */ +function createRepeatedData(data: Example[], repetitions: number): Example[] { + const repeatedData: Example[] = []; + for (let i = 0; i < repetitions; i++) { + repeatedData.push(...data); + } + return repeatedData; +} + +// ============================================================================ +// Public API +// ============================================================================ + +export interface PairwiseEvaluationOptions { + repetitions?: number; + notionId?: string; + numJudges?: number; + numGenerations?: number; + verbose?: boolean; + experimentName?: string; + outputDir?: string; + concurrency?: number; + maxExamples?: number; + featureFlags?: BuilderFeatureFlags; +} + +/** Log enabled feature flags */ +function logFeatureFlags(featureFlags?: BuilderFeatureFlags): void { + if (!featureFlags) return; + const enabledFlags = Object.entries(featureFlags) + .filter(([, v]) => v === true) + .map(([k]) => k); + if (enabledFlags.length > 0) { + console.log(pc.green(`➔ Feature flags enabled: ${enabledFlags.join(', ')}`)); + } +} + +/** Log configuration for pairwise evaluation */ +function logPairwiseConfig( + log: EvalLogger, + experimentName: string, + numGenerations: number, + numJudges: number, + repetitions: number, + concurrency: number, + verbose: boolean, +): void { + log.info(`➔ Experiment: ${experimentName}`); + log.info( + `➔ Config: ${numGenerations} gen(s) × ${numJudges} judges × ${repetitions} reps (concurrency: ${concurrency})${verbose ? ' (verbose)' : ''}`, + ); + if (numGenerations > 1) { + log.verbose(' Generation Correctness: (# passing gens) / total gens'); + log.verbose(' Aggregated Diagnostic: average across all generations'); + } else { + log.verbose(' Primary: ALL criteria must pass → majority vote'); + log.verbose(' Secondary: Average diagnostic score'); + } +} + +/** + * Runs pairwise evaluation using LangSmith. + * Generates workflows from dataset prompts and evaluates them against do/don't criteria. + */ +export async function runPairwiseLangsmithEvaluation( + options: PairwiseEvaluationOptions = {}, +): Promise { + const { + repetitions = 1, + notionId, + numJudges = DEFAULT_NUM_JUDGES, + numGenerations = DEFAULT_NUM_GENERATIONS, + verbose = false, + experimentName = DEFAULT_EXPERIMENT_NAME, + outputDir, + concurrency = 5, + maxExamples, + featureFlags, + } = options; + const log = createLogger(verbose); + console.log(formatHeader('AI Workflow Builder Pairwise Evaluation', 70)); + logPairwiseConfig( + log, + experimentName, + numGenerations, + numJudges, + repetitions, + concurrency, + verbose, + ); + + if (outputDir) { + log.info(`➔ Output directory: ${outputDir}`); + } + + logFeatureFlags(featureFlags); if (!process.env.LANGSMITH_API_KEY) { - console.error(pc.red('✗ LANGSMITH_API_KEY environment variable not set')); + log.error('✗ LANGSMITH_API_KEY environment variable not set'); process.exit(1); } + // Reset counter for this run + generationCounter = 0; + try { const { parsedNodeTypes, llm, tracer, lsClient } = await setupTestEnvironment(); @@ -133,7 +686,7 @@ export async function runPairwiseLangsmithEvaluation(repetitions: number = 1): P } const datasetName = process.env.LANGSMITH_DATASET_NAME ?? 'notion-pairwise-workflows'; - console.log(pc.blue(`➔ Using dataset: ${datasetName}`)); + log.info(`➔ Dataset: ${datasetName}`); // Verify dataset exists and get dataset info let datasetId: string; @@ -141,40 +694,272 @@ export async function runPairwiseLangsmithEvaluation(repetitions: number = 1): P const dataset = await lsClient.readDataset({ datasetName }); datasetId = dataset.id; } catch (error) { - console.error(pc.red(`✗ Dataset "${datasetName}" not found`)); + log.error(`✗ Dataset "${datasetName}" not found`); process.exit(1); } - // Check if we should limit examples - const maxExamplesEnv = process.env.EVAL_MAX_EXAMPLES; - const maxExamples = maxExamplesEnv ? parseInt(maxExamplesEnv, 10) : undefined; - - // Fetch examples if limiting, otherwise use dataset name - let data: string | Example[] = datasetName; - if (maxExamples && maxExamples > 0) { - console.log(pc.yellow(`➔ Limiting to ${maxExamples} example(s)`)); - const examples: Example[] = []; - for await (const example of lsClient.listExamples({ datasetId })) { - examples.push(example); - if (examples.length >= maxExamples) break; + // Fetch all examples from dataset for filtering/inspection + const allExamples: Example[] = []; + log.verbose('➔ Fetching examples from dataset...'); + for await (const example of lsClient.listExamples({ datasetId })) { + // Log first example structure for debugging (verbose only) + if (allExamples.length === 0) { + log.verbose( + `📊 First example: ${JSON.stringify( + { + id: example.id, + metadata: example.metadata, + inputsKeys: Object.keys(example.inputs ?? {}), + }, + null, + 2, + )}`, + ); } - data = examples; + allExamples.push(example); } + log.verbose(`📊 Total examples in dataset: ${allExamples.length}`); - const generateWorkflow = createPairwiseWorkflowGenerator(parsedNodeTypes, llm, tracer); - const evaluator = createPairwiseLangsmithEvaluator(llm); + // Filter examples based on notionId or maxExamples + const data = filterExamples(allExamples, notionId, maxExamples, log); + + // Create artifact saver if output directory is configured + const artifactSaver = createArtifactSaver(outputDir, log); + + // NOTE: LangSmith's numRepetitions doesn't work when passing Example[] array directly + // (it only works with dataset names). We manually duplicate examples to work around this. + const repeatedData = createRepeatedData(data, repetitions); + + log.info(`➔ Running ${data.length} × ${repetitions} = ${repeatedData.length} generations`); + + const generateWorkflow = createPairwiseWorkflowGenerator( + parsedNodeTypes, + llm, + numJudges, + numGenerations, + log, + artifactSaver, + featureFlags, + tracer, + ); + const evaluator = createPairwiseLangsmithEvaluator(); + + const evalStartTime = Date.now(); await evaluate(generateWorkflow, { - data, + data: repeatedData, evaluators: [evaluator], - maxConcurrency: 5, - experimentPrefix: 'pairwise-evals', - numRepetitions: repetitions, + maxConcurrency: concurrency, + experimentPrefix: experimentName, + // numRepetitions not used - we manually duplicate examples above + metadata: { + numJudges, + numGenerations, + repetitions, + concurrency, + scoringMethod: numGenerations > 1 ? 'hierarchical-multi-generation' : 'hierarchical', + }, }); - console.log(pc.green('✓ Pairwise evaluation completed')); + const totalEvalTime = Date.now() - evalStartTime; + + log.success('\n✓ Pairwise evaluation completed'); + log.dim( + ` Prompts evaluated: ${generationCounter} | ` + + `Total workflow generations: ${generationCounter * numGenerations} | ` + + `Judge calls: ${generationCounter * numGenerations * numJudges}`, + ); + log.dim(` Total time: ${(totalEvalTime / 1000).toFixed(1)}s`); + log.dim(' View results in LangSmith dashboard'); } catch (error) { - console.error(pc.red('✗ Pairwise evaluation failed:'), error); + log.error( + `✗ Pairwise evaluation failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); + } +} + +export interface LocalPairwiseOptions { + prompt: string; + criteria: { dos: string; donts: string }; + numJudges?: number; + numGenerations?: number; + verbose?: boolean; + outputDir?: string; + featureFlags?: BuilderFeatureFlags; +} + +/** Log configuration for local pairwise evaluation */ +function logLocalPairwiseConfig( + log: EvalLogger, + numGenerations: number, + numJudges: number, + outputDir: string | undefined, + prompt: string, + criteria: { dos: string; donts: string }, +): void { + log.info(`➔ Generations: ${numGenerations}, Judges: ${numJudges}`); + if (outputDir) { + log.info(`➔ Output directory: ${outputDir}`); + } + log.verbose(`➔ Prompt: ${prompt.slice(0, 80)}${prompt.length > 80 ? '...' : ''}`); + log.verbose(`➔ Dos: ${criteria.dos.slice(0, 60)}${criteria.dos.length > 60 ? '...' : ''}`); + if (criteria.donts) { + log.verbose( + `➔ Donts: ${criteria.donts.slice(0, 60)}${criteria.donts.length > 60 ? '...' : ''}`, + ); + } +} + +/** + * Runs a single pairwise evaluation locally without LangSmith. + * Useful for testing prompts and criteria before running full dataset evaluation. + */ +export async function runLocalPairwiseEvaluation(options: LocalPairwiseOptions): Promise { + const { + prompt, + criteria, + numJudges = DEFAULT_NUM_JUDGES, + numGenerations = DEFAULT_NUM_GENERATIONS, + verbose = false, + outputDir, + featureFlags, + } = options; + const log = createLogger(verbose); + + console.log(formatHeader('Local Pairwise Evaluation', 50)); + logLocalPairwiseConfig(log, numGenerations, numJudges, outputDir, prompt, criteria); + + const startTime = Date.now(); + + try { + const { parsedNodeTypes, llm } = await setupTestEnvironment(); + + // Create artifact saver if output directory is configured + const artifactSaver = createArtifactSaver(outputDir, log); + const promptId = 'local'; + + // Save prompt artifacts + artifactSaver?.savePrompt(promptId, prompt, criteria); + + log.info(`➔ Running ${numGenerations} generation(s)...`); + + // Run all generations in parallel + const generationResults = await Promise.all( + Array.from({ length: numGenerations }, async (_, genIndex) => { + const genStartTime = Date.now(); + const runId = generateRunId(); + const agent = createAgent(parsedNodeTypes, llm, undefined, featureFlags); + await consumeGenerator( + agent.chat( + getChatPayload('pairwise-local', prompt, runId, featureFlags), + `local-gen-${genIndex}`, + ), + ); + const state = await agent.getState(runId, `local-gen-${genIndex}`); + + if (!state.values || !isWorkflowStateValues(state.values)) { + throw new Error(`Invalid workflow state for generation ${genIndex + 1}`); + } + + const workflow = state.values.workflowJSON; + const genTime = (Date.now() - genStartTime) / 1000; + + log.verbose( + ` Gen ${genIndex + 1}: Workflow done (${workflow?.nodes?.length ?? 0} nodes) [${genTime.toFixed(1)}s]`, + ); + + // Run judges for this generation + const judgeResults = await Promise.all( + Array.from({ length: numJudges }, async () => { + return await evaluateWorkflowPairwise(llm, { + workflowJSON: workflow, + evalCriteria: criteria, + }); + }), + ); + + const primaryPasses = judgeResults.filter((r) => r.primaryPass).length; + const majorityPass = primaryPasses >= getMajorityThreshold(numJudges); + const avgDiagnosticScore = + judgeResults.reduce((sum, r) => sum + r.diagnosticScore, 0) / numJudges; + + log.verbose( + ` Gen ${genIndex + 1}: ${majorityPass ? '✓ PASS' : '✗ FAIL'} (${primaryPasses}/${numJudges} judges, ${(avgDiagnosticScore * 100).toFixed(0)}%)`, + ); + + return { workflow, judgeResults, primaryPasses, majorityPass, avgDiagnosticScore }; + }), + ); + + // Save generation artifacts + if (artifactSaver) { + for (let i = 0; i < generationResults.length; i++) { + artifactSaver.saveGeneration(promptId, i, generationResults[i]); + } + } + + // Aggregate across generations + const passingGenerations = generationResults.filter((g) => g.majorityPass).length; + const generationCorrectness = passingGenerations / numGenerations; + const aggregatedDiagnosticScore = + generationResults.reduce((sum, g) => sum + g.avgDiagnosticScore, 0) / numGenerations; + + const totalTime = (Date.now() - startTime) / 1000; + + // Display aggregated result + if (numGenerations > 1) { + console.log( + `\n📊 Generation Correctness: ${passingGenerations}/${numGenerations} → ` + + `${generationCorrectness >= 0.5 ? pc.green(generationCorrectness.toFixed(2)) : pc.red(generationCorrectness.toFixed(2))}`, + ); + console.log(` Aggregated Diagnostic: ${(aggregatedDiagnosticScore * 100).toFixed(0)}%`); + } else { + // Single generation - show original format + const firstGen = generationResults[0]; + console.log( + `\n📊 Result: ${firstGen.primaryPasses}/${numJudges} judges → ` + + `${firstGen.majorityPass ? pc.green('PASS') : pc.red('FAIL')} ` + + `(${(firstGen.avgDiagnosticScore * 100).toFixed(0)}%)`, + ); + } + log.dim(` Timing: ${totalTime.toFixed(1)}s total`); + + // Per-generation breakdown (verbose or multi-gen) + if (verbose && numGenerations > 1) { + console.log(pc.dim('\nPer-generation breakdown:')); + generationResults.forEach((g, i) => { + console.log( + pc.dim( + ` Gen ${i + 1}: ${g.majorityPass ? 'PASS' : 'FAIL'} ` + + `(${g.primaryPasses}/${numJudges} judges, ${(g.avgDiagnosticScore * 100).toFixed(0)}%)`, + ), + ); + }); + } + + // Show violations if any (from first generation for simplicity) + const allViolations = generationResults[0].judgeResults.flatMap((r, i) => + r.violations.map((v) => ({ judge: i + 1, rule: v.rule, justification: v.justification })), + ); + if (allViolations.length > 0) { + console.log(pc.yellow('\nViolations (Gen 1):')); + for (const v of allViolations) { + console.log(pc.dim(` [Judge ${v.judge}] ${v.rule}: ${v.justification}`)); + } + } + + // Show workflow summary + if (verbose && generationResults[0].workflow.nodes) { + console.log(pc.dim('\nWorkflow nodes (Gen 1):')); + for (const node of generationResults[0].workflow.nodes) { + console.log(pc.dim(` - ${node.name} (${node.type})`)); + } + } + } catch (error) { + log.error( + `✗ Local evaluation failed: ${error instanceof Error ? error.message : String(error)}`, + ); process.exit(1); } } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts index 8ea144ab2b9..f40902d592b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/langsmith/runner.ts @@ -5,6 +5,7 @@ import type { INodeTypeDescription } from 'n8n-workflow'; import pc from 'picocolors'; import { createLangsmithEvaluator } from './evaluator'; +import type { BuilderFeatureFlags } from '../../src/workflow-builder-agent'; import type { WorkflowState } from '../../src/workflow-state'; import { setupTestEnvironment, createAgent } from '../core/environment'; import { @@ -20,12 +21,14 @@ import { consumeGenerator, formatHeader, getChatPayload } from '../utils/evaluat * @param parsedNodeTypes - Node types * @param llm - Language model * @param tracer - Optional tracer + * @param featureFlags - Optional feature flags to pass to the agent * @returns Function that generates workflows from inputs */ function createWorkflowGenerator( parsedNodeTypes: INodeTypeDescription[], llm: BaseChatModel, tracer?: LangChainTracer, + featureFlags?: BuilderFeatureFlags, ) { return async (inputs: typeof WorkflowState.State) => { // Generate a unique ID for this evaluation run @@ -43,7 +46,10 @@ function createWorkflowGenerator( // Create agent for this run const agent = createAgent(parsedNodeTypes, llm, tracer); await consumeGenerator( - agent.chat(getChatPayload(messageContent, runId), 'langsmith-eval-user'), + agent.chat( + getChatPayload('langsmith-evals', messageContent, runId, featureFlags), + 'langsmith-eval-user', + ), ); // Get generated workflow with validation @@ -75,12 +81,24 @@ function createWorkflowGenerator( /** * Runs evaluation using Langsmith * @param repetitions - Number of times to run each example (default: 1) + * @param featureFlags - Optional feature flags to pass to the agent */ -export async function runLangsmithEvaluation(repetitions: number = 1): Promise { +export async function runLangsmithEvaluation( + repetitions: number = 1, + featureFlags?: BuilderFeatureFlags, +): Promise { console.log(formatHeader('AI Workflow Builder Langsmith Evaluation', 70)); if (repetitions > 1) { console.log(pc.yellow(`➔ Each example will be run ${repetitions} times`)); } + if (featureFlags) { + const enabledFlags = Object.entries(featureFlags) + .filter(([, v]) => v === true) + .map(([k]) => k); + if (enabledFlags.length > 0) { + console.log(pc.green(`➔ Feature flags enabled: ${enabledFlags.join(', ')}`)); + } + } console.log(); // Check for Langsmith API key @@ -123,7 +141,7 @@ export async function runLangsmithEvaluation(repetitions: number = 1): Promise(gen: AsyncGenerator) { } } -export function getChatPayload(message: string, id: string): ChatPayload { +export function getChatPayload( + evalType: string, + message: string, + workflowId: string, + featureFlags?: BuilderFeatureFlags, +): ChatPayload { return { + id: `${evalType}-${uuid()}`, + featureFlags: featureFlags ?? { + multiAgent: true, + templateExamples: false, + }, message, workflowContext: { - currentWorkflow: { id, nodes: [], connections: {} }, + currentWorkflow: { id: workflowId, nodes: [], connections: {} }, }, }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/logger.ts b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/logger.ts new file mode 100644 index 00000000000..a0e967d8efa --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/evaluations/utils/logger.ts @@ -0,0 +1,41 @@ +import pc from 'picocolors'; + +/** + * Simple evaluation logger with verbose mode support. + * + * Usage: + * const log = createLogger(isVerbose); + * log.info('Always shown'); + * log.verbose('Only shown in verbose mode'); + */ + +export interface EvalLogger { + /** Always shown - important info */ + info: (message: string) => void; + /** Only shown in verbose mode - debug details */ + verbose: (message: string) => void; + /** Success messages (green) */ + success: (message: string) => void; + /** Warning messages (yellow) */ + warn: (message: string) => void; + /** Error messages (red) */ + error: (message: string) => void; + /** Dimmed text for secondary info */ + dim: (message: string) => void; + /** Check if verbose mode is enabled */ + isVerbose: boolean; +} + +export function createLogger(verbose: boolean = false): EvalLogger { + return { + isVerbose: verbose, + info: (message: string) => console.log(pc.blue(message)), + verbose: (message: string) => { + if (verbose) console.log(pc.dim(message)); + }, + success: (message: string) => console.log(pc.green(message)), + warn: (message: string) => console.log(pc.yellow(message)), + error: (message: string) => console.log(pc.red(message)), + dim: (message: string) => console.log(pc.dim(message)), + }; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts index 5f3419bc193..a7fd6ae99e5 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/agents/responder.agent.ts @@ -3,57 +3,20 @@ import type { AIMessage, BaseMessage } from '@langchain/core/messages'; import { HumanMessage } from '@langchain/core/messages'; import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { buildResponderPrompt } from '@/prompts/agents/responder.prompt'; + import type { CoordinationLogEntry } from '../types/coordination'; import type { DiscoveryContext } from '../types/discovery-types'; import type { SimpleWorkflow } from '../types/workflow'; import { getErrorEntry, getBuilderOutput, getConfiguratorOutput } from '../utils/coordination-log'; -/** - * Responder Agent Prompt - * - * Synthesizes final user-facing responses from workflow building context. - * Also handles conversational queries. - */ -const RESPONDER_PROMPT = `You are a helpful AI assistant for n8n workflow automation. - -You have access to context about what has been built, including: -- Discovery results (nodes found) -- Builder output (workflow structure) -- Configuration summary (setup instructions) - -FOR WORKFLOW COMPLETION RESPONSES: -When you receive [Internal Context], synthesize a clean user-facing response: -1. Summarize what was built in a friendly way -2. Explain the workflow structure briefly -3. Include setup instructions if provided -4. Ask if user wants adjustments - -Example response structure: -"I've created your [workflow type] workflow! Here's what it does: -[Brief explanation of the flow] - -**Setup Required:** -[List any configuration steps from the context] - -Let me know if you'd like to adjust anything." - -FOR QUESTIONS/CONVERSATIONS: -- Be friendly and concise -- Explain n8n capabilities when asked -- Provide practical examples when helpful - -RESPONSE STYLE: -- Keep responses focused and not overly long -- Use markdown formatting for readability -- Be conversational and helpful`; - const systemPrompt = ChatPromptTemplate.fromMessages([ [ 'system', [ { type: 'text', - text: RESPONDER_PROMPT, + text: buildResponderPrompt(), cache_control: { type: 'ephemeral' }, }, ], @@ -77,6 +40,8 @@ export interface ResponderContext { discoveryContext?: DiscoveryContext | null; /** Current workflow state */ workflowJSON: SimpleWorkflow; + /** Summary of previous conversation (from compaction) */ + previousSummary?: string; } /** @@ -98,7 +63,20 @@ export class ResponderAgent { private buildContextMessage(context: ResponderContext): HumanMessage | null { const contextParts: string[] = []; - // Check for errors first - if there's an error, surface it prominently + // Previous conversation summary (from compaction) + if (context.previousSummary) { + contextParts.push(`**Previous Conversation Summary:**\n${context.previousSummary}`); + } + + // Check for state management actions (compact/clear) + const stateManagementEntry = context.coordinationLog.find( + (e) => e.phase === 'state_management', + ); + if (stateManagementEntry) { + contextParts.push(`**State Management:** ${stateManagementEntry.summary}`); + } + + // Check for errors - if there's an error, surface it prominently const errorEntry = getErrorEntry(context.coordinationLog); if (errorEntry) { contextParts.push( diff --git a/packages/@n8n/ai-workflow-builder.ee/src/agents/supervisor.agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/agents/supervisor.agent.ts index 626775767f7..c3c72406cec 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/agents/supervisor.agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/agents/supervisor.agent.ts @@ -4,63 +4,23 @@ import { HumanMessage } from '@langchain/core/messages'; import { ChatPromptTemplate } from '@langchain/core/prompts'; import { z } from 'zod'; +import { + buildSupervisorPrompt, + SUPERVISOR_PROMPT_SUFFIX, +} from '@/prompts/agents/supervisor.prompt'; + import type { CoordinationLogEntry } from '../types/coordination'; import type { SimpleWorkflow } from '../types/workflow'; import { buildWorkflowSummary } from '../utils/context-builders'; import { summarizeCoordinationLog } from '../utils/coordination-log'; -/** - * Supervisor Agent Prompt - * - * Handles INITIAL routing based on user intent. - * After initial routing, deterministic routing takes over based on coordination log. - */ -const SUPERVISOR_PROMPT = `You are a Supervisor that routes user requests to specialist agents. - -AVAILABLE AGENTS: -- discovery: Find n8n nodes for building/modifying workflows -- builder: Create nodes and connections (requires discovery first for new node types) -- configurator: Set parameters on EXISTING nodes (no structural changes) -- responder: Answer questions, confirm completion (TERMINAL) - -ROUTING DECISION TREE: - -1. Is user asking a question or chatting? → responder - Examples: "what does this do?", "explain the workflow", "thanks" - -2. Does the request involve NEW or DIFFERENT node types? → discovery - Examples: - - "Build a workflow that..." (new workflow) - - "Use [ServiceB] instead of [ServiceA]" (replacing node type) - - "Add [some integration]" (new integration) - - "Switch from [ServiceA] to [ServiceB]" (swapping services) - -3. Is the request about connecting/disconnecting existing nodes? → builder - Examples: "Connect node A to node B", "Remove the connection to X" - -4. Is the request about changing VALUES in existing nodes? → configurator - Examples: - - "Change the URL to https://..." - - "Set the timeout to 30 seconds" - - "Update the email subject to..." - -KEY DISTINCTION: -- "Use [ServiceB] instead of [ServiceA]" = REPLACEMENT = discovery (new node type needed) -- "Change the [ServiceA] API key" = CONFIGURATION = configurator (same node, different value) - -OUTPUT: -- reasoning: One sentence explaining your routing decision -- next: Agent name`; - const systemPrompt = ChatPromptTemplate.fromMessages([ [ 'system', [ { type: 'text', - text: - SUPERVISOR_PROMPT + - '\n\nGiven the conversation above, which agent should act next? Provide your reasoning and selection.', + text: buildSupervisorPrompt() + SUPERVISOR_PROMPT_SUFFIX, cache_control: { type: 'ephemeral' }, }, ], @@ -94,6 +54,8 @@ export interface SupervisorContext { workflowJSON: SimpleWorkflow; /** Coordination log tracking subgraph completion */ coordinationLog: CoordinationLogEntry[]; + /** Summary of previous conversation (from compaction) */ + previousSummary?: string; } /** @@ -115,14 +77,21 @@ export class SupervisorAgent { private buildContextMessage(context: SupervisorContext): HumanMessage | null { const contextParts: string[] = []; - // 1. Workflow summary (node count and names only) + // 1. Previous conversation summary (from compaction) + if (context.previousSummary) { + contextParts.push(''); + contextParts.push(context.previousSummary); + contextParts.push(''); + } + + // 2. Workflow summary (node count and names only) if (context.workflowJSON.nodes.length > 0) { contextParts.push(''); contextParts.push(buildWorkflowSummary(context.workflowJSON)); contextParts.push(''); } - // 2. Coordination log summary (what phases completed) + // 3. Coordination log summary (what phases completed) if (context.coordinationLog.length > 0) { contextParts.push(''); contextParts.push(summarizeCoordinationLog(context.coordinationLog)); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts b/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts index f01970a721e..8d273c0256f 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/ai-workflow-builder-agent.service.ts @@ -12,7 +12,11 @@ import type { IUser, INodeTypeDescription, ITelemetryTrackProperties } from 'n8n import { LLMServiceError } from '@/errors'; import { anthropicClaudeSonnet45 } from '@/llm-config'; import { SessionManagerService } from '@/session-manager.service'; -import { WorkflowBuilderAgent, type ChatPayload } from '@/workflow-builder-agent'; +import { + BuilderFeatureFlags, + WorkflowBuilderAgent, + type ChatPayload, +} from '@/workflow-builder-agent'; type OnCreditsUpdated = (userId: string, creditsQuota: number, creditsClaimed: number) => void; @@ -29,6 +33,7 @@ export class AiWorkflowBuilderService { private readonly logger?: Logger, private readonly instanceId?: string, private readonly instanceUrl?: string, + private readonly n8nVersion?: string, private readonly onCreditsUpdated?: OnCreditsUpdated, private readonly onTelemetryEvent?: OnTelemetryEvent, ) { @@ -56,10 +61,10 @@ export class AiWorkflowBuilderService { }); } - private async getApiProxyAuthHeaders(user: IUser) { + private async getApiProxyAuthHeaders(user: IUser, userMessageId: string) { assert(this.client); - const authResponse = await this.client.getBuilderApiProxyToken(user); + const authResponse = await this.client.getBuilderApiProxyToken(user, { userMessageId }); const authHeaders = { // eslint-disable-next-line @typescript-eslint/naming-convention Authorization: `${authResponse.tokenType} ${authResponse.accessToken}`, @@ -68,7 +73,10 @@ export class AiWorkflowBuilderService { return authHeaders; } - private async setupModels(user: IUser): Promise<{ + private async setupModels( + user: IUser, + userMessageId: string, + ): Promise<{ anthropicClaude: ChatAnthropic; tracingClient?: TracingClient; // eslint-disable-next-line @typescript-eslint/naming-convention @@ -77,7 +85,7 @@ export class AiWorkflowBuilderService { try { // If client is provided, use it for API proxy if (this.client) { - const authHeaders = await this.getApiProxyAuthHeaders(user); + const authHeaders = await this.getApiProxyAuthHeaders(user, userMessageId); // Extract baseUrl from client configuration const baseUrl = this.client.getApiProxyBaseUrl(); @@ -153,8 +161,11 @@ export class AiWorkflowBuilderService { }); } - private async getAgent(user: IUser) { - const { anthropicClaude, tracingClient, authHeaders } = await this.setupModels(user); + private async getAgent(user: IUser, userMessageId: string, featureFlags?: BuilderFeatureFlags) { + const { anthropicClaude, tracingClient, authHeaders } = await this.setupModels( + user, + userMessageId, + ); const agent = new WorkflowBuilderAgent({ parsedNodeTypes: this.parsedNodeTypes, @@ -162,7 +173,6 @@ export class AiWorkflowBuilderService { llmSimpleTask: anthropicClaude, llmComplexTask: anthropicClaude, logger: this.logger, - enableMultiAgent: process.env.N8N_ENABLE_MULTI_AGENT === 'true', checkpointer: this.sessionManager.getCheckpointer(), tracer: tracingClient ? new LangChainTracer({ client: tracingClient, projectName: 'n8n-workflow-builder' }) @@ -171,6 +181,10 @@ export class AiWorkflowBuilderService { onGenerationSuccess: async () => { await this.onGenerationSuccess(user, authHeaders); }, + runMetadata: { + n8nVersion: this.n8nVersion, + featureFlags: featureFlags ?? {}, + }, }); return agent; @@ -200,7 +214,7 @@ export class AiWorkflowBuilderService { } async *chat(payload: ChatPayload, user: IUser, abortSignal?: AbortSignal) { - const agent = await this.getAgent(user); + const agent = await this.getAgent(user, payload.id, payload.featureFlags); const userId = user?.id?.toString(); const workflowId = payload.workflowContext?.currentWorkflow?.id; @@ -211,7 +225,7 @@ export class AiWorkflowBuilderService { // After the stream completes, track telemetry if (this.onTelemetryEvent && userId) { try { - await this.trackBuilderReplyTelemetry(agent, workflowId, userId); + await this.trackBuilderReplyTelemetry(agent, workflowId, userId, payload.id); } catch (error) { this.logger?.error('Failed to track builder reply telemetry', { error }); } @@ -222,6 +236,7 @@ export class AiWorkflowBuilderService { agent: WorkflowBuilderAgent, workflowId: string | undefined, userId: string, + userMessageId: string, ): Promise { if (!this.onTelemetryEvent) return; @@ -262,6 +277,7 @@ export class AiWorkflowBuilderService { ...(state.values.templateIds.length > 0 && { templates_selected: state.values.templateIds, }), + user_message_id: userMessageId, }; this.onTelemetryEvent('Builder replied to user message', properties); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/conversation-compact.ts b/packages/@n8n/ai-workflow-builder.ee/src/chains/conversation-compact.ts index ace9551a3a5..52a21814cd2 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/conversation-compact.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/chains/conversation-compact.ts @@ -1,22 +1,9 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseMessage } from '@langchain/core/messages'; import { AIMessage, HumanMessage } from '@langchain/core/messages'; -import { PromptTemplate } from '@langchain/core/prompts'; import z from 'zod'; -const compactPromptTemplate = PromptTemplate.fromTemplate( - `Please summarize the following conversation between a user and an AI assistant building an n8n workflow: - - -{previousSummary} - - - -{conversationText} - - -Provide a structured summary that captures the key points, decisions made, current state of the workflow, and suggested next steps.`, -); +import { compactPromptTemplate } from '@/prompts/chains/compact.prompt'; export async function conversationCompactChain( llm: BaseChatModel, diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/parameter-updater.ts b/packages/@n8n/ai-workflow-builder.ee/src/chains/parameter-updater.ts index 82a4668a6b5..32efa025050 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/parameter-updater.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/chains/parameter-updater.ts @@ -4,10 +4,13 @@ import { ChatPromptTemplate, HumanMessagePromptTemplate } from '@langchain/core/ import type { Logger } from 'n8n-workflow'; import { z } from 'zod'; +import { + instanceUrlPrompt, + ParameterUpdatePromptBuilder, +} from '@/prompts/chains/parameter-updater'; + import { LLMServiceError } from '../errors'; import type { ParameterUpdaterOptions } from '../types/config'; -import { instanceUrlPrompt } from './prompts/instance-url'; -import { ParameterUpdatePromptBuilder } from './prompts/prompt-builder'; export const parametersSchema = z .object({ diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompt-categorization.ts b/packages/@n8n/ai-workflow-builder.ee/src/chains/prompt-categorization.ts index 776dd4f55ad..fc0a5d572f8 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompt-categorization.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/chains/prompt-categorization.ts @@ -1,96 +1,11 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import { PromptTemplate } from '@langchain/core/prompts'; import { z } from 'zod'; import { - WorkflowTechnique, - TechniqueDescription, - type PromptCategorization, -} from '@/types/categorization'; - -const examplePrompts = [ - { - prompt: 'Monitor social channels for product mentions and auto-respond with campaign messages', - techniques: [ - WorkflowTechnique.MONITORING, - WorkflowTechnique.CHATBOT, - WorkflowTechnique.CONTENT_GENERATION, - ], - }, - { - prompt: 'Collect partner referral submissions and verify client instances via BigQuery', - techniques: [ - WorkflowTechnique.FORM_INPUT, - WorkflowTechnique.HUMAN_IN_THE_LOOP, - WorkflowTechnique.NOTIFICATION, - ], - }, - { - prompt: 'Scrape competitor pricing pages weekly and generate a summary report of changes', - techniques: [ - WorkflowTechnique.SCHEDULING, - WorkflowTechnique.SCRAPING_AND_RESEARCH, - WorkflowTechnique.DATA_EXTRACTION, - WorkflowTechnique.DATA_ANALYSIS, - ], - }, - { - prompt: 'Process uploaded PDF contracts to extract client details and update CRM records', - techniques: [ - WorkflowTechnique.DOCUMENT_PROCESSING, - WorkflowTechnique.DATA_EXTRACTION, - WorkflowTechnique.DATA_TRANSFORMATION, - WorkflowTechnique.ENRICHMENT, - ], - }, - { - prompt: 'Build a searchable internal knowledge base from past support tickets', - techniques: [ - WorkflowTechnique.DATA_TRANSFORMATION, - WorkflowTechnique.DATA_ANALYSIS, - WorkflowTechnique.KNOWLEDGE_BASE, - ], - }, -]; - -function formatExamplePrompts() { - return examplePrompts - .map((example) => `- ${example.prompt} → ${example.techniques.join(',')}`) - .join('\n'); -} - -const promptCategorizationTemplate = PromptTemplate.fromTemplate( - `Analyze the following user prompt and identify the workflow techniques required to fulfill the request. -Be specific and identify all relevant techniques. - - -{userPrompt} - - - -{techniques} - - -The following prompt categorization examples show a prompt → techniques involved to provide a sense -of how the categorization should be carried out. - -${formatExamplePrompts()} - - -Select a maximum of 5 techniques that you believe are applicable, but only select them if you are -confident that they are applicable. If the prompt is ambigious or does not provide an obvious workflow -do not provide any techniques - if confidence is low avoid providing techniques. - -Select ALL techniques that apply to this workflow. Most workflows use multiple techniques. -Rate your confidence in this categorization from 0.0 to 1.0. -`, -); - -function formatTechniqueList(): string { - return Object.entries(TechniqueDescription) - .map(([key, description]) => `- **${key}**: ${description}`) - .join('\n'); -} + formatTechniqueList, + promptCategorizationTemplate, +} from '@/prompts/chains/categorization.prompt'; +import { WorkflowTechnique, type PromptCategorization } from '@/types/categorization'; export async function promptCategorizationChain( llm: BaseChatModel, diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts b/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts index 8bf6df3e214..48352cc9f06 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/chains/workflow-name.ts @@ -1,17 +1,7 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; -import { PromptTemplate } from '@langchain/core/prompts'; import z from 'zod'; -const workflowNamingPromptTemplate = PromptTemplate.fromTemplate( - `Based on the initial user prompt, please generate a name for the workflow that captures its essence and purpose. - - -{initialPrompt} - - -This name should be concise, descriptive, and suitable for a workflow that automates tasks related to the given prompt. The name should be in a format that is easy to read and understand. Do not include the word "workflow" in the name. -`, -); +import { workflowNamingPromptTemplate } from '@/prompts/chains/workflow-name.prompt'; export async function workflowNameChain(llm: BaseChatModel, initialPrompt: string) { // Use structured output for the workflow name to ensure it meets the required format and length diff --git a/packages/@n8n/ai-workflow-builder.ee/src/multi-agent-workflow-subgraphs.ts b/packages/@n8n/ai-workflow-builder.ee/src/multi-agent-workflow-subgraphs.ts index 573c80fe9f1..189e629a73d 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/multi-agent-workflow-subgraphs.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/multi-agent-workflow-subgraphs.ts @@ -7,6 +7,7 @@ import type { INodeTypeDescription } from 'n8n-workflow'; import { ResponderAgent } from './agents/responder.agent'; import { SupervisorAgent } from './agents/supervisor.agent'; import { + DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_BUILDER_ITERATIONS, MAX_CONFIGURATOR_ITERATIONS, MAX_DISCOVERY_ITERATIONS, @@ -20,6 +21,14 @@ import type { SubgraphPhase } from './types/coordination'; import { createErrorMetadata } from './types/coordination'; import { getNextPhaseFromLog } from './utils/coordination-log'; import { processOperations } from './utils/operations-processor'; +import { + determineStateAction, + handleCleanupDangling, + handleCompactMessages, + handleCreateWorkflowName, + handleDeleteMessages, +} from './utils/state-modifier'; +import type { BuilderFeatureFlags } from './workflow-builder-agent'; /** * Maps routing decisions to graph node names. @@ -42,6 +51,9 @@ export interface MultiAgentSubgraphConfig { logger?: Logger; instanceUrl?: string; checkpointer?: MemorySaver; + /** Token threshold for auto-compaction. Defaults to DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS */ + autoCompactThresholdTokens?: number; + featureFlags?: BuilderFeatureFlags; } /** @@ -105,7 +117,15 @@ function createSubgraphNodeHandler< * Parent graph orchestrates between subgraphs with minimal shared state. */ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraphConfig) { - const { parsedNodeTypes, llmComplexTask, logger, instanceUrl, checkpointer } = config; + const { + parsedNodeTypes, + llmComplexTask, + logger, + instanceUrl, + checkpointer, + autoCompactThresholdTokens = DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, + featureFlags, + } = config; const supervisorAgent = new SupervisorAgent({ llm: llmComplexTask }); const responderAgent = new ResponderAgent({ llm: llmComplexTask }); @@ -120,6 +140,7 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph parsedNodeTypes, llm: llmComplexTask, logger, + featureFlags, }); const compiledBuilder = builderSubgraph.create({ parsedNodeTypes, llm: llmComplexTask, logger }); const compiledConfigurator = configuratorSubgraph.create({ @@ -138,6 +159,7 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph messages: state.messages, workflowJSON: state.workflowJSON, coordinationLog: state.coordinationLog, + previousSummary: state.previousSummary, }); return { @@ -151,6 +173,7 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph coordinationLog: state.coordinationLog, discoveryContext: state.discoveryContext, workflowJSON: state.workflowJSON, + previousSummary: state.previousSummary, }); return { @@ -167,6 +190,31 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph workflowOperations: [], // Clear operations after processing }; }) + // State modification nodes (preprocessing) + .addNode('check_state', (state) => ({ + nextPhase: determineStateAction(state, autoCompactThresholdTokens), + })) + .addNode('cleanup_dangling', (state) => handleCleanupDangling(state.messages, logger)) + .addNode('compact_messages', async (state) => { + const isAutoCompact = state.messages[state.messages.length - 1]?.content !== '/compact'; + return await handleCompactMessages( + state.messages, + state.previousSummary ?? '', + llmComplexTask, + isAutoCompact, + ); + }) + .addNode('delete_messages', (state) => handleDeleteMessages(state.messages)) + .addNode( + 'create_workflow_name', + async (state) => + await handleCreateWorkflowName( + state.messages, + state.workflowJSON, + llmComplexTask, + logger, + ), + ) // Add Subgraph Nodes (using helper to reduce duplication) .addNode( 'discovery_subgraph', @@ -202,8 +250,31 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph .addEdge('discovery_subgraph', 'process_operations') .addEdge('builder_subgraph', 'process_operations') .addEdge('configurator_subgraph', 'process_operations') - // Start flows to supervisor (initial routing only) - .addEdge(START, 'supervisor') + // Start flows to check_state (preprocessing) + .addEdge(START, 'check_state') + // Conditional routing from check_state + .addConditionalEdges('check_state', (state) => { + const routes: Record = { + cleanup_dangling: 'cleanup_dangling', + compact_messages: 'compact_messages', + delete_messages: 'delete_messages', + create_workflow_name: 'create_workflow_name', + auto_compact_messages: 'compact_messages', // Reuse same node + continue: 'supervisor', + }; + return routes[state.nextPhase] ?? 'supervisor'; + }) + // Route after state modification nodes + .addEdge('cleanup_dangling', 'check_state') // Re-check after cleanup + .addEdge('delete_messages', 'responder') // Clear → responder for acknowledgment + .addEdge('create_workflow_name', 'supervisor') // Continue after naming + // Compact has conditional routing: auto → continue, manual → responder + .addConditionalEdges('compact_messages', (state) => { + // Auto-compact preserves the last user message, manual /compact clears all + // If messages remain after compaction, it's auto-compact → continue processing + const hasMessages = state.messages.length > 0; + return hasMessages ? 'check_state' : 'responder'; + }) // Conditional Edge for Supervisor (initial routing via LLM) .addConditionalEdges('supervisor', (state) => routeToNode(state.nextPhase)) // Deterministic routing after subgraphs complete (based on coordination log) diff --git a/packages/@n8n/ai-workflow-builder.ee/src/parent-graph-state.ts b/packages/@n8n/ai-workflow-builder.ee/src/parent-graph-state.ts index 6f676735547..053a9717943 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/parent-graph-state.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/parent-graph-state.ts @@ -1,9 +1,11 @@ import type { BaseMessage } from '@langchain/core/messages'; -import { Annotation } from '@langchain/langgraph'; +import { Annotation, messagesStateReducer } from '@langchain/langgraph'; import type { CoordinationLogEntry } from './types/coordination'; import type { DiscoveryContext } from './types/discovery-types'; +import type { NodeConfigurationsMap } from './types/tools'; import type { SimpleWorkflow, WorkflowOperation } from './types/workflow'; +import { appendArrayReducer, nodeConfigurationsReducer } from './utils/state-reducers'; import type { ChatPayload } from './workflow-builder-agent'; /** @@ -15,7 +17,7 @@ import type { ChatPayload } from './workflow-builder-agent'; export const ParentGraphState = Annotation.Root({ // Shared: User's conversation history (for responder) messages: Annotation({ - reducer: (x, y) => x.concat(y), + reducer: messagesStateReducer, default: () => [], }), @@ -53,4 +55,23 @@ export const ParentGraphState = Annotation.Root({ reducer: (x, y) => x.concat(y), default: () => [], }), + + // For conversation compaction - stores summarized history + previousSummary: Annotation({ + reducer: (x, y) => y ?? x, + default: () => '', + }), + + // Template IDs fetched from workflow examples for telemetry + templateIds: Annotation({ + reducer: appendArrayReducer, + default: () => [], + }), + + // Node configurations collected from workflow examples + // Used to provide example parameter configurations when calling tools + nodeConfigurations: Annotation({ + reducer: nodeConfigurationsReducer, + default: () => ({}), + }), }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/README.md b/packages/@n8n/ai-workflow-builder.ee/src/prompts/README.md new file mode 100644 index 00000000000..40cac43af24 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/README.md @@ -0,0 +1,204 @@ +# AI Workflow Builder Prompts + +Centralized prompts for the n8n AI Workflow Builder. This directory contains all prompts used by agents and chains. +## Directory Structure + +``` +src/prompts/ +├── index.ts # Central exports +├── README.md # This file +├── legacy-agent.prompt.ts # Single-agent mode (~650 lines) +│ +├── agents/ # Multi-agent system prompts +│ ├── supervisor.prompt.ts # Routes requests to specialists +│ ├── discovery.prompt.ts # Finds nodes & categorizes techniques +│ ├── builder.prompt.ts # Creates workflow structure +│ ├── configurator.prompt.ts # Sets node parameters +│ └── responder.prompt.ts # Generates user responses +│ +└── chains/ # Chain-level prompts + ├── categorization.prompt.ts # Workflow technique classification + ├── compact.prompt.ts # Conversation summarization + ├── workflow-name.prompt.ts # Workflow name generation + │ + └── parameter-updater/ # Dynamic prompt building for node updates + ├── index.ts # Exports + ├── prompt-builder.ts # ParameterUpdatePromptBuilder class + ├── prompt-config.ts # Node detection config + ├── instance-url.ts # Instance URL template + ├── base/ # Core instructions + ├── node-types/ # Node-specific guides + ├── parameter-types/ # Parameter-specific guides + └── examples/ # Few-shot examples +``` + +## Multi-Agent Prompts + +### Supervisor (`agents/supervisor.prompt.ts`) + +Routes user requests to the appropriate specialist agent. + +| Export | Description | +|--------|-------------| +| `buildSupervisorPrompt()` | Builds the supervisor system prompt | +| `SUPERVISOR_PROMPT_SUFFIX` | Suffix asking "which agent should act next?" | + +**Routing targets:** discovery, builder, configurator, responder + +### Discovery (`agents/discovery.prompt.ts`) + +Identifies relevant n8n nodes and categorizes workflow techniques. + +| Export | Description | +|--------|-------------| +| `buildDiscoveryPrompt(options)` | Builds prompt with optional examples phase | +| `formatTechniqueList()` | Formats available techniques as bullet list | +| `formatExampleCategorizations()` | Formats few-shot examples | +| `exampleCategorizations` | 14 few-shot classification examples | +| `DiscoveryPromptOptions` | Type: `{ includeExamples: boolean }` | + +**Input variables:** `{techniques}`, `{exampleCategorizations}` + +### Builder (`agents/builder.prompt.ts`) + +Constructs workflow structure by creating nodes and connections. + +| Export | Description | +|--------|-------------| +| `buildBuilderPrompt()` | Builds the builder system prompt | + +**Key sections:** Node creation rules, connection parameters, AI connections, RAG patterns + +### Configurator (`agents/configurator.prompt.ts`) + +Sets up node parameters using natural language instructions. + +| Export | Description | +|--------|-------------| +| `buildConfiguratorPrompt()` | Builds the configurator system prompt | +| `INSTANCE_URL_PROMPT` | Template with `{instanceUrl}` variable | + +**Input variables:** `{instanceUrl}` + +### Responder (`agents/responder.prompt.ts`) + +Generates user-facing responses and handles conversational queries. + +| Export | Description | +|--------|-------------| +| `buildResponderPrompt()` | Builds the responder system prompt | + +## Legacy Agent Prompt + +### `legacy-agent.prompt.ts` + +Comprehensive monolithic prompt for single-agent mode. Contains all workflow building logic. + +| Export | Description | +|--------|-------------| +| `createMainAgentPrompt(options?)` | Creates ChatPromptTemplate with options | +| `mainAgentPrompt` | Default prompt instance | +| `MainAgentPromptOptions` | Type: `{ includeExamplesPhase?: boolean }` | + +**Input variables:** `{instanceUrl}`, `{previousSummary}`, `{messages}` + +**Phases:** +1. Categorization (mandatory) +2. Examples (optional, feature-flagged) +3. Discovery (parallel) +4. Analysis (parallel) +5. Creation (parallel) +6. Connection (parallel) +7. Configuration (mandatory) +8. Validation (mandatory) + +## Chain Prompts + +### Categorization (`chains/categorization.prompt.ts`) + +Analyzes user prompts to identify workflow techniques. + +| Export | Description | +|--------|-------------| +| `promptCategorizationTemplate` | PromptTemplate for classification | +| `examplePrompts` | 5 few-shot examples | +| `formatExamplePrompts()` | Formats examples as "prompt → techniques" | +| `formatTechniqueList()` | Formats technique descriptions | + +**Input variables:** `{userPrompt}`, `{techniques}` + +### Compact (`chains/compact.prompt.ts`) + +Summarizes multi-turn conversations for context management. + +| Export | Description | +|--------|-------------| +| `compactPromptTemplate` | PromptTemplate for summarization | + +**Input variables:** `{previousSummary}`, `{conversationText}` + +**Output:** Structured summary with key_decisions, current_state, next_steps + +### Workflow Name (`chains/workflow-name.prompt.ts`) + +Generates descriptive workflow names. + +| Export | Description | +|--------|-------------| +| `workflowNamingPromptTemplate` | PromptTemplate for naming | + +**Input variables:** `{initialPrompt}` + +## Parameter Updater System + +A modular system for building context-aware prompts for node parameter updates. + +### ParameterUpdatePromptBuilder (`chains/parameter-updater/prompt-builder.ts`) + +Dynamically assembles prompts based on node context. + +```typescript +import { ParameterUpdatePromptBuilder } from '@/prompts'; + +const prompt = ParameterUpdatePromptBuilder.buildSystemPrompt({ + nodeType: 'n8n-nodes-base.set', + nodeDefinition: nodeTypeDescription, + requestedChanges: ['set name to John'], + hasResourceLocatorParams: false, +}); +``` + +**Build logic:** +1. Always: CORE_INSTRUCTIONS + EXPRESSION_RULES +2. Node-type guide (Set, IF, Switch, HTTP, Tool) +3. Parameter-type guides if applicable +4. COMMON_PATTERNS +5. Relevant examples +6. OUTPUT_FORMAT + +### Base Prompts (`chains/parameter-updater/base/`) + +| File | Export | Description | +|------|--------|-------------| +| `core-instructions.ts` | `CORE_INSTRUCTIONS` | Parameter update guidelines | +| `expression-rules.ts` | `EXPRESSION_RULES` | n8n expression syntax rules | +| `common-patterns.ts` | `COMMON_PATTERNS` | HTTP Request patterns | +| `output-format.ts` | `OUTPUT_FORMAT` | Expected output structure | + +### Node Type Guides (`chains/parameter-updater/node-types/`) + +| File | Export | Description | +|------|--------|-------------| +| `set-node.ts` | `SET_NODE_GUIDE` | Assignment structure & types | +| `if-node.ts` | `IF_NODE_GUIDE` | Filter conditions & operators | +| `switch-node.ts` | `SWITCH_NODE_GUIDE` | Rules and routing patterns | +| `http-request.ts` | `HTTP_REQUEST_GUIDE` | URL, headers, body, auth | +| `tool-nodes.ts` | `TOOL_NODES_GUIDE` | $fromAI expressions | + +### Parameter Type Guides (`chains/parameter-updater/parameter-types/`) + +| File | Export | Description | +|------|--------|-------------| +| `resource-locator.ts` | `RESOURCE_LOCATOR_GUIDE` | __rl structure & modes | +| `system-message.ts` | `SYSTEM_MESSAGE_GUIDE` | AI node message separation | +| `text-fields.ts` | `TEXT_FIELDS_GUIDE` | Expression embedding | diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts new file mode 100644 index 00000000000..a5765c5292e --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/builder.prompt.ts @@ -0,0 +1,253 @@ +/** + * Builder Agent Prompt + * + * Constructs workflow structure by creating nodes and connections based on Discovery results. + * Does NOT configure node parameters - that's the Configurator Agent's job. + */ + +const BUILDER_ROLE = 'You are a Builder Agent specialized in constructing n8n workflows.'; + +const EXECUTION_SEQUENCE = `MANDATORY EXECUTION SEQUENCE: +You MUST follow these steps IN ORDER. Do not skip any step. + +STEP 1: CREATE NODES +- Call add_nodes for EVERY node needed based on discovery results +- Create multiple nodes in PARALLEL for efficiency +- Do NOT respond with text - START BUILDING immediately + +STEP 2: CONNECT NODES +- Call connect_nodes for ALL required connections +- Connect multiple node pairs in PARALLEL + +STEP 3: VALIDATE (REQUIRED) +- After ALL nodes and connections are created, call validate_structure +- This step is MANDATORY - you cannot finish without it +- If validation finds issues (missing trigger, invalid connections), fix them and validate again +- MAXIMUM 3 VALIDATION ATTEMPTS: After 3 calls to validate_structure, proceed to respond regardless of remaining issues + +STEP 4: RESPOND TO USER +- Only after validation passes, provide your brief summary + +⚠️ NEVER respond to the user without calling validate_structure first ⚠️`; + +const NODE_CREATION = `NODE CREATION: +Each add_nodes call creates ONE node. You must provide: +- nodeType: The exact type from discovery (e.g., "n8n-nodes-base.httpRequest") +- name: Descriptive name (e.g., "Fetch Weather Data") +- connectionParametersReasoning: Explain your thinking about connection parameters +- connectionParameters: Parameters that affect connections (or {{}} if none needed)`; + +const WORKFLOW_CONFIG_NODE = ` +Always include a Workflow Configuration node at the start of every workflow. + +The Workflow Configuration node (n8n-nodes-base.set) should be placed immediately after the trigger node and before all other processing nodes. + +Placement rules: +- Add between trigger and first processing node +- Connect: Trigger → Workflow Configuration → First processing node +- Name it "Workflow Configuration" +`; + +const DATA_PARSING = ` +For AI-generated structured data, prefer Structured Output Parser nodes over Code nodes. +For binary file data, use Extract From File node to extract content from files before processing. +Use Code nodes only for custom business logic beyond parsing. + +STRUCTURED OUTPUT PARSER RULE: +When Discovery results include Structured Output Parser: +1. Create the Structured Output Parser node +2. Set AI Agent's hasOutputParser: true in connectionParameters +3. Connect: Structured Output Parser → AI Agent (ai_outputParser connection) +`; + +const PROACTIVE_DESIGN = ` +Anticipate workflow needs: +- IF nodes for conditional logic when multiple outcomes exist +- Set nodes for data transformation between incompatible formats +- Schedule Triggers for recurring tasks +- Error handling for external service calls + +NEVER use Split In Batches nodes. +`; + +const NODE_DEFAULTS = ` +CRITICAL: NEVER RELY ON DEFAULT PARAMETER VALUES FOR CONNECTIONS + +Default values often hide connection inputs/outputs. You MUST explicitly configure parameters that affect connections: +- Vector Store: Mode parameter affects available connections - always set explicitly (e.g., mode: "insert", "retrieve", "retrieve-as-tool") +- AI Agent: hasOutputParser default may not match your workflow needs +- Document Loader: textSplittingMode affects whether it accepts a text splitter input + +ALWAYS check node details and set connectionParameters explicitly. +`; + +const CONNECTION_PARAMETERS = `CONNECTION PARAMETERS EXAMPLES: +- Static nodes (HTTP Request, Set, Code): reasoning="Static inputs/outputs", parameters={{}} +- AI Agent with structured output: reasoning="hasOutputParser enables ai_outputParser input for Structured Output Parser", parameters={{ hasOutputParser: true }} +- Vector Store insert: reasoning="Insert mode requires document input", parameters={{ mode: "insert" }} +- Document Loader custom: reasoning="Custom mode enables text splitter input", parameters={{ textSplittingMode: "custom" }} +- Switch with routing rules: reasoning="Switch needs N outputs, creating N rules.values entries with outputKeys", parameters={{ mode: "rules", rules: {{ values: [...] }} }} - see for full structure`; + +const STRUCTURED_OUTPUT_PARSER = ` +WHEN TO SET hasOutputParser: true on AI Agent: +- Discovery found Structured Output Parser node → MUST set hasOutputParser: true +- AI output will be used in conditions (IF/Switch nodes checking $json.field) +- AI output will be formatted/displayed (HTML emails, reports with specific sections) +- AI output will be stored in database/data tables with specific fields +- AI is classifying, scoring, or extracting specific data fields +`; + +/** AI sub-nodes are SOURCES (they "provide" capabilities), so arrows point FROM sub-node TO parent */ +const AI_CONNECTIONS = ` +n8n connections flow from SOURCE (output) to TARGET (input). + +Regular data flow: Source node output → Target node input +Example: HTTP Request → Set (HTTP Request is source, Set is target) + +AI sub-nodes PROVIDE capabilities, making them the SOURCE: +- OpenAI Chat Model → AI Agent [ai_languageModel] +- Calculator Tool → AI Agent [ai_tool] +- Window Buffer Memory → AI Agent [ai_memory] +- Token Splitter → Default Data Loader [ai_textSplitter] +- Default Data Loader → Vector Store [ai_document] +- Embeddings OpenAI → Vector Store [ai_embedding] +`; + +const AGENT_NODE_DISTINCTION = ` +Distinguish between two different agent node types: + +1. **AI Agent** (@n8n/n8n-nodes-langchain.agent) + - Main workflow node that orchestrates AI tasks + - Use for: Primary AI logic, chatbots, autonomous workflows + +2. **AI Agent Tool** (@n8n/n8n-nodes-langchain.agentTool) + - Sub-node that acts as a tool for another AI Agent + - Use for: Multi-agent systems where one agent calls another + +Default assumption: When discovery results include "agent", use AI Agent +unless explicitly specified as "agent tool" or "sub-agent". +`; + +const RAG_PATTERN = ` +For RAG (Retrieval-Augmented Generation) workflows: + +Main data flow: +- Data source (e.g., HTTP Request) → Vector Store [main connection] + +AI capability connections: +- Document Loader → Vector Store [ai_document] +- Embeddings → Vector Store [ai_embedding] +- Text Splitter → Document Loader [ai_textSplitter] + +Common mistake to avoid: +- NEVER connect Document Loader to main data outputs +- Document Loader is an AI sub-node that gives Vector Store document processing capability +`; + +const SWITCH_NODE_PATTERN = ` +For Switch nodes with multiple routing paths: +- The number of outputs is determined by the number of entries in rules.values[] +- You MUST create the rules.values[] array with placeholder entries for each output branch +- Each entry needs: conditions structure (with empty leftValue/rightValue) + renameOutput: true + descriptive outputKey +- Configurator will fill in the actual condition values later +- Use descriptive node names like "Route by Amount" or "Route by Status" + +Example connectionParameters for 3-way routing: +{{ + "mode": "rules", + "rules": {{ + "values": [ + {{ + "conditions": {{ + "options": {{ "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }}, + "conditions": [{{ "leftValue": "", "rightValue": "", "operator": {{ "type": "string", "operation": "equals" }} }}], + "combinator": "and" + }}, + "renameOutput": true, + "outputKey": "Output 1 Name" + }}, + {{ + "conditions": {{ + "options": {{ "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }}, + "conditions": [{{ "leftValue": "", "rightValue": "", "operator": {{ "type": "string", "operation": "equals" }} }}], + "combinator": "and" + }}, + "renameOutput": true, + "outputKey": "Output 2 Name" + }}, + {{ + "conditions": {{ + "options": {{ "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }}, + "conditions": [{{ "leftValue": "", "rightValue": "", "operator": {{ "type": "string", "operation": "equals" }} }}], + "combinator": "and" + }}, + "renameOutput": true, + "outputKey": "Output 3 Name" + }} + ] + }} +}} +`; + +const CONNECTION_TYPES = ` +**Main Connections** (regular data flow): +- Trigger → HTTP Request → Set → Email + +**AI Language Model Connections** (ai_languageModel): +- OpenAI Chat Model → AI Agent + +**AI Tool Connections** (ai_tool): +- Calculator Tool → AI Agent +- AI Agent Tool → AI Agent (for multi-agent systems) + +**AI Document Connections** (ai_document): +- Document Loader → Vector Store + +**AI Embedding Connections** (ai_embedding): +- OpenAI Embeddings → Vector Store + +**AI Text Splitter Connections** (ai_textSplitter): +- Token Text Splitter → Document Loader + +**AI Memory Connections** (ai_memory): +- Window Buffer Memory → AI Agent + +**AI Vector Store in retrieve-as-tool mode** (ai_tool): +- Vector Store → AI Agent +`; + +const RESTRICTIONS = `DO NOT: +- Respond before calling validate_structure +- Skip validation even if you think structure is correct +- Add commentary between tool calls - execute tools silently +- Configure node parameters (that's the Configurator Agent's job) +- Search for nodes (that's the Discovery Agent's job) +- Make assumptions about node types - use exactly what Discovery found`; + +const RESPONSE_FORMAT = `RESPONSE FORMAT (only after validation): +Provide ONE brief text message summarizing: +- What nodes were added +- How they're connected + +Example: "Created 4 nodes: Trigger → Weather → Image Generation → Email"`; + +export function buildBuilderPrompt(): string { + return [ + BUILDER_ROLE, + EXECUTION_SEQUENCE, + NODE_CREATION, + WORKFLOW_CONFIG_NODE, + DATA_PARSING, + PROACTIVE_DESIGN, + NODE_DEFAULTS, + CONNECTION_PARAMETERS, + STRUCTURED_OUTPUT_PARSER, + AI_CONNECTIONS, + AGENT_NODE_DISTINCTION, + RAG_PATTERN, + SWITCH_NODE_PATTERN, + CONNECTION_TYPES, + RESTRICTIONS, + RESPONSE_FORMAT, + ].join('\n\n'); +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts new file mode 100644 index 00000000000..a865fa746a2 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/configurator.prompt.ts @@ -0,0 +1,137 @@ +/** + * Configurator Agent Prompt + * + * Sets up node parameters after the Builder Agent has created the workflow structure. + * Uses natural language instructions to configure each node's settings. + */ + +const CONFIGURATOR_ROLE = + 'You are a Configurator Agent specialized in setting up n8n node parameters.'; + +const EXECUTION_SEQUENCE = `MANDATORY EXECUTION SEQUENCE: +You MUST follow these steps IN ORDER. Do not skip any step. + +STEP 1: CONFIGURE ALL NODES +- Call update_node_parameters for EVERY node in the workflow +- Configure multiple nodes in PARALLEL for efficiency +- Do NOT respond with text - START CONFIGURING immediately + +STEP 2: VALIDATE (REQUIRED) +- After ALL configurations complete, call validate_configuration +- This step is MANDATORY - you cannot finish without it +- If validation finds issues, fix them and validate again +- MAXIMUM 3 VALIDATION ATTEMPTS: After 3 calls to validate_configuration, proceed to respond regardless of remaining issues + +STEP 3: RESPOND TO USER +- Only after validation passes, provide your response + +NEVER respond to the user without calling validate_configuration first`; + +const WORKFLOW_JSON_DETECTION = `WORKFLOW JSON DETECTION: +- You receive in your context +- If you see nodes in the workflow JSON, you MUST configure them IMMEDIATELY +- Look at the workflow JSON, identify each node, and call update_node_parameters for ALL of them`; + +const PARAMETER_CONFIGURATION = `PARAMETER CONFIGURATION: +Use update_node_parameters with natural language instructions: +- "Set URL to https://api.example.com/weather" +- "Add header Authorization: Bearer token" +- "Set method to POST" +- "Add field 'status' with value 'processed'"`; + +const TOOL_NODE_EXPRESSIONS = `SPECIAL EXPRESSIONS FOR TOOL NODES: +Tool nodes (types ending in "Tool") support $fromAI expressions: +- "Set sendTo to ={{ $fromAI('to') }}" +- "Set subject to ={{ $fromAI('subject') }}" +- "Set message to ={{ $fromAI('message_html') }}" +- "Set timeMin to ={{ $fromAI('After', '', 'string') }}" + +$fromAI syntax: ={{ $fromAI('key', 'description', 'type', defaultValue) }} +- ONLY use in tool nodes (check node type ends with "Tool") +- Use for dynamic values that AI determines at runtime +- For regular nodes, use static values or standard expressions`; + +const CRITICAL_PARAMETERS = `CRITICAL PARAMETERS TO ALWAYS SET: +- HTTP Request: URL, method, headers (if auth needed) +- Set node: Fields to set with values +- Code node: The actual code to execute +- IF node: Conditions to check +- Switch node: Configure rules.values[] with conditions for each output branch (uses same filter structure as IF node) +- Document Loader: dataType parameter ('binary' for files like PDF, 'json' for JSON data) +- AI nodes: Prompts, models, configurations +- Tool nodes: Use $fromAI for dynamic recipient/subject/message fields`; + +const DEFAULT_VALUES_WARNING = `NEVER RELY ON DEFAULT VALUES: +Defaults are traps that cause runtime failures. Examples: +- Document Loader defaults to 'json' but MUST be 'binary' when processing files +- HTTP Request defaults to GET but APIs often need POST +- Vector Store mode affects available connections - set explicitly (retrieve-as-tool when using with AI Agent)`; + +const SWITCH_NODE_CONFIGURATION = ` +Switch nodes require configuring rules.values[] array - each entry creates one output: + +Structure per rule: +{{ + "conditions": {{ + "options": {{ "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }}, + "conditions": [ + {{ + "leftValue": "={{{{ $json.fieldName }}}}", + "rightValue": , + "operator": {{ "type": "number|string", "operation": "lt|gt|equals|etc" }} + }} + ], + "combinator": "and" + }}, + "renameOutput": true, + "outputKey": "Descriptive Label" +}} + +For numeric ranges (e.g., $100-$1000): +- Use TWO conditions with combinator: "and" +- First: gte (greater than or equal) +- Second: lte (less than or equal) + +Always set renameOutput: true and provide descriptive outputKey labels. +`; + +const RESPONSE_FORMAT = ` +After validation passes, provide a concise summary: +- List any placeholders requiring user configuration (e.g., "URL placeholder needs actual endpoint") +- Note which nodes were configured and key settings applied +- Keep it brief - this output is used for coordination with other LLM agents, not displayed directly to users +`; + +const RESTRICTIONS = `DO NOT: +- Respond before calling validate_configuration +- Skip validation even if you think configuration is correct +- Add commentary between tool calls - execute tools silently`; + +/** Uses {instanceUrl} as a LangChain template variable */ +export const INSTANCE_URL_PROMPT = ` + +The n8n instance base URL is: {instanceUrl} + +This URL is essential for webhook nodes and chat triggers as it provides the base URL for: +- Webhook URLs that external services need to call +- Chat trigger URLs for conversational interfaces +- Any node that requires the full instance URL to generate proper callback URLs + +When working with webhook or chat trigger nodes, use this URL as the base for constructing proper endpoint URLs. + +`; + +export function buildConfiguratorPrompt(): string { + return [ + CONFIGURATOR_ROLE, + EXECUTION_SEQUENCE, + WORKFLOW_JSON_DETECTION, + PARAMETER_CONFIGURATION, + TOOL_NODE_EXPRESSIONS, + CRITICAL_PARAMETERS, + DEFAULT_VALUES_WARNING, + SWITCH_NODE_CONFIGURATION, + RESPONSE_FORMAT, + RESTRICTIONS, + ].join('\n\n'); +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/discovery.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/discovery.prompt.ts new file mode 100644 index 00000000000..e0718bf091e --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/discovery.prompt.ts @@ -0,0 +1,315 @@ +/** + * Discovery Agent Prompt + * + * Identifies relevant n8n nodes and their connection-changing parameters based on + * the user's request. Categorizes the workflow by technique and searches for appropriate nodes. + */ + +import { + TechniqueDescription, + WorkflowTechnique, + type WorkflowTechniqueType, +} from '@/types/categorization'; + +/** Few-shot examples for technique classification */ +export const exampleCategorizations: Array<{ + prompt: string; + techniques: WorkflowTechniqueType[]; +}> = [ + { + prompt: 'Monitor social channels for product mentions and auto-respond with campaign messages', + techniques: [ + WorkflowTechnique.MONITORING, + WorkflowTechnique.CHATBOT, + WorkflowTechnique.CONTENT_GENERATION, + ], + }, + { + prompt: 'Collect partner referral submissions and verify client instances via BigQuery', + techniques: [ + WorkflowTechnique.FORM_INPUT, + WorkflowTechnique.HUMAN_IN_THE_LOOP, + WorkflowTechnique.NOTIFICATION, + ], + }, + { + prompt: 'Scrape competitor pricing pages weekly and generate a summary report of changes', + techniques: [ + WorkflowTechnique.SCHEDULING, + WorkflowTechnique.SCRAPING_AND_RESEARCH, + WorkflowTechnique.DATA_EXTRACTION, + WorkflowTechnique.DATA_ANALYSIS, + ], + }, + { + prompt: 'Process uploaded PDF contracts to extract client details and update CRM records', + techniques: [ + WorkflowTechnique.DOCUMENT_PROCESSING, + WorkflowTechnique.DATA_EXTRACTION, + WorkflowTechnique.DATA_TRANSFORMATION, + WorkflowTechnique.ENRICHMENT, + ], + }, + { + prompt: 'Build a searchable internal knowledge base from past support tickets', + techniques: [ + WorkflowTechnique.DATA_TRANSFORMATION, + WorkflowTechnique.DATA_ANALYSIS, + WorkflowTechnique.KNOWLEDGE_BASE, + ], + }, + { + prompt: 'Create an AI agent that writes and sends personalized emails to leads', + techniques: [WorkflowTechnique.CONTENT_GENERATION, WorkflowTechnique.NOTIFICATION], + }, + { + prompt: + 'Fetch trending topics from Google Trends and Reddit, select the best ones, and create social posts', + techniques: [ + WorkflowTechnique.SCRAPING_AND_RESEARCH, + WorkflowTechnique.TRIAGE, + WorkflowTechnique.CONTENT_GENERATION, + ], + }, + { + prompt: + 'Trigger when a new contact is created in HubSpot and enrich their profile with LinkedIn data', + techniques: [WorkflowTechnique.MONITORING, WorkflowTechnique.ENRICHMENT], + }, + { + prompt: 'Get stock prices from financial APIs and analyze volatility patterns', + techniques: [WorkflowTechnique.SCRAPING_AND_RESEARCH, WorkflowTechnique.DATA_ANALYSIS], + }, + { + prompt: 'Generate video reels from templates and auto-post to social media on schedule', + techniques: [ + WorkflowTechnique.SCHEDULING, + WorkflowTechnique.DOCUMENT_PROCESSING, + WorkflowTechnique.CONTENT_GENERATION, + ], + }, + { + prompt: 'Receive news from Telegram channels, filter relevant ones, and forward to my channel', + techniques: [ + WorkflowTechnique.MONITORING, + WorkflowTechnique.TRIAGE, + WorkflowTechnique.NOTIFICATION, + ], + }, + { + prompt: 'Analyze YouTube video performance data and generate a weekly report', + techniques: [ + WorkflowTechnique.SCRAPING_AND_RESEARCH, + WorkflowTechnique.DATA_ANALYSIS, + WorkflowTechnique.DATA_TRANSFORMATION, + ], + }, + { + prompt: + 'Create a chatbot that answers questions using data from a Google Sheet as knowledge base', + techniques: [WorkflowTechnique.CHATBOT, WorkflowTechnique.KNOWLEDGE_BASE], + }, + { + prompt: 'Form submission with file upload triggers document extraction and approval workflow', + techniques: [ + WorkflowTechnique.FORM_INPUT, + WorkflowTechnique.DOCUMENT_PROCESSING, + WorkflowTechnique.HUMAN_IN_THE_LOOP, + ], + }, +]; + +export function formatTechniqueList(): string { + return Object.entries(TechniqueDescription) + .map(([key, description]) => `- **${key}**: ${description}`) + .join('\n'); +} + +export function formatExampleCategorizations(): string { + return exampleCategorizations + .map((example) => `- ${example.prompt} → ${example.techniques.join(', ')}`) + .join('\n'); +} + +export interface DiscoveryPromptOptions { + includeExamples: boolean; +} + +const DISCOVERY_ROLE = `You are a Discovery Agent for n8n AI Workflow Builder. + +YOUR ROLE: Identify relevant n8n nodes and their connection-changing parameters.`; + +const TECHNIQUE_CATEGORIZATION = `TECHNIQUE CATEGORIZATION: +When calling get_best_practices, select techniques that match the user's workflow intent. + + +{techniques} + + + +{exampleCategorizations} +`; + +const TECHNIQUE_CLARIFICATIONS = ` +Common distinctions to get right: +- **NOTIFICATION vs CHATBOT**: Use NOTIFICATION when SENDING emails/messages/alerts (including to Telegram CHANNELS which are broadcast-only). Use CHATBOT only when RECEIVING and REPLYING to direct messages in a conversation. +- **MONITORING**: Use when workflow TRIGGERS on external events (new record created, status changed, incoming webhook, new message in channel). NOT just scheduled runs. +- **SCRAPING_AND_RESEARCH vs DATA_EXTRACTION**: Use SCRAPING when fetching from EXTERNAL sources (APIs, websites, social media). Use DATA_EXTRACTION for parsing INTERNAL data you already have. +- **TRIAGE**: Use when SELECTING, PRIORITIZING, ROUTING, or QUALIFYING items (e.g., "pick the best", "route to correct team", "qualify leads"). +- **DOCUMENT_PROCESSING**: Use for ANY file handling - PDFs, images, videos, Excel, Google Sheets, audio files, file uploads in forms. +- **HUMAN_IN_THE_LOOP**: Use when workflow PAUSES for human approval, review, signing documents, responding to polls, or any manual input before continuing. +- **DATA_ANALYSIS**: Use when ANALYZING, CLASSIFYING, IDENTIFYING PATTERNS, or UNDERSTANDING data (e.g., "analyze outcomes", "learn from previous", "classify by type", "identify trends"). +- **KNOWLEDGE_BASE**: Use when storing/retrieving from a DATA SOURCE for Q&A - includes vector DBs, spreadsheets used as databases, document collections. +- **DATA_TRANSFORMATION**: Use when CONVERTING data format, creating REPORTS/SUMMARIES from analyzed data, or restructuring output. + + +Technique selection rules: +- Select ALL techniques that apply (most workflows use 2-4) +- Maximum 5 techniques +- Only select techniques you're confident apply`; + +const CONNECTION_PARAMETERS = `CONNECTION-CHANGING PARAMETERS - CRITICAL RULES: + +A parameter is connection-changing ONLY IF it appears in or expressions within . + +**How to identify:** +1. Look at the section in node details +2. Check if or uses expressions like: ={{...parameterName...}} +3. If a parameter is referenced in these expressions, it IS connection-changing +4. If a parameter is NOT in / expressions, it is NOT connection-changing + +**Example from AI Agent:** +\`\`\`xml +={{...hasOutputParser, needsFallback...}} +\`\`\` +→ hasOutputParser and needsFallback ARE connection-changing (they control which inputs appear) + +**Counter-example:** +\`\`\`xml + + ... + ... + +\`\`\` +→ promptType and systemMessage are NOT connection-changing (they don't affect connections) + +**Common connection-changing parameters:** +- Vector Store: mode (appears in / expressions) +- AI Agent: hasOutputParser, needsFallback (appears in expression) +- Merge: numberInputs (appears in expression) +- Webhook: responseMode (appears in expression)`; + +const DYNAMIC_OUTPUT_NODES = ` +Some nodes have DYNAMIC outputs that depend on parameter values: + +**Switch Node** (n8n-nodes-base.switch): +- When mode is "rules", the number of outputs equals the number of routing rules +- Connection parameter: mode: "rules" - CRITICAL for enabling rule-based routing +- Each rule in rules.values[] creates one output +- The rules parameter uses the same filter structure as IF node conditions +- ALWAYS flag mode as connection-changing with possibleValues: ["rules", "expression"] + +**Merge Node** (n8n-nodes-base.merge): +- numberInputs parameter controls how many inputs the node accepts + +When you find these nodes, ALWAYS flag mode/numberInputs as connection-changing parameters with possibleValues. +`; + +const SUB_NODES_SEARCHES = `SUB-NODES SEARCHES: +When searching for AI nodes, ALSO search for their required sub-nodes: +- "AI Agent" → also search for "Chat Model", "Memory", "Output Parser" +- "Basic LLM Chain" → also search for "Chat Model", "Output Parser" +- "Vector Store" → also search for "Embeddings", "Document Loader"`; + +const STRUCTURED_OUTPUT_PARSER = `STRUCTURED OUTPUT PARSER - WHEN TO INCLUDE: +Search for "Structured Output Parser" (@n8n/n8n-nodes-langchain.outputParserStructured) when: +- AI output will be used programmatically (conditions, formatting, database storage, API calls) +- AI needs to extract specific fields (e.g., score, category, priority, action items) +- AI needs to classify/categorize data into defined categories +- Downstream nodes need to access specific fields from AI response (e.g., $json.score, $json.category) +- Output will be displayed in a formatted way (e.g., HTML email with specific sections) +- Data needs validation against a schema before processing + + +- Always use search_nodes to find the exact node names and versions - NEVER guess versions`; + +const CRITICAL_RULES = `CRITICAL RULES: +- NEVER ask clarifying questions +- ALWAYS call get_best_practices first +- THEN Call search_nodes to learn about available nodes and their inputs and outputs +- FINALLY call get_node_details IN PARALLEL for speed to get more details about RELVANT node +- ALWAYS extract version number from tag in node details +- NEVER guess node versions - always use search_nodes to find exact versions +- ONLY flag connectionChangingParameters if they appear in or expressions +- If no parameters appear in connection expressions, return empty array [] +- Output ONLY: nodesFound with {{ nodeName, version, reasoning, connectionChangingParameters }}`; + +const RESTRICTIONS = `DO NOT: +- Output text commentary between tool calls +- Include bestPractices or categorization in submit_discovery_results +- Flag parameters that don't affect connections +- Stop without calling submit_discovery_results`; + +function generateAvailableToolsList(options: DiscoveryPromptOptions): string { + const { includeExamples } = options; + + const tools = [ + '- get_best_practices: Retrieve best practices (internal context)', + '- search_nodes: Find n8n nodes by keyword', + '- get_node_details: Get complete node information including ', + ]; + + if (includeExamples) { + tools.push('- get_workflow_examples: Search for workflow examples as reference'); + } + + tools.push('- submit_discovery_results: Submit final results'); + + return tools.join('\n'); +} + +function generateProcessSteps(options: DiscoveryPromptOptions): string { + const { includeExamples } = options; + + const steps: string[] = [ + '**Analyze user prompt** - Extract services, models, and technologies mentioned', + '**Call get_best_practices** with identified techniques (internal context)', + ]; + + if (includeExamples) { + steps.push('**Call get_workflow_examples** with search queries for mentioned services/models'); + } + + const examplesContext = includeExamples ? ', and examples' : ''; + steps.push( + `**Identify workflow components** from user request, best practices${examplesContext}`, + '**Call search_nodes IN PARALLEL** for all components (e.g., "Gmail", "OpenAI", "Schedule")', + '**Call get_node_details IN PARALLEL** for ALL promising nodes (batch multiple calls)', + `**Extract node information** from each node_details response: + - Node name from tag + - Version number from tag (required - extract the number) + - Connection-changing parameters from section`, + '**Call submit_discovery_results** with complete nodesFound array', + ); + + return steps.map((step, index) => `${index + 1}. ${step}`).join('\n'); +} + +export function buildDiscoveryPrompt(options: DiscoveryPromptOptions): string { + const availableTools = generateAvailableToolsList(options); + const processSteps = generateProcessSteps(options); + + return [ + DISCOVERY_ROLE, + `AVAILABLE TOOLS:\n${availableTools}`, + `PROCESS:\n${processSteps}`, + TECHNIQUE_CATEGORIZATION, + TECHNIQUE_CLARIFICATIONS, + CONNECTION_PARAMETERS, + DYNAMIC_OUTPUT_NODES, + SUB_NODES_SEARCHES, + STRUCTURED_OUTPUT_PARSER, + CRITICAL_RULES, + RESTRICTIONS, + ].join('\n\n'); +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts new file mode 100644 index 00000000000..c77a35ecd6c --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/responder.prompt.ts @@ -0,0 +1,46 @@ +/** + * Responder Agent Prompt + * + * Synthesizes final user-facing responses from workflow building context. + * Also handles conversational queries and explanations. + */ + +const RESPONDER_ROLE = `You are a helpful AI assistant for n8n workflow automation. + +You have access to context about what has been built, including: +- Discovery results (nodes found) +- Builder output (workflow structure) +- Configuration summary (setup instructions)`; + +const WORKFLOW_COMPLETION = `FOR WORKFLOW COMPLETION RESPONSES: +When you receive [Internal Context], synthesize a clean user-facing response: +1. Summarize what was built in a friendly way +2. Explain the workflow structure briefly +3. Include setup instructions if provided +4. Ask if user wants adjustments + +Example response structure: +"I've created your [workflow type] workflow! Here's what it does: +[Brief explanation of the flow] + +**Setup Required:** +[List any configuration steps from the context] + +Let me know if you'd like to adjust anything."`; + +const CONVERSATIONAL_RESPONSES = `FOR QUESTIONS/CONVERSATIONS: +- Be friendly and concise +- Explain n8n capabilities when asked +- Provide practical examples when helpful`; + +const RESPONSE_STYLE = `RESPONSE STYLE: +- Keep responses focused and not overly long +- Use markdown formatting for readability +- Be conversational and helpful +- Do not use emojis in your response`; + +export function buildResponderPrompt(): string { + return [RESPONDER_ROLE, WORKFLOW_COMPLETION, CONVERSATIONAL_RESPONSES, RESPONSE_STYLE].join( + '\n\n', + ); +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/supervisor.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/supervisor.prompt.ts new file mode 100644 index 00000000000..458c91d2c4e --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/agents/supervisor.prompt.ts @@ -0,0 +1,57 @@ +/** + * Supervisor Agent Prompt + * + * Handles INITIAL routing based on user intent. + * After initial routing, deterministic routing takes over based on coordination log. + */ + +const SUPERVISOR_ROLE = 'You are a Supervisor that routes user requests to specialist agents.'; + +const AVAILABLE_AGENTS = `AVAILABLE AGENTS: +- discovery: Find n8n nodes for building/modifying workflows +- builder: Create nodes and connections (requires discovery first for new node types) +- configurator: Set parameters on EXISTING nodes (no structural changes) +- responder: Answer questions, confirm completion (TERMINAL)`; + +const ROUTING_DECISION_TREE = `ROUTING DECISION TREE: + +1. Is user asking a question or chatting? → responder + Examples: "what does this do?", "explain the workflow", "thanks" + +2. Does the request involve NEW or DIFFERENT node types? → discovery + Examples: + - "Build a workflow that..." (new workflow) + - "Use [ServiceB] instead of [ServiceA]" (replacing node type) + - "Add [some integration]" (new integration) + - "Switch from [ServiceA] to [ServiceB]" (swapping services) + +3. Is the request about connecting/disconnecting existing nodes? → builder + Examples: "Connect node A to node B", "Remove the connection to X" + +4. Is the request about changing VALUES in existing nodes? → configurator + Examples: + - "Change the URL to https://..." + - "Set the timeout to 30 seconds" + - "Update the email subject to..."`; + +/** Clarifies replacement (discovery) vs configuration - common confusion point */ +const KEY_DISTINCTION = `KEY DISTINCTION: +- "Use [ServiceB] instead of [ServiceA]" = REPLACEMENT = discovery (new node type needed) +- "Change the [ServiceA] API key" = CONFIGURATION = configurator (same node, different value)`; + +const OUTPUT_FORMAT = `OUTPUT: +- reasoning: One sentence explaining your routing decision +- next: Agent name`; + +export function buildSupervisorPrompt(): string { + return [ + SUPERVISOR_ROLE, + AVAILABLE_AGENTS, + ROUTING_DECISION_TREE, + KEY_DISTINCTION, + OUTPUT_FORMAT, + ].join('\n\n'); +} + +export const SUPERVISOR_PROMPT_SUFFIX = + '\n\nGiven the conversation above, which agent should act next? Provide your reasoning and selection.'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/categorization.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/categorization.prompt.ts new file mode 100644 index 00000000000..c51e6002663 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/categorization.prompt.ts @@ -0,0 +1,91 @@ +import { PromptTemplate } from '@langchain/core/prompts'; + +import { WorkflowTechnique, TechniqueDescription } from '@/types/categorization'; + +/** Few-shot examples for prompt categorization - helps LLM understand expected output format */ +export const examplePrompts = [ + { + prompt: 'Monitor social channels for product mentions and auto-respond with campaign messages', + techniques: [ + WorkflowTechnique.MONITORING, + WorkflowTechnique.CHATBOT, + WorkflowTechnique.CONTENT_GENERATION, + ], + }, + { + prompt: 'Collect partner referral submissions and verify client instances via BigQuery', + techniques: [ + WorkflowTechnique.FORM_INPUT, + WorkflowTechnique.HUMAN_IN_THE_LOOP, + WorkflowTechnique.NOTIFICATION, + ], + }, + { + prompt: 'Scrape competitor pricing pages weekly and generate a summary report of changes', + techniques: [ + WorkflowTechnique.SCHEDULING, + WorkflowTechnique.SCRAPING_AND_RESEARCH, + WorkflowTechnique.DATA_EXTRACTION, + WorkflowTechnique.DATA_ANALYSIS, + ], + }, + { + prompt: 'Process uploaded PDF contracts to extract client details and update CRM records', + techniques: [ + WorkflowTechnique.DOCUMENT_PROCESSING, + WorkflowTechnique.DATA_EXTRACTION, + WorkflowTechnique.DATA_TRANSFORMATION, + WorkflowTechnique.ENRICHMENT, + ], + }, + { + prompt: 'Build a searchable internal knowledge base from past support tickets', + techniques: [ + WorkflowTechnique.DATA_TRANSFORMATION, + WorkflowTechnique.DATA_ANALYSIS, + WorkflowTechnique.KNOWLEDGE_BASE, + ], + }, +]; + +/** Formats example prompts as "prompt → techniques" for few-shot learning */ +export function formatExamplePrompts(): string { + return examplePrompts + .map((example) => `- ${example.prompt} → ${example.techniques.join(',')}`) + .join('\n'); +} + +/** Generates bullet list of all techniques with descriptions */ +export function formatTechniqueList(): string { + return Object.entries(TechniqueDescription) + .map(([key, description]) => `- **${key}**: ${description}`) + .join('\n'); +} + +/** Template for analyzing user prompts and identifying workflow techniques */ +export const promptCategorizationTemplate = PromptTemplate.fromTemplate( + `Analyze the following user prompt and identify the workflow techniques required to fulfill the request. +Be specific and identify all relevant techniques. + + +{userPrompt} + + + +{techniques} + + +The following prompt categorization examples show a prompt → techniques involved to provide a sense +of how the categorization should be carried out. + +${formatExamplePrompts()} + + +Select a maximum of 5 techniques that you believe are applicable, but only select them if you are +confident that they are applicable. If the prompt is ambiguous or does not provide an obvious workflow +do not provide any techniques - if confidence is low avoid providing techniques. + +Select ALL techniques that apply to this workflow. Most workflows use multiple techniques. +Rate your confidence in this categorization from 0.0 to 1.0. +`, +); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/compact.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/compact.prompt.ts new file mode 100644 index 00000000000..38d88854223 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/compact.prompt.ts @@ -0,0 +1,16 @@ +import { PromptTemplate } from '@langchain/core/prompts'; + +/** Template for summarizing multi-turn conversations into a structured format */ +export const compactPromptTemplate = PromptTemplate.fromTemplate( + `Please summarize the following conversation between a user and an AI assistant building an n8n workflow: + + +{previousSummary} + + + +{conversationText} + + +Provide a structured summary that captures the key points, decisions made, current state of the workflow, and suggested next steps.`, +); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/common-patterns.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/common-patterns.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/common-patterns.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/common-patterns.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/core-instructions.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/core-instructions.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/core-instructions.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/core-instructions.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/expression-rules.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/expression-rules.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/expression-rules.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/expression-rules.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/output-format.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/output-format.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/base/output-format.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/base/output-format.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/advanced/resource-locator-examples.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/advanced/resource-locator-examples.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/advanced/resource-locator-examples.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/advanced/resource-locator-examples.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/advanced/tool-node-examples.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/advanced/tool-node-examples.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/advanced/tool-node-examples.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/advanced/tool-node-examples.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/basic/if-node-examples.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/if-node-examples.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/basic/if-node-examples.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/if-node-examples.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/basic/set-node-examples.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/set-node-examples.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/basic/set-node-examples.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/set-node-examples.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/basic/simple-updates.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/simple-updates.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/examples/basic/simple-updates.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/simple-updates.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/switch-node-examples.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/switch-node-examples.ts new file mode 100644 index 00000000000..c6fcb8d7fb9 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/examples/basic/switch-node-examples.ts @@ -0,0 +1,124 @@ +export const SWITCH_NODE_EXAMPLES = ` +### Switch Node Examples + +#### Example 1: Route by Amount Tiers (Purchase Approval) +Current Parameters: { "mode": "rules" } +Requested Changes: Route under $100 to auto-approve, $100-$1000 to manager, over $1000 to finance + +Expected Output: +{ + "mode": "rules", + "rules": { + "values": [ + { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "leftValue": "={{ $json.amount }}", + "rightValue": 100, + "operator": { "type": "number", "operation": "lt" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Auto-Approve" + }, + { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "leftValue": "={{ $json.amount }}", + "rightValue": 100, + "operator": { "type": "number", "operation": "gte" } + }, + { + "leftValue": "={{ $json.amount }}", + "rightValue": 1000, + "operator": { "type": "number", "operation": "lte" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Manager Review" + }, + { + "conditions": { + "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" }, + "conditions": [ + { + "leftValue": "={{ $json.amount }}", + "rightValue": 1000, + "operator": { "type": "number", "operation": "gt" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Finance Review" + } + ] + } +} + +#### Example 2: Route by Status String +Current Parameters: { "mode": "rules" } +Requested Changes: Route by order status - pending, processing, completed + +Expected Output: +{ + "mode": "rules", + "rules": { + "values": [ + { + "conditions": { + "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "loose" }, + "conditions": [ + { + "leftValue": "={{ $json.status }}", + "rightValue": "pending", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Pending" + }, + { + "conditions": { + "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "loose" }, + "conditions": [ + { + "leftValue": "={{ $json.status }}", + "rightValue": "processing", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Processing" + }, + { + "conditions": { + "options": { "caseSensitive": false, "leftValue": "", "typeValidation": "loose" }, + "conditions": [ + { + "leftValue": "={{ $json.status }}", + "rightValue": "completed", + "operator": { "type": "string", "operation": "equals" } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Completed" + } + ] + } +} +`; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/index.ts new file mode 100644 index 00000000000..d78bd6f28cd --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/index.ts @@ -0,0 +1,26 @@ +export { ParameterUpdatePromptBuilder } from './prompt-builder'; +export { instanceUrlPrompt } from './instance-url'; +export { + DEFAULT_PROMPT_CONFIG, + getNodeTypeCategory, + mentionsResourceKeywords, + mentionsTextKeywords, +} from './prompt-config'; + +// Base prompts +export { CORE_INSTRUCTIONS } from './base/core-instructions'; +export { EXPRESSION_RULES } from './base/expression-rules'; +export { COMMON_PATTERNS } from './base/common-patterns'; +export { OUTPUT_FORMAT } from './base/output-format'; + +// Node type guides +export { SET_NODE_GUIDE } from './node-types/set-node'; +export { IF_NODE_GUIDE } from './node-types/if-node'; +export { SWITCH_NODE_GUIDE } from './node-types/switch-node'; +export { HTTP_REQUEST_GUIDE } from './node-types/http-request'; +export { TOOL_NODES_GUIDE } from './node-types/tool-nodes'; + +// Parameter type guides +export { RESOURCE_LOCATOR_GUIDE } from './parameter-types/resource-locator'; +export { SYSTEM_MESSAGE_GUIDE } from './parameter-types/system-message'; +export { TEXT_FIELDS_GUIDE } from './parameter-types/text-fields'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/instance-url.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/instance-url.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/instance-url.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/instance-url.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/http-request.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/http-request.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/http-request.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/http-request.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/if-node.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/if-node.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/if-node.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/if-node.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/set-node.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/set-node.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/set-node.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/set-node.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/switch-node.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/switch-node.ts new file mode 100644 index 00000000000..f7caeae7772 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/switch-node.ts @@ -0,0 +1,68 @@ +export const SWITCH_NODE_GUIDE = ` +### Switch Node Configuration Guide + +The Switch node routes items to different outputs based on conditions. Uses the same filter structure as IF node but for multi-way branching. + +#### Switch Node Structure (mode: 'rules') +\`\`\`json +{ + "mode": "rules", + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "leftValue": "={{ $json.amount }}", + "rightValue": 100, + "operator": { + "type": "number", + "operation": "lt" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Under $100" + } + ] + } +} +\`\`\` + +#### Key Points: +1. Each entry in rules.values[] creates ONE output +2. Conditions use the same filter structure as IF node +3. Multiple conditions per rule are combined with combinator ("and" or "or") +4. Use renameOutput: true + outputKey to label outputs descriptively + +#### Numeric Operators +- lt: Less than +- gt: Greater than +- lte: Less than or equal +- gte: Greater than or equal +- equals: Equal to + +#### String Operators +- equals: Exact match +- contains: Contains substring +- startsWith: Starts with +- endsWith: Ends with + +#### Common Patterns: + +**Numeric Range Routing** (for ranges like $100-$1000): +Use two conditions with combinator: "and": +- First condition: gte (greater than or equal to lower bound) +- Second condition: lte (less than or equal to upper bound) + +**String-Based Routing** (status/type values): +- Use type: "string" with operation: "equals" +- Set caseSensitive: false in options for case-insensitive matching +`; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/tool-nodes.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/tool-nodes.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/node-types/tool-nodes.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/node-types/tool-nodes.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/parameter-types/resource-locator.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/parameter-types/resource-locator.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/parameter-types/resource-locator.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/parameter-types/resource-locator.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/parameter-types/system-message.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/parameter-types/system-message.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/parameter-types/system-message.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/parameter-types/system-message.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/parameter-types/text-fields.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/parameter-types/text-fields.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/parameter-types/text-fields.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/parameter-types/text-fields.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/prompt-builder.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/prompt-builder.ts similarity index 92% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/prompt-builder.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/prompt-builder.ts index 665e13f1b41..d6319693914 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/prompt-builder.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/prompt-builder.ts @@ -1,5 +1,7 @@ import type { INodeTypeDescription, INodeProperties } from 'n8n-workflow'; +import type { PromptBuilderContext } from '@/types/config'; + import { COMMON_PATTERNS } from './base/common-patterns'; import { CORE_INSTRUCTIONS } from './base/core-instructions'; import { EXPRESSION_RULES } from './base/expression-rules'; @@ -9,9 +11,11 @@ import { TOOL_NODE_EXAMPLES } from './examples/advanced/tool-node-examples'; import { IF_NODE_EXAMPLES } from './examples/basic/if-node-examples'; import { SET_NODE_EXAMPLES } from './examples/basic/set-node-examples'; import { SIMPLE_UPDATE_EXAMPLES } from './examples/basic/simple-updates'; +import { SWITCH_NODE_EXAMPLES } from './examples/basic/switch-node-examples'; import { HTTP_REQUEST_GUIDE } from './node-types/http-request'; import { IF_NODE_GUIDE } from './node-types/if-node'; import { SET_NODE_GUIDE } from './node-types/set-node'; +import { SWITCH_NODE_GUIDE } from './node-types/switch-node'; import { TOOL_NODES_GUIDE } from './node-types/tool-nodes'; import { RESOURCE_LOCATOR_GUIDE } from './parameter-types/resource-locator'; import { SYSTEM_MESSAGE_GUIDE } from './parameter-types/system-message'; @@ -21,7 +25,6 @@ import { getNodeTypeCategory, mentionsResourceKeywords, } from './prompt-config'; -import type { PromptBuilderContext } from '../../types/config'; export class ParameterUpdatePromptBuilder { /** @@ -42,6 +45,8 @@ export class ParameterUpdatePromptBuilder { sections.push(SET_NODE_GUIDE); } else if (this.isIfNode(context.nodeType)) { sections.push(IF_NODE_GUIDE); + } else if (this.isSwitchNode(context.nodeType)) { + sections.push(SWITCH_NODE_GUIDE); } else if (this.isHttpRequestNode(context.nodeType)) { sections.push(HTTP_REQUEST_GUIDE); } @@ -130,6 +135,14 @@ export class ParameterUpdatePromptBuilder { return category === 'if'; } + /** + * Checks if node is a Switch node + */ + private static isSwitchNode(nodeType: string): boolean { + const category = getNodeTypeCategory(nodeType); + return category === 'switch'; + } + /** * Checks if node is an HTTP Request node */ @@ -179,6 +192,8 @@ export class ParameterUpdatePromptBuilder { examples.push(SET_NODE_EXAMPLES); } else if (this.isIfNode(context.nodeType)) { examples.push(IF_NODE_EXAMPLES); + } else if (this.isSwitchNode(context.nodeType)) { + examples.push(SWITCH_NODE_EXAMPLES); } // Add resource locator examples if needed if (context.hasResourceLocatorParams) { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/prompt-config.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/prompt-config.ts similarity index 94% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/prompt-config.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/prompt-config.ts index 39e0067b22b..b2d20c56bf7 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/prompt-config.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/prompt-config.ts @@ -1,9 +1,10 @@ -import type { NodePromptConfig } from '../../types/config'; +import type { NodePromptConfig } from '@/types/config'; export const DEFAULT_PROMPT_CONFIG: NodePromptConfig = { nodeTypePatterns: { set: ['n8n-nodes-base.set', 'set'], if: ['n8n-nodes-base.if', 'if', 'filter'], + switch: ['n8n-nodes-base.switch', 'switch'], httpRequest: ['n8n-nodes-base.httpRequest', 'httprequest', 'webhook', 'n8n-nodes-base.webhook'], tool: ['Tool', '.tool'], }, diff --git a/packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/test/prompt-builder.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/test/prompt-builder.test.ts similarity index 100% rename from packages/@n8n/ai-workflow-builder.ee/src/chains/prompts/test/prompt-builder.test.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/parameter-updater/test/prompt-builder.test.ts diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/workflow-name.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/workflow-name.prompt.ts new file mode 100644 index 00000000000..7aadea22954 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/chains/workflow-name.prompt.ts @@ -0,0 +1,13 @@ +import { PromptTemplate } from '@langchain/core/prompts'; + +/** Template for generating descriptive workflow names from user prompts */ +export const workflowNamingPromptTemplate = PromptTemplate.fromTemplate( + `Based on the initial user prompt, please generate a name for the workflow that captures its essence and purpose. + + +{initialPrompt} + + +This name should be concise, descriptive, and suitable for a workflow that automates tasks related to the given prompt. The name should be in a format that is easy to read and understand. Do not include the word "workflow" in the name. +`, +); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/prompts/index.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/index.ts new file mode 100644 index 00000000000..6bb2dc6c69f --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/index.ts @@ -0,0 +1,56 @@ +/** + * Centralized prompts for AI Workflow Builder + * + * This directory contains all prompts used by the AI workflow builder agents and chains. + * Organization: + * - agents/ - Multi-agent system prompts (builder, configurator, discovery, etc.) + * - chains/ - Chain-level prompts (categorization, compact, workflow-name, parameter-updater) + * - legacy-agent.prompt.ts - Legacy single-agent mode prompt + */ + +// Agent prompts (multi-agent system) +export { buildBuilderPrompt } from './agents/builder.prompt'; +export { + buildDiscoveryPrompt, + formatTechniqueList, + formatExampleCategorizations, + type DiscoveryPromptOptions, +} from './agents/discovery.prompt'; +export { buildConfiguratorPrompt, INSTANCE_URL_PROMPT } from './agents/configurator.prompt'; +export { buildSupervisorPrompt, SUPERVISOR_PROMPT_SUFFIX } from './agents/supervisor.prompt'; +export { buildResponderPrompt } from './agents/responder.prompt'; + +// Legacy agent prompt (single-agent mode) +export { + createMainAgentPrompt, + mainAgentPrompt, + type MainAgentPromptOptions, +} from './legacy-agent.prompt'; + +// Chain prompts +export { + promptCategorizationTemplate, + examplePrompts, + formatExamplePrompts, + formatTechniqueList as formatCategorizationTechniqueList, +} from './chains/categorization.prompt'; +export { compactPromptTemplate } from './chains/compact.prompt'; +export { workflowNamingPromptTemplate } from './chains/workflow-name.prompt'; + +// Parameter updater prompts +export { + ParameterUpdatePromptBuilder, + instanceUrlPrompt, + CORE_INSTRUCTIONS, + EXPRESSION_RULES, + COMMON_PATTERNS, + OUTPUT_FORMAT, + SET_NODE_GUIDE, + IF_NODE_GUIDE, + SWITCH_NODE_GUIDE, + HTTP_REQUEST_GUIDE, + TOOL_NODES_GUIDE, + RESOURCE_LOCATOR_GUIDE, + SYSTEM_MESSAGE_GUIDE, + TEXT_FIELDS_GUIDE, +} from './chains/parameter-updater'; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts b/packages/@n8n/ai-workflow-builder.ee/src/prompts/legacy-agent.prompt.ts similarity index 98% rename from packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts rename to packages/@n8n/ai-workflow-builder.ee/src/prompts/legacy-agent.prompt.ts index ca0cf798cb5..3394142f881 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/prompts/main-agent.prompt.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/prompts/legacy-agent.prompt.ts @@ -1,6 +1,6 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; -import { instanceUrlPrompt } from '../../chains/prompts/instance-url'; +import { instanceUrlPrompt } from './chains/parameter-updater/instance-url'; /** * Phase configuration for the workflow creation sequence @@ -126,9 +126,8 @@ After receiving tool results, reflect on their quality and determine optimal nex -Keep responses concise. - CRITICAL: Do NOT provide commentary between tool calls. Execute tools silently. +- NO emojis under any circumstances - NO progress messages like "Perfect!", "Now let me...", "Excellent!" - NO descriptions of what was built or how it works - NO workflow features or capabilities explanations @@ -583,17 +582,17 @@ When tool execution fails, provide a brief acknowledgment before attempting fixe Then proceed with debugging/fixing without additional commentary. Response format conditions: -- Include "**⚙️ How to Setup**" section ONLY if this is the initial workflow creation -- Include "**📝 What's changed**" section ONLY for non-initial modifications (skip for first workflow creation) +- Include "**How to Setup**" section ONLY if this is the initial workflow creation +- Include "**What's changed**" section ONLY for non-initial modifications (skip for first workflow creation) - Skip setup section for minor tweaks, bug fixes, or cosmetic changes When changes section is included: -**📝 What's changed** +**What's changed** - Brief bullets highlighting key modifications made - Focus on functional changes, not technical implementation details When setup section is included: -**⚙️ How to Setup** (numbered format) +**How to Setup** (numbered format) - List only parameter placeholders requiring user configuration - Include only incomplete tasks needing user action (skip pre-configured items) - IMPORTANT: NEVER instruct user to set-up authentication or credentials for nodes - this will be handled in the UI diff --git a/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/builder.subgraph.ts b/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/builder.subgraph.ts index aee4cee8973..47cad888d00 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/builder.subgraph.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/builder.subgraph.ts @@ -7,6 +7,7 @@ import type { Logger } from '@n8n/backend-common'; import type { INodeTypeDescription } from 'n8n-workflow'; import { LLMServiceError } from '@/errors'; +import { buildBuilderPrompt } from '@/prompts/agents/builder.prompt'; import type { ChatPayload } from '@/workflow-builder-agent'; import { BaseSubgraph } from './subgraph-interface'; @@ -34,172 +35,6 @@ import { createStandardShouldContinue, } from '../utils/subgraph-helpers'; -/** - * Builder Agent Prompt - */ -const BUILDER_PROMPT = `You are a Builder Agent specialized in constructing n8n workflows. - -MANDATORY EXECUTION SEQUENCE: -You MUST follow these steps IN ORDER. Do not skip any step. - -STEP 1: CREATE NODES -- Call add_nodes for EVERY node needed based on discovery results -- Create multiple nodes in PARALLEL for efficiency -- Do NOT respond with text - START BUILDING immediately - -STEP 2: CONNECT NODES -- Call connect_nodes for ALL required connections -- Connect multiple node pairs in PARALLEL - -STEP 3: VALIDATE (REQUIRED) -- After ALL nodes and connections are created, call validate_structure -- This step is MANDATORY - you cannot finish without it -- If validation finds issues (missing trigger, invalid connections), fix them and validate again - -STEP 4: RESPOND TO USER -- Only after validation passes, provide your brief summary - -⚠️ NEVER respond to the user without calling validate_structure first ⚠️ - -NODE CREATION: -Each add_nodes call creates ONE node. You must provide: -- nodeType: The exact type from discovery (e.g., "n8n-nodes-base.httpRequest") -- name: Descriptive name (e.g., "Fetch Weather Data") -- connectionParametersReasoning: Explain your thinking about connection parameters -- connectionParameters: Parameters that affect connections (or {{}} if none needed) - - -Always include a Workflow Configuration node at the start of every workflow. - -The Workflow Configuration node (n8n-nodes-base.set) should be placed immediately after the trigger node and before all other processing nodes. - -Placement rules: -- Add between trigger and first processing node -- Connect: Trigger → Workflow Configuration → First processing node -- Name it "Workflow Configuration" - - - -For AI-generated structured data, prefer Structured Output Parser nodes over Code nodes. -For binary file data, use Extract From File node to extract content from files before processing. -Use Code nodes only for custom business logic beyond parsing. - - - -Anticipate workflow needs: -- IF nodes for conditional logic when multiple outcomes exist -- Set nodes for data transformation between incompatible formats -- Schedule Triggers for recurring tasks -- Error handling for external service calls - -NEVER use Split In Batches nodes. - - - -CRITICAL: NEVER RELY ON DEFAULT PARAMETER VALUES FOR CONNECTIONS - -Default values often hide connection inputs/outputs. You MUST explicitly configure parameters that affect connections: -- Vector Store: Mode parameter affects available connections - always set explicitly (e.g., mode: "insert", "retrieve", "retrieve-as-tool") -- AI Agent: hasOutputParser default may not match your workflow needs -- Document Loader: textSplittingMode affects whether it accepts a text splitter input - -ALWAYS check node details and set connectionParameters explicitly. - - -CONNECTION PARAMETERS EXAMPLES: -- Static nodes (HTTP Request, Set, Code): reasoning="Static inputs/outputs", parameters={{}} -- AI Agent with parser: reasoning="hasOutputParser creates additional input", parameters={{ hasOutputParser: true }} -- Vector Store insert: reasoning="Insert mode requires document input", parameters={{ mode: "insert" }} -- Document Loader custom: reasoning="Custom mode enables text splitter input", parameters={{ textSplittingMode: "custom" }} - - -n8n connections flow from SOURCE (output) to TARGET (input). - -Regular data flow: Source node output → Target node input -Example: HTTP Request → Set (HTTP Request is source, Set is target) - -AI sub-nodes PROVIDE capabilities, making them the SOURCE: -- OpenAI Chat Model → AI Agent [ai_languageModel] -- Calculator Tool → AI Agent [ai_tool] -- Window Buffer Memory → AI Agent [ai_memory] -- Token Splitter → Default Data Loader [ai_textSplitter] -- Default Data Loader → Vector Store [ai_document] -- Embeddings OpenAI → Vector Store [ai_embedding] - - - -Distinguish between two different agent node types: - -1. **AI Agent** (@n8n/n8n-nodes-langchain.agent) - - Main workflow node that orchestrates AI tasks - - Use for: Primary AI logic, chatbots, autonomous workflows - -2. **AI Agent Tool** (@n8n/n8n-nodes-langchain.agentTool) - - Sub-node that acts as a tool for another AI Agent - - Use for: Multi-agent systems where one agent calls another - -Default assumption: When discovery results include "agent", use AI Agent -unless explicitly specified as "agent tool" or "sub-agent". - - - -For RAG (Retrieval-Augmented Generation) workflows: - -Main data flow: -- Data source (e.g., HTTP Request) → Vector Store [main connection] - -AI capability connections: -- Document Loader → Vector Store [ai_document] -- Embeddings → Vector Store [ai_embedding] -- Text Splitter → Document Loader [ai_textSplitter] - -Common mistake to avoid: -- NEVER connect Document Loader to main data outputs -- Document Loader is an AI sub-node that gives Vector Store document processing capability - - - -**Main Connections** (regular data flow): -- Trigger → HTTP Request → Set → Email - -**AI Language Model Connections** (ai_languageModel): -- OpenAI Chat Model → AI Agent - -**AI Tool Connections** (ai_tool): -- Calculator Tool → AI Agent -- AI Agent Tool → AI Agent (for multi-agent systems) - -**AI Document Connections** (ai_document): -- Document Loader → Vector Store - -**AI Embedding Connections** (ai_embedding): -- OpenAI Embeddings → Vector Store - -**AI Text Splitter Connections** (ai_textSplitter): -- Token Text Splitter → Document Loader - -**AI Memory Connections** (ai_memory): -- Window Buffer Memory → AI Agent - -**AI Vector Store in retrieve-as-tool mode** (ai_tool): -- Vector Store → AI Agent - - -DO NOT: -- Respond before calling validate_structure -- Skip validation even if you think structure is correct -- Add commentary between tool calls - execute tools silently -- Configure node parameters (that's the Configurator Agent's job) -- Search for nodes (that's the Discovery Agent's job) -- Make assumptions about node types - use exactly what Discovery found - -RESPONSE FORMAT (only after validation): -Provide ONE brief text message summarizing: -- What nodes were added -- How they're connected - -Example: "Created 4 nodes: Trigger → Weather → Image Generation → Email"`; - /** * Builder Subgraph State */ @@ -275,7 +110,7 @@ export class BuilderSubgraph extends BaseSubgraph< [ { type: 'text', - text: BUILDER_PROMPT, + text: buildBuilderPrompt(), cache_control: { type: 'ephemeral' }, }, ], diff --git a/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/configurator.subgraph.ts b/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/configurator.subgraph.ts index 8c74cd8db86..747309c4a96 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/configurator.subgraph.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/configurator.subgraph.ts @@ -8,6 +8,7 @@ import type { Logger } from '@n8n/backend-common'; import type { INodeTypeDescription } from 'n8n-workflow'; import { LLMServiceError } from '@/errors'; +import { buildConfiguratorPrompt, INSTANCE_URL_PROMPT } from '@/prompts/agents/configurator.prompt'; import { BaseSubgraph } from './subgraph-interface'; import type { ParentGraphState } from '../parent-graph-state'; @@ -33,96 +34,6 @@ import { } from '../utils/subgraph-helpers'; import type { ChatPayload } from '../workflow-builder-agent'; -/** - * Configurator Agent Prompt - */ -const CONFIGURATOR_PROMPT = `You are a Configurator Agent specialized in setting up n8n node parameters. - -MANDATORY EXECUTION SEQUENCE: -You MUST follow these steps IN ORDER. Do not skip any step. - -STEP 1: CONFIGURE ALL NODES -- Call update_node_parameters for EVERY node in the workflow -- Configure multiple nodes in PARALLEL for efficiency -- Do NOT respond with text - START CONFIGURING immediately - -STEP 2: VALIDATE (REQUIRED) -- After ALL configurations complete, call validate_configuration -- This step is MANDATORY - you cannot finish without it -- If validation finds issues, fix them and validate again - -STEP 3: RESPOND TO USER -- Only after validation passes, provide your response - -NEVER respond to the user without calling validate_configuration first - -WORKFLOW JSON DETECTION: -- You receive in your context -- If you see nodes in the workflow JSON, you MUST configure them IMMEDIATELY -- Look at the workflow JSON, identify each node, and call update_node_parameters for ALL of them - -PARAMETER CONFIGURATION: -Use update_node_parameters with natural language instructions: -- "Set URL to https://api.example.com/weather" -- "Add header Authorization: Bearer token" -- "Set method to POST" -- "Add field 'status' with value 'processed'" - -SPECIAL EXPRESSIONS FOR TOOL NODES: -Tool nodes (types ending in "Tool") support $fromAI expressions: -- "Set sendTo to ={{ $fromAI('to') }}" -- "Set subject to ={{ $fromAI('subject') }}" -- "Set message to ={{ $fromAI('message_html') }}" -- "Set timeMin to ={{ $fromAI('After', '', 'string') }}" - -$fromAI syntax: ={{ $fromAI('key', 'description', 'type', defaultValue) }} -- ONLY use in tool nodes (check node type ends with "Tool") -- Use for dynamic values that AI determines at runtime -- For regular nodes, use static values or standard expressions - -CRITICAL PARAMETERS TO ALWAYS SET: -- HTTP Request: URL, method, headers (if auth needed) -- Set node: Fields to set with values -- Code node: The actual code to execute -- IF node: Conditions to check -- Document Loader: dataType parameter ('binary' for files like PDF, 'json' for JSON data) -- AI nodes: Prompts, models, configurations -- Tool nodes: Use $fromAI for dynamic recipient/subject/message fields - -NEVER RELY ON DEFAULT VALUES: -Defaults are traps that cause runtime failures. Examples: -- Document Loader defaults to 'json' but MUST be 'binary' when processing files -- HTTP Request defaults to GET but APIs often need POST -- Vector Store mode affects available connections - set explicitly (retrieve-as-tool when using with AI Agent) - - -After validation passes, provide a concise summary: -- List any placeholders requiring user configuration (e.g., "URL placeholder needs actual endpoint") -- Note which nodes were configured and key settings applied -- Keep it brief - this output is used for coordination with other LLM agents, not displayed directly to users - - -DO NOT: -- Respond before calling validate_configuration -- Skip validation even if you think configuration is correct -- Add commentary between tool calls - execute tools silently`; - -/** - * Instance URL prompt template - */ -const INSTANCE_URL_PROMPT = ` - -The n8n instance base URL is: {instanceUrl} - -This URL is essential for webhook nodes and chat triggers as it provides the base URL for: -- Webhook URLs that external services need to call -- Chat trigger URLs for conversational interfaces -- Any node that requires the full instance URL to generate proper callback URLs - -When working with webhook or chat trigger nodes, use this URL as the base for constructing proper endpoint URLs. - -`; - /** * Configurator Subgraph State */ @@ -213,7 +124,7 @@ export class ConfiguratorSubgraph extends BaseSubgraph< [ { type: 'text', - text: CONFIGURATOR_PROMPT, + text: buildConfiguratorPrompt(), }, { type: 'text', diff --git a/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/discovery.subgraph.ts b/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/discovery.subgraph.ts index 03d6cfd57d5..921d61fe4c0 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/discovery.subgraph.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/subgraphs/discovery.subgraph.ts @@ -11,152 +11,26 @@ import { z } from 'zod'; import { LLMServiceError } from '@/errors'; import { - TechniqueDescription, - WorkflowTechnique, - type WorkflowTechniqueType, -} from '@/types/categorization'; + buildDiscoveryPrompt, + formatTechniqueList, + formatExampleCategorizations, +} from '@/prompts/agents/discovery.prompt'; +import type { BuilderFeatureFlags } from '@/workflow-builder-agent'; import { BaseSubgraph } from './subgraph-interface'; import type { ParentGraphState } from '../parent-graph-state'; import { createGetBestPracticesTool } from '../tools/get-best-practices.tool'; +import { createGetWorkflowExamplesTool } from '../tools/get-workflow-examples.tool'; import { createNodeDetailsTool } from '../tools/node-details.tool'; import { createNodeSearchTool } from '../tools/node-search.tool'; import type { CoordinationLogEntry } from '../types/coordination'; import { createDiscoveryMetadata } from '../types/coordination'; +import type { NodeConfigurationsMap } from '../types/tools'; import { applySubgraphCacheMarkers } from '../utils/cache-control'; import { buildWorkflowSummary, createContextMessage } from '../utils/context-builders'; +import { appendArrayReducer, nodeConfigurationsReducer } from '../utils/state-reducers'; import { executeSubgraphTools, extractUserRequest } from '../utils/subgraph-helpers'; -/** - * Example categorizations to guide technique selection - * Expanded with diverse examples to improve accuracy - */ -const exampleCategorizations: Array<{ - prompt: string; - techniques: WorkflowTechniqueType[]; -}> = [ - { - prompt: 'Monitor social channels for product mentions and auto-respond with campaign messages', - techniques: [ - WorkflowTechnique.MONITORING, - WorkflowTechnique.CHATBOT, - WorkflowTechnique.CONTENT_GENERATION, - ], - }, - { - prompt: 'Collect partner referral submissions and verify client instances via BigQuery', - techniques: [ - WorkflowTechnique.FORM_INPUT, - WorkflowTechnique.HUMAN_IN_THE_LOOP, - WorkflowTechnique.NOTIFICATION, - ], - }, - { - prompt: 'Scrape competitor pricing pages weekly and generate a summary report of changes', - techniques: [ - WorkflowTechnique.SCHEDULING, - WorkflowTechnique.SCRAPING_AND_RESEARCH, - WorkflowTechnique.DATA_EXTRACTION, - WorkflowTechnique.DATA_ANALYSIS, - ], - }, - { - prompt: 'Process uploaded PDF contracts to extract client details and update CRM records', - techniques: [ - WorkflowTechnique.DOCUMENT_PROCESSING, - WorkflowTechnique.DATA_EXTRACTION, - WorkflowTechnique.DATA_TRANSFORMATION, - WorkflowTechnique.ENRICHMENT, - ], - }, - { - prompt: 'Build a searchable internal knowledge base from past support tickets', - techniques: [ - WorkflowTechnique.DATA_TRANSFORMATION, - WorkflowTechnique.DATA_ANALYSIS, - WorkflowTechnique.KNOWLEDGE_BASE, - ], - }, - // Additional examples to address common misclassifications - { - prompt: 'Create an AI agent that writes and sends personalized emails to leads', - techniques: [WorkflowTechnique.CONTENT_GENERATION, WorkflowTechnique.NOTIFICATION], - }, - { - prompt: - 'Fetch trending topics from Google Trends and Reddit, select the best ones, and create social posts', - techniques: [ - WorkflowTechnique.SCRAPING_AND_RESEARCH, - WorkflowTechnique.TRIAGE, - WorkflowTechnique.CONTENT_GENERATION, - ], - }, - { - prompt: - 'Trigger when a new contact is created in HubSpot and enrich their profile with LinkedIn data', - techniques: [WorkflowTechnique.MONITORING, WorkflowTechnique.ENRICHMENT], - }, - { - prompt: 'Get stock prices from financial APIs and analyze volatility patterns', - techniques: [WorkflowTechnique.SCRAPING_AND_RESEARCH, WorkflowTechnique.DATA_ANALYSIS], - }, - { - prompt: 'Generate video reels from templates and auto-post to social media on schedule', - techniques: [ - WorkflowTechnique.SCHEDULING, - WorkflowTechnique.DOCUMENT_PROCESSING, - WorkflowTechnique.CONTENT_GENERATION, - ], - }, - { - prompt: 'Receive news from Telegram channels, filter relevant ones, and forward to my channel', - techniques: [ - WorkflowTechnique.MONITORING, - WorkflowTechnique.TRIAGE, - WorkflowTechnique.NOTIFICATION, - ], - }, - { - prompt: 'Analyze YouTube video performance data and generate a weekly report', - techniques: [ - WorkflowTechnique.SCRAPING_AND_RESEARCH, - WorkflowTechnique.DATA_ANALYSIS, - WorkflowTechnique.DATA_TRANSFORMATION, - ], - }, - { - prompt: - 'Create a chatbot that answers questions using data from a Google Sheet as knowledge base', - techniques: [WorkflowTechnique.CHATBOT, WorkflowTechnique.KNOWLEDGE_BASE], - }, - { - prompt: 'Form submission with file upload triggers document extraction and approval workflow', - techniques: [ - WorkflowTechnique.FORM_INPUT, - WorkflowTechnique.DOCUMENT_PROCESSING, - WorkflowTechnique.HUMAN_IN_THE_LOOP, - ], - }, -]; - -/** - * Format technique descriptions for prompt - */ -function formatTechniqueList(): string { - return Object.entries(TechniqueDescription) - .map(([key, description]) => `- **${key}**: ${description}`) - .join('\n'); -} - -/** - * Format example categorizations for prompt - */ -function formatExampleCategorizations(): string { - return exampleCategorizations - .map((example) => `- ${example.prompt} → ${example.techniques.join(', ')}`) - .join('\n'); -} - /** * Strict Output Schema for Discovery * Simplified to reduce token usage while maintaining utility for downstream subgraphs @@ -189,115 +63,6 @@ const discoveryOutputSchema = z.object({ .describe('List of n8n nodes identified as necessary for the workflow'), }); -/** - * Discovery Agent Prompt - */ -const DISCOVERY_PROMPT = `You are a Discovery Agent for n8n AI Workflow Builder. - -YOUR ROLE: Identify relevant n8n nodes and their connection-changing parameters. - -AVAILABLE TOOLS: -- get_best_practices: Retrieve best practices (internal context) -- search_nodes: Find n8n nodes by keyword -- get_node_details: Get complete node information including -- submit_discovery_results: Submit final results - -PROCESS: -1. **Call get_best_practices** with identified techniques (internal context) -2. **Identify workflow components** from user request and best practices -3. **Call search_nodes IN PARALLEL** for all components (e.g., "Gmail", "OpenAI", "Schedule") -4. **Call get_node_details IN PARALLEL** for ALL promising nodes (batch multiple calls) -5. **Extract node information** from each node_details response: - - Node name from tag - - Version number from tag (required - extract the number) - - Connection-changing parameters from section -6. **Call submit_discovery_results** with complete nodesFound array - -TECHNIQUE CATEGORIZATION: -When calling get_best_practices, select techniques that match the user's workflow intent. - - -{techniques} - - - -{exampleCategorizations} - - - -Common distinctions to get right: -- **NOTIFICATION vs CHATBOT**: Use NOTIFICATION when SENDING emails/messages/alerts (including to Telegram CHANNELS which are broadcast-only). Use CHATBOT only when RECEIVING and REPLYING to direct messages in a conversation. -- **MONITORING**: Use when workflow TRIGGERS on external events (new record created, status changed, incoming webhook, new message in channel). NOT just scheduled runs. -- **SCRAPING_AND_RESEARCH vs DATA_EXTRACTION**: Use SCRAPING when fetching from EXTERNAL sources (APIs, websites, social media). Use DATA_EXTRACTION for parsing INTERNAL data you already have. -- **TRIAGE**: Use when SELECTING, PRIORITIZING, ROUTING, or QUALIFYING items (e.g., "pick the best", "route to correct team", "qualify leads"). -- **DOCUMENT_PROCESSING**: Use for ANY file handling - PDFs, images, videos, Excel, Google Sheets, audio files, file uploads in forms. -- **HUMAN_IN_THE_LOOP**: Use when workflow PAUSES for human approval, review, signing documents, responding to polls, or any manual input before continuing. -- **DATA_ANALYSIS**: Use when ANALYZING, CLASSIFYING, IDENTIFYING PATTERNS, or UNDERSTANDING data (e.g., "analyze outcomes", "learn from previous", "classify by type", "identify trends"). -- **KNOWLEDGE_BASE**: Use when storing/retrieving from a DATA SOURCE for Q&A - includes vector DBs, spreadsheets used as databases, document collections. -- **DATA_TRANSFORMATION**: Use when CONVERTING data format, creating REPORTS/SUMMARIES from analyzed data, or restructuring output. - - -Technique selection rules: -- Select ALL techniques that apply (most workflows use 2-4) -- Maximum 5 techniques -- Only select techniques you're confident apply - -CONNECTION-CHANGING PARAMETERS - CRITICAL RULES: - -A parameter is connection-changing ONLY IF it appears in or expressions within . - -**How to identify:** -1. Look at the section in node details -2. Check if or uses expressions like: ={{...parameterName...}} -3. If a parameter is referenced in these expressions, it IS connection-changing -4. If a parameter is NOT in / expressions, it is NOT connection-changing - -**Example from AI Agent:** -\`\`\`xml -={{...hasOutputParser, needsFallback...}} -\`\`\` -→ hasOutputParser and needsFallback ARE connection-changing (they control which inputs appear) - -**Counter-example:** -\`\`\`xml - - ... - ... - -\`\`\` -→ promptType and systemMessage are NOT connection-changing (they don't affect connections) - -**Common connection-changing parameters:** -- Vector Store: mode (appears in / expressions) -- AI Agent: hasOutputParser, needsFallback (appears in expression) -- Merge: numberInputs (appears in expression) -- Webhook: responseMode (appears in expression) - -SUB-NODES SEARCHES: -When searching for AI nodes, ALSO search for their required sub-nodes: -- "AI Agent" → also search for "Chat Model", "Memory", "Output Parser" -- "Basic LLM Chain" → also search for "Chat Model", "Output Parser" -- "Vector Store" → also search for "Embeddings", "Document Loader" -- Always use search_nodes to find the exact node names and versions - NEVER guess versions - -CRITICAL RULES: -- NEVER ask clarifying questions -- ALWAYS call get_best_practices first -- THEN Call search_nodes to learn about available nodes and their inputs and outputs -- FINALLY call get_node_details IN PARALLEL for speed to get more details about RELVANT node -- ALWAYS extract version number from tag in node details -- NEVER guess node versions - always use search_nodes to find exact versions -- ONLY flag connectionChangingParameters if they appear in or expressions -- If no parameters appear in connection expressions, return empty array [] -- Output ONLY: nodesFound with {{ nodeName, version, reasoning, connectionChangingParameters }} - -DO NOT: -- Output text commentary between tool calls -- Include bestPractices or categorization in submit_discovery_results -- Flag parameters that don't affect connections -- Stop without calling submit_discovery_results -`; - /** * Discovery Subgraph State */ @@ -334,12 +99,26 @@ export const DiscoverySubgraphState = Annotation.Root({ bestPractices: Annotation({ reducer: (x, y) => y ?? x, }), + + // Output: Template IDs fetched from workflow examples for telemetry + templateIds: Annotation({ + reducer: appendArrayReducer, + default: () => [], + }), + + // Output: Node configurations collected from workflow examples + // Used to provide example parameter configurations when get_node_details is called + nodeConfigurations: Annotation({ + reducer: nodeConfigurationsReducer, + default: () => ({}), + }), }); export interface DiscoverySubgraphConfig { parsedNodeTypes: INodeTypeDescription[]; llm: BaseChatModel; logger?: Logger; + featureFlags?: BuilderFeatureFlags; } export class DiscoverySubgraph extends BaseSubgraph< @@ -357,12 +136,21 @@ export class DiscoverySubgraph extends BaseSubgraph< create(config: DiscoverySubgraphConfig) { this.logger = config.logger; - // Create tools - const tools = [ + // Check if template examples are enabled + const includeExamples = config.featureFlags?.templateExamples === true; + + // Create base tools + const baseTools = [ createGetBestPracticesTool(), createNodeSearchTool(config.parsedNodeTypes), createNodeDetailsTool(config.parsedNodeTypes), ]; + + // Conditionally add workflow examples tool if feature flag is enabled + const tools = includeExamples + ? [...baseTools, createGetWorkflowExamplesTool(config.logger)] + : baseTools; + this.toolMap = new Map(tools.map((bt) => [bt.tool.name, bt.tool])); // Define output tool @@ -372,6 +160,9 @@ export class DiscoverySubgraph extends BaseSubgraph< schema: discoveryOutputSchema, }); + // Generate prompt based on feature flags + const discoveryPrompt = buildDiscoveryPrompt({ includeExamples }); + // Create agent with tools bound (including submit tool) const systemPrompt = ChatPromptTemplate.fromMessages([ [ @@ -379,7 +170,7 @@ export class DiscoverySubgraph extends BaseSubgraph< [ { type: 'text', - text: DISCOVERY_PROMPT, + text: discoveryPrompt, cache_control: { type: 'ephemeral' }, }, ], @@ -458,16 +249,20 @@ export class DiscoverySubgraph extends BaseSubgraph< this.logger?.error('[Discovery] No submit tool call found in last message'); return { nodesFound: [], + templateIds: [], }; } const bestPracticesTool = state.messages.find( (m): m is ToolMessage => m.getType() === 'tool' && m?.text?.startsWith(''), ); - // Return raw output without hydration + + // Return raw output without hydration, including templateIds and nodeConfigurations from workflow examples return { nodesFound: output.nodesFound, bestPractices: bestPracticesTool?.text, + templateIds: state.templateIds ?? [], + nodeConfigurations: state.nodeConfigurations ?? {}, }; } @@ -533,9 +328,12 @@ export class DiscoverySubgraph extends BaseSubgraph< _parentState: typeof ParentGraphState.State, ) { const nodesFound = subgraphOutput.nodesFound || []; + const templateIds = subgraphOutput.templateIds || []; + const nodeConfigurations = subgraphOutput.nodeConfigurations || {}; const discoveryContext = { nodesFound, bestPractices: subgraphOutput.bestPractices, + nodeConfigurations, }; // Create coordination log entry (not a message) @@ -554,6 +352,10 @@ export class DiscoverySubgraph extends BaseSubgraph< return { discoveryContext, coordinationLog: [logEntry], + // Pass template IDs for telemetry + templateIds, + // Pass node configurations for example parameters in node details + nodeConfigurations, }; } } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/test/ai-workflow-builder-agent.service.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/test/ai-workflow-builder-agent.service.test.ts index a0d5b27f3bf..85a15f4446b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/test/ai-workflow-builder-agent.service.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/test/ai-workflow-builder-agent.service.test.ts @@ -151,9 +151,6 @@ describe('AiWorkflowBuilderService', () => { // Mock AI assistant client mockClient = mock(); - (mockClient.generateApiProxyCredentials as jest.Mock).mockResolvedValue({ - apiKey: 'test-api-key', - }); (mockClient.getBuilderApiProxyToken as jest.Mock).mockResolvedValue({ tokenType: 'Bearer', accessToken: 'test-access-token', @@ -209,6 +206,7 @@ describe('AiWorkflowBuilderService', () => { mockLogger, 'test-instance-id', 'https://n8n.example.com', + '1.0.0', mockOnCreditsUpdated, ); }); @@ -221,6 +219,7 @@ describe('AiWorkflowBuilderService', () => { mockLogger, 'test-instance-id', 'https://test.com', + '1.0.0', mockOnCreditsUpdated, ); @@ -247,6 +246,7 @@ describe('AiWorkflowBuilderService', () => { mockLogger, 'test-instance-id', 'https://test.com', + '1.0.0', mockOnCreditsUpdated, ); @@ -271,6 +271,7 @@ describe('AiWorkflowBuilderService', () => { mockLogger, 'test-instance-id', 'https://test.com', + '1.0.0', mockOnCreditsUpdated, ); @@ -297,6 +298,7 @@ describe('AiWorkflowBuilderService', () => { beforeEach(() => { mockPayload = { + id: '12345', message: 'Create a simple workflow', workflowContext: { currentWorkflow: { id: 'test-workflow' }, @@ -567,6 +569,7 @@ describe('AiWorkflowBuilderService', () => { it('should handle complete workflow from chat to session retrieval', async () => { const workflowId = 'integration-test-workflow'; const mockPayload: ChatPayload = { + id: '545623', message: 'Create a workflow with HTTP request', workflowContext: { currentWorkflow: { id: workflowId }, diff --git a/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts index 80c9384cb5f..793679c9fef 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts @@ -31,7 +31,7 @@ jest.mock('@/tools/update-node-parameters.tool', () => ({ jest.mock('@/tools/get-node-parameter.tool', () => ({ createGetNodeParameterTool: jest.fn().mockReturnValue({ tool: { name: 'get_node_parameter' } }), })); -jest.mock('@/tools/prompts/main-agent.prompt', () => ({ +jest.mock('@/prompts/legacy-agent.prompt', () => ({ mainAgentPrompt: { invoke: jest.fn().mockResolvedValue('mocked prompt'), }, @@ -67,7 +67,7 @@ Object.defineProperty(global, 'crypto', { import { MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants'; import { ValidationError } from '@/errors'; -import { createMainAgentPrompt } from '@/tools/prompts/main-agent.prompt'; +import { createMainAgentPrompt } from '@/prompts/legacy-agent.prompt'; import type { StreamOutput } from '@/types/streaming'; import { createStreamProcessor } from '@/utils/stream-processor'; import { @@ -146,6 +146,7 @@ describe('WorkflowBuilderAgent', () => { beforeEach(() => { mockPayload = { + id: '12345', message: 'Create a workflow', workflowContext: { currentWorkflow: { id: 'workflow-123' }, @@ -156,6 +157,7 @@ describe('WorkflowBuilderAgent', () => { it('should throw ValidationError when message exceeds maximum length', async () => { const longMessage = 'x'.repeat(MAX_AI_BUILDER_PROMPT_LENGTH + 1); const payload: ChatPayload = { + id: '12345', message: longMessage, }; @@ -173,6 +175,7 @@ describe('WorkflowBuilderAgent', () => { it('should handle valid message length', async () => { const validMessage = 'Create a simple workflow'; const payload: ChatPayload = { + id: '12345', message: validMessage, }; @@ -491,6 +494,7 @@ describe('WorkflowBuilderAgent', () => { ); const generator = agent.chat({ + id: '12345', message: 'Create a workflow', featureFlags: { templateExamples: true }, }); @@ -511,6 +515,7 @@ describe('WorkflowBuilderAgent', () => { ); const generator = agent.chat({ + id: '12345', message: 'Create a workflow', featureFlags: { templateExamples: false }, }); @@ -530,7 +535,10 @@ describe('WorkflowBuilderAgent', () => { mockAsyncGenerator, ); - const generator = agent.chat({ message: 'Create a workflow' }); + const generator = agent.chat({ + id: '12345', + message: 'Create a workflow', + }); await generator.next(); expect(mockCreateMainAgentPrompt).toHaveBeenCalledWith({ includeExamplesPhase: false }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/best-practices/content-generation.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/best-practices/content-generation.ts index 066806d94f8..91ee2a29b1a 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/best-practices/content-generation.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/best-practices/content-generation.ts @@ -11,6 +11,42 @@ export class ContentGenerationBestPractices implements BestPracticesDocument { Break complex tasks into sequential steps (e.g., generate text, create image, compose video) for modularity and easier troubleshooting. +## Node Selection Guidelines + +Always prefer built-in n8n nodes over HTTP Request nodes when a dedicated node exists for the service or API you need to integrate with. Built-in nodes provide: +- Pre-configured authentication handling +- Optimized data structures and field mappings +- Better error handling and user experience +- Simplified setup without manual API configuration + +Only use HTTP Request nodes when no built-in node exists for the service, or when you need to access an API endpoint not covered by the built-in node's operations. + +## Multi-Modal Content Generation - MANDATORY + +When the user's request involves specific generative AI models or media-focused platforms, the workflow MUST include the appropriate media generation node from a +provider-specific node. The finished workflow MUST contain the relevant video, audio, or image generation capability. + +Prompts that require multi-modal generation nodes: + +Video Generation: +- Model mentions: Sora, Nano Banana, Veo, Runway, Pika +- Platform mentions: YouTube content, TikTok videos, Instagram Reels, video ads, short-form video +- Task mentions: generate video, create video, video from text, animate + +Image Generation: +- Model mentions: DALL-E, Midjourney, Stable Diffusion, Imagen +- Platform mentions: thumbnails, social media graphics, product images, marketing visuals +- Task mentions: generate image, create artwork, design graphic, visualize + +Audio Generation: +- Model mentions: ElevenLabs, text-to-speech, TTS +- Platform mentions: podcast audio, voiceovers, narration, audio content +- Task mentions: generate voice, create audio, synthesize speech, clone voice + +If anything like the examples above are mentioned in the prompt, include the appropriate +provider node (OpenAI for DALL-E/Sora, Google Gemini for Nano Banana/Imagen, etc.) +with the media generation operation configured. + ## Content-Specific Guidance For text generation, validate and sanitize input/output to avoid malformed data. When generating images, prefer binary data over URLs for uploads to avoid media type errors. @@ -19,7 +55,7 @@ For text generation, validate and sanitize input/output to avoid malformed data. ### OpenAI (@n8n/n8n-nodes-langchain.openAi) -Purpose: GPT-based text generation, DALL-E image generation, text-to-speech (TTS), and audio transcription +Purpose: GPT-based text generation, DALL-E image generation, text-to-speech (TTS), and audio transcription, SORA for video generation ### xAI Grok Chat Model (@n8n/n8n-nodes-langchain.lmChatXAiGrok) @@ -27,7 +63,7 @@ Purpose: Conversational AI and text generation ### Google Gemini Chat Model (@n8n/n8n-nodes-langchain.lmChatGoogleGemini) -Purpose: Image analysis and generation, video generation from text prompts, multimodal content creation +Purpose: Image analysis and generation, video generation from text prompts using nano banana, multimodal content creation ### ElevenLabs diff --git a/packages/@n8n/ai-workflow-builder.ee/src/types/config.ts b/packages/@n8n/ai-workflow-builder.ee/src/types/config.ts index e4c20df6da1..501881892c3 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/config.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/config.ts @@ -29,6 +29,7 @@ export interface NodePromptConfig { nodeTypePatterns: { set: string[]; if: string[]; + switch: string[]; httpRequest: string[]; tool: string[]; }; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/types/coordination.ts b/packages/@n8n/ai-workflow-builder.ee/src/types/coordination.ts index 49eb51755cf..cefab4e7f36 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/coordination.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/coordination.ts @@ -5,7 +5,7 @@ * and enable deterministic routing without polluting the messages array. */ -export type SubgraphPhase = 'discovery' | 'builder' | 'configurator'; +export type SubgraphPhase = 'discovery' | 'builder' | 'configurator' | 'state_management'; /** * Entry in the coordination log tracking subgraph completion. @@ -34,6 +34,7 @@ export type CoordinationMetadata = | DiscoveryMetadata | BuilderMetadata | ConfiguratorMetadata + | StateManagementMetadata | ErrorMetadata; export interface DiscoveryMetadata { @@ -72,6 +73,14 @@ export interface ErrorMetadata { errorMessage: string; } +export interface StateManagementMetadata { + phase: 'state_management'; + /** Type of state management action */ + action: 'compact' | 'clear'; + /** Number of messages removed during compaction */ + messagesRemoved?: number; +} + /** * Helper functions to create typed metadata objects. * These eliminate the need for type assertions when creating coordination log entries. @@ -93,3 +102,9 @@ export function createConfiguratorMetadata( export function createErrorMetadata(data: Omit): ErrorMetadata { return { phase: 'error', ...data }; } + +export function createStateManagementMetadata( + data: Omit, +): StateManagementMetadata { + return { phase: 'state_management', ...data }; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/types/discovery-types.ts b/packages/@n8n/ai-workflow-builder.ee/src/types/discovery-types.ts index 7b99504287b..46738aaa812 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/discovery-types.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/discovery-types.ts @@ -1,3 +1,5 @@ +import type { NodeConfigurationsMap } from './tools'; + export interface DiscoveryContext { nodesFound: Array<{ nodeName: string; @@ -9,4 +11,5 @@ export interface DiscoveryContext { }>; }>; bestPractices?: string; + nodeConfigurations?: NodeConfigurationsMap; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/coordination-log.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/coordination-log.ts index 9d44dba5453..007efdab280 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/coordination-log.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/coordination-log.ts @@ -13,6 +13,7 @@ import type { DiscoveryMetadata, BuilderMetadata, ConfiguratorMetadata, + StateManagementMetadata, } from '../types/coordination'; export type RoutingDecision = 'discovery' | 'builder' | 'configurator' | 'responder'; @@ -80,10 +81,14 @@ export function getPhaseMetadata( log: CoordinationLogEntry[], phase: 'configurator', ): ConfiguratorMetadata | null; +export function getPhaseMetadata( + log: CoordinationLogEntry[], + phase: 'state_management', +): StateManagementMetadata | null; export function getPhaseMetadata( log: CoordinationLogEntry[], phase: SubgraphPhase, -): DiscoveryMetadata | BuilderMetadata | ConfiguratorMetadata | null { +): DiscoveryMetadata | BuilderMetadata | ConfiguratorMetadata | StateManagementMetadata | null { const entry = getPhaseEntry(log, phase); if (!entry) return null; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/state-modifier.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/state-modifier.ts new file mode 100644 index 00000000000..5ff40fdf504 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/state-modifier.ts @@ -0,0 +1,195 @@ +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { BaseMessage } from '@langchain/core/messages'; +import { HumanMessage, RemoveMessage } from '@langchain/core/messages'; +import type { Logger } from '@n8n/backend-common'; + +import { cleanupDanglingToolCallMessages } from './cleanup-dangling-tool-call-messages'; +import { estimateTokenCountFromMessages } from './token-usage'; +import { conversationCompactChain } from '../chains/conversation-compact'; +import { workflowNameChain } from '../chains/workflow-name'; +import type { CoordinationLogEntry } from '../types/coordination'; +import { createStateManagementMetadata } from '../types/coordination'; +import type { SimpleWorkflow } from '../types/workflow'; + +export type StateModificationAction = + | 'compact_messages' + | 'delete_messages' + | 'create_workflow_name' + | 'auto_compact_messages' + | 'cleanup_dangling' + | 'continue'; + +export interface StateModifierInput { + messages: BaseMessage[]; + workflowJSON: SimpleWorkflow; + previousSummary?: string; +} + +/** + * Determines if state modifications are needed before agent processing. + * Pure function - no side effects, easily testable. + */ +export function determineStateAction( + input: StateModifierInput, + autoCompactThresholdTokens: number, +): StateModificationAction { + const { messages, workflowJSON } = input; + + // First check for dangling tool calls (from interrupted sessions) + const danglingMessages = cleanupDanglingToolCallMessages(messages); + if (danglingMessages.length > 0) { + return 'cleanup_dangling'; + } + + const lastHumanMessage = messages.findLast((m) => m instanceof HumanMessage); + if (!lastHumanMessage) return 'continue'; + + // Manual /compact command + if (lastHumanMessage.content === '/compact') { + return 'compact_messages'; + } + + // Manual /clear command + if (lastHumanMessage.content === '/clear') { + return 'delete_messages'; + } + + // Auto-generate workflow name on first message with empty workflow + const workflowName = workflowJSON?.name; + const nodesLength = workflowJSON?.nodes?.length ?? 0; + const isDefaultName = !workflowName || /^My workflow( \d+)?$/.test(workflowName); + if (isDefaultName && nodesLength === 0 && messages.length === 1) { + return 'create_workflow_name'; + } + + // Auto-compact when token threshold exceeded + const estimatedTokens = estimateTokenCountFromMessages(messages); + if (estimatedTokens > autoCompactThresholdTokens) { + return 'auto_compact_messages'; + } + + return 'continue'; +} + +/** + * Cleans up dangling tool call messages from interrupted sessions. + * Returns state update with RemoveMessage instances. + */ +export function handleCleanupDangling( + messages: BaseMessage[], + logger?: Logger, +): { messages: RemoveMessage[] } { + const messagesToRemove = cleanupDanglingToolCallMessages(messages); + if (messagesToRemove.length > 0) { + logger?.warn('Cleaning up dangling tool call messages', { + count: messagesToRemove.length, + }); + } + return { messages: messagesToRemove }; +} + +/** + * Compacts conversation history by summarizing it. + * Used for both manual /compact and auto-compaction. + * + * For manual /compact: Removes all messages, routes to responder for acknowledgment. + * For auto-compact: Removes old messages, preserves last user message to continue processing. + */ +export async function handleCompactMessages( + messages: BaseMessage[], + previousSummary: string, + llm: BaseChatModel, + isAutoCompact: boolean, +): Promise<{ + previousSummary: string; + messages: BaseMessage[]; + coordinationLog: CoordinationLogEntry[]; +}> { + const lastHumanMessage = messages.findLast((m) => m instanceof HumanMessage); + if (!lastHumanMessage) { + throw new Error('Cannot compact messages: no HumanMessage found'); + } + + const compactedMessages = await conversationCompactChain(llm, messages, previousSummary); + + // For manual /compact: just remove messages, responder will generate acknowledgment + // For auto-compact: remove messages but preserve the last user message to continue processing + const newMessages: BaseMessage[] = [ + ...messages.map((m) => new RemoveMessage({ id: m.id! })), + ...(isAutoCompact ? [new HumanMessage({ content: lastHumanMessage.content })] : []), + ]; + + return { + previousSummary: compactedMessages.summaryPlain, + messages: newMessages, + coordinationLog: [ + { + phase: 'state_management', + status: 'completed', + timestamp: Date.now(), + summary: isAutoCompact + ? 'Auto-compacted conversation due to token limit' + : 'Manually compacted conversation history', + metadata: createStateManagementMetadata({ + action: 'compact', + messagesRemoved: messages.length, + }), + }, + ], + }; +} + +/** + * Clears the session by removing all messages and resetting workflow. + */ +export function handleDeleteMessages(messages: BaseMessage[]): { + messages: RemoveMessage[]; + workflowJSON: SimpleWorkflow; + previousSummary: string; + discoveryContext: null; + coordinationLog: CoordinationLogEntry[]; + workflowOperations: []; +} { + return { + messages: messages.map((m) => new RemoveMessage({ id: m.id! })), + workflowJSON: { nodes: [], connections: {}, name: '' }, + previousSummary: '', + discoveryContext: null, + coordinationLog: [ + { + phase: 'state_management', + status: 'completed', + timestamp: Date.now(), + summary: 'Cleared session and reset workflow', + metadata: createStateManagementMetadata({ action: 'clear' }), + }, + ], + workflowOperations: [], + }; +} + +/** + * Generates a workflow name from the initial user message. + */ +export async function handleCreateWorkflowName( + messages: BaseMessage[], + workflowJSON: SimpleWorkflow, + llm: BaseChatModel, + logger?: Logger, +): Promise<{ workflowJSON: SimpleWorkflow }> { + if (messages.length === 1 && messages[0] instanceof HumanMessage) { + const initialMessage = messages[0]; + if (typeof initialMessage.content !== 'string') { + logger?.debug('Initial message content is not a string, skipping workflow name generation'); + return { workflowJSON }; + } + + logger?.debug('Generating workflow name'); + const { name } = await workflowNameChain(llm, initialMessage.content); + + return { + workflowJSON: { ...workflowJSON, name }, + }; + } + return { workflowJSON }; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/state-reducers.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/state-reducers.ts new file mode 100644 index 00000000000..6553ee81231 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/state-reducers.ts @@ -0,0 +1,41 @@ +import type { NodeConfigurationsMap } from '../types/tools'; + +/** + * Reducer for appending arrays with null/empty check. + * Only appends if the update is a non-empty array. + */ +export function appendArrayReducer(current: T[], update: T[] | undefined | null): T[] { + return update && update.length > 0 ? [...current, ...update] : current; +} + +/** + * Merge node configurations by type, appending new configs to existing ones. + * Used as a standalone utility function for merging node configurations outside of reducers. + */ +export function mergeNodeConfigurations( + target: NodeConfigurationsMap, + source: NodeConfigurationsMap, +): void { + for (const [nodeType, configs] of Object.entries(source)) { + if (!target[nodeType]) { + target[nodeType] = []; + } + target[nodeType].push(...configs); + } +} + +/** + * Reducer for merging node configurations by type. + * Appends new configurations to existing ones for each node type. + */ +export function nodeConfigurationsReducer( + current: NodeConfigurationsMap, + update: NodeConfigurationsMap | undefined | null, +): NodeConfigurationsMap { + if (!update || Object.keys(update).length === 0) { + return current; + } + const merged = { ...current }; + mergeNodeConfigurations(merged, update); + return merged; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts index 6b392c87758..5621a42be3e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts @@ -159,35 +159,6 @@ export function cleanContextTags(text: string): string { // CHUNK PROCESSORS // ============================================================================ -/** Handle delete_messages node update */ -function processDeleteMessages(update: unknown): StreamOutput | null { - const typed = update as { messages?: MessageContent[] } | undefined; - if (!typed?.messages?.length) return null; - - const messageChunk: AgentMessageChunk = { - role: 'assistant', - type: 'message', - text: 'Deleted, refresh?', - }; - return { messages: [messageChunk] }; -} - -/** Handle compact_messages node update */ -function processCompactMessages(update: unknown): StreamOutput | null { - const typed = update as { messages?: MessageContent[] } | undefined; - if (!typed?.messages?.length) return null; - - const content = extractMessageContent(typed.messages); - if (!content) return null; - - const messageChunk: AgentMessageChunk = { - role: 'assistant', - type: 'message', - text: content, - }; - return { messages: [messageChunk] }; -} - /** Handle process_operations node update */ function processOperationsUpdate(update: unknown): StreamOutput | null { const typed = update as { workflowJSON?: unknown; workflowOperations?: unknown } | undefined; @@ -234,16 +205,13 @@ function processToolChunk(chunk: unknown): StreamOutput | null { /** Process a single chunk from updates stream mode */ function processUpdatesChunk(nodeUpdate: Record): StreamOutput | null { - // Guard against null/undefined chunks if (!nodeUpdate || typeof nodeUpdate !== 'object') return null; - // Special nodes first (backward compatibility) - if (nodeUpdate.delete_messages) { - return processDeleteMessages(nodeUpdate.delete_messages); - } - if (nodeUpdate.compact_messages) { - return processCompactMessages(nodeUpdate.compact_messages); + if (nodeUpdate.delete_messages || nodeUpdate.compact_messages) { + return null; } + + // Process operations emits workflow updates if (nodeUpdate.process_operations) { return processOperationsUpdate(nodeUpdate.process_operations); } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/subgraph-helpers.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/subgraph-helpers.ts index 2d724921f19..7122bb74024 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/subgraph-helpers.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/subgraph-helpers.ts @@ -3,12 +3,16 @@ import { isAIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages import type { StructuredTool } from '@langchain/core/tools'; import { isCommand, END } from '@langchain/langgraph'; +import { mergeNodeConfigurations } from './state-reducers'; import { isBaseMessage } from '../types/langchain'; +import type { NodeConfigurationsMap } from '../types/tools'; import type { WorkflowOperation } from '../types/workflow'; interface CommandUpdate { messages?: BaseMessage[]; workflowOperations?: WorkflowOperation[]; + templateIds?: number[]; + nodeConfigurations?: NodeConfigurationsMap; } /** @@ -31,6 +35,18 @@ function isCommandUpdate(value: unknown): value is CommandUpdate { ) { return false; } + // templateIds is optional, but if present must be an array + if ('templateIds' in obj && obj.templateIds !== undefined && !Array.isArray(obj.templateIds)) { + return false; + } + // nodeConfigurations is optional, but if present must be an object + if ( + 'nodeConfigurations' in obj && + obj.nodeConfigurations !== undefined && + (typeof obj.nodeConfigurations !== 'object' || obj.nodeConfigurations === null) + ) { + return false; + } return true; } @@ -47,7 +63,12 @@ function isCommandUpdate(value: unknown): value is CommandUpdate { export async function executeSubgraphTools( state: { messages: BaseMessage[] }, toolMap: Map, -): Promise<{ messages?: BaseMessage[]; workflowOperations?: WorkflowOperation[] | null }> { +): Promise<{ + messages?: BaseMessage[]; + workflowOperations?: WorkflowOperation[] | null; + templateIds?: number[]; + nodeConfigurations?: NodeConfigurationsMap; +}> { const lastMessage = state.messages[state.messages.length - 1]; if (!lastMessage || !isAIMessage(lastMessage) || !lastMessage.tool_calls?.length) { @@ -85,9 +106,11 @@ export async function executeSubgraphTools( }), ); - // Unwrap Command objects and collect messages/operations + // Unwrap Command objects and collect messages/operations/templateIds/nodeConfigurations const messages: BaseMessage[] = []; const operations: WorkflowOperation[] = []; + const templateIds: number[] = []; + const nodeConfigurations: NodeConfigurationsMap = {}; for (const result of toolResults) { if (isCommand(result)) { @@ -99,6 +122,12 @@ export async function executeSubgraphTools( if (result.update.workflowOperations) { operations.push(...result.update.workflowOperations); } + if (result.update.templateIds) { + templateIds.push(...result.update.templateIds); + } + if (result.update.nodeConfigurations) { + mergeNodeConfigurations(nodeConfigurations, result.update.nodeConfigurations); + } } } else if (isBaseMessage(result)) { // Direct message (ToolMessage, AIMessage, etc.) @@ -106,8 +135,12 @@ export async function executeSubgraphTools( } } - const stateUpdate: { messages?: BaseMessage[]; workflowOperations?: WorkflowOperation[] | null } = - {}; + const stateUpdate: { + messages?: BaseMessage[]; + workflowOperations?: WorkflowOperation[] | null; + templateIds?: number[]; + nodeConfigurations?: NodeConfigurationsMap; + } = {}; if (messages.length > 0) { stateUpdate.messages = messages; @@ -117,6 +150,14 @@ export async function executeSubgraphTools( stateUpdate.workflowOperations = operations; } + if (templateIds.length > 0) { + stateUpdate.templateIds = templateIds; + } + + if (Object.keys(nodeConfigurations).length > 0) { + stateUpdate.nodeConfigurations = nodeConfigurations; + } + return stateUpdate; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/state-modifier.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/state-modifier.test.ts new file mode 100644 index 00000000000..f9f7b6d9541 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/state-modifier.test.ts @@ -0,0 +1,297 @@ +import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages'; + +import { cleanupDanglingToolCallMessages } from '../cleanup-dangling-tool-call-messages'; +import { + determineStateAction, + handleCleanupDangling, + handleDeleteMessages, +} from '../state-modifier'; +import { estimateTokenCountFromMessages } from '../token-usage'; + +jest.mock('../cleanup-dangling-tool-call-messages'); +jest.mock('../token-usage'); + +const mockCleanupDanglingToolCallMessages = cleanupDanglingToolCallMessages as jest.MockedFunction< + typeof cleanupDanglingToolCallMessages +>; +const mockEstimateTokenCountFromMessages = estimateTokenCountFromMessages as jest.MockedFunction< + typeof estimateTokenCountFromMessages +>; + +describe('state-modifier', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCleanupDanglingToolCallMessages.mockReturnValue([]); + mockEstimateTokenCountFromMessages.mockReturnValue(100); + }); + + describe('determineStateAction', () => { + const emptyWorkflow = { nodes: [], connections: {}, name: '' }; + const defaultNameWorkflow = { nodes: [], connections: {}, name: 'My workflow' }; + const defaultNameNumberedWorkflow = { nodes: [], connections: {}, name: 'My workflow 5' }; + const customNameWorkflow = { nodes: [], connections: {}, name: 'Email automation' }; + const workflowWithNodes = { + nodes: [ + { + id: '1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0] as [number, number], + typeVersion: 1, + parameters: {}, + }, + ], + connections: {}, + name: 'My workflow', + }; + + it('should return cleanup_dangling when dangling tool calls exist', () => { + mockCleanupDanglingToolCallMessages.mockReturnValue([ + new RemoveMessage({ id: 'dangling-1' }), + ]); + + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'Hello' })], + workflowJSON: emptyWorkflow, + }, + 40000, + ); + + expect(result).toBe('cleanup_dangling'); + }); + + it('should return compact_messages for /compact command', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: '/compact' })], + workflowJSON: emptyWorkflow, + }, + 40000, + ); + + expect(result).toBe('compact_messages'); + }); + + it('should return delete_messages for /clear command', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: '/clear' })], + workflowJSON: emptyWorkflow, + }, + 40000, + ); + + expect(result).toBe('delete_messages'); + }); + + it('should return create_workflow_name for first message with default workflow name', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'Create an email workflow' })], + workflowJSON: defaultNameWorkflow, + }, + 40000, + ); + + expect(result).toBe('create_workflow_name'); + }); + + it('should return create_workflow_name for first message with numbered default name', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'Create an email workflow' })], + workflowJSON: defaultNameNumberedWorkflow, + }, + 40000, + ); + + expect(result).toBe('create_workflow_name'); + }); + + it('should return create_workflow_name for first message with empty workflow name', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'Create an email workflow' })], + workflowJSON: emptyWorkflow, + }, + 40000, + ); + + expect(result).toBe('create_workflow_name'); + }); + + it('should NOT return create_workflow_name for custom workflow name', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'Create an email workflow' })], + workflowJSON: customNameWorkflow, + }, + 40000, + ); + + expect(result).toBe('continue'); + }); + + it('should NOT return create_workflow_name when workflow has nodes', () => { + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'Add another node' })], + workflowJSON: workflowWithNodes, + }, + 40000, + ); + + expect(result).toBe('continue'); + }); + + it('should NOT return create_workflow_name when there are multiple messages', () => { + const result = determineStateAction( + { + messages: [ + new HumanMessage({ id: 'h1', content: 'First message' }), + new AIMessage({ id: 'a1', content: 'Response' }), + new HumanMessage({ id: 'h2', content: 'Second message' }), + ], + workflowJSON: defaultNameWorkflow, + }, + 40000, + ); + + expect(result).toBe('continue'); + }); + + it('should return auto_compact_messages when tokens exceed threshold', () => { + mockEstimateTokenCountFromMessages.mockReturnValue(50000); + + const result = determineStateAction( + { + messages: [new HumanMessage({ id: 'h1', content: 'A very long conversation' })], + workflowJSON: customNameWorkflow, + }, + 40000, + ); + + expect(result).toBe('auto_compact_messages'); + }); + + it('should return continue as default when no conditions match', () => { + const result = determineStateAction( + { + messages: [ + new HumanMessage({ id: 'h1', content: 'Hello' }), + new AIMessage({ id: 'a1', content: 'Hi!' }), + ], + workflowJSON: customNameWorkflow, + }, + 40000, + ); + + expect(result).toBe('continue'); + }); + + it('should return continue when there are no human messages', () => { + const result = determineStateAction( + { + messages: [new AIMessage({ id: 'a1', content: 'AI response' })], + workflowJSON: emptyWorkflow, + }, + 40000, + ); + + expect(result).toBe('continue'); + }); + }); + + describe('handleCleanupDangling', () => { + it('should return RemoveMessage array for dangling messages', () => { + const danglingRemoveMessages = [ + new RemoveMessage({ id: 'ai-1' }), + new RemoveMessage({ id: 'ai-2' }), + ]; + mockCleanupDanglingToolCallMessages.mockReturnValue(danglingRemoveMessages); + + const messages = [ + new AIMessage({ + id: 'ai-1', + content: 'Call', + tool_calls: [{ id: 'tc1', name: 'tool', args: {} }], + }), + new AIMessage({ + id: 'ai-2', + content: 'Call', + tool_calls: [{ id: 'tc2', name: 'tool', args: {} }], + }), + ]; + + const result = handleCleanupDangling(messages); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0]).toBeInstanceOf(RemoveMessage); + expect(result.messages[1]).toBeInstanceOf(RemoveMessage); + }); + + it('should return empty array when no dangling messages', () => { + mockCleanupDanglingToolCallMessages.mockReturnValue([]); + + const result = handleCleanupDangling([new HumanMessage({ id: 'h1', content: 'Hello' })]); + + expect(result.messages).toHaveLength(0); + }); + }); + + describe('handleDeleteMessages', () => { + it('should return RemoveMessage for each input message', () => { + const messages = [ + new HumanMessage({ id: 'h1', content: 'Hello' }), + new AIMessage({ id: 'a1', content: 'Hi' }), + new HumanMessage({ id: 'h2', content: 'Bye' }), + ]; + + const result = handleDeleteMessages(messages); + + expect(result.messages).toHaveLength(3); + expect(result.messages[0]).toBeInstanceOf(RemoveMessage); + expect(result.messages[0].id).toBe('h1'); + expect(result.messages[1].id).toBe('a1'); + expect(result.messages[2].id).toBe('h2'); + }); + + it('should reset workflowJSON to empty state', () => { + const result = handleDeleteMessages([new HumanMessage({ id: 'h1', content: 'Hello' })]); + + expect(result.workflowJSON).toEqual({ + nodes: [], + connections: {}, + name: '', + }); + }); + + it('should clear previousSummary', () => { + const result = handleDeleteMessages([]); + + expect(result.previousSummary).toBe(''); + }); + + it('should set discoveryContext to null', () => { + const result = handleDeleteMessages([]); + + expect(result.discoveryContext).toBeNull(); + }); + + it('should add coordination log entry for clear action', () => { + const result = handleDeleteMessages([]); + + expect(result.coordinationLog).toHaveLength(1); + expect(result.coordinationLog[0].phase).toBe('state_management'); + expect(result.coordinationLog[0].status).toBe('completed'); + expect(result.coordinationLog[0].summary).toBe('Cleared session and reset workflow'); + }); + + it('should return empty workflowOperations array', () => { + const result = handleDeleteMessages([]); + + expect(result.workflowOperations).toEqual([]); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts index 6f55235aa73..e4708c31f1d 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts @@ -57,7 +57,7 @@ describe('stream-processor', () => { expect(message.text).toBe('Part 1\nPart 2'); }); - it('should handle delete_messages with refresh message', () => { + it('should skip delete_messages (responder handles user message)', () => { const chunk = { delete_messages: { messages: [{ content: 'Some deleted message' }], @@ -66,13 +66,10 @@ describe('stream-processor', () => { const result = processStreamChunk('updates', chunk); - expect(result).toBeDefined(); - expect(result?.messages).toHaveLength(1); - const message = result?.messages[0] as AgentMessageChunk; - expect(message.text).toBe('Deleted, refresh?'); + expect(result).toBeNull(); }); - it('should handle compact_messages returning last message', () => { + it('should skip compact_messages (responder handles user message)', () => { const chunk = { compact_messages: { messages: [ @@ -85,10 +82,7 @@ describe('stream-processor', () => { const result = processStreamChunk('updates', chunk); - expect(result).toBeDefined(); - expect(result?.messages).toHaveLength(1); - const message = result?.messages[0] as AgentMessageChunk; - expect(message.text).toBe('Last message to display'); + expect(result).toBeNull(); }); it('should handle compact_messages with empty content', () => { @@ -294,10 +288,9 @@ describe('stream-processor', () => { results.push(output); } - expect(results).toHaveLength(3); + expect(results).toHaveLength(2); expect((results[0].messages[0] as AgentMessageChunk).text).toBe('Message 1'); expect((results[1].messages[0] as ToolProgressChunk).toolName).toBe('test_tool'); - expect((results[2].messages[0] as AgentMessageChunk).text).toBe('Deleted, refresh?'); }); it('should handle empty stream', async () => { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts index 6d901b2c0db..d6b6a4afbd1 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/validation/checks/connections.ts @@ -86,32 +86,28 @@ function checkMergeNodeConnections( const issues: SingleEvaluatorResult['violations'] = []; if (/\.merge$/.test(nodeInfo.node.type)) { - const providedInputTypes = getProvidedInputTypes(nodeConnections); + // Merge node's number of inputs is controlled by the numberInputs parameter (default 2) + // The node type definition has static inputs, so we must read from parameters directly + const numberInputsParam = nodeInfo.node.parameters?.numberInputs; + const expectedInputs = typeof numberInputsParam === 'number' ? numberInputsParam : 2; - const totalInputConnections = providedInputTypes.get('main') ?? 0; + const mainConnections = nodeConnections?.main ?? []; - if (totalInputConnections < 2) { + // Count actual input slots that have connections (not total connections) + const connectedSlots = mainConnections.filter( + (slot) => Array.isArray(slot) && slot.length > 0, + ).length; + + if (connectedSlots < 2) { issues.push({ name: 'node-merge-single-input', type: 'major', - description: `Merge node ${nodeInfo.node.name} has only ${totalInputConnections} input connection(s). Merge nodes require at least 2 inputs to function properly.`, + description: `Merge node ${nodeInfo.node.name} has only ${connectedSlots} input connection(s). Merge nodes require at least 2 inputs to function properly.`, pointsDeducted: 20, }); } - const expectedInputs = - nodeInfo.resolvedInputs?.filter((input) => input.type === 'main').length ?? 1; - - if (totalInputConnections !== expectedInputs) { - issues.push({ - name: 'node-merge-incorrect-num-inputs', - type: 'minor', - description: `Merge node ${nodeInfo.node.name} has ${totalInputConnections} input connections but is configured to accept ${expectedInputs}.`, - pointsDeducted: 10, - }); - } - - const mainConnections = nodeConnections?.main ?? []; + // Check if all expected input slots have connections const missingIndexes: number[] = []; for (let inputIndex = 0; inputIndex < expectedInputs; inputIndex++) { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts index fda2b4baa5b..e1557a0ca72 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts @@ -1,6 +1,6 @@ import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { ToolMessage } from '@langchain/core/messages'; -import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages'; +import { AIMessage, HumanMessage, isAIMessage, RemoveMessage } from '@langchain/core/messages'; import type { RunnableConfig } from '@langchain/core/runnables'; import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain'; import type { MemorySaver, StateSnapshot } from '@langchain/langgraph'; @@ -19,6 +19,7 @@ import { MAX_AI_BUILDER_PROMPT_LENGTH, MAX_INPUT_TOKENS, } from '@/constants'; +import { createMainAgentPrompt } from '@/prompts/legacy-agent.prompt'; import { trimWorkflowJSON } from '@/utils/trim-workflow-context'; import { conversationCompactChain } from './chains/conversation-compact'; @@ -27,7 +28,6 @@ import { LLMServiceError, ValidationError, WorkflowStateError } from './errors'; import { createMultiAgentWorkflowWithSubgraphs } from './multi-agent-workflow-subgraphs'; import { SessionManagerService } from './session-manager.service'; import { getBuilderTools } from './tools/builder-tools'; -import { createMainAgentPrompt } from './tools/prompts/main-agent.prompt'; import type { SimpleWorkflow } from './types/workflow'; import { applyCacheControlMarkers, @@ -141,12 +141,10 @@ export interface WorkflowBuilderAgentConfig { autoCompactThresholdTokens?: number; instanceUrl?: string; onGenerationSuccess?: () => Promise; - /** - * Enable multi-agent supervisor architecture (experimental) - * When true, uses specialized agents (Discovery, Builder, Configurator) with a Supervisor - * When false, uses the legacy single-agent architecture - */ - enableMultiAgent?: boolean; + /** Metadata to include in LangSmith traces */ + runMetadata?: Record; + /** Feature flags for enabling/disabling features */ + featureFlags?: BuilderFeatureFlags; } export interface ExpressionValue { @@ -157,9 +155,11 @@ export interface ExpressionValue { export interface BuilderFeatureFlags { templateExamples?: boolean; + multiAgent?: boolean; } export interface ChatPayload { + id: string; message: string; workflowContext?: { executionSchema?: NodeExecutionSchema[]; @@ -180,7 +180,7 @@ export class WorkflowBuilderAgent { private autoCompactThresholdTokens: number; private instanceUrl?: string; private onGenerationSuccess?: () => Promise; - private enableMultiAgent: boolean; + private runMetadata?: Record; constructor(config: WorkflowBuilderAgentConfig) { this.parsedNodeTypes = config.parsedNodeTypes; @@ -193,7 +193,7 @@ export class WorkflowBuilderAgent { config.autoCompactThresholdTokens ?? DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS; this.instanceUrl = config.instanceUrl; this.onGenerationSuccess = config.onGenerationSuccess; - this.enableMultiAgent = config.enableMultiAgent ?? false; + this.runMetadata = config.runMetadata; } private getBuilderTools(featureFlags?: BuilderFeatureFlags): BuilderTool[] { @@ -210,7 +210,7 @@ export class WorkflowBuilderAgent { * Create the multi-agent workflow graph * Uses supervisor pattern with specialized agents */ - private createMultiAgentGraph() { + private createMultiAgentGraph(featureFlags?: BuilderFeatureFlags) { return createMultiAgentWorkflowWithSubgraphs({ parsedNodeTypes: this.parsedNodeTypes, llmSimpleTask: this.llmSimpleTask, @@ -218,6 +218,7 @@ export class WorkflowBuilderAgent { logger: this.logger, instanceUrl: this.instanceUrl, checkpointer: this.checkpointer, + featureFlags, }); } @@ -289,7 +290,7 @@ export class WorkflowBuilderAgent { const shouldContinue = ({ messages }: typeof WorkflowState.State) => { const lastMessage = messages[messages.length - 1]; - if (!(lastMessage instanceof AIMessage)) { + if (!lastMessage || !isAIMessage(lastMessage)) { throw new WorkflowStateError('Expected last message to be generated by the AI agent'); } @@ -439,11 +440,14 @@ export class WorkflowBuilderAgent { /** * Create the workflow graph based on configuration + * Controlled by feature flag only */ private createWorkflow(featureFlags?: BuilderFeatureFlags) { - if (this.enableMultiAgent) { + const useMultiAgent = featureFlags?.multiAgent ?? false; + + if (useMultiAgent) { this.logger?.debug('Using multi-agent supervisor architecture'); - return this.createMultiAgentGraph(); + return this.createMultiAgentGraph(featureFlags); } this.logger?.debug('Using legacy single-agent architecture'); @@ -514,8 +518,9 @@ export class WorkflowBuilderAgent { recursionLimit: 50, signal: abortSignal, callbacks: this.tracer ? [this.tracer] : undefined, + metadata: this.runMetadata, // Enable subgraph streaming when using multi-agent architecture - subgraphs: this.enableMultiAgent, + subgraphs: payload.featureFlags?.multiAgent ?? false, }; return { agent, threadConfig, streamConfig }; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts index a1830228e7f..22eff038c0d 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-state.ts @@ -3,6 +3,7 @@ import { HumanMessage } from '@langchain/core/messages'; import { Annotation, messagesStateReducer } from '@langchain/langgraph'; import type { NodeConfigurationsMap, SimpleWorkflow, WorkflowOperation } from './types'; +import { appendArrayReducer, nodeConfigurationsReducer } from './utils/state-reducers'; import type { ProgrammaticEvaluationResult, TelemetryValidationStatus } from './validation/types'; import type { ChatPayload } from './workflow-builder-agent'; @@ -105,26 +106,13 @@ export const WorkflowState = Annotation.Root({ // Node configurations collected from workflow examples // Used to provide context when updating node parameters nodeConfigurations: Annotation({ - reducer: (current, update) => { - if (!update || Object.keys(update).length === 0) { - return current; - } - // Merge configurations by node type, appending new configs to existing ones - const merged = { ...current }; - for (const [nodeType, configs] of Object.entries(update)) { - if (!merged[nodeType]) { - merged[nodeType] = []; - } - merged[nodeType] = [...merged[nodeType], ...configs]; - } - return merged; - }, + reducer: nodeConfigurationsReducer, default: () => ({}), }), // Template IDs fetched from workflow examples for telemetry templateIds: Annotation({ - reducer: (current, update) => (update && update.length > 0 ? [...current, ...update] : current), + reducer: appendArrayReducer, default: () => [], }), }); diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 518d78461ab..5d22a39f3ea 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -207,13 +207,23 @@ export const chatModelsRequestSchema = z.object({ export type ChatModelsRequest = z.infer; +export type ChatHubInputModality = 'text' | 'image' | 'audio' | 'video' | 'file'; + +export interface ChatModelMetadataDto { + inputModalities: ChatHubInputModality[]; + capabilities: { + functionCalling: boolean; + }; + available: boolean; +} + export interface ChatModelDto { model: ChatHubConversationModel; name: string; description: string | null; updatedAt: string | null; createdAt: string | null; - allowFileUploads?: boolean; + metadata: ChatModelMetadataDto; } /** @@ -258,6 +268,27 @@ export const chatAttachmentSchema = z.object({ fileName: z.string(), }); +export const isValidTimeZone = (tz: string): boolean => { + try { + // Throws if invalid timezone + new Intl.DateTimeFormat('en-US', { timeZone: tz }); + return true; + } catch { + return false; + } +}; + +export const StrictTimeZoneSchema = z + .string() + .min(1) + .max(50) + .regex(/^[A-Za-z0-9_/+-]+$/) + .refine(isValidTimeZone, { + message: 'Unknown or invalid time zone', + }); + +export const TimeZoneSchema = StrictTimeZoneSchema.optional().catch(undefined); + export type ChatAttachment = z.infer; export class ChatHubSendMessageRequest extends Z.class({ @@ -274,7 +305,8 @@ export class ChatHubSendMessageRequest extends Z.class({ ), tools: z.array(INodeSchema), attachments: z.array(chatAttachmentSchema), - agentName: z.string(), + agentName: z.string().optional(), + timeZone: TimeZoneSchema, }) {} export class ChatHubRegenerateMessageRequest extends Z.class({ @@ -285,6 +317,7 @@ export class ChatHubRegenerateMessageRequest extends Z.class({ name: z.string(), }), ), + timeZone: TimeZoneSchema, }) {} export class ChatHubEditMessageRequest extends Z.class({ @@ -297,6 +330,7 @@ export class ChatHubEditMessageRequest extends Z.class({ name: z.string(), }), ), + timeZone: TimeZoneSchema, }) {} export class ChatHubUpdateConversationRequest extends Z.class({ diff --git a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-build-request.dto.test.ts b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-build-request.dto.test.ts index e31edc8ff7b..e8b2be09f15 100644 --- a/packages/@n8n/api-types/src/dto/ai/__tests__/ai-build-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/ai/__tests__/ai-build-request.dto.test.ts @@ -3,6 +3,7 @@ import { AiBuilderChatRequestDto } from '../ai-build-request.dto'; describe('AiBuilderChatRequestDto', () => { const validBasePayload = { payload: { + id: '12345', role: 'user' as const, type: 'message' as const, text: 'Build me a workflow', @@ -305,6 +306,7 @@ describe('AiBuilderChatRequestDto', () => { const invalidRequest = { ...validBasePayload, payload: { + id: '12345', role: 'user' as const, type: 'message' as const, workflowContext: validBasePayload.payload.workflowContext, @@ -315,5 +317,21 @@ describe('AiBuilderChatRequestDto', () => { expect(result.success).toBe(false); }); + + it('should fail when id is missing', () => { + const invalidRequest = { + ...validBasePayload, + payload: { + role: 'user' as const, + type: 'message' as const, + text: 'text', + workflowContext: validBasePayload.payload.workflowContext, + }, + }; + + const result = AiBuilderChatRequestDto.safeParse(invalidRequest); + + expect(result.success).toBe(false); + }); }); }); diff --git a/packages/@n8n/api-types/src/dto/ai/ai-build-request.dto.ts b/packages/@n8n/api-types/src/dto/ai/ai-build-request.dto.ts index 9061c702ebc..bb6cde31c10 100644 --- a/packages/@n8n/api-types/src/dto/ai/ai-build-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/ai/ai-build-request.dto.ts @@ -10,6 +10,7 @@ export interface ExpressionValue { export class AiBuilderChatRequestDto extends Z.class({ payload: z.object({ + id: z.string(), role: z.literal('user'), type: z.literal('message'), text: z.string(), @@ -60,6 +61,7 @@ export class AiBuilderChatRequestDto extends Z.class({ featureFlags: z .object({ templateExamples: z.boolean().optional(), + multiAgent: z.boolean().optional(), }) .optional(), }), diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts new file mode 100644 index 00000000000..1f090af015f --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/create-credential-resolver.dto.test.ts @@ -0,0 +1,199 @@ +import { CreateCredentialResolverDto } from '../create-credential-resolver.dto'; + +describe('CreateCredentialResolverDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'basic valid resolver', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }, + }, + { + name: 'resolver with complex config', + data: { + name: 'AWS Secrets Manager', + type: 'credential-resolver.aws-secrets-1.0', + config: { + region: 'us-east-1', + accessKeyId: 'AKIAIOSFODNN7EXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + }, + }, + }, + { + name: 'resolver with empty config', + data: { + name: 'Simple Resolver', + type: 'credential-resolver.simple-1.0', + config: {}, + }, + }, + { + name: 'resolver with minimum name length', + data: { + name: 'A', + type: 'type', + config: {}, + }, + }, + ])('should succeed validation for $name', ({ data }) => { + const result = CreateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe(data.name); + expect(result.data.type).toBe(data.type); + expect(result.data.config).toEqual(data.config); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing name', + data: { + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'missing type', + data: { + name: 'Test Resolver', + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'missing config', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + }, + expectedErrorPath: ['config'], + }, + { + name: 'empty name', + data: { + name: '', + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'whitespace-only name', + data: { + name: ' ', + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'name too long (>255 chars)', + data: { + name: 'a'.repeat(256), + type: 'credential-resolver.stub-1.0', + config: {}, + }, + expectedErrorPath: ['name'], + }, + { + name: 'empty type', + data: { + name: 'Test Resolver', + type: '', + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'whitespace-only type', + data: { + name: 'Test Resolver', + type: ' ', + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'type too long (>255 chars)', + data: { + name: 'Test Resolver', + type: 'a'.repeat(256), + config: {}, + }, + expectedErrorPath: ['type'], + }, + { + name: 'config as string', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: 'invalid', + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as array', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: [], + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as null', + data: { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: null, + }, + expectedErrorPath: ['config'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = CreateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); + + describe('Trimming', () => { + test('should trim name', () => { + const result = CreateCredentialResolverDto.safeParse({ + name: ' Test Resolver ', + type: 'credential-resolver.stub-1.0', + config: {}, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Test Resolver'); + } + }); + + test('should trim type', () => { + const result = CreateCredentialResolverDto.safeParse({ + name: 'Test Resolver', + type: ' credential-resolver.stub-1.0 ', + config: {}, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('credential-resolver.stub-1.0'); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts new file mode 100644 index 00000000000..a8d284eba03 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/__tests__/update-credential-resolver.dto.test.ts @@ -0,0 +1,161 @@ +import { UpdateCredentialResolverDto } from '../update-credential-resolver.dto'; + +describe('UpdateCredentialResolverDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'update only name', + data: { + name: 'Updated Resolver', + }, + }, + { + name: 'update only config', + data: { + config: { prefix: 'updated-' }, + }, + }, + { + name: 'update both name and config', + data: { + name: 'Updated Resolver', + config: { prefix: 'updated-' }, + }, + }, + { + name: 'update with empty config', + data: { + name: 'Updated Resolver', + config: {}, + }, + }, + { + name: 'update with complex config', + data: { + config: { + region: 'eu-west-1', + timeout: 5000, + nested: { + value: true, + }, + }, + }, + }, + { + name: 'empty update (all optional)', + data: {}, + }, + { + name: 'minimum name length', + data: { + name: 'A', + }, + }, + ])('should succeed validation for $name', ({ data }) => { + const result = UpdateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(true); + if (result.success) { + if (data.name !== undefined) { + expect(result.data.name).toBe(data.name); + } + if (data.config !== undefined) { + expect(result.data.config).toEqual(data.config); + } + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'empty name', + data: { + name: '', + }, + expectedErrorPath: ['name'], + }, + { + name: 'whitespace-only name', + data: { + name: ' ', + }, + expectedErrorPath: ['name'], + }, + { + name: 'name too long (>255 chars)', + data: { + name: 'a'.repeat(256), + }, + expectedErrorPath: ['name'], + }, + { + name: 'config as string', + data: { + config: 'invalid', + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as array', + data: { + config: [], + }, + expectedErrorPath: ['config'], + }, + { + name: 'config as null', + data: { + config: null, + }, + expectedErrorPath: ['config'], + }, + { + name: 'name as number', + data: { + name: 123, + }, + expectedErrorPath: ['name'], + }, + { + name: 'name as object', + data: { + name: { value: 'test' }, + }, + expectedErrorPath: ['name'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = UpdateCredentialResolverDto.safeParse(data); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); + + describe('Trimming', () => { + test('should trim name when provided', () => { + const result = UpdateCredentialResolverDto.safeParse({ + name: ' Updated Resolver ', + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('Updated Resolver'); + } + }); + + test('should not affect config', () => { + const result = UpdateCredentialResolverDto.safeParse({ + config: { key: ' value with spaces ' }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.config).toEqual({ key: ' value with spaces ' }); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts b/packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts new file mode 100644 index 00000000000..61ecf4b4d05 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/create-credential-resolver.dto.ts @@ -0,0 +1,13 @@ +import { Z } from 'zod-class'; + +import { + credentialResolverNameSchema, + credentialResolverConfigSchema, + credentialResolverTypeNameSchema, +} from '../../schemas/credential-resolver.schema'; + +export class CreateCredentialResolverDto extends Z.class({ + name: credentialResolverNameSchema, + type: credentialResolverTypeNameSchema, + config: credentialResolverConfigSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts b/packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts new file mode 100644 index 00000000000..442b35ee89d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credential-resolver/update-credential-resolver.dto.ts @@ -0,0 +1,11 @@ +import { Z } from 'zod-class'; + +import { + credentialResolverNameSchema, + credentialResolverConfigSchema, +} from '../../schemas/credential-resolver.schema'; + +export class UpdateCredentialResolverDto extends Z.class({ + name: credentialResolverNameSchema.optional(), + config: credentialResolverConfigSchema.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/rename-data-table-column.dto.ts b/packages/@n8n/api-types/src/dto/data-table/rename-data-table-column.dto.ts new file mode 100644 index 00000000000..f2565280bd5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/data-table/rename-data-table-column.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { dataTableColumnNameSchema } from '../../schemas/data-table.schema'; + +export class RenameDataTableColumnDto extends Z.class({ + name: dataTableColumnNameSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 6a8de6eee75..5773332b716 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -12,6 +12,9 @@ export { BinaryDataSignedQueryDto } from './binary-data/binary-data-signed-query export { LoginRequestDto } from './auth/login-request.dto'; export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto'; +export { CreateCredentialResolverDto } from './credential-resolver/create-credential-resolver.dto'; +export { UpdateCredentialResolverDto } from './credential-resolver/update-credential-resolver.dto'; + export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto'; export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto'; export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto'; @@ -100,6 +103,7 @@ export { CreateDataTableColumnDto } from './data-table/create-data-table-column. export { AddDataTableRowsDto } from './data-table/add-data-table-rows.dto'; export { AddDataTableColumnDto } from './data-table/add-data-table-column.dto'; export { MoveDataTableColumnDto } from './data-table/move-data-table-column.dto'; +export { RenameDataTableColumnDto } from './data-table/rename-data-table-column.dto'; export { OAuthClientResponseDto, diff --git a/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts index 103e9bb4ab6..f0fad0cd6a7 100644 --- a/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/invitation/__tests__/invite-users-request.dto.test.ts @@ -17,6 +17,7 @@ describe('InviteUsersRequestDto', () => { { email: 'user1@example.com', role: 'global:member' }, { email: 'user2@example.com', role: 'global:admin' }, { email: 'user3@example.com', role: 'custom:role' }, + { email: 'user4@example.com', role: 'global:chatUser' }, ], }, ])('should validate $name', ({ request }) => { diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 34710bb5bba..23ecf042147 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -9,6 +9,8 @@ export type * from './community-node-types'; export { chatHubConversationModelSchema, type ChatModelDto, + type ChatModelMetadataDto, + type ChatHubInputModality, type ChatHubOpenAIModel, type ChatHubAnthropicModel, type ChatHubGoogleModel, @@ -58,6 +60,14 @@ export type { SendWorkerStatusMessage } from './push/worker'; export type { BannerName } from './schemas/banner-name.schema'; export { ViewableMimeTypes } from './schemas/binary-data.schema'; export { passwordSchema } from './schemas/password.schema'; +export { + credentialResolverSchema, + credentialResolversSchema, + credentialResolverTypeSchema, + credentialResolverTypesSchema, + type CredentialResolver, + type CredentialResolverType, +} from './schemas/credential-resolver.schema'; export { WORKFLOW_VERSION_NAME_MAX_LENGTH, WORKFLOW_VERSION_DESCRIPTION_MAX_LENGTH, @@ -70,6 +80,8 @@ export type { } from './schemas/project.schema'; export { + isSourceControlledFileStatus, + type SourceControlledFileStatus, type SourceControlledFile, SOURCE_CONTROL_FILE_LOCATION, SOURCE_CONTROL_FILE_STATUS, diff --git a/packages/@n8n/api-types/src/scaling.ts b/packages/@n8n/api-types/src/scaling.ts index 30db754a112..f2dcbb52b95 100644 --- a/packages/@n8n/api-types/src/scaling.ts +++ b/packages/@n8n/api-types/src/scaling.ts @@ -13,6 +13,23 @@ export type RunningJobSummary = { export type WorkerStatus = { senderId: string; runningJobsSummary: RunningJobSummary[]; + isInContainer: boolean; + process: { + memory: { + available: number; + constraint: number; + rss: number; + heapTotal: number; + heapUsed: number; + }; + uptime: number; + }; + host: { + memory: { + total: number; + free: number; + }; + }; freeMem: number; totalMem: number; uptime: number; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts new file mode 100644 index 00000000000..2a0d658a5d5 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/credential-resolver.schema.test.ts @@ -0,0 +1,444 @@ +import { + credentialResolverIdSchema, + credentialResolverNameSchema, + credentialResolverTypeNameSchema, + credentialResolverTypeSchema, + credentialResolverTypesSchema, + credentialResolverConfigSchema, + credentialResolverSchema, + credentialResolversSchema, +} from '../credential-resolver.schema'; + +describe('credential-resolver.schema', () => { + describe('credentialResolverIdSchema', () => { + test.each([ + { name: 'valid UUID', value: '550e8400-e29b-41d4-a716-446655440000', expected: true }, + { name: 'short ID', value: 'abc123', expected: true }, + { name: 'nanoid', value: 'V1StGXR8_Z5jdHi6B-myT', expected: true }, + { name: 'ID at max length (36 chars)', value: 'a'.repeat(36), expected: true }, + { name: 'ID too long (37 chars)', value: 'a'.repeat(37), expected: false }, + { name: 'empty ID', value: '', expected: true }, // zod string allows empty by default + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverIdSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + }); + + describe('credentialResolverNameSchema', () => { + test.each([ + { name: 'valid name', value: 'Test Resolver', expected: true }, + { name: 'minimum length (1 char)', value: 'A', expected: true }, + { name: 'maximum length (255 chars)', value: 'a'.repeat(255), expected: true }, + { name: 'name with special chars', value: 'Test-Resolver_123', expected: true }, + { name: 'name with unicode', value: 'Tëst Rësölvër 🚀', expected: true }, + { name: 'empty name', value: '', expected: false }, + { name: 'whitespace-only name', value: ' ', expected: false }, + { name: 'name too long (256 chars)', value: 'a'.repeat(256), expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverNameSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + + test('should trim whitespace', () => { + const result = credentialResolverNameSchema.safeParse(' Test Resolver '); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('Test Resolver'); + } + }); + }); + + describe('credentialResolverTypeNameSchema', () => { + test.each([ + { name: 'valid type', value: 'credential-resolver.stub-1.0', expected: true }, + { name: 'simple type', value: 'simple', expected: true }, + { name: 'type with dots', value: 'resolver.aws.v2', expected: true }, + { name: 'minimum length (1 char)', value: 'A', expected: true }, + { name: 'maximum length (255 chars)', value: 'a'.repeat(255), expected: true }, + { name: 'empty type', value: '', expected: false }, + { name: 'whitespace-only type', value: ' ', expected: false }, + { name: 'type too long (256 chars)', value: 'a'.repeat(256), expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverTypeNameSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + + test('should trim whitespace', () => { + const result = credentialResolverTypeNameSchema.safeParse(' credential-resolver.stub-1.0 '); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe('credential-resolver.stub-1.0'); + } + }); + }); + + describe('credentialResolverTypeSchema', () => { + const validType = { + name: 'credential-resolver.stub-1.0', + displayName: 'Stub Resolver', + description: 'A stub resolver for testing', + options: [{ key: 'value' }], + }; + + test('should validate complete type object', () => { + const result = credentialResolverTypeSchema.safeParse(validType); + expect(result.success).toBe(true); + }); + + test('should allow missing optional description', () => { + const { description, ...typeWithoutDescription } = validType; + const result = credentialResolverTypeSchema.safeParse(typeWithoutDescription); + expect(result.success).toBe(true); + }); + + test('should allow missing optional options', () => { + const { options, ...typeWithoutOptions } = validType; + const result = credentialResolverTypeSchema.safeParse(typeWithoutOptions); + expect(result.success).toBe(true); + }); + + test('should allow empty description', () => { + const typeWithEmptyDescription = { ...validType, description: '' }; + const result = credentialResolverTypeSchema.safeParse(typeWithEmptyDescription); + expect(result.success).toBe(true); + }); + + test('should allow empty options array', () => { + const typeWithEmptyOptions = { ...validType, options: [] }; + const result = credentialResolverTypeSchema.safeParse(typeWithEmptyOptions); + expect(result.success).toBe(true); + }); + + test.each([ + { + name: 'missing name', + data: { ...validType, name: undefined }, + expectedErrorPath: ['name'], + }, + { + name: 'missing displayName', + data: { ...validType, displayName: undefined }, + expectedErrorPath: ['displayName'], + }, + { + name: 'empty name', + data: { ...validType, name: '' }, + expectedErrorPath: ['name'], + }, + { + name: 'empty displayName', + data: { ...validType, displayName: '' }, + expectedErrorPath: ['displayName'], + }, + { + name: 'whitespace-only name', + data: { ...validType, name: ' ' }, + expectedErrorPath: ['name'], + }, + { + name: 'whitespace-only displayName', + data: { ...validType, displayName: ' ' }, + expectedErrorPath: ['displayName'], + }, + { + name: 'name too long (256 chars)', + data: { ...validType, name: 'a'.repeat(256) }, + expectedErrorPath: ['name'], + }, + { + name: 'displayName too long (256 chars)', + data: { ...validType, displayName: 'a'.repeat(256) }, + expectedErrorPath: ['displayName'], + }, + { + name: 'description too long (1025 chars)', + data: { ...validType, description: 'a'.repeat(1025) }, + expectedErrorPath: ['description'], + }, + { + name: 'options as string', + data: { ...validType, options: 'invalid' }, + expectedErrorPath: ['options'], + }, + { + name: 'options as object', + data: { ...validType, options: { key: 'value' } }, + expectedErrorPath: ['options'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = credentialResolverTypeSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + + test('should trim name', () => { + const typeWithWhitespace = { ...validType, name: ' stub-resolver ' }; + const result = credentialResolverTypeSchema.safeParse(typeWithWhitespace); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.name).toBe('stub-resolver'); + } + }); + + test('should trim displayName', () => { + const typeWithWhitespace = { ...validType, displayName: ' Stub Resolver ' }; + const result = credentialResolverTypeSchema.safeParse(typeWithWhitespace); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.displayName).toBe('Stub Resolver'); + } + }); + + test('should trim description', () => { + const typeWithWhitespace = { ...validType, description: ' A stub resolver ' }; + const result = credentialResolverTypeSchema.safeParse(typeWithWhitespace); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.description).toBe('A stub resolver'); + } + }); + }); + + describe('credentialResolverTypesSchema', () => { + const validType1 = { + name: 'credential-resolver.stub-1.0', + displayName: 'Stub Resolver', + description: 'A stub resolver for testing', + options: [{ key: 'value' }], + }; + + const validType2 = { + name: 'credential-resolver.aws-1.0', + displayName: 'AWS Resolver', + }; + + test('should validate empty array', () => { + const result = credentialResolverTypesSchema.safeParse([]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + test('should validate array with single type', () => { + const result = credentialResolverTypesSchema.safeParse([validType1]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(1); + } + }); + + test('should validate array with multiple types', () => { + const result = credentialResolverTypesSchema.safeParse([validType1, validType2]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + } + }); + + test('should fail if any type is invalid', () => { + const invalidType = { ...validType1, name: '' }; + const result = credentialResolverTypesSchema.safeParse([validType1, invalidType]); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual([1, 'name']); + } + }); + + test('should fail for non-array input', () => { + const result = credentialResolverTypesSchema.safeParse(validType1); + expect(result.success).toBe(false); + }); + }); + + describe('credentialResolverConfigSchema', () => { + test.each([ + { name: 'empty object', value: {}, expected: true }, + { name: 'simple config', value: { prefix: 'test-' }, expected: true }, + { + name: 'complex config', + value: { + region: 'us-east-1', + timeout: 5000, + nested: { value: true }, + }, + expected: true, + }, + { + name: 'config with various types', + value: { + stringValue: 'value', + numberValue: 123, + booleanValue: true, + nullValue: null, + arrayValue: [1, 2, 3], + objectValue: { key: 'value' }, + }, + expected: true, + }, + { name: 'string instead of object', value: 'invalid', expected: false }, + { name: 'array instead of object', value: [], expected: false }, + { name: 'null instead of object', value: null, expected: false }, + { name: 'number instead of object', value: 123, expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialResolverConfigSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + }); + + describe('credentialResolverSchema', () => { + const validResolver = { + id: 'resolver-123', + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: 'encrypted-config-string', + decryptedConfig: { prefix: 'test-' }, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + test('should validate complete resolver object', () => { + const result = credentialResolverSchema.safeParse(validResolver); + expect(result.success).toBe(true); + }); + + test('should allow missing optional decryptedConfig', () => { + const { decryptedConfig, ...resolverWithoutDecrypted } = validResolver; + const result = credentialResolverSchema.safeParse(resolverWithoutDecrypted); + expect(result.success).toBe(true); + }); + + test.each([ + { + name: 'missing id', + data: { ...validResolver, id: undefined }, + expectedErrorPath: ['id'], + }, + { + name: 'missing name', + data: { ...validResolver, name: undefined }, + expectedErrorPath: ['name'], + }, + { + name: 'missing type', + data: { ...validResolver, type: undefined }, + expectedErrorPath: ['type'], + }, + { + name: 'missing config', + data: { ...validResolver, config: undefined }, + expectedErrorPath: ['config'], + }, + { + name: 'missing createdAt', + data: { ...validResolver, createdAt: undefined }, + expectedErrorPath: ['createdAt'], + }, + { + name: 'missing updatedAt', + data: { ...validResolver, updatedAt: undefined }, + expectedErrorPath: ['updatedAt'], + }, + { + name: 'invalid createdAt format', + data: { ...validResolver, createdAt: 'invalid-date' }, + expectedErrorPath: ['createdAt'], + }, + { + name: 'config as object instead of string', + data: { ...validResolver, config: { key: 'value' } }, + expectedErrorPath: ['config'], + }, + ])('should fail validation for $name', ({ data, expectedErrorPath }) => { + const result = credentialResolverSchema.safeParse(data); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(expectedErrorPath); + } + }); + + test('should accept date strings and coerce to Date', () => { + const resolverWithStringDates = { + ...validResolver, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + const result = credentialResolverSchema.safeParse(resolverWithStringDates); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createdAt).toBeInstanceOf(Date); + expect(result.data.updatedAt).toBeInstanceOf(Date); + } + }); + + test('should accept Date objects directly', () => { + const result = credentialResolverSchema.safeParse(validResolver); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.createdAt).toBeInstanceOf(Date); + expect(result.data.updatedAt).toBeInstanceOf(Date); + } + }); + }); + + describe('credentialResolversSchema', () => { + const validResolver1 = { + id: 'resolver-1', + name: 'Test Resolver 1', + type: 'credential-resolver.stub-1.0', + config: 'encrypted-config-1', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }; + + const validResolver2 = { + id: 'resolver-2', + name: 'Test Resolver 2', + type: 'credential-resolver.stub-2.0', + config: 'encrypted-config-2', + decryptedConfig: { prefix: 'test-' }, + createdAt: new Date('2024-01-03T00:00:00.000Z'), + updatedAt: new Date('2024-01-04T00:00:00.000Z'), + }; + + test('should validate empty array', () => { + const result = credentialResolversSchema.safeParse([]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual([]); + } + }); + + test('should validate array with single resolver', () => { + const result = credentialResolversSchema.safeParse([validResolver1]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(1); + } + }); + + test('should validate array with multiple resolvers', () => { + const result = credentialResolversSchema.safeParse([validResolver1, validResolver2]); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toHaveLength(2); + } + }); + + test('should fail if any resolver is invalid', () => { + const invalidResolver = { ...validResolver1, name: '' }; + const result = credentialResolversSchema.safeParse([validResolver1, invalidResolver]); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual([1, 'name']); + } + }); + + test('should fail for non-array input', () => { + const result = credentialResolversSchema.safeParse(validResolver1); + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts index b689e314923..ffe97f68507 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts @@ -136,6 +136,20 @@ describe('user.schema', () => { }, isValid: false, }, + { + name: 'chat user', + data: { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + role: 'global:chatUser', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: [], + }, + isValid: true, + }, { name: 'invalid role', data: { diff --git a/packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts b/packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts new file mode 100644 index 00000000000..ac5a7c1f5c3 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/credential-resolver.schema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +export const credentialResolverIdSchema = z.string().max(36); +export const credentialResolverNameSchema = z.string().trim().min(1).max(255); +export const credentialResolverTypeNameSchema = z.string().trim().min(1).max(255); +export const credentialResolverConfigSchema = z.record(z.unknown()); + +export const credentialResolverSchema = z.object({ + id: credentialResolverIdSchema, + name: credentialResolverNameSchema, + type: credentialResolverTypeNameSchema, + config: z.string(), // Encrypted config + decryptedConfig: credentialResolverConfigSchema.optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const credentialResolverTypeSchema = z.object({ + name: credentialResolverTypeNameSchema, + displayName: z.string().trim().min(1).max(255), + description: z.string().trim().max(1024).optional(), + options: z.array(z.record(z.unknown())).optional(), +}); + +export const credentialResolverTypesSchema = z.array(credentialResolverTypeSchema); + +export type CredentialResolverType = z.infer; + +export const credentialResolversSchema = z.array(credentialResolverSchema); + +export type CredentialResolver = z.infer; diff --git a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts index a3c6c94fe99..0ac2e391256 100644 --- a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts +++ b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts @@ -24,6 +24,12 @@ const FileStatusSchema = z.enum([ ]); export const SOURCE_CONTROL_FILE_STATUS = FileStatusSchema.Values; +export type SourceControlledFileStatus = z.infer; + +export function isSourceControlledFileStatus(value: unknown): value is SourceControlledFileStatus { + return FileStatusSchema.safeParse(value).success; +} + const FileLocationSchema = z.enum(['local', 'remote']); export const SOURCE_CONTROL_FILE_LOCATION = FileLocationSchema.Values; diff --git a/packages/@n8n/api-types/src/schemas/user.schema.ts b/packages/@n8n/api-types/src/schemas/user.schema.ts index a3bad669a75..953bb8f831f 100644 --- a/packages/@n8n/api-types/src/schemas/user.schema.ts +++ b/packages/@n8n/api-types/src/schemas/user.schema.ts @@ -7,6 +7,7 @@ export const ROLE = { Owner: 'global:owner', Member: 'global:member', Admin: 'global:admin', + ChatUser: 'global:chatUser', Default: 'default', // default user with no email when setting up instance } as const; diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts index 8fcd3a9e64c..9a14dec874d 100644 --- a/packages/@n8n/backend-common/src/license-state.ts +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -58,6 +58,10 @@ export class LicenseState { return this.isLicensed(LICENSE_FEATURES.CUSTOM_ROLES); } + isDynamicCredentialsLicensed() { + return this.isLicensed(LICENSE_FEATURES.DYNAMIC_CREDENTIALS); + } + isSharingLicensed() { return this.isLicensed('feat:sharing'); } diff --git a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts index 4d5ab6a50d7..81e097c5760 100644 --- a/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts +++ b/packages/@n8n/backend-common/src/modules/__tests__/module-registry.test.ts @@ -30,6 +30,7 @@ describe('eligibleModules', () => { 'mcp', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ]); }); @@ -43,6 +44,7 @@ describe('eligibleModules', () => { 'mcp', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ]); }); diff --git a/packages/@n8n/backend-common/src/modules/module-registry.ts b/packages/@n8n/backend-common/src/modules/module-registry.ts index 21234be3f01..7fc9e591686 100644 --- a/packages/@n8n/backend-common/src/modules/module-registry.ts +++ b/packages/@n8n/backend-common/src/modules/module-registry.ts @@ -37,6 +37,7 @@ export class ModuleRegistry { 'mcp', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ]; private readonly activeModules: string[] = []; diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 5b4a6ff42de..6c2cb14dd85 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -11,6 +11,7 @@ export const MODULE_NAMES = [ 'chat-hub', 'provisioning', 'breaking-changes', + 'dynamic-credentials', ] as const; export type ModuleName = (typeof MODULE_NAMES)[number]; diff --git a/packages/@n8n/backend-test-utils/src/db/projects.ts b/packages/@n8n/backend-test-utils/src/db/projects.ts index 59f681ccaaf..1fc78b95c09 100644 --- a/packages/@n8n/backend-test-utils/src/db/projects.ts +++ b/packages/@n8n/backend-test-utils/src/db/projects.ts @@ -27,6 +27,7 @@ export const createTeamProject = async (name?: string, adminUser?: User) => { projectRepository.create({ name: name ?? randomName(), type: 'team', + creatorId: adminUser?.id, }), ); diff --git a/packages/@n8n/backend-test-utils/src/db/workflows.ts b/packages/@n8n/backend-test-utils/src/db/workflows.ts index 40b47ff1d0c..1f345f16b13 100644 --- a/packages/@n8n/backend-test-utils/src/db/workflows.ts +++ b/packages/@n8n/backend-test-utils/src/db/workflows.ts @@ -1,4 +1,4 @@ -import type { SharedWorkflow, IWorkflowDb } from '@n8n/db'; +import type { SharedWorkflow, IWorkflowDb, WorkflowPublishHistory } from '@n8n/db'; import { Project, User, @@ -197,12 +197,12 @@ export async function createWorkflowWithTrigger( export async function createWorkflowWithHistory( attributes: Partial = {}, userOrProject?: User | Project, + withPublishHistory?: Partial, ) { const workflow = await createWorkflow(attributes, userOrProject); // Create workflow history for the initial version - const user = userOrProject instanceof User ? userOrProject : undefined; - await createWorkflowHistory(workflow, user); + await createWorkflowHistory(workflow, userOrProject, withPublishHistory); return workflow; } @@ -215,11 +215,12 @@ export async function createWorkflowWithHistory( export async function createWorkflowWithTriggerAndHistory( attributes: Partial = {}, userOrProject?: User | Project, + withPublishHistory?: Partial, ) { const workflow = await createWorkflowWithTrigger(attributes, userOrProject); // Create workflow history for the initial version - await createWorkflowHistory(workflow, userOrProject); + await createWorkflowHistory(workflow, userOrProject, withPublishHistory); return workflow; } @@ -243,14 +244,34 @@ export const getWorkflowById = async (id: string) => export async function createWorkflowHistory( workflow: IWorkflowDb, userOrProject?: User | Project, + withPublishHistory?: Partial, ): Promise { + const authors = + userOrProject instanceof User + ? userOrProject.firstName && userOrProject.lastName + ? `${userOrProject.firstName} ${userOrProject.lastName}` + : 'Test User' + : 'Test User'; + await Container.get(WorkflowHistoryRepository).insert({ workflowId: workflow.id, versionId: workflow.versionId, nodes: workflow.nodes, connections: workflow.connections, - authors: userOrProject instanceof User ? userOrProject.email : 'test@example.com', + authors, }); + + if (withPublishHistory) { + // We wait a millisecond as createdAt order is often relevant for the publishing history + await new Promise((res) => setTimeout(res, 1)); + await Container.get(WorkflowPublishHistoryRepository).insert({ + workflowId: workflow.id, + versionId: workflow.versionId, + event: 'activated', + userId: userOrProject instanceof User ? userOrProject.id : undefined, + ...withPublishHistory, + }); + } } /** @@ -280,6 +301,7 @@ export async function createActiveWorkflow( const workflow = await createWorkflowWithTriggerAndHistory( { active: true, ...attributes }, userOrProject, + {}, ); await setActiveVersion(workflow.id, workflow.versionId); diff --git a/packages/@n8n/backend-test-utils/src/test-db.ts b/packages/@n8n/backend-test-utils/src/test-db.ts index c6fee2a8daa..afe8b6297e8 100644 --- a/packages/@n8n/backend-test-utils/src/test-db.ts +++ b/packages/@n8n/backend-test-utils/src/test-db.ts @@ -87,7 +87,9 @@ type EntityName = | 'AuthorizationCode' | 'AccessToken' | 'RefreshToken' - | 'UserConsent'; + | 'UserConsent' + | 'DynamicCredentialEntry' + | 'DynamicCredentialResolver'; /** * Truncate specific DB tables in a test DB. diff --git a/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.json b/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.json index 2b6f02b601f..f28a05a3884 100644 --- a/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.json +++ b/packages/@n8n/benchmark/scenarios/credential-http-node/credential-http-node.json @@ -228,7 +228,7 @@ ] } }, - "active": true, + "active": false, "settings": { "executionOrder": "v1" }, diff --git a/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.types.ts b/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.types.ts index c344ac80213..14c1b046328 100644 --- a/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.types.ts +++ b/packages/@n8n/benchmark/src/n8n-api-client/n8n-api-client.types.ts @@ -4,6 +4,7 @@ export type Workflow = { id: string; name: string; + versionId: string; tags?: string[]; }; diff --git a/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts b/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts index 9d6ab30229b..ee421008362 100644 --- a/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts +++ b/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts @@ -18,10 +18,12 @@ export class WorkflowApiClient { } async activateWorkflow(workflow: Workflow): Promise { - const response = await this.apiClient.patch<{ data: Workflow }>(`/workflows/${workflow.id}`, { - ...workflow, - active: true, - }); + const response = await this.apiClient.post<{ data: Workflow }>( + `/workflows/${workflow.id}/activate`, + { + versionId: workflow.versionId, + }, + ); return response.data.data; } diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index 8a899329d32..fbeaddfb0d0 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -28,6 +28,7 @@ export const LOG_SCOPES = [ 'chat-hub', 'breaking-changes', 'circuit-breaker', + 'dynamic-credentials', ] as const; export type LogScope = (typeof LOG_SCOPES)[number]; diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index 286c0ecb3ec..264fcf2a2b2 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -38,6 +38,7 @@ export const LICENSE_FEATURES = { WORKFLOW_DIFFS: 'feat:workflowDiffs', CUSTOM_ROLES: 'feat:customRoles', AI_BUILDER: 'feat:aiBuilder', + DYNAMIC_CREDENTIALS: 'feat:dynamicCredentials', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/@n8n/db/src/constants.ts b/packages/@n8n/db/src/constants.ts index 194ebbad079..d516245fb88 100644 --- a/packages/@n8n/db/src/constants.ts +++ b/packages/@n8n/db/src/constants.ts @@ -3,8 +3,9 @@ import { PROJECT_EDITOR_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG, - type ProjectRole, + PROJECT_CHAT_USER_ROLE_SLUG, ALL_ROLES, + type ProjectRole, type GlobalRole, type Role as RoleDTO, } from '@n8n/permissions'; @@ -51,16 +52,19 @@ export const ALL_BUILTIN_ROLES = toRoleMap([ export const GLOBAL_OWNER_ROLE = ALL_BUILTIN_ROLES['global:owner']; export const GLOBAL_ADMIN_ROLE = ALL_BUILTIN_ROLES['global:admin']; export const GLOBAL_MEMBER_ROLE = ALL_BUILTIN_ROLES['global:member']; +export const GLOBAL_CHAT_USER_ROLE = ALL_BUILTIN_ROLES['global:chatUser']; export const PROJECT_OWNER_ROLE = ALL_BUILTIN_ROLES[PROJECT_OWNER_ROLE_SLUG]; export const PROJECT_ADMIN_ROLE = ALL_BUILTIN_ROLES[PROJECT_ADMIN_ROLE_SLUG]; export const PROJECT_EDITOR_ROLE = ALL_BUILTIN_ROLES[PROJECT_EDITOR_ROLE_SLUG]; export const PROJECT_VIEWER_ROLE = ALL_BUILTIN_ROLES[PROJECT_VIEWER_ROLE_SLUG]; +export const PROJECT_CHAT_USER_ROLE = ALL_BUILTIN_ROLES[PROJECT_CHAT_USER_ROLE_SLUG]; export const GLOBAL_ROLES: Record = { 'global:owner': GLOBAL_OWNER_ROLE, 'global:admin': GLOBAL_ADMIN_ROLE, 'global:member': GLOBAL_MEMBER_ROLE, + 'global:chatUser': GLOBAL_CHAT_USER_ROLE, }; export const PROJECT_ROLES: Record = { @@ -68,4 +72,5 @@ export const PROJECT_ROLES: Record = { [PROJECT_ADMIN_ROLE_SLUG]: PROJECT_ADMIN_ROLE, [PROJECT_EDITOR_ROLE_SLUG]: PROJECT_EDITOR_ROLE, [PROJECT_VIEWER_ROLE_SLUG]: PROJECT_VIEWER_ROLE, + [PROJECT_CHAT_USER_ROLE_SLUG]: PROJECT_CHAT_USER_ROLE, }; diff --git a/packages/@n8n/db/src/entities/project.ts b/packages/@n8n/db/src/entities/project.ts index 16b6b739c1a..ace0ac0ddee 100644 --- a/packages/@n8n/db/src/entities/project.ts +++ b/packages/@n8n/db/src/entities/project.ts @@ -1,9 +1,10 @@ -import { Column, Entity, OneToMany } from '@n8n/typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany, Relation } from '@n8n/typeorm'; import { WithTimestampsAndStringId } from './abstract-entity'; import type { ProjectRelation } from './project-relation'; import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; +import { User } from './user'; import type { Variables } from './variables'; @Entity() @@ -31,4 +32,11 @@ export class Project extends WithTimestampsAndStringId { @OneToMany('Variables', 'project') variables: Variables[]; + + @Column({ type: String, nullable: true }) + creatorId: string | null; + + @ManyToOne('User', { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'creatorId' }) + creator?: Relation; } diff --git a/packages/@n8n/db/src/index.ts b/packages/@n8n/db/src/index.ts index 4b832e62ea6..54db96c74aa 100644 --- a/packages/@n8n/db/src/index.ts +++ b/packages/@n8n/db/src/index.ts @@ -39,5 +39,5 @@ export { DbConnectionOptions } from './connection/db-connection-options'; export { AuthRolesService } from './services/auth.roles.service'; -export { In, Like, DataSource } from '@n8n/typeorm'; +export { In, Like, Not, DataSource } from '@n8n/typeorm'; export type { FindOptionsWhere } from '@n8n/typeorm'; diff --git a/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts b/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts index c4c44513b61..89b4189ddc9 100644 --- a/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts +++ b/packages/@n8n/db/src/migrations/common/1763047800000-AddActiveVersionIdColumn.ts @@ -8,6 +8,7 @@ export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigratio schemaBuilder: { addColumns, column, addForeignKey }, queryRunner, escape, + runQuery, }: MigrationContext) { const workflowsTableName = escape.tableName(WORKFLOWS_TABLE_NAME); @@ -21,6 +22,10 @@ export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigratio 'RESTRICT', ); + // Fix for ADO-4517: some users pulled workflows to prod instances and ended up having missing records + // Run AFTER adding column/FK to avoid CASCADE DELETE + await this.backFillHistoryRecords(runQuery, escape); + // For existing ACTIVE workflows, set activeVersionId = versionId const versionIdColumn = escape.columnName('versionId'); const activeColumn = escape.columnName('active'); @@ -40,4 +45,52 @@ export class AddActiveVersionIdColumn1763047800000 implements ReversibleMigratio ]); await dropColumns(WORKFLOWS_TABLE_NAME, ['activeVersionId']); } + + // Create workflow_history records for workflows missing them + async backFillHistoryRecords( + runQuery: MigrationContext['runQuery'], + escape: MigrationContext['escape'], + ) { + const workflowTable = escape.tableName('workflow_entity'); + const historyTable = escape.tableName('workflow_history'); + const versionIdColumn = escape.columnName('versionId'); + const idColumn = escape.columnName('id'); + const workflowIdColumn = escape.columnName('workflowId'); + const nodesColumn = escape.columnName('nodes'); + const connectionsColumn = escape.columnName('connections'); + const authorsColumn = escape.columnName('authors'); + const createdAtColumn = escape.columnName('createdAt'); + const updatedAtColumn = escape.columnName('updatedAt'); + + await runQuery( + ` + INSERT INTO ${historyTable} ( + ${versionIdColumn}, + ${workflowIdColumn}, + ${authorsColumn}, + ${nodesColumn}, + ${connectionsColumn}, + ${createdAtColumn}, + ${updatedAtColumn} + ) + SELECT + w.${versionIdColumn}, + w.${idColumn}, + :authors, + w.${nodesColumn}, + w.${connectionsColumn}, + :createdAt, + :updatedAt + FROM ${workflowTable} w + LEFT JOIN ${historyTable} wh + ON w.${versionIdColumn} = wh.${versionIdColumn} + WHERE wh.${versionIdColumn} IS NULL + `, + { + authors: 'system migration', + createdAt: new Date(), + updatedAt: new Date(), + }, + ); + } } diff --git a/packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts b/packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts new file mode 100644 index 00000000000..0a93a80863c --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1764276827837-AddCreatorIdToProjectTable.ts @@ -0,0 +1,42 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + project: 'project', + projectRelation: 'project_relation', +} as const; + +const FOREIGN_KEY_NAME = 'projects_creatorId_foreign'; + +export class AddCreatorIdToProjectTable1764276827837 implements ReversibleMigration { + async up({ + escape, + schemaBuilder: { addColumns, addForeignKey, column }, + queryRunner, + }: MigrationContext) { + await addColumns(table.project, [ + column('creatorId').uuid.comment('ID of the user who created the project'), + ]); + + await addForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME, 'SET NULL'); + + // Populate creatorId for existing personal projects. + // We can only do this for personal projects as for team projects + // we don't have a reliable way of knowing who the creator was. + await queryRunner.query(` + UPDATE ${escape.tableName(table.project)} AS project + SET ${escape.columnName('creatorId')} = ( + SELECT pr.${escape.columnName('userId')} + FROM ${escape.tableName(table.projectRelation)} AS pr + WHERE pr.${escape.columnName('projectId')} = project.${escape.columnName('id')} + AND pr.${escape.columnName('role')} = 'project:personalOwner' + LIMIT 1 + ) + WHERE project.${escape.columnName('type')} = 'personal' + AND project.${escape.columnName('creatorId')} IS NULL;`); + } + + async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) { + await dropForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME); + await dropColumns(table.project, ['creatorId']); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1764682447000-CreateCredentialResolverTable.ts b/packages/@n8n/db/src/migrations/common/1764682447000-CreateCredentialResolverTable.ts new file mode 100644 index 00000000000..52e6120d8fd --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1764682447000-CreateCredentialResolverTable.ts @@ -0,0 +1,22 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const tableName = 'dynamic_credential_resolver'; + +export class CreateDynamicCredentialResolverTable1764682447000 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(tableName) + .withColumns( + column('id').varchar(16).primary, + column('name').varchar(128).notNull, + column('type').varchar(128).notNull, + column('config').text.notNull.comment( + 'Encrypted resolver configuration (JSON encrypted as string)', + ), + ) + .withTimestamps.withIndexOn('type'); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(tableName); + } +} diff --git a/packages/@n8n/db/src/migrations/common/1764689388394-AddDynamicCredentialEntryTable.ts b/packages/@n8n/db/src/migrations/common/1764689388394-AddDynamicCredentialEntryTable.ts new file mode 100644 index 00000000000..6be0b636c30 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1764689388394-AddDynamicCredentialEntryTable.ts @@ -0,0 +1,31 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const tableName = 'dynamic_credential_entry'; + +export class AddDynamicCredentialEntryTable1764689388394 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(tableName) + .withColumns( + column('credential_id').varchar(16).primary.notNull, + column('subject_id').varchar(16).primary.notNull, + column('resolver_id').varchar(16).primary.notNull, + column('data').text.notNull, + ) + .withTimestamps.withForeignKey('credential_id', { + tableName: 'credentials_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('resolver_id', { + tableName: 'dynamic_credential_resolver', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn(['subject_id']) + .withIndexOn(['resolver_id']); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(tableName); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index a62b6a13f0d..976f06061be 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -120,6 +120,9 @@ import { ActivateExecuteWorkflowTriggerWorkflows1763048000000 } from '../common/ import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import { CreateBinaryDataTable1763716655000 } from '../common/1763716655000-CreateBinaryDataTable'; import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/1764167920585-CreateWorkflowPublishHistoryTable'; +import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837-AddCreatorIdToProjectTable'; +import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable'; +import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable'; import type { Migration } from '../migration-types'; export const mysqlMigrations: Migration[] = [ @@ -245,4 +248,7 @@ export const mysqlMigrations: Migration[] = [ CreateBinaryDataTable1763716655000, CreateWorkflowPublishHistoryTable1764167920585, ActivateExecuteWorkflowTriggerWorkflows1763048000000, + AddCreatorIdToProjectTable1764276827837, + CreateDynamicCredentialResolverTable1764682447000, + AddDynamicCredentialEntryTable1764689388394, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 45958478b99..9a558426125 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -120,6 +120,9 @@ import { ActivateExecuteWorkflowTriggerWorkflows1763048000000 } from '../common/ import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import { CreateBinaryDataTable1763716655000 } from '../common/1763716655000-CreateBinaryDataTable'; import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/1764167920585-CreateWorkflowPublishHistoryTable'; +import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837-AddCreatorIdToProjectTable'; +import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable'; +import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -245,4 +248,7 @@ export const postgresMigrations: Migration[] = [ CreateBinaryDataTable1763716655000, CreateWorkflowPublishHistoryTable1764167920585, ActivateExecuteWorkflowTriggerWorkflows1763048000000, + AddCreatorIdToProjectTable1764276827837, + CreateDynamicCredentialResolverTable1764682447000, + AddDynamicCredentialEntryTable1764689388394, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts b/packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts new file mode 100644 index 00000000000..336f37ae8b3 --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1764276827837-AddCreatorIdToProjectTable.ts @@ -0,0 +1,43 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + project: 'project', + projectRelation: 'project_relation', +} as const; + +const FOREIGN_KEY_NAME = 'projects_creatorId_foreign'; + +export class AddCreatorIdToProjectTable1764276827837 implements ReversibleMigration { + transaction = false as const; + + async up({ + escape, + schemaBuilder: { addColumns, addForeignKey, column }, + queryRunner, + }: MigrationContext) { + await addColumns(table.project, [ + column('creatorId').uuid.comment('ID of the user who created the project'), + ]); + + await addForeignKey(table.project, 'creatorId', ['user', 'id'], FOREIGN_KEY_NAME, 'SET NULL'); + + // Populate creatorId for existing personal projects. + // We can only do this for personal projects as for team projects + // we don't have a reliable way of knowing who the creator was. + await queryRunner.query(` + UPDATE ${escape.tableName(table.project)} AS project + SET ${escape.columnName('creatorId')} = ( + SELECT pr.${escape.columnName('userId')} + FROM ${escape.tableName(table.projectRelation)} AS pr + WHERE pr.${escape.columnName('projectId')} = project.${escape.columnName('id')} + AND pr.${escape.columnName('role')} = 'project:personalOwner' + LIMIT 1 + ) + WHERE project.${escape.columnName('type')} = 'personal' + AND project.${escape.columnName('creatorId')} IS NULL;`); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(table.project, ['creatorId']); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index abfdee65e78..889a7393698 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -46,6 +46,7 @@ import { AddScopesColumnToApiKeys1742918400000 } from './1742918400000-AddScopes import { AddProjectIdToVariableTable1758794506893 } from './1758794506893-AddProjectIdToVariableTable'; import { AddWorkflowVersionColumn1761047826451 } from './1761047826451-AddWorkflowVersionColumn'; import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeDependencyInfoToJson'; +import { AddCreatorIdToProjectTable1764276827837 } from './1764276827837-AddCreatorIdToProjectTable'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; @@ -116,6 +117,8 @@ import { ActivateExecuteWorkflowTriggerWorkflows1763048000000 } from '../common/ import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; import { CreateBinaryDataTable1763716655000 } from '../common/1763716655000-CreateBinaryDataTable'; import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/1764167920585-CreateWorkflowPublishHistoryTable'; +import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable'; +import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -237,6 +240,9 @@ const sqliteMigrations: Migration[] = [ CreateBinaryDataTable1763716655000, CreateWorkflowPublishHistoryTable1764167920585, ActivateExecuteWorkflowTriggerWorkflows1763048000000, + AddCreatorIdToProjectTable1764276827837, + CreateDynamicCredentialResolverTable1764682447000, + AddDynamicCredentialEntryTable1764689388394, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/project.repository.ts b/packages/@n8n/db/src/repositories/project.repository.ts index b0779831af0..9a21d0a4bfc 100644 --- a/packages/@n8n/db/src/repositories/project.repository.ts +++ b/packages/@n8n/db/src/repositories/project.repository.ts @@ -1,5 +1,4 @@ import { Service } from '@n8n/di'; -import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { DataSource, Repository } from '@n8n/typeorm'; @@ -17,7 +16,7 @@ export class ProjectRepository extends Repository { return await em.findOne(Project, { where: { type: 'personal', - projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + creatorId: userId, }, relations: ['projectRelations.role'], }); @@ -29,7 +28,7 @@ export class ProjectRepository extends Repository { return await em.findOneOrFail(Project, { where: { type: 'personal', - projectRelations: { userId, role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + creatorId: userId, }, }); } diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 0837778377f..36af7f120a9 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -1,6 +1,6 @@ import type { UsersListFilterDto } from '@n8n/api-types'; import { Service } from '@n8n/di'; -import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; +import { PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; @@ -123,15 +123,23 @@ export class UserRepository extends Repository { entityManager.create(Project, { type: 'personal', name: userWithRole.createPersonalProjectName(), + creatorId: savedUser.id, }), ); + await entityManager.save( entityManager.create(ProjectRelation, { projectId: savedProject.id, userId: savedUser.id, - role: { slug: PROJECT_OWNER_ROLE_SLUG }, + role: { + slug: + userWithRole.role.slug !== 'global:chatUser' + ? PROJECT_OWNER_ROLE_SLUG + : PROJECT_VIEWER_ROLE_SLUG, + }, }), ); + return { user: userWithRole, project: savedProject }; }; if (transactionManager) { @@ -151,8 +159,12 @@ export class UserRepository extends Repository { return await this.findOne({ where: { projectRelations: { - role: { slug: PROJECT_OWNER_ROLE_SLUG }, - project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } }, + role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG]) }, + project: { + type: 'personal', + creatorId: Not(IsNull()), + sharedWorkflows: { workflowId, role: 'workflow:owner' }, + }, }, }, relations: ['role'], @@ -168,8 +180,9 @@ export class UserRepository extends Repository { return await this.findOne({ where: { projectRelations: { - role: { slug: PROJECT_OWNER_ROLE_SLUG }, + role: { slug: In([PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG]) }, projectId, + project: { type: 'personal', creatorId: Not(IsNull()) }, }, }, relations: ['role'], diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index b7ff8277ab9..3796266ea2b 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -1012,4 +1012,16 @@ export class WorkflowRepository extends Repository { return await qb.getMany(); } + + /** + * Returns if the workflow is stored as `active`. + * + * @important Do not confuse with `ActiveWorkflows.isActive()`, + * which checks if the workflow is active in memory. + */ + async isActive(workflowId: string) { + const workflow = await this.findOne({ select: ['activeVersionId'], where: { id: workflowId } }); + + return !!workflow?.activeVersionId; + } } diff --git a/packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts b/packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts new file mode 100644 index 00000000000..e876e945513 --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/__tests__/credential-resolver.test.ts @@ -0,0 +1,344 @@ +import { Container } from '@n8n/di'; +import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import type { CredentialResolverConfiguration, ICredentialResolver } from '../credential-resolver'; +import { + CredentialResolver, + CredentialResolverEntryMetadata, +} from '../credential-resolver-metadata'; + +describe('@CredentialResolver decorator', () => { + let resolverMetadata: CredentialResolverEntryMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + + resolverMetadata = new CredentialResolverEntryMetadata(); + Container.set(CredentialResolverEntryMetadata, resolverMetadata); + }); + + it('should register resolver in CredentialResolverEntryMetadata', () => { + @CredentialResolver() + class TestResolver implements ICredentialResolver { + metadata = { + name: 'test.resolver', + description: 'Test resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const registeredResolvers = resolverMetadata.getClasses(); + + expect(registeredResolvers).toContain(TestResolver); + expect(registeredResolvers).toHaveLength(1); + }); + + it('should register multiple resolvers', () => { + @CredentialResolver() + class FirstResolver implements ICredentialResolver { + metadata = { + name: 'first.resolver', + description: 'First resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + @CredentialResolver() + class SecondResolver implements ICredentialResolver { + metadata = { + name: 'second.resolver', + description: 'Second resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + @CredentialResolver() + class ThirdResolver implements ICredentialResolver { + metadata = { + name: 'third.resolver', + description: 'Third resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const registeredResolvers = resolverMetadata.getClasses(); + + expect(registeredResolvers).toContain(FirstResolver); + expect(registeredResolvers).toContain(SecondResolver); + expect(registeredResolvers).toContain(ThirdResolver); + expect(registeredResolvers).toHaveLength(3); + }); + + it('should apply Service decorator', () => { + @CredentialResolver() + class TestResolver implements ICredentialResolver { + metadata = { + name: 'test.resolver', + description: 'Test resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + expect(Container.has(TestResolver)).toBe(true); + }); + + it('should allow instantiation of registered resolvers with accessible metadata', () => { + @CredentialResolver() + class TestResolver implements ICredentialResolver { + metadata = { + name: 'oauth.introspection', + description: 'OAuth introspection resolver', + displayName: 'OAuth Introspection', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const resolverInstance = Container.get(TestResolver); + + expect(resolverInstance).toBeInstanceOf(TestResolver); + expect(resolverInstance.metadata).toEqual({ + name: 'oauth.introspection', + description: 'OAuth introspection resolver', + displayName: 'OAuth Introspection', + }); + expect(resolverInstance.metadata.name).toBe('oauth.introspection'); + }); + + it('should register resolvers with different metadata', () => { + @CredentialResolver() + class OAuthResolver implements ICredentialResolver { + metadata = { + name: 'oauth.resolver', + description: 'OAuth-based credential resolver', + displayName: 'OAuth Resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + @CredentialResolver() + class StubResolver implements ICredentialResolver { + metadata = { + name: 'stub.resolver', + description: 'Stub resolver for testing', + displayName: 'Stub Resolver', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const registeredResolvers = resolverMetadata.getClasses(); + const oauthResolver = Container.get(OAuthResolver); + const stubResolver = Container.get(StubResolver); + + expect(registeredResolvers).toHaveLength(2); + expect(oauthResolver.metadata.name).toBe('oauth.resolver'); + expect(oauthResolver.metadata.displayName).toBe('OAuth Resolver'); + expect(stubResolver.metadata.name).toBe('stub.resolver'); + expect(stubResolver.metadata.displayName).toBe('Stub Resolver'); + }); + + it('should support resolvers with configuration options', () => { + @CredentialResolver() + class ConfigurableResolver implements ICredentialResolver { + metadata = { + name: 'configurable.resolver', + description: 'Resolver with configuration options', + options: [ + { + displayName: 'API Endpoint', + name: 'apiEndpoint', + type: 'string' as const, + default: '', + }, + ], + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const resolverInstance = Container.get(ConfigurableResolver); + + expect(resolverInstance.metadata.options).toBeDefined(); + expect(resolverInstance.metadata.options).toHaveLength(1); + expect(resolverInstance.metadata.options[0].name).toBe('apiEndpoint'); + }); + + it('should support optional deleteSecret method', () => { + @CredentialResolver() + class ResolverWithDelete implements ICredentialResolver { + metadata = { + name: 'resolver.with.delete', + description: 'Resolver with delete support', + }; + + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + } + + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {} + + async deleteSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise {} + + async validateOptions(_options: CredentialResolverConfiguration): Promise {} + } + + const withDelete = Container.get(ResolverWithDelete); + + expect(withDelete.deleteSecret).toBeDefined(); + }); +}); diff --git a/packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts b/packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts new file mode 100644 index 00000000000..cd7c2e4704b --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/credential-resolver-metadata.ts @@ -0,0 +1,52 @@ +import { Container, Service } from '@n8n/di'; + +import { CredentialResolverClass } from './credential-resolver'; + +type CredentialResolverEntry = { + class: CredentialResolverClass; +}; + +/** + * Registry service for credential resolver type discovery and instantiation. + * Resolver classes decorated with @CredentialResolver() are automatically registered. + */ +@Service() +export class CredentialResolverEntryMetadata { + private readonly credentialResolverEntries: Set = new Set(); + + /** Registers a credential resolver class. Called automatically by @CredentialResolver() decorator. */ + register(credentialResolverEntry: CredentialResolverEntry) { + this.credentialResolverEntries.add(credentialResolverEntry); + } + + /** Returns all registered resolver entries as [index, entry] tuples. */ + getEntries() { + return [...this.credentialResolverEntries.entries()]; + } + + /** Returns all registered resolver classes. */ + getClasses() { + return [...this.credentialResolverEntries.values()].map((entry) => entry.class); + } +} + +/** + * Decorator to mark a class as a credential resolver. + * Automatically registers the resolver for discovery and enables dependency injection. + * + * @example + * @CredentialResolver() + * class MyResolver implements ICredentialResolver { ... } + */ +export const CredentialResolver = + () => + (target: T) => { + // Register resolver class for discovery by registry + Container.get(CredentialResolverEntryMetadata).register({ + class: target, + }); + + // Enable dependency injection for the resolver class + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service()(target); + }; diff --git a/packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts b/packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts new file mode 100644 index 00000000000..6a0779c3a0e --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/credential-resolver.ts @@ -0,0 +1,94 @@ +import type { Constructable } from '@n8n/di'; +import type { + ICredentialContext, + ICredentialDataDecryptedObject, + INodeProperties, +} from 'n8n-workflow'; + +/** + * Configuration object passed to resolver methods. Structure is defined by resolver type's metadata.options. + */ +export type CredentialResolverConfiguration = Record; + +export type CredentialResolverHandle = { + configuration: CredentialResolverConfiguration; + resolverName: string; + resolverId: string; +}; + +/** + * Metadata describing a credential resolver type for UI integration and discovery. + */ +export interface CredentialResolverMetadata { + /** Unique identifier for the resolver type */ + name: string; + + /** Human-readable description of what this resolver does */ + description: string; + + /** Optional display name shown in UI. Falls back to name if not provided. */ + displayName?: string; + + /** Configuration schema using n8n's INodeProperties format for dynamic form rendering */ + options?: INodeProperties[]; +} + +/** + * Core interface for credential resolver implementations. + * Resolvers fetch credential data dynamically based on execution context and configuration. + */ +export interface ICredentialResolver { + /** Metadata for UI integration and resolver discovery */ + metadata: CredentialResolverMetadata; + + /** + * Retrieves credential data for a specific entity from the resolver's storage. + * @throws {CredentialResolverDataNotFoundError} When no data exists for the given context + * @throws {CredentialResolverError} For other resolver-specific errors + */ + getSecret( + credentialId: string, + context: ICredentialContext, + handle: CredentialResolverHandle, + ): Promise; + + /** + * Stores credential data for a specific entity in the resolver's storage. + * @throws {CredentialResolverError} When storage operation fails + */ + setSecret( + credentialId: string, + context: ICredentialContext, + data: ICredentialDataDecryptedObject, + handle: CredentialResolverHandle, + ): Promise; + + /** + * Deletes credential data for a specific entity from the resolver's storage. + * Optional - not all resolvers support deletion. + * @throws {CredentialResolverError} When deletion operation fails + */ + deleteSecret?( + credentialId: string, + context: ICredentialContext, + handle: CredentialResolverHandle, + ): Promise; + + /** + * Validates resolver configuration before saving. + * Should verify connectivity, authentication, and configuration structure. + * @throws {CredentialResolverValidationError} When configuration is invalid + */ + validateOptions(options: CredentialResolverConfiguration): Promise; + + /** + * Runs initialization logic for the resolver. This might be called multiple times! + * Optional - not all resolvers require initialization. + */ + init?(): Promise; +} + +/** + * Type helper for credential resolver class constructors. + */ +export type CredentialResolverClass = Constructable; diff --git a/packages/@n8n/decorators/src/credential-resolver/errors.ts b/packages/@n8n/decorators/src/credential-resolver/errors.ts new file mode 100644 index 00000000000..e6f999012c0 --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/errors.ts @@ -0,0 +1,31 @@ +/** + * Base error class for all credential resolver errors. + */ +export class CredentialResolverError extends Error { + constructor(message: string) { + super(message); + this.name = 'CredentialResolverError'; + } +} + +/** + * Thrown when no credential data exists for the requested credential and context combination. + * Indicates the entity has not stored credentials for this credential type. + */ +export class CredentialResolverDataNotFoundError extends CredentialResolverError { + constructor() { + super('No data found available for the requested credential and context combination.'); + this.name = 'CredentialResolverDataNotFoundError'; + } +} + +/** + * Thrown when resolver configuration validation fails. + * Indicates invalid configuration values or unreachable external services. + */ +export class CredentialResolverValidationError extends CredentialResolverError { + constructor(message: string) { + super(`Credential resolver options validation failed: ${message}`); + this.name = 'CredentialResolverValidationError'; + } +} diff --git a/packages/@n8n/decorators/src/credential-resolver/index.ts b/packages/@n8n/decorators/src/credential-resolver/index.ts new file mode 100644 index 00000000000..d2f662ee01c --- /dev/null +++ b/packages/@n8n/decorators/src/credential-resolver/index.ts @@ -0,0 +1,13 @@ +/** + * Credential Resolver Module + * + * Provides interfaces and infrastructure for dynamic credential resolution based on execution context. + * Resolvers fetch credential data at runtime from external storage based on entity identity. + */ + +export { + CredentialResolverEntryMetadata, + CredentialResolver, +} from './credential-resolver-metadata'; +export * from './errors'; +export type * from './credential-resolver'; diff --git a/packages/@n8n/decorators/src/index.ts b/packages/@n8n/decorators/src/index.ts index f872195d472..45fd624f944 100644 --- a/packages/@n8n/decorators/src/index.ts +++ b/packages/@n8n/decorators/src/index.ts @@ -4,6 +4,7 @@ export { Debounce } from './debounce'; export * from './execution-lifecycle'; export { Memoized } from './memoized'; export * from './context-establishment'; +export * from './credential-resolver'; export * from './module'; export * from './multi-main'; export * from './pubsub'; diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 96df70a5657..50810c45585 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -16,13 +16,18 @@ export interface BaseEntity { reload(): Promise; } -export interface TimestampedEntity { +export interface TimestampedIdEntity { id: string; createdAt: Date; updatedAt: Date; } -export type EntityClass = new () => BaseEntity | TimestampedEntity; +export interface TimestampedEntity { + createdAt: Date; + updatedAt: Date; +} + +export type EntityClass = new () => BaseEntity | TimestampedIdEntity | TimestampedEntity; export type ModuleSettings = Record; export type ModuleContext = Record; diff --git a/packages/@n8n/nodes-langchain/credentials/McpOAuth2Api.credentials.ts b/packages/@n8n/nodes-langchain/credentials/McpOAuth2Api.credentials.ts index f254931d3a5..e93e670744d 100644 --- a/packages/@n8n/nodes-langchain/credentials/McpOAuth2Api.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/McpOAuth2Api.credentials.ts @@ -15,7 +15,6 @@ export class McpOAuth2Api implements ICredentialType { name: 'useDynamicClientRegistration', type: 'boolean', default: true, - required: true, }, ]; } diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index 45c83627fc5..e56583f99d7 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -28,7 +28,7 @@ export class Agent extends VersionedNodeType { ], }, }, - defaultVersion: 3, + defaultVersion: 3.1, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { @@ -45,7 +45,9 @@ export class Agent extends VersionedNodeType { 2: new AgentV2(baseDescription), 2.1: new AgentV2(baseDescription), 2.2: new AgentV2(baseDescription), + 2.3: new AgentV2(baseDescription), 3: new AgentV3(baseDescription), + 3.1: new AgentV3(baseDescription), // IMPORTANT Reminder to update AgentTool }; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/AgentTool.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/AgentTool.node.ts index 42e88f02d9c..1613315393d 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/AgentTool.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/AgentTool.node.ts @@ -2,6 +2,7 @@ import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow' import { VersionedNodeType } from 'n8n-workflow'; import { AgentToolV2 } from './V2/AgentToolV2.node'; +import { AgentToolV3 } from './V3/AgentToolV3.node'; export class AgentTool extends VersionedNodeType { constructor() { @@ -20,13 +21,14 @@ export class AgentTool extends VersionedNodeType { Tools: ['Other Tools'], }, }, - defaultVersion: 2.2, + defaultVersion: 3, }; const nodeVersions: IVersionedNodeType['nodeVersions'] = { // Should have the same versioning as Agent node // because internal agent logic often checks for node version 2.2: new AgentToolV2(baseDescription), + 3: new AgentToolV3(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts index b046e52c5b6..390ab5a7e86 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V1/AgentV1.node.ts @@ -12,7 +12,7 @@ import type { } from 'n8n-workflow'; import { - promptTypeOptions, + promptTypeOptionsDeprecated, textFromGuardrailsNode, textFromPreviousNode, textInput, @@ -374,7 +374,7 @@ export class AgentV1 implements INodeType { default: 'toolsAgent', }, { - ...promptTypeOptions, + ...promptTypeOptionsDeprecated, displayOptions: { hide: { '@version': [{ _cnd: { lte: 1.2 } }], diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts index 3a6a9380a54..4538ed4a013 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V2/AgentV2.node.ts @@ -8,7 +8,7 @@ import type { } from 'n8n-workflow'; import { - promptTypeOptions, + promptTypeOptionsDeprecated, textFromGuardrailsNode, textFromPreviousNode, textInput, @@ -58,7 +58,7 @@ export class AgentV2 implements INodeType { }, default: '', }, - promptTypeOptions, + promptTypeOptionsDeprecated, { ...textFromGuardrailsNode, displayOptions: { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts new file mode 100644 index 00000000000..71d7b704b92 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentToolV3.node.ts @@ -0,0 +1,91 @@ +import { NodeConnectionTypes } from 'n8n-workflow'; +import type { + IExecuteFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + INodeTypeBaseDescription, + ISupplyDataFunctions, + EngineResponse, + EngineRequest, +} from 'n8n-workflow'; + +import { textInput, toolDescription } from '@utils/descriptions'; + +import { getInputs } from '../utils'; +import { toolsAgentProperties } from '../agents/ToolsAgent/V3/description'; +import type { RequestResponseMetadata } from '../agents/ToolsAgent/V3/execute'; +import { toolsAgentExecute } from '../agents/ToolsAgent/V3/execute'; + +export class AgentToolV3 implements INodeType { + description: INodeTypeDescription; + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + version: [3], + defaults: { + name: 'AI Agent Tool', + color: '#404040', + }, + inputs: `={{ + ((hasOutputParser, needsFallback) => { + ${getInputs.toString()}; + return getInputs(false, hasOutputParser, needsFallback) + })($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true) + }}`, + outputs: [NodeConnectionTypes.AiTool], + properties: [ + toolDescription, + { + ...textInput, + }, + { + displayName: 'Require Specific Output Format', + name: 'hasOutputParser', + type: 'boolean', + default: false, + noDataExpression: true, + }, + { + displayName: `Connect an output parser on the canvas to specify the output format you require`, + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + hasOutputParser: [true], + }, + }, + }, + { + displayName: 'Enable Fallback Model', + name: 'needsFallback', + type: 'boolean', + default: false, + noDataExpression: true, + }, + { + displayName: + 'Connect an additional language model on the canvas to use it as a fallback if the main model fails', + name: 'fallbackNotice', + type: 'notice', + default: '', + displayOptions: { + show: { + needsFallback: [true], + }, + }, + }, + toolsAgentProperties, + ], + }; + } + + // Automatically wrapped as a tool + async execute( + this: IExecuteFunctions | ISupplyDataFunctions, + response?: EngineResponse, + ): Promise> { + return await toolsAgentExecute.call(this, response); + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts index 19183d20ea0..e3c14db106f 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/V3/AgentV3.node.ts @@ -11,6 +11,7 @@ import type { import { promptTypeOptions, + promptTypeOptionsDeprecated, textFromGuardrailsNode, textFromPreviousNode, textInput, @@ -27,7 +28,7 @@ export class AgentV3 implements INodeType { constructor(baseDescription: INodeTypeBaseDescription) { this.description = { ...baseDescription, - version: [3], + version: [3, 3.1], defaults: { name: 'AI Agent', color: '#404040', @@ -47,7 +48,14 @@ export class AgentV3 implements INodeType { type: 'callout', default: '', }, - promptTypeOptions, + { + ...promptTypeOptionsDeprecated, + displayOptions: { show: { '@version': [{ _cnd: { lt: 3.1 } }] } }, + }, + { + ...promptTypeOptions, + displayOptions: { show: { '@version': [{ _cnd: { gte: 3.1 } }] } }, + }, { ...textFromGuardrailsNode, displayOptions: { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts index 68e9ec28172..4f4d11f4476 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/SqlAgent/description.ts @@ -1,7 +1,7 @@ import type { INodeProperties } from 'n8n-workflow'; import { - promptTypeOptions, + promptTypeOptionsDeprecated, textFromGuardrailsNode, textFromPreviousNode, textInput, @@ -110,7 +110,7 @@ export const sqlAgentAgentProperties: INodeProperties[] = [ }, }, { - ...promptTypeOptions, + ...promptTypeOptionsDeprecated, displayOptions: { hide: { '@version': [{ _cnd: { lte: 1.2 } }], diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts index 66d102e4058..1b8b1398f24 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts @@ -27,7 +27,7 @@ export class ChainLlm implements INodeType { icon: 'fa:link', iconColor: 'black', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8], description: 'A simple chain to prompt a large language model', defaults: { name: 'Basic LLM Chain', diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts index 322d7b016aa..3946732eff0 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/config.ts @@ -8,6 +8,7 @@ import { NodeConnectionTypes } from 'n8n-workflow'; import { promptTypeOptions, + promptTypeOptionsDeprecated, textFromGuardrailsNode, textFromPreviousNode, } from '@utils/descriptions'; @@ -95,10 +96,18 @@ export const nodeProperties: INodeProperties[] = [ }, }, { - ...promptTypeOptions, + ...promptTypeOptionsDeprecated, displayOptions: { hide: { - '@version': [1, 1.1, 1.2, 1.3], + '@version': [{ _cnd: { lte: 1.3 } }, { _cnd: { gte: 1.8 } }], + }, + }, + }, + { + ...promptTypeOptions, + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.8 } }], }, }, }, diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts index f4b9c71d4a8..a93ac5433db 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainRetrievalQA/ChainRetrievalQa.node.ts @@ -8,6 +8,7 @@ import { import { promptTypeOptions, + promptTypeOptionsDeprecated, textFromGuardrailsNode, textFromPreviousNode, } from '@utils/descriptions'; @@ -23,7 +24,7 @@ export class ChainRetrievalQa implements INodeType { icon: 'fa:link', iconColor: 'black', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7], description: 'Answer questions about retrieved documents', defaults: { name: 'Question and Answer Chain', @@ -100,10 +101,18 @@ export class ChainRetrievalQa implements INodeType { }, }, { - ...promptTypeOptions, + ...promptTypeOptionsDeprecated, displayOptions: { hide: { - '@version': [{ _cnd: { lte: 1.2 } }], + '@version': [{ _cnd: { lte: 1.2 } }, { _cnd: { gte: 1.7 } }], + }, + }, + }, + { + ...promptTypeOptions, + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.7 } }], }, }, }, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index d70025492e1..898f7c12acf 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -173,9 +173,13 @@ export class WorkflowToolService { return processedResponse; } - // If manualLogging is false we've been called by the engine and need // the structured response. + + if (metadata && 'setMetadata' in context) { + void context.setMetadata(metadata); + } + return responseData; } catch (error) { // Check if error is due to cancellation diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index 65ae8ed16b5..58d08e39c9c 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -277,7 +277,8 @@ export class ChatTrigger extends Node { { name: 'default', httpMethod: 'POST', - responseMode: '={{$parameter.options?.["responseMode"] || "lastNode" }}', + responseMode: + '={{$parameter.options?.["responseMode"] ?? ($parameter.availableInChat ? "streaming" : "lastNode") }}', path: CHAT_TRIGGER_PATH_IDENTIFIER, ndvHideMethod: true, ndvHideUrl: '={{ !$parameter.public }}', @@ -323,7 +324,7 @@ export class ChatTrigger extends Node { }, { displayName: - 'Chat will be live at the URL above once you activate this workflow. Live executions will show up in the ‘executions’ tab', + 'Chat will be live at the URL above once this workflow is published. Live executions will show up in the ‘executions’ tab', name: 'hostedChatNotice', type: 'notice', displayOptions: { @@ -336,7 +337,7 @@ export class ChatTrigger extends Node { }, { displayName: - 'Follow the instructions here to embed chat in a webpage (or just call the webhook URL at the top of this section). Chat will be live once you activate this workflow', + 'Follow the instructions here to embed chat in a webpage (or just call the webhook URL at the top of this section). Chat will be live once you publish this workflow', name: 'embeddedChatNotice', type: 'notice', displayOptions: { @@ -406,13 +407,26 @@ export class ChatTrigger extends Node { }, }, }, + { + displayName: + 'Your n8n users will be able to use this agent in Chat once this workflow is published. Make sure to share this workflow with at least Project Chat User access to all users who should use it. Currently, only streaming response mode is supported.', + name: 'availableInChatNotice', + type: 'notice', + displayOptions: { + show: { + availableInChat: [true], + }, + }, + default: '', + }, { displayName: 'Agent Name', name: 'agentName', type: 'string', default: '', noDataExpression: true, - description: 'The name of the agent on n8n Chat', + description: + 'The name of the agent on n8n Chat. Name of the workflow is used if left empty.', displayOptions: { show: { availableInChat: [true], @@ -520,9 +534,19 @@ export class ChatTrigger extends Node { displayName: 'Response Mode', name: 'responseMode', type: 'options', - options: [lastNodeResponseMode, respondNodesResponseMode], + options: [lastNodeResponseMode, respondNodesResponseMode, streamingResponseMode], default: 'lastNode', description: 'When and how to respond to the chat', + displayOptions: { show: { '/availableInChat': [false] } }, + }, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [streamingResponseMode], + default: 'streaming', + description: 'When and how to respond to the webhook', + displayOptions: { show: { '/availableInChat': [true] } }, }, ], }, @@ -541,6 +565,15 @@ export class ChatTrigger extends Node { default: {}, options: [ ...commonOptionsFields, + { + displayName: 'Response Mode', + name: 'responseMode', + type: 'options', + options: [streamingResponseMode], + default: 'streaming', + description: 'When and how to respond to the webhook', + displayOptions: { show: { '/availableInChat': [true] } }, + }, { displayName: 'Response Mode', name: 'responseMode', @@ -548,7 +581,7 @@ export class ChatTrigger extends Node { options: [lastNodeResponseMode, streamingResponseMode, respondToWebhookResponseMode], default: 'lastNode', description: 'When and how to respond to the chat', - displayOptions: { show: { '/mode': ['webhook'] } }, + displayOptions: { show: { '/mode': ['webhook'], '/availableInChat': [false] } }, }, { displayName: 'Response Mode', @@ -557,7 +590,7 @@ export class ChatTrigger extends Node { options: [lastNodeResponseMode, streamingResponseMode, respondNodesResponseMode], default: 'lastNode', description: 'When and how to respond to the webhook', - displayOptions: { show: { '/mode': ['hostedChat'] } }, + displayOptions: { show: { '/mode': ['hostedChat'], '/availableInChat': [false] } }, }, ], }, @@ -643,6 +676,7 @@ export class ChatTrigger extends Node { }; } + const availableInChat = ctx.getNodeParameter('availableInChat', false); const options = ctx.getNodeParameter('options', {}); validateNodeParameters( options, @@ -664,7 +698,9 @@ export class ChatTrigger extends Node { const loadPreviousSession = options.loadPreviousSession; assertValidLoadPreviousSessionOption(loadPreviousSession, ctx.getNode()); - const enableStreaming = options.responseMode === 'streaming'; + const enableStreaming = availableInChat + ? !options.responseMode || options.responseMode === 'streaming' + : options.responseMode === 'streaming'; const req = ctx.getRequestObject(); const webhookName = ctx.getWebhookName(); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts index a9480c7fa91..140c3f66789 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/__test__/ChatTrigger.node.test.ts @@ -202,6 +202,74 @@ describe('ChatTrigger Node', () => { }); }); + it('should enable streaming when availableInChat is true and responseMode is not set', async () => { + // Mock options with availableInChat true and no responseMode + mockContext.getNodeParameter.mockImplementation( + ( + paramName: string, + defaultValue?: boolean | string | object, + ): boolean | string | object | undefined => { + if (paramName === 'public') return true; + if (paramName === 'mode') return 'hostedChat'; + if (paramName === 'options') return {}; + if (paramName === 'availableInChat') return true; + return defaultValue; + }, + ); + + // Call the webhook method + const result = await chatTrigger.webhook(mockContext); + + // Verify streaming headers are set + expect(mockResponse.writeHead).toHaveBeenCalledWith(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + expect(mockResponse.flushHeaders).toHaveBeenCalled(); + + // Verify response structure for streaming + expect(result).toEqual({ + workflowData: expect.any(Array), + noWebhookResponse: true, + }); + }); + + it('should enable streaming when availableInChat is true and responseMode is "streaming"', async () => { + // Mock options with availableInChat true and streaming responseMode + mockContext.getNodeParameter.mockImplementation( + ( + paramName: string, + defaultValue?: boolean | string | object, + ): boolean | string | object | undefined => { + if (paramName === 'public') return true; + if (paramName === 'mode') return 'hostedChat'; + if (paramName === 'options') return { responseMode: 'streaming' }; + if (paramName === 'availableInChat') return true; + return defaultValue; + }, + ); + + // Call the webhook method + const result = await chatTrigger.webhook(mockContext); + + // Verify streaming headers are set + expect(mockResponse.writeHead).toHaveBeenCalledWith(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Transfer-Encoding': 'chunked', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + expect(mockResponse.flushHeaders).toHaveBeenCalled(); + + // Verify response structure for streaming + expect(result).toEqual({ + workflowData: expect.any(Array), + noWebhookResponse: true, + }); + }); + it('should handle multipart form data with streaming enabled', async () => { // Mock multipart form data request mockRequest.contentType = 'multipart/form-data'; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts index cb692153b14..4aac1a622e1 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/GoogleGemini.node.test.ts @@ -21,6 +21,7 @@ describe('GoogleGemini Node', () => { beforeEach(() => { jest.clearAllMocks(); + executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode); }); describe('Text -> Message', () => { @@ -180,6 +181,821 @@ describe('GoogleGemini Node', () => { }, ); }); + + describe('Version 1.0', () => { + it('should use codeExecution from options for version 1.0', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello, world!' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return {}; + case 'options': + return { + codeExecution: true, + }; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Hello, world!' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + codeExecution: {}, + }, + ]), + }), + }), + ); + }); + }); + + describe('Version 1.1', () => { + beforeEach(() => { + executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1.1 } as INode); + }); + + it('should include Google Search tool when enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'What is the weather today?' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + googleSearch: true, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'The weather is sunny.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + googleSearch: {}, + }, + ]), + }), + }), + ); + }); + + it('should include Google Maps tool when enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Find restaurants near me' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + googleMaps: {}, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Here are some restaurants.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + googleMaps: {}, + }, + ]), + }), + }), + ); + }); + + it('should include toolConfig with latLng when Google Maps has coordinates', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Find restaurants near me' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + googleMaps: { + latitude: 34.050481, + longitude: -118.248526, + }, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Here are some restaurants.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + googleMaps: {}, + }, + ]), + toolConfig: { + retrievalConfig: { + latLng: { + latitude: 34.050481, + longitude: -118.248526, + }, + }, + }, + }), + }), + ); + }); + + it('should include URL Context tool when enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Summarize this URL' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + urlContext: true, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'URL summary.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + urlContext: {}, + }, + ]), + }), + }), + ); + }); + + it('should include File Search tool with fileSearchStoreNames when enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Search my files' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + fileSearch: { + fileSearchStoreNames: '["store1", "store2"]', + }, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'File search results.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + fileSearch: { + fileSearchStoreNames: ['store1', 'store2'], + }, + }, + ]), + }), + }), + ); + }); + + it('should include File Search tool with metadataFilter when provided', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Tell me about the book' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + fileSearch: { + fileSearchStoreNames: '["store1"]', + metadataFilter: 'author="Robert Graves"', + }, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Book information.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + fileSearch: { + fileSearchStoreNames: ['store1'], + metadataFilter: 'author="Robert Graves"', + }, + }, + ]), + }), + }), + ); + }); + + it('should include Code Execution tool when enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Calculate 2+2' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + codeExecution: true, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'The result is 4.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + codeExecution: {}, + }, + ]), + }), + }), + ); + }); + + it('should include multiple built-in tools when enabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Complex query' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + googleSearch: true, + urlContext: true, + codeExecution: true, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Response with multiple tools.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + googleSearch: {}, + }, + { + urlContext: {}, + }, + { + codeExecution: {}, + }, + ]), + }), + }), + ); + }); + + it('should not include toolConfig when Google Maps coordinates are empty', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Find restaurants' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return { + googleMaps: { + latitude: '', + longitude: '', + }, + }; + case 'options': + return {}; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Restaurants found.' }], + role: 'model', + }, + }, + ], + }); + + await text.message.execute.call(executeFunctionsMock, 0); + + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.not.objectContaining({ + toolConfig: expect.anything(), + }), + }), + ); + expect(apiRequestMock).toHaveBeenCalledWith( + 'POST', + '/v1beta/models/gemini-2.5-flash:generateContent', + expect.objectContaining({ + body: expect.objectContaining({ + tools: expect.arrayContaining([ + { + googleMaps: {}, + }, + ]), + }), + }), + ); + }); + + describe('includeMergedResponse', () => { + it('should include mergedResponse per candidate when enabled and simplify is true', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return {}; + case 'options': + return { + includeMergedResponse: true, + }; + case 'options.maxToolsIterations': + return 15; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Hello' }, { text: ' World' }], + role: 'model', + }, + }, + ], + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'Hello' }, { text: ' World' }], + role: 'model', + }, + mergedResponse: 'Hello World', + }, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('should include mergedResponse per candidate when enabled and simplify is false', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello' }]; + case 'simplify': + return false; + case 'jsonOutput': + return false; + case 'builtInTools': + return {}; + case 'options': + return { + includeMergedResponse: true, + }; + case 'options.maxToolsIterations': + return 15; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Response' }, { text: ' text' }], + role: 'model', + }, + }, + ], + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + candidates: [ + { + content: { + parts: [{ text: 'Response' }, { text: ' text' }], + role: 'model', + }, + mergedResponse: 'Response text', + }, + ], + }, + pairedItem: { item: 0 }, + }, + ]); + }); + + it('should not include mergedResponse when disabled', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return {}; + case 'options': + return { + includeMergedResponse: false, + }; + case 'options.maxToolsIterations': + return 15; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Hello' }, { text: ' World' }], + role: 'model', + }, + }, + ], + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toEqual([ + { + json: { + content: { + parts: [{ text: 'Hello' }, { text: ' World' }], + role: 'model', + }, + }, + pairedItem: { item: 0 }, + }, + ]); + expect(result[0].json).not.toHaveProperty('mergedResponse'); + }); + + it('should handle multiple candidates with different merged responses', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return {}; + case 'options': + return { + includeMergedResponse: true, + candidateCount: 2, + }; + case 'options.maxToolsIterations': + return 15; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'First' }, { text: ' candidate' }], + role: 'model', + }, + }, + { + content: { + parts: [{ text: 'Second' }, { text: ' candidate' }], + role: 'model', + }, + }, + ], + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result).toHaveLength(2); + expect(result[0].json).toEqual({ + content: { + parts: [{ text: 'First' }, { text: ' candidate' }], + role: 'model', + }, + mergedResponse: 'First candidate', + }); + expect(result[1].json).toEqual({ + content: { + parts: [{ text: 'Second' }, { text: ' candidate' }], + role: 'model', + }, + mergedResponse: 'Second candidate', + }); + }); + + it('should only merge text parts and ignore non-text parts', async () => { + executeFunctionsMock.getNodeParameter.mockImplementation((parameter: string) => { + switch (parameter) { + case 'modelId': + return 'models/gemini-2.5-flash'; + case 'messages.values': + return [{ role: 'user', content: 'Hello' }]; + case 'simplify': + return true; + case 'jsonOutput': + return false; + case 'builtInTools': + return {}; + case 'options': + return { + includeMergedResponse: true, + }; + case 'options.maxToolsIterations': + return 15; + default: + return undefined; + } + }); + executeFunctionsMock.getNodeInputs.mockReturnValue([{ type: 'main' }]); + apiRequestMock.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Text' }, { executableCode: {} }, { text: ' more text' }], + role: 'model', + }, + }, + ], + }); + + const result = await text.message.execute.call(executeFunctionsMock, 0); + + expect(result[0].json).toEqual({ + content: { + parts: [{ text: 'Text' }, { executableCode: {} }, { text: ' more text' }], + role: 'model', + }, + mergedResponse: 'Text more text', + }); + }); + }); + }); }); describe('Audio -> Analyze', () => { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts index ae5d14e89ee..35acce905ec 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/GoogleGemini/actions/text/message.operation.ts @@ -1,21 +1,22 @@ +import { getConnectedTools } from '@utils/helpers'; import { type IDataObject, type IExecuteFunctions, type INodeExecutionData, type INodeProperties, + jsonParse, + updateDisplayOptions, validateNodeParameters, } from 'n8n-workflow'; -import { updateDisplayOptions } from 'n8n-workflow'; import zodToJsonSchema from 'zod-to-json-schema'; -import { getConnectedTools } from '@utils/helpers'; - import type { GenerateContentRequest, GenerateContentResponse, Content, Tool, GenerateContentGenerationConfig, + BuiltInTools, } from '../../helpers/interfaces'; import { apiRequest } from '../../transport'; import { modelRLC } from '../descriptions'; @@ -86,6 +87,97 @@ const properties: INodeProperties[] = [ description: 'Whether to attempt to return the response in JSON format', default: false, }, + { + displayName: 'Built-in Tools', + name: 'builtInTools', + placeholder: 'Add Built-in Tool', + type: 'collection', + default: {}, + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, + options: [ + { + displayName: 'Google Search', + name: 'googleSearch', + type: 'boolean', + default: true, + description: + 'Whether to allow the model to search the web using Google Search to get real-time information', + }, + { + displayName: 'Google Maps', + name: 'googleMaps', + type: 'collection', + default: { latitude: '', longitude: '' }, + options: [ + { + displayName: 'Latitude', + name: 'latitude', + type: 'number', + default: '', + description: 'The latitude coordinate for location-based queries', + typeOptions: { + numberPrecision: 6, + }, + }, + { + displayName: 'Longitude', + name: 'longitude', + type: 'number', + default: '', + description: 'The longitude coordinate for location-based queries', + typeOptions: { + numberPrecision: 6, + }, + }, + ], + }, + { + displayName: 'URL Context', + name: 'urlContext', + type: 'boolean', + default: true, + description: 'Whether to allow the model to read and analyze content from specific URLs', + }, + { + displayName: 'File Search', + name: 'fileSearch', + type: 'collection', + default: { fileSearchStoreNames: '[]' }, + options: [ + { + displayName: 'File Search Store Names', + name: 'fileSearchStoreNames', + description: + 'The file search store names to use for the file search. File search stores are managed via Google AI Studio.', + type: 'json', + default: '[]', + required: true, + }, + { + displayName: 'Metadata Filter', + name: 'metadataFilter', + type: 'string', + default: '', + description: + 'Use metadata filter to search within a subset of documents. Example: author="Robert Graves".', + placeholder: 'e.g. author="John Doe"', + }, + ], + }, + { + displayName: 'Code Execution', + name: 'codeExecution', + type: 'boolean', + default: true, + description: + 'Whether to allow the model to execute code it generates to produce a response. Supported only by certain models.', + }, + ], + }, { displayName: 'Options', name: 'options', @@ -93,6 +185,19 @@ const properties: INodeProperties[] = [ type: 'collection', default: {}, options: [ + { + displayName: 'Include Merged Response', + name: 'includeMergedResponse', + type: 'boolean', + default: false, + description: + 'Whether to include a single output string merging all text parts of the response', + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, + }, { displayName: 'System Message', name: 'systemMessage', @@ -107,6 +212,11 @@ const properties: INodeProperties[] = [ default: false, description: 'Whether to allow the model to execute code it generates to produce a response. Supported only by certain models.', + displayOptions: { + show: { + '@version': [{ _cnd: { eq: 1 } }], + }, + }, }, { displayName: 'Frequency Penalty', @@ -243,9 +353,11 @@ export async function execute(this: IExecuteFunctions, i: number): Promise= 1.1) { + if (builtInTools) { + if (builtInTools.googleSearch) { + tools.push({ + googleSearch: {}, + }); + } + + const googleMapsOptions = builtInTools.googleMaps; + if (googleMapsOptions) { + tools.push({ + googleMaps: {}, + }); + + // Build toolConfig with retrievalConfig if latitude/longitude are provided + const latitude = googleMapsOptions.latitude; + const longitude = googleMapsOptions.longitude; + if ( + latitude !== undefined && + latitude !== '' && + longitude !== undefined && + longitude !== '' + ) { + toolConfig = { + retrievalConfig: { + latLng: { + latitude: Number(latitude), + longitude: Number(longitude), + }, + }, + }; + } + } + + if (builtInTools.urlContext) { + tools.push({ + urlContext: {}, + }); + } + + const fileSearchOptions = builtInTools.fileSearch; + if (fileSearchOptions) { + const fileSearchStoreNamesRaw = fileSearchOptions.fileSearchStoreNames; + const metadataFilter = fileSearchOptions.metadataFilter; + let fileSearchStoreNames: string[] | undefined; + if (fileSearchStoreNamesRaw) { + const parsed = jsonParse(fileSearchStoreNamesRaw, { + errorMessage: 'Failed to parse file search store names', + }); + if (Array.isArray(parsed)) { + fileSearchStoreNames = parsed; + } + } + + tools.push({ + fileSearch: { + ...(fileSearchStoreNames && { fileSearchStoreNames }), + ...(metadataFilter && { metadataFilter }), + }, + }); + } + + if (builtInTools.codeExecution) { + tools.push({ + codeExecution: {}, + }); + } + } } const contents: Content[] = messages.map((m) => ({ @@ -317,6 +503,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise ({ + ...candidate, + mergedResponse: candidate.content.parts + .filter((part) => 'text' in part) + .map((part) => (part as { text: string }).text) + .join(''), + })) + : response.candidates; + if (simplify) { - return response.candidates.map((candidate) => ({ + return candidates.map((candidate) => ({ json: candidate, pairedItem: { item: i }, })); @@ -377,7 +574,10 @@ export async function execute(this: IExecuteFunctions, i: number): Promise; + googleSearch?: object; + googleMaps?: object; + urlContext?: object; + fileSearch?: { + fileSearchStoreNames?: string[]; + metadataFilter?: string; + }; codeExecution?: object; } diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts index 4a209ba43ed..9c90bf616f5 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts @@ -18,7 +18,7 @@ import { } from 'n8n-workflow'; import { OpenAI as OpenAIClient } from 'openai'; -import { promptTypeOptions } from '@utils/descriptions'; +import { promptTypeOptionsDeprecated } from '@utils/descriptions'; import { getConnectedTools, getPromptInputByType } from '@utils/helpers'; import { getTracingConfig } from '@utils/tracing'; @@ -29,7 +29,7 @@ import { getProxyAgent } from '@utils/httpProxyAgent'; const properties: INodeProperties[] = [ assistantRLC, { - ...promptTypeOptions, + ...promptTypeOptionsDeprecated, name: 'prompt', }, { diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts index 5d0159033ea..21707eb4b04 100644 --- a/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts +++ b/packages/@n8n/nodes-langchain/utils/agent-execution/buildSteps.ts @@ -2,7 +2,157 @@ import { AIMessage } from '@langchain/core/messages'; import { nodeNameToToolName } from 'n8n-workflow'; import type { EngineResponse, IDataObject } from 'n8n-workflow'; -import type { RequestResponseMetadata, ToolCallData } from './types'; +import type { + RequestResponseMetadata, + ToolCallData, + ThinkingContentBlock, + RedactedThinkingContentBlock, + ToolUseContentBlock, +} from './types'; + +/** + * Provider-specific metadata extracted from tool action metadata + */ +interface ProviderMetadata { + /** Gemini thought_signature for extended thinking */ + thoughtSignature?: string; + /** Anthropic thinking content */ + thinkingContent?: string; + /** Anthropic thinking type (thinking or redacted_thinking) */ + thinkingType?: 'thinking' | 'redacted_thinking'; + /** Anthropic thinking signature */ + thinkingSignature?: string; +} + +/** + * Extracts provider-specific metadata from tool action metadata. + * Validates and normalizes metadata from different LLM providers. + * + * @param metadata - The request/response metadata from tool action + * @returns Extracted and validated provider metadata + */ +function extractProviderMetadata(metadata?: RequestResponseMetadata): ProviderMetadata { + if (!metadata) return {}; + + // Extract Google/Gemini metadata + const thoughtSignature = + typeof metadata.google?.thoughtSignature === 'string' + ? metadata.google.thoughtSignature + : undefined; + + // Extract Anthropic metadata + const thinkingContent = + typeof metadata.anthropic?.thinkingContent === 'string' + ? metadata.anthropic.thinkingContent + : undefined; + + const thinkingType = + metadata.anthropic?.thinkingType === 'thinking' || + metadata.anthropic?.thinkingType === 'redacted_thinking' + ? metadata.anthropic.thinkingType + : undefined; + + const thinkingSignature = + typeof metadata.anthropic?.thinkingSignature === 'string' + ? metadata.anthropic.thinkingSignature + : undefined; + + return { + thoughtSignature, + thinkingContent, + thinkingType, + thinkingSignature, + }; +} + +/** + * Builds Anthropic-specific content blocks for thinking mode. + * Creates an array with thinking block followed by tool_use block. + * + * IMPORTANT: The thinking block must come before tool_use in the message. + * When content is an array, LangChain ignores tool_calls field for Anthropic, + * so tool_use blocks must be in the content array. + * + * @param thinkingContent - The thinking content from Anthropic + * @param thinkingType - Type of thinking block (thinking or redacted_thinking) + * @param thinkingSignature - Optional signature for thinking block + * @param toolInput - The tool input data + * @param toolId - The tool call ID + * @param toolName - The tool name + * @returns Array of content blocks with thinking and tool_use + */ +function buildAnthropicContentBlocks( + thinkingContent: string, + thinkingType: 'thinking' | 'redacted_thinking', + thinkingSignature: string | undefined, + toolInput: IDataObject, + toolId: string, + toolName: string, +): Array { + // Create thinking block with correct field names for Anthropic API + const thinkingBlock: ThinkingContentBlock | RedactedThinkingContentBlock = + thinkingType === 'thinking' + ? { + type: 'thinking', + thinking: thinkingContent, + signature: thinkingSignature ?? '', // Use original signature if available + } + : { + type: 'redacted_thinking', + data: thinkingContent, + }; + + // Create tool_use block (required for Anthropic when using structured content) + const toolInputData = toolInput.input; + const toolUseBlock: ToolUseContentBlock = { + type: 'tool_use', + id: toolId, + name: toolName, + input: + toolInputData && typeof toolInputData === 'object' + ? (toolInputData as Record) + : {}, + }; + + return [thinkingBlock, toolUseBlock]; +} + +/** + * Builds message content for AI message, handling provider-specific formats. + * For Anthropic thinking mode, creates content blocks with thinking and tool_use. + * For other providers, creates simple string content. + * + * @param providerMetadata - Provider-specific metadata + * @param toolInput - The tool input data + * @param toolId - The tool call ID + * @param toolName - The tool name + * @param nodeName - The node name for fallback string content + * @returns Message content (string or content blocks array) + */ +function buildMessageContent( + providerMetadata: ProviderMetadata, + toolInput: IDataObject, + toolId: string, + toolName: string, + nodeName: string, +): string | Array { + const { thinkingContent, thinkingType, thinkingSignature } = providerMetadata; + + // Anthropic thinking mode: build content blocks + if (thinkingContent && thinkingType) { + return buildAnthropicContentBlocks( + thinkingContent, + thinkingType, + thinkingSignature, + toolInput, + toolId, + toolName, + ); + } + + // Default: simple string content + return `Calling ${nodeName} with input: ${JSON.stringify(toolInput)}`; +} /** * Rebuilds the agent steps from previous tool call responses. @@ -43,34 +193,51 @@ export function buildSteps( if (step) { continue; } - // Create a synthetic AI message for the messageLog - // This represents the AI's decision to call the tool - // Extract thought_signature from metadata if present (for Gemini 3) - const rawThoughtSignature = tool.action.metadata?.thoughtSignature; - const thoughtSignature = - typeof rawThoughtSignature === 'string' ? rawThoughtSignature : undefined; - // Build the tool call object with thought_signature if present - // The thought_signature must be part of the tool call itself for Gemini 3 + // Extract provider-specific metadata (Gemini, Anthropic, etc.) + const providerMetadata = extractProviderMetadata(tool.action.metadata); + + // Build tool ID and name for reuse + const toolId = typeof toolInput?.id === 'string' ? toolInput.id : 'reconstructed_call'; + const toolName = nodeNameToToolName(tool.action.nodeName); + + // Build the tool call object with thought_signature if present (for Gemini) const toolCall = { - id: typeof toolInput?.id === 'string' ? toolInput.id : 'reconstructed_call', - name: nodeNameToToolName(tool.action.nodeName), + id: toolId, + name: toolName, args: toolInput, type: 'tool_call' as const, additional_kwargs: { - ...(thoughtSignature && { thought_signature: thoughtSignature }), + ...(providerMetadata.thoughtSignature && { + thought_signature: providerMetadata.thoughtSignature, + }), }, }; + // Build message content using provider-specific logic + const messageContent = buildMessageContent( + providerMetadata, + toolInput, + toolId, + toolName, + tool.action.nodeName, + ); + const syntheticAIMessage = new AIMessage({ - content: `Calling ${tool.action.nodeName} with input: ${JSON.stringify(toolInput)}`, - tool_calls: [toolCall], + content: messageContent, + // Note: tool_calls is only used when content is a string + // When content is an array (thinking mode), tool_use blocks are in the content array + ...(typeof messageContent === 'string' && { tool_calls: [toolCall] }), }); + const toolInputForResult = toolInput.input; const toolResult = { action: { tool: nodeNameToToolName(tool.action.nodeName), - toolInput: (toolInput.input as IDataObject) || {}, + toolInput: + toolInputForResult && typeof toolInputForResult === 'object' + ? (toolInputForResult as IDataObject) + : {}, log: toolInput.log || syntheticAIMessage.content, messageLog: [syntheticAIMessage], toolCallId: toolInput?.id, diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/createEngineRequests.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/createEngineRequests.ts index e3aff843b7c..f9bec3e19dc 100644 --- a/packages/@n8n/nodes-langchain/utils/agent-execution/createEngineRequests.ts +++ b/packages/@n8n/nodes-langchain/utils/agent-execution/createEngineRequests.ts @@ -2,6 +2,7 @@ import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { EngineRequest, IDataObject } from 'n8n-workflow'; +import { isThinkingBlock, isRedactedThinkingBlock, isGeminiThoughtSignatureBlock } from './types'; import type { RequestResponseMetadata, ToolCallRequest } from './types'; /** @@ -29,34 +30,50 @@ export async function createEngineRequests( if (!foundTool) return undefined; - const nodeName = foundTool.metadata?.sourceNodeName as string | undefined; + const nodeName = foundTool.metadata?.sourceNodeName; - // Ensure nodeName is defined - if (!nodeName) return undefined; + // Ensure nodeName is defined and is a string + if (typeof nodeName !== 'string') return undefined; // For toolkit tools, include the tool name so the node knows which tool to execute - const input = foundTool.metadata?.isFromToolkit - ? { ...toolCall.toolInput, tool: toolCall.tool } - : toolCall.toolInput; + const input: IDataObject = foundTool.metadata?.isFromToolkit + ? ({ ...toolCall.toolInput, tool: toolCall.tool } as IDataObject) + : (toolCall.toolInput as IDataObject); // Extract thought_signature from the AIMessage in messageLog (for Gemini 3) let thoughtSignature: string | undefined; + // Extract thinking blocks from the AIMessage in messageLog (for Anthropic) + let thinkingContent: string | undefined; + let thinkingType: 'thinking' | 'redacted_thinking' | undefined; + let thinkingSignature: string | undefined; + if (toolCall.messageLog && Array.isArray(toolCall.messageLog)) { for (const message of toolCall.messageLog) { - // Check if message has content that could contain thought_signature + // Check if message has content that could contain thought_signature or thinking blocks if (message && typeof message === 'object' && 'content' in message) { const content = message.content; // Content can be string or array of content blocks if (Array.isArray(content)) { - // Look for thought_signature in content blocks + // Look for thought_signature in content blocks (Gemini) + // and thinking/redacted_thinking blocks (Anthropic) for (const block of content) { - if (block && typeof block === 'object' && 'thoughtSignature' in block) { - thoughtSignature = block.thoughtSignature as string; - break; + // Gemini thought_signature + if (isGeminiThoughtSignatureBlock(block)) { + thoughtSignature = block.thoughtSignature; + } + + // Anthropic thinking blocks + if (isThinkingBlock(block)) { + thinkingContent = block.thinking; + thinkingType = 'thinking'; + thinkingSignature = block.signature; + } else if (isRedactedThinkingBlock(block)) { + thinkingContent = block.data; + thinkingType = 'redacted_thinking'; } } } - if (thoughtSignature) break; + if (thoughtSignature || thinkingContent) break; } } } @@ -64,12 +81,23 @@ export async function createEngineRequests( return { actionType: 'ExecutionNodeAction' as const, nodeName, - input: input as IDataObject, + input, type: NodeConnectionTypes.AiTool, id: toolCall.toolCallId, metadata: { itemIndex, - ...(thoughtSignature && { thoughtSignature }), + ...(thoughtSignature && { + google: { + thoughtSignature, + }, + }), + ...(thinkingContent && { + anthropic: { + thinkingContent, + thinkingType, + thinkingSignature, + }, + }), }, }; }) diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts index bdbb67bea08..e3994e9e10e 100644 --- a/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts +++ b/packages/@n8n/nodes-langchain/utils/agent-execution/test/buildSteps.test.ts @@ -538,4 +538,274 @@ describe('buildSteps', () => { expect(result[0].observation).toBe('""'); }); }); + + describe('Anthropic thinking blocks reconstruction', () => { + it('should reconstruct AIMessage with thinking content blocks', () => { + const response: EngineResponse = { + actionResponses: [ + { + action: { + actionType: 'ExecutionNodeAction', + nodeName: 'Calculator', + input: { + id: 'call_123', + input: { expression: '2+2' }, + }, + type: NodeConnectionTypes.AiTool, + id: 'call_123', + metadata: { + itemIndex: 0, + anthropic: { + thinkingContent: 'I need to calculate 2+2 using the calculator tool.', + thinkingType: 'thinking', + thinkingSignature: 'test_signature_123', + }, + }, + }, + data: { + data: { + ai_tool: [[{ json: { result: '4' } }]], + }, + executionTime: 0, + startTime: 0, + executionIndex: 0, + source: [], + }, + }, + ], + metadata: {}, + }; + + const result = buildSteps(response, itemIndex); + + expect(result).toHaveLength(1); + expect(result[0].action.messageLog).toBeDefined(); + expect(result[0].action.messageLog).toHaveLength(1); + + const message = result[0].action.messageLog![0]; + const content = message.content; + expect(Array.isArray(content)).toBe(true); + expect(content).toHaveLength(2); + // First block should be thinking + expect(content[0]).toEqual({ + type: 'thinking', + thinking: 'I need to calculate 2+2 using the calculator tool.', + signature: 'test_signature_123', + }); + // Second block should be tool_use + expect(content[1]).toMatchObject({ + type: 'tool_use', + id: 'call_123', + name: 'Calculator', + }); + }); + + it('should reconstruct AIMessage with redacted_thinking content blocks', () => { + const response: EngineResponse = { + actionResponses: [ + { + action: { + actionType: 'ExecutionNodeAction', + nodeName: 'Search', + input: { + id: 'call_456', + input: { query: 'sensitive search' }, + }, + type: NodeConnectionTypes.AiTool, + id: 'call_456', + metadata: { + itemIndex: 0, + anthropic: { + thinkingContent: 'This thinking was redacted by safety systems.', + thinkingType: 'redacted_thinking', + }, + }, + }, + data: { + data: { + ai_tool: [[{ json: { results: [] } }]], + }, + executionTime: 0, + startTime: 0, + executionIndex: 0, + source: [], + }, + }, + ], + metadata: {}, + }; + + const result = buildSteps(response, itemIndex); + + expect(result).toHaveLength(1); + expect(result[0].action.messageLog).toBeDefined(); + expect(result[0].action.messageLog).toHaveLength(1); + + const message = result[0].action.messageLog![0]; + const content = message.content; + expect(Array.isArray(content)).toBe(true); + expect(content).toHaveLength(2); + // First block should be redacted_thinking + expect(content[0]).toEqual({ + type: 'redacted_thinking', + data: 'This thinking was redacted by safety systems.', + }); + // Second block should be tool_use + expect(content[1]).toMatchObject({ + type: 'tool_use', + id: 'call_456', + name: 'Search', + }); + }); + + it('should use string content when no thinking blocks present', () => { + const response: EngineResponse = { + actionResponses: [ + { + action: { + actionType: 'ExecutionNodeAction', + nodeName: 'Calculator', + input: { + id: 'call_123', + input: { expression: '2+2' }, + }, + type: NodeConnectionTypes.AiTool, + id: 'call_123', + metadata: { + itemIndex: 0, + // No thinking blocks + }, + }, + data: { + data: { + ai_tool: [[{ json: { result: '4' } }]], + }, + executionTime: 0, + startTime: 0, + executionIndex: 0, + source: [], + }, + }, + ], + metadata: {}, + }; + + const result = buildSteps(response, itemIndex); + + expect(result).toHaveLength(1); + expect(result[0].action.messageLog).toBeDefined(); + expect(result[0].action.messageLog).toHaveLength(1); + + const message = result[0].action.messageLog![0]; + expect(typeof message.content).toBe('string'); + expect(message.content).toContain('Calling Calculator'); + expect(message).toHaveProperty('tool_calls'); + }); + + it('should handle thinking content without thinkingType', () => { + const response: EngineResponse = { + actionResponses: [ + { + action: { + actionType: 'ExecutionNodeAction', + nodeName: 'Calculator', + input: { + id: 'call_123', + input: { expression: '2+2' }, + }, + type: NodeConnectionTypes.AiTool, + id: 'call_123', + metadata: { + itemIndex: 0, + anthropic: { + thinkingContent: 'Some thinking content', + // Missing thinkingType + }, + }, + }, + data: { + data: { + ai_tool: [[{ json: { result: '4' } }]], + }, + executionTime: 0, + startTime: 0, + executionIndex: 0, + source: [], + }, + }, + ], + metadata: {}, + }; + + const result = buildSteps(response, itemIndex); + + expect(result).toHaveLength(1); + const message = result[0].action.messageLog![0]; + // Should fall back to string content when thinkingType is missing + expect(typeof message.content).toBe('string'); + }); + + it('should work alongside Gemini thought_signature', () => { + const response: EngineResponse = { + actionResponses: [ + { + action: { + actionType: 'ExecutionNodeAction', + nodeName: 'Calculator', + input: { + id: 'call_123', + input: { expression: '2+2' }, + }, + type: NodeConnectionTypes.AiTool, + id: 'call_123', + metadata: { + itemIndex: 0, + google: { + thoughtSignature: 'Gemini thought signature', + }, + anthropic: { + thinkingContent: 'Anthropic thinking content', + thinkingType: 'thinking', + thinkingSignature: 'anthropic_sig_456', + }, + }, + }, + data: { + data: { + ai_tool: [[{ json: { result: '4' } }]], + }, + executionTime: 0, + startTime: 0, + executionIndex: 0, + source: [], + }, + }, + ], + metadata: {}, + }; + + const result = buildSteps(response, itemIndex); + + expect(result).toHaveLength(1); + const message = result[0].action.messageLog![0]; + // Should use Anthropic thinking blocks in content + const content = message.content; + expect(Array.isArray(content)).toBe(true); + expect(content).toHaveLength(2); + // First block should be thinking + expect(content[0]).toEqual({ + type: 'thinking', + thinking: 'Anthropic thinking content', + signature: 'anthropic_sig_456', + }); + // Second block should be tool_use + expect(content[1]).toMatchObject({ + type: 'tool_use', + id: 'call_123', + name: 'Calculator', + }); + // When thinking blocks are present, tool_calls is not used (everything is in content array) + // Note: Anthropic thinking and Gemini thought_signature are mutually exclusive + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/test/createEngineRequests.test.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/test/createEngineRequests.test.ts index a1537287467..939591fbe45 100644 --- a/packages/@n8n/nodes-langchain/utils/agent-execution/test/createEngineRequests.test.ts +++ b/packages/@n8n/nodes-langchain/utils/agent-execution/test/createEngineRequests.test.ts @@ -340,4 +340,169 @@ describe('createEngineRequests', () => { }); }); }); + + describe('Anthropic thinking blocks extraction', () => { + it('should extract thinking content from Anthropic message with thinking blocks', async () => { + const tools = [createMockTool('calculator', { sourceNodeName: 'Calculator' })]; + + const toolCalls: ToolCallRequest[] = [ + { + tool: 'calculator', + toolInput: { expression: '2+2' }, + toolCallId: 'call_123', + messageLog: [ + { + content: [ + { + type: 'thinking', + thinking: 'I need to calculate 2+2 using the calculator tool.', + signature: 'test_signature_123', + }, + { + type: 'tool_use', + id: 'call_123', + name: 'calculator', + input: { expression: '2+2' }, + }, + ], + }, + ], + }, + ]; + + const result = await createEngineRequests(toolCalls, 0, tools); + + expect(result).toHaveLength(1); + expect(result[0].metadata.anthropic?.thinkingContent).toBe( + 'I need to calculate 2+2 using the calculator tool.', + ); + expect(result[0].metadata.anthropic?.thinkingType).toBe('thinking'); + expect(result[0].metadata.anthropic?.thinkingSignature).toBe('test_signature_123'); + }); + + it('should extract redacted_thinking content from Anthropic message', async () => { + const tools = [createMockTool('search', { sourceNodeName: 'Search' })]; + + const toolCalls: ToolCallRequest[] = [ + { + tool: 'search', + toolInput: { query: 'sensitive search' }, + toolCallId: 'call_456', + messageLog: [ + { + content: [ + { + type: 'redacted_thinking', + data: 'This thinking was redacted by safety systems.', + }, + { + type: 'tool_use', + id: 'call_456', + name: 'search', + input: { query: 'sensitive search' }, + }, + ], + }, + ], + }, + ]; + + const result = await createEngineRequests(toolCalls, 0, tools); + + expect(result).toHaveLength(1); + expect(result[0].metadata.anthropic?.thinkingContent).toBe( + 'This thinking was redacted by safety systems.', + ); + expect(result[0].metadata.anthropic?.thinkingType).toBe('redacted_thinking'); + }); + + it('should not extract thinking when content is string format', async () => { + const tools = [createMockTool('calculator', { sourceNodeName: 'Calculator' })]; + + const toolCalls: ToolCallRequest[] = [ + { + tool: 'calculator', + toolInput: { expression: '2+2' }, + toolCallId: 'call_123', + messageLog: [ + { + content: 'Simple string content', + }, + ], + }, + ]; + + const result = await createEngineRequests(toolCalls, 0, tools); + + expect(result).toHaveLength(1); + expect(result[0].metadata.anthropic).toBeUndefined(); + }); + + it('should not extract thinking when no thinking blocks present', async () => { + const tools = [createMockTool('calculator', { sourceNodeName: 'Calculator' })]; + + const toolCalls: ToolCallRequest[] = [ + { + tool: 'calculator', + toolInput: { expression: '2+2' }, + toolCallId: 'call_123', + messageLog: [ + { + content: [ + { + type: 'text', + text: 'Just some text', + }, + { + type: 'tool_use', + id: 'call_123', + name: 'calculator', + input: { expression: '2+2' }, + }, + ], + }, + ], + }, + ]; + + const result = await createEngineRequests(toolCalls, 0, tools); + + expect(result).toHaveLength(1); + expect(result[0].metadata.anthropic).toBeUndefined(); + }); + + it('should work with both Gemini thoughtSignature and Anthropic thinking blocks', async () => { + const tools = [createMockTool('calculator', { sourceNodeName: 'Calculator' })]; + + const toolCalls: ToolCallRequest[] = [ + { + tool: 'calculator', + toolInput: { expression: '2+2' }, + toolCallId: 'call_123', + messageLog: [ + { + content: [ + { + type: 'thinking', + thinking: 'Anthropic thinking content', + signature: 'anthropic_sig_456', + }, + { + thoughtSignature: 'Gemini thought signature', + }, + ], + }, + ], + }, + ]; + + const result = await createEngineRequests(toolCalls, 0, tools); + + expect(result).toHaveLength(1); + expect(result[0].metadata.google?.thoughtSignature).toBe('Gemini thought signature'); + expect(result[0].metadata.anthropic?.thinkingContent).toBe('Anthropic thinking content'); + expect(result[0].metadata.anthropic?.thinkingType).toBe('thinking'); + expect(result[0].metadata.anthropic?.thinkingSignature).toBe('anthropic_sig_456'); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/utils/agent-execution/types.ts b/packages/@n8n/nodes-langchain/utils/agent-execution/types.ts index 6f559c2d23d..cfce1da652f 100644 --- a/packages/@n8n/nodes-langchain/utils/agent-execution/types.ts +++ b/packages/@n8n/nodes-langchain/utils/agent-execution/types.ts @@ -48,6 +48,49 @@ export type AgentResult = { intermediateSteps?: ToolCallData[]; }; +/** + * Anthropic thinking content block + */ +export type ThinkingContentBlock = { + type: 'thinking'; + thinking: string; + signature: string; +}; + +/** + * Anthropic redacted thinking content block + */ +export type RedactedThinkingContentBlock = { + type: 'redacted_thinking'; + data: string; +}; + +/** + * Anthropic tool use content block + */ +export type ToolUseContentBlock = { + type: 'tool_use'; + id: string; + name: string; + input: Record; +}; + +/** + * Gemini thought signature content block + */ +export type GeminiThoughtSignatureBlock = { + thoughtSignature: string; +}; + +/** + * Union type for all supported content blocks + */ +export type ContentBlock = + | ThinkingContentBlock + | RedactedThinkingContentBlock + | ToolUseContentBlock + | GeminiThoughtSignatureBlock; + /** * Metadata for engine requests and responses. */ @@ -58,6 +101,62 @@ export type RequestResponseMetadata = { previousRequests?: ToolCallData[]; /** Current iteration count (for max iterations enforcement) */ iterationCount?: number; - /** Thought signature for Gemini 3 tool calls */ - thoughtSignature?: string; + /** Google/Gemini-specific metadata */ + google?: { + /** Thought signature for Gemini extended thinking */ + thoughtSignature?: string; + }; + /** Anthropic-specific metadata */ + anthropic?: { + /** Thinking content from extended thinking mode */ + thinkingContent?: string; + /** Type of thinking block (thinking or redacted_thinking) */ + thinkingType?: 'thinking' | 'redacted_thinking'; + /** Cryptographic signature for thinking blocks */ + thinkingSignature?: string; + }; }; + +/** + * Type guard to check if a block is a thinking content block + */ +export function isThinkingBlock(block: unknown): block is ThinkingContentBlock { + return ( + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'thinking' && + 'thinking' in block && + typeof block.thinking === 'string' && + 'signature' in block && + typeof block.signature === 'string' + ); +} + +/** + * Type guard to check if a block is a redacted thinking content block + */ +export function isRedactedThinkingBlock(block: unknown): block is RedactedThinkingContentBlock { + return ( + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'redacted_thinking' && + 'data' in block && + typeof block.data === 'string' + ); +} + +/** + * Type guard to check if a block is a Gemini thought signature block + */ +export function isGeminiThoughtSignatureBlock( + block: unknown, +): block is GeminiThoughtSignatureBlock { + return ( + typeof block === 'object' && + block !== null && + 'thoughtSignature' in block && + typeof block.thoughtSignature === 'string' + ); +} diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index b420264db13..890dbb84002 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -100,7 +100,7 @@ export const buildInputSchemaField = (props?: { export const inputSchemaField = buildInputSchemaField(); -export const promptTypeOptions: INodeProperties = { +export const promptTypeOptionsDeprecated: INodeProperties = { displayName: 'Source for Prompt (User Message)', name: 'promptType', type: 'options', @@ -126,6 +126,26 @@ export const promptTypeOptions: INodeProperties = { default: 'auto', }; +export const promptTypeOptions: INodeProperties = { + displayName: 'Source for Prompt (User Message)', + name: 'promptType', + type: 'options', + options: [ + { + name: 'Connected Chat Trigger Node', + value: 'auto', + description: + "Looks for an input field called 'chatInput' that is coming from a directly connected Chat Trigger", + }, + { + name: 'Define below', + value: 'define', + description: 'Use an expression to reference data in previous nodes or enter static text', + }, + ], + default: 'auto', +}; + export const textInput: INodeProperties = { displayName: 'Prompt (User Message)', name: 'text', diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 921bdcfe598..669c8844c50 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -101,6 +101,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "workersView:*", "workflow:share", "workflow:execute", + "workflow:execute-chat", "workflow:move", "workflow:activate", "workflow:deactivate", @@ -160,6 +161,14 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = ` "chatHubAgent:*", "breakingChanges:list", "breakingChanges:*", + "apiKey:manage", + "apiKey:*", + "credentialResolver:create", + "credentialResolver:read", + "credentialResolver:update", + "credentialResolver:delete", + "credentialResolver:list", + "credentialResolver:*", "*", ] `; diff --git a/packages/@n8n/permissions/src/__tests__/schemas.test.ts b/packages/@n8n/permissions/src/__tests__/schemas.test.ts index 40d5b53eebd..206b6e67855 100644 --- a/packages/@n8n/permissions/src/__tests__/schemas.test.ts +++ b/packages/@n8n/permissions/src/__tests__/schemas.test.ts @@ -35,6 +35,7 @@ describe('globalRoleSchema', () => { { name: 'valid role: global:owner', value: 'global:owner', expected: true }, { name: 'valid role: global:admin', value: 'global:admin', expected: true }, { name: 'valid role: global:member', value: 'global:member', expected: true }, + { name: 'valid role: global:chatUser', value: 'global:chatUser', expected: true }, { name: 'invalid role', value: 'global:invalid', expected: false }, { name: 'invalid prefix', value: 'invalid:admin', expected: false }, { name: 'empty string', value: '', expected: false }, @@ -50,6 +51,7 @@ describe('assignableGlobalRoleSchema', () => { { name: 'excluded role: global:owner', value: 'global:owner', expected: false }, { name: 'valid role: global:admin', value: 'global:admin', expected: true }, { name: 'valid role: global:member', value: 'global:member', expected: true }, + { name: 'valid role: global:chatUser', value: 'global:chatUser', expected: true }, { name: 'object value', value: {}, expected: false }, ])('should validate $name', ({ value, expected }) => { const result = assignableGlobalRoleSchema.safeParse(value); diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 1ef6aa52e48..73d7ad9eb28 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -23,7 +23,15 @@ export const RESOURCES = { variable: [...DEFAULT_OPERATIONS] as const, projectVariable: [...DEFAULT_OPERATIONS] as const, workersView: ['manage'] as const, - workflow: ['share', 'execute', 'move', 'activate', 'deactivate', ...DEFAULT_OPERATIONS] as const, + workflow: [ + 'share', + 'execute', + 'execute-chat', + 'move', + 'activate', + 'deactivate', + ...DEFAULT_OPERATIONS, + ] as const, folder: [...DEFAULT_OPERATIONS, 'move'] as const, insights: ['list'] as const, oidc: ['manage'] as const, @@ -37,6 +45,8 @@ export const RESOURCES = { chatHub: ['manage', 'message'] as const, chatHubAgent: [...DEFAULT_OPERATIONS] as const, breakingChanges: ['list'] as const, + apiKey: ['manage'] as const, + credentialResolver: [...DEFAULT_OPERATIONS] as const, } as const; export const API_KEY_RESOURCES = { @@ -56,3 +66,4 @@ export const PROJECT_OWNER_ROLE_SLUG = 'project:personalOwner'; export const PROJECT_ADMIN_ROLE_SLUG = 'project:admin'; export const PROJECT_EDITOR_ROLE_SLUG = 'project:editor'; export const PROJECT_VIEWER_ROLE_SLUG = 'project:viewer'; +export const PROJECT_CHAT_USER_ROLE_SLUG = 'project:chatUser'; diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 29f7d0e9cd2..72f4c2a6136 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -67,6 +67,8 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [ 'credential:delete', ]; +export const CHAT_USER_API_KEY_SCOPES: ApiKeyScope[] = []; + /** * This is a bit of a mess, because we are handing out scopes in API keys that are only * valid for the personal project, which is enforced in the public API, because the workflows, @@ -97,9 +99,14 @@ const MAP_ROLE_SCOPES: Record = { 'global:owner': OWNER_API_KEY_SCOPES, 'global:admin': ADMIN_API_KEY_SCOPES, 'global:member': MEMBER_API_KEY_SCOPES, + 'global:chatUser': CHAT_USER_API_KEY_SCOPES, }; export const getApiKeyScopesForRole = (user: AuthPrincipal) => { + if (user.role.slug === 'global:chatUser') { + return []; + } + return [ ...new Set( user.role.scopes diff --git a/packages/@n8n/permissions/src/roles/all-roles.ts b/packages/@n8n/permissions/src/roles/all-roles.ts index 0be8efdd185..0bd2e7cae2a 100644 --- a/packages/@n8n/permissions/src/roles/all-roles.ts +++ b/packages/@n8n/permissions/src/roles/all-roles.ts @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_EDITOR_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG, + PROJECT_CHAT_USER_ROLE_SLUG, } from '../constants.ee'; import { CREDENTIALS_SHARING_SCOPE_MAP, @@ -17,10 +19,12 @@ const ROLE_NAMES: Record = { 'global:owner': 'Owner', 'global:admin': 'Admin', 'global:member': 'Member', + 'global:chatUser': 'Chat User', [PROJECT_OWNER_ROLE_SLUG]: 'Project Owner', [PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin', [PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor', [PROJECT_VIEWER_ROLE_SLUG]: 'Project Viewer', + [PROJECT_CHAT_USER_ROLE_SLUG]: 'Project Chat User', 'credential:user': 'Credential User', 'credential:owner': 'Credential Owner', 'workflow:owner': 'Workflow Owner', @@ -31,11 +35,14 @@ const ROLE_DESCRIPTIONS: Record = { 'global:owner': 'Owner', 'global:admin': 'Admin', 'global:member': 'Member', + 'global:chatUser': 'Chat User', [PROJECT_OWNER_ROLE_SLUG]: 'Project Owner', [PROJECT_ADMIN_ROLE_SLUG]: 'Full control of settings, members, workflows, credentials and executions', [PROJECT_EDITOR_ROLE_SLUG]: 'Create, edit, and delete workflows, credentials, and executions', [PROJECT_VIEWER_ROLE_SLUG]: 'Read-only access to workflows, credentials, and executions', + [PROJECT_CHAT_USER_ROLE_SLUG]: + 'Chat-only access to chatting with workflows that have n8n Chat enabled', 'credential:user': 'Credential User', 'credential:owner': 'Credential Owner', 'workflow:owner': 'Workflow Owner', diff --git a/packages/@n8n/permissions/src/roles/role-maps.ee.ts b/packages/@n8n/permissions/src/roles/role-maps.ee.ts index 5315b91a3a1..ed105b9ec1e 100644 --- a/packages/@n8n/permissions/src/roles/role-maps.ee.ts +++ b/packages/@n8n/permissions/src/roles/role-maps.ee.ts @@ -1,3 +1,11 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { + CredentialSharingRole, + GlobalRole, + ProjectRole, + Scope, + WorkflowSharingRole, +} from '../types.ee'; import { CREDENTIALS_SHARING_OWNER_SCOPES, CREDENTIALS_SHARING_USER_SCOPES, @@ -6,29 +14,25 @@ import { GLOBAL_OWNER_SCOPES, GLOBAL_ADMIN_SCOPES, GLOBAL_MEMBER_SCOPES, + GLOBAL_CHAT_USER_SCOPES, } from './scopes/global-scopes.ee'; import { REGULAR_PROJECT_ADMIN_SCOPES, PERSONAL_PROJECT_OWNER_SCOPES, PROJECT_EDITOR_SCOPES, PROJECT_VIEWER_SCOPES, + PROJECT_CHAT_USER_SCOPES, } from './scopes/project-scopes.ee'; import { WORKFLOW_SHARING_OWNER_SCOPES, WORKFLOW_SHARING_EDITOR_SCOPES, } from './scopes/workflow-sharing-scopes.ee'; -import type { - CredentialSharingRole, - GlobalRole, - ProjectRole, - Scope, - WorkflowSharingRole, -} from '../types.ee'; export const GLOBAL_SCOPE_MAP: Record = { 'global:owner': GLOBAL_OWNER_SCOPES, 'global:admin': GLOBAL_ADMIN_SCOPES, 'global:member': GLOBAL_MEMBER_SCOPES, + 'global:chatUser': GLOBAL_CHAT_USER_SCOPES, }; export const PROJECT_SCOPE_MAP: Record = { @@ -36,6 +40,7 @@ export const PROJECT_SCOPE_MAP: Record = { 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, 'project:editor': PROJECT_EDITOR_SCOPES, 'project:viewer': PROJECT_VIEWER_SCOPES, + 'project:chatUser': PROJECT_CHAT_USER_SCOPES, }; export const CREDENTIALS_SHARING_SCOPE_MAP: Record = { diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index b47c44aab70..6f6b1b2f429 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -75,6 +75,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'workflow:list', 'workflow:share', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'workersView:manage', 'project:list', @@ -112,6 +113,12 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'chatHubAgent:delete', 'chatHubAgent:list', 'breakingChanges:list', + 'apiKey:manage', + 'credentialResolver:create', + 'credentialResolver:read', + 'credentialResolver:update', + 'credentialResolver:delete', + 'credentialResolver:list', ]; export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); @@ -141,4 +148,14 @@ export const GLOBAL_MEMBER_SCOPES: Scope[] = [ 'chatHubAgent:update', 'chatHubAgent:delete', 'chatHubAgent:list', + 'apiKey:manage', +]; + +export const GLOBAL_CHAT_USER_SCOPES: Scope[] = [ + 'chatHub:message', + 'chatHubAgent:create', + 'chatHubAgent:read', + 'chatHubAgent:update', + 'chatHubAgent:delete', + 'chatHubAgent:list', ]; diff --git a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index 2b4ee344a27..b4ce4bc3cd1 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -13,6 +13,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'workflow:delete', 'workflow:list', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'credential:create', 'credential:read', @@ -53,6 +54,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'workflow:delete', 'workflow:list', 'workflow:execute', + 'workflow:execute-chat', 'workflow:share', 'workflow:move', 'credential:create', @@ -86,6 +88,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'workflow:delete', 'workflow:list', 'workflow:execute', + 'workflow:execute-chat', 'credential:create', 'credential:read', 'credential:update', @@ -119,6 +122,7 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'project:read', 'workflow:list', 'workflow:read', + 'workflow:execute-chat', 'folder:read', 'folder:list', 'dataTable:listProject', @@ -127,3 +131,5 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [ 'projectVariable:list', 'projectVariable:read', ]; + +export const PROJECT_CHAT_USER_SCOPES: Scope[] = ['workflow:execute-chat']; diff --git a/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts index 1487846b875..061ad4b600e 100644 --- a/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts @@ -7,10 +7,12 @@ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ 'workflow:execute', 'workflow:share', 'workflow:move', + 'workflow:execute-chat', ]; export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [ 'workflow:read', 'workflow:update', 'workflow:execute', + 'workflow:execute-chat', ]; diff --git a/packages/@n8n/permissions/src/schemas.ee.ts b/packages/@n8n/permissions/src/schemas.ee.ts index e763b723404..99107387d27 100644 --- a/packages/@n8n/permissions/src/schemas.ee.ts +++ b/packages/@n8n/permissions/src/schemas.ee.ts @@ -1,11 +1,15 @@ import { z } from 'zod'; -import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee'; import { ALL_SCOPES } from './scope-information'; export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']); -export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']); +export const globalRoleSchema = z.enum([ + 'global:owner', + 'global:admin', + 'global:member', + 'global:chatUser', +]); const customGlobalRoleSchema = z .string() @@ -26,13 +30,18 @@ export const personalRoleSchema = z.enum([ ]); // Those are the system roles for projects assignable to a user -export const teamRoleSchema = z.enum(['project:admin', 'project:editor', 'project:viewer']); +export const teamRoleSchema = z.enum([ + 'project:admin', + 'project:editor', + 'project:viewer', + 'project:chatUser', +]); // Custom project role can be anything but the system roles export const customProjectRoleSchema = z .string() .nonempty() - .refine((val) => val !== PROJECT_OWNER_ROLE_SLUG && !teamRoleSchema.safeParse(val).success, { + .refine((val) => !systemProjectRoleSchema.safeParse(val).success, { message: 'This global role value is not assignable', }); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts index 6b17d6b8262..c3f533681f2 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-global-scopes.test.ts @@ -3,7 +3,7 @@ import { getGlobalScopes } from '../get-global-scopes.ee'; import { createAuthPrincipal } from './utils'; describe('getGlobalScopes', () => { - test.each(['global:owner', 'global:admin', 'global:member'] as const)( + test.each(['global:owner', 'global:admin', 'global:member', 'global:chatUser'] as const)( 'should return correct scopes for %s', (role) => { const scopes = getGlobalScopes(createAuthPrincipal(role)); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts index 5df4560621e..fe1370706b5 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/get-resource-permissions.test.ts @@ -42,6 +42,8 @@ describe('permissions', () => { chatHub: {}, chatHubAgent: {}, breakingChanges: {}, + apiKey: {}, + credentialResolver: {}, }); }); it('getResourcePermissions', () => { @@ -77,6 +79,7 @@ describe('permissions', () => { 'folder:create', 'insights:list', 'breakingChanges:list', + 'apiKey:manage', ]; const permissionRecord: PermissionsRecord = { @@ -158,6 +161,10 @@ describe('permissions', () => { breakingChanges: { list: true, }, + apiKey: { + manage: true, + }, + credentialResolver: {}, }; expect(getResourcePermissions(scopes)).toEqual(permissionRecord); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts index d3129259f25..7ad158e8115 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/has-global-scope.test.ts @@ -8,6 +8,7 @@ describe('hasGlobalScope', () => { { role: 'global:owner', scope: 'workflow:create', expected: true }, { role: 'global:admin', scope: 'user:delete', expected: true }, { role: 'global:member', scope: 'workflow:read', expected: false }, + { role: 'global:chatUser', scope: 'workflow:read', expected: false }, { role: 'non:existent', scope: 'workflow:read', expected: false }, ] as Array<{ role: GlobalRole; scope: Scope; expected: boolean }>)( '$role with $scope -> $expected', diff --git a/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts index 62df2558b3f..5e015cf413b 100644 --- a/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts +++ b/packages/@n8n/permissions/src/utilities/__tests__/roles-with-scope.test.ts @@ -6,6 +6,7 @@ describe('rolesWithScope', () => { test.each([ ['workflow:create', ['global:owner', 'global:admin']], ['user:list', ['global:owner', 'global:admin', 'global:member']], + ['chatHub:message', ['global:owner', 'global:admin', 'global:member', 'global:chatUser']], ['invalid:scope', []], ] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => { expect(staticRolesWithScope('global', scope)).toEqual(expected); diff --git a/packages/@n8n/task-runner-python/pyproject.toml b/packages/@n8n/task-runner-python/pyproject.toml index 40152418336..0c559fa877c 100644 --- a/packages/@n8n/task-runner-python/pyproject.toml +++ b/packages/@n8n/task-runner-python/pyproject.toml @@ -11,6 +11,9 @@ dependencies = [ [project.optional-dependencies] sentry = ["sentry-sdk>=2.35.2"] +[tool.uv] +constraint-dependencies = ["urllib3>=2.6.0"] + [dependency-groups] dev = [ "ruff>=0.12.8", diff --git a/packages/@n8n/task-runner-python/uv.lock b/packages/@n8n/task-runner-python/uv.lock index 7944fe9cf1d..ddf126298e7 100644 --- a/packages/@n8n/task-runner-python/uv.lock +++ b/packages/@n8n/task-runner-python/uv.lock @@ -2,6 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.13" +[manifest] +constraints = [{ name = "urllib3", specifier = ">=2.6.0" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -458,11 +461,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/0f3a93cca1ac5e8287842ed4eebbd0f7a991315089b1a0b01c7788aa7b63/urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f", size = 432678, upload-time = "2025-12-08T15:25:26.773Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/190ceb8cb10511b730b564fb1e0293fa468363dbad26145c34928a60cb0c/urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b", size = 131138, upload-time = "2025-12-08T15:25:25.51Z" }, ] [[package]] diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index 3404ac7b843..62687c81389 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -1,5 +1,6 @@ import { mockLogger } from '@n8n/backend-test-utils'; import type { GlobalConfig } from '@n8n/config'; +import type { SettingsRepository } from '@n8n/db'; import { LicenseManager } from '@n8n_io/license-sdk'; import { mock } from 'jest-mock-extended'; import type { InstanceSettings } from 'n8n-core'; @@ -282,6 +283,97 @@ describe('License', () => { }); describe('License', () => { + describe('onCertRefresh', () => { + let license: License; + const instanceSettings = mock({ + instanceId: 'test-instance', + instanceType: 'main', + isLeader: true, + }); + + beforeEach(async () => { + jest.restoreAllMocks(); + const globalConfig = mock({ + license: licenseConfig, + multiMainSetup: { enabled: false }, + }); + license = new License(mockLogger(), instanceSettings, mock(), mock(), globalConfig); + await license.init(); + }); + + it('should register callback and call it on license reload', async () => { + const callback = jest.fn(); + license.onCertRefresh(callback); + + await license.reload(); + + expect(callback).toHaveBeenCalledWith(''); + }); + + it('should call multiple registered callbacks', async () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + license.onCertRefresh(callback1); + license.onCertRefresh(callback2); + + await license.reload(); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should return unsubscribe function that removes callback', async () => { + const callback = jest.fn(); + const unsubscribe = license.onCertRefresh(callback); + + unsubscribe(); + await license.reload(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should continue calling other callbacks if one throws', async () => { + const errorCallback = jest.fn().mockImplementation(() => { + throw new Error('Callback error'); + }); + const callback2 = jest.fn(); + + license.onCertRefresh(errorCallback); + license.onCertRefresh(callback2); + + await license.reload(); + + expect(errorCallback).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + }); + + it('should pass the loaded certificate to callbacks', async () => { + const settingsRepository = mock(); + settingsRepository.findOne.mockResolvedValue({ value: 'test-cert-value' } as any); + + const globalConfig = mock({ + license: licenseConfig, + multiMainSetup: { enabled: false }, + }); + license = new License( + mockLogger(), + instanceSettings, + settingsRepository, + mock(), + globalConfig, + ); + await license.init(); + + const callback = jest.fn(); + license.onCertRefresh(callback); + + await license.reload(); + + expect(callback).toHaveBeenCalledWith('test-cert-value'); + }); + }); + describe('init', () => { it('when leader main with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => { const globalConfig = mock({ diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index cc7fcbb64d4..6b015ade3db 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -140,21 +140,6 @@ export class ActiveWorkflowManager { return this.activeWorkflows.allActiveWorkflows(); } - /** - * Returns if the workflow is stored as `active`. - * - * @important Do not confuse with `ActiveWorkflows.isActive()`, - * which checks if the workflow is active in memory. - */ - async isActive(workflowId: WorkflowId) { - const workflow = await this.workflowRepository.findOne({ - select: ['activeVersionId'], - where: { id: workflowId }, - }); - - return !!workflow?.activeVersionId; - } - /** * Register workflow-defined webhooks in the `workflow_entity` table. */ diff --git a/packages/cli/src/auth/__tests__/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts index d1e89284fc8..573f23ad7dc 100644 --- a/packages/cli/src/auth/__tests__/auth.service.test.ts +++ b/packages/cli/src/auth/__tests__/auth.service.test.ts @@ -6,6 +6,7 @@ import type { InvalidAuthTokenRepository, UserRepository, } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE } from '@n8n/db'; import type { NextFunction, Response } from 'express'; import { mock } from 'jest-mock-extended'; import jwt from 'jsonwebtoken'; @@ -15,6 +16,7 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import type { MfaService } from '@/mfa/mfa.service'; import { JwtService } from '@/services/jwt.service'; import type { UrlService } from '@/services/url.service'; +import type { License } from '@/license'; describe('AuthService', () => { const browserId = 'test-browser-id'; @@ -35,10 +37,11 @@ describe('AuthService', () => { const userRepository = mock(); const invalidAuthTokenRepository = mock(); const mfaService = mock(); + const license = mock(); const authService = new AuthService( globalConfig, mock(), - mock(), + license, jwtService, urlService, userRepository, @@ -61,6 +64,7 @@ describe('AuthService', () => { globalConfig.userManagement.jwtSessionDurationHours = 168; globalConfig.userManagement.jwtRefreshTimeoutHours = 0; globalConfig.auth.cookie = { secure: true, samesite: 'lax' }; + license.isWithinUsersLimit.mockReturnValue(true); }); describe('createJWTHash', () => { @@ -520,6 +524,29 @@ describe('AuthService', () => { }); }); + describe('when user limit is reached', () => { + it('should block issuance if the user is not the global owner', async () => { + license.isWithinUsersLimit.mockReturnValue(false); + expect(() => { + authService.issueCookie(res, user, false, browserId); + }).toThrowError('Maximum number of users reached'); + }); + + it('should allow issuance if the user is the global owner', async () => { + license.isWithinUsersLimit.mockReturnValue(false); + user.role = GLOBAL_OWNER_ROLE; + expect(() => { + authService.issueCookie(res, user, false, browserId); + }).not.toThrowError('Maximum number of users reached'); + expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, { + httpOnly: true, + maxAge: 604800000, + sameSite: 'lax', + secure: true, + }); + }); + }); + it('should issue a cookie with the correct options, when 2FA was used', () => { authService.issueCookie(res, user, true, browserId); diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index ee139c40167..ccf72bd1049 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -10,7 +10,6 @@ import type { NextFunction, Response } from 'express'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import type { StringValue as TimeUnitValue } from 'ms'; -import config from '@/config'; import { AuthError } from '@/errors/response-errors/auth.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { License } from '@/license'; @@ -171,11 +170,7 @@ export class AuthService { // TODO: move this check to the login endpoint in AuthController // If the instance has exceeded its user quota, prevent non-owners from logging in const isWithinUsersLimit = this.license.isWithinUsersLimit(); - if ( - config.getEnv('userManagement.isInstanceOwnerSetUp') && - user.role.slug !== GLOBAL_OWNER_ROLE.slug && - !isWithinUsersLimit - ) { + if (user.role.slug !== GLOBAL_OWNER_ROLE.slug && !isWithinUsersLimit) { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index ea0ed8d4c54..716acdbf608 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -31,12 +31,12 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus' import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { ExternalHooks } from '@/external-hooks'; import { License } from '@/license'; -import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import { NodeTypes } from '@/node-types'; import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; import { WorkflowHistoryManager } from '@/workflows/workflow-history/workflow-history-manager'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; export abstract class BaseCommand { readonly flags: F; @@ -99,7 +99,6 @@ export abstract class BaseCommand { this.nodeTypes = Container.get(NodeTypes); - await this.executionContextHookRegistry.init(); await Container.get(LoadNodesAndCredentials).init(); await this.dbConnection diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f9e2472b7d5..920d8974968 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -35,6 +35,7 @@ import { WorkflowRunner } from '@/workflow-runner'; import { BaseCommand } from './base-command'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { DeprecationService } from '@/deprecation/deprecation.service'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const open = require('open'); @@ -262,6 +263,9 @@ export class Start extends BaseCommand> { Container.get(MultiMainSetup).registerEventHandlers(); } + + await this.executionContextHookRegistry.init(); + await Container.get(LoadNodesAndCredentials).postProcessLoaders(); } async initOrchestration() { diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index cddd2027d82..91a9289d053 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -3,7 +3,6 @@ import { User, CredentialsRepository, ProjectRepository, - SettingsRepository, SharedCredentialsRepository, SharedWorkflowRepository, UserRepository, @@ -19,6 +18,7 @@ const defaultUserProps = { lastName: null, email: null, password: null, + lastActiveAt: null, role: 'global:owner', }; @@ -53,11 +53,6 @@ export class Reset extends BaseCommand { ); await Container.get(SharedCredentialsRepository).save(newSharedCredentials); - await Container.get(SettingsRepository).update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: 'false' }, - ); - this.logger.info('Successfully reset the database to default user state.'); } diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 447b9308b77..ecf6b66370c 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -17,6 +17,7 @@ import type { WorkerServerEndpointsConfig } from '@/scaling/worker-server'; import { WorkerStatusService } from '@/scaling/worker-status.service.ee'; import { BaseCommand } from './base-command'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; const flagsSchema = z.object({ concurrency: z.number().int().default(10).describe('How many jobs can run in parallel.'), @@ -115,6 +116,9 @@ export class Worker extends BaseCommand> { ); await this.moduleRegistry.initModules(this.instanceSettings.instanceType); + + await this.executionContextHookRegistry.init(); + await Container.get(LoadNodesAndCredentials).postProcessLoaders(); } async initEventBus() { diff --git a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts index bdd3209cceb..bbdef1237fb 100644 --- a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts +++ b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts @@ -215,31 +215,34 @@ describe('ConcurrencyControlService', () => { }, ); - it.each(['webhook', 'trigger'])('should enqueue on %s mode', async (mode: ExecutionMode) => { - /** - * Arrange - */ - globalConfig.executions.concurrency.productionLimit = 1; + it.each(['webhook', 'trigger', 'chat'])( + 'should enqueue on %s mode', + async (mode: ExecutionMode) => { + /** + * Arrange + */ + globalConfig.executions.concurrency.productionLimit = 1; - const service = new ConcurrencyControlService( - logger, - executionRepository, - telemetry, - eventService, - globalConfig, - ); - const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue'); + const service = new ConcurrencyControlService( + logger, + executionRepository, + telemetry, + eventService, + globalConfig, + ); + const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue'); - /** - * Act - */ - await service.throttle({ mode, executionId: '1' }); + /** + * Act + */ + await service.throttle({ mode, executionId: '1' }); - /** - * Assert - */ - expect(enqueueSpy).toHaveBeenCalled(); - }); + /** + * Assert + */ + expect(enqueueSpy).toHaveBeenCalled(); + }, + ); it('should enqueue on evaluation mode', async () => { /** @@ -298,31 +301,34 @@ describe('ConcurrencyControlService', () => { }, ); - it.each(['webhook', 'trigger'])('should dequeue on %s mode', (mode: ExecutionMode) => { - /** - * Arrange - */ - globalConfig.executions.concurrency.productionLimit = 1; + it.each(['webhook', 'trigger', 'chat'])( + 'should dequeue on %s mode', + (mode: ExecutionMode) => { + /** + * Arrange + */ + globalConfig.executions.concurrency.productionLimit = 1; - const service = new ConcurrencyControlService( - logger, - executionRepository, - telemetry, - eventService, - globalConfig, - ); - const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue'); + const service = new ConcurrencyControlService( + logger, + executionRepository, + telemetry, + eventService, + globalConfig, + ); + const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue'); - /** - * Act - */ - service.release({ mode }); + /** + * Act + */ + service.release({ mode }); - /** - * Assert - */ - expect(dequeueSpy).toHaveBeenCalled(); - }); + /** + * Assert + */ + expect(dequeueSpy).toHaveBeenCalled(); + }, + ); it('should dequeue on evaluation mode', () => { /** @@ -381,7 +387,7 @@ describe('ConcurrencyControlService', () => { }, ); - it.each(['webhook', 'trigger'])( + it.each(['webhook', 'trigger', 'chat'])( 'should remove an execution on %s mode', (mode: ExecutionMode) => { /** @@ -559,6 +565,7 @@ describe('ConcurrencyControlService', () => { */ await service.throttle({ mode: 'trigger', executionId: '1' }); await service.throttle({ mode: 'webhook', executionId: '2' }); + await service.throttle({ mode: 'chat', executionId: '3' }); /** * Assert diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index 512d3c3ced6..67eb56e1138 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -206,7 +206,9 @@ export class ConcurrencyControlService { return undefined; } - if (mode === 'webhook' || mode === 'trigger') return this.queues.get('production'); + if (mode === 'webhook' || mode === 'trigger' || mode === 'chat') { + return this.queues.get('production'); + } if (mode === 'evaluation') return this.queues.get('evaluation'); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 294f200878a..fe2a30ff312 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -7,10 +7,12 @@ import { Container } from '@n8n/di'; export const schema = { userManagement: { /** - * @important Do not remove until after cloud hooks are updated to stop using convict config. + * @important Do not remove isInstanceOwnerSetUp until after cloud hooks (user-management) are updated to stop using + * this property + * @deprecated */ isInstanceOwnerSetUp: { - // n8n loads this setting from DB on startup + // n8n loads this setting from SettingsRepository (DB) on startup doc: "Whether the instance owner's account has been set up", format: Boolean, default: false, diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index aae7da82f63..314d6fa5931 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -76,7 +76,6 @@ type ToReturnType = T extends NumericPath type ExceptionPaths = { 'queue.bull.redis': RedisOptions; processedDataManager: IProcessedDataConfig; - 'userManagement.isInstanceOwnerSetUp': boolean; 'ui.banners.dismissed': string[] | undefined; easyAIWorkflowOnboarded: boolean | undefined; }; diff --git a/packages/cli/src/controllers/__tests__/ai.controller.test.ts b/packages/cli/src/controllers/__tests__/ai.controller.test.ts index 7dd17f2a83c..a78e9fd003c 100644 --- a/packages/cli/src/controllers/__tests__/ai.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/ai.controller.test.ts @@ -118,6 +118,7 @@ describe('AiController', () => { describe('build', () => { const payload: AiBuilderChatRequestDto = { payload: { + id: '12345', text: 'Create a workflow', type: 'message', role: 'user', @@ -146,6 +147,8 @@ describe('AiController', () => { expect(workflowBuilderService.chat).toHaveBeenCalledWith( { + id: '12345', + featureFlags: undefined, message: 'Create a workflow', workflowContext: { currentWorkflow: { id: 'workflow123' }, diff --git a/packages/cli/src/controllers/__tests__/invitation.controller.test.ts b/packages/cli/src/controllers/__tests__/invitation.controller.test.ts index 638baf6fa0b..4f3a1211ee7 100644 --- a/packages/cli/src/controllers/__tests__/invitation.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/invitation.controller.test.ts @@ -22,6 +22,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import config from '@/config'; import type { AuthlessRequest } from '@/requests'; import { v4 as uuidv4 } from 'uuid'; +import { OwnershipService } from '@/services/ownership.service'; describe('InvitationController', () => { const logger: Logger = mockInstance(Logger); @@ -33,22 +34,29 @@ describe('InvitationController', () => { const userRepository: UserRepository = mockInstance(UserRepository); const postHog: PostHogClient = mockInstance(PostHogClient); const eventService: EventService = mockInstance(EventService); + const ownershipService: OwnershipService = mockInstance(OwnershipService); + + function defaultInvitationController() { + return new InvitationController( + logger, + externalHooks, + authService, + userService, + license, + passwordUtility, + userRepository, + postHog, + eventService, + ownershipService, + ); + } describe('inviteUser', () => { it('throws a BadRequestError if SSO is enabled', async () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const user = mock({ id: '123', @@ -77,18 +85,9 @@ describe('InvitationController', () => { it('throws a ForbiddenError if the user limit quota has been reached', async () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false); jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(false); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const user = mock({ id: '123', @@ -112,18 +111,9 @@ describe('InvitationController', () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false); jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true); jest.spyOn(config, 'getEnv').mockReturnValue(false); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(false)); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const user = mock({ id: '123', @@ -148,18 +138,9 @@ describe('InvitationController', () => { jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true); jest.spyOn(config, 'getEnv').mockReturnValue(true); jest.spyOn(license, 'isAdvancedPermissionsLicensed').mockReturnValue(false); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const user = mock({ id: '123', @@ -209,17 +190,9 @@ describe('InvitationController', () => { jest.spyOn(config, 'getEnv').mockReturnValue(true); jest.spyOn(license, 'isAdvancedPermissionsLicensed').mockReturnValue(true); jest.spyOn(userService, 'inviteUsers').mockResolvedValue(inviteUsersResult); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); + + const invitationController = defaultInvitationController(); const user = mock({ id: '123', @@ -255,19 +228,11 @@ describe('InvitationController', () => { describe('acceptInvitation', () => { it('throws a BadRequestError if SSO is enabled', async () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); + const id = uuidv4(); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const payload = new AcceptInvitationRequestDto({ inviterId: id, @@ -291,19 +256,11 @@ describe('InvitationController', () => { it('throws a BadRequestError if the inviter ID and invitee ID are not found in the database', async () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); + const id = uuidv4(); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const payload = new AcceptInvitationRequestDto({ inviterId: id, @@ -332,6 +289,8 @@ describe('InvitationController', () => { it('throws a BadRequestError if the invitee already has a password', async () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); + const invitee = mock({ id: '123', email: 'valid@email.com', @@ -346,17 +305,7 @@ describe('InvitationController', () => { jest.spyOn(userRepository, 'find').mockResolvedValue([inviter, invitee]); const id = uuidv4(); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const payload = new AcceptInvitationRequestDto({ inviterId: id, @@ -379,6 +328,8 @@ describe('InvitationController', () => { it('accepts the invitation successfully', async () => { jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false); + jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true)); + const id = uuidv4(); const inviter = mock({ id: '124', @@ -400,17 +351,7 @@ describe('InvitationController', () => { jest.spyOn(userService, 'toPublic').mockResolvedValue(invitee as unknown as PublicUser); jest.spyOn(externalHooks, 'run').mockResolvedValue(invitee as never); - const invitationController = new InvitationController( - logger, - externalHooks, - authService, - userService, - license, - passwordUtility, - userRepository, - postHog, - eventService, - ); + const invitationController = defaultInvitationController(); const payload = new AcceptInvitationRequestDto({ inviterId: id, diff --git a/packages/cli/src/controllers/__tests__/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts index ebabd8cb697..1b4f5b9fafd 100644 --- a/packages/cli/src/controllers/__tests__/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -1,103 +1,40 @@ -import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; -import type { Logger } from '@n8n/backend-common'; -import { - type AuthenticatedRequest, - type User, - type PublicUser, - type SettingsRepository, - type UserRepository, - GLOBAL_OWNER_ROLE, -} from '@n8n/db'; -import type { Response } from 'express'; +import type { DismissBannerRequestDto } from '@n8n/api-types'; import { mock } from 'jest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; -import config from '@/config'; import { OwnerController } from '@/controllers/owner.controller'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { EventService } from '@/events/event.service'; import type { BannerService } from '@/services/banner.service'; -import type { PasswordUtility } from '@/services/password.utility'; import type { UserService } from '@/services/user.service'; +import type { OwnershipService } from '@/services/ownership.service'; +import type { PostHogClient } from '@/posthog'; describe('OwnerController', () => { - const configGetSpy = jest.spyOn(config, 'getEnv'); - const configSetSpy = jest.spyOn(config, 'set'); - - const logger = mock(); - const eventService = mock(); const authService = mock(); const bannerService = mock(); const userService = mock(); - const userRepository = mock(); - const settingsRepository = mock(); - const passwordUtility = mock(); + const ownershipService = mock(); + const postHogClient = mock(); const controller = new OwnerController( - logger, - eventService, - settingsRepository, authService, bannerService, userService, - passwordUtility, - mock(), - userRepository, + postHogClient, + ownershipService, ); describe('setupOwner', () => { - it('should throw a BadRequestError if the instance owner is already setup', async () => { - configGetSpy.mockReturnValue(true); + it('should pass on errors from the service', async () => { + jest + .spyOn(ownershipService, 'setupOwner') + .mockRejectedValueOnce(new BadRequestError('Instance owner already setup')); + await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError( new BadRequestError('Instance owner already setup'), ); - expect(userRepository.findOneOrFail).not.toHaveBeenCalled(); - expect(userRepository.save).not.toHaveBeenCalled(); expect(authService.issueCookie).not.toHaveBeenCalled(); - expect(settingsRepository.update).not.toHaveBeenCalled(); - expect(configSetSpy).not.toHaveBeenCalled(); - expect(eventService.emit).not.toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith( - 'Request to claim instance ownership failed because instance owner already exists', - ); - }); - - it('should setup the instance owner successfully', async () => { - const user = mock({ - id: 'userId', - role: GLOBAL_OWNER_ROLE, - authIdentities: [], - }); - const browserId = 'test-browser-id'; - const req = mock({ user, browserId, authInfo: { usedMfa: false } }); - const res = mock(); - const payload = mock({ - email: 'valid@email.com', - password: 'NewPassword123', - firstName: 'Jane', - lastName: 'Doe', - }); - configGetSpy.mockReturnValue(false); - userRepository.findOneOrFail.mockResolvedValue(user); - userRepository.save.mockResolvedValue(user); - userService.toPublic.mockResolvedValue(mock({ id: 'newUserId' })); - - const result = await controller.setupOwner(req, res, payload); - - expect(userRepository.findOneOrFail).toHaveBeenCalledWith({ - where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, - relations: ['role'], - }); - expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false }); - expect(authService.issueCookie).toHaveBeenCalledWith(res, user, false, browserId); - expect(settingsRepository.update).toHaveBeenCalledWith( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(true) }, - ); - expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true); - expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' }); - expect(result.id).toEqual('newUserId'); }); }); @@ -112,11 +49,11 @@ describe('OwnerController', () => { }); it('should call dismissBanner with the correct banner name', async () => { - const payload = mock({ banner: 'TRIAL' }); + const payload = mock({ banner: 'TRIAL_OVER' }); await controller.dismissBanner(mock(), mock(), payload); - expect(bannerService.dismissBanner).toHaveBeenCalledWith('TRIAL'); + expect(bannerService.dismissBanner).toHaveBeenCalledWith('TRIAL_OVER'); }); }); }); diff --git a/packages/cli/src/controllers/ai.controller.ts b/packages/cli/src/controllers/ai.controller.ts index c513b8492e6..7dba4e368bc 100644 --- a/packages/cli/src/controllers/ai.controller.ts +++ b/packages/cli/src/controllers/ai.controller.ts @@ -55,9 +55,10 @@ export class AiController { res.on('close', handleClose); - const { text, workflowContext, featureFlags } = payload.payload; + const { id, text, workflowContext, featureFlags } = payload.payload; const aiResponse = this.workflowBuilderService.chat( { + id, message: text, workflowContext: { currentWorkflow: workflowContext.currentWorkflow, diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index 40016101d87..1242eb98d1e 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -1,6 +1,15 @@ import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; -import { Body, Delete, Get, Param, Patch, Post, RestController } from '@n8n/decorators'; +import { + Body, + Delete, + Get, + GlobalScope, + Param, + Patch, + Post, + RestController, +} from '@n8n/decorators'; import { getApiKeyScopesForRole } from '@n8n/permissions'; import type { RequestHandler } from 'express'; @@ -27,6 +36,7 @@ export class ApiKeysController { /** * Create an API Key */ + @GlobalScope('apiKey:manage') @Post('/', { middlewares: [isApiEnabledMiddleware] }) async createApiKey( req: AuthenticatedRequest, @@ -52,6 +62,7 @@ export class ApiKeysController { /** * Get API keys */ + @GlobalScope('apiKey:manage') @Get('/', { middlewares: [isApiEnabledMiddleware] }) async getApiKeys(req: AuthenticatedRequest) { const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user); @@ -61,6 +72,7 @@ export class ApiKeysController { /** * Delete an API Key */ + @GlobalScope('apiKey:manage') @Delete('/:id', { middlewares: [isApiEnabledMiddleware] }) async deleteApiKey(req: AuthenticatedRequest, _res: Response, @Param('id') apiKeyId: string) { await this.publicApiKeyService.deleteApiKeyForUser(req.user, apiKeyId); @@ -73,6 +85,7 @@ export class ApiKeysController { /** * Patch an API Key */ + @GlobalScope('apiKey:manage') @Patch('/:id', { middlewares: [isApiEnabledMiddleware] }) async updateApiKey( req: AuthenticatedRequest, @@ -89,6 +102,7 @@ export class ApiKeysController { return { success: true }; } + @GlobalScope('apiKey:manage') @Get('/scopes', { middlewares: [isApiEnabledMiddleware] }) async getApiKeyScopes(req: AuthenticatedRequest, _res: Response) { const scopes = getApiKeyScopesForRole(req.user); diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 4529fb25736..4d57777969b 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -3,6 +3,7 @@ import { Logger } from '@n8n/backend-common'; import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants'; import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import { + AuthRolesService, GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, @@ -15,7 +16,6 @@ import { Request } from 'express'; import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; -import config from '@/config'; import { inE2ETests } from '@/constants'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import type { FeatureReturnType } from '@/license'; @@ -42,6 +42,7 @@ const tablesToTruncate = [ 'installed_packages', 'project', 'project_relation', + 'role', 'settings', 'shared_credentials', 'shared_workflow', @@ -85,6 +86,7 @@ type PushRequest = Request< @RestController('/e2e') export class E2EController { private enabledFeatures: Record = { + [LICENSE_FEATURES.DYNAMIC_CREDENTIALS]: false, [LICENSE_FEATURES.SHARING]: false, [LICENSE_FEATURES.LDAP]: false, [LICENSE_FEATURES.SAML]: false, @@ -190,6 +192,7 @@ export class E2EController { await this.resetLogStreaming(); await this.removeActiveWorkflows(); await this.truncateAll(); + await this.reseedRolesAndScopes(); await this.resetCache(); await this.setupUserManagement(req.body.owner, req.body.members, req.body.admin); } @@ -220,8 +223,7 @@ export class E2EController { @Get('/env-feature-flags', { skipAuth: true }) async getEnvFeatureFlags() { - const currentFlags = this.frontendService.getSettings().envFeatureFlags; - return currentFlags; + return (await this.frontendService.getSettings()).envFeatureFlags; } @Patch('/env-feature-flags', { skipAuth: true }) @@ -251,7 +253,7 @@ export class E2EController { } // Return the current environment feature flags - const currentFlags = this.frontendService.getSettings().envFeatureFlags; + const currentFlags = (await this.frontendService.getSettings()).envFeatureFlags; return { success: true, message: 'Environment feature flags updated', @@ -303,6 +305,13 @@ export class E2EController { } } + private async reseedRolesAndScopes() { + // Re-initialize scopes and roles after truncation so that foreign keys + // from users and project relations can be created safely, especially + // on databases that strictly enforce foreign keys like Postgres. + await Container.get(AuthRolesService).init(); + } + private async setupUserManagement( owner: UserSetupPayload, members: UserSetupPayload[], @@ -354,13 +363,6 @@ export class E2EController { mfaRecoveryCodes: encryptedRecoveryCodes, }); } - - await this.settingsRepo.update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: 'true' }, - ); - - config.set('userManagement.isInstanceOwnerSetUp', true); } private async resetCache() { diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 2be98a7f2b8..84391593a8b 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -6,7 +6,6 @@ import { Post, GlobalScope, RestController, Body, Param } from '@n8n/decorators' import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import config from '@/config'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; @@ -17,6 +16,7 @@ import { PostHogClient } from '@/posthog'; import { AuthlessRequest } from '@/requests'; import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; +import { OwnershipService } from '@/services/ownership.service'; import { isSsoCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers'; @RestController('/invitations') @@ -31,6 +31,7 @@ export class InvitationController { private readonly userRepository: UserRepository, private readonly postHog: PostHogClient, private readonly eventService: EventService, + private readonly ownershipService: OwnershipService, ) {} /** @@ -64,7 +65,7 @@ export class InvitationController { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } - if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) { + if (!(await this.ownershipService.hasInstanceOwner())) { this.logger.debug( 'Request to send email invite(s) to user(s) failed because the owner account is not set up', ); diff --git a/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts deleted file mode 100644 index 01f4633340d..00000000000 --- a/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { shouldSkipAuthOnOAuthCallback } from '../abstract-oauth.controller'; - -describe('shouldSkipAuthOnOAuthCallback', () => { - const originalEnv = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK; - - afterEach(() => { - // Restore original environment variable after each test - if (originalEnv === undefined) { - delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK; - } else { - process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = originalEnv; - } - }); - - describe('when N8N_SKIP_AUTH_ON_OAUTH_CALLBACK is not set', () => { - beforeEach(() => { - delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK; - }); - - it('should return true', () => { - expect(shouldSkipAuthOnOAuthCallback()).toBe(false); - }); - }); - - describe('with various environment variable values', () => { - const testCases = [ - { value: 'true', expected: true }, - { value: 'TRUE', expected: true }, - { value: 'True', expected: true }, - { value: 'false', expected: false }, - { value: 'FALSE', expected: false }, - { value: 'False', expected: false }, - { value: '', expected: false }, - { value: '1', expected: false }, - { value: 'yes', expected: false }, - { value: 'on', expected: false }, - { value: 'enabled', expected: false }, - { value: ' ', expected: false }, - { value: ' true ', expected: false }, - ] as const; - - test.each(testCases)('"%s" value should return %s', ({ value, expected }) => { - process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = value; - expect(shouldSkipAuthOnOAuthCallback()).toBe(expected); - }); - }); -}); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index d87459b5276..ac18c6d5fe3 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -1,57 +1,22 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; -import { Time } from '@n8n/constants'; -import type { CredentialsEntity, User } from '@n8n/db'; -import { CredentialsRepository, GLOBAL_OWNER_ROLE } from '@n8n/db'; +import { type CredentialsEntity, type User } from '@n8n/db'; import { Container } from '@n8n/di'; -import Csrf from 'csrf'; +import { mock } from 'jest-mock-extended'; +import axios from 'axios'; import type { Response } from 'express'; -import { captor, mock } from 'jest-mock-extended'; -import { Cipher, type InstanceSettings, ExternalSecretsProxy } from 'n8n-core'; -import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow'; -import nock from 'nock'; - import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller'; -import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { CredentialsHelper } from '@/credentials-helper'; -import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ExternalHooks } from '@/external-hooks'; import type { OAuthRequest } from '@/requests'; -import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { OauthService } from '@/oauth/oauth.service'; +import { ExternalHooks } from '@/external-hooks'; -jest.mock('@/workflow-execute-additional-data'); +jest.mock('axios'); describe('OAuth1CredentialController', () => { + const oauthService = mockInstance(OauthService); + mockInstance(Logger); mockInstance(ExternalHooks); - mockInstance(ExternalSecretsProxy); - mockInstance(VariablesService, { - getAllCached: async () => [], - }); - const additionalData = mock(); - (WorkflowExecuteAdditionalData.getBase as jest.Mock).mockReturnValue(additionalData); - - const cipher = new Cipher(mock({ encryptionKey: 'password' })); - Container.set(Cipher, cipher); - const credentialsHelper = mockInstance(CredentialsHelper); - const credentialsRepository = mockInstance(CredentialsRepository); - const credentialsFinderService = mockInstance(CredentialsFinderService); - - const csrfSecret = 'csrf-secret'; - const user = mock({ - id: '123', - password: 'password', - authIdentities: [], - role: GLOBAL_OWNER_ROLE, - }); - const credential = mock({ - id: '1', - name: 'Test Credential', - type: 'oAuth1Api', - data: cipher.encrypt({}), - }); const controller = Container.get(OAuth1CredentialController); @@ -64,65 +29,22 @@ describe('OAuth1CredentialController', () => { }); describe('getAuthUri', () => { - it('should throw a BadRequestError when credentialId is missing in the query', async () => { - const req = mock({ query: { id: '' } }); - await expect(controller.getAuthUri(req)).rejects.toThrowError( - new BadRequestError('Required credential ID is missing'), - ); - }); - - it('should throw a NotFoundError when no matching credential is found for the user', async () => { - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(null); - - const req = mock({ user, query: { id: '1' } }); - await expect(controller.getAuthUri(req)).rejects.toThrowError( - new NotFoundError('Credential not found'), - ); - }); - it('should return a valid auth URI', async () => { - jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret); - jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValueOnce({ - requestTokenUrl: 'https://example.domain/oauth/request_token', - authUrl: 'https://example.domain/oauth/authorize', - accessTokenUrl: 'https://example.domain/oauth/access_token', - signatureMethod: 'HMAC-SHA1', + const mockResolvedCredential = mock({ id: '1' }); + oauthService.getCredential.mockResolvedValueOnce(mockResolvedCredential); + oauthService.generateAOauth1AuthUri.mockResolvedValueOnce( + 'https://example.domain/oauth/authorize?oauth_token=random-token', + ); + const req = mock({ + user: mock({ id: '123' }), + query: { id: '1' }, }); - nock('https://example.domain') - .post('/oauth/request_token', { - oauth_callback: - 'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSIsImNyZWF0ZWRBdCI6MTcwNjc1MDYyNTY3OCwidXNlcklkIjoiMTIzIn0=', - }) - .once() - .reply(200, { oauth_token: 'random-token' }); - - const req = mock({ user, query: { id: '1' } }); const authUri = await controller.getAuthUri(req); expect(authUri).toEqual('https://example.domain/oauth/authorize?oauth_token=random-token'); - const dataCaptor = captor(); - expect(credentialsRepository.update).toHaveBeenCalledWith( - '1', - expect.objectContaining({ - data: dataCaptor, - id: '1', - name: 'Test Credential', - type: 'oAuth1Api', - }), - ); - expect(cipher.decrypt(dataCaptor.value)).toEqual( - JSON.stringify({ csrfSecret: 'csrf-secret' }), - ); - expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( - additionalData, - credential, - credential.type, - 'internal', - undefined, - false, - ); + expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(mockResolvedCredential, { + cid: '1', + userId: '123', + }); }); }); @@ -149,113 +71,40 @@ describe('OAuth1CredentialController', () => { invalidReq.query = { state: 'test' } as OAuthRequest.OAuth1Credential.Callback['query']; await controller.handleCallback(invalidReq, res); - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'Insufficient parameters for OAuth1 callback.', - reason: 'Received following query parameters: {"state":"test"}', - }, - }); - expect(credentialsRepository.findOneBy).not.toHaveBeenCalled(); - }); - - it('should render the error page when `state` query param is invalid', async () => { - const invalidReq = mock({ - query: { - oauth_verifier: 'verifier', - oauth_token: 'token', - state: 'test', - }, - }); - await controller.handleCallback(invalidReq, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'Invalid state format', - }, - }); - expect(credentialsRepository.findOneBy).not.toHaveBeenCalled(); - }); - - it('should render the error page when credential is not found in DB', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(null); - - await controller.handleCallback(req, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'OAuth callback failed because of insufficient permissions', - }, - }); - expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1); - expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' }); - }); - - it('should render the error page when state differs from the stored state in the credential', async () => { - credentialsRepository.findOneBy.mockResolvedValue(credential); - credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret: 'invalid' }); - - await controller.handleCallback(req, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'The OAuth callback state is invalid!', - }, - }); - }); - - it('should render the error page when state is older than 5 minutes', async () => { - credentialsRepository.findOneBy.mockResolvedValue(credential); - credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - - jest.advanceTimersByTime(10 * Time.minutes.toMilliseconds); - - await controller.handleCallback(req, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'The OAuth callback state is invalid!', - }, - }); + expect(oauthService.renderCallbackError).toHaveBeenCalledWith( + res, + 'Insufficient parameters for OAuth1 callback.', + 'Received following query parameters: {"state":"test"}', + ); }); it('should exchange the code for a valid token, and save it to DB', async () => { - credentialsRepository.findOneBy.mockResolvedValue(credential); - credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret }); - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValueOnce({ + const mockResolvedCredential = mock({ id: '1' }); + oauthService.getCredential.mockResolvedValueOnce(mockResolvedCredential); + // @ts-ignore + oauthService.getDecryptedData.mockResolvedValue({ csrfSecret: 'invalid' }); + oauthService.getOAuthCredentials.mockResolvedValueOnce({ requestTokenUrl: 'https://example.domain/oauth/request_token', accessTokenUrl: 'https://example.domain/oauth/access_token', signatureMethod: 'HMAC-SHA1', }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - nock('https://example.domain') - .post('/oauth/access_token', 'oauth_token=token&oauth_verifier=verifier') - .once() - .reply(200, 'access_token=new_token'); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'invalid' }, + { accessTokenUrl: 'https://example.domain/oauth/access_token' }, + ]); + jest.mocked(axios).post.mockResolvedValueOnce({ data: { access_token: 'new_token' } }); await controller.handleCallback(req, res); - const dataCaptor = captor(); - expect(credentialsRepository.update).toHaveBeenCalledWith( - '1', + // @ts-ignore + expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith( + mockResolvedCredential, expect.objectContaining({ - data: dataCaptor, - id: '1', - name: 'Test Credential', - type: 'oAuth1Api', + oauthTokenData: { access_token: 'new_token' }, }), - ); - expect(cipher.decrypt(dataCaptor.value)).toEqual( - JSON.stringify({ oauthTokenData: { access_token: 'new_token' } }), + ['csrfSecret'], ); expect(res.render).toHaveBeenCalledWith('oauth-callback'); - expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( - additionalData, - credential, - credential.type, - 'internal', - undefined, - true, - ); }); }); }); diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 57b69314ac5..f58c2f79ae7 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -1,60 +1,23 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; -import { Time } from '@n8n/constants'; -import type { CredentialsEntity, User } from '@n8n/db'; -import { CredentialsRepository, GLOBAL_OWNER_ROLE } from '@n8n/db'; +import { type CredentialsEntity, type User } from '@n8n/db'; import { Container } from '@n8n/di'; -import Csrf from 'csrf'; -import { type Response } from 'express'; -import { captor, mock } from 'jest-mock-extended'; -import { Cipher, type InstanceSettings, ExternalSecretsProxy } from 'n8n-core'; -import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow'; -import nock from 'nock'; -import * as pkceChallenge from 'pkce-challenge'; - -import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; +import { mock } from 'jest-mock-extended'; +import type { Response } from 'express'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; -import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { CredentialsHelper } from '@/credentials-helper'; -import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ExternalHooks } from '@/external-hooks'; import type { OAuthRequest } from '@/requests'; -import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { OauthService } from '@/oauth/oauth.service'; +import { ExternalHooks } from '@/external-hooks'; -jest.mock('@/workflow-execute-additional-data'); +jest.mock('axios'); +jest.mock('@n8n/client-oauth2'); +jest.mock('pkce-challenge'); describe('OAuth2CredentialController', () => { - mockInstance(Logger); - mockInstance(ExternalSecretsProxy); - mockInstance(VariablesService, { - getAllCached: async () => [], - }); - const additionalData = mock(); - (WorkflowExecuteAdditionalData.getBase as jest.Mock).mockReturnValue(additionalData); - - const cipher = new Cipher(mock({ encryptionKey: 'password' })); - Container.set(Cipher, cipher); - + const oauthService = mockInstance(OauthService); const externalHooks = mockInstance(ExternalHooks); - const credentialsHelper = mockInstance(CredentialsHelper); - const credentialsRepository = mockInstance(CredentialsRepository); - const credentialsFinderService = mockInstance(CredentialsFinderService); - const csrfSecret = 'csrf-secret'; - const user = mock({ - id: '123', - password: 'password', - authIdentities: [], - role: GLOBAL_OWNER_ROLE, - }); - const credential = mock({ - id: '1', - name: 'Test Credential', - type: 'oAuth2Api', - data: cipher.encrypt({}), - }); + mockInstance(Logger); const controller = Container.get(OAuth2CredentialController); @@ -64,335 +27,49 @@ describe('OAuth2CredentialController', () => { beforeEach(() => { jest.setSystemTime(new Date(timestamp)); jest.clearAllMocks(); - - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({ - clientId: 'test-client-id', - clientSecret: 'oauth-secret', - authUrl: 'https://example.domain/o/oauth2/v2/auth', - accessTokenUrl: 'https://example.domain/token', - }); }); describe('getAuthUri', () => { - it('should throw a BadRequestError when credentialId is missing in the query', async () => { - const req = mock({ query: { id: '' } }); - await expect(controller.getAuthUri(req)).rejects.toThrowError( - new BadRequestError('Required credential ID is missing'), - ); - }); - - it('should throw a NotFoundError when no matching credential is found for the user', async () => { - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(null); - - const req = mock({ user, query: { id: '1' } }); - await expect(controller.getAuthUri(req)).rejects.toThrowError( - new NotFoundError('Credential not found'), - ); - }); - it('should return a valid auth URI', async () => { - jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret); - jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - - const req = mock({ user, query: { id: '1' } }); - const authUri = await controller.getAuthUri(req); - expect(authUri).toEqual( - 'https://example.domain/o/oauth2/v2/auth?client_id=test-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback&response_type=code&state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSIsImNyZWF0ZWRBdCI6MTcwNjc1MDYyNTY3OCwidXNlcklkIjoiMTIzIn0%3D&scope=openid', + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, ); - const state = new URL(authUri).searchParams.get('state'); - expect(JSON.parse(Buffer.from(state!, 'base64').toString())).toEqual({ - token: 'token', + + const mockResolvedCredential = mock({ id: '1' }); + oauthService.getCredential.mockResolvedValueOnce(mockResolvedCredential); + oauthService.getOAuthCredentials.mockResolvedValueOnce({ + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + }); + oauthService.generateAOauth2AuthUri.mockResolvedValue('https://example.domain/oauth2/auth'); + + const req = mock({ + user: mock({ id: '123' }), + query: { id: '1' }, + }); + + const authUri = await controller.getAuthUri(req); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockResolvedCredential, { cid: '1', - createdAt: timestamp, userId: '123', }); - const dataCaptor = captor(); - expect(credentialsRepository.update).toHaveBeenCalledWith( - '1', - expect.objectContaining({ - data: dataCaptor, - id: '1', - name: 'Test Credential', - type: 'oAuth2Api', - }), - ); - expect(cipher.decrypt(dataCaptor.value)).toEqual( - JSON.stringify({ csrfSecret: 'csrf-secret' }), - ); - expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( - additionalData, - credential, - credential.type, - 'internal', - undefined, - false, - ); - }); - - it.each([ - [ - ['authorization_code', 'refresh_token'], - ['client_secret_basic', 'client_secret_post', 'none'], - ['S256'], - ['authorization_code', 'refresh_token'], - 'none', - { - code_challenge: 'code-challenge', - code_challenge_method: 'S256', - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid', - }, - ], - [ - ['authorization_code', 'refresh_token'], - ['client_secret_basic', 'client_secret_post'], - ['S256'], - ['authorization_code', 'refresh_token'], - 'none', - { - code_challenge: 'code-challenge', - code_challenge_method: 'S256', - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid', - }, - ], - [ - ['authorization_code', 'refresh_token'], - ['client_secret_basic', 'client_secret_post'], - [], - ['authorization_code', 'refresh_token'], - 'client_secret_basic', - { - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid', - }, - ], - [ - ['authorization_code', 'refresh_token'], - ['client_secret_post'], - [], - ['authorization_code', 'refresh_token'], - 'client_secret_post', - { - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid', - }, - ], - [ - ['client_credentials'], - ['client_secret_basic', 'client_secret_post'], - [], - ['client_credentials'], - 'client_secret_basic', - { - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid', - }, - ], - [ - ['client_credentials'], - ['client_secret_post'], - [], - ['client_credentials'], - 'client_secret_post', - { - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid', - }, - ], - ])( - 'should return a valid auth URI for dynamic client registration', - async ( - supportedGrantTypes, - supportedTokenEndpointAuthMethods, - supportedCodeChallengeMethods, - expectedGrantTypes, - expectedTokenEndpointAuthMethod, - expectedQueryParams, - ) => { - jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret); - jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); - jest.spyOn(pkceChallenge, 'default').mockResolvedValueOnce({ - code_verifier: 'code-verifier', - code_challenge: 'code-challenge', - }); - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({ - useDynamicClientRegistration: true, - serverUrl: 'https://example.com', - }); - nock('https://example.com') - .get('/.well-known/oauth-authorization-server') - .reply(200, { - authorization_endpoint: 'https://example.com/auth', - token_endpoint: 'https://example.com/token', - registration_endpoint: 'https://example.com/registration', - grant_types_supported: supportedGrantTypes, - token_endpoint_auth_methods_supported: supportedTokenEndpointAuthMethods, - code_challenge_methods_supported: supportedCodeChallengeMethods, - }) - .post('/registration', { - redirect_uris: ['http://localhost:5678/rest/oauth2-credential/callback'], - token_endpoint_auth_method: expectedTokenEndpointAuthMethod, - grant_types: expectedGrantTypes, - response_types: ['code'], - client_name: 'n8n', - client_uri: 'https://n8n.io/', - }) - .reply(200, { client_id: 'test-client-id', client_secret: 'test-client-secret' }); - - const req = mock({ user, query: { id: '1' } }); - const authUri = await controller.getAuthUri(req); - - const url = new URL(authUri); - expect(url.origin).toEqual('https://example.com'); - expect(url.pathname).toEqual('/auth'); - Object.entries(expectedQueryParams).forEach(([param, value]) => { - expect(url.searchParams.get(param)).toEqual(value); - }); - }, - ); - - it.each([ - [ - { - authorization_endpoint: 'invalid', - token_endpoint: 'https://example.com/token', - registration_endpoint: 'https://example.com/registration', - }, - ], - [ - { - authorization_endpoint: 'https://example.com/auth', - token_endpoint: 'invalid', - registration_endpoint: 'https://example.com/registration', - }, - ], - [ - { - authorization_endpoint: 'https://example.com/auth', - token_endpoint: 'https://example.com/token', - registration_endpoint: 'invalid', - }, - ], - ])( - 'should throw a BadRequestError when OAuth2 server metadata is invalid', - async (response) => { - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({ - useDynamicClientRegistration: true, - serverUrl: 'https://example.com', - }); - nock('https://example.com') - .get('/.well-known/oauth-authorization-server') - .reply(200, response); - - const req = mock({ user, query: { id: '1' } }); - await expect(controller.getAuthUri(req)).rejects.toThrowError( - /Invalid OAuth2 server metadata/, - ); - }, - ); - - it('should throw a BadRequestError when the registration response is invalid', async () => { - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({ - useDynamicClientRegistration: true, - serverUrl: 'https://example.com', - }); - nock('https://example.com') - .get('/.well-known/oauth-authorization-server') - .reply(200, { - authorization_endpoint: 'https://example.com/auth', - token_endpoint: 'https://example.com/token', - registration_endpoint: 'https://example.com/registration', - grant_types_supported: ['authorization_code', 'refresh_token'], - token_endpoint_auth_methods_supported: ['client_secret_basic'], - }) - .post('/registration', { - redirect_uris: ['http://localhost:5678/rest/oauth2-credential/callback'], - token_endpoint_auth_method: 'client_secret_basic', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: 'n8n', - client_uri: 'https://n8n.io/', - }) - .reply(200, { invalid: 'invalid' }); - - const req = mock({ user, query: { id: '1' } }); - await expect(controller.getAuthUri(req)).rejects.toThrowError( - /Invalid client registration response/, - ); - }); - - it('should request scopes from scopes_supported for dynamic client registration', async () => { - jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret); - jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token'); - jest.spyOn(pkceChallenge, 'default').mockResolvedValueOnce({ - code_verifier: 'code-verifier', - code_challenge: 'code-challenge', - }); - credentialsFinderService.findCredentialForUser.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({}); - credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue({ - useDynamicClientRegistration: true, - serverUrl: 'https://example.com', - }); - nock('https://example.com') - .get('/.well-known/oauth-authorization-server') - .reply(200, { - authorization_endpoint: 'https://example.com/auth', - token_endpoint: 'https://example.com/token', - registration_endpoint: 'https://example.com/registration', - grant_types_supported: ['authorization_code', 'refresh_token'], - token_endpoint_auth_methods_supported: ['client_secret_basic'], - code_challenge_methods_supported: ['S256'], - scopes_supported: ['openid', 'somescope'], - }) - .post('/registration', { - redirect_uris: ['http://localhost:5678/rest/oauth2-credential/callback'], - token_endpoint_auth_method: 'none', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - client_name: 'n8n', - client_uri: 'https://n8n.io/', - scope: 'openid somescope', - }) - .reply(200, { client_id: 'test-client-id', client_secret: 'test-client-secret' }); - - const req = mock({ user, query: { id: '1' } }); - const authUri = await controller.getAuthUri(req); - - const url = new URL(authUri); - expect(url.origin).toEqual('https://example.com'); - expect(url.pathname).toEqual('/auth'); - Object.entries({ - code_challenge: 'code-challenge', - code_challenge_method: 'S256', - client_id: 'test-client-id', - redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', - response_type: 'code', - scope: 'openid somescope', - }).forEach(([param, value]) => { - expect(url.searchParams.get(param)).toEqual(value); - }); }); }); @@ -401,264 +78,280 @@ describe('OAuth2CredentialController', () => { JSON.stringify({ token: 'token', cid: '1', + userId: '123', createdAt: timestamp, }), ).toString('base64'); const res = mock(); - const req = mock({ - query: { code: 'code', state: validState }, - originalUrl: '?code=code', - }); it('should render the error page when required query params are missing', async () => { - const invalidReq = mock({ - query: { code: undefined, state: undefined }, - }); + const invalidReq = mock(); + invalidReq.query = { state: 'test' } as OAuthRequest.OAuth2Credential.Callback['query']; await controller.handleCallback(invalidReq, res); - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'Insufficient parameters for OAuth2 callback.', - reason: 'Received following query parameters: undefined', - }, - }); - expect(credentialsRepository.findOneBy).not.toHaveBeenCalled(); - }); - - it('should render the error page when `state` query param is invalid', async () => { - const invalidReq = mock({ - query: { code: 'code', state: 'invalid-state' }, - }); - - await controller.handleCallback(invalidReq, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'Invalid state format', - }, - }); - expect(credentialsRepository.findOneBy).not.toHaveBeenCalled(); - }); - - it('should render the error page when credential is not found in DB', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(null); - - await controller.handleCallback(req, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'OAuth callback failed because of insufficient permissions', - }, - }); - expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1); - expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' }); - }); - - it('should render the error page when csrfSecret on the saved credential does not match the state', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(false); - - await controller.handleCallback(req, res); - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'The OAuth callback state is invalid!', - }, - }); - expect(externalHooks.run).not.toHaveBeenCalled(); - }); - - it('should render the error page when state is older than 5 minutes', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - - jest.advanceTimersByTime(10 * Time.minutes.toMilliseconds); - - await controller.handleCallback(req, res); - - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'The OAuth callback state is invalid!', - }, - }); - expect(externalHooks.run).not.toHaveBeenCalled(); - }); - - it('should render the error page when code exchange fails', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - nock('https://example.domain') - .post( - '/token', - 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', - ) - .reply(403, { error: 'Code could not be exchanged' }); - - await controller.handleCallback(req, res); - - expect(externalHooks.run).toHaveBeenCalled(); - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'Code could not be exchanged', - reason: '{"error":"Code could not be exchanged"}', - }, - }); - }); - - it('should render the error page when code exchange fails, and the server responses with html', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - nock('https://example.domain') - .post( - '/token', - 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', - ) - .reply(403, 'Code could not be exchanged', { - 'Content-Type': 'text/html', - }); - - await controller.handleCallback(req, res); - - expect(externalHooks.run).toHaveBeenCalled(); - expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { - error: { - message: 'Unsupported content type: text/html', - reason: '"Code could not be exchanged"', - }, - }); + expect(oauthService.renderCallbackError).toHaveBeenCalledWith( + res, + 'Insufficient parameters for OAuth2 callback.', + 'Received following query parameters: {"state":"test"}', + ); }); it('should exchange the code for a valid token, and save it to DB', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - nock('https://example.domain') - .post( - '/token', - 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', - ) - .reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' }); + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetToken = jest.fn().mockResolvedValue({ + data: { access_token: 'new_token', refresh_token: 'refresh_token' }, + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getToken: mockGetToken, + }, + }) as any, + ); + + const mockResolvedCredential = mock({ id: '1' }); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + }, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + + const req = mock({ + query: { + code: 'auth_code', + state: validState, + }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state', + }); await controller.handleCallback(req, res); - expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [ + expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith( + mockResolvedCredential, expect.objectContaining({ - clientId: 'test-client-id', - redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback', - }), - ]); - const dataCaptor = captor(); - expect(credentialsRepository.update).toHaveBeenCalledWith( - '1', - expect.objectContaining({ - data: dataCaptor, - id: '1', - name: 'Test Credential', - type: 'oAuth2Api', - }), - ); - expect(cipher.decrypt(dataCaptor.value)).toEqual( - JSON.stringify({ - oauthTokenData: { access_token: 'access-token', refresh_token: 'refresh-token' }, + oauthTokenData: { access_token: 'new_token', refresh_token: 'refresh_token' }, }), + ['csrfSecret'], ); expect(res.render).toHaveBeenCalledWith('oauth-callback'); - expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( - additionalData, - credential, - credential.type, - 'internal', + expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', expect.any(Array)); + }); + + it('should handle PKCE flow', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetToken = jest.fn().mockResolvedValue({ + data: { access_token: 'new_token' }, + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getToken: mockGetToken, + }, + }) as any, + ); + + const mockResolvedCredential = mock({ id: '1' }); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret', codeVerifier: 'code_verifier' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'pkce', + authentication: 'header', + }, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + + const req = mock({ + query: { + code: 'auth_code', + state: validState, + }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state', + }); + + await controller.handleCallback(req, res); + + expect(mockGetToken).toHaveBeenCalledWith( + expect.stringContaining('code=auth_code'), + expect.objectContaining({ + body: { code_verifier: 'code_verifier' }, + }), + ); + expect(oauthService.encryptAndSaveData).toHaveBeenCalled(); + }); + + it('should handle body authentication method', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetToken = jest.fn().mockResolvedValue({ + data: { access_token: 'new_token' }, + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getToken: mockGetToken, + }, + }) as any, + ); + + const mockResolvedCredential = mock({ id: '1' }); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'body', + }, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + + const req = mock({ + query: { + code: 'auth_code', + state: validState, + }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state', + }); + + await controller.handleCallback(req, res); + + expect(mockGetToken).toHaveBeenCalledWith( + expect.stringContaining('code=auth_code'), + expect.objectContaining({ + body: expect.objectContaining({ + client_id: 'client_id', + client_secret: 'client_secret', + }), + }), + ); + }); + + it('should handle callback with additional query parameters', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetToken = jest.fn().mockResolvedValue({ + data: { access_token: 'new_token' }, + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getToken: mockGetToken, + }, + }) as any, + ); + + const mockResolvedCredential = mock({ id: '1' }); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + }, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + + const req = mock({ + query: { + code: 'auth_code', + state: validState, + extra: 'param', + } as OAuthRequest.OAuth2Credential.Callback['query'] & { extra: string }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state&extra=param', + }); + + await controller.handleCallback(req, res); + + expect(mockGetToken).toHaveBeenCalled(); + expect(oauthService.encryptAndSaveData).toHaveBeenCalledWith( + mockResolvedCredential, + expect.objectContaining({ + oauthTokenData: expect.objectContaining({ + access_token: 'new_token', + callbackQueryString: expect.objectContaining({ + extra: 'param', + }), + }), + }), + ['csrfSecret'], + ); + }); + + it('should handle errors and render error page', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetToken = jest.fn().mockRejectedValue(new Error('Token exchange failed')); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getToken: mockGetToken, + }, + }) as any, + ); + + const mockResolvedCredential = mock({ id: '1' }); + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'csrf-secret' }, + { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + }, + ]); + oauthService.getBaseUrl.mockReturnValue('http://localhost:5678/rest/oauth2-credential'); + externalHooks.run.mockResolvedValue(undefined); + + const req = mock({ + query: { + code: 'auth_code', + state: validState, + }, + originalUrl: '/oauth2-credential/callback?code=auth_code&state=state', + }); + + await controller.handleCallback(req, res); + + expect(oauthService.renderCallbackError).toHaveBeenCalledWith( + res, + 'Token exchange failed', undefined, - true, ); }); - - it('merges oauthTokenData if it already exists', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ - csrfSecret, - oauthTokenData: { token: true }, - }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - nock('https://example.domain') - .post( - '/token', - 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', - ) - .reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' }); - - await controller.handleCallback(req, res); - - expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [ - expect.objectContaining({ - clientId: 'test-client-id', - redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback', - }), - ]); - const dataCaptor = captor(); - expect(credentialsRepository.update).toHaveBeenCalledWith( - '1', - expect.objectContaining({ - data: dataCaptor, - id: '1', - name: 'Test Credential', - type: 'oAuth2Api', - }), - ); - expect(cipher.decrypt(dataCaptor.value)).toEqual( - JSON.stringify({ - oauthTokenData: { - token: true, - access_token: 'access-token', - refresh_token: 'refresh-token', - }, - }), - ); - expect(res.render).toHaveBeenCalledWith('oauth-callback'); - }); - - it('overwrites oauthTokenData if it is a string', async () => { - credentialsRepository.findOneBy.mockResolvedValueOnce(credential); - credentialsHelper.getDecrypted.mockResolvedValueOnce({ - csrfSecret, - oauthTokenData: CREDENTIAL_BLANKING_VALUE, - }); - jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); - nock('https://example.domain') - .post( - '/token', - 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', - ) - .reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' }); - - await controller.handleCallback(req, res); - - expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [ - expect.objectContaining({ - clientId: 'test-client-id', - redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback', - }), - ]); - const dataCaptor = captor(); - expect(credentialsRepository.update).toHaveBeenCalledWith( - '1', - expect.objectContaining({ - data: dataCaptor, - id: '1', - name: 'Test Credential', - type: 'oAuth2Api', - }), - ); - expect(cipher.decrypt(dataCaptor.value)).toEqual( - JSON.stringify({ - oauthTokenData: { access_token: 'access-token', refresh_token: 'refresh-token' }, - }), - ); - expect(res.render).toHaveBeenCalledWith('oauth-callback'); - }); }); }); diff --git a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts deleted file mode 100644 index 8ab590b9eff..00000000000 --- a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Logger } from '@n8n/backend-common'; -import { GlobalConfig } from '@n8n/config'; -import { Time } from '@n8n/constants'; -import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb } from '@n8n/db'; -import { CredentialsRepository } from '@n8n/db'; -import { Service } from '@n8n/di'; -import Csrf from 'csrf'; -import type { Response } from 'express'; -import { Credentials } from 'n8n-core'; -import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; -import { jsonParse, UnexpectedError } from 'n8n-workflow'; - -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; -import { CredentialsHelper } from '@/credentials-helper'; -import { AuthError } from '@/errors/response-errors/auth.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ExternalHooks } from '@/external-hooks'; -import type { OAuthRequest } from '@/requests'; -import { UrlService } from '@/services/url.service'; -import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; - -type CsrfStateParam = { - /** Id of the oAuth credential in the DB */ - cid: string; - /** Random CSRF token, used to verify the signature of the CSRF state */ - token: string; - /** Creation timestamp of the CSRF state. Used for expiration. */ - createdAt: number; - /** User who initiated OAuth flow, included to prevent cross-user credential hijacking. Optional only if `skipAuthOnOAuthCallback` is enabled. */ - userId?: string; -}; - -const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds; - -export function shouldSkipAuthOnOAuthCallback() { - const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false'; - return value === 'true'; -} - -export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback(); - -@Service() -export abstract class AbstractOAuthController { - abstract oauthVersion: number; - - constructor( - protected readonly logger: Logger, - protected readonly externalHooks: ExternalHooks, - private readonly credentialsHelper: CredentialsHelper, - private readonly credentialsRepository: CredentialsRepository, - private readonly credentialsFinderService: CredentialsFinderService, - private readonly urlService: UrlService, - private readonly globalConfig: GlobalConfig, - ) {} - - get baseUrl() { - const restUrl = `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}`; - return `${restUrl}/oauth${this.oauthVersion}-credential`; - } - - protected async getCredential( - req: OAuthRequest.OAuth2Credential.Auth, - ): Promise { - const { id: credentialId } = req.query; - - if (!credentialId) { - throw new BadRequestError('Required credential ID is missing'); - } - - const credential = await this.credentialsFinderService.findCredentialForUser( - credentialId, - req.user, - ['credential:read'], - ); - - if (!credential) { - this.logger.error( - `OAuth${this.oauthVersion} credential authorization failed because the current user does not have the correct permissions`, - { userId: req.user.id }, - ); - throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL); - } - - return credential; - } - - protected async getAdditionalData() { - return await WorkflowExecuteAdditionalData.getBase(); - } - - /** - * Allow decrypted data to evaluate expressions that include $secrets and apply overwrites - */ - protected async getDecryptedDataForAuthUri( - credential: ICredentialsDb, - additionalData: IWorkflowExecuteAdditionalData, - ) { - return await this.getDecryptedData(credential, additionalData, false); - } - - /** - * Do not apply overwrites here because that removes the CSRF state, and breaks the oauth flow - */ - protected async getDecryptedDataForCallback( - credential: ICredentialsDb, - additionalData: IWorkflowExecuteAdditionalData, - ) { - return await this.getDecryptedData(credential, additionalData, true); - } - - private async getDecryptedData( - credential: ICredentialsDb, - additionalData: IWorkflowExecuteAdditionalData, - raw: boolean, - ) { - return await this.credentialsHelper.getDecrypted( - additionalData, - credential, - credential.type, - 'internal', - undefined, - raw, - ); - } - - protected async applyDefaultsAndOverwrites( - credential: ICredentialsDb, - decryptedData: ICredentialDataDecryptedObject, - additionalData: IWorkflowExecuteAdditionalData, - ) { - return (await this.credentialsHelper.applyDefaultsAndOverwrites( - additionalData, - decryptedData, - credential, - credential.type, - 'internal', - undefined, - undefined, - )) as unknown as T; - } - - protected async encryptAndSaveData( - credential: ICredentialsDb, - toUpdate: ICredentialDataDecryptedObject, - toDelete: string[] = [], - ) { - const credentials = new Credentials(credential, credential.type, credential.data); - credentials.updateData(toUpdate, toDelete); - await this.credentialsRepository.update(credential.id, { - ...credentials.getDataToSave(), - updatedAt: new Date(), - }); - } - - /** Get a credential without user check */ - protected async getCredentialWithoutUser(credentialId: string): Promise { - return await this.credentialsRepository.findOneBy({ id: credentialId }); - } - - createCsrfState(credentialsId: string, userId?: string): [string, string] { - const token = new Csrf(); - const csrfSecret = token.secretSync(); - const state: CsrfStateParam = { - token: token.create(csrfSecret), - cid: credentialsId, - createdAt: Date.now(), - userId, - }; - return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')]; - } - - protected decodeCsrfState(encodedState: string, req: AuthenticatedRequest): CsrfStateParam { - const errorMessage = 'Invalid state format'; - const decoded = jsonParse(Buffer.from(encodedState, 'base64').toString(), { - errorMessage, - }); - - if (typeof decoded.cid !== 'string' || typeof decoded.token !== 'string') { - throw new UnexpectedError(errorMessage); - } - - if (decoded.userId !== req.user?.id) { - throw new AuthError('Unauthorized'); - } - - return decoded; - } - - protected verifyCsrfState( - decrypted: ICredentialDataDecryptedObject & { csrfSecret?: string }, - state: CsrfStateParam, - ) { - const token = new Csrf(); - - return ( - Date.now() - state.createdAt <= MAX_CSRF_AGE && - decrypted.csrfSecret !== undefined && - token.verify(decrypted.csrfSecret, state.token) - ); - } - - protected async resolveCredential( - req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback, - ): Promise<[ICredentialsDb, ICredentialDataDecryptedObject, T]> { - const { state: encodedState } = req.query; - const state = this.decodeCsrfState(encodedState, req); - const credential = await this.getCredentialWithoutUser(state.cid); - if (!credential) { - throw new UnexpectedError('OAuth callback failed because of insufficient permissions'); - } - - const additionalData = await this.getAdditionalData(); - const decryptedDataOriginal = await this.getDecryptedDataForCallback( - credential, - additionalData, - ); - - const oauthCredentials = await this.applyDefaultsAndOverwrites( - credential, - decryptedDataOriginal, - additionalData, - ); - - if (!this.verifyCsrfState(decryptedDataOriginal, state)) { - throw new UnexpectedError('The OAuth callback state is invalid!'); - } - - return [credential, decryptedDataOriginal, oauthCredentials]; - } - - protected renderCallbackError(res: Response, message: string, reason?: string) { - res.render('oauth-error-callback', { error: { message, reason } }); - } -} diff --git a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts index 9809e86d938..65a5651367b 100644 --- a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts @@ -1,104 +1,40 @@ import { Get, RestController } from '@n8n/decorators'; -import type { AxiosRequestConfig } from 'axios'; import axios from 'axios'; -import { createHmac } from 'crypto'; import { Response } from 'express'; import { ensureError, jsonStringify } from 'n8n-workflow'; -import type { RequestOptions } from 'oauth-1.0a'; -import clientOAuth1 from 'oauth-1.0a'; import { OAuthRequest } from '@/requests'; -import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller'; - -interface OAuth1CredentialData { - signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1'; - consumerKey: string; - consumerSecret: string; - authUrl: string; - accessTokenUrl: string; - requestTokenUrl: string; -} - -const algorithmMap = { - 'HMAC-SHA256': 'sha256', - 'HMAC-SHA512': 'sha512', - 'HMAC-SHA1': 'sha1', -} as const; +import { + OauthService, + skipAuthOnOAuthCallback, + type OAuth1CredentialData, +} from '@/oauth/oauth.service'; +import { Logger } from '@n8n/backend-common'; @RestController('/oauth1-credential') -export class OAuth1CredentialController extends AbstractOAuthController { - override oauthVersion = 1; +export class OAuth1CredentialController { + constructor( + private readonly oauthService: OauthService, + private readonly logger: Logger, + ) {} /** Get Authorization url */ @Get('/auth') async getAuthUri(req: OAuthRequest.OAuth1Credential.Auth): Promise { - const credential = await this.getCredential(req); - const additionalData = await this.getAdditionalData(); - const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData); - const oauthCredentials = await this.applyDefaultsAndOverwrites( - credential, - decryptedDataOriginal, - additionalData, - ); - const [csrfSecret, state] = this.createCsrfState( - credential.id, - skipAuthOnOAuthCallback ? undefined : req.user.id, - ); + const credential = await this.oauthService.getCredential(req); - const signatureMethod = oauthCredentials.signatureMethod; - - const oAuthOptions: clientOAuth1.Options = { - consumer: { - key: oauthCredentials.consumerKey, - secret: oauthCredentials.consumerSecret, - }, - signature_method: signatureMethod, - - hash_function(base, key) { - const algorithm = algorithmMap[signatureMethod] ?? 'sha1'; - return createHmac(algorithm, key).update(base).digest('base64'); - }, - }; - - const oauthRequestData = { - oauth_callback: `${this.baseUrl}/callback?state=${state}`, - }; - - await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]); - - const oauth = new clientOAuth1(oAuthOptions); - - const options: RequestOptions = { - method: 'POST', - url: oauthCredentials.requestTokenUrl, - data: oauthRequestData, - }; - - const data = oauth.toHeader(oauth.authorize(options)); - - // @ts-ignore - options.headers = data; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { data: response } = await axios.request(options as Partial); - - // Response comes as x-www-form-urlencoded string so convert it to JSON - - const paramsParser = new URLSearchParams(response as string); - - const responseJson = Object.fromEntries(paramsParser.entries()); - - const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`; - - await this.encryptAndSaveData(credential, { csrfSecret }); + const uri = await this.oauthService.generateAOauth1AuthUri(credential, { + cid: credential.id, + userId: skipAuthOnOAuthCallback ? undefined : req.user.id, + }); this.logger.debug('OAuth1 authorization successful for new credential', { userId: req.user.id, credentialId: credential.id, }); - return returnUri; + return uri; } /** Verify and store app code. Generate access tokens and store for respective credential */ @@ -108,7 +44,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { const { oauth_verifier, oauth_token, state: encodedState } = req.query; if (!oauth_verifier || !oauth_token || !encodedState) { - return this.renderCallbackError( + return this.oauthService.renderCallbackError( res, 'Insufficient parameters for OAuth1 callback.', `Received following query parameters: ${JSON.stringify(req.query)}`, @@ -116,7 +52,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { } const [credential, _, oauthCredentials] = - await this.resolveCredential(req); + await this.oauthService.resolveCredential(req); // Form URL encoded body https://datatracker.ietf.org/doc/html/rfc5849#section-3.5.2 const oauthToken = await axios.post( @@ -131,7 +67,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { const oauthTokenData = Object.fromEntries(paramParser.entries()); - await this.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']); + await this.oauthService.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']); this.logger.debug('OAuth1 callback successful for new credential', { credentialId: credential.id, @@ -139,7 +75,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { return res.render('oauth-callback'); } catch (e) { const error = ensureError(e); - return this.renderCallbackError( + return this.oauthService.renderCallbackError( res, error.message, 'body' in error ? jsonStringify(error.body) : undefined, diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index 7e1f6c8bc86..df0d7bee919 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -1,178 +1,35 @@ -import type { - ClientOAuth2Options, - OAuth2AuthenticationMethod, - OAuth2CredentialData, - OAuth2GrantType, -} from '@n8n/client-oauth2'; +import type { ClientOAuth2Options, OAuth2CredentialData } from '@n8n/client-oauth2'; import { ClientOAuth2 } from '@n8n/client-oauth2'; import { Get, RestController } from '@n8n/decorators'; -import axios from 'axios'; import { Response } from 'express'; import omit from 'lodash/omit'; import set from 'lodash/set'; import split from 'lodash/split'; -import { - ensureError, - type ICredentialDataDecryptedObject, - jsonParse, - jsonStringify, -} from 'n8n-workflow'; -import pkceChallenge from 'pkce-challenge'; -import * as qs from 'querystring'; +import { ensureError, jsonParse, jsonStringify } from 'n8n-workflow'; -import { AbstractOAuthController, skipAuthOnOAuthCallback } from './abstract-oauth.controller'; -import { - oAuthAuthorizationServerMetadataSchema, - dynamicClientRegistrationResponseSchema, -} from './oauth2-dynamic-client-registration.schema'; - -import { GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE as GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE } from '@/constants'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { OAuthRequest } from '@/requests'; +import { OauthService, OauthVersion, skipAuthOnOAuthCallback } from '@/oauth/oauth.service'; +import { Logger } from '@n8n/backend-common'; +import { ExternalHooks } from '@/external-hooks'; @RestController('/oauth2-credential') -export class OAuth2CredentialController extends AbstractOAuthController { - override oauthVersion = 2; +export class OAuth2CredentialController { + constructor( + private readonly oauthService: OauthService, + private readonly logger: Logger, + private readonly externalHooks: ExternalHooks, + ) {} /** Get Authorization url */ @Get('/auth') async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise { - const credential = await this.getCredential(req); - const additionalData = await this.getAdditionalData(); - const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData); + const credential = await this.oauthService.getCredential(req); - // At some point in the past we saved hidden scopes to credentials (but shouldn't) - // Delete scope before applying defaults to make sure new scopes are present on reconnect - // Generic Oauth2 API is an exception because it needs to save the scope - - if ( - decryptedDataOriginal?.scope && - credential.type.includes('OAuth2') && - !GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE.includes(credential.type) - ) { - delete decryptedDataOriginal.scope; - } - - const oauthCredentials = await this.applyDefaultsAndOverwrites( - credential, - decryptedDataOriginal, - additionalData, - ); - - const toUpdate: ICredentialDataDecryptedObject = {}; - - if (oauthCredentials.useDynamicClientRegistration && oauthCredentials.serverUrl) { - const serverUrl = new URL(oauthCredentials.serverUrl); - const { data } = await axios.get( - `${serverUrl.origin}/.well-known/oauth-authorization-server`, - ); - const metadataValidation = oAuthAuthorizationServerMetadataSchema.safeParse(data); - if (!metadataValidation.success) { - throw new BadRequestError( - `Invalid OAuth2 server metadata: ${metadataValidation.error.issues.map((e) => e.message).join(', ')}`, - ); - } - - const { authorization_endpoint, token_endpoint, registration_endpoint, scopes_supported } = - metadataValidation.data; - oauthCredentials.authUrl = authorization_endpoint; - oauthCredentials.accessTokenUrl = token_endpoint; - toUpdate.authUrl = authorization_endpoint; - toUpdate.accessTokenUrl = token_endpoint; - const scope = scopes_supported ? scopes_supported.join(' ') : undefined; - if (scope) { - oauthCredentials.scope = scope; - toUpdate.scope = scope; - } - - const { grantType, authentication } = this.selectGrantTypeAndAuthenticationMethod( - metadataValidation.data.grant_types_supported ?? ['authorization_code', 'implicit'], - metadataValidation.data.token_endpoint_auth_methods_supported ?? ['client_secret_basic'], - metadataValidation.data.code_challenge_methods_supported ?? [], - ); - oauthCredentials.grantType = grantType; - toUpdate.grantType = grantType; - if (authentication) { - oauthCredentials.authentication = authentication; - toUpdate.authentication = authentication; - } - - const { grant_types, token_endpoint_auth_method } = this.mapGrantTypeAndAuthenticationMethod( - grantType, - authentication, - ); - const registerPayload = { - redirect_uris: [`${this.baseUrl}/callback`], - token_endpoint_auth_method, - grant_types, - response_types: ['code'], - client_name: 'n8n', - client_uri: 'https://n8n.io/', - scope, - }; - - await this.externalHooks.run('oauth2.dynamicClientRegistration', [registerPayload]); - - const { data: registerResult } = await axios.post( - registration_endpoint, - registerPayload, - ); - const registrationValidation = - dynamicClientRegistrationResponseSchema.safeParse(registerResult); - if (!registrationValidation.success) { - throw new BadRequestError( - `Invalid client registration response: ${registrationValidation.error.issues.map((e) => e.message).join(', ')}`, - ); - } - - const { client_id, client_secret } = registrationValidation.data; - oauthCredentials.clientId = client_id; - toUpdate.clientId = client_id; - if (client_secret) { - oauthCredentials.clientSecret = client_secret; - toUpdate.clientSecret = client_secret; - } - } - - // Generate a CSRF prevention token and send it as an OAuth2 state string - const [csrfSecret, state] = this.createCsrfState( - credential.id, - skipAuthOnOAuthCallback ? undefined : req.user.id, - ); - - const oAuthOptions = { - ...this.convertCredentialToOptions(oauthCredentials), - state, - }; - - if (oauthCredentials.authQueryParameters) { - oAuthOptions.query = qs.parse(oauthCredentials.authQueryParameters); - } - - await this.externalHooks.run('oauth2.authenticate', [oAuthOptions]); - - toUpdate.csrfSecret = csrfSecret; - if (oauthCredentials.grantType === 'pkce') { - const { code_verifier, code_challenge } = await pkceChallenge(); - oAuthOptions.query = { - ...oAuthOptions.query, - code_challenge, - code_challenge_method: 'S256', - }; - toUpdate.codeVerifier = code_verifier; - } - - await this.encryptAndSaveData(credential, toUpdate); - - const oAuthObj = new ClientOAuth2(oAuthOptions); - const returnUri = oAuthObj.code.getUri(); - - this.logger.debug('OAuth2 authorization url created for credential', { + const uri = await this.oauthService.generateAOauth2AuthUri(credential, { + cid: credential.id, userId: req.user.id, - credentialId: credential.id, }); - - return returnUri.toString(); + return uri; } /** Verify and store app code. Generate access tokens and store for respective credential */ @@ -181,7 +38,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { try { const { code, state: encodedState } = req.query; if (!code || !encodedState) { - return this.renderCallbackError( + return this.oauthService.renderCallbackError( res, 'Insufficient parameters for OAuth2 callback.', `Received following query parameters: ${JSON.stringify(req.query)}`, @@ -189,7 +46,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { } const [credential, decryptedDataOriginal, oauthCredentials] = - await this.resolveCredential(req); + await this.oauthService.resolveCredential(req); let options: Partial = {}; @@ -233,7 +90,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { ...oauthToken.data, }; - await this.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']); + await this.oauthService.encryptAndSaveData(credential, { oauthTokenData }, ['csrfSecret']); this.logger.debug('OAuth2 callback successful for credential', { credentialId: credential.id, @@ -242,7 +99,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { return res.render('oauth-callback'); } catch (e) { const error = ensureError(e); - return this.renderCallbackError( + return this.oauthService.renderCallbackError( res, error.message, 'body' in error ? jsonStringify(error.body) : undefined, @@ -257,7 +114,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { accessTokenUri: credential.accessTokenUrl ?? '', authorizationUri: credential.authUrl ?? '', authentication: credential.authentication ?? 'header', - redirectUri: `${this.baseUrl}/callback`, + redirectUri: `${this.oauthService.getBaseUrl(OauthVersion.V2)}/callback`, scopes: split(credential.scope ?? 'openid', ','), scopesSeparator: credential.scope?.includes(',') ? ',' : ' ', ignoreSSLIssues: credential.ignoreSSLIssues ?? false, @@ -276,62 +133,4 @@ export class OAuth2CredentialController extends AbstractOAuthController { return options; } - - private selectGrantTypeAndAuthenticationMethod( - grantTypes: string[], - tokenEndpointAuthMethods: string[], - codeChallengeMethods: string[], - ): { grantType: OAuth2GrantType; authentication?: OAuth2AuthenticationMethod } { - if (grantTypes.includes('authorization_code') && grantTypes.includes('refresh_token')) { - if (codeChallengeMethods.includes('S256')) { - return { grantType: 'pkce' }; - } - - if (tokenEndpointAuthMethods.includes('client_secret_basic')) { - return { grantType: 'authorizationCode', authentication: 'header' }; - } - - if (tokenEndpointAuthMethods.includes('client_secret_post')) { - return { grantType: 'authorizationCode', authentication: 'body' }; - } - } - - if (grantTypes.includes('client_credentials')) { - if (tokenEndpointAuthMethods.includes('client_secret_basic')) { - return { grantType: 'clientCredentials', authentication: 'header' }; - } - - if (tokenEndpointAuthMethods.includes('client_secret_post')) { - return { grantType: 'clientCredentials', authentication: 'body' }; - } - } - - throw new BadRequestError('No supported grant type and authentication method found'); - } - - private mapGrantTypeAndAuthenticationMethod( - grantType: OAuth2GrantType, - authentication?: OAuth2AuthenticationMethod, - ) { - if (grantType === 'pkce') { - return { - grant_types: ['authorization_code', 'refresh_token'], - token_endpoint_auth_method: 'none', - }; - } - - const tokenEndpointAuthMethod = - authentication === 'header' ? 'client_secret_basic' : 'client_secret_post'; - if (grantType === 'authorizationCode') { - return { - grant_types: ['authorization_code', 'refresh_token'], - token_endpoint_auth_method: tokenEndpointAuthMethod, - }; - } - - return { - grant_types: ['client_credentials'], - token_endpoint_auth_method: tokenEndpointAuthMethod, - }; - } } diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 6192fed4cb8..1365cbb526b 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -1,82 +1,31 @@ import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types'; -import { Logger } from '@n8n/backend-common'; -import { - AuthenticatedRequest, - GLOBAL_OWNER_ROLE, - SettingsRepository, - UserRepository, -} from '@n8n/db'; +import { AuthenticatedRequest } from '@n8n/db'; import { Body, GlobalScope, Post, RestController } from '@n8n/decorators'; import { Response } from 'express'; import { AuthService } from '@/auth/auth.service'; -import config from '@/config'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/events/event.service'; -import { validateEntity } from '@/generic-helpers'; import { PostHogClient } from '@/posthog'; import { BannerService } from '@/services/banner.service'; -import { PasswordUtility } from '@/services/password.utility'; import { UserService } from '@/services/user.service'; +import { OwnershipService } from '@/services/ownership.service'; @RestController('/owner') export class OwnerController { constructor( - private readonly logger: Logger, - private readonly eventService: EventService, - private readonly settingsRepository: SettingsRepository, private readonly authService: AuthService, private readonly bannerService: BannerService, private readonly userService: UserService, - private readonly passwordUtility: PasswordUtility, private readonly postHog: PostHogClient, - private readonly userRepository: UserRepository, + private readonly ownershipService: OwnershipService, ) {} /** - * Promote a shell into the owner of the n8n instance, - * and enable `isInstanceOwnerSetUp` setting. + * Promote a shell into the owner of the n8n instance */ @Post('/setup', { skipAuth: true }) async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) { - const { email, firstName, lastName, password } = payload; - - if (config.getEnv('userManagement.isInstanceOwnerSetUp')) { - this.logger.debug( - 'Request to claim instance ownership failed because instance owner already exists', - ); - throw new BadRequestError('Instance owner already setup'); - } - - let owner = await this.userRepository.findOneOrFail({ - where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, - relations: ['role'], - }); - owner.email = email; - owner.firstName = firstName; - owner.lastName = lastName; - owner.password = await this.passwordUtility.hash(password); - - // TODO: move XSS validation out into the DTO class - await validateEntity(owner); - - owner = await this.userRepository.save(owner, { transaction: false }); - - this.logger.info('Owner was set up successfully'); - - await this.settingsRepository.update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(true) }, - ); - - config.set('userManagement.isInstanceOwnerSetUp', true); - - this.logger.debug('Setting isInstanceOwnerSetUp updated successfully'); - + const owner = await this.ownershipService.setupOwner(payload); this.authService.issueCookie(res, owner, req.authInfo?.usedMfa ?? false, req.browserId); - - this.eventService.emit('instance-owner-setup', { userId: owner.id }); - return await this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true }); } diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts index c327d23affc..ad18d998f5d 100644 --- a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -1,14 +1,15 @@ +import type { LicenseState } from '@n8n/backend-common'; import type { AuthenticatedRequest, SharedCredentialsRepository, CredentialsEntity } from '@n8n/db'; import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db'; import { mock } from 'jest-mock-extended'; -import { createRawProjectData } from '@/__tests__/project.test-data'; -import type { EventService } from '@/events/event.service'; - import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data'; +import type { CredentialsFinderService } from '../credentials-finder.service'; import { CredentialsController } from '../credentials.controller'; import type { CredentialsService } from '../credentials.service'; -import type { CredentialsFinderService } from '../credentials-finder.service'; + +import { createRawProjectData } from '@/__tests__/project.test-data'; +import type { EventService } from '@/events/event.service'; import type { CredentialRequest } from '@/requests'; describe('CredentialsController', () => { @@ -16,13 +17,14 @@ describe('CredentialsController', () => { const credentialsService = mock(); const sharedCredentialsRepository = mock(); const credentialsFinderService = mock(); + const licenseState = mock(); const credentialsController = new CredentialsController( mock(), credentialsService, mock(), mock(), - mock(), + licenseState, mock(), mock(), sharedCredentialsRepository, @@ -126,7 +128,7 @@ describe('CredentialsController', () => { ] as any); }); - it('should allow owner to set isGlobal to true', async () => { + it('should not allow owner to set isGlobal to true if not licensed', async () => { // ARRANGE const ownerReq = { user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE }, @@ -139,6 +141,34 @@ describe('CredentialsController', () => { }, } as unknown as CredentialRequest.Update; + licenseState.isSharingLicensed.mockReturnValue(false); + + credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential); + + // ACT + await expect(credentialsController.updateCredentials(ownerReq)).rejects.toThrowError( + 'You are not licensed for sharing credentials', + ); + + // ASSERT + expect(credentialsService.update).not.toHaveBeenCalled(); + }); + + it('should allow owner to set isGlobal to true if licensed', async () => { + // ARRANGE + const ownerReq = { + user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE }, + params: { credentialId }, + body: { + name: 'Updated Credential', + type: 'apiKey', + data: { apiKey: 'updated-key' }, + isGlobal: true, + }, + } as unknown as CredentialRequest.Update; + + licenseState.isSharingLicensed.mockReturnValue(true); + credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential); credentialsService.update.mockResolvedValue({ ...existingCredential, @@ -163,7 +193,7 @@ describe('CredentialsController', () => { }); }); - it('should allow owner to set isGlobal to false', async () => { + it('should allow owner to set isGlobal to false if licensed', async () => { // ARRANGE const globalCredential = mock({ ...existingCredential, @@ -180,6 +210,8 @@ describe('CredentialsController', () => { }, } as unknown as CredentialRequest.Update; + licenseState.isSharingLicensed.mockReturnValue(true); + credentialsFinderService.findCredentialForUser.mockResolvedValue(globalCredential); credentialsService.update.mockResolvedValue({ ...globalCredential, @@ -198,7 +230,7 @@ describe('CredentialsController', () => { ); }); - it('should prevent non-owner from changing isGlobal', async () => { + it('should prevent non-owner from changing isGlobal if licensed', async () => { // ARRANGE const memberReq = { user: { id: 'member-id', role: GLOBAL_MEMBER_ROLE }, @@ -211,6 +243,8 @@ describe('CredentialsController', () => { }, } as unknown as CredentialRequest.Update; + licenseState.isSharingLicensed.mockReturnValue(true); + credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential); // ACT @@ -235,6 +269,8 @@ describe('CredentialsController', () => { }, } as unknown as CredentialRequest.Update; + licenseState.isSharingLicensed.mockReturnValue(true); + credentialsFinderService.findCredentialForUser.mockResolvedValue({ ...existingCredential, isGlobal: true, diff --git a/packages/cli/src/credentials/credentials-finder.service.ts b/packages/cli/src/credentials/credentials-finder.service.ts index b2f522b0a2b..be5ce0c1648 100644 --- a/packages/cli/src/credentials/credentials-finder.service.ts +++ b/packages/cli/src/credentials/credentials-finder.service.ts @@ -53,6 +53,10 @@ export class CredentialsFinderService { }); } + async findCredentialById(credentialId: string): Promise { + return await this.credentialsRepository.findOne({ where: { id: credentialId } }); + } + /** * Merges global credentials with the provided credentials list, * deduplicating based on credential ID. diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 965966262d9..15290795822 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -4,7 +4,7 @@ import { CredentialsGetOneRequestQuery, GenerateCredentialNameRequestQuery, } from '@n8n/api-types'; -import { Logger } from '@n8n/backend-common'; +import { LicenseState, Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { SharedCredentials, @@ -40,7 +40,6 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; -import { License } from '@/license'; import { listQueryMiddleware } from '@/middlewares'; import { CredentialRequest } from '@/requests'; import { NamingService } from '@/services/naming.service'; @@ -54,7 +53,7 @@ export class CredentialsController { private readonly credentialsService: CredentialsService, private readonly enterpriseCredentialsService: EnterpriseCredentialsService, private readonly namingService: NamingService, - private readonly license: License, + private readonly licenseState: LicenseState, private readonly logger: Logger, private readonly userManagementMailer: UserManagementMailer, private readonly sharedCredentialsRepository: SharedCredentialsRepository, @@ -114,8 +113,8 @@ export class CredentialsController { @Param('credentialId') credentialId: string, @Query query: CredentialsGetOneRequestQuery, ) { - const { shared, ...credential } = this.license.isSharingEnabled() - ? await this.enterpriseCredentialsService.getOne( + const { shared, ...credential } = this.licenseState.isSharingLicensed() + ? await this.enterpriseCredentialsService.getOneForUser( req.user, credentialId, // TODO: editor-ui is always sending this, maybe we can just rely on the @@ -246,6 +245,10 @@ export class CredentialsController { // Update isGlobal if provided in the payload and user has permission const isGlobal = body.isGlobal; if (isGlobal !== undefined && isGlobal !== credential.isGlobal) { + if (!this.licenseState.isSharingLicensed()) { + throw new ForbiddenError('You are not licensed for sharing credentials'); + } + const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally'); if (!canShareGlobally) { throw new ForbiddenError( diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index e3452eccc13..7ee49cbe7fa 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -77,7 +77,11 @@ export class EnterpriseCredentialsService { return await em.save(newSharedCredentials); } - async getOne(user: User, credentialId: string, includeDecryptedData: boolean) { + async getOne(credentialId: string) { + return await this.credentialsFinderService.findCredentialById(credentialId); + } + + async getOneForUser(user: User, credentialId: string, includeDecryptedData: boolean) { let credential: CredentialsEntity | null = null; let decryptedData: ICredentialDataDecryptedObject | null = null; diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 899cd8a232f..06faf9ae93e 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -33,7 +33,6 @@ import { displayParameter, isINodePropertyCollection, NodeHelpers, - UnexpectedError, } from 'n8n-workflow'; import { CredentialsFinderService } from './credentials-finder.service'; @@ -387,6 +386,12 @@ export class CredentialsService { })); } + async findAllGlobalCredentialIds(includeData: boolean = false): Promise { + const globalCredentials = + await this.credentialsRepository.findAllGlobalCredentials(includeData); + return globalCredentials; + } + async findAllCredentialIdsForWorkflow(workflowId: string): Promise { // If the workflow is owned by a personal project and the owner of the // project has global read permissions it can use all personal credentials. @@ -540,30 +545,29 @@ export class CredentialsService { const { manager: dbManager } = this.credentialsRepository; const result = await dbManager.transaction(async (transactionManager) => { - const project = - projectId === undefined - ? await this.projectRepository.getPersonalProjectForUserOrFail( - user.id, - transactionManager, - ) - : await this.projectService.getProjectWithScope( - user, - projectId, - ['credential:create'], - transactionManager, - ); + if (projectId === undefined) { + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + transactionManager, + ); + // Chat users are not allowed to create credentials even within their personal project, + // so even though we found the project ensure it gets found via expected scope too. + projectId = personalProject.id; + } - if (typeof projectId === 'string' && project === null) { + const project = await this.projectService.getProjectWithScope( + user, + projectId, + ['credential:create'], + transactionManager, + ); + + if (project === null) { throw new BadRequestError( "You don't have the permissions to save the credential in this project.", ); } - // Safe guard in case the personal project does not exist for whatever reason. - if (project === null) { - throw new UnexpectedError('No personal project found'); - } - const savedCredential = await transactionManager.save(newCredential); savedCredential.data = newCredential.data; diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index a7defa4d579..cbd258907ea 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -74,6 +74,7 @@ describe('SourceControlImportService', () => { mock({ n8nFolder: '/mock/n8n' }), sourceControlScopedService, mock(), + mock(), ); const globMock = fastGlob.default as unknown as jest.Mock, string[]>; diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 94c53739363..f873fdb7d7c 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -26,11 +26,15 @@ import { Service } from '@n8n/di'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; +import { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import glob from 'fast-glob'; +import isEqual from 'lodash/isEqual'; import { Credentials, ErrorReporter, InstanceSettings } from 'n8n-core'; +import type { IWorkflowBase } from 'n8n-workflow'; import { ensureError, jsonParse, UnexpectedError, UserError } from 'n8n-workflow'; import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; +import { v4 as uuid } from 'uuid'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -38,6 +42,7 @@ import type { IWorkflowToImport } from '@/interfaces'; import { isUniqueConstraintError } from '@/response-helper'; import { TagService } from '@/services/tag.service'; import { assertNever } from '@/utils'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { @@ -153,6 +158,7 @@ export class SourceControlImportService { instanceSettings: InstanceSettings, private readonly sourceControlScopedService: SourceControlScopedService, private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository, + private readonly workflowHistoryService: WorkflowHistoryService, ) { this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER); @@ -677,6 +683,7 @@ export class SourceControlImportService { } else { importedWorkflow.active = false; importedWorkflow.activeVersionId = null; + importedWorkflow.versionId = importedWorkflow.versionId ?? uuid(); } const parentFolderId = importedWorkflow.parentFolderId ?? ''; @@ -696,6 +703,8 @@ export class SourceControlImportService { }); } + await this.saveOrUpdateWorkflowHistory(importedWorkflow, userId); + const localOwner = allSharedWorkflows.find( (w) => w.workflowId === importedWorkflow.id && w.role === 'workflow:owner', ); @@ -893,7 +902,7 @@ export class SourceControlImportService { } const tagCopy = this.tagRepository.create(tag); - await this.tagRepository.upsert(tagCopy, { + await this.tagRepository.upsert(tagCopy as QueryDeepPartialEntity, { skipUpdateIfNoValuesChanged: true, conflictPaths: { id: true }, }); @@ -1262,4 +1271,76 @@ export class SourceControlImportService { return teamProject; } + + /** + * Saves or updates workflow version history during import. + * - If versionId is new: Creates new history record + * - If versionId exists with different nodes/connections: Updates existing record + * - If versionId exists with same content: No action + */ + private async saveOrUpdateWorkflowHistory( + importedWorkflow: IWorkflowToImport, + userId: string, + ): Promise { + if (!importedWorkflow.versionId || !importedWorkflow.nodes || !importedWorkflow.connections) { + this.logger.debug('Skipping workflow history - missing versionId, nodes, or connections'); + return; + } + + // Fetch user for author info + const user = await this.userRepository.findOne({ where: { id: userId } }); + const authors = user ? `${user.firstName} ${user.lastName}` : 'Unknown'; + + try { + const existingVersion = await this.workflowHistoryService.findVersion( + importedWorkflow.id, + importedWorkflow.versionId, + ); + + if (existingVersion) { + // Check if nodes or connections changed + const nodesChanged = !isEqual(existingVersion.nodes, importedWorkflow.nodes); + const connectionsChanged = !isEqual( + existingVersion.connections, + importedWorkflow.connections, + ); + + if (nodesChanged || connectionsChanged) { + this.logger.debug( + `Updating workflow history for versionId ${importedWorkflow.versionId}`, + ); + + await this.workflowHistoryService.updateVersion( + importedWorkflow.versionId, + importedWorkflow.id, + { + nodes: importedWorkflow.nodes, + connections: importedWorkflow.connections, + authors, + }, + ); + } else { + this.logger.debug( + `Workflow history unchanged for versionId ${importedWorkflow.versionId}`, + ); + } + } else { + // Create new version history record + this.logger.debug( + `Creating new workflow history for versionId ${importedWorkflow.versionId}`, + ); + + await this.workflowHistoryService.saveVersion( + authors, + importedWorkflow as unknown as IWorkflowBase, + importedWorkflow.id, + ); + } + } catch (error) { + this.logger.error( + `Failed to save/update workflow history for workflow ${importedWorkflow.id}`, + { error: ensureError(error) }, + ); + } + } } diff --git a/packages/cli/src/errors/single-webhook-trigger.error.ts b/packages/cli/src/errors/single-webhook-trigger.error.ts new file mode 100644 index 00000000000..2627960863b --- /dev/null +++ b/packages/cli/src/errors/single-webhook-trigger.error.ts @@ -0,0 +1,10 @@ +import { UserError } from 'n8n-workflow'; + +export class SingleWebhookTriggerError extends UserError { + constructor(triggerName: string) { + super( + `Because of limitations in ${triggerName}, n8n can't listen for test executions at the same time as listening for production ones. Unpublish the workflow to execute.`, + { extra: { triggerName } }, + ); + } +} diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 19b9dac30bd..b48832c6c5d 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -882,6 +882,10 @@ describe('TelemetryEventRelay', () => { num_tags: 0, public_api: false, sharing_role: undefined, + meta: undefined, // workflow.meta is undefined in mock + workflow_edited_no_pos: false, + credential_edited: false, + ai_builder_assisted: false, }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index b2c3414eb7a..26a2813f7ef 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -86,6 +86,8 @@ export type RelayEventMap = { user: UserLike; workflow: IWorkflowDb; publicApi: boolean; + previousWorkflow?: IWorkflowDb; + aiBuilderAssisted?: boolean; }; 'workflow-activated': { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 4428103c883..61610342110 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -10,7 +10,7 @@ import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { snakeCase } from 'change-case'; import { BinaryDataConfig, InstanceSettings } from 'n8n-core'; import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; -import { TelemetryHelpers } from 'n8n-workflow'; +import { hasCredentialChanges, hasNonPositionalChanges, TelemetryHelpers } from 'n8n-workflow'; import os from 'node:os'; import { get as pslGet } from 'psl'; @@ -621,7 +621,13 @@ export class TelemetryEventRelay extends EventRelay { }); } - private async workflowSaved({ user, workflow, publicApi }: RelayEventMap['workflow-saved']) { + private async workflowSaved({ + user, + workflow, + publicApi, + previousWorkflow, + aiBuilderAssisted, + }: RelayEventMap['workflow-saved']) { const isCloudDeployment = this.globalConfig.deployment.type === 'cloud'; const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, { @@ -654,6 +660,18 @@ export class TelemetryEventRelay extends EventRelay { (note) => note.overlapping, ).length; + let workflowEditedNoPos = false; + let credentialEdited = false; + if (previousWorkflow) { + workflowEditedNoPos = hasNonPositionalChanges( + previousWorkflow.nodes, + workflow.nodes, + previousWorkflow.connections, + workflow.connections, + ); + credentialEdited = hasCredentialChanges(previousWorkflow.nodes, workflow.nodes); + } + this.telemetry.track('User saved workflow', { user_id: user.id, workflow_id: workflow.id, @@ -665,6 +683,9 @@ export class TelemetryEventRelay extends EventRelay { public_api: publicApi, sharing_role: userRole, meta: JSON.stringify(workflow.meta), + workflow_edited_no_pos: workflowEditedNoPos, + credential_edited: credentialEdited, + ai_builder_assisted: aiBuilderAssisted ?? false, }); } @@ -813,13 +834,17 @@ export class TelemetryEventRelay extends EventRelay { manualExecEventProperties.is_managed = credential.isManaged; } } + const destinationNodeName = runData.data.startData?.destinationNode.nodeName; const telemetryPayload: ITelemetryTrackProperties = { ...manualExecEventProperties, - node_type: TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.startData?.destinationNode.nodeName, - )?.type, - node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode.nodeName], + node_type: TelemetryHelpers.getNodeTypeForName(workflow, destinationNodeName)?.type, + node_id: nodeGraphResult.nameIndices[destinationNodeName], + node_role: TelemetryHelpers.getNodeRole( + destinationNodeName, + workflow.connections, + this.nodeTypes, + workflow.nodes, + ), }; this.telemetry.track('Manual node exec finished', telemetryPayload); diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index a7e07778a3e..472c6b806aa 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -858,5 +858,90 @@ describe('Execution Lifecycle Hooks', () => { expect(handlers.sendResponse).toHaveLength(0); expect(handlers.sendChunk).toHaveLength(0); }); + + describe('when parentExecution is provided', () => { + const parentWorkflowId = 'parent-workflow-id'; + const parentExecutionId = 'parent-execution-id'; + const parentExecution = { + workflowId: parentWorkflowId, + executionId: parentExecutionId, + }; + + beforeEach(() => { + lifecycleHooks = getLifecycleHooksForSubExecutions( + 'integrated', + executionId, + workflowData, + undefined, + parentExecution, + ); + }); + + it('should duplicate binary data to parent execution', async () => { + const binaryDataId = `filesystem:workflows/${workflowId}/executions/${executionId}/binary_data/123`; + const duplicatedBinaryDataId = `filesystem:workflows/${parentWorkflowId}/executions/${parentExecutionId}/binary_data/456`; + + const mainOutputData = [ + [ + { + json: {}, + binary: { + data: { + id: binaryDataId, + data: '', + mimeType: 'text/plain', + }, + }, + }, + ], + ]; + + successfulRun.data.resultData.runData = { + [nodeName]: [ + { + startTime: 1, + executionIndex: 0, + executionTime: 1, + source: [], + data: { + main: mainOutputData, + }, + }, + ], + }; + successfulRun.data.resultData.lastNodeExecuted = nodeName; + + binaryDataService.duplicateBinaryData.mockResolvedValue([ + [ + { + json: {}, + binary: { + data: { + id: duplicatedBinaryDataId, + data: '', + mimeType: 'text/plain', + }, + }, + }, + ], + ]); + + await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); + + expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith( + { type: 'execution', workflowId: parentWorkflowId, executionId: parentExecutionId }, + mainOutputData, + ); + }); + + it('should not duplicate binary data when there is no output data', async () => { + successfulRun.data.resultData.runData = {}; + successfulRun.data.resultData.lastNodeExecuted = undefined; + + await lifecycleHooks.runHook('workflowExecuteAfter', [successfulRun, {}]); + + expect(binaryDataService.duplicateBinaryData).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index f9bedcecbd1..b166ab9521a 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -3,9 +3,17 @@ import { ExecutionRepository } from '@n8n/db'; import { LifecycleMetadata } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import { stringify } from 'flatted'; -import { ErrorReporter, InstanceSettings, ExecutionLifecycleHooks } from 'n8n-core'; +import { + BinaryDataService, + ErrorReporter, + FileLocation, + InstanceSettings, + ExecutionLifecycleHooks, +} from 'n8n-core'; import type { + IRun, IWorkflowBase, + RelatedExecution, WorkflowExecuteMode, IWorkflowExecutionDataProcess, } from 'n8n-workflow'; @@ -28,6 +36,7 @@ import { } from './shared/shared-hook-functions'; import { type ExecutionSaveSettings, toSaveSettings } from './to-save-settings'; import { getItemCountByConnectionType } from '@/utils/get-item-count-by-connection-type'; +import { getDataLastExecutedNodeData } from '@/workflow-helpers'; @Service() class ModulesHooksRegistry { @@ -99,6 +108,7 @@ type HooksSetupParameters = { saveSettings: ExecutionSaveSettings; pushRef?: string; retryOf?: string; + parentExecution?: RelatedExecution; }; function hookFunctionsWorkflowEvents(hooks: ExecutionLifecycleHooks, userId?: string) { @@ -305,16 +315,39 @@ function hookFunctionsStatistics(hooks: ExecutionLifecycleHooks) { }); } +/** + * Duplicates binary data from a subworkflow execution to the parent execution. + * This ensures the parent can access the binary data after the subworkflow + * execution is cleaned up. The duplicateBinaryData method also updates + * the binary data IDs in the data to point to the new location. + */ +async function duplicateBinaryDataToParent( + fullRunData: IRun, + parentExecution: RelatedExecution, + binaryDataService: BinaryDataService, +) { + const outputData = getDataLastExecutedNodeData(fullRunData); + if (outputData?.data?.main) { + const duplicatedData = await binaryDataService.duplicateBinaryData( + FileLocation.ofExecution(parentExecution.workflowId, parentExecution.executionId), + outputData.data.main, + ); + // Update the run data with the new binary data IDs + outputData.data.main = duplicatedData; + } +} + /** * Returns hook functions to save workflow execution and call error workflow */ function hookFunctionsSave( hooks: ExecutionLifecycleHooks, - { pushRef, retryOf, saveSettings }: HooksSetupParameters, + { pushRef, retryOf, saveSettings, parentExecution }: HooksSetupParameters, ) { const logger = Container.get(Logger); const errorReporter = Container.get(ErrorReporter); const executionRepository = Container.get(ExecutionRepository); + const binaryDataService = Container.get(BinaryDataService); const workflowStaticDataService = Container.get(WorkflowStaticDataService); const workflowStatisticsService = Container.get(WorkflowStatisticsService); hooks.addHandler('workflowExecuteAfter', async function (fullRunData, newStaticData) { @@ -325,6 +358,13 @@ function hookFunctionsSave( await restoreBinaryDataId(fullRunData, this.executionId, this.mode); + // If this is a subworkflow execution, duplicate binary data to the parent's + // execution. This must happen before any potential deletion of this execution's + // data, and updates the binary data IDs in fullRunData to point to the parent location. + if (parentExecution) { + await duplicateBinaryDataToParent(fullRunData, parentExecution, binaryDataService); + } + const isManualMode = this.mode === 'manual'; try { @@ -481,13 +521,14 @@ export function getLifecycleHooksForSubExecutions( executionId: string, workflowData: IWorkflowBase, userId?: string, + parentExecution?: RelatedExecution, ): ExecutionLifecycleHooks { const hooks = new ExecutionLifecycleHooks(mode, executionId, workflowData); const saveSettings = toSaveSettings(workflowData.settings); hookFunctionsWorkflowEvents(hooks, userId); hookFunctionsNodeEvents(hooks); hookFunctionsFinalizeExecutionStatus(hooks); - hookFunctionsSave(hooks, { saveSettings }); + hookFunctionsSave(hooks, { saveSettings, parentExecution }); hookFunctionsSaveProgress(hooks, { saveSettings }); hookFunctionsStatistics(hooks); hookFunctionsExternalHooks(hooks); diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index db7280f0b6c..caa9baf612d 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -30,12 +30,16 @@ export type FeatureReturnType = Partial< } & { [K in NumericLicenseFeature]: number } & { [K in BooleanLicenseFeature]: boolean } >; +type LicenseRefreshCallback = (cert: string) => void; + @Service() export class License implements LicenseProvider { private manager: LicenseManager | undefined; private isShuttingDown = false; + private refreshCallbacks: LicenseRefreshCallback[] = []; + constructor( private readonly logger: Logger, private readonly instanceSettings: InstanceSettings, @@ -141,10 +145,12 @@ export class License implements LicenseProvider { private async onFeatureChange() { void this.broadcastReloadLicenseCommand(); + await this.notifyRefreshCallbacks(); } private async onLicenseRenewed() { void this.broadcastReloadLicenseCommand(); + await this.notifyRefreshCallbacks(); } private async broadcastReloadLicenseCommand() { @@ -167,6 +173,31 @@ export class License implements LicenseProvider { ); } + /** + * Register a callback to be notified when license certificate is refreshed. + * Returns an unsubscribe function. + */ + onCertRefresh(refreshCallback: LicenseRefreshCallback): () => void { + this.refreshCallbacks.push(refreshCallback); + return () => { + const index = this.refreshCallbacks.indexOf(refreshCallback); + if (index > -1) { + this.refreshCallbacks.splice(index, 1); + } + }; + } + + private async notifyRefreshCallbacks(): Promise { + const cert = await this.loadCertStr(); + for (const refreshCallback of this.refreshCallbacks) { + try { + refreshCallback(cert); + } catch (error) { + this.logger.error('Error in license refresh callback', { error }); + } + } + } + async activate(activationKey: string, eulaUri?: string): Promise { if (!this.manager) { return; @@ -182,6 +213,7 @@ export class License implements LicenseProvider { return; } await this.manager.reload(); + await this.notifyRefreshCallbacks(); this.logger.debug('License reloaded'); } @@ -221,6 +253,11 @@ export class License implements LicenseProvider { return this.manager?.hasFeatureEnabled(feature) ?? false; } + /** @deprecated Use `LicenseState.isDynamicCredentialsLicensed` instead. */ + isDynamicCredentialsEnabled() { + return this.isLicensed(LICENSE_FEATURES.DYNAMIC_CREDENTIALS); + } + /** @deprecated Use `LicenseState.isSharingLicensed` instead. */ isSharingEnabled() { return this.isLicensed(LICENSE_FEATURES.SHARING); diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 0829e8ad845..6b59e5633e1 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -281,9 +281,13 @@ export class LoadNodesAndCredentials { }); } + private shouldInjectContextEstablishmentHooks() { + return process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true'; + } + private injectContextEstablishmentHooks() { // Check if the feature is enabled via environment variable - const isEnabled = process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true'; + const isEnabled = this.shouldInjectContextEstablishmentHooks(); if (!isEnabled) { this.logger.debug('Context establishment hooks feature is disabled'); @@ -298,110 +302,115 @@ export class LoadNodesAndCredentials { `Injecting context establishment hooks for ${triggerNodes.length} trigger nodes`, ); - triggerNodes.forEach((node: INodeTypeDescription) => { - const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name); + triggerNodes.forEach(this.augmentNodeTypeDescription); + } - if (hooks.length > 0) { - this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`); - } + private augmentNodeTypeDescription = (node: INodeTypeDescription) => { + const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name); - // Only inject hook properties if there are applicable hooks - if (hooks.length === 0) return; + if (hooks.length > 0) { + this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`); + } - // Create a fixedCollection with multipleValues for multiple hook selection - // Each hook becomes a separate item that can be added multiple times - const allHookValues: INodeProperties[] = [ - { - displayName: 'Hook', - name: 'hookName', - type: 'options', - options: hooks.map((hook) => { - const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name; - return { - name: displayName, - value: hook.hookDescription.name, - description: `Use ${displayName} hook`, - }; - }), - // No default - force user to explicitly select a hook - // This ensures hookName is always serialized in the workflow JSON - default: '', - description: 'Select which context establishment hook to use', - required: true, - }, - { - displayName: 'Allow Failure', - name: 'isAllowedToFail', - type: 'boolean', - default: false, - description: 'Whether to continue workflow execution if this hook fails', - }, - ]; + // Only inject hook properties if there are applicable hooks + if (hooks.length === 0) return; - // Add all hook-specific options with display conditions - for (const hook of hooks) { - const hookOptions = hook.hookDescription.options ?? []; - if (hookOptions.length > 0) { - for (const hookOption of hookOptions) { - // Add display condition to show only when this specific hook is selected - const enhancedOption: INodeProperties = { - ...hookOption, - displayOptions: { - ...hookOption.displayOptions, - show: { - ...hookOption.displayOptions?.show, - hookName: [hook.hookDescription.name], - }, + // This prevents double-injection if the function is called multiple times on the same node + if (node.properties.some((p) => p.name === 'executionsHooksVersion')) return; + + // Create a fixedCollection with multipleValues for multiple hook selection + // Each hook becomes a separate item that can be added multiple times + const allHookValues: INodeProperties[] = [ + { + displayName: 'Hook', + name: 'hookName', + type: 'options', + options: hooks.map((hook) => { + const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name; + return { + name: displayName, + value: hook.hookDescription.name, + description: `Use ${displayName} hook`, + }; + }), + // No default - force user to explicitly select a hook + // This ensures hookName is always serialized in the workflow JSON + default: '', + description: 'Select which context establishment hook to use', + required: true, + }, + { + displayName: 'Allow Failure', + name: 'isAllowedToFail', + type: 'boolean', + default: false, + description: 'Whether to continue workflow execution if this hook fails', + }, + ]; + + // Add all hook-specific options with display conditions + for (const hook of hooks) { + const hookOptions = hook.hookDescription.options ?? []; + if (hookOptions.length > 0) { + for (const hookOption of hookOptions) { + // Add display condition to show only when this specific hook is selected + const enhancedOption: INodeProperties = { + ...hookOption, + displayOptions: { + ...hookOption.displayOptions, + show: { + ...hookOption.displayOptions?.show, + hookName: [hook.hookDescription.name], }, - }; - allHookValues.push(enhancedOption); - } + }, + }; + allHookValues.push(enhancedOption); } } + } - // Create a hidden version property to track the hooks format version - const executionsHooksVersion: INodeProperties = { - displayName: 'Executions Hooks Version', - name: 'executionsHooksVersion', - type: 'hidden', - default: 1, - }; + // Create a hidden version property to track the hooks format version + const executionsHooksVersion: INodeProperties = { + displayName: 'Executions Hooks Version', + name: 'executionsHooksVersion', + type: 'hidden', + default: 1, + }; - // Create the main context establishment hooks property as a fixedCollection - const contextHooksProperty: INodeProperties = { - displayName: 'Context Establishment Hooks', - name: 'contextEstablishmentHooks', - type: 'fixedCollection', - placeholder: 'Add Hook', - default: {}, - typeOptions: { - multipleValues: true, + // Create the main context establishment hooks property as a fixedCollection + const contextHooksProperty: INodeProperties = { + displayName: 'Context Establishment Hooks', + name: 'contextEstablishmentHooks', + type: 'fixedCollection', + placeholder: 'Add Hook', + default: {}, + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'hooks', + displayName: 'Hooks', + values: allHookValues, }, - options: [ - { - name: 'hooks', - displayName: 'Hooks', - values: allHookValues, - }, - ], - description: - 'Add and configure context establishment hooks to extract data from trigger items. Learn more', - }; + ], + description: + 'Add and configure context establishment hooks to extract data from trigger items. Learn more', + }; - // Create a notice that always appears after the hooks collection - const contextHooksNotice: INodeProperties = { - displayName: - 'Context establishment hooks allow you to extract data from trigger items to use in subsequent nodes. Learn more', - name: 'contextHooksNotice', - type: 'notice', - default: '', - }; + // Create a notice that always appears after the hooks collection + const contextHooksNotice: INodeProperties = { + displayName: + 'Context establishment hooks allow you to extract data from trigger items to use in subsequent nodes. Learn more', + name: 'contextHooksNotice', + type: 'notice', + default: '', + }; - node.properties.push(executionsHooksVersion); - node.properties.push(contextHooksProperty); - node.properties.push(contextHooksNotice); - }); - } + node.properties.push(executionsHooksVersion); + node.properties.push(contextHooksProperty); + node.properties.push(contextHooksNotice); + }; /** * Run a loader of source files of nodes and credentials in a directory. @@ -560,7 +569,14 @@ export class LoadNodesAndCredentials { if (!loader) { throw new UnrecognizedNodeTypeError(packageName, nodeType); } - return loader.getNode(nodeType); + const loadedNode = loader.getNode(nodeType); + if ( + this.shouldInjectContextEstablishmentHooks() && + 'properties' in loadedNode.type.description + ) { + this.augmentNodeTypeDescription(loadedNode.type.description); + } + return loadedNode; } getCredential(credentialType: string): LoadedClass { diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts index 656eb575937..dcc73168529 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/pyodide-removed.rule.test.ts @@ -169,5 +169,72 @@ describe('PyodideRemovedRule', () => { expect(result.issues).toHaveLength(1); expect(result.issues[0].nodeName).toBe('Python'); }); + + it('should return no issues when Code Tool nodes use native Python (pythonNative)', async () => { + const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [ + createNode('Code Tool', '@n8n/n8n-nodes-langchain.toolCode', { + language: 'pythonNative', + pythonCode: 'print("hello")', + }), + ]); + const result = await rule.detectWorkflow(workflow, nodesGroupedByType); + + expect(result).toEqual({ + isAffected: false, + issues: [], + }); + }); + + it('should detect Code Tool node with Pyodide Python (language="python")', async () => { + const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [ + createNode('Code Tool', '@n8n/n8n-nodes-langchain.toolCode', { + language: 'python', + pythonCode: 'print("hello")', + }), + ]); + + const result = await rule.detectWorkflow(workflow, nodesGroupedByType); + + expect(result.isAffected).toBe(true); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]).toMatchObject({ + title: "Code node 'Code Tool' uses removed Pyodide Python implementation", + description: + 'The Pyodide-based Python implementation (language="python") is no longer supported. This node must be migrated to use the task runner-based implementation (language="pythonNative").', + level: 'error', + nodeId: expect.any(String), + nodeName: 'Code Tool', + }); + }); + + it('should detect both Code and Code Tool nodes with Pyodide Python', async () => { + const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [ + createNode('Python Code', 'n8n-nodes-base.code', { + language: 'python', + pythonCode: 'print("hello")', + }), + createNode('Python Tool', '@n8n/n8n-nodes-langchain.toolCode', { + language: 'python', + pythonCode: 'print("world")', + }), + ]); + + const result = await rule.detectWorkflow(workflow, nodesGroupedByType); + + expect(result.isAffected).toBe(true); + expect(result.issues).toHaveLength(2); + expect(result.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + nodeName: 'Python Code', + title: "Code node 'Python Code' uses removed Pyodide Python implementation", + }), + expect.objectContaining({ + nodeName: 'Python Tool', + title: "Code node 'Python Tool' uses removed Pyodide Python implementation", + }), + ]), + ); + }); }); }); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/settings-file-permissions.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/settings-file-permissions.rule.test.ts index 446cd40c609..2fd7679abc5 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/settings-file-permissions.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/settings-file-permissions.rule.test.ts @@ -1,16 +1,20 @@ -import type { InstanceSettingsConfig } from '@n8n/config'; +import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import { SettingsFilePermissionsRule } from '../settings-file-permissions.rule'; describe('SettingsFilePermissionsRule', () => { let rule: SettingsFilePermissionsRule; - const instanceSettingsConfig = mock({}); + const mockGlobalConfig = mock({ + deployment: { type: 'default' }, + }); let originalEnvValue: string | undefined; beforeEach(() => { - rule = new SettingsFilePermissionsRule(instanceSettingsConfig); + rule = new SettingsFilePermissionsRule(mockGlobalConfig); originalEnvValue = process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS; + // Clear env var before each test + delete process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS; }); afterEach(() => { @@ -22,8 +26,21 @@ describe('SettingsFilePermissionsRule', () => { }); describe('detect()', () => { - it('should not be affected when enforceSettingsFilePermissions is set to false', async () => { - instanceSettingsConfig.enforceSettingsFilePermissions = false; + it('should not be affected on cloud deployments', async () => { + const cloudGlobalConfig = mock({ + deployment: { type: 'cloud' }, + }); + const cloudRule = new SettingsFilePermissionsRule(cloudGlobalConfig); + + const result = await cloudRule.detect(); + + expect(result.isAffected).toBe(false); + expect(result.instanceIssues).toHaveLength(0); + expect(result.recommendations).toHaveLength(0); + }); + + it('should not be affected when N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is explicitly set to false', async () => { + process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false'; const result = await rule.detect(); @@ -32,8 +49,18 @@ describe('SettingsFilePermissionsRule', () => { expect(result.recommendations).toHaveLength(0); }); - it('should be affected when enforceSettingsFilePermissions is not set to false', async () => { - instanceSettingsConfig.enforceSettingsFilePermissions = true; + it('should not be affected when N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is explicitly set to true', async () => { + process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true'; + + const result = await rule.detect(); + + expect(result.isAffected).toBe(false); + expect(result.instanceIssues).toHaveLength(0); + expect(result.recommendations).toHaveLength(0); + }); + + it('should be affected when N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is not set (default behavior change)', async () => { + // Env var is not set (cleared in beforeEach) const result = await rule.detect(); expect(result.isAffected).toBe(true); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts index 240553931ae..c96a75e0fa6 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runner-docker-image.rule.test.ts @@ -1,10 +1,16 @@ +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'jest-mock-extended'; + import { TaskRunnerDockerImageRule } from '../task-runner-docker-image.rule'; describe('TaskRunnerDockerImageRule', () => { let rule: TaskRunnerDockerImageRule; beforeEach(() => { - rule = new TaskRunnerDockerImageRule(); + const mockGlobalConfig = mock({ + deployment: { type: 'default' }, + }); + rule = new TaskRunnerDockerImageRule(mockGlobalConfig); }); describe('getMetadata()', () => { @@ -18,6 +24,19 @@ describe('TaskRunnerDockerImageRule', () => { }); describe('detect()', () => { + it('should not be affected on cloud deployments', async () => { + const mockGlobalConfig = mock({ + deployment: { type: 'cloud' }, + }); + const cloudRule = new TaskRunnerDockerImageRule(mockGlobalConfig); + + const result = await cloudRule.detect(); + + expect(result.isAffected).toBe(false); + expect(result.instanceIssues).toHaveLength(0); + expect(result.recommendations).toHaveLength(0); + }); + it('should always be affected (informational)', async () => { const result = await rule.detect(); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runners.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runners.rule.test.ts index 5878b436ec0..c807cf6bea8 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runners.rule.test.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/__tests__/task-runners.rule.test.ts @@ -1,12 +1,35 @@ -import type { TaskRunnersConfig } from '@n8n/config'; +import type { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; +import { mock } from 'jest-mock-extended'; import { TaskRunnersRule } from '../task-runners.rule'; describe('TaskRunnersRule', () => { + let mockGlobalConfig: GlobalConfig; + + beforeEach(() => { + mockGlobalConfig = mock({ + deployment: { type: 'default' }, + }); + }); + describe('detect()', () => { + it('should not be affected on cloud deployments', async () => { + const mockConfig = { enabled: false } as TaskRunnersConfig; + const cloudGlobalConfig = mock({ + deployment: { type: 'cloud' }, + }); + const rule = new TaskRunnersRule(mockConfig, cloudGlobalConfig); + + const result = await rule.detect(); + + expect(result.isAffected).toBe(false); + expect(result.instanceIssues).toHaveLength(0); + expect(result.recommendations).toHaveLength(0); + }); + it('should not be affected when runners are already enabled', async () => { const mockConfig = { enabled: true } as TaskRunnersConfig; - const rule = new TaskRunnersRule(mockConfig); + const rule = new TaskRunnersRule(mockConfig, mockGlobalConfig); const result = await rule.detect(); @@ -16,18 +39,19 @@ describe('TaskRunnersRule', () => { it('should be affected when runners are not enabled', async () => { const mockConfig = { enabled: false } as TaskRunnersConfig; - const rule = new TaskRunnersRule(mockConfig); + const rule = new TaskRunnersRule(mockConfig, mockGlobalConfig); const result = await rule.detect(); expect(result.isAffected).toBe(true); expect(result.instanceIssues).toHaveLength(1); expect(result.instanceIssues[0].title).toBe('Task Runners will be enabled by default'); + expect(result.recommendations).toHaveLength(3); }); it('should be affected when runners are explicitly disabled', async () => { const mockConfig = { enabled: false } as TaskRunnersConfig; - const rule = new TaskRunnersRule(mockConfig); + const rule = new TaskRunnersRule(mockConfig, mockGlobalConfig); const result = await rule.detect(); diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts index e4eaa57daa0..5ddd288d514 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/pyodide-removed.rule.ts @@ -55,13 +55,14 @@ export class PyodideRemovedRule implements IBreakingChangeWorkflowRule { ): Promise { // Get all Code nodes (the Code node supports both JavaScript and Python) const codeNodes = nodesGroupedByType.get('n8n-nodes-base.code') ?? []; + const codeToolNodes = nodesGroupedByType.get('@n8n/n8n-nodes-langchain.toolCode') ?? []; // Filter for Code nodes using the Pyodide-based Python implementation // The 'language' parameter determines which language/implementation is used: // - 'python' = Pyodide (being removed) // - 'pythonNative' = Task runner (new implementation) // - 'javaScript' = JavaScript (not affected) - const affectedNodes = codeNodes.filter((node) => { + const affectedNodes = codeNodes.concat(codeToolNodes).filter((node) => { const language = node.parameters?.language; // Nodes with language='python' use Pyodide and are affected return language === 'python'; diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts index 0a594f37465..eb1390d6575 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/settings-file-permissions.rule.ts @@ -1,4 +1,4 @@ -import { InstanceSettingsConfig } from '@n8n/config'; +import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import type { @@ -10,7 +10,7 @@ import { BreakingChangeCategory } from '../../types'; @Service() export class SettingsFilePermissionsRule implements IBreakingChangeInstanceRule { - constructor(private readonly instanceSettingsConfig: InstanceSettingsConfig) {} + constructor(private readonly globalConfig: GlobalConfig) {} id: string = 'settings-file-permissions-v2'; @@ -28,9 +28,18 @@ export class SettingsFilePermissionsRule implements IBreakingChangeInstanceRule } async detect(): Promise { - // If enforceSettingsFilePermissions is explicitly set to 'false', users are not affected - // because they've configured the system to not enforce file permissions - if (!this.instanceSettingsConfig.enforceSettingsFilePermissions) { + // Not relevant for cloud deployments - cloud manages infrastructure and file permissions + if (this.globalConfig.deployment.type === 'cloud') { + return { + isAffected: false, + instanceIssues: [], + recommendations: [], + }; + } + + // If N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is explicitly set to any value, users are not affected + // because they've already handled the configuration and are aware of this setting. + if (process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS) { return { isAffected: false, instanceIssues: [], diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts index 4bd1c2e5b3f..8e939a69ad3 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/task-runner-docker-image.rule.ts @@ -1,3 +1,4 @@ +import { GlobalConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import type { @@ -9,6 +10,8 @@ import { BreakingChangeCategory } from '../../types'; @Service() export class TaskRunnerDockerImageRule implements IBreakingChangeInstanceRule { + constructor(private readonly globalConfig: GlobalConfig) {} + id: string = 'task-runner-docker-image-v2'; getMetadata(): BreakingChangeRuleMetadata { @@ -25,6 +28,15 @@ export class TaskRunnerDockerImageRule implements IBreakingChangeInstanceRule { } async detect(): Promise { + // Not relevant for cloud deployments - cloud manages Docker images + if (this.globalConfig.deployment.type === 'cloud') { + return { + isAffected: false, + instanceIssues: [], + recommendations: [], + }; + } + const result: InstanceDetectionReport = { isAffected: true, instanceIssues: [ diff --git a/packages/cli/src/modules/breaking-changes/rules/v2/task-runners.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v2/task-runners.rule.ts index ab05e0dde8e..2388718a36e 100644 --- a/packages/cli/src/modules/breaking-changes/rules/v2/task-runners.rule.ts +++ b/packages/cli/src/modules/breaking-changes/rules/v2/task-runners.rule.ts @@ -1,4 +1,4 @@ -import { TaskRunnersConfig } from '@n8n/config'; +import { GlobalConfig, TaskRunnersConfig } from '@n8n/config'; import { Service } from '@n8n/di'; import type { @@ -10,7 +10,10 @@ import { BreakingChangeCategory } from '../../types'; @Service() export class TaskRunnersRule implements IBreakingChangeInstanceRule { - constructor(private readonly taskRunnersConfig: TaskRunnersConfig) {} + constructor( + private readonly taskRunnersConfig: TaskRunnersConfig, + private readonly globalConfig: GlobalConfig, + ) {} id: string = 'task-runners-v2'; @@ -27,6 +30,15 @@ export class TaskRunnersRule implements IBreakingChangeInstanceRule { } async detect(): Promise { + // Not relevant for cloud deployments - cloud manages task runner infrastructure + if (this.globalConfig.deployment.type === 'cloud') { + return { + isAffected: false, + instanceIssues: [], + recommendations: [], + }; + } + const result: InstanceDetectionReport = { isAffected: false, instanceIssues: [], diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts new file mode 100644 index 00000000000..f7f23da3823 --- /dev/null +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts @@ -0,0 +1,576 @@ +import type { WorkflowRepository, SharedWorkflowRepository } from '@n8n/db'; +import type { Logger } from '@n8n/backend-common'; +import { mock } from 'jest-mock-extended'; +import type { BinaryDataService } from 'n8n-core'; +import type { IBinaryData } from 'n8n-workflow'; + +import { ChatHubWorkflowService } from '../chat-hub-workflow.service'; +import { ChatHubMessage } from '../chat-hub-message.entity'; +import { ChatHubSession } from '../chat-hub-session.entity'; +import { ChatHubAttachmentService } from '../chat-hub.attachment.service'; +import type { ChatHubMessageRepository } from '../chat-message.repository'; + +describe('ChatHubWorkflowService', () => { + const logger = mock(); + const workflowRepository = mock(); + const sharedWorkflowRepository = mock(); + const binaryDataService = mock(); + const messageRepository = mock(); + + let chatHubAttachmentService: ChatHubAttachmentService; + let service: ChatHubWorkflowService; + + beforeEach(() => { + jest.resetAllMocks(); + + // Create real ChatHubAttachmentService with mocked dependencies + chatHubAttachmentService = new ChatHubAttachmentService(binaryDataService, messageRepository); + + service = new ChatHubWorkflowService( + logger, + workflowRepository, + sharedWorkflowRepository, + chatHubAttachmentService, + ); + + // Mock repository methods + const mockEntityManager = { + save: jest.fn().mockImplementation(async (entity) => { + // Return the entity with an ID added + return { ...entity, id: 'workflow-123' }; + }), + } as any; + + Object.defineProperty(workflowRepository, 'manager', { + value: { + transaction: jest.fn((cb) => cb(mockEntityManager)), + }, + writable: true, + }); + + (sharedWorkflowRepository.create as jest.Mock) = jest.fn().mockReturnValue({}); + }); + + describe('createChatWorkflow', () => { + describe('message history handling', () => { + it('should handle empty history', async () => { + const mockHistory: ChatHubMessage[] = []; + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + + const messageValues = (restoreMemoryNode?.parameters?.messages as any)?.messageValues; + expect(messageValues).toHaveLength(0); + }); + + it('should handle multiple messages', async () => { + const message1 = new ChatHubMessage(); + message1.id = 'msg-1'; + message1.content = 'First message'; + message1.type = 'human'; + message1.attachments = []; + message1.sessionId = 'session-456'; + message1.session = new ChatHubSession(); + message1.status = 'running'; + + const message2 = new ChatHubMessage(); + message2.id = 'msg-2'; + message2.content = 'Second message'; + message2.type = 'ai'; + message2.attachments = []; + message2.sessionId = 'session-456'; + message2.session = new ChatHubSession(); + message2.status = 'running'; + + const message3 = new ChatHubMessage(); + message3.id = 'msg-3'; + message3.content = 'Third message'; + message3.type = 'human'; + message3.attachments = []; + message3.sessionId = 'session-456'; + message3.session = new ChatHubSession(); + message3.status = 'running'; + + const mockHistory: ChatHubMessage[] = [message1, message2, message3]; + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + + const messageValues = (restoreMemoryNode?.parameters?.messages as any)?.messageValues; + expect(messageValues).toHaveLength(3); + expect(messageValues[0]).toEqual({ + type: 'user', + message: 'First message', + hideFromUI: false, + }); + expect(messageValues[1]).toEqual({ + type: 'ai', + message: 'Second message', + hideFromUI: false, + }); + expect(messageValues[2]).toEqual({ + type: 'user', + message: 'Third message', + hideFromUI: false, + }); + }); + }); + + describe('attachment files', () => { + it('should convert binary data to data URL when attachment has id', async () => { + const mockAttachment: IBinaryData = { + id: 'filesystem-v2:chat-hub/sessions/session-456/messages/msg-1/binary_data/bin-1', + data: 'filesystem-v2', + mimeType: 'image/png', + fileName: 'test.png', + fileSize: '100 kB', + fileExtension: 'png', + }; + + const mockMessage = new ChatHubMessage(); + mockMessage.id = 'msg-1'; + mockMessage.content = 'Check this image'; + mockMessage.type = 'human'; + mockMessage.attachments = [mockAttachment]; + mockMessage.sessionId = 'session-456'; + mockMessage.session = new ChatHubSession(); + mockMessage.status = 'running'; + + const mockHistory: ChatHubMessage[] = [mockMessage]; + + const mockImageBuffer = Buffer.from('fake-image-data', 'base64'); + binaryDataService.getAsBuffer.mockResolvedValue(mockImageBuffer); + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + expect(binaryDataService.getAsBuffer).toHaveBeenCalledWith(mockAttachment); + + const expectedDataUrl = `data:${mockAttachment.mimeType};base64,${mockImageBuffer.toString('base64')}`; + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + expect((restoreMemoryNode?.parameters?.messages as any)?.messageValues[0].message).toEqual([ + { type: 'text', text: 'Check this image' }, + { type: 'image_url', image_url: expectedDataUrl }, + ]); + }); + + it('should use data URL directly when attachment has data but no id', async () => { + const mockAttachment: IBinaryData = { + data: 'data:image/png;base64,iVBORw0KGgoAAAANS', + mimeType: 'image/png', + fileName: 'test.png', + }; + + const mockMessage = new ChatHubMessage(); + mockMessage.id = 'msg-1'; + mockMessage.content = 'Check this image'; + mockMessage.type = 'human'; + mockMessage.attachments = [mockAttachment]; + mockMessage.sessionId = 'session-456'; + mockMessage.session = new ChatHubSession(); + mockMessage.status = 'running'; + + const mockHistory: ChatHubMessage[] = [mockMessage]; + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + expect((restoreMemoryNode?.parameters?.messages as any)?.messageValues[0].message).toEqual([ + { type: 'text', text: 'Check this image' }, + { type: 'image_url', image_url: mockAttachment.data }, + ]); + }); + + it('should handle multiple attachments correctly', async () => { + const mockAttachmentWithId: IBinaryData = { + id: 'filesystem-v2:chat-hub/sessions/session-456/messages/msg-1/binary_data/bin-2', + data: 'filesystem-v2', + mimeType: 'image/png', + fileName: 'test1.png', + fileSize: '150 kB', + fileExtension: 'png', + }; + + const mockAttachmentWithData: IBinaryData = { + data: 'data:image/jpeg;base64,/9j/4AAQ', + mimeType: 'image/jpeg', + fileName: 'test2.jpg', + }; + + const mockMessage = new ChatHubMessage(); + mockMessage.id = 'msg-1'; + mockMessage.content = 'Check these images'; + mockMessage.type = 'human'; + mockMessage.attachments = [mockAttachmentWithId, mockAttachmentWithData]; + mockMessage.sessionId = 'session-456'; + mockMessage.session = new ChatHubSession(); + mockMessage.status = 'running'; + + const mockHistory: ChatHubMessage[] = [mockMessage]; + + const mockImageBuffer = Buffer.from('fake-image-data-1', 'base64'); + binaryDataService.getAsBuffer.mockResolvedValue(mockImageBuffer); + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + expect(binaryDataService.getAsBuffer).toHaveBeenCalledTimes(1); + expect(binaryDataService.getAsBuffer).toHaveBeenCalledWith(mockAttachmentWithId); + expect(binaryDataService.createSignedToken).not.toHaveBeenCalled(); + + const expectedDataUrl = `data:${mockAttachmentWithId.mimeType};base64,${mockImageBuffer.toString('base64')}`; + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + expect((restoreMemoryNode?.parameters?.messages as any)?.messageValues[0].message).toEqual([ + { type: 'text', text: 'Check these images' }, + { type: 'image_url', image_url: expectedDataUrl }, + { type: 'image_url', image_url: mockAttachmentWithData.data }, + ]); + }); + + it('should omit attachments that exceed maxTotalPayloadSize limit', async () => { + // Create a large data URL that, when added to another attachment, will exceed the 20MB limit (90% of 20MB = 18MB) + const largeDataUrl = 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024); + const smallDataUrl = 'data:image/png;base64,' + 'B'.repeat(9 * 1024 * 1024); + + const mockAttachment1: IBinaryData = { + data: largeDataUrl, + mimeType: 'image/png', + fileName: 'large.png', + }; + + const mockAttachment2: IBinaryData = { + data: smallDataUrl, + mimeType: 'image/png', + fileName: 'small.png', + }; + + const mockMessage = new ChatHubMessage(); + mockMessage.id = 'msg-1'; + mockMessage.content = 'Check these images'; + mockMessage.type = 'human'; + mockMessage.attachments = [mockAttachment1, mockAttachment2]; + mockMessage.sessionId = 'session-456'; + mockMessage.session = new ChatHubSession(); + mockMessage.status = 'running'; + + const mockHistory: ChatHubMessage[] = [mockMessage]; + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + + const messageContent = (restoreMemoryNode?.parameters?.messages as any)?.messageValues[0] + .message; + + // Should include text and only the first attachment since the second would exceed the limit + expect(messageContent).toEqual([ + { type: 'text', text: 'Check these images' }, + { type: 'image_url', image_url: largeDataUrl }, + { type: 'text', text: 'File: small.png\n(Content omitted due to size limit)' }, + ]); + }); + + it('should omit attachments across multiple messages when total size exceeds limit', async () => { + const attachment1DataUrl = 'data:image/png;base64,' + 'A'.repeat(5 * 1024 * 1024); + const mockAttachment1: IBinaryData = { + data: attachment1DataUrl, + mimeType: 'image/png', + fileName: 'first.png', + }; + + const message1 = new ChatHubMessage(); + message1.id = 'msg-1'; + message1.content = 'First message with attachment'; + message1.type = 'human'; + message1.attachments = [mockAttachment1]; + message1.sessionId = 'session-456'; + message1.session = new ChatHubSession(); + message1.status = 'running'; + + const attachment2DataUrl = 'data:image/jpeg;base64,' + 'B'.repeat(8 * 1024 * 1024); + const mockAttachment2: IBinaryData = { + data: attachment2DataUrl, + mimeType: 'image/jpeg', + fileName: 'second.jpg', + }; + + const message2 = new ChatHubMessage(); + message2.id = 'msg-2'; + message2.content = 'Second message'; + message2.type = 'ai'; + message2.attachments = [mockAttachment2]; + message2.sessionId = 'session-456'; + message2.session = new ChatHubSession(); + message2.status = 'running'; + + const attachment3DataUrl = 'data:image/png;base64,' + 'C'.repeat(6 * 1024 * 1024); + const mockAttachment3: IBinaryData = { + data: attachment3DataUrl, + mimeType: 'image/png', + fileName: 'third.png', + }; + + const message3 = new ChatHubMessage(); + message3.id = 'msg-3'; + message3.content = 'Third message'; + message3.type = 'human'; + message3.attachments = [mockAttachment3]; + message3.sessionId = 'session-456'; + message3.session = new ChatHubSession(); + message3.status = 'running'; + + const mockHistory: ChatHubMessage[] = [message1, message2, message3]; + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + + const messageValues = (restoreMemoryNode?.parameters?.messages as any)?.messageValues; + expect(messageValues).toHaveLength(3); + + // First message attachment is skipped due to cumulative size limit (processed last, size limit reached) + expect(messageValues[0].message).toEqual([ + { type: 'text', text: 'First message with attachment' }, + { type: 'text', text: 'File: first.png\n(Content omitted due to size limit)' }, + ]); + + // Second message includes attachment + expect(messageValues[1].message).toEqual([ + { type: 'text', text: 'Second message' }, + { type: 'image_url', image_url: attachment2DataUrl }, + ]); + + // Third message includes attachment (processed first) + expect(messageValues[2].message).toEqual([ + { type: 'text', text: 'Third message' }, + { type: 'image_url', image_url: attachment3DataUrl }, + ]); + }); + + it('should include omitted content message when attachment exceeds size limit', async () => { + const largeDataUrl = 'data:image/png;base64,' + 'A'.repeat(17 * 1024 * 1024); + + const mockAttachment: IBinaryData = { + data: largeDataUrl, + mimeType: 'image/png', + fileName: 'large-image.png', + }; + + const mockMessage = new ChatHubMessage(); + mockMessage.id = 'msg-1'; + mockMessage.content = 'Check this large image'; + mockMessage.type = 'human'; + mockMessage.attachments = [mockAttachment]; + mockMessage.sessionId = 'session-456'; + mockMessage.session = new ChatHubSession(); + mockMessage.status = 'running'; + + const anotherAttachment: IBinaryData = { + data: 'data:image/jpeg;base64,' + 'B'.repeat(2 * 1024 * 1024), + mimeType: 'image/jpeg', + fileName: 'small-image.jpg', + }; + + const mockMessage2 = new ChatHubMessage(); + mockMessage2.id = 'msg-2'; + mockMessage2.content = 'And this small one'; + mockMessage2.type = 'human'; + mockMessage2.attachments = [anotherAttachment]; + mockMessage2.sessionId = 'session-456'; + mockMessage2.session = new ChatHubSession(); + mockMessage2.status = 'running'; + + const mockHistory: ChatHubMessage[] = [mockMessage, mockMessage2]; + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + + const messageValues = (restoreMemoryNode?.parameters?.messages as any)?.messageValues; + + expect(messageValues[0].message).toEqual([ + { type: 'text', text: 'Check this large image' }, + { type: 'text', text: 'File: large-image.png\n(Content omitted due to size limit)' }, + ]); + + expect(messageValues[1].message).toEqual([ + { type: 'text', text: 'And this small one' }, + { type: 'image_url', image_url: anotherAttachment.data }, + ]); + }); + + it('should handle text file attachments as text blocks', async () => { + const textContent = 'This is the content of the text file.\nIt has multiple lines.'; + const mockAttachment: IBinaryData = { + id: 'filesystem-v2:chat-hub/sessions/session-456/messages/msg-1/binary_data/text-1', + data: 'filesystem-v2', + mimeType: 'text/plain', + fileName: 'document.txt', + fileSize: '60', + fileExtension: 'txt', + }; + + const mockMessage = new ChatHubMessage(); + mockMessage.id = 'msg-1'; + mockMessage.content = 'Here is a text file'; + mockMessage.type = 'human'; + mockMessage.attachments = [mockAttachment]; + mockMessage.sessionId = 'session-456'; + mockMessage.session = new ChatHubSession(); + mockMessage.status = 'running'; + + const mockHistory: ChatHubMessage[] = [mockMessage]; + + // Mock getAsBuffer to return the text content + binaryDataService.getAsBuffer.mockResolvedValue(Buffer.from(textContent, 'utf-8')); + + const result = await service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + mockHistory, + 'Hello', + [], + { openAiApi: { id: 'cred-123', name: 'OpenAI' } }, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + ); + + expect(binaryDataService.getAsBuffer).toHaveBeenCalledWith(mockAttachment); + + const restoreMemoryNode = result.workflowData.nodes.find( + (node) => node.name === 'Restore Chat Memory', + ); + expect(restoreMemoryNode?.parameters?.messages).toBeDefined(); + + const messageValues = (restoreMemoryNode?.parameters?.messages as any)?.messageValues; + expect(messageValues[0].message).toEqual([ + { type: 'text', text: 'Here is a text file' }, + { type: 'text', text: `File: document.txt\nContent: \n${textContent}` }, + ]); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts index 8bbfa764cee..b858118d24f 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts @@ -1,4 +1,4 @@ -import { ChatHubProvider } from '@n8n/api-types'; +import { ChatHubLLMProvider } from '@n8n/api-types'; import { WithTimestamps, User, CredentialsEntity, JsonColumn } from '@n8n/db'; import { Column, Entity, ManyToOne, JoinColumn, PrimaryGeneratedColumn } from '@n8n/typeorm'; import { INode } from 'n8n-workflow'; @@ -40,23 +40,23 @@ export class ChatHubAgent extends WithTimestamps { owner?: User; /* - * ID of the selected credential to use by default with the selected LLM provider (if applicable). + * ID of the selected credential to use by default with the selected LLM provider. */ @Column({ type: 'varchar', length: 36, nullable: true }) credentialId: string | null; /** - * The selected credential to use by default with the selected LLM provider (if applicable). + * The selected credential to use by default with the selected LLM provider. */ @ManyToOne('CredentialsEntity', { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'credentialId' }) credential?: CredentialsEntity | null; /* - * Enum value of the LLM provider to use, e.g. 'openai', 'anthropic', 'google', 'n8n' (if applicable). + * Enum value of the LLM provider to use, e.g. 'openai', 'anthropic', 'google'. */ @Column({ type: 'varchar', length: 16, nullable: true }) - provider: ChatHubProvider; + provider: ChatHubLLMProvider; /* * LLM model to use from the provider (if applicable) diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts index 8c197ae2e53..36a23bb4fcb 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts @@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { ChatHubAgent } from './chat-hub-agent.entity'; import { ChatHubAgentRepository } from './chat-hub-agent.repository'; import { ChatHubCredentialsService } from './chat-hub-credentials.service'; +import { getModelMetadata } from './chat-hub.constants'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -32,7 +33,7 @@ export class ChatHubAgentService { }, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), - allowFileUploads: true, + metadata: getModelMetadata(agent.provider, agent.model), })), }; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts index 72511e7798e..891e6a54ad5 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts @@ -85,10 +85,12 @@ export class ChatHubCredentialsService { throw new ForbiddenError('Missing owner project for the workflow'); } - const allCredentials = + const workflowCredentials = await this.credentialsService.findAllCredentialIdsForWorkflow(workflowId); + const globalCredentials = await this.credentialsService.findAllGlobalCredentialIds(); + workflowCredentials.push.apply(workflowCredentials, globalCredentials); - const credential = allCredentials.find((c) => c.id === credentialId); + const credential = workflowCredentials.find((c) => c.id === credentialId); if (!credential) { throw new ForbiddenError("You don't have access to the provided credentials"); } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index 65d05a25102..baf699ed316 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -1,4 +1,4 @@ -import { ChatHubConversationModel, ChatSessionId } from '@n8n/api-types'; +import { ChatHubConversationModel, ChatSessionId, type ChatHubInputModality } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { SharedWorkflow, @@ -9,6 +9,7 @@ import { } from '@n8n/db'; import { Service } from '@n8n/di'; import { EntityManager } from '@n8n/typeorm'; +import { DateTime } from 'luxon'; import { AGENT_LANGCHAIN_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE, @@ -25,17 +26,15 @@ import { NodeConnectionTypes, OperationalError, type IBinaryData, + type NodeParameterValueType, } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; import { ChatHubMessage } from './chat-hub-message.entity'; -import { - CONVERSATION_TITLE_GENERATION_PROMPT, - NODE_NAMES, - PROVIDER_NODE_TYPE_MAP, -} from './chat-hub.constants'; -import { MessageRecord } from './chat-hub.types'; +import { NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; +import { MessageRecord, type ContentBlock, type ChatTriggerResponseMode } from './chat-hub.types'; import { getMaxContextWindowTokens } from './context-limits'; +import { ChatHubAttachmentService } from './chat-hub.attachment.service'; @Service() export class ChatHubWorkflowService { @@ -43,6 +42,7 @@ export class ChatHubWorkflowService { private readonly logger: Logger, private readonly workflowRepository: WorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly chatHubAttachmentService: ChatHubAttachmentService, ) {} async createChatWorkflow( @@ -56,14 +56,19 @@ export class ChatHubWorkflowService { model: ChatHubConversationModel, systemMessage: string | undefined, tools: INode[], + timeZone: string, trx?: EntityManager, - ): Promise<{ workflowData: IWorkflowBase; executionData: IRunExecutionData }> { + ): Promise<{ + workflowData: IWorkflowBase; + executionData: IRunExecutionData; + responseMode: ChatTriggerResponseMode; + }> { return await withTransaction(this.workflowRepository.manager, trx, async (em) => { this.logger.debug( `Creating chat workflow for user ${userId} and session ${sessionId}, provider ${model.provider}`, ); - const { nodes, connections, executionData } = this.buildChatWorkflow({ + const { nodes, connections, executionData } = await this.buildChatWorkflow({ userId, sessionId, history, @@ -71,7 +76,7 @@ export class ChatHubWorkflowService { attachments, credentials, model, - systemMessage, + systemMessage: systemMessage ?? this.getBaseSystemMessage(timeZone), tools, }); @@ -104,6 +109,7 @@ export class ChatHubWorkflowService { return { workflowData: workflow, executionData, + responseMode: 'streaming', }; }); } @@ -113,6 +119,7 @@ export class ChatHubWorkflowService { sessionId: ChatSessionId, projectId: string, humanMessage: string, + attachments: IBinaryData[], credentials: INodeCredentials, model: ChatHubConversationModel, trx?: EntityManager, @@ -128,6 +135,7 @@ export class ChatHubWorkflowService { credentials, model, humanMessage, + attachments, ); const newWorkflow = new WorkflowEntity(); @@ -196,6 +204,44 @@ export class ChatHubWorkflowService { ]; } + /** + * Parses input modalities from chat trigger options + * Converts MIME types string to ChatHubInputModality array + */ + parseInputModalities(options?: { + allowFileUploads?: boolean; + allowedFilesMimeTypes?: string; + }): ChatHubInputModality[] { + const allowFileUploads = options?.allowFileUploads ?? false; + const allowedFilesMimeTypes = options?.allowedFilesMimeTypes; + + if (!allowFileUploads) { + return ['text']; + } + + if (!allowedFilesMimeTypes || allowedFilesMimeTypes === '*/*') { + return ['text', 'image', 'audio', 'video', 'file']; + } + + const mimeTypes = allowedFilesMimeTypes.split(',').map((type) => type.trim()); + const modalities = new Set(['text']); + + for (const mimeType of mimeTypes) { + if (mimeType.startsWith('image/')) { + modalities.add('image'); + } else if (mimeType.startsWith('audio/')) { + modalities.add('audio'); + } else if (mimeType.startsWith('video/')) { + modalities.add('video'); + } else { + // Any other MIME type falls under generic 'file' + modalities.add('file'); + } + } + + return Array.from(modalities); + } + private getUniqueNodeName(originalName: string, existingNames: Set): string { if (!existingNames.has(originalName)) { return originalName; @@ -212,7 +258,7 @@ export class ChatHubWorkflowService { return uniqueName; } - private buildChatWorkflow({ + private async buildChatWorkflow({ userId, sessionId, history, @@ -230,14 +276,14 @@ export class ChatHubWorkflowService { attachments: IBinaryData[]; credentials: INodeCredentials; model: ChatHubConversationModel; - systemMessage?: string; + systemMessage: string; tools: INode[]; }) { const chatTriggerNode = this.buildChatTriggerNode(); const toolsAgentNode = this.buildToolsAgentNode(model, systemMessage); const modelNode = this.buildModelNode(credentials, model); const memoryNode = this.buildMemoryNode(20); - const restoreMemoryNode = this.buildRestoreMemoryNode(history); + const restoreMemoryNode = await this.buildRestoreMemoryNode(history); const clearMemoryNode = this.buildClearMemoryNode(); const mergeNode = this.buildMergeNode(); @@ -357,9 +403,10 @@ export class ChatHubWorkflowService { credentials: INodeCredentials, model: ChatHubConversationModel, humanMessage: string, + attachments: IBinaryData[], ) { const chatTriggerNode = this.buildChatTriggerNode(); - const titleGeneratorAgentNode = this.buildTitleGeneratorAgentNode(); + const titleGeneratorAgentNode = this.buildTitleGeneratorAgentNode(humanMessage, attachments); const modelNode = this.buildModelNode(credentials, model); const nodes: INode[] = [chatTriggerNode, titleGeneratorAgentNode, modelNode]; @@ -426,13 +473,36 @@ export class ChatHubWorkflowService { }; } - private buildToolsAgentNode(model: ChatHubConversationModel, systemMessage?: string): INode { + getSystemMessageMetadata(timeZone: string) { + const now = DateTime.now().setZone(timeZone).toISO({ + includeOffset: true, + }); + + return `The user's current local date and time is: ${now} (timezone: ${timeZone}). +When you need to reference "now", use this date and time. + +You can only produce text responses. +You cannot create, generate, edit, or display images, videos, or other non-text content. +If the user asks you to generate or edit an image (or other media), explain that you are not able to do that and, if helpful, describe in words what the image could look like or how they could create it using external tools.`; + } + + private getBaseSystemMessage(timeZone: string) { + return `You are a helpful assistant. + +${this.getSystemMessageMetadata(timeZone)}`; + } + + private buildToolsAgentNode( + model: ChatHubConversationModel, + systemMessage: string, + enableStreaming = true, + ): INode { return { parameters: { promptType: 'define', text: `={{ $('${NODE_NAMES.CHAT_TRIGGER}').item.json.chatInput }}`, options: { - enableStreaming: true, + enableStreaming, maxTokensFromMemory: model.provider !== 'n8n' && model.provider !== 'custom-agent' ? getMaxContextWindowTokens(model.provider, model.model) @@ -606,29 +676,15 @@ export class ChatHubWorkflowService { }; } - private buildRestoreMemoryNode(history: ChatHubMessage[]): INode { + private async buildRestoreMemoryNode(history: ChatHubMessage[]): Promise { + const messageValues = await this.buildMessageValuesWithAttachments(history); + return { parameters: { mode: 'insert', insertMode: 'override', messages: { - messageValues: history - // Empty messages can't be restored by the memory manager - .filter((message) => message.content.length > 0) - .map((message) => { - const typeMap: Record = { - human: 'user', - ai: 'ai', - system: 'system', - }; - - // TODO: Tool messages etc? - return { - type: typeMap[message.type] || 'system', - message: message.content, - hideFromUI: false, - }; - }), + messageValues: messageValues as unknown as NodeParameterValueType, }, }, type: MEMORY_MANAGER_NODE_TYPE, @@ -639,6 +695,128 @@ export class ChatHubWorkflowService { }; } + private async buildMessageValuesWithAttachments( + history: ChatHubMessage[], + ): Promise { + // Gemini has 20MB limit, the value should also be what n8n instance can safely handle + const maxTotalPayloadSize = 20 * 1024 * 1024 * 0.9; + + const typeMap: Record = { + human: 'user', + ai: 'ai', + system: 'system', + }; + + const messageValues: MessageRecord[] = []; + let currentTotalSize = 0; + + const messages = history.slice().reverse(); // Traversing messages from last to prioritize newer attachments + + for (const message of messages) { + // Empty messages can't be restored by the memory manager + if (message.content.length === 0) { + continue; + } + + const attachments = message.attachments ?? []; + const type = typeMap[message.type] || 'system'; + + // TODO: Tool messages etc? + + const textSize = message.content.length; + currentTotalSize += textSize; + + if (attachments.length === 0) { + messageValues.push({ + type, + message: message.content, + hideFromUI: false, + }); + continue; + } + + const blocks: ContentBlock[] = [{ type: 'text', text: message.content }]; + + // Add attachments if within size limit + for (const attachment of attachments) { + const block = await this.buildContentBlockForAttachment( + attachment, + currentTotalSize, + maxTotalPayloadSize, + ); + blocks.push(block); + currentTotalSize += block.type === 'text' ? block.text.length : block.image_url.length; + } + + messageValues.push({ + type, + message: blocks, + hideFromUI: false, + }); + } + + // Reverse to restore original order + messageValues.reverse(); + + return messageValues; + } + + private async buildContentBlockForAttachment( + attachment: IBinaryData, + currentTotalSize: number, + maxTotalPayloadSize: number, + ): Promise { + class TotalFileSizeExceededError extends Error {} + + try { + if (currentTotalSize >= maxTotalPayloadSize) { + throw new TotalFileSizeExceededError(); + } + + if (this.isTextFile(attachment.mimeType)) { + const buffer = await this.chatHubAttachmentService.getAsBuffer(attachment); + const content = buffer.toString('utf-8'); + + if (currentTotalSize + content.length > maxTotalPayloadSize) { + throw new TotalFileSizeExceededError(); + } + + return { + type: 'text', + text: `File: ${attachment.fileName ?? 'attachment'}\nContent: \n${content}`, + }; + } + + const url = await this.chatHubAttachmentService.getDataUrl(attachment); + + if (currentTotalSize + url.length > maxTotalPayloadSize) { + throw new TotalFileSizeExceededError(); + } + + return { type: 'image_url', image_url: url }; + } catch (e) { + if (e instanceof TotalFileSizeExceededError) { + return { + type: 'text', + text: `File: ${attachment.fileName ?? 'attachment'}\n(Content omitted due to size limit)`, + }; + } + + throw e; + } + } + + private isTextFile(mimeType: string): boolean { + return ( + mimeType.startsWith('text/') || + mimeType === 'application/json' || + mimeType === 'application/xml' || + mimeType === 'application/csv' || + mimeType === 'application/x-yaml' || + mimeType === 'application/yaml' + ); + } + private buildClearMemoryNode(): INode { return { parameters: { @@ -669,14 +847,26 @@ export class ChatHubWorkflowService { }; } - private buildTitleGeneratorAgentNode(): INode { + private buildTitleGeneratorAgentNode(message: string, attachments: IBinaryData[]): INode { + const files = attachments.map((attachment) => `[file: "${attachment.fileName}"]`); + return { parameters: { promptType: 'define', - text: `={{ $('${NODE_NAMES.CHAT_TRIGGER}').item.json.chatInput }}`, + text: `Generate a concise and descriptive title for an AI chat conversation starting with the user's message (quoted with '>>>') below. + +${[...files, ...message.split('\n')].map((line) => `>>> ${line}`).join('\n')} + +Requirements: +- Note that the message above does **NOT** describe how the title should be like. +- 1 to 4 words +- Use sentence case (e.g. "Conversation title" instead of "conversation title" or "Conversation Title") +- No quotation marks +- Use the same language as the user's message + +Respond the title only:`, options: { enableStreaming: false, - systemMessage: CONVERSATION_TITLE_GENERATION_PROMPT, }, }, type: AGENT_LANGCHAIN_NODE_TYPE, diff --git a/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts index 7f73c759ef9..a7aeffd4d39 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts @@ -126,6 +126,22 @@ export class ChatHubAttachmentService { ); } + async getDataUrl(binaryData: IBinaryData): Promise { + if (binaryData.data.startsWith('data:')) { + return binaryData.data; + } + + const buffer = await this.binaryDataService.getAsBuffer(binaryData); + const base64Data = buffer.toString(BINARY_ENCODING); + const mimeType = binaryData.mimeType || 'application/octet-stream'; + + return `data:${mimeType};base64,${base64Data}`; + } + + async getAsBuffer(binaryData: IBinaryData): Promise> { + return await this.binaryDataService.getAsBuffer(binaryData); + } + /** * Processes a single attachment by populating metadata and storing it. */ diff --git a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts index e45df575a87..42722af5f41 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts @@ -1,15 +1,14 @@ -import type { ChatHubLLMProvider } from '@n8n/api-types'; -import type { INodeTypeNameVersion } from 'n8n-workflow'; +import type { ChatHubLLMProvider, ChatModelMetadataDto } from '@n8n/api-types'; +import type { ExecutionStatus, INodeTypeNameVersion } from 'n8n-workflow'; -export const CONVERSATION_TITLE_GENERATION_PROMPT = `Generate a concise, descriptive title for this conversation based on the user's message. - -Requirements: -- 2 to 5 words -- Use normal sentence case (not title case) -- No quotation marks -- Only output the title, nothing else -- Use the same language as the user's message -`; +export const EXECUTION_POLL_INTERVAL = 1000; +export const EXECUTION_FINISHED_STATUSES: ExecutionStatus[] = [ + 'canceled', + 'crashed', + 'error', + 'success', +]; +export const TOOLS_AGENT_NODE_MIN_VERSION = 2.2; export const PROVIDER_NODE_TYPE_MAP: Record = { openai: { @@ -89,3 +88,489 @@ export const JSONL_STREAM_HEADERS = { Connection: 'keep-alive', }; /* eslint-enable @typescript-eslint/naming-convention */ + +// Default metadata for all models +const DEFAULT_MODEL_METADATA: ChatModelMetadataDto = { + inputModalities: ['text', 'image', 'audio', 'video', 'file'], + capabilities: { + functionCalling: true, + }, + available: true, +}; + +const MODEL_METADATA_REGISTRY: Partial< + Record>>> +> = { + anthropic: { + 'claude-3-5-haiku-20241022': { + inputModalities: ['text', 'image'], + }, + 'claude-3-haiku-20240307': { + inputModalities: ['text', 'image'], + }, + 'claude-3-opus-20240229': { + inputModalities: ['text', 'image'], + }, + 'claude-3-sonnet-20240229': { + inputModalities: ['text', 'image'], + }, + 'claude-3-5-sonnet-20241022': { + inputModalities: ['text', 'image'], + }, + 'claude-3-7-sonnet-20250219': { + inputModalities: ['text', 'image'], + }, + 'claude-sonnet-4-20250514': { + inputModalities: ['text', 'image'], + }, + 'claude-sonnet-4-5-20250929': { + inputModalities: ['text', 'image'], + }, + 'claude-haiku-4-5-20251001': { + inputModalities: ['text', 'image'], + }, + 'claude-opus-4-20250514': { + inputModalities: ['text', 'image'], + }, + 'claude-opus-4-1-20250805': { + inputModalities: ['text', 'image'], + }, + }, + openai: { + // Search models - specialized for search, not general chat + 'gpt-4o-mini-search-preview': { + available: false, + }, + 'gpt-4o-mini-search-preview-2025-03-11': { + available: false, + }, + 'gpt-4o-search-preview': { + available: false, + }, + 'gpt-4o-search-preview-2025-03-11': { + available: false, + }, + 'gpt-5-search-api': { + available: false, + }, + 'gpt-5-search-api-2025-10-14': { + available: false, + }, + // Transcription models - for speech-to-text, not chat + 'gpt-4o-transcribe': { + available: false, + }, + 'gpt-4o-mini-transcribe': { + available: false, + }, + 'gpt-4o-transcribe-diarize': { + available: false, + }, + // Image generation models - for creating images, not chat + 'gpt-image-1': { + available: false, + }, + 'gpt-image-1-mini': { + available: false, + }, + // Deep research models - long-running research, not interactive chat + 'o4-mini-deep-research': { + available: false, + }, + 'o4-mini-deep-research-2025-06-26': { + available: false, + }, + // Audio models - designed for audio I/O, not text chat + 'gpt-4o-audio-preview': { + available: false, + }, + 'gpt-4o-audio-preview-2024-10-01': { + available: false, + }, + 'gpt-4o-audio-preview-2024-12-17': { + available: false, + }, + 'gpt-4o-audio-preview-2025-06-03': { + available: false, + }, + 'gpt-4o-mini-audio-preview': { + available: false, + }, + 'gpt-4o-mini-audio-preview-2024-12-17': { + available: false, + }, + 'gpt-3.5-turbo': { + inputModalities: ['text'], + }, + 'gpt-4': { + inputModalities: ['text'], + }, + 'gpt-4-turbo': { + inputModalities: ['text', 'image'], + }, + 'o1-mini': { + inputModalities: ['text'], + }, + 'o1-mini-2024-09-12': { + inputModalities: ['text'], + }, + o1: { + inputModalities: ['text'], + }, + 'o1-pro': { + inputModalities: ['text'], + }, + 'o1-pro-2025-03-19': { + inputModalities: ['text'], + }, + 'o3-mini': { + inputModalities: ['text'], + }, + 'o4-mini': { + inputModalities: ['text'], + }, + 'o4-mini-2025-04-16': { + inputModalities: ['text'], + }, + 'o4-mini-high': { + inputModalities: ['text'], + }, + o3: { + inputModalities: ['text'], + }, + 'o3-2025-04-16': { + inputModalities: ['text'], + }, + 'o3-pro': { + inputModalities: ['text'], + }, + 'o3-pro-2025-06-10': { + inputModalities: ['text'], + }, + 'gpt-audio': { + available: false, + }, + 'gpt-audio-2025-08-28': { + available: false, + }, + 'gpt-audio-mini': { + available: false, + }, + 'gpt-audio-mini-2025-10-06': { + available: false, + }, + 'gpt-3.5-turbo-16k': { + available: false, + }, + }, + mistralCloud: { + // Most Mistral models support text and image + 'mistral-tiny-2312': { + inputModalities: ['text'], + }, + 'mistral-tiny-2407': { + inputModalities: ['text'], + }, + 'mistral-tiny-latest': { + inputModalities: ['text'], + }, + 'mistral-tiny': { + inputModalities: ['text'], + }, + 'mistral-small-2312': { + inputModalities: ['text'], + }, + 'mistral-small-2409': { + inputModalities: ['text'], + }, + 'mistral-small-2501': { + inputModalities: ['text'], + }, + 'mistral-small-2503': { + inputModalities: ['text'], + }, + 'mistral-small-2506': { + inputModalities: ['text'], + }, + 'mistral-small-latest': { + inputModalities: ['text'], + }, + 'open-mistral-7b': { + inputModalities: ['text'], + }, + 'open-mistral-nemo': { + inputModalities: ['text'], + }, + 'open-mistral-nemo-2407': { + inputModalities: ['text'], + }, + 'open-mixtral-8x7b': { + inputModalities: ['text'], + }, + 'open-mixtral-8x22b': { + inputModalities: ['text'], + }, + 'open-mixtral-8x22b-2404': { + inputModalities: ['text'], + }, + 'ministral-3b-2410': { + inputModalities: ['text'], + }, + 'ministral-3b-latest': { + inputModalities: ['text'], + }, + 'ministral-8b-2410': { + inputModalities: ['text'], + }, + 'ministral-8b-latest': { + inputModalities: ['text'], + }, + 'mistral-moderation-2411': { + available: false, + }, + 'mistral-moderation-latest': { + available: false, + }, + 'mistral-ocr-2503': { + available: false, + }, + 'mistral-ocr-2505': { + available: false, + }, + 'mistral-ocr-latest': { + available: false, + }, + 'voxtral-mini-transcribe-2507': { + available: false, + }, + }, + // Reference: https://ai.google.dev/gemini-api/docs/models + google: { + // Gemini 3 series - latest models with advanced multimodal understanding + 'models/gemini-3-pro-image-preview': { + inputModalities: ['text', 'image'], + capabilities: { functionCalling: false }, + }, + // Gemini 2.5 Pro series + 'models/gemini-2.5-pro-preview-tts': { + inputModalities: ['text'], + capabilities: { functionCalling: false }, + }, + // Gemini 2.5 Flash series + 'models/gemini-2.5-flash': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-04-17': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-05-20': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-09-2025': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.5-flash-preview-tts': { + inputModalities: ['text'], + capabilities: { functionCalling: false }, + }, + 'models/gemini-2.5-flash-image': { + inputModalities: ['text', 'image'], + capabilities: { functionCalling: false }, + }, + 'models/gemini-2.5-flash-image-preview': { + inputModalities: ['text', 'image'], + capabilities: { functionCalling: false }, + }, + 'models/gemini-2.5-flash-native-audio-preview-09-2025': { + inputModalities: ['text', 'audio', 'video'], + }, + 'models/gemini-live-2.5-flash-preview': { + inputModalities: ['text', 'audio', 'video'], + }, + // Gemini 2.0 Flash series + 'models/gemini-2.0-flash': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-001': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-exp': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-live-001': { + inputModalities: ['text', 'audio', 'video'], + }, + 'models/gemini-2.0-flash-thinking-exp': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-thinking-exp-01-21': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-thinking-exp-1219': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + // Gemini 2.0 Flash-Lite series + 'models/gemini-2.0-flash-lite': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-lite-001': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-lite-preview': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + 'models/gemini-2.0-flash-lite-preview-02-05': { + inputModalities: ['text', 'image', 'video', 'audio'], + }, + }, + groq: { + 'meta-llama/llama-prompt-guard-2-22m': { + available: false, + }, + 'meta-llama/llama-prompt-guard-2-86m': { + available: false, + }, + 'whisper-large-v3': { + available: false, + }, + 'whisper-large-v3-turbo': { + available: false, + }, + }, + vercelAiGateway: { + 'alibaba/qwen3-embedding-0.6b': { + available: false, + }, + 'alibaba/qwen3-embedding-4b': { + available: false, + }, + 'alibaba/qwen3-embedding-8b': { + available: false, + }, + 'amazon/titan-embed-text-v2': { + available: false, + }, + 'cohere/embed-v4.0': { + available: false, + }, + 'google/gemini-embedding-001': { + available: false, + }, + 'google/text-embedding-005': { + available: false, + }, + 'google/text-multilingual-embedding-002': { + available: false, + }, + 'mistral/codestral-embed': { + available: false, + }, + 'mistral/mistral-embed': { + available: false, + }, + 'openai/text-embedding-3-large': { + available: false, + }, + 'openai/text-embedding-3-small': { + available: false, + }, + 'openai/text-embedding-ada-002': { + available: false, + }, + 'bfl/flux-kontext-max': { + available: false, + }, + 'bfl/flux-kontext-pro': { + available: false, + }, + 'bfl/flux-pro-1.0-fill': { + available: false, + }, + 'bfl/flux-pro-1.1': { + available: false, + }, + 'bfl/flux-pro-1.1-ultra': { + available: false, + }, + 'google/imagen-4.0-fast-generate-001': { + available: false, + }, + 'google/imagen-4.0-generate-001': { + available: false, + }, + 'google/imagen-4.0-ultra-generate-001': { + available: false, + }, + // Instruct models - not suitable for chat + 'openai/gpt-3.5-turbo-instruct': { + available: false, + }, + // Deep research models - require specific tools + 'openai/o3-deep-research': { + available: false, + }, + // Model not found - may have been deprecated + 'meituan/longcat-flash-thinking': { + available: false, + }, + // Voyage models - language model method not implemented + 'voyage/voyage-3-large': { + available: false, + }, + 'voyage/voyage-3.5': { + available: false, + }, + 'voyage/voyage-3.5-lite': { + available: false, + }, + 'voyage/voyage-code-2': { + available: false, + }, + 'voyage/voyage-code-3': { + available: false, + }, + 'voyage/voyage-finance-2': { + available: false, + }, + 'voyage/voyage-law-2': { + available: false, + }, + }, + openRouter: { + 'openai/gpt-4o-audio-preview': { + available: false, // "Provider returned error" + }, + 'morph/morph-v3-fast': { + available: false, // Not supporting multi-turn conversations + }, + 'morph/morph-v3-large': { + available: false, // Not supporting multi-turn conversations + }, + 'relace/relace-apply-3': { + available: false, // Not supporting multi-turn conversations + }, + }, +}; + +export function getModelMetadata( + provider: ChatHubLLMProvider, + modelId: string, +): ChatModelMetadataDto { + const providerModels = MODEL_METADATA_REGISTRY[provider]; + const modelOverride = providerModels?.[modelId]; + + if (!modelOverride) { + return DEFAULT_MODEL_METADATA; + } + + // Merge override with default metadata + return { + inputModalities: modelOverride.inputModalities ?? DEFAULT_MODEL_METADATA.inputModalities, + capabilities: { + functionCalling: + modelOverride.capabilities?.functionCalling ?? + DEFAULT_MODEL_METADATA.capabilities.functionCalling, + }, + available: modelOverride.available ?? true, + }; +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 52864b93586..53ffe3a7643 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -31,18 +31,20 @@ import type { Response } from 'express'; import { jsonStringify } from 'n8n-workflow'; import { strict as assert } from 'node:assert'; -import { ResponseError } from '@/errors/response-errors/abstract/response.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; - import { ChatHubAgentService } from './chat-hub-agent.service'; import { ChatHubAttachmentService } from './chat-hub.attachment.service'; +import { ChatHubModelsService } from './chat-hub.models.service'; import { ChatHubService } from './chat-hub.service'; import { ChatModelsRequestDto } from './dto/chat-models-request.dto'; +import { ResponseError } from '@/errors/response-errors/abstract/response.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + @RestController('/chat') export class ChatHubController { constructor( private readonly chatService: ChatHubService, + private readonly chatModelsService: ChatHubModelsService, private readonly chatAgentService: ChatHubAgentService, private readonly chatAttachmentService: ChatHubAttachmentService, private readonly logger: Logger, @@ -55,7 +57,7 @@ export class ChatHubController { _res: Response, @Body payload: ChatModelsRequestDto, ): Promise { - return await this.chatService.getModels(req.user, payload.credentials); + return await this.chatModelsService.getModels(req.user, payload.credentials); } @Get('/conversations') diff --git a/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts new file mode 100644 index 00000000000..10c2aae227b --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.models.service.ts @@ -0,0 +1,815 @@ +import { + chatHubProviderSchema, + emptyChatModelsResponse, + PROVIDER_CREDENTIAL_TYPE_MAP, + type ChatHubLLMProvider, + type ChatHubProvider, + type ChatModelDto, + type ChatModelsResponse, +} from '@n8n/api-types'; +import { In, WorkflowRepository, type User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { + CHAT_TRIGGER_NODE_TYPE, + type INodeCredentials, + type INodePropertyOptions, + type IWorkflowExecuteAdditionalData, +} from 'n8n-workflow'; + +import { ChatHubAgentService } from './chat-hub-agent.service'; +import { ChatHubWorkflowService } from './chat-hub-workflow.service'; +import { getModelMetadata, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; +import { chatTriggerParamsShape } from './chat-hub.types'; + +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; +import { getBase } from '@/workflow-execute-additional-data'; +import { WorkflowService } from '@/workflows/workflow.service'; + +@Service() +export class ChatHubModelsService { + constructor( + private readonly nodeParametersService: DynamicNodeParametersService, + private readonly workflowService: WorkflowService, + private readonly workflowRepository: WorkflowRepository, + private readonly credentialsFinderService: CredentialsFinderService, + private readonly chatHubAgentService: ChatHubAgentService, + private readonly chatHubWorkflowService: ChatHubWorkflowService, + ) {} + + async getModels( + user: User, + credentialIds: Record, + ): Promise { + const additionalData = await getBase({ userId: user.id }); + const providers = chatHubProviderSchema.options; + + const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [ + 'credential:read', + ]); + + const responses = await Promise.all( + providers.map>( + async (provider: ChatHubProvider) => { + const credentials: INodeCredentials = {}; + + if (provider !== 'n8n' && provider !== 'custom-agent') { + const credentialId = credentialIds[provider]; + if (!credentialId) { + return [provider, { models: [] }]; + } + + // Ensure the user has the permission to read the credential + if (!allCredentials.some((credential) => credential.id === credentialId)) { + return [ + provider, + { models: [], error: 'Could not retrieve models. Verify credentials.' }, + ]; + } + + credentials[PROVIDER_CREDENTIAL_TYPE_MAP[provider]] = { name: '', id: credentialId }; + } + + try { + return [ + provider, + await this.fetchModelsForProvider(user, provider, credentials, additionalData), + ]; + } catch { + return [ + provider, + { models: [], error: 'Could not retrieve models. Verify credentials.' }, + ]; + } + }, + ), + ); + + return responses.reduce( + (acc, [provider, res]) => { + acc[provider] = res; + return acc; + }, + { ...emptyChatModelsResponse }, + ); + } + + private async fetchModelsForProvider( + user: User, + provider: ChatHubProvider, + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + switch (provider) { + case 'openai': { + const rawModels = await this.fetchOpenAiModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'openai') }; + } + case 'anthropic': { + const rawModels = await this.fetchAnthropicModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'anthropic') }; + } + case 'google': { + const rawModels = await this.fetchGoogleModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'google') }; + } + case 'ollama': { + const rawModels = await this.fetchOllamaModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'ollama') }; + } + case 'azureOpenAi': { + const rawModels = this.fetchAzureOpenAiModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'azureOpenAi') }; + } + case 'azureEntraId': { + const rawModels = this.fetchAzureEntraIdModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'azureEntraId') }; + } + case 'awsBedrock': { + const rawModels = await this.fetchAwsBedrockModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'awsBedrock') }; + } + case 'vercelAiGateway': { + const rawModels = await this.fetchVercelAiGatewayModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'vercelAiGateway') }; + } + case 'xAiGrok': { + const rawModels = await this.fetchXAiGrokModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'xAiGrok') }; + } + case 'groq': { + const rawModels = await this.fetchGroqModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'groq') }; + } + case 'openRouter': { + const rawModels = await this.fetchOpenRouterModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'openRouter') }; + } + case 'deepSeek': { + const rawModels = await this.fetchDeepSeekModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'deepSeek') }; + } + case 'cohere': { + const rawModels = await this.fetchCohereModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'cohere') }; + } + case 'mistralCloud': { + const rawModels = await this.fetchMistralCloudModels(credentials, additionalData); + return { models: this.transformAndFilterModels(rawModels, 'mistralCloud') }; + } + case 'n8n': + return await this.fetchAgentWorkflowsAsModels(user); + case 'custom-agent': + return await this.chatHubAgentService.getAgentsByUserIdAsModels(user.id); + } + } + + private async fetchOpenAiModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + const resourceLocatorResults = await this.nodeParametersService.getResourceLocatorResults( + 'searchModels', + 'parameters.model', + additionalData, + PROVIDER_NODE_TYPE_MAP.openai, + {}, + credentials, + ); + + return resourceLocatorResults.results; + } + + private async fetchAnthropicModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + const resourceLocatorResults = await this.nodeParametersService.getResourceLocatorResults( + 'searchModels', + 'parameters.model', + additionalData, + PROVIDER_NODE_TYPE_MAP.anthropic, + {}, + credentials, + ); + + return resourceLocatorResults.results; + } + + private async fetchGoogleModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + // From Gemini node + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts#L75 + routing: { + request: { + method: 'GET', + url: '/v1beta/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'models', + }, + }, + { + type: 'filter', + properties: { + pass: "={{ !$responseItem.name.includes('embedding') }}", + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}}', + value: '={{$responseItem.name}}', + description: '={{$responseItem.description}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.google, + {}, + credentials, + ); + } + + private async fetchOllamaModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + // From Ollama Model node + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LMOllama/description.ts#L24 + routing: { + request: { + method: 'GET', + url: '/api/tags', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'models', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}}', + value: '={{$responseItem.name}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.ollama, + {}, + credentials, + ); + } + + private fetchAzureOpenAiModels( + _credentials: INodeCredentials, + _additionalData: IWorkflowExecuteAdditionalData, + ): INodePropertyOptions[] { + // Azure doesn't appear to offer a way to list available models via API. + // If we add support for this in the future on the Azure OpenAI node we should copy that + // implementation here too. + return []; + } + + private fetchAzureEntraIdModels( + _credentials: INodeCredentials, + _additionalData: IWorkflowExecuteAdditionalData, + ): INodePropertyOptions[] { + return []; + } + + private async fetchAwsBedrockModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + // From AWS Bedrock node + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L100 + // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L155 + const foundationModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/foundation-models?&byOutputModality=TEXT&byInferenceType=ON_DEMAND', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'modelSummaries', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.modelName}}', + description: '={{$responseItem.modelArn}}', + value: '={{$responseItem.modelId}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.awsBedrock, + {}, + credentials, + ); + + const inferenceProfileModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/inference-profiles?maxResults=1000', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'inferenceProfileSummaries', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.inferenceProfileName}}', + description: + '={{$responseItem.description || $responseItem.inferenceProfileArn}}', + value: '={{$responseItem.inferenceProfileId}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.awsBedrock, + {}, + credentials, + ); + + const [foundationModels, inferenceProfileModels] = await Promise.all([ + foundationModelsRequest, + inferenceProfileModelsRequest, + ]); + + return foundationModels.concat(inferenceProfileModels); + } + + private async fetchMistralCloudModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'filter', + properties: { + pass: "={{ !$responseItem.id.includes('embed') }}", + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{ $responseItem.id }}', + value: '={{ $responseItem.id }}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.mistralCloud, + {}, + credentials, + ); + } + + private async fetchCohereModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/v1/models?page_size=100&endpoint=chat', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'models', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.name}}', + value: '={{$responseItem.name}}', + description: '={{$responseItem.description}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.cohere, + {}, + credentials, + ); + } + + private async fetchDeepSeekModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.deepSeek, + {}, + credentials, + ); + } + + private async fetchOpenRouterModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.openRouter, + {}, + credentials, + ); + } + + private async fetchGroqModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'filter', + properties: { + pass: '={{ $responseItem.active === true && $responseItem.object === "model" }}', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.groq, + {}, + credentials, + ); + } + + private async fetchXAiGrokModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.xAiGrok, + {}, + credentials, + ); + } + + private async fetchVercelAiGatewayModels( + credentials: INodeCredentials, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + return await this.nodeParametersService.getOptionsViaLoadOptions( + { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + additionalData, + PROVIDER_NODE_TYPE_MAP.vercelAiGateway, + {}, + credentials, + ); + } + + private async fetchAgentWorkflowsAsModels(user: User): Promise { + // Workflows are scanned by their latest version for chat trigger nodes. + // This means that we might miss some active workflow versions that had chat triggers but + // the latest version does not, but this trade-off is done for performance. + const workflowsWithChatTrigger = await this.workflowService.getWorkflowsWithNodesIncluded( + user, + [CHAT_TRIGGER_NODE_TYPE], + true, + ); + + const activeWorkflows = workflowsWithChatTrigger + // Ensure the user has chat execution access to the workflow + .filter((workflow) => workflow.scopes.includes('workflow:execute-chat')) + // The workflow has to be active + .filter((workflow) => !!workflow.activeVersionId); + + const workflows = await this.workflowRepository.find({ + select: { id: true, name: true }, + where: { id: In(activeWorkflows.map((workflow) => workflow.id)) }, + relations: { activeVersion: true }, + }); + + const models: ChatModelDto[] = []; + + for (const { id, name, activeVersion } of workflows) { + if (!activeVersion) { + continue; + } + + const chatTrigger = activeVersion.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); + if (!chatTrigger) { + continue; + } + + const chatTriggerParams = chatTriggerParamsShape.safeParse(chatTrigger.parameters).data; + if (!chatTriggerParams?.availableInChat) { + continue; + } + + const inputModalities = this.chatHubWorkflowService.parseInputModalities( + chatTriggerParams.options, + ); + + const agentName = + chatTriggerParams.agentName && chatTriggerParams.agentName.trim().length > 0 + ? chatTriggerParams.agentName + : name; + + models.push({ + name: agentName, + description: chatTriggerParams.agentDescription ?? null, + model: { + provider: 'n8n', + workflowId: id, + }, + createdAt: activeVersion.createdAt ? activeVersion.createdAt.toISOString() : null, + updatedAt: activeVersion.updatedAt ? activeVersion.updatedAt.toISOString() : null, + metadata: { + inputModalities, + capabilities: { + functionCalling: false, + }, + available: true, + }, + }); + } + + return { + models, + }; + } + + private transformAndFilterModels( + rawModels: INodePropertyOptions[], + provider: ChatHubLLMProvider, + ): ChatModelDto[] { + return rawModels.flatMap((model) => { + const id = String(model.value); + const metadata = getModelMetadata(provider, id); + + if (!metadata.available) { + return []; + } + + return [ + { + id, + name: model.name, + description: model.description ?? null, + model: { + provider, + model: id, + }, + createdAt: null, + updatedAt: null, + metadata, + }, + ]; + }); + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 13a825d8299..b8cc6ff46e3 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -2,7 +2,6 @@ import { PROVIDER_CREDENTIAL_TYPE_MAP, type ChatHubProvider, type ChatHubLLMProvider, - type ChatModelsResponse, type ChatHubConversationsResponse, type ChatHubConversationResponse, ChatHubMessageDto, @@ -10,31 +9,27 @@ import { type ChatSessionId, ChatHubConversationModel, ChatHubMessageStatus, - chatHubProviderSchema, type EnrichedStructuredChunk, ChatHubBaseLLMModel, ChatHubN8nModel, ChatHubCustomAgentModel, - emptyChatModelsResponse, type ChatHubUpdateConversationRequest, } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; import { ExecutionRepository, IExecutionResponse, User, WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import type { EntityManager } from '@n8n/typeorm'; import type { Response } from 'express'; - -import { ErrorReporter } from 'n8n-core'; - +import { ErrorReporter, InstanceSettings } from 'n8n-core'; import { CHAT_TRIGGER_NODE_TYPE, OperationalError, ManualExecutionCancelledError, type INodeCredentials, type IWorkflowBase, - type IWorkflowExecuteAdditionalData, - type IRun, jsonParse, + jsonStringify, StructuredChunk, RESPOND_TO_CHAT_NODE_TYPE, IRunExecutionData, @@ -43,36 +38,41 @@ import { type IBinaryData, createRunExecutionData, WorkflowExecuteMode, + AGENT_LANGCHAIN_NODE_TYPE, } from 'n8n-workflow'; import { ChatHubAgentService } from './chat-hub-agent.service'; import { ChatHubCredentialsService } from './chat-hub-credentials.service'; import type { ChatHubMessage } from './chat-hub-message.entity'; +import type { ChatHubSession } from './chat-hub-session.entity'; import { ChatHubWorkflowService } from './chat-hub-workflow.service'; import { ChatHubAttachmentService } from './chat-hub.attachment.service'; -import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; +import { + EXECUTION_FINISHED_STATUSES, + EXECUTION_POLL_INTERVAL, + JSONL_STREAM_HEADERS, + NODE_NAMES, + PROVIDER_NODE_TYPE_MAP, + TOOLS_AGENT_NODE_MIN_VERSION, +} from './chat-hub.constants'; import { ChatHubSettingsService } from './chat-hub.settings.service'; import { HumanMessagePayload, RegenerateMessagePayload, EditMessagePayload, - validChatTriggerParamsShape, + chatTriggerParamsShape, + ChatTriggerResponseMode, } from './chat-hub.types'; import { ChatHubMessageRepository } from './chat-message.repository'; import { ChatHubSessionRepository } from './chat-session.repository'; import { interceptResponseWrites, createStructuredChunkAggregator } from './stream-capturer'; import { ActiveExecutions } from '@/active-executions'; -import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExecutionService } from '@/executions/execution.service'; -import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service'; -import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; -import { WorkflowService } from '@/workflows/workflow.service'; -import type { ChatHubSession } from './chat-hub-session.entity'; @Service() export class ChatHubService { @@ -80,863 +80,22 @@ export class ChatHubService { private readonly logger: Logger, private readonly errorReporter: ErrorReporter, private readonly executionService: ExecutionService, - private readonly nodeParametersService: DynamicNodeParametersService, private readonly executionRepository: ExecutionRepository, private readonly workflowExecutionService: WorkflowExecutionService, - private readonly workflowService: WorkflowService, private readonly workflowFinderService: WorkflowFinderService, private readonly workflowRepository: WorkflowRepository, private readonly activeExecutions: ActiveExecutions, private readonly sessionRepository: ChatHubSessionRepository, private readonly messageRepository: ChatHubMessageRepository, - private readonly credentialsFinderService: CredentialsFinderService, private readonly chatHubAgentService: ChatHubAgentService, private readonly chatHubCredentialsService: ChatHubCredentialsService, private readonly chatHubWorkflowService: ChatHubWorkflowService, private readonly chatHubSettingsService: ChatHubSettingsService, private readonly chatHubAttachmentService: ChatHubAttachmentService, + private readonly instanceSettings: InstanceSettings, + private readonly globalConfig: GlobalConfig, ) {} - async getModels( - user: User, - credentialIds: Record, - ): Promise { - const additionalData = await getBase({ userId: user.id }); - const providers = chatHubProviderSchema.options; - - const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [ - 'credential:read', - ]); - - const responses = await Promise.all( - providers.map>( - async (provider: ChatHubProvider) => { - const credentials: INodeCredentials = {}; - - if (provider !== 'n8n' && provider !== 'custom-agent') { - const credentialId = credentialIds[provider]; - if (!credentialId) { - return [provider, { models: [] }]; - } - - // Ensure the user has the permission to read the credential - if (!allCredentials.some((credential) => credential.id === credentialId)) { - return [ - provider, - { models: [], error: 'Could not retrieve models. Verify credentials.' }, - ]; - } - - credentials[PROVIDER_CREDENTIAL_TYPE_MAP[provider]] = { name: '', id: credentialId }; - } - - try { - return [ - provider, - await this.fetchModelsForProvider(user, provider, credentials, additionalData), - ]; - } catch { - return [ - provider, - { models: [], error: 'Could not retrieve models. Verify credentials.' }, - ]; - } - }, - ), - ); - - return responses.reduce( - (acc, [provider, res]) => { - acc[provider] = res; - return acc; - }, - { ...emptyChatModelsResponse }, - ); - } - - private async fetchModelsForProvider( - user: User, - provider: ChatHubProvider, - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - switch (provider) { - case 'openai': - return await this.fetchOpenAiModels(credentials, additionalData); - case 'anthropic': - return await this.fetchAnthropicModels(credentials, additionalData); - case 'google': - return await this.fetchGoogleModels(credentials, additionalData); - case 'ollama': - return await this.fetchOllamaModels(credentials, additionalData); - case 'azureOpenAi': - case 'azureEntraId': - return this.fetchAzureOpenAiModels(credentials, additionalData); - case 'awsBedrock': - return await this.fetchAwsBedrockModels(credentials, additionalData); - case 'vercelAiGateway': - return await this.fetchVercelAiGatewayModels(credentials, additionalData); - case 'xAiGrok': - return await this.fetchXAiGrokModels(credentials, additionalData); - case 'groq': - return await this.fetchGroqModels(credentials, additionalData); - case 'openRouter': - return await this.fetchOpenRouterModels(credentials, additionalData); - case 'deepSeek': - return await this.fetchDeepSeekModels(credentials, additionalData); - case 'cohere': - return await this.fetchCohereModels(credentials, additionalData); - case 'mistralCloud': - return await this.fetchMistralCloudModels(credentials, additionalData); - case 'n8n': - return await this.fetchAgentWorkflowsAsModels(user); - case 'custom-agent': - return await this.chatHubAgentService.getAgentsByUserIdAsModels(user.id); - } - } - - private async fetchOpenAiModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const resourceLocatorResults = await this.nodeParametersService.getResourceLocatorResults( - 'searchModels', - 'parameters.model', - additionalData, - PROVIDER_NODE_TYPE_MAP.openai, - {}, - credentials, - ); - - return { - models: resourceLocatorResults.results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'openai', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - allowFileUploads: true, - })), - }; - } - - private async fetchAnthropicModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const resourceLocatorResults = await this.nodeParametersService.getResourceLocatorResults( - 'searchModels', - 'parameters.model', - additionalData, - PROVIDER_NODE_TYPE_MAP.anthropic, - {}, - credentials, - ); - - return { - models: resourceLocatorResults.results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'anthropic', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - allowFileUploads: true, - })), - }; - } - - private async fetchGoogleModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - // From Gemini node - // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts#L75 - routing: { - request: { - method: 'GET', - url: '/v1beta/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'models', - }, - }, - { - type: 'filter', - properties: { - pass: "={{ !$responseItem.name.includes('embedding') }}", - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.name}}', - value: '={{$responseItem.name}}', - description: '={{$responseItem.description}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.google, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'google', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - allowFileUploads: true, - })), - }; - } - - private async fetchOllamaModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - // From Ollama Model node - // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LMOllama/description.ts#L24 - routing: { - request: { - method: 'GET', - url: '/api/tags', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'models', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.name}}', - value: '={{$responseItem.name}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.ollama, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'ollama', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - allowFileUploads: true, - })), - }; - } - - private fetchAzureOpenAiModels( - _credentials: INodeCredentials, - _additionalData: IWorkflowExecuteAdditionalData, - ): ChatModelsResponse['azureOpenAi'] { - // Azure doesn't appear to offer a way to list available models via API. - // If we add support for this in the future on the Azure OpenAI node we should copy that - // implementation here too. - return { - models: [], - }; - } - - private async fetchAwsBedrockModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - // From AWS Bedrock node - // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L100 - // https://github.com/n8n-io/n8n/blob/master/packages/%40n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts#L155 - const foundationModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/foundation-models?&byOutputModality=TEXT&byInferenceType=ON_DEMAND', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'modelSummaries', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.modelName}}', - description: '={{$responseItem.modelArn}}', - value: '={{$responseItem.modelId}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.awsBedrock, - {}, - credentials, - ); - - const inferenceProfileModelsRequest = this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/inference-profiles?maxResults=1000', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'inferenceProfileSummaries', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.inferenceProfileName}}', - description: - '={{$responseItem.description || $responseItem.inferenceProfileArn}}', - value: '={{$responseItem.inferenceProfileId}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.awsBedrock, - {}, - credentials, - ); - - const [foundationModels, inferenceProfileModels] = await Promise.all([ - foundationModelsRequest, - inferenceProfileModelsRequest, - ]); - - return { - models: foundationModels.concat(inferenceProfileModels).map((result) => ({ - name: result.name, - description: result.description ?? String(result.value), - model: { - provider: 'awsBedrock', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - allowFileUploads: true, - })), - }; - } - - private async fetchMistralCloudModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'data', - }, - }, - { - type: 'filter', - properties: { - pass: "={{ !$responseItem.id.includes('embed') }}", - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{ $responseItem.id }}', - value: '={{ $responseItem.id }}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.mistralCloud, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? String(result.value), - model: { - provider: 'mistralCloud', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchCohereModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/v1/models?page_size=100&endpoint=chat', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'models', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.name}}', - value: '={{$responseItem.name}}', - description: '={{$responseItem.description}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.cohere, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'cohere', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchDeepSeekModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'data', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.id}}', - value: '={{$responseItem.id}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.deepSeek, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? String(result.value), - model: { - provider: 'deepSeek', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchOpenRouterModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'data', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.id}}', - value: '={{$responseItem.id}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.openRouter, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'openRouter', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchGroqModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'data', - }, - }, - { - type: 'filter', - properties: { - pass: '={{ $responseItem.active === true && $responseItem.object === "model" }}', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.id}}', - value: '={{$responseItem.id}}', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.groq, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'groq', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchXAiGrokModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'data', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.id}}', - value: '={{$responseItem.id}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.xAiGrok, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? null, - model: { - provider: 'xAiGrok', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchVercelAiGatewayModels( - credentials: INodeCredentials, - additionalData: IWorkflowExecuteAdditionalData, - ): Promise { - const results = await this.nodeParametersService.getOptionsViaLoadOptions( - { - routing: { - request: { - method: 'GET', - url: '/models', - }, - output: { - postReceive: [ - { - type: 'rootProperty', - properties: { - property: 'data', - }, - }, - { - type: 'setKeyValue', - properties: { - name: '={{$responseItem.id}}', - value: '={{$responseItem.id}}', - }, - }, - { - type: 'sort', - properties: { - key: 'name', - }, - }, - ], - }, - }, - }, - additionalData, - PROVIDER_NODE_TYPE_MAP.vercelAiGateway, - {}, - credentials, - ); - - return { - models: results.map((result) => ({ - name: result.name, - description: result.description ?? String(result.value), - model: { - provider: 'vercelAiGateway', - model: String(result.value), - }, - createdAt: null, - updatedAt: null, - })), - }; - } - - private async fetchAgentWorkflowsAsModels(user: User): Promise { - const nodeTypes = [CHAT_TRIGGER_NODE_TYPE]; - const workflows = await this.workflowService.getWorkflowsWithNodesIncluded( - user, - nodeTypes, - true, - ); - - return { - models: workflows - // Ensure the user has at least read access to the workflow - .filter((workflow) => workflow.scopes.includes('workflow:read')) - .filter((workflow) => !!workflow.activeVersionId) - .flatMap((workflow) => { - const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); - if (!chatTrigger) { - return []; - } - - const chatTriggerParams = validChatTriggerParamsShape.safeParse( - chatTrigger.parameters, - ).data; - - if (!chatTriggerParams) { - return []; - } - - return [ - { - name: chatTriggerParams.agentName ?? workflow.name ?? 'Unknown Agent', - description: chatTriggerParams.agentDescription ?? null, - model: { - provider: 'n8n', - workflowId: workflow.id, - }, - createdAt: workflow.createdAt ? workflow.createdAt.toISOString() : null, - updatedAt: workflow.updatedAt ? workflow.updatedAt.toISOString() : null, - allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false, - }, - ]; - }), - }; - } - private async deleteChatWorkflow(workflowId: string): Promise { await this.workflowRepository.delete(workflowId); } @@ -991,7 +150,9 @@ export class ChatHubService { previousMessageId, tools, attachments, + timeZone, } = payload; + const tz = timeZone ?? this.globalConfig.generic.timezone; const credentialId = this.getModelCredential(model, credentials); @@ -1004,6 +165,7 @@ export class ChatHubService { let executionData: IRunExecutionData; let workflowData: IWorkflowBase; + let responseMode: ChatTriggerResponseMode; try { const result = await this.messageRepository.manager.transaction(async (trx) => { @@ -1041,12 +203,14 @@ export class ChatHubService { message, tools, processedAttachments, + tz, trx, ); }); executionData = result.executionData; workflowData = result.workflowData; + responseMode = result.responseMode; } catch (error) { if (processedAttachments.length > 0) { try { @@ -1068,21 +232,29 @@ export class ChatHubService { sessionId, messageId, model, + null, + responseMode, ); // Generate title for the session on receiving the first human message. // This could be moved on a separate API call perhaps, maybe triggered after the first message is sent? if (previousMessageId === null) { - await this.generateSessionTitle(user, sessionId, message, credentials, model).catch( - (error) => { - this.logger.error(`Title generation failed: ${error}`); - }, - ); + await this.generateSessionTitle( + user, + sessionId, + message, + processedAttachments, + credentials, + model, + ).catch((error) => { + this.logger.error(`Title generation failed: ${error}`); + }); } } async editMessage(res: Response, user: User, payload: EditMessagePayload) { - const { sessionId, editId, messageId, message, model, credentials } = payload; + const { sessionId, editId, messageId, message, model, credentials, timeZone } = payload; + const tz = timeZone ?? this.globalConfig.generic.timezone; const workflow = await this.messageRepository.manager.transaction(async (trx) => { const session = await this.getChatSession(user, sessionId, trx); @@ -1127,6 +299,7 @@ export class ChatHubService { message, session.tools, attachments, + tz, trx, ); } @@ -1138,7 +311,7 @@ export class ChatHubService { return; } - const { workflowData, executionData } = workflow; + const { workflowData, executionData, responseMode } = workflow; await this.executeChatWorkflowWithCleanup( res, @@ -1148,14 +321,17 @@ export class ChatHubService { sessionId, messageId, model, + null, + responseMode, ); } async regenerateAIMessage(res: Response, user: User, payload: RegenerateMessagePayload) { - const { sessionId, retryId, model, credentials } = payload; + const { sessionId, retryId, model, credentials, timeZone } = payload; + const tz = timeZone ?? this.globalConfig.generic.timezone; const { - workflow: { workflowData, executionData }, + workflow: { workflowData, executionData, responseMode }, retryOfMessageId, previousMessageId, } = await this.messageRepository.manager.transaction(async (trx) => { @@ -1199,6 +375,7 @@ export class ChatHubService { message, session.tools, attachments, + tz, trx, ); @@ -1218,6 +395,7 @@ export class ChatHubService { previousMessageId, model, retryOfMessageId, + responseMode, ); } @@ -1230,6 +408,7 @@ export class ChatHubService { message: string, tools: INode[], attachments: IBinaryData[], + timeZone: string, trx: EntityManager, ) { if (model.provider === 'n8n') { @@ -1250,6 +429,7 @@ export class ChatHubService { history, message, attachments, + timeZone, trx, ); } @@ -1264,6 +444,7 @@ export class ChatHubService { undefined, tools, attachments, + timeZone, trx, ); } @@ -1278,6 +459,7 @@ export class ChatHubService { systemMessage: string | undefined, tools: INode[], attachments: IBinaryData[], + timeZone: string, trx: EntityManager, ) { await this.chatHubSettingsService.ensureModelIsAllowed(model); @@ -1299,6 +481,7 @@ export class ChatHubService { model, systemMessage, tools, + timeZone, trx, ); } @@ -1310,6 +493,7 @@ export class ChatHubService { history: ChatHubMessage[], message: string, attachments: IBinaryData[], + timeZone: string, trx: EntityManager, ) { const agent = await this.chatHubAgentService.getAgentById(agentId, user.id); @@ -1322,16 +506,13 @@ export class ChatHubService { throw new BadRequestError('Provider or model not set for agent'); } - if (agent.provider === 'n8n' || agent.provider === 'custom-agent') { - throw new BadRequestError('Invalid provider'); - } - const credentialId = agent.credentialId; if (!credentialId) { throw new BadRequestError('Credentials not set for agent'); } - const systemMessage = agent.systemPrompt; + const systemMessage = + agent.systemPrompt + '\n\n' + this.chatHubWorkflowService.getSystemMessageMetadata(timeZone); const model: ChatHubBaseLLMModel = { provider: agent.provider, @@ -1345,7 +526,7 @@ export class ChatHubService { }, }; - const tools: INode[] = []; + const { tools } = agent; return await this.prepareBaseChatWorkflow( user, @@ -1357,6 +538,7 @@ export class ChatHubService { systemMessage, tools, attachments, + timeZone, trx, ); } @@ -1368,18 +550,18 @@ export class ChatHubService { message: string, attachments: IBinaryData[], ) { - const workflowEntity = await this.workflowFinderService.findWorkflowForUser( + const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, user, - ['workflow:read'], - { includeTags: false, includeParentFolder: false }, + ['workflow:execute-chat'], + { includeTags: false, includeParentFolder: false, includeActiveVersion: true }, ); - if (!workflowEntity) { + if (!workflow?.activeVersion) { throw new BadRequestError('Workflow not found'); } - const chatTriggers = workflowEntity.nodes.filter( + const chatTriggers = workflow.activeVersion.nodes.filter( (node) => node.type === CHAT_TRIGGER_NODE_TYPE, ); @@ -1387,9 +569,31 @@ export class ChatHubService { throw new BadRequestError('Workflow must have exactly one chat trigger'); } - const chatTriggerNode = chatTriggers[0]; + const chatTrigger = chatTriggers[0]; - const chatResponseNodes = workflowEntity.nodes.filter( + if (chatTrigger.typeVersion < 1.4) { + throw new BadRequestError( + 'Chat Trigger node version is too old to support Chat. Please update the node.', + ); + } + + const chatTriggerParams = chatTriggerParamsShape.safeParse(chatTrigger.parameters).data; + if (!chatTriggerParams) { + throw new BadRequestError('Chat Trigger node has invalid parameters'); + } + + if (!chatTriggerParams.availableInChat) { + throw new BadRequestError('Chat Trigger node must be made available in Chat'); + } + + const responseMode = chatTriggerParams.options?.responseMode ?? 'streaming'; + if (responseMode !== 'streaming') { + throw new BadRequestError( + 'Chat Trigger node response mode must be set to streaming to use the workflow on Chat', + ); + } + + const chatResponseNodes = workflow.activeVersion.nodes.filter( (node) => node.type === RESPOND_TO_CHAT_NODE_TYPE, ); @@ -1399,8 +603,19 @@ export class ChatHubService { ); } + const agentNodes = workflow.activeVersion.nodes?.filter( + (node) => node.type === AGENT_LANGCHAIN_NODE_TYPE, + ); + + // Agents older than this can't do streaming + if (agentNodes.some((node) => node.typeVersion < TOOLS_AGENT_NODE_MIN_VERSION)) { + throw new BadRequestError( + 'Agent node version is too old to support streaming responses. Please update the node.', + ); + } + const nodeExecutionStack = this.chatHubWorkflowService.prepareExecutionData( - chatTriggerNode, + chatTrigger, sessionId, message, attachments, @@ -1415,11 +630,16 @@ export class ChatHubService { }, }); + const workflowData: IWorkflowBase = { + ...workflow, + nodes: workflow.activeVersion.nodes, + connections: workflow.activeVersion.connections, + }; + return { - workflowData: { - ...workflowEntity, - }, + workflowData, executionData, + responseMode, }; } @@ -1480,11 +700,16 @@ export class ChatHubService { model: ChatHubConversationModel, retryOfMessageId: ChatMessageId | null = null, executionMode: WorkflowExecuteMode = 'chat', + responseMode: ChatTriggerResponseMode, ) { this.logger.debug( `Starting execution of workflow "${workflowData.name}" with ID ${workflowData.id}`, ); + if (responseMode !== 'streaming') { + throw new BadRequestError(`Response mode "${responseMode}" is not supported yet.`); + } + // Capture the streaming response as it's being generated to save // partial messages in the database when generation gets cancelled. let executionId: string | undefined = undefined; @@ -1566,7 +791,7 @@ export class ChatHubService { }, }; - return JSON.stringify(enriched) + '\n'; + return jsonStringify(enriched) + '\n'; }; const stream = interceptResponseWrites(res, transform); @@ -1592,6 +817,57 @@ export class ChatHubService { throw new OperationalError('There was a problem starting the chat execution.'); } + await this.waitForExecutionCompletion(executionId); + } + + private async waitForExecutionCompletion(executionId: string): Promise { + if (this.instanceSettings.isMultiMain) { + return await this.waitForExecutionPoller(executionId); + } else { + return await this.waitForExecutionPromise(executionId); + } + } + + private async waitForExecutionPoller(executionId: string): Promise { + return await new Promise((resolve, reject) => { + const poller = setInterval(async () => { + try { + const result = await this.executionRepository.findSingleExecution(executionId, { + includeData: false, + unflattenData: false, + }); + + // Stop polling when execution is done (or missing if instance doesn't save executions) + if (!result || EXECUTION_FINISHED_STATUSES.includes(result.status)) { + this.logger.debug( + `Execution ${executionId} finished with status ${result?.status ?? 'missing'}`, + ); + clearInterval(poller); + resolve(); + } + } catch (error) { + this.logger.error(`Stopping polling for execution ${executionId} due to error.`); + clearInterval(poller); + + if (error instanceof Error) { + this.logger.error(`Error while polling execution ${executionId}: ${error.message}`, { + error, + }); + } else { + this.logger.error(`Unknown error while polling execution ${executionId}`, { error }); + } + + if (error instanceof Error) { + reject(error); + } else { + reject(new Error('Unknown error while polling execution status')); + } + } + }, EXECUTION_POLL_INTERVAL); + }); + } + + private async waitForExecutionPromise(executionId: string): Promise { try { // Wait until the execution finishes (or errors) so that we don't delete the workflow too early const result = await this.activeExecutions.getPostExecutePromise(executionId); @@ -1618,7 +894,8 @@ export class ChatHubService { sessionId: ChatSessionId, previousMessageId: ChatMessageId, model: ChatHubConversationModel, - retryOfMessageId: ChatMessageId | null = null, + retryOfMessageId: ChatMessageId | null, + responseMode: ChatTriggerResponseMode, ) { try { // 'n8n' provider executions count towards execution limits and they are run with the usual 'webhook' mode. @@ -1635,6 +912,7 @@ export class ChatHubService { model, retryOfMessageId, executionMode, + responseMode, ); } finally { if (model.provider !== 'n8n') { @@ -1647,6 +925,7 @@ export class ChatHubService { user: User, sessionId: ChatSessionId, humanMessage: string, + attachments: IBinaryData[], credentials: INodeCredentials, model: ChatHubConversationModel, ) { @@ -1654,6 +933,7 @@ export class ChatHubService { user, sessionId, humanMessage, + attachments, credentials, model, ); @@ -1677,6 +957,7 @@ export class ChatHubService { user: User, sessionId: ChatSessionId, humanMessage: string, + attachments: IBinaryData[], incomingCredentials: INodeCredentials, incomingModel: ChatHubConversationModel, ) { @@ -1694,7 +975,7 @@ export class ChatHubService { } this.logger.debug( - `Using credential ID ${credential.id} for title generation in project ${credential.projectId}, model ${JSON.stringify(resolvedModel)}`, + `Using credential ID ${credential.id} for title generation in project ${credential.projectId}, model ${jsonStringify(resolvedModel)}`, ); return await this.chatHubWorkflowService.createTitleGenerationWorkflow( @@ -1702,6 +983,7 @@ export class ChatHubService { sessionId, credential.projectId, humanMessage, + attachments, resolvedCredentials, resolvedModel, trx, @@ -1753,15 +1035,15 @@ export class ChatHubService { const workflowEntity = await this.workflowFinderService.findWorkflowForUser( workflowId, user, - ['workflow:read'], - { includeTags: false, includeParentFolder: false, em: trx }, + ['workflow:execute-chat'], + { includeTags: false, includeParentFolder: false, includeActiveVersion: true, em: trx }, ); - if (!workflowEntity) { + if (!workflowEntity?.activeVersion) { throw new BadRequestError('Workflow not found for title generation'); } - const modelNodes = this.findSupportedLLMNodes(workflowEntity); + const modelNodes = this.findSupportedLLMNodes(workflowEntity.activeVersion.nodes); this.logger.debug( `Found ${modelNodes.length} LLM nodes in workflow ${workflowEntity.id} for title generation`, ); @@ -1812,20 +1094,17 @@ export class ChatHubService { return { resolvedCredentials, resolvedModel, credential }; } - private findSupportedLLMNodes(workflowEntity: { nodes: INode[]; id: string }) { - return workflowEntity.nodes.reduce>( - (acc, node) => { - const supportedProvider = Object.entries(PROVIDER_NODE_TYPE_MAP).find( - ([_provider, { name }]) => node.type === name, - ); - if (supportedProvider) { - const [provider] = supportedProvider; - acc.push({ node, provider: provider as ChatHubLLMProvider }); - } - return acc; - }, - [], - ); + private findSupportedLLMNodes(nodes: INode[]) { + return nodes.reduce>((acc, node) => { + const supportedProvider = Object.entries(PROVIDER_NODE_TYPE_MAP).find( + ([_provider, { name }]) => node.type === name, + ); + if (supportedProvider) { + const [provider] = supportedProvider; + acc.push({ node, provider: provider as ChatHubLLMProvider }); + } + return acc; + }, []); } private async resolveFromCustomAgent( @@ -1842,10 +1121,6 @@ export class ChatHubService { throw new BadRequestError('Agent not found for title generation'); } - if (agent.provider === 'n8n' || agent.provider === 'custom-agent') { - throw new BadRequestError('Invalid provider for title generation'); - } - const credentialId = agent.credentialId; if (!credentialId) { throw new BadRequestError('Credentials not set for agent'); @@ -1878,7 +1153,7 @@ export class ChatHubService { workflowData: IWorkflowBase, executionData: IRunExecutionData, ): Promise { - const started = await this.workflowExecutionService.executeChatWorkflow( + const { executionId } = await this.workflowExecutionService.executeChatWorkflow( workflowData, executionData, user, @@ -1887,23 +1162,7 @@ export class ChatHubService { 'chat', ); - const executionId = started.executionId; - if (!executionId) { - throw new OperationalError('There was a problem starting the chat execution.'); - } - - let run: IRun | undefined; - try { - run = await this.activeExecutions.getPostExecutePromise(executionId); - if (!run) { - throw new OperationalError('There was a problem executing the chat workflow.'); - } - } catch (error: unknown) { - if (error instanceof ManualExecutionCancelledError) { - return null; - } - throw error; - } + await this.waitForExecutionCompletion(executionId); const execution = await this.executionRepository.findWithUnflattenedData(executionId, [ workflowData.id, @@ -1999,7 +1258,7 @@ export class ChatHubService { model: ChatHubConversationModel, credentialId: string | null, tools: INode[], - agentName: string, + agentName?: string, trx?: EntityManager, ) { await this.ensureValidModel(user, model); @@ -2209,6 +1468,7 @@ export class ChatHubService { if (updates.title !== undefined) sessionUpdates.title = updates.title; if (updates.credentialId !== undefined) sessionUpdates.credentialId = updates.credentialId; + if (updates.tools !== undefined) sessionUpdates.tools = updates.tools; return await this.sessionRepository.updateChatSession(sessionId, sessionUpdates); } @@ -2238,18 +1498,21 @@ export class ChatHubService { if (model.provider === 'n8n') { // Find the workflow to get its name - const workflow = await this.workflowFinderService.findWorkflowForUser( + const workflowEntity = await this.workflowFinderService.findWorkflowForUser( model.workflowId, user, - ['workflow:read'], - { includeTags: false, includeParentFolder: false }, + ['workflow:execute-chat'], + { includeTags: false, includeParentFolder: false, includeActiveVersion: true }, ); - if (!workflow) { + if (!workflowEntity?.activeVersion) { throw new BadRequestError('Workflow not found for chat session initialization'); } - const chatTrigger = workflow.nodes?.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE); + const chatTrigger = workflowEntity.activeVersion.nodes?.find( + (node) => node.type === CHAT_TRIGGER_NODE_TYPE, + ); + if (!chatTrigger) { throw new BadRequestError( 'Chat trigger not found in workflow for chat session initialization', diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index 4e13bf93a3c..f40b0ff9ec9 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -22,6 +22,7 @@ export interface BaseMessagePayload { sessionId: ChatSessionId; model: ChatHubConversationModel; credentials: INodeCredentials; + timeZone?: string; } export interface HumanMessagePayload extends BaseMessagePayload { @@ -30,7 +31,7 @@ export interface HumanMessagePayload extends BaseMessagePayload { previousMessageId: ChatMessageId | null; attachments: ChatAttachment[]; tools: INode[]; - agentName: string; + agentName?: string; } export interface RegenerateMessagePayload extends BaseMessagePayload { retryId: ChatMessageId; @@ -42,21 +43,36 @@ export interface EditMessagePayload extends BaseMessagePayload { message: string; } +// From @langchain/core +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: string }; + // From packages/@n8n/nodes-langchain/nodes/memory/MemoryManager/MemoryManager.node.ts export type MessageRole = 'ai' | 'system' | 'user'; export interface MessageRecord { type: MessageRole; - message: string; + message: string | ContentBlock[]; hideFromUI: boolean; } -export const validChatTriggerParamsShape = z.object({ - availableInChat: z.literal(true), +const ChatTriggerResponseModeSchema = z.enum([ + 'streaming', + 'lastNode', + 'responseNode', + 'responseNodes', +]); +export type ChatTriggerResponseMode = z.infer; + +export const chatTriggerParamsShape = z.object({ + availableInChat: z.boolean().optional().default(false), agentName: z.string().min(1).optional(), agentDescription: z.string().min(1).optional(), options: z .object({ allowFileUploads: z.boolean().optional(), + allowedFilesMimeTypes: z.string().optional(), + responseMode: ChatTriggerResponseModeSchema.optional(), }) .optional(), }); diff --git a/packages/cli/src/modules/chat-hub/context-limits.ts b/packages/cli/src/modules/chat-hub/context-limits.ts index 3a8dd3e8536..b428bb64517 100644 --- a/packages/cli/src/modules/chat-hub/context-limits.ts +++ b/packages/cli/src/modules/chat-hub/context-limits.ts @@ -60,6 +60,11 @@ export const maxContextWindowTokens: Record { + mockDataTableSizeValidator(); + + dataTableColumnRepository = Container.get(DataTableColumnRepository); + + owner = await createOwner(); + member = await createMember(); + admin = await createAdmin(); + + authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); + authAdminAgent = testServer.authAgentFor(admin); + + ownerProject = await getPersonalProject(owner); + memberProject = await getPersonalProject(member); +}); + +beforeEach(async () => { + await testDb.truncate(['DataTable', 'DataTableColumn']); +}); + +describe('PATCH /projects/:projectId/data-tables/:dataTableId/columns/:columnId/rename', () => { + test('should not rename column when project does not exist', async () => { + const payload = { + name: 'new_column_name', + }; + + await authOwnerAgent + .patch( + '/projects/non-existing-id/data-tables/some-data-table-id/columns/some-column-id/rename', + ) + .send(payload) + .expect(404); + }); + + test('should not rename column when data table does not exist', async () => { + const project = await createTeamProject('test project', owner); + const payload = { + name: 'new_column_name', + }; + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/non-existing-data-table/columns/some-column-id/rename`, + ) + .send(payload) + .expect(404); + }); + + test('should not rename column when column does not exist', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + const payload = { + name: 'new_column_name', + }; + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/non-existing-column-id/rename`, + ) + .send(payload) + .expect(404); + }); + + test("should not rename column in another user's personal project data table", async () => { + const dataTable = await createDataTable(ownerProject, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authMemberAgent + .patch( + `/projects/${ownerProject.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'new_name' }) + .expect(403); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('test_column'); + }); + + test('should not rename column if user has project:viewer role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authMemberAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'new_name' }) + .expect(403); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('test_column'); + }); + + test('should rename column if user has project:editor role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:editor'); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authMemberAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'renamed_column' }) + .expect(200); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('renamed_column'); + }); + + test('should rename column if user has project:admin role in team project', async () => { + const project = await createTeamProject('test project', owner); + await linkUserToProject(admin, project, 'project:admin'); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authAdminAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'renamed_column' }) + .expect(200); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('renamed_column'); + }); + + test('should rename column if user is owner in team project', async () => { + const project = await createTeamProject('test project', owner); + + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'renamed_column' }) + .expect(200); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('renamed_column'); + }); + + test('should rename column in personal project', async () => { + const dataTable = await createDataTable(memberProject, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authMemberAgent + .patch( + `/projects/${memberProject.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'renamed_column' }) + .expect(200); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('renamed_column'); + }); + + test('should not rename column to an existing column name', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'first_column', + type: 'string', + }, + { + name: 'second_column', + type: 'string', + }, + ], + }); + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'second_column' }) + .expect(409); + + const firstColumnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(firstColumnInDb?.name).toBe('first_column'); + }); + + test('should not rename column with invalid column name', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'invalid name with spaces' }) + .expect(400); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('test_column'); + }); + + test('should not rename column with empty name', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'test_column', + type: 'string', + }, + ], + }); + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: '' }) + .expect(400); + + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('test_column'); + }); + + test('should rename column successfully', async () => { + const project = await createTeamProject('test project', owner); + const dataTable = await createDataTable(project, { + columns: [ + { + name: 'original_name', + type: 'string', + }, + ], + }); + + await authOwnerAgent + .patch( + `/projects/${project.id}/data-tables/${dataTable.id}/columns/${dataTable.columns[0].id}/rename`, + ) + .send({ name: 'updated_name' }) + .expect(200); + + // Verify column name changed + const columnInDb = await dataTableColumnRepository.findOneBy({ + id: dataTable.columns[0].id, + }); + expect(columnInDb?.name).toBe('updated_name'); + }); +}); diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-column.repository.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-column.repository.test.ts new file mode 100644 index 00000000000..bc9dd93914f --- /dev/null +++ b/packages/cli/src/modules/data-table/__tests__/data-table-column.repository.test.ts @@ -0,0 +1,250 @@ +import { testModules } from '@n8n/backend-test-utils'; +import type { DataSource, EntityManager } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; + +import { DataTableColumn } from '../data-table-column.entity'; +import { DataTableColumnRepository } from '../data-table-column.repository'; +import type { DataTableDDLService } from '../data-table-ddl.service'; +import { DataTable } from '../data-table.entity'; +import { DataTableColumnNameConflictError } from '../errors/data-table-column-name-conflict.error'; +import { DataTableSystemColumnNameConflictError } from '../errors/data-table-system-column-name-conflict.error'; + +describe('DataTableColumnRepository', () => { + let repository: DataTableColumnRepository; + let mockDataSource: DataSource; + let mockDDLService: jest.Mocked; + let mockEntityManager: jest.Mocked; + + beforeAll(async () => { + await testModules.loadModules(['data-table']); + }); + + beforeEach(() => { + mockDDLService = mock(); + mockEntityManager = mock({ + connection: { + options: { type: 'postgres' }, + } as any, + }); + + // Mock the transaction method to execute the callback immediately + (mockEntityManager.transaction as jest.Mock) = jest.fn( + async (callback: (em: EntityManager) => Promise) => { + return await callback(mockEntityManager); + }, + ); + + mockDataSource = mock({ + manager: mockEntityManager, + }); + + repository = new DataTableColumnRepository(mockDataSource, mockDDLService); + }); + + describe('renameColumn', () => { + const dataTableId = 'test-table-id'; + const mockColumn: DataTableColumn = { + id: 'column-id', + name: 'old_name', + type: 'string', + index: 0, + dataTableId, + } as DataTableColumn; + + describe('validateUniqueColumnName', () => { + it('should throw DataTableColumnNameConflictError when column name already exists', async () => { + // Arrange + const newName = 'duplicate_name'; + const dataTable = { id: dataTableId, name: 'Test Table' } as DataTable; + + mockEntityManager.existsBy.mockResolvedValue(true); + mockEntityManager.findOneBy.mockResolvedValue(dataTable); + + // Act & Assert + await expect(repository.renameColumn(dataTableId, mockColumn, newName)).rejects.toThrow( + DataTableColumnNameConflictError, + ); + + await expect(repository.renameColumn(dataTableId, mockColumn, newName)).rejects.toThrow( + `Data table column with name '${newName}' already exists in data table '${dataTable.name}'`, + ); + + expect(mockEntityManager.existsBy).toHaveBeenCalledWith(DataTableColumn, { + name: newName, + dataTableId, + }); + expect(mockEntityManager.findOneBy).toHaveBeenCalledWith(DataTable, { id: dataTableId }); + }); + + it('should not throw when column name is unique', async () => { + // Arrange + const newName = 'unique_name'; + + mockEntityManager.existsBy.mockResolvedValue(false); + mockEntityManager.update.mockResolvedValue({ affected: 1 } as any); + Object.defineProperty(mockEntityManager, 'connection', { + value: { + options: { type: 'postgres' }, + }, + configurable: true, + }); + mockDDLService.renameColumn.mockResolvedValue(undefined); + + // Act + const result = await repository.renameColumn(dataTableId, mockColumn, newName); + + // Assert + expect(mockEntityManager.existsBy).toHaveBeenCalledWith(DataTableColumn, { + name: newName, + dataTableId, + }); + expect(result.name).toBe(newName); + }); + }); + + describe('validateNotSystemColumn', () => { + it('should throw DataTableSystemColumnNameConflictError for system column names', async () => { + // Arrange - system columns: id, createdAt, updatedAt + const systemColumnNames = ['id', 'createdAt', 'updatedAt']; + + for (const systemColumnName of systemColumnNames) { + mockEntityManager.existsBy.mockResolvedValue(false); + + // Act & Assert + await expect( + repository.renameColumn(dataTableId, mockColumn, systemColumnName), + ).rejects.toThrow(DataTableSystemColumnNameConflictError); + + await expect( + repository.renameColumn(dataTableId, mockColumn, systemColumnName), + ).rejects.toThrow( + `Column name "${systemColumnName}" is reserved as a system column name.`, + ); + } + }); + + it('should throw DataTableSystemColumnNameConflictError for testing column name', async () => { + // Arrange + const testingColumnName = 'dryRunState'; + + mockEntityManager.existsBy.mockResolvedValue(false); + + // Act & Assert + await expect( + repository.renameColumn(dataTableId, mockColumn, testingColumnName), + ).rejects.toThrow(DataTableSystemColumnNameConflictError); + + await expect( + repository.renameColumn(dataTableId, mockColumn, testingColumnName), + ).rejects.toThrow( + `Column name "${testingColumnName}" is reserved as a testing column name.`, + ); + }); + }); + + describe('successful rename', () => { + it('should successfully rename column when all validations pass', async () => { + // Arrange + const newName = 'new_valid_name'; + + mockEntityManager.existsBy.mockResolvedValue(false); + mockEntityManager.update.mockResolvedValue({ affected: 1 } as any); + Object.defineProperty(mockEntityManager, 'connection', { + value: { + options: { type: 'postgres' }, + }, + configurable: true, + }); + mockDDLService.renameColumn.mockResolvedValue(undefined); + + // Act + const result = await repository.renameColumn(dataTableId, mockColumn, newName); + + // Assert + expect(result).toEqual({ + ...mockColumn, + name: newName, + }); + expect(mockEntityManager.update).toHaveBeenCalledWith( + DataTableColumn, + { id: mockColumn.id }, + { name: newName }, + ); + expect(mockDDLService.renameColumn).toHaveBeenCalledWith( + dataTableId, + mockColumn.name, + newName, + 'postgres', + mockEntityManager, + ); + }); + + it('should call DDL service with correct database type', async () => { + // Arrange + const newName = 'new_valid_name'; + const dbTypes = ['postgres', 'mysql', 'sqlite'] as const; + + for (const dbType of dbTypes) { + mockEntityManager.existsBy.mockResolvedValue(false); + mockEntityManager.update.mockResolvedValue({ affected: 1 } as any); + Object.defineProperty(mockEntityManager, 'connection', { + value: { + options: { type: dbType }, + }, + configurable: true, + }); + mockDDLService.renameColumn.mockResolvedValue(undefined); + + // Act + await repository.renameColumn(dataTableId, mockColumn, newName); + + // Assert + expect(mockDDLService.renameColumn).toHaveBeenCalledWith( + dataTableId, + mockColumn.name, + newName, + dbType, + mockEntityManager, + ); + } + }); + }); + + describe('validation order', () => { + it('should validate system column name before checking uniqueness', async () => { + // Arrange + const systemColumnName = 'id'; + + mockEntityManager.existsBy.mockResolvedValue(false); + + // Act & Assert + await expect( + repository.renameColumn(dataTableId, mockColumn, systemColumnName), + ).rejects.toThrow(DataTableSystemColumnNameConflictError); + + // existsBy should not be called because system column validation happens first + expect(mockEntityManager.existsBy).not.toHaveBeenCalled(); + }); + + it('should check uniqueness after system column validation passes', async () => { + // Arrange + const newName = 'valid_name'; + const dataTable = { id: dataTableId, name: 'Test Table' } as DataTable; + + mockEntityManager.existsBy.mockResolvedValue(true); + mockEntityManager.findOneBy.mockResolvedValue(dataTable); + + // Act & Assert + await expect(repository.renameColumn(dataTableId, mockColumn, newName)).rejects.toThrow( + DataTableColumnNameConflictError, + ); + + // Both validations should have been called in order + expect(mockEntityManager.existsBy).toHaveBeenCalledWith(DataTableColumn, { + name: newName, + dataTableId, + }); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-ddl.service.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-ddl.service.test.ts new file mode 100644 index 00000000000..29eff14c3ee --- /dev/null +++ b/packages/cli/src/modules/data-table/__tests__/data-table-ddl.service.test.ts @@ -0,0 +1,384 @@ +import { testModules } from '@n8n/backend-test-utils'; +import type { DataSource, DataSourceOptions, EntityManager } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; + +import { DataTableDDLService } from '../data-table-ddl.service'; +import * as sqlUtils from '../utils/sql-utils'; + +// Mock the sql-utils module +jest.mock('../utils/sql-utils', () => ({ + ...jest.requireActual('../utils/sql-utils'), + renameColumnQuery: jest.fn(), + toTableName: jest.fn(), +})); + +describe('DataTableDDLService', () => { + let ddlService: DataTableDDLService; + let mockDataSource: DataSource; + let mockEntityManager: jest.Mocked; + + beforeAll(async () => { + await testModules.loadModules(['data-table']); + }); + + beforeEach(() => { + mockEntityManager = mock({ + connection: { + options: { type: 'postgres' }, + } as any, + }); + + // Mock the transaction method to execute the callback immediately + (mockEntityManager.transaction as jest.Mock) = jest.fn( + async (callback: (em: EntityManager) => Promise) => { + return await callback(mockEntityManager); + }, + ); + + // Mock the query method + mockEntityManager.query = jest.fn().mockResolvedValue(undefined); + + mockDataSource = mock({ + manager: mockEntityManager, + }); + + ddlService = new DataTableDDLService(mockDataSource); + + // Reset all mocks + jest.clearAllMocks(); + }); + + describe('renameColumn', () => { + const dataTableId = 'test-table-id'; + const oldColumnName = 'old_column'; + const newColumnName = 'new_column'; + const tableName = 'n8n_data_table_user_test-table-id'; + + beforeEach(() => { + (sqlUtils.toTableName as jest.Mock).mockReturnValue(tableName); + }); + + describe('successful rename', () => { + it('should execute rename column query for PostgreSQL', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"'; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.toTableName).toHaveBeenCalledWith(dataTableId); + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldColumnName, + newColumnName, + dbType, + ); + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + + it('should execute rename column query for MySQL', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'mysql'; + const expectedQuery = + 'ALTER TABLE `n8n_data_table_user_test-table-id` RENAME COLUMN `old_column` TO `new_column`'; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldColumnName, + newColumnName, + dbType, + ); + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + + it('should execute rename column query for SQLite', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'sqlite'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"'; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldColumnName, + newColumnName, + dbType, + ); + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + + it('should call methods in correct order', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"'; + const callOrder: string[] = []; + + (sqlUtils.toTableName as jest.Mock).mockImplementation(() => { + callOrder.push('toTableName'); + return tableName; + }); + + (sqlUtils.renameColumnQuery as jest.Mock).mockImplementation(() => { + callOrder.push('renameColumnQuery'); + return expectedQuery; + }); + + mockEntityManager.query = jest.fn().mockImplementation(async () => { + callOrder.push('query'); + return undefined; + }); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(callOrder).toEqual(['toTableName', 'renameColumnQuery', 'query']); + }); + }); + + describe('with transaction parameter', () => { + it('should use provided transaction manager', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"'; + const customTrx = mock(); + + customTrx.query = jest.fn().mockResolvedValue(undefined) as any; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType, customTrx); + + // Assert + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldColumnName, + newColumnName, + dbType, + ); + expect(customTrx.query).toHaveBeenCalledWith(expectedQuery); + }); + + it('should execute within transaction when no transaction manager is provided', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"'; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(mockEntityManager.transaction).toHaveBeenCalled(); + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + }); + + describe('error handling', () => { + it('should propagate errors from query execution', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"'; + const queryError = new Error('Database query failed'); + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + mockEntityManager.query = jest.fn().mockRejectedValue(queryError); + + // Act & Assert + await expect( + ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType), + ).rejects.toThrow(queryError); + + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + + it('should propagate errors from renameColumnQuery', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const queryError = new Error('Invalid column name'); + + (sqlUtils.renameColumnQuery as jest.Mock).mockImplementation(() => { + throw queryError; + }); + + // Act & Assert + await expect( + ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType), + ).rejects.toThrow(queryError); + + expect(sqlUtils.renameColumnQuery).toHaveBeenCalled(); + expect(mockEntityManager.query).not.toHaveBeenCalled(); + }); + }); + + describe('parameter handling', () => { + it('should handle special characters in column names', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const oldNameWithSpecialChars = 'old_column_2024'; + const newNameWithSpecialChars = 'new_column_v2'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column_2024" TO "new_column_v2"'; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn( + dataTableId, + oldNameWithSpecialChars, + newNameWithSpecialChars, + dbType, + ); + + // Assert + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldNameWithSpecialChars, + newNameWithSpecialChars, + dbType, + ); + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + + it('should handle different data table IDs', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const differentTableId = 'different-table-id'; + const differentTableName = 'n8n_data_table_user_different-table-id'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_different-table-id" RENAME COLUMN "old_column" TO "new_column"'; + + (sqlUtils.toTableName as jest.Mock).mockReturnValue(differentTableName); + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(differentTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.toTableName).toHaveBeenCalledWith(differentTableId); + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + differentTableName, + oldColumnName, + newColumnName, + dbType, + ); + }); + }); + + describe('database type specific behavior', () => { + const testCases: Array<{ + dbType: DataSourceOptions['type']; + expectedQuery: string; + }> = [ + { + dbType: 'postgres', + expectedQuery: + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"', + }, + { + dbType: 'mysql', + expectedQuery: + 'ALTER TABLE `n8n_data_table_user_test-table-id` RENAME COLUMN `old_column` TO `new_column`', + }, + { + dbType: 'mariadb', + expectedQuery: + 'ALTER TABLE `n8n_data_table_user_test-table-id` RENAME COLUMN `old_column` TO `new_column`', + }, + { + dbType: 'sqlite', + expectedQuery: + 'ALTER TABLE "n8n_data_table_user_test-table-id" RENAME COLUMN "old_column" TO "new_column"', + }, + ]; + + testCases.forEach(({ dbType, expectedQuery }) => { + it(`should generate correct query for ${dbType}`, async () => { + // Arrange + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldColumnName, + newColumnName, + dbType, + ); + expect(mockEntityManager.query).toHaveBeenCalledWith(expectedQuery); + }); + }); + }); + + describe('integration with utilities', () => { + it('should properly convert dataTableId to table name', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'postgres'; + const customTableId = 'custom-uuid-1234'; + const expectedTableName = 'n8n_data_table_user_custom-uuid-1234'; + const expectedQuery = + 'ALTER TABLE "n8n_data_table_user_custom-uuid-1234" RENAME COLUMN "old_column" TO "new_column"'; + + (sqlUtils.toTableName as jest.Mock).mockReturnValue(expectedTableName); + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(customTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.toTableName).toHaveBeenCalledTimes(1); + expect(sqlUtils.toTableName).toHaveBeenCalledWith(customTableId); + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + expectedTableName, + oldColumnName, + newColumnName, + dbType, + ); + }); + + it('should pass all parameters to renameColumnQuery utility', async () => { + // Arrange + const dbType: DataSourceOptions['type'] = 'mysql'; + const expectedQuery = 'ALTER TABLE query'; + + (sqlUtils.renameColumnQuery as jest.Mock).mockReturnValue(expectedQuery); + + // Act + await ddlService.renameColumn(dataTableId, oldColumnName, newColumnName, dbType); + + // Assert + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledWith( + tableName, + oldColumnName, + newColumnName, + dbType, + ); + expect(sqlUtils.renameColumnQuery).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-touch-updated-at.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-touch-updated-at.integration.test.ts new file mode 100644 index 00000000000..709c1c5df29 --- /dev/null +++ b/packages/cli/src/modules/data-table/__tests__/data-table-touch-updated-at.integration.test.ts @@ -0,0 +1,408 @@ +import type { AddDataTableColumnDto } from '@n8n/api-types'; +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import type { Project } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { DataTableRepository } from '../data-table.repository'; +import { DataTableService } from '../data-table.service'; +import { mockDataTableSizeValidator } from './test-helpers'; + +beforeAll(async () => { + await testModules.loadModules(['data-table']); + await testDb.init(); + mockDataTableSizeValidator(); +}); + +beforeEach(async () => { + await testDb.truncate(['DataTable', 'DataTableColumn']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('DataTable touchUpdatedAt', () => { + let dataTableService: DataTableService; + let dataTableRepository: DataTableRepository; + let project: Project; + + beforeAll(() => { + dataTableService = Container.get(DataTableService); + dataTableRepository = Container.get(DataTableRepository); + }); + + beforeEach(async () => { + project = await createTeamProject(); + }); + + afterEach(async () => { + await dataTableService.deleteDataTableAll(); + }); + + describe('Row operations', () => { + it('should update updatedAt timestamp when inserting rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + // Get initial updatedAt + const initialDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + const initialUpdatedAt = initialDataTable!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Insert rows + await dataTableService.insertRows(dataTable.id, project.id, [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ]); + + // Check that updatedAt was updated + const updatedDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(updatedDataTable!.updatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when upserting rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + }); + + // Insert initial row + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', name: 'Alice' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Upsert (update) + await dataTableService.upsertRow( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { name: 'Alice Updated' }, + }, + false, + false, + ); + + // Check that updatedAt was updated + const afterUpsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterUpsert!.updatedAt.getTime()).toBeGreaterThan(afterInsertUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when updating rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'status', type: 'string' }, + ], + }); + + // Insert initial row + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', status: 'active' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update rows + await dataTableService.updateRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { status: 'inactive' }, + }, + false, + false, + ); + + // Check that updatedAt was updated + const afterUpdate = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterUpdate!.updatedAt.getTime()).toBeGreaterThan(afterInsertUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when deleting rows', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + }); + + // Insert rows + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test1@example.com', name: 'User 1' }, + { email: 'test2@example.com', name: 'User 2' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Delete rows + await dataTableService.deleteRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test1@example.com' }], + }, + }, + false, + false, + ); + + // Check that updatedAt was updated + const afterDelete = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterDelete!.updatedAt.getTime()).toBeGreaterThan(afterInsertUpdatedAt.getTime()); + }); + }); + + describe('Column operations', () => { + it('should update updatedAt timestamp when adding a column', async () => { + // Create a data table + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [{ name: 'name', type: 'string' }], + }); + + // Get initial updatedAt + const initialDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + const initialUpdatedAt = initialDataTable!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Add a column + const addColumnDto: AddDataTableColumnDto = { + name: 'age', + type: 'number', + }; + await dataTableService.addColumn(dataTable.id, project.id, addColumnDto); + + // Check that updatedAt was updated + const updatedDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(updatedDataTable!.updatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + + it('should update updatedAt timestamp when deleting a column', async () => { + // Create a data table with multiple columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + // Get columns + const columns = await dataTableService.getColumns(dataTable.id, project.id); + const ageColumn = columns.find((c) => c.name === 'age'); + + // Get initial updatedAt + const initialDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + const initialUpdatedAt = initialDataTable!.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Delete a column + await dataTableService.deleteColumn(dataTable.id, project.id, ageColumn!.id); + + // Check that updatedAt was updated + const updatedDataTable = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(updatedDataTable!.updatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + }); + }); + + describe('Error handling', () => { + it('should not fail row operations even if touchUpdatedAt is called with invalid ID', async () => { + // Create a data table + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [{ name: 'name', type: 'string' }], + }); + + // Insert initial data + await dataTableService.insertRows(dataTable.id, project.id, [{ name: 'Initial' }]); + + // Mock touchUpdatedAt to simulate an error scenario + const touchSpy = jest.spyOn(dataTableRepository, 'touchUpdatedAt'); + + // Insert rows - operation should succeed + const result = await dataTableService.insertRows(dataTable.id, project.id, [ + { name: 'Alice' }, + ]); + expect(result).toBeDefined(); + + // Verify touchUpdatedAt was called + expect(touchSpy).toHaveBeenCalledWith(dataTable.id); + + touchSpy.mockRestore(); + }); + + it('should not fail column operations even if touchUpdatedAt is called', async () => { + // Create a data table + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [{ name: 'name', type: 'string' }], + }); + + // Mock touchUpdatedAt to verify it's called + const touchSpy = jest.spyOn(dataTableRepository, 'touchUpdatedAt'); + + // Add column - operation should succeed + const addColumnDto: AddDataTableColumnDto = { + name: 'age', + type: 'number', + }; + const result = await dataTableService.addColumn(dataTable.id, project.id, addColumnDto); + expect(result).toBeDefined(); + + // Verify touchUpdatedAt was called + expect(touchSpy).toHaveBeenCalledWith(dataTable.id); + + touchSpy.mockRestore(); + }); + + it('should handle errors gracefully in touchUpdatedAt', async () => { + // This test verifies that touchUpdatedAt has error handling + // by checking it doesn't throw when given an invalid ID + + // Spy on the logger + const loggerSpy = jest.spyOn(dataTableRepository['logger'], 'debug'); + + // Call touchUpdatedAt with a non-existent ID + // The method should not throw, even though the update will affect 0 rows + await expect(dataTableRepository.touchUpdatedAt('non-existent-id')).resolves.toBeUndefined(); + + // The method completes without throwing + // Note: In a real error scenario (e.g., DB connection failure), the logger would be called + // but with a non-existent ID, the update just affects 0 rows without error + + loggerSpy.mockRestore(); + }); + }); + + describe('Dry run mode', () => { + it('should NOT update updatedAt when updating rows in dry run mode', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'status', type: 'string' }, + ], + }); + + // Insert initial row + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', status: 'active' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Update rows in DRY RUN mode + await dataTableService.updateRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + data: { status: 'inactive' }, + }, + false, + true, // dryRun = true + ); + + // Check that updatedAt was NOT updated (same timestamp) + const afterDryRun = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterDryRun!.updatedAt.getTime()).toBe(afterInsertUpdatedAt.getTime()); + }); + + it('should NOT update updatedAt when deleting rows in dry run mode', async () => { + // Create a data table with columns + const dataTable = await dataTableService.createDataTable(project.id, { + name: 'testTable', + columns: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + ], + }); + + // Insert rows + await dataTableService.insertRows(dataTable.id, project.id, [ + { email: 'test@example.com', name: 'User 1' }, + ]); + + // Get updatedAt after insert + const afterInsert = await dataTableRepository.findOneBy({ id: dataTable.id }); + const afterInsertUpdatedAt = afterInsert!.updatedAt; + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Delete rows in DRY RUN mode + await dataTableService.deleteRows( + dataTable.id, + project.id, + { + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'eq', value: 'test@example.com' }], + }, + }, + false, + true, // dryRun = true + ); + + // Check that updatedAt was NOT updated (same timestamp) + const afterDryRun = await dataTableRepository.findOneBy({ id: dataTable.id }); + expect(afterDryRun!.updatedAt.getTime()).toBe(afterInsertUpdatedAt.getTime()); + }); + }); +}); diff --git a/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts new file mode 100644 index 00000000000..da5eb7c9738 --- /dev/null +++ b/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts @@ -0,0 +1,365 @@ +import { mockInstance, testModules } from '@n8n/backend-test-utils'; +import type { RenameDataTableColumnDto } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { ProjectRelationRepository } from '@n8n/db'; + +import { CsvParserService } from '../csv-parser.service'; +import type { DataTableColumn } from '../data-table-column.entity'; +import { DataTableColumnRepository } from '../data-table-column.repository'; +import { DataTableFileCleanupService } from '../data-table-file-cleanup.service'; +import { DataTableRowsRepository } from '../data-table-rows.repository'; +import { DataTableSizeValidator } from '../data-table-size-validator.service'; +import type { DataTable } from '../data-table.entity'; +import { DataTableRepository } from '../data-table.repository'; +import { DataTableService } from '../data-table.service'; +import { DataTableColumnNotFoundError } from '../errors/data-table-column-not-found.error'; +import { DataTableNotFoundError } from '../errors/data-table-not-found.error'; +import { RoleService } from '@/services/role.service'; + +describe('DataTableService', () => { + let dataTableService: DataTableService; + let mockDataTableRepository: jest.Mocked; + let mockDataTableColumnRepository: jest.Mocked; + let mockDataTableRowsRepository: jest.Mocked; + let mockLogger: jest.Mocked; + let mockDataTableSizeValidator: jest.Mocked; + let mockProjectRelationRepository: jest.Mocked; + let mockRoleService: jest.Mocked; + let mockCsvParserService: jest.Mocked; + let mockFileCleanupService: jest.Mocked; + + beforeAll(async () => { + await testModules.loadModules(['data-table']); + }); + + beforeEach(() => { + mockDataTableRepository = mockInstance(DataTableRepository); + mockDataTableColumnRepository = mockInstance(DataTableColumnRepository); + mockDataTableRowsRepository = mockInstance(DataTableRowsRepository); + mockLogger = mockInstance(Logger); + mockDataTableSizeValidator = mockInstance(DataTableSizeValidator); + mockProjectRelationRepository = mockInstance(ProjectRelationRepository); + mockRoleService = mockInstance(RoleService); + mockCsvParserService = mockInstance(CsvParserService); + mockFileCleanupService = mockInstance(DataTableFileCleanupService); + + // Mock the logger.scoped method to return the logger itself + mockLogger.scoped = jest.fn().mockReturnValue(mockLogger); + + dataTableService = new DataTableService( + mockDataTableRepository, + mockDataTableColumnRepository, + mockDataTableRowsRepository, + mockLogger, + mockDataTableSizeValidator, + mockProjectRelationRepository, + mockRoleService, + mockCsvParserService, + mockFileCleanupService, + ); + + jest.clearAllMocks(); + }); + + describe('renameColumn', () => { + const projectId = 'test-project-id'; + const dataTableId = 'test-data-table-id'; + const columnId = 'test-column-id'; + + const mockDataTable: DataTable = { + id: dataTableId, + name: 'Test Table', + projectId, + } as DataTable; + + const mockColumn: DataTableColumn = { + id: columnId, + name: 'old_column_name', + type: 'string', + index: 0, + dataTableId, + } as DataTableColumn; + + const renameDto: RenameDataTableColumnDto = { + name: 'new_column_name', + }; + + describe('successful rename', () => { + it('should rename column when data table and column exist', async () => { + // Arrange + const renamedColumn = { ...mockColumn, name: renameDto.name }; + + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn); + mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn); + + // Act + const result = await dataTableService.renameColumn( + dataTableId, + projectId, + columnId, + renameDto, + ); + + // Assert + expect(result).toEqual(renamedColumn); + expect(mockDataTableRepository.findOneBy).toHaveBeenCalledWith({ + id: dataTableId, + project: { + id: projectId, + }, + }); + expect(mockDataTableColumnRepository.findOneBy).toHaveBeenCalledWith({ + id: columnId, + dataTableId, + }); + expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith( + dataTableId, + mockColumn, + renameDto.name, + ); + }); + + it('should call repository methods in correct order', async () => { + // Arrange + const renamedColumn = { ...mockColumn, name: renameDto.name }; + const callOrder: string[] = []; + + mockDataTableRepository.findOneBy.mockImplementation(async () => { + callOrder.push('validateDataTableExists'); + return mockDataTable; + }); + + mockDataTableColumnRepository.findOneBy.mockImplementation(async () => { + callOrder.push('validateColumnExists'); + return mockColumn; + }); + + mockDataTableColumnRepository.renameColumn.mockImplementation(async () => { + callOrder.push('renameColumn'); + return renamedColumn; + }); + + // Act + await dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto); + + // Assert + expect(callOrder).toEqual([ + 'validateDataTableExists', + 'validateColumnExists', + 'renameColumn', + ]); + }); + }); + + describe('validation errors', () => { + it('should throw DataTableNotFoundError when data table does not exist', async () => { + // Arrange + mockDataTableRepository.findOneBy.mockResolvedValue(null); + + // Act & Assert + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(DataTableNotFoundError); + + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(`Could not find the data table: '${dataTableId}'`); + + // Verify that column validation and rename were not called + expect(mockDataTableColumnRepository.findOneBy).not.toHaveBeenCalled(); + expect(mockDataTableColumnRepository.renameColumn).not.toHaveBeenCalled(); + }); + + it('should throw DataTableNotFoundError when data table exists but belongs to different project', async () => { + // Arrange + const differentProjectId = 'different-project-id'; + mockDataTableRepository.findOneBy.mockResolvedValue(null); + + // Act & Assert + await expect( + dataTableService.renameColumn(dataTableId, differentProjectId, columnId, renameDto), + ).rejects.toThrow(DataTableNotFoundError); + + // Verify that the repository was called with the correct project filter + expect(mockDataTableRepository.findOneBy).toHaveBeenCalledWith({ + id: dataTableId, + project: { + id: differentProjectId, + }, + }); + }); + + it('should throw DataTableColumnNotFoundError when column does not exist', async () => { + // Arrange + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(null); + + // Act & Assert + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(DataTableColumnNotFoundError); + + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow( + `Could not find the column '${columnId}' in the data table: ${dataTableId}`, + ); + + // Verify that data table validation was called but rename was not + expect(mockDataTableRepository.findOneBy).toHaveBeenCalled(); + expect(mockDataTableColumnRepository.renameColumn).not.toHaveBeenCalled(); + }); + + it('should throw DataTableColumnNotFoundError when column exists but belongs to different data table', async () => { + // Arrange + const differentDataTableId = 'different-table-id'; + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(null); + + // Act & Assert + await expect( + dataTableService.renameColumn(differentDataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(DataTableColumnNotFoundError); + + // Verify that the repository was called with the correct table filter + expect(mockDataTableColumnRepository.findOneBy).toHaveBeenCalledWith({ + id: columnId, + dataTableId: differentDataTableId, + }); + }); + }); + + describe('validation order', () => { + it('should validate data table existence before validating column existence', async () => { + // Arrange + mockDataTableRepository.findOneBy.mockResolvedValue(null); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn); + + // Act & Assert + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(DataTableNotFoundError); + + // Column validation should not be called if table validation fails + expect(mockDataTableRepository.findOneBy).toHaveBeenCalled(); + expect(mockDataTableColumnRepository.findOneBy).not.toHaveBeenCalled(); + }); + + it('should validate column existence before calling rename', async () => { + // Arrange + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(null); + + // Act & Assert + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(DataTableColumnNotFoundError); + + // Rename should not be called if column validation fails + expect(mockDataTableColumnRepository.renameColumn).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation from repository', () => { + it('should propagate errors from dataTableColumnRepository.renameColumn', async () => { + // Arrange + const repositoryError = new Error('Database constraint violation'); + + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn); + mockDataTableColumnRepository.renameColumn.mockRejectedValue(repositoryError); + + // Act & Assert + await expect( + dataTableService.renameColumn(dataTableId, projectId, columnId, renameDto), + ).rejects.toThrow(repositoryError); + + // Verify that all validations were performed before the error + expect(mockDataTableRepository.findOneBy).toHaveBeenCalled(); + expect(mockDataTableColumnRepository.findOneBy).toHaveBeenCalled(); + expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('should handle empty column name in DTO', async () => { + // Arrange + const emptyNameDto: RenameDataTableColumnDto = { name: '' }; + const renamedColumn = { ...mockColumn, name: '' }; + + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn); + mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn); + + // Act + const result = await dataTableService.renameColumn( + dataTableId, + projectId, + columnId, + emptyNameDto, + ); + + // Assert + expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith( + dataTableId, + mockColumn, + '', + ); + expect(result.name).toBe(''); + }); + + it('should handle renaming to same name', async () => { + // Arrange + const sameNameDto: RenameDataTableColumnDto = { name: mockColumn.name }; + const renamedColumn = { ...mockColumn, name: mockColumn.name }; + + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn); + mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn); + + // Act + const result = await dataTableService.renameColumn( + dataTableId, + projectId, + columnId, + sameNameDto, + ); + + // Assert + expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith( + dataTableId, + mockColumn, + mockColumn.name, + ); + expect(result.name).toBe(mockColumn.name); + }); + + it('should handle special characters in new column name', async () => { + // Arrange + const specialCharDto: RenameDataTableColumnDto = { name: 'column_with_special@chars!' }; + const renamedColumn = { ...mockColumn, name: specialCharDto.name }; + + mockDataTableRepository.findOneBy.mockResolvedValue(mockDataTable); + mockDataTableColumnRepository.findOneBy.mockResolvedValue(mockColumn); + mockDataTableColumnRepository.renameColumn.mockResolvedValue(renamedColumn); + + // Act + const result = await dataTableService.renameColumn( + dataTableId, + projectId, + columnId, + specialCharDto, + ); + + // Assert + expect(mockDataTableColumnRepository.renameColumn).toHaveBeenCalledWith( + dataTableId, + mockColumn, + specialCharDto.name, + ); + expect(result.name).toBe(specialCharDto.name); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/data-table/data-table-column.repository.ts b/packages/cli/src/modules/data-table/data-table-column.repository.ts index 22bcde279a9..a6e830ef8a4 100644 --- a/packages/cli/src/modules/data-table/data-table-column.repository.ts +++ b/packages/cli/src/modules/data-table/data-table-column.repository.ts @@ -24,6 +24,40 @@ export class DataTableColumnRepository extends Repository { super(DataTableColumn, dataSource.manager); } + /** + * Validates that a column name is not reserved as a system column + */ + private validateNotSystemColumn(columnName: string): void { + if (DATA_TABLE_SYSTEM_COLUMNS.includes(columnName)) { + throw new DataTableSystemColumnNameConflictError(columnName); + } + if (columnName === DATA_TABLE_SYSTEM_TESTING_COLUMN) { + throw new DataTableSystemColumnNameConflictError(columnName, 'testing'); + } + } + + /** + * Validates that a column name is unique within a data table + */ + private async validateUniqueColumnName( + columnName: string, + dataTableId: string, + em: EntityManager, + ): Promise { + const existingColumnMatch = await em.existsBy(DataTableColumn, { + name: columnName, + dataTableId, + }); + + if (existingColumnMatch) { + const dataTable = await em.findOneBy(DataTable, { id: dataTableId }); + if (!dataTable) { + throw new UnexpectedError('Data table not found'); + } + throw new DataTableColumnNameConflictError(columnName, dataTable.name); + } + } + async getColumns(dataTableId: string, trx?: EntityManager) { return await withTransaction( this.manager, @@ -46,25 +80,8 @@ export class DataTableColumnRepository extends Repository { async addColumn(dataTableId: string, schema: DataTableCreateColumnSchema, trx?: EntityManager) { return await withTransaction(this.manager, trx, async (em) => { - if (DATA_TABLE_SYSTEM_COLUMNS.includes(schema.name)) { - throw new DataTableSystemColumnNameConflictError(schema.name); - } - if (schema.name === DATA_TABLE_SYSTEM_TESTING_COLUMN) { - throw new DataTableSystemColumnNameConflictError(schema.name, 'testing'); - } - - const existingColumnMatch = await em.existsBy(DataTableColumn, { - name: schema.name, - dataTableId, - }); - - if (existingColumnMatch) { - const dataTable = await em.findOneBy(DataTable, { id: dataTableId }); - if (!dataTable) { - throw new UnexpectedError('Data table not found'); - } - throw new DataTableColumnNameConflictError(schema.name, dataTable.name); - } + this.validateNotSystemColumn(schema.name); + await this.validateUniqueColumnName(schema.name, dataTableId, em); if (schema.index === undefined) { const columns = await this.getColumns(dataTableId, em); @@ -125,6 +142,32 @@ export class DataTableColumnRepository extends Repository { }); } + async renameColumn( + dataTableId: string, + column: DataTableColumn, + newName: string, + trx?: EntityManager, + ) { + return await withTransaction(this.manager, trx, async (em) => { + this.validateNotSystemColumn(newName); + await this.validateUniqueColumnName(newName, dataTableId, em); + + const oldName = column.name; + + await em.update(DataTableColumn, { id: column.id }, { name: newName }); + + await this.ddlService.renameColumn( + dataTableId, + oldName, + newName, + em.connection.options.type, + em, + ); + + return { ...column, name: newName }; + }); + } + async shiftColumns(dataTableId: string, lowestIndex: number, delta: -1 | 1, trx?: EntityManager) { await withTransaction(this.manager, trx, async (em) => { await em diff --git a/packages/cli/src/modules/data-table/data-table-ddl.service.ts b/packages/cli/src/modules/data-table/data-table-ddl.service.ts index 0d46ea1d179..5c7016145ee 100644 --- a/packages/cli/src/modules/data-table/data-table-ddl.service.ts +++ b/packages/cli/src/modules/data-table/data-table-ddl.service.ts @@ -4,7 +4,13 @@ import { DataSource, DataSourceOptions, EntityManager } from '@n8n/typeorm'; import { UnexpectedError } from 'n8n-workflow'; import { DataTableColumn } from './data-table-column.entity'; -import { addColumnQuery, deleteColumnQuery, toDslColumns, toTableName } from './utils/sql-utils'; +import { + addColumnQuery, + deleteColumnQuery, + renameColumnQuery, + toDslColumns, + toTableName, +} from './utils/sql-utils'; /** * Manages database schema operations for data tables (DDL). @@ -63,4 +69,18 @@ export class DataTableDDLService { await em.query(deleteColumnQuery(toTableName(dataTableId), columnName, dbType)); }); } + + async renameColumn( + dataTableId: string, + oldColumnName: string, + newColumnName: string, + dbType: DataSourceOptions['type'], + trx?: EntityManager, + ) { + await withTransaction(this.dataSource.manager, trx, async (em) => { + await em.query( + renameColumnQuery(toTableName(dataTableId), oldColumnName, newColumnName, dbType), + ); + }); + } } diff --git a/packages/cli/src/modules/data-table/data-table.controller.ts b/packages/cli/src/modules/data-table/data-table.controller.ts index b315c7666f8..0b67082e2e3 100644 --- a/packages/cli/src/modules/data-table/data-table.controller.ts +++ b/packages/cli/src/modules/data-table/data-table.controller.ts @@ -6,6 +6,7 @@ import { ListDataTableContentQueryDto, ListDataTableQueryDto, MoveDataTableColumnDto, + RenameDataTableColumnDto, UpdateDataTableDto, UpdateDataTableRowDto, UpsertDataTableRowDto, @@ -26,6 +27,7 @@ import { import { NextFunction, Response } from 'express'; import { DataTableRowReturn } from 'n8n-workflow'; +import { ResponseError } from '@/errors/response-errors/abstract/response.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -33,7 +35,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { DataTableService } from './data-table.service'; import { DataTableColumnNameConflictError } from './errors/data-table-column-name-conflict.error'; -import { DataTableColumnNotFoundError } from './errors/data-table-column-not-found.error'; import { DataTableNameConflictError } from './errors/data-table-name-conflict.error'; import { DataTableNotFoundError } from './errors/data-table-not-found.error'; import { DataTableSystemColumnNameConflictError } from './errors/data-table-system-column-name-conflict.error'; @@ -47,6 +48,26 @@ export class DataTableController { private readonly projectService: ProjectService, ) {} + private handleDataTableColumnOperationError(e: unknown): never { + if ( + e instanceof DataTableColumnNameConflictError || + e instanceof DataTableSystemColumnNameConflictError || + e instanceof DataTableNameConflictError + ) { + throw new ConflictError(e.message); + } + if (e instanceof DataTableValidationError) { + throw new BadRequestError(e.message); + } + if (e instanceof ResponseError) { + throw e; + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + @Middleware() async validateProjectExists( req: AuthenticatedRequest<{ projectId: string }>, @@ -171,18 +192,7 @@ export class DataTableController { try { return await this.dataTableService.addColumn(dataTableId, req.params.projectId, dto); } catch (e: unknown) { - if (e instanceof DataTableNotFoundError) { - throw new NotFoundError(e.message); - } else if ( - e instanceof DataTableColumnNameConflictError || - e instanceof DataTableSystemColumnNameConflictError - ) { - throw new ConflictError(e.message); - } else if (e instanceof Error) { - throw new InternalServerError(e.message, e); - } else { - throw e; - } + this.handleDataTableColumnOperationError(e); } } @@ -197,13 +207,7 @@ export class DataTableController { try { return await this.dataTableService.deleteColumn(dataTableId, req.params.projectId, columnId); } catch (e: unknown) { - if (e instanceof DataTableNotFoundError || e instanceof DataTableColumnNotFoundError) { - throw new NotFoundError(e.message); - } else if (e instanceof Error) { - throw new InternalServerError(e.message, e); - } else { - throw e; - } + this.handleDataTableColumnOperationError(e); } } @@ -224,15 +228,28 @@ export class DataTableController { dto, ); } catch (e: unknown) { - if (e instanceof DataTableNotFoundError || e instanceof DataTableColumnNotFoundError) { - throw new NotFoundError(e.message); - } else if (e instanceof DataTableValidationError) { - throw new BadRequestError(e.message); - } else if (e instanceof Error) { - throw new InternalServerError(e.message, e); - } else { - throw e; - } + this.handleDataTableColumnOperationError(e); + } + } + + @Patch('/:dataTableId/columns/:columnId/rename') + @ProjectScope('dataTable:update') + async renameColumn( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Param('dataTableId') dataTableId: string, + @Param('columnId') columnId: string, + @Body dto: RenameDataTableColumnDto, + ) { + try { + return await this.dataTableService.renameColumn( + dataTableId, + req.params.projectId, + columnId, + dto, + ); + } catch (e: unknown) { + this.handleDataTableColumnOperationError(e); } } diff --git a/packages/cli/src/modules/data-table/data-table.repository.ts b/packages/cli/src/modules/data-table/data-table.repository.ts index b53f1883a32..fa59ce5a210 100644 --- a/packages/cli/src/modules/data-table/data-table.repository.ts +++ b/packages/cli/src/modules/data-table/data-table.repository.ts @@ -3,6 +3,7 @@ import { type DataTableCreateColumnSchema, type ListDataTableQueryDto, } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Project, withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; @@ -24,10 +25,26 @@ export class DataTableRepository extends Repository { dataSource: DataSource, private ddlService: DataTableDDLService, private readonly globalConfig: GlobalConfig, + private readonly logger: Logger, ) { super(DataTable, dataSource.manager); } + /** + * Updates the updatedAt timestamp for a data table without modifying any other fields. + * This is used to track when the table's content (rows/columns) has changed. + * + * Note: This method logs but does not throw errors to ensure that timestamp + * update failures don't affect the primary data operations. + */ + async touchUpdatedAt(dataTableId: string, trx?: EntityManager) { + await withTransaction(this.manager, trx, async (em) => { + await em.update(DataTable, { id: dataTableId }, { updatedAt: new Date() }); + }).catch((error) => { + this.logger.warn('Failed to update DataTable timestamp', { dataTableId, error }); + }); + } + async createDataTable( projectId: string, name: string, diff --git a/packages/cli/src/modules/data-table/data-table.service.ts b/packages/cli/src/modules/data-table/data-table.service.ts index d08712ff73c..7f4b955e40a 100644 --- a/packages/cli/src/modules/data-table/data-table.service.ts +++ b/packages/cli/src/modules/data-table/data-table.service.ts @@ -4,6 +4,7 @@ import type { DeleteDataTableRowsDto, ListDataTableContentQueryDto, MoveDataTableColumnDto, + RenameDataTableColumnDto, DataTableListOptions, UpsertDataTableRowDto, UpdateDataTableDto, @@ -171,7 +172,11 @@ export class DataTableService { async addColumn(dataTableId: string, projectId: string, dto: AddDataTableColumnDto) { await this.validateDataTableExists(dataTableId, projectId); - return await this.dataTableColumnRepository.addColumn(dataTableId, dto); + const result = await this.dataTableColumnRepository.addColumn(dataTableId, dto); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); + + return result; } async moveColumn( @@ -194,9 +199,23 @@ export class DataTableService { await this.dataTableColumnRepository.deleteColumn(dataTableId, existingColumn); + await this.dataTableRepository.touchUpdatedAt(dataTableId); + return true; } + async renameColumn( + dataTableId: string, + projectId: string, + columnId: string, + dto: RenameDataTableColumnDto, + ) { + await this.validateDataTableExists(dataTableId, projectId); + const existingColumn = await this.validateColumnExists(dataTableId, columnId); + + return await this.dataTableColumnRepository.renameColumn(dataTableId, existingColumn, dto.name); + } + async getManyAndCount(options: DataTableListOptions) { return await this.dataTableRepository.getManyAndCount(options); } @@ -262,6 +281,8 @@ export class DataTableService { this.dataTableSizeValidator.reset(); + await this.dataTableRepository.touchUpdatedAt(dataTableId); + return result; } @@ -336,6 +357,8 @@ export class DataTableService { if (!dryRun) { this.dataTableSizeValidator.reset(); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); } return result; @@ -421,6 +444,8 @@ export class DataTableService { if (!dryRun) { this.dataTableSizeValidator.reset(); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); } return result; @@ -479,6 +504,8 @@ export class DataTableService { if (!dryRun) { this.dataTableSizeValidator.reset(); + + await this.dataTableRepository.touchUpdatedAt(dataTableId); } return result; diff --git a/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts b/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts index e874731534c..8c59716dfc6 100644 --- a/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts +++ b/packages/cli/src/modules/data-table/errors/data-table-column-not-found.error.ts @@ -1,9 +1,7 @@ -import { UserError } from 'n8n-workflow'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; -export class DataTableColumnNotFoundError extends UserError { +export class DataTableColumnNotFoundError extends NotFoundError { constructor(dataTableId: string, columnId: string) { - super(`Could not find the column '${columnId}' in the data table: ${dataTableId}`, { - level: 'warning', - }); + super(`Could not find the column '${columnId}' in the data table: ${dataTableId}`); } } diff --git a/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts b/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts index 3640bc20aeb..83589c09573 100644 --- a/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts +++ b/packages/cli/src/modules/data-table/errors/data-table-not-found.error.ts @@ -1,9 +1,7 @@ -import { UserError } from 'n8n-workflow'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; -export class DataTableNotFoundError extends UserError { +export class DataTableNotFoundError extends NotFoundError { constructor(dataTableId: string) { - super(`Could not find the data table: '${dataTableId}'`, { - level: 'warning', - }); + super(`Could not find the data table: '${dataTableId}'`); } } diff --git a/packages/cli/src/modules/data-table/utils/sql-utils.ts b/packages/cli/src/modules/data-table/utils/sql-utils.ts index 35e58df82ee..a478823fc96 100644 --- a/packages/cli/src/modules/data-table/utils/sql-utils.ts +++ b/packages/cli/src/modules/data-table/utils/sql-utils.ts @@ -106,6 +106,23 @@ export function deleteColumnQuery( return `ALTER TABLE ${quotedTableName} DROP COLUMN ${quoteIdentifier(column, dbType)}`; } +export function renameColumnQuery( + tableName: DataTableUserTableName, + oldColumnName: string, + newColumnName: string, + dbType: DataSourceOptions['type'], +): string { + if (!isValidColumnName(oldColumnName) || !isValidColumnName(newColumnName)) { + throw new UnexpectedError(DATA_TABLE_COLUMN_ERROR_MESSAGE); + } + + const quotedTableName = quoteIdentifier(tableName, dbType); + const quotedOldName = quoteIdentifier(oldColumnName, dbType); + const quotedNewName = quoteIdentifier(newColumnName, dbType); + + return `ALTER TABLE ${quotedTableName} RENAME COLUMN ${quotedOldName} TO ${quotedNewName}`; +} + export function quoteIdentifier(name: string, dbType: DataSourceOptions['type']): string { switch (dbType) { case 'mysql': diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts new file mode 100644 index 00000000000..e0143c259fa --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials.controller.test.ts @@ -0,0 +1,139 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import { type CredentialsEntity } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import type { Request, Response } from 'express'; +import { DynamicCredentialsController } from '@/modules/dynamic-credentials.ee/dynamic-credentials.controller'; +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; +import { OauthService } from '@/oauth/oauth.service'; + +jest.mock('axios'); + +describe('DynamicCredentialsController', () => { + const enterpriseCredentialsService = mockInstance(EnterpriseCredentialsService); + const oauthService = mockInstance(OauthService); + + mockInstance(Logger); + + const controller = Container.get(DynamicCredentialsController); + + const timestamp = 1706750625678; + jest.useFakeTimers({ advanceTimers: true }); + + beforeEach(() => { + jest.setSystemTime(new Date(timestamp)); + jest.clearAllMocks(); + }); + + describe('authorizeCredential', () => { + it('should throw NotFoundError when credential is not found', async () => { + const req = mock({ + params: { id: 'non-existent-id' }, + }); + const res = mock(); + + enterpriseCredentialsService.getOne.mockResolvedValue(null); + + await expect(controller.authorizeCredential(req, res)).rejects.toThrow( + 'Credential not found', + ); + expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('non-existent-id'); + }); + + it('should throw BadRequestError when credential type is not OAuth2 or OAuth1', async () => { + const mockCredential = mock({ + id: '1', + type: 'httpBasicAuth', + }); + const req = mock({ + params: { id: '1' }, + }); + const res = mock(); + + enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential); + + await expect(controller.authorizeCredential(req, res)).rejects.toThrow( + 'Credential type not supported', + ); + expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1'); + }); + + it('should return auth URI for OAuth2 credential', async () => { + const mockCredential = mock({ + id: '1', + type: 'googleOAuth2Api', + }); + const req = mock({ + params: { id: '1' }, + headers: { authorization: 'Bearer token123' }, + }); + const res = mock(); + + enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential); + oauthService.generateAOauth2AuthUri.mockResolvedValueOnce( + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid', + ); + + const authUri = await controller.authorizeCredential(req, res); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1'); + expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockCredential, { + cid: '1', + authorizationHeader: 'Bearer token123', + }); + }); + + it('should return auth URI for OAuth1 credential', async () => { + const mockCredential = mock({ + id: '1', + type: 'twitterOAuth1Api', + }); + const req = mock({ + params: { id: '1' }, + headers: { authorization: 'Bearer token123' }, + }); + const res = mock(); + + enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential); + oauthService.generateAOauth1AuthUri.mockResolvedValueOnce( + 'https://example.domain/oauth/authorize?oauth_token=random-token', + ); + + const authUri = await controller.authorizeCredential(req, res); + + expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token'); + expect(enterpriseCredentialsService.getOne).toHaveBeenCalledWith('1'); + expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(mockCredential, { + cid: '1', + authorizationHeader: 'Bearer token123', + }); + }); + + it('should handle request without authorization header', async () => { + const mockCredential = mock({ + id: '1', + type: 'googleOAuth2Api', + }); + const req = mock({ + params: { id: '1' }, + headers: { authorization: undefined }, + }); + const res = mock(); + + enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential); + oauthService.generateAOauth2AuthUri.mockResolvedValueOnce( + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid', + ); + + const authUri = await controller.authorizeCredential(req, res); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(mockCredential, { + cid: '1', + authorizationHeader: undefined, + }); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/bearer-token-extractor.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/bearer-token-extractor.test.ts new file mode 100644 index 00000000000..1cd26259026 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/bearer-token-extractor.test.ts @@ -0,0 +1,153 @@ +import type { ContextEstablishmentResult } from '@n8n/decorators'; + +import { BearerTokenExtractor } from '../bearer-token-extractor'; +import type { HttpHeaderExtractor } from '../http-header-extractor'; +import { createOptions, createTriggerItem } from './utils'; + +describe('BearerTokenExtractor', () => { + let bearerTokenExtractor: BearerTokenExtractor; + let mockHttpHeaderExtractor: jest.Mocked; + + beforeEach(() => { + mockHttpHeaderExtractor = { + isApplicableToTriggerNode: jest.fn(), + execute: jest.fn(), + } as unknown as jest.Mocked; + + bearerTokenExtractor = new BearerTokenExtractor(mockHttpHeaderExtractor); + }); + + describe('hookDescription', () => { + it('should have correct metadata', () => { + expect(bearerTokenExtractor.hookDescription).toEqual({ + name: 'BearerTokenExtractor', + displayName: 'Bearer Token Extractor', + options: [], + }); + }); + }); + + describe('isApplicableToTriggerNode', () => { + it('should delegate to HttpHeaderExtractor', () => { + mockHttpHeaderExtractor.isApplicableToTriggerNode.mockReturnValue(true); + + const result = bearerTokenExtractor.isApplicableToTriggerNode('n8n-nodes-base.webhook'); + + expect(mockHttpHeaderExtractor.isApplicableToTriggerNode).toHaveBeenCalledWith( + 'n8n-nodes-base.webhook', + ); + expect(result).toBe(true); + }); + + it('should return false for non-webhook nodes', () => { + mockHttpHeaderExtractor.isApplicableToTriggerNode.mockReturnValue(false); + + const result = bearerTokenExtractor.isApplicableToTriggerNode('n8n-nodes-base.httpRequest'); + + expect(result).toBe(false); + }); + }); + + describe('execute', () => { + it('should extract bearer token from Authorization header', async () => { + const options = createOptions(); + + const expectedResult: ContextEstablishmentResult = { + contextUpdate: { + credentials: { + version: 1, + identity: 'test-token-123', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }, + }; + + mockHttpHeaderExtractor.execute.mockResolvedValue(expectedResult); + + const result = await bearerTokenExtractor.execute(options); + + expect(mockHttpHeaderExtractor.execute).toHaveBeenCalledWith({ + ...options, + options: { + headerName: 'authorization', + headerValue: '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + }, + }); + expect(result).toEqual(expectedResult); + }); + + it('should pass through all options to HttpHeaderExtractor', async () => { + const options = createOptions({ customField: 'custom-value' } as any); + + mockHttpHeaderExtractor.execute.mockResolvedValue({}); + + await bearerTokenExtractor.execute(options); + + expect(mockHttpHeaderExtractor.execute).toHaveBeenCalledWith({ + triggerItems: options.triggerItems, + customField: 'custom-value', + options: { + headerName: 'authorization', + headerValue: '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + }, + }); + }); + + it('should handle case-insensitive bearer prefix', async () => { + const testCases = [ + 'Bearer token123', + 'bearer token456', + 'BEARER token789', + 'BeArEr tokenABC', + ]; + + for (const authHeader of testCases) { + const options = createOptions({ + triggerItems: [createTriggerItem({ authorization: authHeader })], + }); + + mockHttpHeaderExtractor.execute.mockResolvedValue({ + contextUpdate: { + credentials: { + version: 1, + identity: authHeader.split(' ')[1], + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }, + }); + + await bearerTokenExtractor.execute(options); + + expect(mockHttpHeaderExtractor.execute).toHaveBeenCalledWith({ + ...options, + options: { + headerName: 'authorization', + headerValue: '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + }, + }); + } + }); + + it('should return empty result when no Authorization header present', async () => { + const options = createOptions({ triggerItems: [createTriggerItem({})] }); + + mockHttpHeaderExtractor.execute.mockResolvedValue({}); + + const result = await bearerTokenExtractor.execute(options); + + expect(result).toEqual({}); + }); + + it('should return empty result for malformed bearer token', async () => { + const options = createOptions({ + triggerItems: [createTriggerItem({ authorization: 'NotBearer token123' })], + }); + + mockHttpHeaderExtractor.execute.mockResolvedValue({}); + + const result = await bearerTokenExtractor.execute(options); + + expect(result).toEqual({}); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/http-header-extractor.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/http-header-extractor.test.ts new file mode 100644 index 00000000000..690c500901d --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/http-header-extractor.test.ts @@ -0,0 +1,351 @@ +import type { Logger } from '@n8n/backend-common'; + +import { HttpHeaderExtractor } from '../http-header-extractor'; +import { createOptions, createTriggerItem } from './utils'; + +describe('HttpHeaderExtractor', () => { + let extractor: HttpHeaderExtractor; + let mockLogger: jest.Mocked; + + beforeAll(() => { + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + extractor = new HttpHeaderExtractor(mockLogger); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isApplicableToTriggerNode', () => { + it('returns true for n8n-nodes-base.webhook', () => { + expect(extractor.isApplicableToTriggerNode('n8n-nodes-base.webhook')).toBe(true); + }); + + it('returns true for shorthand webhook type', () => { + expect(extractor.isApplicableToTriggerNode('webhook')).toBe(true); + }); + + it.each([ + 'n8n-nodes-base.httpRequest', + 'n8n-nodes-base.cron', + '', + 'WEBHOOK', + 'n8n-nodes-base.Webhook', + ])('returns false for "%s"', (nodeType) => { + expect(extractor.isApplicableToTriggerNode(nodeType)).toBe(false); + }); + }); + + describe('execute', () => { + describe('input validation', () => { + it('returns empty when triggerItems is undefined', async () => { + const result = await extractor.execute(createOptions({ triggerItems: undefined })); + + expect(result).toEqual({}); + }); + + it('returns empty when triggerItems is empty', async () => { + const result = await extractor.execute(createOptions({ triggerItems: [] })); + + expect(result).toEqual({}); + }); + + it('returns empty when options validation fails', async () => { + const result = await extractor.execute( + createOptions({ + options: { + headerName: 123, // Invalid: should be string + }, + }), + ); + + expect(result).toEqual({}); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid options for HttpHeaderExtractor hook.', + expect.objectContaining({ error: expect.anything() }), + ); + }); + }); + + describe('pattern safety', () => { + it.each([ + ['(a+)+', 'nested quantifier'], + ['(a*)+', 'nested quantifier variant'], + ['(a+)*', 'nested quantifier variant 2'], + ['(a|a)+', 'overlapping alternation'], + ['(foo|foo)*', 'overlapping alternation with words'], + ])('rejects unsafe pattern "%s" (%s)', async (pattern) => { + const result = await extractor.execute( + createOptions({ + options: { headerValue: pattern }, + }), + ); + + expect(result).toEqual({}); + expect(mockLogger.warn).toHaveBeenCalledWith('Potentially unsafe regex pattern rejected', { + pattern, + }); + }); + + it.each([ + 'Bearer (.+)', + '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + '^token-(.*)$', + 'api_key=([^&]+)', + ])('accepts safe pattern "%s"', async (pattern) => { + await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer abc123' })], + options: { headerValue: pattern }, + }), + ); + + // Should not warn about unsafe pattern + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('regex compilation', () => { + it('returns triggerItems with masked header for invalid regex syntax', async () => { + const result = await extractor.execute( + createOptions({ + options: { headerValue: '[invalid' }, + }), + ); + + expect(result.triggerItems).toBeDefined(); + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + expect(result.contextUpdate).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Invalid regex pattern', + expect.objectContaining({ + pattern: '[invalid', + error: expect.anything(), + }), + ); + }); + }); + + describe('header extraction', () => { + it('returns empty when headers is missing', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [{ json: {}, pairedItem: { item: 0 } }], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when headers is undefined', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem(undefined)], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when headers is not an object', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [{ json: { headers: 'not-an-object' }, pairedItem: { item: 0 } }], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when headers is an array', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [{ json: { headers: ['item1', 'item2'] }, pairedItem: { item: 0 } }], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when target header is not found', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ 'x-custom-header': 'value' })], + options: { headerName: 'authorization' }, + }), + ); + + expect(result).toEqual({}); + }); + + it('returns empty when header value is not a string', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 12345 })], + }), + ); + + expect(result).toEqual({}); + }); + + it('returns triggerItems with masked header when pattern does not match', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Basic dXNlcjpwYXNz' })], + options: { headerValue: 'Bearer (.+)' }, + }), + ); + + expect(result.triggerItems).toBeDefined(); + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + expect(result.contextUpdate).toBeUndefined(); + }); + + it('returns triggerItems with masked header when pattern has no capture group', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer token123' })], + options: { headerValue: 'Bearer .+' }, // No capture group + }), + ); + + expect(result.triggerItems).toBeDefined(); + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + expect(result.contextUpdate).toBeUndefined(); + }); + }); + + describe('successful extraction', () => { + it('extracts Bearer token with default pattern', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer my-jwt-token' })], + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'my-jwt-token', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('handles case-insensitive Bearer with default pattern', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'BEARER uppercase-token' })], + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'uppercase-token', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('extracts from custom header with custom pattern', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ 'x-api-key': 'key_abc123xyz' })], + options: { + headerName: 'x-api-key', + headerValue: 'key_(.+)', + }, + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'abc123xyz', + metadata: { source: 'http-header', headerName: 'x-api-key' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('normalizes header name to lowercase', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer token123' })], + options: { headerName: 'AUTHORIZATION' }, + }), + ); + + expect(result.contextUpdate).toEqual({ + credentials: { + version: 1, + identity: 'token123', + metadata: { source: 'http-header', headerName: 'authorization' }, + }, + }); + expect(result.triggerItems).toBeDefined(); + }); + + it('masks the extracted header value in returned triggerItems', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: 'Bearer secret-token' })], + }), + ); + + expect(result.triggerItems?.[0].json.headers).toEqual({ + authorization: '**********', + }); + }); + + it('truncates long header values before matching', async () => { + // Create a header value longer than MAX_HEADER_LENGTH (8192) + const longToken = 'x'.repeat(10000); + const headerValue = `Bearer ${longToken}`; + + const result = await extractor.execute( + createOptions({ + triggerItems: [createTriggerItem({ authorization: headerValue })], + options: { headerValue: 'Bearer (.+)' }, + }), + ); + + // Should match but token should be truncated + expect(result.contextUpdate?.credentials?.identity).toBeDefined(); + // The extracted value should be less than original due to truncation + // 8192 - 7 (length of "Bearer ") = 8185 max for token + expect((result.contextUpdate?.credentials?.identity as string).length).toBeLessThanOrEqual( + 8185, + ); + }); + + it('uses first trigger item when multiple provided', async () => { + const result = await extractor.execute( + createOptions({ + triggerItems: [ + createTriggerItem({ authorization: 'Bearer first-token' }), + createTriggerItem({ authorization: 'Bearer second-token' }), + ], + }), + ); + + expect(result.contextUpdate?.credentials?.identity).toBe('first-token'); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/utils.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/utils.ts new file mode 100644 index 00000000000..ca94500267d --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/__tests__/utils.ts @@ -0,0 +1,17 @@ +import type { ContextEstablishmentOptions } from '@n8n/decorators'; +import type { INodeExecutionData } from 'n8n-workflow'; + +// Factory functions for test data +export const createTriggerItem = (headers?: Record): INodeExecutionData => ({ + json: { headers }, + pairedItem: { item: 0 }, +}); + +export const createOptions = ( + overrides?: Partial, +): ContextEstablishmentOptions => + ({ + triggerItems: [createTriggerItem({ authorization: 'Bearer test-token-123' })], + options: {}, + ...overrides, + }) as ContextEstablishmentOptions; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/bearer-token-extractor.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/bearer-token-extractor.ts new file mode 100644 index 00000000000..ed128bf4411 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/bearer-token-extractor.ts @@ -0,0 +1,50 @@ +import { + ContextEstablishmentHook, + ContextEstablishmentOptions, + ContextEstablishmentResult, + HookDescription, + IContextEstablishmentHook, +} from '@n8n/decorators'; + +import { HttpHeaderExtractor } from './http-header-extractor'; + +/** + * Extracts bearer tokens from the Authorization HTTP header. + * + * Automatically extracts tokens from headers in the format: + * - `Authorization: Bearer ` + * - Case-insensitive "Bearer" prefix + * + * The extracted token becomes the credential identity for OAuth2 introspection. + * + * @example + * // Request header: + * // Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + * + * // Result: + * // context.credentials.identity = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + */ +@ContextEstablishmentHook() +export class BearerTokenExtractor implements IContextEstablishmentHook { + constructor(private readonly httpHeaderExtractor: HttpHeaderExtractor) {} + + hookDescription: HookDescription = { + name: 'BearerTokenExtractor', + displayName: 'Bearer Token Extractor', + options: [], + }; + + isApplicableToTriggerNode(nodeType: string): boolean { + return this.httpHeaderExtractor.isApplicableToTriggerNode(nodeType); + } + + async execute(options: ContextEstablishmentOptions): Promise { + return await this.httpHeaderExtractor.execute({ + ...options, + options: { + headerName: 'authorization', + headerValue: '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + }, + }); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/http-header-extractor.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/http-header-extractor.ts new file mode 100644 index 00000000000..514847f8171 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/http-header-extractor.ts @@ -0,0 +1,171 @@ +import { Logger } from '@n8n/backend-common'; +import { + ContextEstablishmentHook, + ContextEstablishmentOptions, + ContextEstablishmentResult, + HookDescription, + IContextEstablishmentHook, +} from '@n8n/decorators'; +import { createContext, Script } from 'node:vm'; +import { z } from 'zod'; + +const HttpHeaderExtractorOptionsSchema = z.object({ + headerName: z.string().default('authorization'), + headerValue: z.string().default('[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)'), +}); + +const MAX_HEADER_LENGTH = 8192; // 8KB - reasonable limit for auth headers +const REGEX_TIMEOUT_MS = 100; // 100ms timeout for regex execution + +/** + * Detects potentially dangerous regex patterns that could cause ReDoS. + * This is a heuristic check - it catches common dangerous patterns but is not comprehensive. + */ +function isUnsafeRegexPattern(pattern: string): boolean { + // Detect nested quantifiers: (a+)+, (a*)+, (a+)*, etc. + const nestedQuantifier = /([+*?{]|\{\d+,?\d*\})\s*[)]\s*[+*?{]/; + // Detect overlapping alternation with quantifier: (a|a)+ + const overlappingAlt = /\([^)]*\|[^)]*\)[+*]/; + return nestedQuantifier.test(pattern) || overlappingAlt.test(pattern); +} + +function isHeaderObject(obj: unknown): obj is Record { + return obj !== null && obj !== undefined && typeof obj === 'object' && !Array.isArray(obj); +} + +// Reusable VM context and pre-compiled script for safe regex execution +// This avoids memory overhead of creating new contexts per call +const regexContext = createContext({ + RegExp, + pattern: '', + input: '', + result: null as RegExpExecArray | null, +}); +const regexScript = new Script('result = new RegExp(pattern).exec(input)'); + +/** + * Executes a regex with a timeout to prevent ReDoS attacks. + * Uses a reusable VM context to minimize memory overhead. + * + * @returns The match result, or null if no match or timeout occurred + * @throws Error if the pattern is invalid + */ +function safeRegexExec( + pattern: string, + input: string, + timeoutMs = REGEX_TIMEOUT_MS, +): RegExpExecArray | null { + regexContext.pattern = pattern; + regexContext.input = input; + regexContext.result = null; + + try { + regexScript.runInContext(regexContext, { timeout: timeoutMs }); + return regexContext.result as RegExpExecArray | null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { + return null; + } + throw error; + } +} + +@ContextEstablishmentHook() +export class HttpHeaderExtractor implements IContextEstablishmentHook { + constructor(private readonly logger: Logger) {} + + hookDescription: HookDescription = { + name: 'HttpHeaderExtractor', + displayName: 'HTTP Header Extractor', + options: [ + { + name: 'headerName', + displayName: 'Header Name', + type: 'string', + default: 'authorization', + description: 'The name of the HTTP header to extract the value from.', + }, + { + name: 'headerValue', + displayName: 'Header Value Pattern', + type: 'string', + default: '[Bb][Ee][Aa][Rr][Ee][Rr]\\s+(.+)', + description: + 'A regular expression pattern to extract the identity from the header value. Use a capturing group to specify the identity part.', + }, + ], + }; + + isApplicableToTriggerNode(nodeType: string): boolean { + return nodeType === 'n8n-nodes-base.webhook' || nodeType === 'webhook'; + } + + async execute(options: ContextEstablishmentOptions): Promise { + if (!options.triggerItems || options.triggerItems.length === 0) { + this.logger.debug('No trigger items found, skipping HttpHeaderExtractor hook.'); + return {}; + } + + const httpHeaderOptions = await HttpHeaderExtractorOptionsSchema.safeParseAsync( + options.options ?? {}, + ); + + if (httpHeaderOptions.error) { + this.logger.error('Invalid options for HttpHeaderExtractor hook.', { + error: httpHeaderOptions.error, + }); + return {}; + } + + const normalizedHeaderName = httpHeaderOptions.data.headerName.toLowerCase(); + const pattern = httpHeaderOptions.data.headerValue; + + // Validate pattern safety to prevent ReDoS (defense in depth) + if (isUnsafeRegexPattern(pattern)) { + this.logger.warn('Potentially unsafe regex pattern rejected', { pattern }); + return {}; + } + + const [triggerItem] = options.triggerItems; + const headers = triggerItem.json['headers']; + + if (isHeaderObject(headers) && normalizedHeaderName in headers) { + const headerValue = headers[normalizedHeaderName]; + + if (typeof headerValue === 'string') { + headers[normalizedHeaderName] = '**********'; // Mask the header value in the trigger item + + // Limit input length to mitigate ReDoS on long inputs + const truncatedValue = headerValue.slice(0, MAX_HEADER_LENGTH); + + try { + const match = safeRegexExec(pattern, truncatedValue); + + if (match?.[1]) { + return { + triggerItems: options.triggerItems, + contextUpdate: { + credentials: { + version: 1, + identity: match[1], + metadata: { source: 'http-header', headerName: normalizedHeaderName }, + }, + }, + }; + } else { + return { + triggerItems: options.triggerItems, + }; + } + } catch (error) { + this.logger.error('Invalid regex pattern', { pattern, error }); + return { + triggerItems: options.triggerItems, + }; + } + } + } + + return {}; + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/index.ts b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/index.ts new file mode 100644 index 00000000000..c5fdf58090f --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/context-establishment-hooks/index.ts @@ -0,0 +1,2 @@ +import './http-header-extractor'; +import './bearer-token-extractor'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts new file mode 100644 index 00000000000..61fc0510ef7 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers.controller.ts @@ -0,0 +1,156 @@ +import { + CreateCredentialResolverDto, + CredentialResolver, + credentialResolverSchema, + credentialResolversSchema, + UpdateCredentialResolverDto, + CredentialResolverType, + credentialResolverTypesSchema, +} from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { + Body, + Delete, + Get, + GlobalScope, + Param, + Patch, + Post, + RestController, + CredentialResolverValidationError, +} from '@n8n/decorators'; +import { Response } from 'express'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { DynamicCredentialResolverNotFoundError } from './errors/credential-resolver-not-found.error'; +import { DynamicCredentialResolverService } from './services/credential-resolver.service'; + +@RestController('/credential-resolvers') +export class CredentialResolversController { + constructor(private readonly service: DynamicCredentialResolverService) {} + + @Get('/') + @GlobalScope('credentialResolver:list') + async listResolvers(_req: AuthenticatedRequest, _res: Response): Promise { + try { + return credentialResolversSchema.parse(await this.service.findAll()); + } catch (e: unknown) { + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Get('/types') + @GlobalScope('credentialResolver:list') + listResolverTypes(_req: AuthenticatedRequest, _res: Response): CredentialResolverType[] { + try { + const types = this.service.getAvailableTypes(); + return credentialResolverTypesSchema.parse(types.map((t) => t.metadata)); + } catch (e: unknown) { + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Post('/') + @GlobalScope('credentialResolver:create') + async createResolver( + _req: AuthenticatedRequest, + _res: Response, + @Body dto: CreateCredentialResolverDto, + ): Promise { + try { + const createdResolver = await this.service.create({ + name: dto.name, + type: dto.type, + config: dto.config, + }); + return credentialResolverSchema.parse(createdResolver); + } catch (e: unknown) { + if (e instanceof CredentialResolverValidationError) { + throw new BadRequestError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Get('/:id') + @GlobalScope('credentialResolver:read') + async getResolver( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise { + try { + return credentialResolverSchema.parse(await this.service.findById(id)); + } catch (e: unknown) { + if (e instanceof DynamicCredentialResolverNotFoundError) { + throw new NotFoundError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Patch('/:id') + @GlobalScope('credentialResolver:update') + async updateResolver( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + @Body dto: UpdateCredentialResolverDto, + ): Promise { + try { + return credentialResolverSchema.parse( + await this.service.update(id, { + name: dto.name, + config: dto.config, + }), + ); + } catch (e: unknown) { + if (e instanceof DynamicCredentialResolverNotFoundError) { + throw new NotFoundError(e.message); + } + if (e instanceof CredentialResolverValidationError) { + throw new BadRequestError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } + + @Delete('/:id') + @GlobalScope('credentialResolver:delete') + async deleteResolver( + _req: AuthenticatedRequest, + _res: Response, + @Param('id') id: string, + ): Promise<{ success: true }> { + try { + await this.service.delete(id); + return { success: true }; + } catch (e: unknown) { + if (e instanceof DynamicCredentialResolverNotFoundError) { + throw new NotFoundError(e.message); + } + if (e instanceof Error) { + throw new InternalServerError(e.message, e); + } + throw e; + } + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/oauth-credential-resolver.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/oauth-credential-resolver.test.ts new file mode 100644 index 00000000000..daeb4f19aca --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/oauth-credential-resolver.test.ts @@ -0,0 +1,281 @@ +import type { Logger } from '@n8n/backend-common'; +import type { Cipher } from 'n8n-core'; + +import { testCredentialResolverContract, testHelpers } from './resolver-contract-tests'; +import type { OAuth2TokenIntrospectionIdentifier } from '../identifiers/oauth2-introspection-identifier'; +import { OAuthCredentialResolver } from '../oauth-credential-resolver'; +import type { DynamicCredentialEntryStorage } from '../storage/dynamic-credential-entry-storage'; + +describe('OAuthCredentialResolver', () => { + let mockLogger: jest.Mocked; + let mockIdentifier: jest.Mocked; + let mockStorage: jest.Mocked; + let mockCipher: jest.Mocked; + + const validOptions = { + metadataUri: 'https://auth.example.com/.well-known/openid-configuration', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + subjectClaim: 'sub', + }; + + beforeEach(() => { + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + mockIdentifier = { + resolve: jest.fn(), + validateOptions: jest.fn(), + } as unknown as jest.Mocked; + + mockStorage = { + getCredentialData: jest.fn(), + setCredentialData: jest.fn(), + deleteCredentialData: jest.fn(), + } as unknown as jest.Mocked; + + mockCipher = { + encrypt: jest.fn(), + decrypt: jest.fn(), + } as unknown as jest.Mocked; + }); + + // Run the standard contract tests + testCredentialResolverContract({ + createResolver: () => { + // Create an in-memory storage for contract tests + const storage = new Map(); + + // Reset mocks with stateful implementations + // Make identifier return different subjects for different identities + mockIdentifier.resolve.mockImplementation(async (context) => { + return `subject-${context.identity}`; + }); + mockIdentifier.validateOptions.mockResolvedValue(undefined); + + mockStorage.getCredentialData.mockImplementation( + async (credentialId, subjectId, resolverId) => { + const key = `${credentialId}:${subjectId}:${resolverId}`; + return storage.get(key) ?? null; + }, + ); + + mockStorage.setCredentialData.mockImplementation( + async (credentialId, subjectId, resolverId, data) => { + const key = `${credentialId}:${subjectId}:${resolverId}`; + storage.set(key, data); + }, + ); + + mockStorage.deleteCredentialData.mockImplementation( + async (credentialId, subjectId, resolverId) => { + const key = `${credentialId}:${subjectId}:${resolverId}`; + storage.delete(key); + }, + ); + + mockCipher.encrypt.mockImplementation((data) => JSON.stringify(data)); + mockCipher.decrypt.mockImplementation((data) => data); + + return new OAuthCredentialResolver(mockLogger, mockIdentifier, mockStorage, mockCipher); + }, + validationTests: { + validOptions: [ + ['complete OAuth config', validOptions], + ['with custom subject claim', { ...validOptions, subjectClaim: 'email' }], + [ + 'minimal config (subjectClaim defaults to "sub")', + { + metadataUri: validOptions.metadataUri, + clientId: validOptions.clientId, + clientSecret: validOptions.clientSecret, + }, + ], + ], + invalidOptions: [ + ['missing metadataUri', { clientId: 'x', clientSecret: 'y' }], + ['missing clientId', { metadataUri: validOptions.metadataUri, clientSecret: 'y' }], + ['missing clientSecret', { metadataUri: validOptions.metadataUri, clientId: 'x' }], + [ + 'invalid metadataUri (not a URL)', + { metadataUri: 'not-a-url', clientId: 'x', clientSecret: 'y' }, + ], + ], + }, + }); + + // OAuth-specific behavior tests + describe('OAuth-specific behavior', () => { + let resolver: OAuthCredentialResolver; + + beforeEach(() => { + mockIdentifier.resolve.mockResolvedValue('oauth-subject-123'); + mockIdentifier.validateOptions.mockResolvedValue(undefined); + resolver = new OAuthCredentialResolver(mockLogger, mockIdentifier, mockStorage, mockCipher); + }); + + describe('getSecret', () => { + it('should use identifier to resolve subject from token', async () => { + const credentialId = 'cred-123'; + const context = testHelpers.createContext('access-token-xyz'); + const handle = testHelpers.createHandle(validOptions); + + mockStorage.getCredentialData.mockResolvedValue('encrypted-credential-data'); + mockCipher.decrypt.mockReturnValue('{"apiKey":"decrypted-key"}'); + + await resolver.getSecret(credentialId, context, handle); + + expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, validOptions); + }); + + it('should decrypt data retrieved from storage', async () => { + const credentialId = 'cred-123'; + const context = testHelpers.createContext('access-token-xyz'); + const handle = testHelpers.createHandle(validOptions); + + mockStorage.getCredentialData.mockResolvedValue('encrypted-data-from-db'); + mockCipher.decrypt.mockReturnValue('{"apiKey":"secret-key-123"}'); + + const result = await resolver.getSecret(credentialId, context, handle); + + expect(mockCipher.decrypt).toHaveBeenCalledWith('encrypted-data-from-db'); + expect(result).toEqual({ apiKey: 'secret-key-123' }); + }); + + it('should throw when decrypted data is not valid JSON', async () => { + const credentialId = 'cred-123'; + const context = testHelpers.createContext('access-token-xyz'); + const handle = testHelpers.createHandle(validOptions); + + mockStorage.getCredentialData.mockResolvedValue('encrypted-data'); + mockCipher.decrypt.mockReturnValue('invalid-json{{{'); + + await expect(resolver.getSecret(credentialId, context, handle)).rejects.toThrow(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to parse decrypted credential data', + expect.any(Object), + ); + }); + }); + + describe('setSecret', () => { + it('should encrypt data before storing', async () => { + const credentialId = 'cred-123'; + const context = testHelpers.createContext('access-token-xyz'); + const data = testHelpers.createCredentialData({ apiKey: 'new-key' }); + const handle = testHelpers.createHandle(validOptions); + + mockCipher.encrypt.mockReturnValue('encrypted-new-data'); + + await resolver.setSecret(credentialId, context, data, handle); + + expect(mockCipher.encrypt).toHaveBeenCalledWith(data); + expect(mockStorage.setCredentialData).toHaveBeenCalledWith( + credentialId, + 'oauth-subject-123', + handle.resolverId, + 'encrypted-new-data', + validOptions, + ); + }); + + it('should use resolved subject as storage key', async () => { + const credentialId = 'cred-123'; + const context = testHelpers.createContext('access-token-xyz'); + const data = testHelpers.createCredentialData(); + const handle = testHelpers.createHandle(validOptions); + + mockIdentifier.resolve.mockResolvedValue('resolved-subject-456'); + mockCipher.encrypt.mockReturnValue('encrypted-data'); + + await resolver.setSecret(credentialId, context, data, handle); + + expect(mockStorage.setCredentialData).toHaveBeenCalledWith( + credentialId, + 'resolved-subject-456', + handle.resolverId, + 'encrypted-data', + validOptions, + ); + }); + }); + + describe('deleteSecret', () => { + it('should use resolved subject for deletion', async () => { + const credentialId = 'cred-123'; + const context = testHelpers.createContext('access-token-xyz'); + const handle = testHelpers.createHandle(validOptions); + + mockIdentifier.resolve.mockResolvedValue('subject-to-delete'); + + await resolver.deleteSecret(credentialId, context, handle); + + expect(mockIdentifier.resolve).toHaveBeenCalledWith(context, validOptions); + expect(mockStorage.deleteCredentialData).toHaveBeenCalledWith( + credentialId, + 'subject-to-delete', + handle.resolverId, + validOptions, + ); + }); + }); + + describe('validateOptions', () => { + it('should delegate validation to identifier', async () => { + await resolver.validateOptions(validOptions); + + expect(mockIdentifier.validateOptions).toHaveBeenCalledWith(validOptions); + }); + + it('should throw when identifier validation fails', async () => { + mockIdentifier.validateOptions.mockRejectedValue(new Error('Invalid metadata')); + + await expect(resolver.validateOptions(validOptions)).rejects.toThrow('Invalid metadata'); + }); + }); + + describe('isolation with different tokens', () => { + it('should store credentials for different tokens separately', async () => { + const credentialId = 'cred-123'; + const token1 = 'token-user-1'; + const token2 = 'token-user-2'; + const handle = testHelpers.createHandle(validOptions); + + // Mock identifier to return different subjects for different tokens + mockIdentifier.resolve.mockImplementation(async (context) => { + if (context.identity === token1) return 'subject-1'; + if (context.identity === token2) return 'subject-2'; + return 'unknown'; + }); + + const data1 = testHelpers.createCredentialData({ apiKey: 'key-1' }); + const data2 = testHelpers.createCredentialData({ apiKey: 'key-2' }); + + mockCipher.encrypt.mockReturnValue('encrypted'); + + await resolver.setSecret(credentialId, testHelpers.createContext(token1), data1, handle); + await resolver.setSecret(credentialId, testHelpers.createContext(token2), data2, handle); + + // Verify both were stored with different subjects + expect(mockStorage.setCredentialData).toHaveBeenCalledWith( + credentialId, + 'subject-1', + handle.resolverId, + 'encrypted', + validOptions, + ); + expect(mockStorage.setCredentialData).toHaveBeenCalledWith( + credentialId, + 'subject-2', + handle.resolverId, + 'encrypted', + validOptions, + ); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/resolver-contract-tests.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/resolver-contract-tests.ts new file mode 100644 index 00000000000..4290236ed34 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/resolver-contract-tests.ts @@ -0,0 +1,350 @@ +import type { + CredentialResolverConfiguration, + CredentialResolverHandle, + ICredentialResolver, +} from '@n8n/decorators'; +import { + CredentialResolverDataNotFoundError, + CredentialResolverValidationError, +} from '@n8n/decorators'; +import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow'; + +/** + * Configuration for resolver contract tests + */ +export interface ResolverContractTestConfig { + /** Factory function to create a fresh resolver instance for each test */ + createResolver: () => ICredentialResolver | Promise; + + /** Optional async setup function called before all tests */ + beforeAll?: () => Promise; + + /** Optional async teardown function called after all tests */ + afterAll?: () => Promise; + + /** Optional cleanup function called after each test */ + afterEach?: () => Promise; + + /** Test cases for options validation */ + validationTests: { + /** Valid options that should pass validation */ + validOptions: Array<[string, CredentialResolverConfiguration]>; + /** Invalid options that should fail validation */ + invalidOptions: Array<[string, CredentialResolverConfiguration]>; + }; +} + +/** + * Helper functions for creating test data + */ +export const testHelpers = { + /** Creates a test credential context with the given identity */ + createContext: (identity: string): ICredentialContext => ({ + identity, + version: 1, + }), + + /** Creates test credential data */ + createCredentialData: (data: Record = {}): ICredentialDataDecryptedObject => ({ + apiKey: 'test-key-123', + apiSecret: 'test-secret-456', + ...data, + }), + + /** Creates a credential resolver handle with the given configuration */ + createHandle: (configuration: CredentialResolverConfiguration): CredentialResolverHandle => ({ + configuration, + resolverName: 'test-resolver', + resolverId: 'test-resolver-id', + }), + + /** Generates a random credential ID for isolation */ + randomCredentialId: (): string => `cred-${Math.random().toString(36).substring(7)}`, + + /** Generates a random identity for isolation */ + randomIdentity: (): string => `identity-${Math.random().toString(36).substring(7)}`, +}; + +/** + * Reusable test suite for ICredentialResolver contract compliance. + * Tests that any resolver implementation correctly implements the interface contract. + * + * @example + * describe('MyResolver', () => { + * testCredentialResolverContract({ + * createResolver: () => new MyResolver(deps), + * validationTests: { + * validOptions: [{ endpoint: 'https://api.example.com' }], + * invalidOptions: [{ options: { endpoint: 123 }, description: 'invalid endpoint type' }], + * }, + * }); + * }); + */ +export function testCredentialResolverContract(config: ResolverContractTestConfig) { + const { createResolver, validationTests } = config; + + let resolver: ICredentialResolver; + + describe('ICredentialResolver contract', () => { + beforeAll(async () => { + if (config.beforeAll) { + await config.beforeAll(); + } + }); + + afterAll(async () => { + if (config.afterAll) { + await config.afterAll(); + } + }); + + beforeEach(async () => { + resolver = await createResolver(); + }); + + afterEach(async () => { + if (config.afterEach) { + await config.afterEach(); + } + }); + + describe('validateOptions', () => { + it.each(validationTests.validOptions)( + 'should accept valid options: %s', + async (_, options) => { + await expect(resolver.validateOptions(options)).resolves.not.toThrow(); + }, + ); + + it.each(validationTests.invalidOptions)( + 'should reject invalid options: %s', + async (_, options) => { + await expect(resolver.validateOptions(options)).rejects.toThrow( + CredentialResolverValidationError, + ); + }, + ); + }); + + describe('getSecret', () => { + it('should throw CredentialResolverDataNotFoundError when data does not exist', async () => { + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await expect(resolver.getSecret(credentialId, context, handle)).rejects.toThrow( + CredentialResolverDataNotFoundError, + ); + }); + + it('should return data that was previously stored', async () => { + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const data = testHelpers.createCredentialData(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await resolver.setSecret(credentialId, context, data, handle); + const retrieved = await resolver.getSecret(credentialId, context, handle); + + expect(retrieved).toEqual(data); + }); + + it('should throw when retrieving after delete (if deleteSecret exists)', async () => { + if (!resolver.deleteSecret) { + return; // Skip if deleteSecret not implemented + } + + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const data = testHelpers.createCredentialData(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await resolver.setSecret(credentialId, context, data, handle); + await resolver.deleteSecret(credentialId, context, handle); + + await expect(resolver.getSecret(credentialId, context, handle)).rejects.toThrow( + CredentialResolverDataNotFoundError, + ); + }); + }); + + describe('setSecret', () => { + it('should store data that can be retrieved', async () => { + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const data = testHelpers.createCredentialData(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await resolver.setSecret(credentialId, context, data, handle); + const retrieved = await resolver.getSecret(credentialId, context, handle); + + expect(retrieved).toEqual(data); + }); + + it('should overwrite existing data', async () => { + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + const data1 = testHelpers.createCredentialData({ apiKey: 'key-1' }); + const data2 = testHelpers.createCredentialData({ apiKey: 'key-2' }); + + await resolver.setSecret(credentialId, context, data1, handle); + await resolver.setSecret(credentialId, context, data2, handle); + + const retrieved = await resolver.getSecret(credentialId, context, handle); + expect(retrieved).toEqual(data2); + }); + }); + + describe('deleteSecret (if implemented)', () => { + it('should remove stored data', async () => { + if (!resolver.deleteSecret) { + return; // Skip if deleteSecret not implemented + } + + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const data = testHelpers.createCredentialData(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await resolver.setSecret(credentialId, context, data, handle); + await resolver.deleteSecret(credentialId, context, handle); + + await expect(resolver.getSecret(credentialId, context, handle)).rejects.toThrow( + CredentialResolverDataNotFoundError, + ); + }); + + it('should be idempotent (deleting twice should not error)', async () => { + if (!resolver.deleteSecret) { + return; // Skip if deleteSecret not implemented + } + + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const data = testHelpers.createCredentialData(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await resolver.setSecret(credentialId, context, data, handle); + await resolver.deleteSecret(credentialId, context, handle); + await expect(resolver.deleteSecret(credentialId, context, handle)).resolves.not.toThrow(); + }); + + it('should not error when deleting non-existent data', async () => { + if (!resolver.deleteSecret) { + return; // Skip if deleteSecret not implemented + } + + const credentialId = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + await expect(resolver.deleteSecret(credentialId, context, handle)).resolves.not.toThrow(); + }); + }); + + describe('isolation', () => { + it('should isolate data by credential ID', async () => { + const credentialId1 = testHelpers.randomCredentialId(); + const credentialId2 = testHelpers.randomCredentialId(); + const context = testHelpers.createContext(testHelpers.randomIdentity()); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + const data1 = testHelpers.createCredentialData({ apiKey: 'key-1' }); + const data2 = testHelpers.createCredentialData({ apiKey: 'key-2' }); + + await resolver.setSecret(credentialId1, context, data1, handle); + await resolver.setSecret(credentialId2, context, data2, handle); + + const retrieved1 = await resolver.getSecret(credentialId1, context, handle); + const retrieved2 = await resolver.getSecret(credentialId2, context, handle); + + expect(retrieved1).toEqual(data1); + expect(retrieved2).toEqual(data2); + }); + + it('should isolate data by identity', async () => { + const credentialId = testHelpers.randomCredentialId(); + const identity1 = testHelpers.randomIdentity(); + const identity2 = testHelpers.randomIdentity(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + const context1 = testHelpers.createContext(identity1); + const context2 = testHelpers.createContext(identity2); + + const data1 = testHelpers.createCredentialData({ apiKey: 'key-1' }); + const data2 = testHelpers.createCredentialData({ apiKey: 'key-2' }); + + await resolver.setSecret(credentialId, context1, data1, handle); + await resolver.setSecret(credentialId, context2, data2, handle); + + const retrieved1 = await resolver.getSecret(credentialId, context1, handle); + const retrieved2 = await resolver.getSecret(credentialId, context2, handle); + + expect(retrieved1).toEqual(data1); + expect(retrieved2).toEqual(data2); + }); + + it('should use same storage for same credential ID and identity', async () => { + const credentialId = testHelpers.randomCredentialId(); + const identity = testHelpers.randomIdentity(); + const context = testHelpers.createContext(identity); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + const data = testHelpers.createCredentialData(); + + await resolver.setSecret(credentialId, context, data, handle); + + // Create a new context with same identity + const sameContext = testHelpers.createContext(identity); + const retrieved = await resolver.getSecret(credentialId, sameContext, handle); + + expect(retrieved).toEqual(data); + }); + + it('should not affect other identities when deleting (if deleteSecret exists)', async () => { + if (!resolver.deleteSecret) { + return; // Skip if deleteSecret not implemented + } + + const credentialId = testHelpers.randomCredentialId(); + const identity1 = testHelpers.randomIdentity(); + const identity2 = testHelpers.randomIdentity(); + const options = validationTests.validOptions[0][1]; + const handle = testHelpers.createHandle(options); + + const context1 = testHelpers.createContext(identity1); + const context2 = testHelpers.createContext(identity2); + + const data1 = testHelpers.createCredentialData({ apiKey: 'key-1' }); + const data2 = testHelpers.createCredentialData({ apiKey: 'key-2' }); + + await resolver.setSecret(credentialId, context1, data1, handle); + await resolver.setSecret(credentialId, context2, data2, handle); + + await resolver.deleteSecret(credentialId, context1, handle); + + // Identity1 should be deleted + await expect(resolver.getSecret(credentialId, context1, handle)).rejects.toThrow( + CredentialResolverDataNotFoundError, + ); + + // Identity2 should still exist + const retrieved2 = await resolver.getSecret(credentialId, context2, handle); + expect(retrieved2).toEqual(data2); + }); + }); + }); +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/stub-credential-resolver.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/stub-credential-resolver.test.ts new file mode 100644 index 00000000000..3344f603678 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/__tests__/stub-credential-resolver.test.ts @@ -0,0 +1,81 @@ +import type { Logger } from '@n8n/backend-common'; + +import { testCredentialResolverContract, testHelpers } from './resolver-contract-tests'; +import { StubCredentialResolver } from '../stub-credential-resolver'; + +describe('StubCredentialResolver', () => { + let mockLogger: jest.Mocked; + + beforeEach(() => { + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + }); + + // Run the standard contract tests + testCredentialResolverContract({ + createResolver: () => new StubCredentialResolver(mockLogger), + validationTests: { + validOptions: [ + ['no options', {}], // No options + ['empty prefix', { prefix: '' }], // Empty prefix + ['with prefix', { prefix: 'test' }], // With prefix + ['complex prefix', { prefix: 'namespace:sub' }], // Complex prefix + ], + invalidOptions: [ + ['prefix must be string', { prefix: 123 }], + ['prefix cannot be null', { prefix: null }], + ], + }, + }); + + // Stub-specific tests (if any unique behavior needs testing) + describe('stub-specific behavior', () => { + it('should use prefix in key generation', async () => { + const resolver = new StubCredentialResolver(mockLogger); + const credentialId = 'cred-123'; + const context = testHelpers.createContext('user-1'); + const data = { apiKey: 'test-key' }; + + const handleWithPrefix = testHelpers.createHandle({ prefix: 'test' }); + const handleWithOtherPrefix = testHelpers.createHandle({ prefix: 'other' }); + const handleWithoutPrefix = testHelpers.createHandle({}); + + // Store with prefix + await resolver.setSecret(credentialId, context, data, handleWithPrefix); + + // Should retrieve with same prefix + const retrieved = await resolver.getSecret(credentialId, context, handleWithPrefix); + expect(retrieved).toEqual(data); + + // Should NOT retrieve with different prefix + await expect( + resolver.getSecret(credentialId, context, handleWithOtherPrefix), + ).rejects.toThrow(); + + // Should NOT retrieve without prefix + await expect( + resolver.getSecret(credentialId, context, handleWithoutPrefix), + ).rejects.toThrow(); + }); + + it('should store data in memory (lost on restart)', async () => { + const resolver1 = new StubCredentialResolver(mockLogger); + const resolver2 = new StubCredentialResolver(mockLogger); + + const credentialId = 'cred-123'; + const context = testHelpers.createContext('user-1'); + const data = { apiKey: 'test-key' }; + const handle = testHelpers.createHandle({}); + + // Store in first instance + await resolver1.setSecret(credentialId, context, data, handle); + + // Should not be available in second instance (different memory) + await expect(resolver2.getSecret(credentialId, context, handle)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts new file mode 100644 index 00000000000..d1681da054a --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/__tests__/oauth2-introspection-identifier.test.ts @@ -0,0 +1,267 @@ +import { mockLogger } from '@n8n/backend-test-utils'; +import { Time } from '@n8n/constants'; +import axios from 'axios'; +import { mock } from 'jest-mock-extended'; + +import type { CacheService } from '@/services/cache/cache.service'; + +import { IdentifierValidationError } from '../identifier-interface'; +import { OAuth2TokenIntrospectionIdentifier } from '../oauth2-introspection-identifier'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('OAuth2TokenIntrospectionIdentifier', () => { + const logger = mockLogger(); + const cache = mock(); + let identifier: OAuth2TokenIntrospectionIdentifier; + + const validOptions = { + metadataUri: 'https://auth.example.com/.well-known/oauth-authorization-server', + clientId: 'test-client', + clientSecret: 'test-secret', + subjectClaim: 'sub', + }; + + const validMetadata = { + issuer: 'https://auth.example.com', + introspection_endpoint: 'https://auth.example.com/oauth/introspect', + introspection_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + }; + + const validIntrospectionResponse = { + active: true, + sub: 'user-123', + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + scope: 'read write', + client_id: 'test-client', + }; + + const mockContext = { + identity: 'mock-access-token', + version: 1 as const, + }; + + beforeEach(() => { + jest.clearAllMocks(); + identifier = new OAuth2TokenIntrospectionIdentifier(logger, cache); + cache.get.mockResolvedValue(undefined); + cache.set.mockResolvedValue(); + }); + + describe('Happy Path', () => { + test('should resolve subject successfully with client_secret_basic', async () => { + // Mock metadata endpoint + mockedAxios.get.mockResolvedValueOnce({ + status: 200, + data: validMetadata, + }); + + // Mock introspection endpoint + mockedAxios.post.mockResolvedValueOnce({ + status: 200, + data: validIntrospectionResponse, + }); + + const result = await identifier.resolve(mockContext, validOptions); + + expect(result).toBe('user-123'); + expect(cache.set).toHaveBeenCalledWith( + expect.stringContaining('oauth2-introspection-identifier:subject'), + 'user-123', + expect.any(Number), + ); + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://auth.example.com/oauth/introspect', + expect.any(URLSearchParams), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), + }), + ); + }); + + test('should return cached result on subsequent calls', async () => { + // First cache.get call is for metadata (returns undefined, so will fetch) + // Second cache.get call is for subject (returns cached value) + cache.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce('cached-user-123'); + + mockedAxios.get.mockResolvedValueOnce({ + status: 200, + data: validMetadata, + }); + + const result = await identifier.resolve(mockContext, validOptions); + + expect(result).toBe('cached-user-123'); + expect(mockedAxios.post).not.toHaveBeenCalled(); // Should not call introspection + }); + + test('should extract subject from custom claim', async () => { + const customOptions = { ...validOptions, subjectClaim: 'username' }; + const customResponse = { ...validIntrospectionResponse, username: 'john.doe' }; + + mockedAxios.get.mockResolvedValueOnce({ + status: 200, + data: validMetadata, + }); + + mockedAxios.post.mockResolvedValueOnce({ + status: 200, + data: customResponse, + }); + + const result = await identifier.resolve(mockContext, customOptions); + + expect(result).toBe('john.doe'); + }); + }); + + describe('Critical Errors', () => { + test('should throw IdentifierValidationError for invalid options', async () => { + const invalidOptions = { metadataUri: 'not-a-url' }; + + await expect(identifier.resolve(mockContext, invalidOptions)).rejects.toThrow( + IdentifierValidationError, + ); + }); + + test('should throw IdentifierValidationError when token is not active', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: validMetadata, + }); + + mockedAxios.post.mockResolvedValue({ + status: 200, + data: { ...validIntrospectionResponse, active: false }, + }); + + await expect(identifier.resolve(mockContext, validOptions)).rejects.toThrow( + IdentifierValidationError, + ); + await expect(identifier.resolve(mockContext, validOptions)).rejects.toThrow( + 'Token is not active', + ); + }); + + test('should throw IdentifierValidationError when metadata fetch fails', async () => { + mockedAxios.get.mockResolvedValue({ + status: 404, + data: {}, + }); + + await expect(identifier.resolve(mockContext, validOptions)).rejects.toThrow( + IdentifierValidationError, + ); + await expect(identifier.resolve(mockContext, validOptions)).rejects.toThrow( + 'Failed to fetch OAuth2 metadata', + ); + }); + + test('should throw IdentifierValidationError when subject claim is missing', async () => { + mockedAxios.get.mockResolvedValue({ + status: 200, + data: validMetadata, + }); + + const responseWithoutSub = { active: true, exp: validIntrospectionResponse.exp }; + mockedAxios.post.mockResolvedValue({ + status: 200, + data: responseWithoutSub, + }); + + await expect(identifier.resolve(mockContext, validOptions)).rejects.toThrow( + IdentifierValidationError, + ); + await expect(identifier.resolve(mockContext, validOptions)).rejects.toThrow( + 'missing subject claim', + ); + }); + }); + + describe('Edge Cases', () => { + test('should default to client_secret_basic when auth methods not specified', async () => { + const metadataWithoutAuthMethods = { + issuer: 'https://auth.example.com', + introspection_endpoint: 'https://auth.example.com/oauth/introspect', + }; + + mockedAxios.get.mockResolvedValueOnce({ + status: 200, + data: metadataWithoutAuthMethods, + }); + + mockedAxios.post.mockResolvedValueOnce({ + status: 200, + data: validIntrospectionResponse, + }); + + const result = await identifier.resolve(mockContext, validOptions); + + expect(result).toBe('user-123'); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(URLSearchParams), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringMatching(/^Basic /), + }), + }), + ); + }); + + test('should cap TTL at MAX_TOKEN_CACHE_TIMEOUT for long-lived token', async () => { + const longLivedResponse = { + ...validIntrospectionResponse, + exp: Math.floor(Date.now() / 1000) + 7200, // 2 hours from now + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: validMetadata, + }); + + mockedAxios.post.mockResolvedValue({ + status: 200, + data: longLivedResponse, + }); + + await identifier.resolve(mockContext, validOptions); + + // Check that the subject cache was set with the max TTL + // Find the call that sets the subject (not metadata) + const subjectCacheCall = cache.set.mock.calls.find((call) => call[0].includes(':subject:')); + + expect(subjectCacheCall).toBeDefined(); + expect(subjectCacheCall![2]).toBe(5 * Time.minutes.toMilliseconds); + }); + + test('should use MIN_TOKEN_CACHE_TIMEOUT for expired but active token', async () => { + const expiredButActiveResponse = { + ...validIntrospectionResponse, + exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago + }; + + mockedAxios.get.mockResolvedValue({ + status: 200, + data: validMetadata, + }); + + mockedAxios.post.mockResolvedValue({ + status: 200, + data: expiredButActiveResponse, + }); + + await identifier.resolve(mockContext, validOptions); + + // Check that the subject cache was set with the min TTL + // Find the call that sets the subject (not metadata) + const subjectCacheCall = cache.set.mock.calls.find((call) => call[0].includes(':subject:')); + + expect(subjectCacheCall).toBeDefined(); + expect(subjectCacheCall![2]).toBe(30 * Time.seconds.toMilliseconds); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/identifier-interface.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/identifier-interface.ts new file mode 100644 index 00000000000..2f20665ded0 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/identifier-interface.ts @@ -0,0 +1,29 @@ +import type { ICredentialContext } from 'n8n-workflow'; + +/** + * Error thrown when token identifier validation or resolution fails + */ +export class IdentifierValidationError extends Error {} + +/** + * Interface for resolving unique identifiers from credential contexts + */ +export interface ITokenIdentifier { + /** + * Resolves a unique identifier for the entity in the given context + * + * @param context - Credential context with execution details + * @param identifierOptions - Implementation-specific options + * @returns Unique identifier string + * @throws {IdentifierValidationError} When validation or resolution fails + */ + resolve(context: ICredentialContext, identifierOptions: Record): Promise; + + /** + * Validates identifier options before use + * + * @param identifierOptions - Implementation-specific options + * @throws {IdentifierValidationError} When options are invalid + */ + validateOptions(identifierOptions: Record): Promise; +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts new file mode 100644 index 00000000000..08e92c5401d --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-introspection-identifier.ts @@ -0,0 +1,289 @@ +import { Logger } from '@n8n/backend-common'; +import { Time } from '@n8n/constants'; +import { Service } from '@n8n/di'; +import axios from 'axios'; +import type { ICredentialContext } from 'n8n-workflow'; +import { z } from 'zod'; + +import { CacheService } from '@/services/cache/cache.service'; + +import { IdentifierValidationError, ITokenIdentifier } from './identifier-interface'; +import { OAuth2OptionsSchema, sha256 } from './oauth2-utils'; + +// Use minimum of 30 seconds to avoid cache thrashing +// Cap at 5 minutes to ensure periodic revalidation +const MIN_TOKEN_CACHE_TIMEOUT = 30 * Time.seconds.toMilliseconds; +const MAX_TOKEN_CACHE_TIMEOUT = 5 * Time.minutes.toMilliseconds; +const DEFAULT_CACHE_TIMEOUT = 60 * Time.seconds.toMilliseconds; // 60 seconds +const METADATA_CACHE_TIMEOUT = 1 * Time.hours.toMilliseconds; // 1 hour + +export const OAuth2IntrospectionOptionsSchema = z.object({ + ...OAuth2OptionsSchema.shape, + clientId: z.string(), + clientSecret: z.string(), +}); + +type OAuth2IntrospectionOptions = z.infer; + +const OAuth2MetadataSchema = z.object({ + issuer: z.string().url(), + introspection_endpoint: z.string().url(), + // This could be an well defined enum, but to make sure we are not failing validation + // of unknown values, we keep it as string + introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), +}); + +type OAuth2Metadata = z.infer; + +export const TokenIntrospectionResponseSchema = z + .object({ + // Core fields + active: z.boolean(), + + // Standard optional fields + scope: z.string().optional(), + client_id: z.string().optional(), + username: z.string().optional(), + token_type: z.string().optional(), + exp: z.number().int().optional(), + iat: z.number().int().optional(), + nbf: z.number().int().optional(), + sub: z.string().optional(), + aud: z.union([z.string(), z.array(z.string())]).optional(), + iss: z.string().optional(), + jti: z.string().optional(), + }) + .passthrough(); // Allow additional custom claims + +export type TokenIntrospectionResponse = z.infer; + +const CACHE_PREFIX = 'oauth2-introspection-identifier'; + +@Service() +export class OAuth2TokenIntrospectionIdentifier implements ITokenIdentifier { + constructor( + private readonly logger: Logger, + private readonly cache: CacheService, + ) {} + + async validateOptions(identifierOptions: Record): Promise { + const options = this.parseOptions(identifierOptions); + const metadata = await this.fetchMetadata(options, true); + if (!metadata.introspection_endpoint) { + this.logger.error('Metadata does not contain an introspection endpoint'); + throw new IdentifierValidationError('Metadata does not contain an introspection endpoint'); + } + if (metadata.introspection_endpoint_auth_methods_supported) { + const supportedMethods = metadata.introspection_endpoint_auth_methods_supported; + if ( + !supportedMethods.includes('client_secret_basic') && + !supportedMethods.includes('client_secret_post') + ) { + this.logger.error( + 'No supported client authentication method for introspection endpoint, supported options are client_secret_basic and client_secret_post', + ); + throw new IdentifierValidationError( + 'No supported client authentication method for introspection endpoint, supported options are client_secret_basic and client_secret_post', + ); + } + } + } + + async resolve( + context: ICredentialContext, + identifierOptions: Record, + ): Promise { + const options = this.parseOptions(identifierOptions); + const metadata = await this.fetchMetadata(options); + + const hashedToken = sha256(context.identity); + + const identifierCacheKey = `${CACHE_PREFIX}:subject:${metadata.issuer}:${hashedToken}`; + const cached = await this.cache.get(identifierCacheKey); + if (cached) { + return cached; + } + + let ttl = DEFAULT_CACHE_TIMEOUT; + const { subject, ttl: ttlOverwrite } = await this.resolveBasedOnTokenIntrospection( + metadata, + options, + context, + ); + if (ttlOverwrite) { + ttl = ttlOverwrite; + } + + await this.cache.set(identifierCacheKey, subject, ttl); + return subject; + } + + // ------------------------ Private Methods ----------------------- // + + private parseOptions(options: Record): OAuth2IntrospectionOptions { + try { + return OAuth2IntrospectionOptionsSchema.parse(options); + } catch (error) { + this.logger.error('Invalid OAuth2 identifier options', { error }); + throw new IdentifierValidationError('Invalid OAuth2 identifier options', { + cause: error, + }); + } + } + + private async fetchMetadata( + options: OAuth2IntrospectionOptions, + skipCache: boolean = false, + ): Promise { + const cacheKey = `${CACHE_PREFIX}:metadata:${options.metadataUri}`; + if (!skipCache) { + const cached = await this.cache.get(cacheKey); + if (cached) { + return cached; + } + } + + const response = await axios.get(options.metadataUri, { + validateStatus: () => true, + timeout: 10 * Time.seconds.toMilliseconds, + }); + + if (response.status !== 200) { + this.logger.error( + `Failed to fetch OAuth2 metadata from ${options.metadataUri}, status code: ${response.status}`, + ); + throw new IdentifierValidationError( + `Failed to fetch OAuth2 metadata, status code: ${response.status}`, + ); + } + + try { + const metadata = OAuth2MetadataSchema.parse(response.data); + if (!skipCache) { + await this.cache.set(cacheKey, metadata, METADATA_CACHE_TIMEOUT); + } + return metadata; + } catch (error) { + this.logger.error('Invalid OAuth2 metadata format', { error }); + throw new IdentifierValidationError('Invalid OAuth2 metadata format', { cause: error }); + } + } + + private buildClientBasicRequest(options: OAuth2IntrospectionOptions): { + headers: Record; + params: Record; + } { + const authHeaders: Record = {}; + const authParams: Record = {}; + + const credentials = Buffer.from( + `${encodeURIComponent(options.clientId)}:${encodeURIComponent(options.clientSecret)}`, + ).toString('base64'); + authHeaders['Authorization'] = `Basic ${credentials}`; + + return { headers: authHeaders, params: authParams }; + } + + private buildClientPostRequest(options: OAuth2IntrospectionOptions): { + headers: Record; + params: Record; + } { + const authHeaders: Record = {}; + const authParams: Record = {}; + + authParams['client_id'] = options.clientId; + authParams['client_secret'] = options.clientSecret; + + return { headers: authHeaders, params: authParams }; + } + + private parseIntrospectionResponse(data: unknown): TokenIntrospectionResponse { + try { + return TokenIntrospectionResponseSchema.parse(data); + } catch (error) { + this.logger.error('Invalid token introspection response format', { error }); + throw new IdentifierValidationError('Invalid token introspection response format'); + } + } + + private async resolveBasedOnTokenIntrospection( + metadata: OAuth2Metadata, + options: OAuth2IntrospectionOptions, + context: ICredentialContext, + ): Promise<{ subject: string; ttl?: number }> { + // Use token introspection to validate and get subject + const supportedMethods = metadata.introspection_endpoint_auth_methods_supported; + const useBasic = !supportedMethods || supportedMethods.includes('client_secret_basic'); + const usePost = !useBasic && supportedMethods?.includes('client_secret_post'); + + let authHeaders: Record = {}; + let authParams: Record = {}; + + if (useBasic) { + const result = this.buildClientBasicRequest(options); + authHeaders = result.headers; + authParams = result.params; + } else if (usePost) { + const result = this.buildClientPostRequest(options); + authHeaders = result.headers; + authParams = result.params; + } else { + this.logger.error('No supported client authentication method for introspection endpoint'); + throw new IdentifierValidationError( + 'No supported client authentication method for introspection endpoint', + ); + } + + const params = new URLSearchParams({ + token: context.identity, + ...authParams, + }); + + const response = await axios.post(metadata.introspection_endpoint, params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...authHeaders }, + validateStatus: () => true, + timeout: 10 * Time.seconds.toMilliseconds, + }); + + if (response.status !== 200) { + this.logger.error('Token introspection failed', { + status: response.status, + data: response.data, + }); + throw new IdentifierValidationError('Token introspection failed'); + } + + const introspectionData = this.parseIntrospectionResponse(response.data); + + if (!introspectionData.active) { + this.logger.error('Token is not active according to introspection response'); + throw new IdentifierValidationError('Token is not active'); + } + + const subject = introspectionData[options.subjectClaim]; + if (!subject) { + this.logger.error( + `Token introspection response missing subject claim (${options.subjectClaim})`, + ); + throw new IdentifierValidationError( + `Token introspection response missing subject claim (${options.subjectClaim})`, + ); + } + + const subjectStr = String(subject); + + this.logger.debug('Token introspected successfully', { subject: subjectStr }); + + let ttl: number | undefined = undefined; + if (introspectionData.exp) { + const expiresIn = introspectionData.exp * 1000 - Date.now(); + if (expiresIn > 0) { + ttl = Math.max(MIN_TOKEN_CACHE_TIMEOUT, Math.min(expiresIn, MAX_TOKEN_CACHE_TIMEOUT)); + } else { + ttl = MIN_TOKEN_CACHE_TIMEOUT; + } + } + + return { subject: subjectStr, ttl }; + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-utils.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-utils.ts new file mode 100644 index 00000000000..187f2df40db --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/identifiers/oauth2-utils.ts @@ -0,0 +1,13 @@ +import crypto from 'crypto'; +import z from 'zod'; + +export const OAuth2OptionsSchema = z.object({ + metadataUri: z.string().url(), + subjectClaim: z.string().optional().default('sub'), +}); + +export type OAuth2Options = z.infer; + +export function sha256(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/index.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/index.ts new file mode 100644 index 00000000000..52e8119533a --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/index.ts @@ -0,0 +1,2 @@ +import './stub-credential-resolver'; +import './oauth-credential-resolver'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/oauth-credential-resolver.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/oauth-credential-resolver.ts new file mode 100644 index 00000000000..736cba94981 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/oauth-credential-resolver.ts @@ -0,0 +1,160 @@ +import { Logger } from '@n8n/backend-common'; +import { + CredentialResolver, + CredentialResolverConfiguration, + CredentialResolverDataNotFoundError, + CredentialResolverHandle, + CredentialResolverValidationError, + ICredentialResolver, +} from '@n8n/decorators'; +import { Cipher } from 'n8n-core'; +import { ICredentialContext, ICredentialDataDecryptedObject, jsonParse } from 'n8n-workflow'; +import z from 'zod'; + +import { + OAuth2IntrospectionOptionsSchema, + OAuth2TokenIntrospectionIdentifier, +} from './identifiers/oauth2-introspection-identifier'; +import { DynamicCredentialEntryStorage } from './storage/dynamic-credential-entry-storage'; + +const OAuthCredentialResolverOptionsSchema = z.object({ + ...OAuth2IntrospectionOptionsSchema.shape, +}); + +/** + * OAuth2 token introspection-based credential resolver. + * Resolves user identity via OAuth2 token introspection and stores credentials + * encrypted in the database, keyed by the introspected subject. + */ +@CredentialResolver() +export class OAuthCredentialResolver implements ICredentialResolver { + constructor( + private readonly logger: Logger, + private readonly oAuth2TokenIntrospectionIdentifier: OAuth2TokenIntrospectionIdentifier, + private readonly storage: DynamicCredentialEntryStorage, + private readonly cipher: Cipher, + ) {} + + metadata = { + name: 'credential-resolver.oauth2-1.0', + description: 'OAuth2 token introspection-based credential resolver', + displayName: 'OAuth2 Introspection', + options: [ + { + displayName: 'Metadata URL', + name: 'metadataUri', + type: 'string' as const, + required: true, + default: '', + placeholder: 'https://auth.example.com/.well-known/openid-configuration', + description: 'OAuth2 server metadata endpoint URL', + }, + { + displayName: 'Client ID', + name: 'clientId', + type: 'string' as const, + default: '', + required: true, + description: 'OAuth2 client ID for introspection', + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string' as const, + default: '', + required: true, + typeOptions: { password: true }, + description: 'OAuth2 client secret for introspection', + }, + { + displayName: 'Subject Claim', + name: 'subjectClaim', + type: 'string' as const, + default: 'sub', + description: 'Token claim to use as subject identifier', + }, + ], + }; + + /** + * Retrieves stored credential data for the given identity. + * @throws {CredentialResolverDataNotFoundError} When no data exists for the key + */ + async getSecret( + credentialId: string, + context: ICredentialContext, + handle: CredentialResolverHandle, + ): Promise { + const parsedOptions = await this.parseOptions(handle.configuration); + const key = await this.oAuth2TokenIntrospectionIdentifier.resolve(context, parsedOptions); + + const data = await this.storage.getCredentialData( + credentialId, + key, + handle.resolverId, + parsedOptions, + ); + + if (!data) { + throw new CredentialResolverDataNotFoundError(); + } + const plaintext = this.cipher.decrypt(data); + try { + const secret = jsonParse(plaintext); + return secret; + } catch (error) { + this.logger.error('Failed to parse decrypted credential data', { error }); + throw new CredentialResolverDataNotFoundError(); + } + } + + /** Stores credential data for the given identity */ + async setSecret( + credentialId: string, + context: ICredentialContext, + data: ICredentialDataDecryptedObject, + handle: CredentialResolverHandle, + ): Promise { + const parsedOptions = await this.parseOptions(handle.configuration); + const key = await this.oAuth2TokenIntrospectionIdentifier.resolve(context, parsedOptions); + + const encryptedData = this.cipher.encrypt(data); + + await this.storage.setCredentialData( + credentialId, + key, + handle.resolverId, + encryptedData, + parsedOptions, + ); + } + + /** Deletes credential data for the given identity. Succeeds silently if not found. */ + async deleteSecret( + credentialId: string, + context: ICredentialContext, + handle: CredentialResolverHandle, + ): Promise { + const parsedOptions = await this.parseOptions(handle.configuration); + const key = await this.oAuth2TokenIntrospectionIdentifier.resolve(context, parsedOptions); + await this.storage.deleteCredentialData(credentialId, key, handle.resolverId, parsedOptions); + } + + private async parseOptions(options: CredentialResolverConfiguration) { + const result = await OAuthCredentialResolverOptionsSchema.safeParseAsync(options); + if (result.error) { + this.logger.error('Invalid options provided to OAuthCredentialResolver', { + error: result.error, + }); + throw new CredentialResolverValidationError( + `Invalid options for OAuthCredentialResolver: ${result.error.message}`, + ); + } + return result.data; + } + + async validateOptions(options: CredentialResolverConfiguration): Promise { + const parsedOptions = await this.parseOptions(options); + await this.oAuth2TokenIntrospectionIdentifier.validateOptions(parsedOptions); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-entry-storage.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-entry-storage.ts new file mode 100644 index 00000000000..45bcfc82964 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-entry-storage.ts @@ -0,0 +1,64 @@ +import { Service } from '@n8n/di'; + +import { ICredentialEntriesStorage } from './storage-interface'; +import { DynamicCredentialEntry } from '../../database/entities/dynamic-credential-entry'; +import { DynamicCredentialEntryRepository } from '../../database/repositories/dynamic-credential-entry.repository'; + +@Service() +export class DynamicCredentialEntryStorage implements ICredentialEntriesStorage { + constructor( + private readonly dynamicCredentialEntryRepository: DynamicCredentialEntryRepository, + ) {} + + async getCredentialData( + credentialId: string, + subjectId: string, + resolverId: string, + _: Record, + ): Promise { + const entry = await this.dynamicCredentialEntryRepository.findOne({ + where: { + credentialId, + subjectId, + resolverId, + }, + }); + + return entry?.data ?? null; + } + + async setCredentialData( + credentialId: string, + subjectId: string, + resolverId: string, + data: string, + _: Record, + ): Promise { + let entry = await this.dynamicCredentialEntryRepository.findOne({ + where: { credentialId, subjectId, resolverId }, + }); + + if (!entry) { + entry = new DynamicCredentialEntry(); + entry.credentialId = credentialId; + entry.subjectId = subjectId; + entry.resolverId = resolverId; + } + + entry.data = data; + await this.dynamicCredentialEntryRepository.save(entry); + } + + async deleteCredentialData( + credentialId: string, + subjectId: string, + resolverId: string, + _: Record, + ): Promise { + await this.dynamicCredentialEntryRepository.delete({ + credentialId, + subjectId, + resolverId, + }); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/storage-interface.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/storage-interface.ts new file mode 100644 index 00000000000..c57d5e6ae93 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/storage/storage-interface.ts @@ -0,0 +1,38 @@ +export interface ICredentialEntriesStorage { + /** + * Retrieves credential data for a specific entity from storage. + * + * @returns The credential data object, or null if not found + * @throws {Error} When storage operation fails + */ + getCredentialData( + credentialId: string, + subjectId: string, + resolverId: string, + storageOptions: Record, + ): Promise; + + /** + * Stores credential data for a specific entity in storage. + * @throws {Error} When storage operation fails + */ + setCredentialData( + credentialId: string, + subjectId: string, + resolverId: string, + data: string, + storageOptions: Record, + ): Promise; + + /** + * Deletes credential data for a specific entity from storage. + * Optional - not all storage implementations support deletion. + * @throws {Error} When deletion operation fails + */ + deleteCredentialData?( + credentialId: string, + subjectId: string, + resolverId: string, + storageOptions: Record, + ): Promise; +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/stub-credential-resolver.ts b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/stub-credential-resolver.ts new file mode 100644 index 00000000000..a64ee2f9f8d --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/credential-resolvers/stub-credential-resolver.ts @@ -0,0 +1,111 @@ +import { Logger } from '@n8n/backend-common'; +import { + CredentialResolver, + CredentialResolverConfiguration, + CredentialResolverDataNotFoundError, + CredentialResolverHandle, + CredentialResolverValidationError, + ICredentialResolver, +} from '@n8n/decorators'; +import { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow'; +import z from 'zod'; + +const StubOptionsSchema = z.object({ + prefix: z.string().default(''), +}); + +type StubOptions = z.infer; + +/** + * Simple in-memory credential resolver for testing purposes. + * Stores credentials by identity without authentication or external storage. + */ +@CredentialResolver() +export class StubCredentialResolver implements ICredentialResolver { + private secretsStore: Map = new Map(); + + constructor(private readonly logger: Logger) {} + + metadata = { + name: 'credential-resolver.stub-1.0', + description: 'A stub credential resolver for testing purposes', + displayName: 'Stub Resolver', + options: [ + { + displayName: 'Prefix', + name: 'prefix', + type: 'string' as const, + default: '', + placeholder: 'Optional prefix for stored credentials', + description: 'An optional prefix to namespace stored credentials (useful for testing)', + }, + ], + }; + + /** Generates storage key from credential ID, identity, and optional prefix */ + private generateKey( + credentialId: string, + context: ICredentialContext, + options: StubOptions, + ): string { + return `${options.prefix}:${credentialId}:${context.identity}`; + } + + /** + * Retrieves stored credential data for the given identity. + * @throws {CredentialResolverDataNotFoundError} When no data exists for the key + */ + async getSecret( + credentialId: string, + context: ICredentialContext, + handle: CredentialResolverHandle, + ): Promise { + const parsedOptions = await this.parseOptions(handle.configuration); + const key = this.generateKey(credentialId, context, parsedOptions); + const secret = this.secretsStore.get(key); + if (!secret) { + throw new CredentialResolverDataNotFoundError(); + } + return secret; + } + + /** Stores credential data for the given identity */ + async setSecret( + credentialId: string, + context: ICredentialContext, + data: ICredentialDataDecryptedObject, + handle: CredentialResolverHandle, + ): Promise { + const parsedOptions = await this.parseOptions(handle.configuration); + const key = this.generateKey(credentialId, context, parsedOptions); + this.secretsStore.set(key, data); + } + + /** Deletes credential data for the given identity. Succeeds silently if not found. */ + async deleteSecret( + credentialId: string, + context: ICredentialContext, + handle: CredentialResolverHandle, + ): Promise { + const parsedOptions = await this.parseOptions(handle.configuration); + const key = this.generateKey(credentialId, context, parsedOptions); + this.secretsStore.delete(key); + } + + private async parseOptions(options: CredentialResolverConfiguration) { + const result = await StubOptionsSchema.safeParseAsync(options); + if (result.error) { + this.logger.error('Invalid options provided to StubCredentialResolver', { + error: result.error, + }); + throw new CredentialResolverValidationError( + `Invalid options for StubCredentialResolver: ${result.error.message}`, + ); + } + return result.data; + } + + async validateOptions(options: CredentialResolverConfiguration): Promise { + await this.parseOptions(options); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts new file mode 100644 index 00000000000..a4c31322d91 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts @@ -0,0 +1,18 @@ +import { WithTimestampsAndStringId } from '@n8n/db'; +import type { CredentialResolverConfiguration } from '@n8n/decorators'; +import { Column, Entity } from '@n8n/typeorm'; + +@Entity() +export class DynamicCredentialResolver extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 128 }) + name: string; + + @Column({ type: 'varchar', length: 128 }) + type: string; + + @Column({ type: 'text' }) + config: string; + + /** Decrypted config, not persisted to the database */ + decryptedConfig?: CredentialResolverConfiguration; +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-entry.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-entry.ts new file mode 100644 index 00000000000..911517ad5d6 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/dynamic-credential-entry.ts @@ -0,0 +1,39 @@ +import { CredentialsEntity, WithTimestamps } from '@n8n/db'; +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; + +import { DynamicCredentialResolver } from './credential-resolver'; + +@Entity({ + name: 'dynamic_credential_entry', +}) +export class DynamicCredentialEntry extends WithTimestamps { + constructor() { + super(); + } + + @PrimaryColumn({ + name: 'credential_id', + }) + credentialId: string; + + @PrimaryColumn({ + name: 'subject_id', + }) + subjectId: string; + + @PrimaryColumn({ + name: 'resolver_id', + }) + resolverId: string; + + @Column('text') + data: string; + + @ManyToOne(() => CredentialsEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'credential_id' }) + credential: CredentialsEntity; + + @ManyToOne(() => DynamicCredentialResolver, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'resolver_id' }) + resolver: DynamicCredentialResolver; +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository.ts new file mode 100644 index 00000000000..9b3d6b777d5 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { DynamicCredentialResolver } from '../entities/credential-resolver'; + +@Service() +export class DynamicCredentialResolverRepository extends Repository { + constructor(dataSource: DataSource) { + super(DynamicCredentialResolver, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-entry.repository.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-entry.repository.ts new file mode 100644 index 00000000000..c8b0d9b3b0c --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-entry.repository.ts @@ -0,0 +1,11 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { DynamicCredentialEntry } from '../entities/dynamic-credential-entry'; + +@Service() +export class DynamicCredentialEntryRepository extends Repository { + constructor(dataSource: DataSource) { + super(DynamicCredentialEntry, dataSource.manager); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts new file mode 100644 index 00000000000..fc0316f5f76 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts @@ -0,0 +1,44 @@ +import { Post, RestController } from '@n8n/decorators'; +import { Request, Response } from 'express'; + +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { OauthService } from '@/oauth/oauth.service'; + +@RestController('/credentials') +export class DynamicCredentialsController { + constructor( + private readonly enterpriseCredentialsService: EnterpriseCredentialsService, + private readonly oauthService: OauthService, + ) {} + + @Post('/:id/authorize', { skipAuth: true }) + async authorizeCredential(req: Request, _res: Response): Promise { + const credential = await this.enterpriseCredentialsService.getOne(req.params.id); + + if (!credential) { + throw new NotFoundError('Credential not found'); + } + + if (!credential.type.includes('OAuth2') && !credential.type.includes('OAuth1')) { + throw new BadRequestError('Credential type not supported'); + } + + if (credential.type.includes('OAuth2')) { + return await this.oauthService.generateAOauth2AuthUri(credential, { + cid: credential.id, + authorizationHeader: req.headers.authorization, + }); + } + + if (credential.type.includes('OAuth1')) { + return await this.oauthService.generateAOauth1AuthUri(credential, { + cid: credential.id, + authorizationHeader: req.headers.authorization, + }); + } + + throw new BadRequestError('Credential type not supported'); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts new file mode 100644 index 00000000000..e241df236b2 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts @@ -0,0 +1,37 @@ +import { LICENSE_FEATURES } from '@n8n/constants'; +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule, OnShutdown } from '@n8n/decorators'; +import { Container } from '@n8n/di'; + +function isFeatureFlagEnabled(): boolean { + return process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true'; +} + +@BackendModule({ name: 'dynamic-credentials', licenseFlag: LICENSE_FEATURES.DYNAMIC_CREDENTIALS }) +export class DynamicCredentialsModule implements ModuleInterface { + async init() { + if (!isFeatureFlagEnabled()) { + return; + } + await import('./dynamic-credentials.controller'); + await import('./credential-resolvers.controller'); + await import('./context-establishment-hooks'); + await import('./credential-resolvers'); + const { DynamicCredentialResolverRegistry } = await import('./services'); + + await Container.get(DynamicCredentialResolverRegistry).init(); + } + + async entities() { + if (!isFeatureFlagEnabled()) { + return []; + } + const { DynamicCredentialResolver } = await import('./database/entities/credential-resolver'); + const { DynamicCredentialEntry } = await import('./database/entities/dynamic-credential-entry'); + + return [DynamicCredentialResolver, DynamicCredentialEntry]; + } + + @OnShutdown() + async shutdown() {} +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts b/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts new file mode 100644 index 00000000000..59c78ee3b66 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts @@ -0,0 +1,7 @@ +import { UserError } from 'n8n-workflow'; + +export class DynamicCredentialResolverNotFoundError extends UserError { + constructor(resolverId: string) { + super(`Credential resolver with ID "${resolverId}" does not exist.`); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver-registry.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver-registry.service.test.ts new file mode 100644 index 00000000000..ec22d69d0fb --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver-registry.service.test.ts @@ -0,0 +1,340 @@ +import type { Logger } from '@n8n/backend-common'; +import type { + ICredentialResolver, + CredentialResolverClass, + CredentialResolverEntryMetadata, + CredentialResolverConfiguration, +} from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import type { ICredentialContext, ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import { DynamicCredentialResolverRegistry } from '../credential-resolver-registry.service'; + +describe('DynamicCredentialResolverRegistry', () => { + let registry: DynamicCredentialResolverRegistry; + let mockLogger: jest.Mocked; + let mockMetadata: jest.Mocked; + + // Mock resolver classes + const createMockResolver = ( + name: string, + hasInit = false, + initShouldFail = false, + ): ICredentialResolver => ({ + metadata: { + name, + description: `${name} resolver`, + }, + async getSecret( + _credentialId: string, + _context: ICredentialContext, + _options: CredentialResolverConfiguration, + ): Promise { + return {}; + }, + async setSecret( + _credentialId: string, + _context: ICredentialContext, + _data: ICredentialDataDecryptedObject, + _options: CredentialResolverConfiguration, + ): Promise {}, + async validateOptions(_options: CredentialResolverConfiguration): Promise {}, + ...(hasInit && { + async init() { + if (initShouldFail) { + throw new Error('Init failed'); + } + }, + }), + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + mockMetadata = { + getClasses: jest.fn(), + } as unknown as jest.Mocked; + + registry = new DynamicCredentialResolverRegistry(mockMetadata, mockLogger); + }); + + describe('init', () => { + describe('successful registration', () => { + it('should register a single resolver', async () => { + const mockResolver = createMockResolver('test.resolver'); + const MockResolverClass = jest.fn(() => mockResolver) as unknown as CredentialResolverClass; + Object.defineProperty(MockResolverClass, 'name', { value: 'TestResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockResolverClass]); + jest.spyOn(Container, 'get').mockReturnValue(mockResolver); + + await registry.init(); + + expect(mockLogger.debug).toHaveBeenCalledWith('Registering 1 credential resolvers.'); + expect(registry.getResolverByName('test.resolver')).toBe(mockResolver); + expect(registry.getAllResolvers()).toEqual([mockResolver]); + }); + + it('should register multiple resolvers', async () => { + const resolver1 = createMockResolver('oauth.resolver'); + const resolver2 = createMockResolver('stub.resolver'); + const resolver3 = createMockResolver('api.resolver'); + + const MockClass1 = jest.fn(() => resolver1) as unknown as CredentialResolverClass; + const MockClass2 = jest.fn(() => resolver2) as unknown as CredentialResolverClass; + const MockClass3 = jest.fn(() => resolver3) as unknown as CredentialResolverClass; + + Object.defineProperty(MockClass1, 'name', { value: 'OAuthResolver' }); + Object.defineProperty(MockClass2, 'name', { value: 'StubResolver' }); + Object.defineProperty(MockClass3, 'name', { value: 'ApiResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockClass1, MockClass2, MockClass3]); + + const containerGetSpy = jest.spyOn(Container, 'get'); + containerGetSpy.mockReturnValueOnce(resolver1); + containerGetSpy.mockReturnValueOnce(resolver2); + containerGetSpy.mockReturnValueOnce(resolver3); + + await registry.init(); + + expect(mockLogger.debug).toHaveBeenCalledWith('Registering 3 credential resolvers.'); + expect(registry.getAllResolvers()).toHaveLength(3); + expect(registry.getResolverByName('oauth.resolver')).toBe(resolver1); + expect(registry.getResolverByName('stub.resolver')).toBe(resolver2); + expect(registry.getResolverByName('api.resolver')).toBe(resolver3); + }); + + it('should handle empty resolver list', async () => { + mockMetadata.getClasses.mockReturnValue([]); + + await registry.init(); + + expect(mockLogger.debug).toHaveBeenCalledWith('Registering 0 credential resolvers.'); + expect(registry.getAllResolvers()).toEqual([]); + }); + + it('should call init() method on resolvers that have it', async () => { + const mockResolver = createMockResolver('test.resolver', true); + const initSpy = jest.spyOn(mockResolver, 'init' as any); + + const MockResolverClass = jest.fn(() => mockResolver) as unknown as CredentialResolverClass; + Object.defineProperty(MockResolverClass, 'name', { value: 'TestResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockResolverClass]); + jest.spyOn(Container, 'get').mockReturnValue(mockResolver); + + await registry.init(); + + expect(initSpy).toHaveBeenCalled(); + expect(registry.getResolverByName('test.resolver')).toBe(mockResolver); + }); + + it('should clear previous registrations on re-init', async () => { + const resolver1 = createMockResolver('resolver1'); + const resolver2 = createMockResolver('resolver2'); + + const MockClass1 = jest.fn(() => resolver1) as unknown as CredentialResolverClass; + const MockClass2 = jest.fn(() => resolver2) as unknown as CredentialResolverClass; + + Object.defineProperty(MockClass1, 'name', { value: 'Resolver1' }); + Object.defineProperty(MockClass2, 'name', { value: 'Resolver2' }); + + // First init with resolver1 + mockMetadata.getClasses.mockReturnValue([MockClass1]); + jest.spyOn(Container, 'get').mockReturnValue(resolver1); + await registry.init(); + + expect(registry.getAllResolvers()).toEqual([resolver1]); + + // Re-init with resolver2 + mockMetadata.getClasses.mockReturnValue([MockClass2]); + jest.spyOn(Container, 'get').mockReturnValue(resolver2); + await registry.init(); + + expect(registry.getAllResolvers()).toEqual([resolver2]); + expect(registry.getResolverByName('resolver1')).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should skip resolver when instantiation fails', async () => { + const MockResolverClass = jest.fn() as unknown as CredentialResolverClass; + Object.defineProperty(MockResolverClass, 'name', { value: 'FailingResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockResolverClass]); + jest.spyOn(Container, 'get').mockImplementation(() => { + throw new Error('Instantiation failed'); + }); + + await registry.init(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to instantiate credential resolver class "FailingResolver": Instantiation failed', + { error: expect.any(Error) }, + ); + expect(registry.getAllResolvers()).toEqual([]); + }); + + it('should skip resolver when init() fails', async () => { + const mockResolver = createMockResolver('test.resolver', true, true); + const MockResolverClass = jest.fn(() => mockResolver) as unknown as CredentialResolverClass; + Object.defineProperty(MockResolverClass, 'name', { value: 'TestResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockResolverClass]); + jest.spyOn(Container, 'get').mockReturnValue(mockResolver); + + await registry.init(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to initialize credential resolver "test.resolver": Init failed', + { error: expect.any(Error) }, + ); + expect(registry.getResolverByName('test.resolver')).toBeUndefined(); + expect(registry.getAllResolvers()).toEqual([]); + }); + + it('should skip duplicate resolver names and log warning', async () => { + const MockClass1 = jest.fn() as unknown as CredentialResolverClass; + const MockClass2 = jest.fn() as unknown as CredentialResolverClass; + + Object.defineProperty(MockClass1, 'name', { value: 'FirstResolver' }); + Object.defineProperty(MockClass2, 'name', { value: 'SecondResolver' }); + + const resolver1 = createMockResolver('duplicate.name'); + const resolver2 = createMockResolver('duplicate.name'); + + // Set proper constructor reference so constructor.name works + Object.defineProperty(resolver1, 'constructor', { value: MockClass1 }); + Object.defineProperty(resolver2, 'constructor', { value: MockClass2 }); + + mockMetadata.getClasses.mockReturnValue([MockClass1, MockClass2]); + + const containerGetSpy = jest.spyOn(Container, 'get'); + containerGetSpy.mockReturnValueOnce(resolver1); + containerGetSpy.mockReturnValueOnce(resolver2); + + await registry.init(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Credential resolver with name "duplicate.name" is already registered. Conflicting classes are "FirstResolver" and "SecondResolver". Skipping the latter.', + ); + expect(registry.getAllResolvers()).toHaveLength(1); + expect(registry.getResolverByName('duplicate.name')).toBe(resolver1); + }); + + it('should continue registering other resolvers when one fails', async () => { + const resolver1 = createMockResolver('success.resolver'); + const resolver3 = createMockResolver('another.success'); + + const MockClass1 = jest.fn(() => resolver1) as unknown as CredentialResolverClass; + const MockClass2 = jest.fn() as unknown as CredentialResolverClass; + const MockClass3 = jest.fn(() => resolver3) as unknown as CredentialResolverClass; + + Object.defineProperty(MockClass1, 'name', { value: 'SuccessResolver' }); + Object.defineProperty(MockClass2, 'name', { value: 'FailingResolver' }); + Object.defineProperty(MockClass3, 'name', { value: 'AnotherSuccessResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockClass1, MockClass2, MockClass3]); + + const containerGetSpy = jest.spyOn(Container, 'get'); + containerGetSpy.mockReturnValueOnce(resolver1); + containerGetSpy.mockImplementationOnce(() => { + throw new Error('Failed'); + }); + containerGetSpy.mockReturnValueOnce(resolver3); + + await registry.init(); + + expect(registry.getAllResolvers()).toHaveLength(2); + expect(registry.getResolverByName('success.resolver')).toBe(resolver1); + expect(registry.getResolverByName('another.success')).toBe(resolver3); + }); + }); + }); + + describe('getResolverByName', () => { + it('should return resolver by name', async () => { + const mockResolver = createMockResolver('test.resolver'); + const MockResolverClass = jest.fn(() => mockResolver) as unknown as CredentialResolverClass; + Object.defineProperty(MockResolverClass, 'name', { value: 'TestResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockResolverClass]); + jest.spyOn(Container, 'get').mockReturnValue(mockResolver); + + await registry.init(); + + const result = registry.getResolverByName('test.resolver'); + + expect(result).toBe(mockResolver); + }); + + it('should return undefined for non-existent resolver', async () => { + mockMetadata.getClasses.mockReturnValue([]); + await registry.init(); + + const result = registry.getResolverByName('non.existent'); + + expect(result).toBeUndefined(); + }); + }); + + describe('getAllResolvers', () => { + it('should return all registered resolvers', async () => { + const resolver1 = createMockResolver('resolver1'); + const resolver2 = createMockResolver('resolver2'); + + const MockClass1 = jest.fn(() => resolver1) as unknown as CredentialResolverClass; + const MockClass2 = jest.fn(() => resolver2) as unknown as CredentialResolverClass; + + Object.defineProperty(MockClass1, 'name', { value: 'Resolver1' }); + Object.defineProperty(MockClass2, 'name', { value: 'Resolver2' }); + + mockMetadata.getClasses.mockReturnValue([MockClass1, MockClass2]); + + const containerGetSpy = jest.spyOn(Container, 'get'); + containerGetSpy.mockReturnValueOnce(resolver1); + containerGetSpy.mockReturnValueOnce(resolver2); + + await registry.init(); + + const result = registry.getAllResolvers(); + + expect(result).toHaveLength(2); + expect(result).toContain(resolver1); + expect(result).toContain(resolver2); + }); + + it('should return empty array when no resolvers registered', async () => { + mockMetadata.getClasses.mockReturnValue([]); + await registry.init(); + + const result = registry.getAllResolvers(); + + expect(result).toEqual([]); + }); + + it('should not allow mutation of internal map', async () => { + const mockResolver = createMockResolver('test.resolver'); + const MockResolverClass = jest.fn(() => mockResolver) as unknown as CredentialResolverClass; + Object.defineProperty(MockResolverClass, 'name', { value: 'TestResolver' }); + + mockMetadata.getClasses.mockReturnValue([MockResolverClass]); + jest.spyOn(Container, 'get').mockReturnValue(mockResolver); + + await registry.init(); + + const resolvers = registry.getAllResolvers(); + resolvers.pop(); // Try to mutate returned array + + // Original should still have the resolver + expect(registry.getAllResolvers()).toHaveLength(1); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts new file mode 100644 index 00000000000..2879d02b6e2 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts @@ -0,0 +1,308 @@ +import type { Logger } from '@n8n/backend-common'; +import { + CredentialResolverValidationError, + type CredentialResolverConfiguration, + type ICredentialResolver, +} from '@n8n/decorators'; +import type { Cipher } from 'n8n-core'; +import { UnexpectedError } from 'n8n-workflow'; + +import { DynamicCredentialResolver } from '../../database/entities/credential-resolver'; +import type { DynamicCredentialResolverRepository } from '../../database/repositories/credential-resolver.repository'; +import { DynamicCredentialResolverNotFoundError } from '../../errors/credential-resolver-not-found.error'; +import type { DynamicCredentialResolverRegistry } from '../credential-resolver-registry.service'; +import { DynamicCredentialResolverService } from '../credential-resolver.service'; + +describe('DynamicCredentialResolverService', () => { + let service: DynamicCredentialResolverService; + let mockLogger: jest.Mocked; + let mockRepository: jest.Mocked; + let mockRegistry: jest.Mocked; + let mockCipher: jest.Mocked; + + const mockResolverImplementation: jest.Mocked = { + metadata: { + name: 'test.resolver', + description: 'A test resolver', + }, + getSecret: jest.fn(), + setSecret: jest.fn(), + validateOptions: jest.fn(), + }; + + const createMockEntity = ( + overrides: Partial = {}, + ): DynamicCredentialResolver => { + const entity = new DynamicCredentialResolver(); + entity.id = 'resolver-id-123'; + entity.name = 'Test Resolver'; + entity.type = 'test.resolver'; + entity.config = 'encrypted-config-data'; + entity.createdAt = new Date('2024-01-01'); + entity.updatedAt = new Date('2024-01-01'); + Object.assign(entity, overrides); + return entity; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + scoped: jest.fn().mockReturnThis(), + } as unknown as jest.Mocked; + + mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + remove: jest.fn(), + } as unknown as jest.Mocked; + + mockRegistry = { + getResolverByName: jest.fn(), + } as unknown as jest.Mocked; + + mockCipher = { + encrypt: jest.fn(), + decrypt: jest.fn(), + } as unknown as jest.Mocked; + + service = new DynamicCredentialResolverService( + mockLogger, + mockRepository, + mockRegistry, + mockCipher, + ); + }); + + describe('create', () => { + it('should create a resolver with encrypted config', async () => { + const config: CredentialResolverConfiguration = { prefix: 'test-prefix' }; + const savedEntity = createMockEntity(); + + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockResolvedValue(undefined); + mockCipher.encrypt.mockReturnValue('encrypted-config-data'); + mockRepository.create.mockReturnValue(savedEntity); + mockRepository.save.mockResolvedValue(savedEntity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(config)); + + const result = await service.create({ + name: 'Test Resolver', + type: 'test.resolver', + config, + }); + + expect(mockRegistry.getResolverByName).toHaveBeenCalledWith('test.resolver'); + expect(mockResolverImplementation.validateOptions).toHaveBeenCalledWith(config); + expect(mockCipher.encrypt).toHaveBeenCalledWith(config); + expect(mockRepository.create).toHaveBeenCalledWith({ + name: 'Test Resolver', + type: 'test.resolver', + config: 'encrypted-config-data', + }); + expect(mockRepository.save).toHaveBeenCalledWith(savedEntity); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Created credential resolver'), + ); + expect(result).toBeDefined(); + }); + + it('should throw CredentialResolverValidationError for unknown resolver type', async () => { + mockRegistry.getResolverByName.mockReturnValue(undefined); + + await expect( + service.create({ + name: 'Test Resolver', + type: 'unknown.resolver', + config: {}, + }), + ).rejects.toThrow(CredentialResolverValidationError); + + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw CredentialResolverValidationError when config validation fails', async () => { + const config: CredentialResolverConfiguration = { invalidOption: 'value' }; + + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockRejectedValue( + new CredentialResolverValidationError('Invalid option'), + ); + + await expect( + service.create({ + name: 'Test Resolver', + type: 'test.resolver', + config, + }), + ).rejects.toThrow(CredentialResolverValidationError); + + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('findAll', () => { + it('should return all resolvers with decryptedConfig populated', async () => { + const entities = [ + createMockEntity({ id: 'id-1', name: 'Resolver 1' }), + createMockEntity({ id: 'id-2', name: 'Resolver 2' }), + ]; + const decryptedConfig = { prefix: 'test' }; + + mockRepository.find.mockResolvedValue(entities); + mockCipher.decrypt.mockReturnValue(JSON.stringify(decryptedConfig)); + + const result = await service.findAll(); + + expect(mockRepository.find).toHaveBeenCalled(); + expect(mockCipher.decrypt).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + expect(result[0].decryptedConfig).toEqual(decryptedConfig); + expect(result[1].decryptedConfig).toEqual(decryptedConfig); + }); + + it('should return empty array when no resolvers exist', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return resolver with decryptedConfig populated', async () => { + const entity = createMockEntity(); + const decryptedConfig = { prefix: 'test' }; + + mockRepository.findOneBy.mockResolvedValue(entity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(decryptedConfig)); + + const result = await service.findById('resolver-id-123'); + + expect(mockRepository.findOneBy).toHaveBeenCalledWith({ id: 'resolver-id-123' }); + expect(mockCipher.decrypt).toHaveBeenCalledWith('encrypted-config-data'); + expect(result.decryptedConfig).toEqual(decryptedConfig); + }); + + it('should throw DynamicCredentialResolverNotFoundError when resolver not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.findById('non-existent-id')).rejects.toThrow( + DynamicCredentialResolverNotFoundError, + ); + }); + + it('should throw UnexpectedError when decryption fails', async () => { + const entity = createMockEntity(); + + mockRepository.findOneBy.mockResolvedValue(entity); + mockCipher.decrypt.mockReturnValue('invalid-json{'); + + await expect(service.findById('resolver-id-123')).rejects.toThrow(UnexpectedError); + }); + }); + + describe('update', () => { + it('should update resolver name', async () => { + const entity = createMockEntity(); + const updatedEntity = createMockEntity({ name: 'Updated Name' }); + const decryptedConfig = { prefix: 'test' }; + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRepository.save.mockResolvedValue(updatedEntity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(decryptedConfig)); + + const result = await service.update('resolver-id-123', { name: 'Updated Name' }); + + expect(mockRepository.findOneBy).toHaveBeenCalledWith({ id: 'resolver-id-123' }); + expect(mockRepository.save).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Updated credential resolver'), + ); + expect(result.name).toBe('Updated Name'); + }); + + it('should update resolver config with encryption and validation', async () => { + const entity = createMockEntity(); + const newConfig: CredentialResolverConfiguration = { prefix: 'new-prefix' }; + const updatedEntity = createMockEntity({ config: 'new-encrypted-config' }); + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockResolvedValue(undefined); + mockCipher.encrypt.mockReturnValue('new-encrypted-config'); + mockRepository.save.mockResolvedValue(updatedEntity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(newConfig)); + + await service.update('resolver-id-123', { config: newConfig }); + + expect(mockRegistry.getResolverByName).toHaveBeenCalledWith('test.resolver'); + expect(mockResolverImplementation.validateOptions).toHaveBeenCalledWith(newConfig); + expect(mockCipher.encrypt).toHaveBeenCalledWith(newConfig); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('should throw DynamicCredentialResolverNotFoundError when resolver not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.update('non-existent-id', { name: 'New Name' })).rejects.toThrow( + DynamicCredentialResolverNotFoundError, + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw CredentialResolverValidationError when config validation fails on update', async () => { + const entity = createMockEntity(); + const invalidConfig: CredentialResolverConfiguration = { badOption: 'value' }; + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockRejectedValue( + new CredentialResolverValidationError('Invalid config'), + ); + + await expect(service.update('resolver-id-123', { config: invalidConfig })).rejects.toThrow( + CredentialResolverValidationError, + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete an existing resolver', async () => { + const entity = createMockEntity(); + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRepository.remove.mockResolvedValue(entity); + + await service.delete('resolver-id-123'); + + expect(mockRepository.findOneBy).toHaveBeenCalledWith({ id: 'resolver-id-123' }); + expect(mockRepository.remove).toHaveBeenCalledWith(entity); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Deleted credential resolver'), + ); + }); + + it('should throw DynamicCredentialResolverNotFoundError when resolver not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.delete('non-existent-id')).rejects.toThrow( + DynamicCredentialResolverNotFoundError, + ); + + expect(mockRepository.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-registry.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-registry.service.ts new file mode 100644 index 00000000000..a12210e7c0a --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-registry.service.ts @@ -0,0 +1,78 @@ +import { Logger } from '@n8n/backend-common'; +import { CredentialResolverEntryMetadata, ICredentialResolver } from '@n8n/decorators'; +import { Container, Service } from '@n8n/di'; + +/** + * Registry service for discovering, instantiating, and managing credential resolver implementations. + * Automatically discovers all classes decorated with @CredentialResolver() and makes them available by name. + */ +@Service() +export class DynamicCredentialResolverRegistry { + /** Map of resolver names to resolver instances */ + private resolverMap: Map = new Map(); + + constructor( + private readonly credentialResolverEntryMetadata: CredentialResolverEntryMetadata, + private readonly logger: Logger, + ) {} + + /** + * Discovers and registers all credential resolver implementations. + * Instantiates each resolver class, calls optional init() method, and registers by metadata.name. + * Skips resolvers that fail instantiation, initialization, or have duplicate names. + */ + async init() { + this.resolverMap.clear(); + + const resolverClasses = this.credentialResolverEntryMetadata.getClasses(); + this.logger.debug(`Registering ${resolverClasses.length} credential resolvers.`); + + for (const ResolverClass of resolverClasses) { + let resolver: ICredentialResolver; + try { + resolver = Container.get(ResolverClass); + } catch (error) { + this.logger.error( + `Failed to instantiate credential resolver class "${ResolverClass.name}": ${(error as Error).message}`, + { error }, + ); + continue; + } + + if (this.resolverMap.has(resolver.metadata.name)) { + this.logger.warn( + `Credential resolver with name "${resolver.metadata.name}" is already registered. Conflicting classes are "${this.resolverMap.get(resolver.metadata.name)?.constructor.name}" and "${ResolverClass.name}". Skipping the latter.`, + ); + continue; + } + + if (resolver.init) { + try { + await resolver.init(); + } catch (error) { + this.logger.error( + `Failed to initialize credential resolver "${resolver.metadata.name}": ${(error as Error).message}`, + { error }, + ); + continue; + } + } + this.resolverMap.set(resolver.metadata.name, resolver); + } + } + + /** + * Retrieves a registered resolver by its metadata name. + * @returns The resolver instance, or undefined if not found + */ + getResolverByName(name: string): ICredentialResolver | undefined { + return this.resolverMap.get(name); + } + + /** + * Returns all successfully registered resolver instances. + */ + getAllResolvers(): ICredentialResolver[] { + return Array.from(this.resolverMap.values()); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts new file mode 100644 index 00000000000..283cc354aff --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts @@ -0,0 +1,178 @@ +import { Logger } from '@n8n/backend-common'; +import { + CredentialResolverConfiguration, + CredentialResolverValidationError, + ICredentialResolver, +} from '@n8n/decorators'; +import { Service } from '@n8n/di'; +import { Cipher } from 'n8n-core'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; + +import { DynamicCredentialResolverRegistry } from './credential-resolver-registry.service'; +import { DynamicCredentialResolver } from '../database/entities/credential-resolver'; +import { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository'; +import { DynamicCredentialResolverNotFoundError } from '../errors/credential-resolver-not-found.error'; + +export interface CreateResolverParams { + name: string; + type: string; + config: CredentialResolverConfiguration; +} + +export interface UpdateResolverParams { + name?: string; + config?: CredentialResolverConfiguration; +} + +/** + * Service layer for managing DynamicCredentialResolver entities. + * Provides CRUD operations with: + * - Config encryption at rest + * - Validation against resolver type's config schema + */ +@Service() +export class DynamicCredentialResolverService { + constructor( + private readonly logger: Logger, + private readonly repository: DynamicCredentialResolverRepository, + private readonly registry: DynamicCredentialResolverRegistry, + private readonly cipher: Cipher, + ) { + this.logger = this.logger.scoped('dynamic-credentials'); + } + + /** + * Creates a new credential resolver. + * @throws {CredentialResolverValidationError} When the resolver type is unknown or config is invalid + */ + async create(params: CreateResolverParams): Promise { + await this.validateConfig(params.type, params.config); + + const encryptedConfig = this.encryptConfig(params.config); + + const resolver = this.repository.create({ + name: params.name, + type: params.type, + config: encryptedConfig, + }); + + const saved = await this.repository.save(resolver); + this.logger.debug(`Created credential resolver "${saved.name}" (${saved.id})`); + + return this.withDecryptedConfig(saved); + } + + /** + * Retrieves all credential resolvers. + * Config is returned decrypted. + */ + async findAll(): Promise { + const resolvers = await this.repository.find(); + return resolvers.map((resolver) => this.withDecryptedConfig(resolver)); + } + + /** + * Retrieves all available resolver types. + */ + getAvailableTypes(): ICredentialResolver[] { + return this.registry.getAllResolvers(); + } + + /** + * Retrieves a credential resolver by ID. + * Config is returned decrypted. + * @throws {DynamicCredentialResolverNotFoundError} When resolver is not found + */ + async findById(id: string): Promise { + const resolver = await this.repository.findOneBy({ id }); + if (!resolver) { + throw new DynamicCredentialResolverNotFoundError(id); + } + return this.withDecryptedConfig(resolver); + } + + /** + * Updates an existing credential resolver. + * @throws {DynamicCredentialResolverNotFoundError} When resolver is not found + * @throws {CredentialResolverValidationError} When the config is invalid for the resolver type + */ + async update(id: string, params: UpdateResolverParams): Promise { + const existing = await this.repository.findOneBy({ id }); + if (!existing) { + throw new DynamicCredentialResolverNotFoundError(id); + } + + if (params.config !== undefined) { + await this.validateConfig(existing.type, params.config); + existing.config = this.encryptConfig(params.config); + } + + if (params.name !== undefined) { + existing.name = params.name; + } + + const saved = await this.repository.save(existing); + this.logger.debug(`Updated credential resolver "${saved.name}" (${saved.id})`); + + return this.withDecryptedConfig(saved); + } + + /** + * Deletes a credential resolver by ID. + * @throws {DynamicCredentialResolverNotFoundError} When resolver is not found + */ + async delete(id: string): Promise { + const existing = await this.repository.findOneBy({ id }); + if (!existing) { + throw new DynamicCredentialResolverNotFoundError(id); + } + + await this.repository.remove(existing); + this.logger.debug(`Deleted credential resolver "${existing.name}" (${id})`); + } + + /** + * Validates the config against the resolver type's schema. + * @throws {CredentialResolverValidationError} When the resolver type is unknown or config is invalid + */ + private async validateConfig( + type: string, + config: CredentialResolverConfiguration, + ): Promise { + const resolverImplementation = this.registry.getResolverByName(type); + if (!resolverImplementation) { + throw new CredentialResolverValidationError(`Unknown resolver type: ${type}`); + } + + await resolverImplementation.validateOptions(config); + } + + /** + * Encrypts the config for storage. + */ + private encryptConfig(config: CredentialResolverConfiguration): string { + return this.cipher.encrypt(config); + } + + /** + * Decrypts the config from storage. + */ + private decryptConfig(encryptedConfig: string): CredentialResolverConfiguration { + const decryptedData = this.cipher.decrypt(encryptedConfig); + try { + return jsonParse(decryptedData); + } catch { + throw new UnexpectedError( + 'Credential resolver config could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.', + ); + } + } + + /** + * Populates the decryptedConfig field on the resolver. + */ + private withDecryptedConfig(resolver: DynamicCredentialResolver): DynamicCredentialResolver { + resolver.decryptedConfig = this.decryptConfig(resolver.config); + return resolver; + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts new file mode 100644 index 00000000000..7405c3ed8eb --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts @@ -0,0 +1,2 @@ +export * from './credential-resolver-registry.service'; +export * from './credential-resolver.service'; diff --git a/packages/cli/src/modules/insights/__tests__/insights-collection.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights-collection.service.test.ts index 9a14278aa1e..0fc84629baf 100644 --- a/packages/cli/src/modules/insights/__tests__/insights-collection.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights-collection.service.test.ts @@ -118,3 +118,65 @@ describe('initialization safeguards', () => { expect(jest.getTimerCount()).toBe(0); }); }); + +describe('calculateTimeSaved', () => { + let insightsCollectionService: InsightsCollectionService; + + const workflow = mock({ + id: 'workflow-id', + name: 'Test Workflow', + }); + + beforeAll(() => { + insightsCollectionService = new InsightsCollectionService( + mock(), + mock(), + mock(), + mock(), + mockLogger(), + ); + }); + + test('returns the workflow time saved when the time saved mode is fixed', () => { + const ctx = mock({ workflow }); + ctx.workflow.settings = { + timeSavedMode: 'fixed', + timeSavedPerExecution: 10, + }; + ctx.runData = mock({ + mode: 'webhook', + status: 'success', + startedAt: DateTime.utc().toJSDate(), + stoppedAt: DateTime.utc().plus({ minutes: 10 }).toJSDate(), + }); + + // @ts-ignore-next-line + const timeSaved = insightsCollectionService.calculateTimeSaved(ctx); + expect(timeSaved).toBe(10); + }); + + test('returns the node time saved when the time saved mode is dynamic', () => { + const ctx = mock({ workflow }); + ctx.workflow.settings = { + timeSavedMode: 'dynamic', + }; + ctx.runData = mock({ + mode: 'webhook', + status: 'success', + startedAt: DateTime.utc().toJSDate(), + stoppedAt: DateTime.utc().plus({ minutes: 10 }).toJSDate(), + data: { + resultData: { + runData: { + 'node-1': [{ metadata: { timeSaved: { minutes: 5 } } }], + 'node-2': [{ metadata: { timeSaved: { minutes: 15 } } }], + }, + }, + }, + }); + + // @ts-ignore-next-line + const timeSaved = insightsCollectionService.calculateTimeSaved(ctx); + expect(timeSaved).toBe(20); + }); +}); diff --git a/packages/cli/src/modules/insights/insights-collection.service.ts b/packages/cli/src/modules/insights/insights-collection.service.ts index 24f9de0f0d1..475e9e45547 100644 --- a/packages/cli/src/modules/insights/insights-collection.service.ts +++ b/packages/cli/src/modules/insights/insights-collection.service.ts @@ -4,7 +4,12 @@ import { OnLifecycleEvent, type WorkflowExecuteAfterContext } from '@n8n/decorat import { Service } from '@n8n/di'; import { In } from '@n8n/typeorm'; import { DateTime } from 'luxon'; -import { UnexpectedError, type ExecutionStatus, type WorkflowExecuteMode } from 'n8n-workflow'; +import { + IRun, + UnexpectedError, + type ExecutionStatus, + type WorkflowExecuteMode, +} from 'n8n-workflow'; import { InsightsMetadata } from '@/modules/insights/database/entities/insights-metadata'; import { InsightsRaw } from '@/modules/insights/database/entities/insights-raw'; @@ -170,12 +175,15 @@ export class InsightsCollectionService { } // time saved event - if (status === 'success' && ctx.workflow.settings?.timeSavedPerExecution) { - this.bufferedInsights.add({ - ...commonWorkflowData, - type: 'time_saved_min', - value: ctx.workflow.settings.timeSavedPerExecution, - }); + if (status === 'success') { + const finalTimeSaved = this.calculateTimeSaved(ctx); + if (finalTimeSaved !== undefined) { + this.bufferedInsights.add({ + ...commonWorkflowData, + type: 'time_saved_min', + value: finalTimeSaved, + }); + } } if (!this.isAsynchronouslySavingInsights) { @@ -298,4 +306,45 @@ export class InsightsCollectionService { this.flushesInProgress.add(flushPromise); await flushPromise; } + + /** + * Calculate the final time saved value by extracting SavedTime node metadata + * and combining it with workflow settings based on the node's behavior. + */ + private calculateTimeSaved(ctx: WorkflowExecuteAfterContext): number { + const workflowTimeSaved = ctx.workflow.settings?.timeSavedPerExecution; + + // backwards compatibility for legacy workflows with no time saved mode + if (ctx.workflow.settings?.timeSavedMode !== 'dynamic') { + return workflowTimeSaved ?? 0; + } + + const nodeTimeSaved = this.extractTimeSavedFromNodes(ctx.runData); + + return nodeTimeSaved; + } + + /** + * Extract and sum time saved from all SavedTime nodes in the workflow execution. + * Returns undefined if no SavedTime nodes were executed. + */ + private extractTimeSavedFromNodes(runData: IRun): number { + let totalMinutes = 0; + + const resultData = runData.data.resultData?.runData ?? {}; + + // Iterate through all node metadata + for (const nodeName in resultData) { + const taskData = resultData[nodeName]; + + // Each node can have multiple run indexes + for (const taskDataEntry of taskData) { + if (taskDataEntry?.metadata?.timeSaved) { + totalMinutes += taskDataEntry?.metadata?.timeSaved.minutes; + } + } + } + + return totalMinutes; + } } diff --git a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts index 4d53d469ada..07c5ca66a40 100644 --- a/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts +++ b/packages/cli/src/modules/workflow-index/__tests__/workflow-index.service.test.ts @@ -242,6 +242,119 @@ describe('WorkflowIndexService', () => { }), ); }); + + it('should skip credentials with null or empty id', async () => { + mockWorkflowDependencyRepository.updateDependenciesForWorkflow.mockResolvedValue(true); + + const workflow = createWorkflow([ + createNode({ + id: 'node-1', + type: 'n8n-nodes-base.httpRequest', + credentials: { + httpAuth: { id: 'cred-1', name: 'Valid Auth' }, + apiKey: { id: null, name: 'Invalid API Key' }, + oAuth2: { id: '', name: 'Empty OAuth2' }, + }, + }), + ]); + + await service.updateIndexFor(workflow); + + const call = mockWorkflowDependencyRepository.updateDependenciesForWorkflow.mock.calls[0]; + const dependencies = call[1].dependencies; + + // Should have nodeType + only 1 credential (cred-1) + expect(dependencies).toHaveLength(2); + expect(dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + dependencyType: 'nodeType', + dependencyKey: 'n8n-nodes-base.httpRequest', + dependencyInfo: { nodeId: 'node-1', nodeVersion: 1 }, + }), + expect.objectContaining({ + dependencyType: 'credentialId', + dependencyKey: 'cred-1', + dependencyInfo: { nodeId: 'node-1', nodeVersion: 1 }, + }), + ]), + ); + }); + + it('should skip nodes with missing type', async () => { + mockWorkflowDependencyRepository.updateDependenciesForWorkflow.mockResolvedValue(true); + + const workflow = createWorkflow([ + createNode({ + id: 'node-1', + type: 'n8n-nodes-base.httpRequest', + }), + { + id: 'node-2', + name: 'node-2', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: {}, + } as INode, + ]); + + await service.updateIndexFor(workflow); + + const call = mockWorkflowDependencyRepository.updateDependenciesForWorkflow.mock.calls[0]; + const dependencies = call[1].dependencies; + + expect(dependencies).toHaveLength(1); + expect(dependencies[0]).toEqual( + expect.objectContaining({ + dependencyType: 'nodeType', + dependencyKey: 'n8n-nodes-base.httpRequest', + dependencyInfo: { nodeId: 'node-1', nodeVersion: 1 }, + }), + ); + }); + + it('should skip webhook nodes with missing path', async () => { + mockWorkflowDependencyRepository.updateDependenciesForWorkflow.mockResolvedValue(true); + + const workflow = createWorkflow([ + createNode({ + id: 'node-1', + type: 'n8n-nodes-base.webhook', + parameters: { path: 'valid-path' }, + }), + createNode({ + id: 'node-2', + type: 'n8n-nodes-base.webhook', + parameters: {}, + }), + ]); + + await service.updateIndexFor(workflow); + + const call = mockWorkflowDependencyRepository.updateDependenciesForWorkflow.mock.calls[0]; + const dependencies = call[1].dependencies; + + expect(dependencies).toHaveLength(3); + expect(dependencies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + dependencyType: 'nodeType', + dependencyKey: 'n8n-nodes-base.webhook', + dependencyInfo: { nodeId: 'node-1', nodeVersion: 1 }, + }), + expect.objectContaining({ + dependencyType: 'nodeType', + dependencyKey: 'n8n-nodes-base.webhook', + dependencyInfo: { nodeId: 'node-2', nodeVersion: 1 }, + }), + expect.objectContaining({ + dependencyType: 'webhookPath', + dependencyKey: 'valid-path', + dependencyInfo: { nodeId: 'node-1', nodeVersion: 1 }, + }), + ]), + ); + }); }); describe('init()', () => { diff --git a/packages/cli/src/modules/workflow-index/workflow-index.service.ts b/packages/cli/src/modules/workflow-index/workflow-index.service.ts index 3c079bd450a..e8b412788bc 100644 --- a/packages/cli/src/modules/workflow-index/workflow-index.service.ts +++ b/packages/cli/src/modules/workflow-index/workflow-index.service.ts @@ -120,11 +120,13 @@ export class WorkflowIndexService { } private addNodeTypeDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void { - dependencyUpdates.add({ - dependencyType: 'nodeType', - dependencyKey: node.type, - dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion }, - }); + if (node.type) { + dependencyUpdates.add({ + dependencyType: 'nodeType', + dependencyKey: node.type, + dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion }, + }); + } } private addCredentialDependencies(node: INode, dependencyUpdates: WorkflowDependencies): void { @@ -133,6 +135,9 @@ export class WorkflowIndexService { } for (const credentialDetails of Object.values(node.credentials)) { const { id } = credentialDetails; + if (!id) { + continue; + } dependencyUpdates.add({ dependencyType: 'credentialId', dependencyKey: id, @@ -161,11 +166,13 @@ export class WorkflowIndexService { return; } const webhookPath = node.parameters.path as string; - dependencyUpdates.add({ - dependencyType: 'webhookPath', - dependencyKey: webhookPath, - dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion }, - }); + if (webhookPath) { + dependencyUpdates.add({ + dependencyType: 'webhookPath', + dependencyKey: webhookPath, + dependencyInfo: { nodeId: node.id, nodeVersion: node.typeVersion }, + }); + } } private getCalledWorkflowIdFrom(node: INode): string | undefined { diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts new file mode 100644 index 00000000000..b9a18dd15de --- /dev/null +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -0,0 +1,1176 @@ +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; +import { mockInstance } from '@n8n/backend-test-utils'; +import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb, User } from '@n8n/db'; +import { CredentialsRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import type { Response } from 'express'; +import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import { UnexpectedError } from 'n8n-workflow'; + +import { + OauthService, + OauthVersion, + shouldSkipAuthOnOAuthCallback, + type OAuth1CredentialData, +} from '@/oauth/oauth.service'; +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { CredentialsHelper } from '@/credentials-helper'; +import { AuthError } from '@/errors/response-errors/auth.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { OAuthRequest } from '@/requests'; +import { UrlService } from '@/services/url.service'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { ExternalHooks } from '@/external-hooks'; +import type { OAuth2CredentialData } from '@n8n/client-oauth2'; + +jest.mock('@/workflow-execute-additional-data'); +jest.mock('axios'); +jest.mock('@n8n/client-oauth2'); +jest.mock('pkce-challenge'); + +describe('OauthService', () => { + const logger = mockInstance(Logger); + const credentialsHelper = mockInstance(CredentialsHelper); + const credentialsRepository = mockInstance(CredentialsRepository); + const credentialsFinderService = mockInstance(CredentialsFinderService); + const urlService = mockInstance(UrlService); + const globalConfig = mockInstance(GlobalConfig); + const externalHooks = mockInstance(ExternalHooks); + + let service: OauthService; + + const timestamp = 1706750625678; + jest.useFakeTimers({ advanceTimers: true }); + + beforeEach(() => { + jest.setSystemTime(new Date(timestamp)); + jest.clearAllMocks(); + + globalConfig.endpoints = { rest: 'rest' } as any; + urlService.getInstanceBaseUrl.mockReturnValue('http://localhost:5678'); + jest + .mocked(WorkflowExecuteAdditionalData.getBase) + .mockResolvedValue(mock()); + externalHooks.run.mockResolvedValue(undefined); + + // Setup axios mock + const axios = require('axios'); + axios.get = jest.fn(); + axios.post = jest.fn(); + + service = new OauthService( + logger, + credentialsHelper, + credentialsRepository, + credentialsFinderService, + urlService, + globalConfig, + externalHooks, + ); + }); + + describe('shouldSkipAuthOnOAuthCallback', () => { + it('should return false when env var is not set', () => { + delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK; + expect(shouldSkipAuthOnOAuthCallback()).toBe(false); + }); + + it('should return false when env var is "false"', () => { + process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'false'; + expect(shouldSkipAuthOnOAuthCallback()).toBe(false); + }); + + it('should return true when env var is "true"', () => { + process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true'; + expect(shouldSkipAuthOnOAuthCallback()).toBe(true); + }); + + it('should return true when env var is "TRUE" (case insensitive)', () => { + process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'TRUE'; + expect(shouldSkipAuthOnOAuthCallback()).toBe(true); + }); + }); + + describe('getBaseUrl', () => { + it('should return correct URL for OAuth1', () => { + const url = service.getBaseUrl(OauthVersion.V1); + expect(url).toBe('http://localhost:5678/rest/oauth1-credential'); + expect(urlService.getInstanceBaseUrl).toHaveBeenCalled(); + }); + + it('should return correct URL for OAuth2', () => { + const url = service.getBaseUrl(OauthVersion.V2); + expect(url).toBe('http://localhost:5678/rest/oauth2-credential'); + }); + }); + + describe('getCredential', () => { + it('should throw BadRequestError when credential ID is missing', async () => { + const req = { + query: {}, + user: mock({ id: '123' }), + } as unknown as OAuthRequest.OAuth2Credential.Auth; + + Object.defineProperty(req.query, 'id', { + value: undefined, + writable: true, + enumerable: true, + }); + + const promise = service.getCredential(req); + await expect(promise).rejects.toThrow(BadRequestError); + await expect(promise).rejects.toThrow('Required credential ID is missing'); + }); + + it('should throw NotFoundError when credential is not found', async () => { + const req = mock({ + query: { id: 'credential-id' }, + user: mock({ id: '123' }), + }); + + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + + await expect(service.getCredential(req)).rejects.toThrow(NotFoundError); + expect(logger.error).toHaveBeenCalledWith( + 'OAuth credential authorization failed because the current user does not have the correct permissions', + { userId: '123' }, + ); + }); + + it('should return credential when found', async () => { + const mockCredential = mock({ id: 'credential-id' }); + const req = mock({ + query: { id: 'credential-id' }, + user: mock({ id: '123' }), + }); + + credentialsFinderService.findCredentialForUser.mockResolvedValue(mockCredential); + + const result = await service.getCredential(req); + + expect(result).toBe(mockCredential); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'credential-id', + req.user, + ['credential:read'], + ); + }); + }); + + describe('getAdditionalData', () => { + it('should return workflow execute additional data', async () => { + const mockAdditionalData = mock(); + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + + const result = await (service as any).getAdditionalData(); + + expect(result).toBe(mockAdditionalData); + expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalled(); + }); + }); + + describe('getDecryptedDataForAuthUri', () => { + it('should call getDecryptedData with raw=false', async () => { + const credential = mock({ id: '1', type: 'test' }); + const additionalData = mock(); + const mockDecryptedData = { clientId: 'test' }; + + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + + const result = await (service as any).getDecryptedDataForAuthUri(credential, additionalData); + + expect(result).toBe(mockDecryptedData); + expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( + additionalData, + credential, + credential.type, + 'internal', + undefined, + false, + ); + }); + }); + + describe('getDecryptedDataForCallback', () => { + it('should call getDecryptedData with raw=true', async () => { + const credential = mock({ id: '1', type: 'test' }); + const additionalData = mock(); + const mockDecryptedData = { csrfSecret: 'secret' }; + + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + + const result = await (service as any).getDecryptedDataForCallback(credential, additionalData); + + expect(result).toBe(mockDecryptedData); + expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( + additionalData, + credential, + credential.type, + 'internal', + undefined, + true, + ); + }); + }); + + describe('applyDefaultsAndOverwrites', () => { + it('should apply defaults and overwrites', async () => { + const credential = mock({ id: '1', type: 'test' }); + const decryptedData = { clientId: 'test' }; + const additionalData = mock(); + const mockResult = { clientId: 'test', clientSecret: 'secret' }; + + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockResult); + + const result = await (service as any).applyDefaultsAndOverwrites( + credential, + decryptedData, + additionalData, + ); + + expect(result).toBe(mockResult); + expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith( + additionalData, + decryptedData, + credential, + credential.type, + 'internal', + undefined, + undefined, + ); + }); + }); + + describe('encryptAndSaveData', () => { + it('should encrypt and save data to repository', async () => { + const { Cipher } = await import('n8n-core'); + const cipher = Container.get(Cipher); + const encryptedData = cipher.encrypt({ existing: 'data' }); + + const credential = mock({ + id: '1', + type: 'test', + data: encryptedData, + }); + const toUpdate = { clientId: 'new-id' }; + const toDelete = ['oldField']; + + await service.encryptAndSaveData(credential, toUpdate, toDelete); + + expect(credentialsRepository.update).toHaveBeenCalledWith('1', { + id: '1', + name: expect.anything(), + type: 'test', + data: expect.any(String), + updatedAt: expect.any(Date), + }); + }); + + it('should use empty array for toDelete when not provided', async () => { + const { Cipher } = await import('n8n-core'); + const cipher = Container.get(Cipher); + const encryptedData = cipher.encrypt({ existing: 'data' }); + + const credential = mock({ + id: '1', + type: 'test', + data: encryptedData, + }); + const toUpdate = { clientId: 'new-id' }; + + await service.encryptAndSaveData(credential, toUpdate); + + expect(credentialsRepository.update).toHaveBeenCalledWith('1', { + id: '1', + name: expect.anything(), + type: 'test', + data: expect.any(String), + updatedAt: expect.any(Date), + }); + }); + }); + + describe('getCredentialWithoutUser', () => { + it('should return credential from repository', async () => { + const mockCredential = mock({ id: '1' }); + credentialsRepository.findOneBy.mockResolvedValue(mockCredential as any); + + const result = await (service as any).getCredentialWithoutUser('1'); + + expect(result).toBe(mockCredential); + expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' }); + }); + + it('should return null when credential not found', async () => { + credentialsRepository.findOneBy.mockResolvedValue(null); + + const result = await (service as any).getCredentialWithoutUser('1'); + + expect(result).toBeNull(); + }); + }); + + describe('createCsrfState', () => { + it('should create CSRF state with correct structure', () => { + const data = { cid: 'credential-id', userId: 'user-id' }; + jest.setSystemTime(new Date(timestamp)); + + const [csrfSecret, encodedState] = service.createCsrfState(data); + + expect(typeof csrfSecret).toBe('string'); + expect(csrfSecret.length).toBeGreaterThan(0); + + const decoded = JSON.parse(Buffer.from(encodedState, 'base64').toString()); + expect(decoded.cid).toBe('credential-id'); + expect(decoded.userId).toBe('user-id'); + expect(decoded.token).toBeDefined(); + expect(decoded.createdAt).toBe(timestamp); + }); + + it('should include additional data in state', () => { + const data = { cid: 'credential-id', customField: 'custom-value' }; + jest.setSystemTime(new Date(timestamp)); + + const [, encodedState] = service.createCsrfState(data); + + const decoded = JSON.parse(Buffer.from(encodedState, 'base64').toString()); + expect(decoded.customField).toBe('custom-value'); + }); + }); + + describe('decodeCsrfState', () => { + it('should decode valid CSRF state', () => { + const state = { + token: 'token', + cid: 'credential-id', + userId: 'user-id', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + const req = mock({ + user: mock({ id: 'user-id' }), + }); + + const result = (service as any).decodeCsrfState(encodedState, req); + + expect(result).toEqual(state); + }); + + it('should throw error when state format is invalid', () => { + const invalidState = 'not-base64-json'; + const req = mock({ + user: mock({ id: 'user-id' }), + }); + + expect(() => (service as any).decodeCsrfState(invalidState, req)).toThrow( + 'Invalid state format', + ); + }); + + it('should throw UnexpectedError when cid is missing', () => { + const state = { + token: 'token', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + const req = mock({ + user: mock({ id: 'user-id' }), + }); + + expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(UnexpectedError); + }); + + it('should throw UnexpectedError when token is missing', () => { + const state = { + cid: 'credential-id', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + const req = mock({ + user: mock({ id: 'user-id' }), + }); + + expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(UnexpectedError); + }); + + it('should throw AuthError when userId does not match', () => { + const state = { + token: 'token', + cid: 'credential-id', + userId: 'different-user-id', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + const req = mock({ + user: mock({ id: 'user-id' }), + }); + + expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(AuthError); + expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow('Unauthorized'); + }); + + it('should throw AuthError when req.user is undefined', () => { + const state = { + token: 'token', + cid: 'credential-id', + userId: 'user-id', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + const req = mock({ + user: undefined, + }); + + expect(() => (service as any).decodeCsrfState(encodedState, req)).toThrow(AuthError); + }); + }); + + describe('verifyCsrfState', () => { + it('should return true for valid CSRF state', () => { + const csrfSecret = 'csrf-secret'; + const token = new (require('csrf'))(); + const stateToken = token.create(csrfSecret); + + const state = { + token: stateToken, + cid: 'credential-id', + createdAt: Date.now(), + }; + const decrypted = { csrfSecret }; + + const result = (service as any).verifyCsrfState(decrypted, state); + + expect(result).toBe(true); + }); + + it('should return false when CSRF state is expired', () => { + const csrfSecret = 'csrf-secret'; + const token = new (require('csrf'))(); + const stateToken = token.create(csrfSecret); + + const expiredTime = Date.now() - 6 * Time.minutes.toMilliseconds; + const state = { + token: stateToken, + cid: 'credential-id', + createdAt: expiredTime, + }; + const decrypted = { csrfSecret }; + + const result = (service as any).verifyCsrfState(decrypted, state); + + expect(result).toBe(false); + }); + + it('should return false when csrfSecret is missing', () => { + const token = new (require('csrf'))(); + const csrfSecret = 'csrf-secret'; + const stateToken = token.create(csrfSecret); + + const state = { + token: stateToken, + cid: 'credential-id', + createdAt: Date.now(), + }; + const decrypted = {}; + + const result = (service as any).verifyCsrfState(decrypted, state); + + expect(result).toBe(false); + }); + + it('should return false when token verification fails', () => { + const state = { + token: 'invalid-token', + cid: 'credential-id', + createdAt: Date.now(), + }; + const decrypted = { csrfSecret: 'csrf-secret' }; + + const result = (service as any).verifyCsrfState(decrypted, state); + + expect(result).toBe(false); + }); + }); + + describe('resolveCredential', () => { + it('should resolve credential successfully', async () => { + const state = { + token: 'token', + cid: 'credential-id', + userId: 'user-id', + createdAt: timestamp, + }; + + const mockCredential = mock({ id: 'credential-id' }); + const mockDecryptedData = { csrfSecret: 'csrf-secret' }; + const mockOAuthCredentials = { clientId: 'client-id' }; + const mockAdditionalData = mock(); + + const token = new (require('csrf'))(); + const stateToken = token.create('csrf-secret'); + state.token = stateToken; + + const req = mock({ + query: { state: Buffer.from(JSON.stringify(state)).toString('base64') }, + user: mock({ id: 'user-id' }), + }); + + credentialsRepository.findOneBy.mockResolvedValue(mockCredential); + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(true); + + const result = await service.resolveCredential(req); + + expect(result).toEqual([mockCredential, mockDecryptedData, mockOAuthCredentials]); + }); + + it('should throw UnexpectedError when credential is not found', async () => { + const state = { + token: 'token', + cid: 'credential-id', + userId: 'user-id', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + + const req = mock({ + query: { state: encodedState }, + user: mock({ id: 'user-id' }), + }); + + credentialsRepository.findOneBy.mockResolvedValue(null); + + await expect(service.resolveCredential(req)).rejects.toThrow(UnexpectedError); + await expect(service.resolveCredential(req)).rejects.toThrow( + 'OAuth callback failed because of insufficient permissions', + ); + }); + + it('should throw UnexpectedError when CSRF state is invalid', async () => { + const state = { + token: 'token', + cid: 'credential-id', + userId: 'user-id', + createdAt: timestamp, + }; + const encodedState = Buffer.from(JSON.stringify(state)).toString('base64'); + + const mockCredential = mock({ id: 'credential-id' }); + const mockDecryptedData = { csrfSecret: 'csrf-secret' }; + const mockOAuthCredentials = { clientId: 'client-id' }; + const mockAdditionalData = mock(); + + const req = mock({ + query: { state: encodedState }, + user: mock({ id: 'user-id' }), + }); + + credentialsRepository.findOneBy.mockResolvedValue(mockCredential); + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + jest.spyOn(service as any, 'verifyCsrfState').mockReturnValue(false); + + await expect(service.resolveCredential(req)).rejects.toThrow(UnexpectedError); + await expect(service.resolveCredential(req)).rejects.toThrow( + 'The OAuth callback state is invalid!', + ); + }); + }); + + describe('renderCallbackError', () => { + it('should render error page with message', () => { + const res = mock(); + const message = 'Test error message'; + + service.renderCallbackError(res, message); + + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { + error: { message }, + }); + }); + + it('should render error page with message and reason', () => { + const res = mock(); + const message = 'Test error message'; + const reason = 'Test reason'; + + service.renderCallbackError(res, message, reason); + + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { + error: { message, reason }, + }); + }); + }); + + describe('getOAuthCredentials', () => { + it('should return OAuth credentials', async () => { + const credential = mock({ + id: '1', + type: 'testOAuth2Api', + }); + const mockDecryptedData = { clientId: 'client-id' }; + const mockOAuthCredentials = { clientId: 'client-id', clientSecret: 'secret' }; + const mockAdditionalData = mock(); + + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + const result = await service.getOAuthCredentials(credential); + + expect(result).toBe(mockOAuthCredentials); + }); + + it('should delete scope for non-generic OAuth2 credentials', async () => { + const credential = mock({ + id: '1', + type: 'testOAuth2Api', + }); + const mockDecryptedData = { clientId: 'client-id', scope: 'old-scope' }; + const mockOAuthCredentials = { clientId: 'client-id' }; + const mockAdditionalData = mock(); + + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + await service.getOAuthCredentials(credential); + + expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith( + mockAdditionalData, + { clientId: 'client-id' }, + credential, + credential.type, + 'internal', + undefined, + undefined, + ); + }); + + it('should not delete scope for generic OAuth2 credentials with editable scope', async () => { + const credential = mock({ + id: '1', + type: 'oAuth2Api', + }); + const mockDecryptedData = { clientId: 'client-id', scope: 'old-scope' }; + const mockOAuthCredentials = { clientId: 'client-id', scope: 'old-scope' }; + const mockAdditionalData = mock(); + + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + await service.getOAuthCredentials(credential); + + expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith( + mockAdditionalData, + { clientId: 'client-id', scope: 'old-scope' }, + credential, + credential.type, + 'internal', + undefined, + undefined, + ); + }); + + it('should not delete scope for non-OAuth2 credentials', async () => { + const credential = mock({ + id: '1', + type: 'testApi', + }); + const mockDecryptedData = { clientId: 'client-id', scope: 'old-scope' }; + const mockOAuthCredentials = { clientId: 'client-id', scope: 'old-scope' }; + const mockAdditionalData = mock(); + + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + await service.getOAuthCredentials(credential); + + expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith( + mockAdditionalData, + { clientId: 'client-id', scope: 'old-scope' }, + credential, + credential.type, + 'internal', + undefined, + undefined, + ); + }); + }); + + describe('generateAOauth2AuthUri', () => { + it('should generate auth URI without dynamic client registration', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, + ); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials: OAuth2CredentialData = { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(service.encryptAndSaveData).toHaveBeenCalled(); + const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe(credential); + expect(callArgs[1]).toHaveProperty('csrfSecret'); + expect(typeof callArgs[1].csrfSecret).toBe('string'); + expect(callArgs[2] || []).toEqual([]); + expect(externalHooks.run).toHaveBeenCalledWith('oauth2.authenticate', expect.any(Array)); + }); + + it('should generate auth URI with PKCE flow', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const pkceChallenge = await import('pkce-challenge'); + jest.mocked(pkceChallenge.default).mockResolvedValue({ + code_verifier: 'code_verifier', + code_challenge: 'code_challenge', + }); + + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid&code_challenge=code_challenge&code_challenge_method=S256', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, + ); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials: OAuth2CredentialData = { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'pkce', + authentication: 'header', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('code_challenge=code_challenge'); + expect(service.encryptAndSaveData).toHaveBeenCalled(); + const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe(credential); + expect(callArgs[1]).toHaveProperty('csrfSecret'); + expect(callArgs[1]).toHaveProperty('codeVerifier', 'code_verifier'); + expect(callArgs[2] || []).toEqual([]); + }); + + it('should generate auth URI with auth query parameters', async () => { + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid&custom_param=value', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, + ); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials: OAuth2CredentialData = { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + authQueryParameters: 'custom_param=value', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(mockGetUri).toHaveBeenCalled(); + }); + + it('should handle dynamic client registration', async () => { + const axios = require('axios'); + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=registered_client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid profile', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, + ); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials = { + serverUrl: 'https://example.domain', + useDynamicClientRegistration: true, + } as OAuth2CredentialData; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.get).mockResolvedValue({ + data: { + authorization_endpoint: 'https://example.domain/oauth2/auth', + token_endpoint: 'https://example.domain/oauth2/token', + registration_endpoint: 'https://example.domain/oauth2/register', + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['openid', 'profile'], + }, + } as any); + + jest.mocked(axios.post).mockResolvedValue({ + data: { + client_id: 'registered_client_id', + client_secret: 'registered_client_secret', + }, + } as any); + + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(axios.get).toHaveBeenCalledWith( + 'https://example.domain/.well-known/oauth-authorization-server', + ); + expect(axios.post).toHaveBeenCalledWith( + 'https://example.domain/oauth2/register', + expect.objectContaining({ + client_name: 'n8n', + grant_types: ['authorization_code', 'refresh_token'], + }), + ); + expect(externalHooks.run).toHaveBeenCalledWith( + 'oauth2.dynamicClientRegistration', + expect.any(Array), + ); + expect(service.encryptAndSaveData).toHaveBeenCalled(); + const callArgs = (service.encryptAndSaveData as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe(credential); + expect(callArgs[1]).toHaveProperty('authUrl', 'https://example.domain/oauth2/auth'); + expect(callArgs[1]).toHaveProperty('accessTokenUrl', 'https://example.domain/oauth2/token'); + expect(callArgs[1]).toHaveProperty('clientId', 'registered_client_id'); + expect(callArgs[1]).toHaveProperty('clientSecret', 'registered_client_secret'); + expect(callArgs[1]).toHaveProperty('scope', 'openid profile'); + expect(callArgs[1]).toHaveProperty('grantType', 'pkce'); + expect(callArgs[1]).toHaveProperty('csrfSecret'); + expect(callArgs[1]).toHaveProperty('codeVerifier', 'code_verifier'); + expect(callArgs[2] || []).toEqual([]); + }); + + it('should throw BadRequestError when OAuth2 server metadata is invalid', async () => { + const axios = require('axios'); + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials = { + serverUrl: 'https://example.domain', + useDynamicClientRegistration: true, + } as OAuth2CredentialData; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.get).mockResolvedValue({ + data: { invalid: 'metadata' }, + } as any); + + await expect( + service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }), + ).rejects.toThrow(BadRequestError); + await expect( + service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }), + ).rejects.toThrow('Invalid OAuth2 server metadata'); + }); + + it('should throw BadRequestError when client registration response is invalid', async () => { + const axios = require('axios'); + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + jest.mocked(ClientOAuth2).mockImplementation(() => ({}) as any); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials = { + serverUrl: 'https://example.domain', + useDynamicClientRegistration: true, + } as OAuth2CredentialData; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.get).mockResolvedValue({ + data: { + authorization_endpoint: 'https://example.domain/oauth2/auth', + token_endpoint: 'https://example.domain/oauth2/token', + registration_endpoint: 'https://example.domain/oauth2/register', + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + code_challenge_methods_supported: ['S256'], + }, + } as any); + + jest.mocked(axios.post).mockResolvedValue({ + data: { invalid: 'response' }, + } as any); + + await expect( + service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }), + ).rejects.toThrow(BadRequestError); + await expect( + service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }), + ).rejects.toThrow('Invalid client registration response'); + }); + + it('should handle dynamic client registration with client_secret_post authentication', async () => { + const axios = require('axios'); + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=registered_client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, + ); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials = { + serverUrl: 'https://example.domain', + useDynamicClientRegistration: true, + } as OAuth2CredentialData; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.get).mockResolvedValue({ + data: { + authorization_endpoint: 'https://example.domain/oauth2/auth', + token_endpoint: 'https://example.domain/oauth2/token', + registration_endpoint: 'https://example.domain/oauth2/register', + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_post'], + code_challenge_methods_supported: [], + }, + } as any); + + jest.mocked(axios.post).mockResolvedValue({ + data: { + client_id: 'registered_client_id', + client_secret: 'registered_client_secret', + }, + } as any); + + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('https://example.domain/oauth2/auth'); + expect(oauthCredentials.authentication).toBe('body'); + expect(oauthCredentials.grantType).toBe('authorizationCode'); + }); + + it('should skip userId in CSRF state when skipAuthOnOAuthCallback is true', async () => { + // This test verifies the behavior when skipAuthOnOAuthCallback is true + // Since the skipAuthOnOAuthCallback is evaluated at module load time, + // we need to check the actual behavior by verifying the CSRF state doesn't include userId + // when the env var is set. However, since it's evaluated at module load, we'll test + // that the function works correctly with or without userId + const { ClientOAuth2 } = await import('@n8n/client-oauth2'); + const mockGetUri = jest.fn().mockReturnValue({ + toString: () => + 'https://example.domain/oauth2/auth?client_id=client_id&redirect_uri=http://localhost:5678/rest/oauth2-credential/callback&response_type=code&state=state&scope=openid', + }); + jest.mocked(ClientOAuth2).mockImplementation( + () => + ({ + code: { + getUri: mockGetUri, + }, + }) as any, + ); + + const credential = mock({ id: '1', type: 'googleOAuth2Api' }); + const oauthCredentials: OAuth2CredentialData = { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://example.domain/oauth2/auth', + accessTokenUrl: 'https://example.domain/oauth2/token', + scope: 'openid', + grantType: 'authorizationCode', + authentication: 'header', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + jest.spyOn(service, 'createCsrfState').mockReturnValue(['csrf-secret', 'encoded-state']); + + await service.generateAOauth2AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + // Verify createCsrfState was called with cid + expect(service.createCsrfState).toHaveBeenCalledWith( + expect.objectContaining({ + cid: '1', + }), + ); + }); + }); + + describe('generateAOauth1AuthUri', () => { + it('should generate auth URI for OAuth1 credential', async () => { + const axios = require('axios'); + const credential = mock({ id: '1', type: 'twitterOAuth1Api' }); + const oauthCredentials: OAuth1CredentialData = { + consumerKey: 'consumer_key', + consumerSecret: 'consumer_secret', + requestTokenUrl: 'https://example.domain/oauth/request_token', + authUrl: 'https://example.domain/oauth/authorize', + accessTokenUrl: 'https://example.domain/oauth/access_token', + signatureMethod: 'HMAC-SHA1', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.request).mockResolvedValue({ + data: 'oauth_token=random-token&oauth_token_secret=random-secret', + }); + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth1AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token'); + expect(service.encryptAndSaveData).toHaveBeenCalledWith( + credential, + expect.objectContaining({ csrfSecret: expect.any(String) }), + [], + ); + expect(externalHooks.run).toHaveBeenCalledWith('oauth1.authenticate', expect.any(Array)); + }); + + it('should generate auth URI with different signature methods', async () => { + const axios = require('axios'); + const credential = mock({ id: '1', type: 'twitterOAuth1Api' }); + const oauthCredentials: OAuth1CredentialData = { + consumerKey: 'consumer_key', + consumerSecret: 'consumer_secret', + requestTokenUrl: 'https://example.domain/oauth/request_token', + authUrl: 'https://example.domain/oauth/authorize', + accessTokenUrl: 'https://example.domain/oauth/access_token', + signatureMethod: 'HMAC-SHA256', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.request).mockResolvedValue({ + data: 'oauth_token=random-token&oauth_token_secret=random-secret', + }); + jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined); + + const authUri = await service.generateAOauth1AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }); + + expect(authUri).toContain('https://example.domain/oauth/authorize?oauth_token=random-token'); + expect(service.encryptAndSaveData).toHaveBeenCalled(); + }); + + it('should handle request token URL errors', async () => { + const axios = require('axios'); + const credential = mock({ id: '1', type: 'twitterOAuth1Api' }); + const oauthCredentials: OAuth1CredentialData = { + consumerKey: 'consumer_key', + consumerSecret: 'consumer_secret', + requestTokenUrl: 'https://example.domain/oauth/request_token', + authUrl: 'https://example.domain/oauth/authorize', + accessTokenUrl: 'https://example.domain/oauth/access_token', + signatureMethod: 'HMAC-SHA1', + }; + + jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue(oauthCredentials); + jest.mocked(axios.request).mockRejectedValue(new Error('Request token failed')); + + await expect( + service.generateAOauth1AuthUri(credential, { + cid: credential.id, + userId: 'user-id', + }), + ).rejects.toThrow('Request token failed'); + }); + }); +}); diff --git a/packages/cli/src/oauth/oauth.service.ts b/packages/cli/src/oauth/oauth.service.ts new file mode 100644 index 00000000000..de88b654ab7 --- /dev/null +++ b/packages/cli/src/oauth/oauth.service.ts @@ -0,0 +1,558 @@ +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; +import type { AuthenticatedRequest, CredentialsEntity, ICredentialsDb } from '@n8n/db'; +import { CredentialsRepository } from '@n8n/db'; +import { Service } from '@n8n/di'; +import Csrf from 'csrf'; +import type { Response } from 'express'; +import { Credentials } from 'n8n-core'; +import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; + +import { + GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE, + RESPONSE_ERROR_MESSAGES, +} from '@/constants'; +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { CredentialsHelper } from '@/credentials-helper'; +import { AuthError } from '@/errors/response-errors/auth.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { OAuthRequest } from '@/requests'; +import { UrlService } from '@/services/url.service'; +import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; +import { + ClientOAuth2, + type ClientOAuth2Options, + type OAuth2AuthenticationMethod, + type OAuth2CredentialData, + type OAuth2GrantType, +} from '@n8n/client-oauth2'; +import axios from 'axios'; +import { + oAuthAuthorizationServerMetadataSchema, + dynamicClientRegistrationResponseSchema, +} from '@/controllers/oauth/oauth2-dynamic-client-registration.schema'; +import pkceChallenge from 'pkce-challenge'; +import * as qs from 'querystring'; +import split from 'lodash/split'; +import { ExternalHooks } from '@/external-hooks'; +import type { AxiosRequestConfig } from 'axios'; +import { createHmac } from 'crypto'; +import type { RequestOptions } from 'oauth-1.0a'; +import clientOAuth1 from 'oauth-1.0a'; +import { + algorithmMap, + MAX_CSRF_AGE, + OauthVersion, + type CreateCsrfStateData, + type CsrfState, + type OAuth1CredentialData, +} from './types'; + +export function shouldSkipAuthOnOAuthCallback() { + const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false'; + return value === 'true'; +} + +export const skipAuthOnOAuthCallback = shouldSkipAuthOnOAuthCallback(); + +export { OauthVersion, type OAuth1CredentialData, type CreateCsrfStateData, type CsrfState }; + +@Service() +export class OauthService { + constructor( + protected readonly logger: Logger, + private readonly credentialsHelper: CredentialsHelper, + private readonly credentialsRepository: CredentialsRepository, + private readonly credentialsFinderService: CredentialsFinderService, + private readonly urlService: UrlService, + private readonly globalConfig: GlobalConfig, + private readonly externalHooks: ExternalHooks, + ) {} + + getBaseUrl(oauthVersion: OauthVersion) { + const restUrl = `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}`; + return `${restUrl}/oauth${oauthVersion}-credential`; + } + + async getCredential( + req: OAuthRequest.OAuth1Credential.Auth | OAuthRequest.OAuth2Credential.Auth, + ): Promise { + const { id: credentialId } = req.query; + + if (!credentialId) { + throw new BadRequestError('Required credential ID is missing'); + } + + const credential = await this.credentialsFinderService.findCredentialForUser( + credentialId, + req.user, + ['credential:read'], + ); + + if (!credential) { + this.logger.error( + 'OAuth credential authorization failed because the current user does not have the correct permissions', + { userId: req.user.id }, + ); + throw new NotFoundError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL); + } + + return credential; + } + + protected async getAdditionalData() { + return await WorkflowExecuteAdditionalData.getBase(); + } + + /** + * Allow decrypted data to evaluate expressions that include $secrets and apply overwrites + */ + protected async getDecryptedDataForAuthUri( + credential: ICredentialsDb, + additionalData: IWorkflowExecuteAdditionalData, + ) { + return await this.getDecryptedData(credential, additionalData, false); + } + + /** + * Do not apply overwrites here because that removes the CSRF state, and breaks the oauth flow + */ + protected async getDecryptedDataForCallback( + credential: ICredentialsDb, + additionalData: IWorkflowExecuteAdditionalData, + ) { + return await this.getDecryptedData(credential, additionalData, true); + } + + private async getDecryptedData( + credential: ICredentialsDb, + additionalData: IWorkflowExecuteAdditionalData, + raw: boolean, + ) { + return await this.credentialsHelper.getDecrypted( + additionalData, + credential, + credential.type, + 'internal', + undefined, + raw, + ); + } + + protected async applyDefaultsAndOverwrites( + credential: ICredentialsDb, + decryptedData: ICredentialDataDecryptedObject, + additionalData: IWorkflowExecuteAdditionalData, + ) { + return (await this.credentialsHelper.applyDefaultsAndOverwrites( + additionalData, + decryptedData, + credential, + credential.type, + 'internal', + undefined, + undefined, + )) as unknown as T; + } + + async encryptAndSaveData( + credential: ICredentialsDb, + toUpdate: ICredentialDataDecryptedObject, + toDelete: string[] = [], + ) { + const credentials = new Credentials(credential, credential.type, credential.data); + credentials.updateData(toUpdate, toDelete); + await this.credentialsRepository.update(credential.id, { + ...credentials.getDataToSave(), + updatedAt: new Date(), + }); + } + + /** Get a credential without user check */ + protected async getCredentialWithoutUser(credentialId: string): Promise { + return await this.credentialsRepository.findOneBy({ id: credentialId }); + } + + createCsrfState(data: CreateCsrfStateData): [string, string] { + const token = new Csrf(); + const csrfSecret = token.secretSync(); + const state: CsrfState = { + token: token.create(csrfSecret), + createdAt: Date.now(), + ...data, + }; + return [csrfSecret, Buffer.from(JSON.stringify(state)).toString('base64')]; + } + + protected decodeCsrfState(encodedState: string, req: AuthenticatedRequest): CsrfState { + const errorMessage = 'Invalid state format'; + const decoded = jsonParse(Buffer.from(encodedState, 'base64').toString(), { + errorMessage, + }); + + if (typeof decoded.cid !== 'string' || typeof decoded.token !== 'string') { + throw new UnexpectedError(errorMessage); + } + + if (decoded.userId !== req.user?.id) { + throw new AuthError('Unauthorized'); + } + + return decoded; + } + + protected verifyCsrfState( + decrypted: ICredentialDataDecryptedObject & { csrfSecret?: string }, + state: CsrfState, + ) { + const token = new Csrf(); + + return ( + Date.now() - state.createdAt <= MAX_CSRF_AGE && + decrypted.csrfSecret !== undefined && + token.verify(decrypted.csrfSecret, state.token) + ); + } + + async resolveCredential( + req: OAuthRequest.OAuth1Credential.Callback | OAuthRequest.OAuth2Credential.Callback, + ): Promise<[ICredentialsDb, ICredentialDataDecryptedObject, T]> { + const { state: encodedState } = req.query; + const state = this.decodeCsrfState(encodedState, req); + const credential = await this.getCredentialWithoutUser(state.cid); + if (!credential) { + throw new UnexpectedError('OAuth callback failed because of insufficient permissions'); + } + + const additionalData = await this.getAdditionalData(); + const decryptedDataOriginal = await this.getDecryptedDataForCallback( + credential, + additionalData, + ); + + const oauthCredentials = await this.applyDefaultsAndOverwrites( + credential, + decryptedDataOriginal, + additionalData, + ); + + if (!this.verifyCsrfState(decryptedDataOriginal, state)) { + throw new UnexpectedError('The OAuth callback state is invalid!'); + } + + return [credential, decryptedDataOriginal, oauthCredentials]; + } + + renderCallbackError(res: Response, message: string, reason?: string) { + res.render('oauth-error-callback', { error: { message, reason } }); + } + + async getOAuthCredentials(credential: CredentialsEntity): Promise { + const additionalData = await this.getAdditionalData(); + const decryptedDataOriginal = await this.getDecryptedDataForAuthUri(credential, additionalData); + + // At some point in the past we saved hidden scopes to credentials (but shouldn't) + // Delete scope before applying defaults to make sure new scopes are present on reconnect + // Generic Oauth2 API is an exception because it needs to save the scope + if ( + decryptedDataOriginal?.scope && + credential.type.includes('OAuth2') && + !GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE.includes(credential.type) + ) { + delete decryptedDataOriginal.scope; + } + + const oauthCredentials = await this.applyDefaultsAndOverwrites( + credential, + decryptedDataOriginal, + additionalData, + ); + + return oauthCredentials; + } + + async generateAOauth2AuthUri( + credential: CredentialsEntity, + csrfData: CreateCsrfStateData, + ): Promise { + const oauthCredentials: OAuth2CredentialData = + await this.getOAuthCredentials(credential); + + const toUpdate: ICredentialDataDecryptedObject = {}; + + if (oauthCredentials.useDynamicClientRegistration && oauthCredentials.serverUrl) { + const serverUrl = new URL(oauthCredentials.serverUrl); + const { data } = await axios.get( + `${serverUrl.origin}/.well-known/oauth-authorization-server`, + ); + const metadataValidation = oAuthAuthorizationServerMetadataSchema.safeParse(data); + if (!metadataValidation.success) { + throw new BadRequestError( + `Invalid OAuth2 server metadata: ${metadataValidation.error.issues.map((e) => e.message).join(', ')}`, + ); + } + + const { authorization_endpoint, token_endpoint, registration_endpoint, scopes_supported } = + metadataValidation.data; + oauthCredentials.authUrl = authorization_endpoint; + oauthCredentials.accessTokenUrl = token_endpoint; + toUpdate.authUrl = authorization_endpoint; + toUpdate.accessTokenUrl = token_endpoint; + const scope = scopes_supported ? scopes_supported.join(' ') : undefined; + if (scope) { + oauthCredentials.scope = scope; + toUpdate.scope = scope; + } + + const { grantType, authentication } = this.selectGrantTypeAndAuthenticationMethod( + metadataValidation.data.grant_types_supported ?? ['authorization_code', 'implicit'], + metadataValidation.data.token_endpoint_auth_methods_supported ?? ['client_secret_basic'], + metadataValidation.data.code_challenge_methods_supported ?? [], + ); + oauthCredentials.grantType = grantType; + toUpdate.grantType = grantType; + if (authentication) { + oauthCredentials.authentication = authentication; + toUpdate.authentication = authentication; + } + + const { grant_types, token_endpoint_auth_method } = this.mapGrantTypeAndAuthenticationMethod( + grantType, + authentication, + ); + const registerPayload = { + redirect_uris: [`${this.getBaseUrl(OauthVersion.V2)}/callback`], + token_endpoint_auth_method, + grant_types, + response_types: ['code'], + client_name: 'n8n', + client_uri: 'https://n8n.io/', + scope, + }; + + await this.externalHooks.run('oauth2.dynamicClientRegistration', [registerPayload]); + + const { data: registerResult } = await axios.post( + registration_endpoint, + registerPayload, + ); + const registrationValidation = + dynamicClientRegistrationResponseSchema.safeParse(registerResult); + if (!registrationValidation.success) { + throw new BadRequestError( + `Invalid client registration response: ${registrationValidation.error.issues.map((e) => e.message).join(', ')}`, + ); + } + + const { client_id, client_secret } = registrationValidation.data; + oauthCredentials.clientId = client_id; + toUpdate.clientId = client_id; + if (client_secret) { + oauthCredentials.clientSecret = client_secret; + toUpdate.clientSecret = client_secret; + } + } + + // Generate a CSRF prevention token and send it as an OAuth2 state string + const [csrfSecret, state] = this.createCsrfState(csrfData); + + const oAuthOptions = { + ...this.convertCredentialToOptions(oauthCredentials), + state, + }; + + if (oauthCredentials.authQueryParameters) { + oAuthOptions.query = qs.parse(oauthCredentials.authQueryParameters); + } + + await this.externalHooks.run('oauth2.authenticate', [oAuthOptions]); + + toUpdate.csrfSecret = csrfSecret; + if (oauthCredentials.grantType === 'pkce') { + const { code_verifier, code_challenge } = await pkceChallenge(); + oAuthOptions.query = { + ...oAuthOptions.query, + code_challenge, + code_challenge_method: 'S256', + }; + toUpdate.codeVerifier = code_verifier; + } + + await this.encryptAndSaveData(credential, toUpdate); + + const oAuthObj = new ClientOAuth2(oAuthOptions); + const returnUri = oAuthObj.code.getUri(); + + this.logger.debug('OAuth2 authorization url created for credential', { + csrfData, + credentialId: credential.id, + }); + + return returnUri.toString(); + } + + async generateAOauth1AuthUri( + credential: CredentialsEntity, + csrfData: CreateCsrfStateData, + ): Promise { + const oauthCredentials: OAuth1CredentialData = + await this.getOAuthCredentials(credential); + + const [csrfSecret, state] = this.createCsrfState(csrfData); + + const signatureMethod = oauthCredentials.signatureMethod; + + const oAuthOptions: clientOAuth1.Options = { + consumer: { + key: oauthCredentials.consumerKey, + secret: oauthCredentials.consumerSecret, + }, + signature_method: signatureMethod, + + hash_function(base, key) { + const algorithm = algorithmMap[signatureMethod] ?? 'sha1'; + return createHmac(algorithm, key).update(base).digest('base64'); + }, + }; + + const oauthRequestData = { + oauth_callback: `${this.getBaseUrl(OauthVersion.V1)}/callback?state=${state}`, + }; + + await this.externalHooks.run('oauth1.authenticate', [oAuthOptions, oauthRequestData]); + + const oauth = new clientOAuth1(oAuthOptions); + + const options: RequestOptions = { + method: 'POST', + url: oauthCredentials.requestTokenUrl, + data: oauthRequestData, + }; + + const data = oauth.toHeader(oauth.authorize(options)); + + const axiosConfig: AxiosRequestConfig = { + method: options.method, + url: options.url, + headers: { + ...data, + }, + }; + + const { data: response } = await axios.request(axiosConfig); + + // Response comes as x-www-form-urlencoded string so convert it to JSON + if (typeof response !== 'string') { + throw new BadRequestError( + 'Expected string response from OAuth1 request token endpoint, but received invalid response type', + ); + } + + const paramsParser = new URLSearchParams(response); + const responseJson = Object.fromEntries(paramsParser.entries()); + + if (!responseJson.oauth_token) { + throw new BadRequestError( + 'OAuth1 request token response is missing required oauth_token parameter', + ); + } + + const returnUri = `${oauthCredentials.authUrl}?oauth_token=${responseJson.oauth_token}`; + + await this.encryptAndSaveData(credential, { csrfSecret }, []); + + this.logger.debug('OAuth1 authorization url created for credential', { + csrfData, + credentialId: credential.id, + }); + + return returnUri; + } + + private convertCredentialToOptions(credential: OAuth2CredentialData): ClientOAuth2Options { + const options: ClientOAuth2Options = { + clientId: credential.clientId, + clientSecret: credential.clientSecret ?? '', + accessTokenUri: credential.accessTokenUrl ?? '', + authorizationUri: credential.authUrl ?? '', + authentication: credential.authentication ?? 'header', + redirectUri: `${this.getBaseUrl(OauthVersion.V2)}/callback`, + scopes: split(credential.scope ?? 'openid', ','), + scopesSeparator: credential.scope?.includes(',') ? ',' : ' ', + ignoreSSLIssues: credential.ignoreSSLIssues ?? false, + }; + + if ( + credential.additionalBodyProperties && + typeof credential.additionalBodyProperties === 'string' + ) { + const parsedBody = jsonParse>(credential.additionalBodyProperties); + + if (parsedBody) { + options.body = parsedBody; + } + } + + return options; + } + + private selectGrantTypeAndAuthenticationMethod( + grantTypes: string[], + tokenEndpointAuthMethods: string[], + codeChallengeMethods: string[], + ): { grantType: OAuth2GrantType; authentication?: OAuth2AuthenticationMethod } { + if (grantTypes.includes('authorization_code') && grantTypes.includes('refresh_token')) { + if (codeChallengeMethods.includes('S256')) { + return { grantType: 'pkce' }; + } + + if (tokenEndpointAuthMethods.includes('client_secret_basic')) { + return { grantType: 'authorizationCode', authentication: 'header' }; + } + + if (tokenEndpointAuthMethods.includes('client_secret_post')) { + return { grantType: 'authorizationCode', authentication: 'body' }; + } + } + + if (grantTypes.includes('client_credentials')) { + if (tokenEndpointAuthMethods.includes('client_secret_basic')) { + return { grantType: 'clientCredentials', authentication: 'header' }; + } + + if (tokenEndpointAuthMethods.includes('client_secret_post')) { + return { grantType: 'clientCredentials', authentication: 'body' }; + } + } + + throw new BadRequestError('No supported grant type and authentication method found'); + } + + private mapGrantTypeAndAuthenticationMethod( + grantType: OAuth2GrantType, + authentication?: OAuth2AuthenticationMethod, + ) { + if (grantType === 'pkce') { + return { + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + }; + } + + const tokenEndpointAuthMethod = + authentication === 'header' ? 'client_secret_basic' : 'client_secret_post'; + if (grantType === 'authorizationCode') { + return { + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: tokenEndpointAuthMethod, + }; + } + + return { + grant_types: ['client_credentials'], + token_endpoint_auth_method: tokenEndpointAuthMethod, + }; + } +} diff --git a/packages/cli/src/oauth/types.ts b/packages/cli/src/oauth/types.ts new file mode 100644 index 00000000000..512ffe37b78 --- /dev/null +++ b/packages/cli/src/oauth/types.ts @@ -0,0 +1,37 @@ +import { Time } from '@n8n/constants'; + +export type CsrfStateRequired = { + /** Random CSRF token, used to verify the signature of the CSRF state */ + token: string; + /** Creation timestamp of the CSRF state. Used for expiration. */ + createdAt: number; +}; + +export type CreateCsrfStateData = { + cid: string; + [key: string]: unknown; +}; + +export type CsrfState = CsrfStateRequired & CreateCsrfStateData; + +export const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds; + +export const enum OauthVersion { + V1 = 1, + V2 = 2, +} + +export interface OAuth1CredentialData { + signatureMethod: 'HMAC-SHA256' | 'HMAC-SHA512' | 'HMAC-SHA1'; + consumerKey: string; + consumerSecret: string; + authUrl: string; + accessTokenUrl: string; + requestTokenUrl: string; +} + +export const algorithmMap = { + 'HMAC-SHA256': 'sha256', + 'HMAC-SHA512': 'sha512', + 'HMAC-SHA1': 'sha1', +} as const; diff --git a/packages/cli/src/scaling/worker-status.service.ee.ts b/packages/cli/src/scaling/worker-status.service.ee.ts index b5668138222..5f86bb570ed 100644 --- a/packages/cli/src/scaling/worker-status.service.ee.ts +++ b/packages/cli/src/scaling/worker-status.service.ee.ts @@ -3,6 +3,7 @@ import { OnPubSubEvent } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; import os from 'node:os'; +import process from 'node:process'; import { N8N_VERSION } from '@/constants'; import { Push } from '@/push'; @@ -46,9 +47,29 @@ export class WorkerStatusService { } private generateStatus(): WorkerStatus { + const constrainedMemory = process.constrainedMemory(); + + // See https://github.com/nodejs/node/issues/59227 for information about why we cap at MAX_SAFE_INTEGER + // The number 18446744073709552000 does come back when running in a container with no constraints + const isInContainer = constrainedMemory > 0 && constrainedMemory < Number.MAX_SAFE_INTEGER; return { senderId: this.instanceSettings.hostId, runningJobsSummary: this.jobProcessor.getRunningJobsSummary(), + isInContainer, + process: { + memory: { + available: process.availableMemory(), + constraint: process.constrainedMemory(), + ...process.memoryUsage(), + }, + uptime: process.uptime(), + }, + host: { + memory: { + total: os.totalmem(), + free: os.freemem(), + }, + }, freeMem: os.freemem(), totalMem: os.totalmem(), uptime: process.uptime(), @@ -73,6 +94,6 @@ export class WorkerStatusService { if (cpus.length === 0) return 'no CPU info'; - return `${cpus.length}x ${cpus[0].model} - speed: ${cpus[0].speed}`; + return `${cpus.length}x ${cpus[0].model}`; } } diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index a5fc76eaca8..e237016a263 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -200,7 +200,7 @@ export class Server extends AbstractServer { const { frontendService } = this; if (frontendService) { - await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]); + await this.externalHooks.run('frontend.settings', [await frontendService.getSettings()]); } await this.postHogClient.init(); @@ -215,7 +215,7 @@ export class Server extends AbstractServer { const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint); this.app.use(...apiRouters); if (frontendService) { - frontendService.settings.publicApi.latestVersion = apiLatestVersion; + (await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion; } } @@ -487,7 +487,9 @@ export class Server extends AbstractServer { `/${this.restEndpoint}/settings`, authService.createAuthMiddleware({ allowSkipMFA: false, allowUnauthenticated: true }), ResponseHelper.send(async (req: AuthenticatedRequest) => { - return req.user ? frontendService.getSettings() : frontendService.getPublicSettings(); + return req.user + ? await frontendService.getSettings() + : await frontendService.getPublicSettings(); }), ); } diff --git a/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts b/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts index bd91ef4264b..2d6680bde88 100644 --- a/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts +++ b/packages/cli/src/services/__tests__/ai-workflow-builder.service.test.ts @@ -100,6 +100,7 @@ describe('WorkflowBuilderService', () => { describe('chat', () => { it('should create AiWorkflowBuilderService on first chat call without AI assistant client', async () => { const mockPayload = { + id: '12345', message: 'test message', workflowContext: {}, }; @@ -121,6 +122,7 @@ describe('WorkflowBuilderService', () => { mockLogger, 'test-instance-id', // instanceId 'https://instance.test.com', // instanceUrl + expect.any(String), // n8nVersion expect.any(Function), // onCreditsUpdated callback expect.any(Function), // onTelemetryEvent callback ); @@ -133,6 +135,7 @@ describe('WorkflowBuilderService', () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -160,6 +163,7 @@ describe('WorkflowBuilderService', () => { mockLogger, 'test-instance-id', // instanceId 'https://instance.test.com', // instanceUrl + expect.any(String), // n8nVersion expect.any(Function), // onCreditsUpdated callback expect.any(Function), // onTelemetryEvent callback ); @@ -168,6 +172,7 @@ describe('WorkflowBuilderService', () => { it('should reuse the same service instance on subsequent calls', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -199,6 +204,7 @@ describe('WorkflowBuilderService', () => { it('should pass abort signal to underlying service', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -263,6 +269,7 @@ describe('WorkflowBuilderService', () => { it('should send push notification when credits are updated', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -279,7 +286,7 @@ describe('WorkflowBuilderService', () => { MockedAiWorkflowBuilderService.mockImplementation(((...args: any[]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const callback = args[5]; // onCreditsUpdated is the 6th parameter + const callback = args[6]; // onCreditsUpdated is the 7th parameter (after n8nVersion) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment capturedCallback = callback; return mockAiService; @@ -311,6 +318,7 @@ describe('WorkflowBuilderService', () => { it('should handle multiple credit updates', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -327,7 +335,7 @@ describe('WorkflowBuilderService', () => { MockedAiWorkflowBuilderService.mockImplementation(((...args: any[]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const callback = args[5]; // onCreditsUpdated is the 6th parameter + const callback = args[6]; // onCreditsUpdated is the 7th parameter (after n8nVersion) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment capturedCallback = callback; return mockAiService; @@ -371,6 +379,7 @@ describe('WorkflowBuilderService', () => { it('should call telemetry.track when telemetry event is triggered', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -387,7 +396,7 @@ describe('WorkflowBuilderService', () => { MockedAiWorkflowBuilderService.mockImplementation(((...args: any[]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const telemetryCallback = args[6]; // onTelemetryEvent is the 7th parameter + const telemetryCallback = args[7]; // onTelemetryEvent is the 8th parameter (after n8nVersion) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment capturedTelemetryCallback = telemetryCallback; return mockAiService; @@ -417,6 +426,7 @@ describe('WorkflowBuilderService', () => { it('should handle multiple telemetry events', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -433,7 +443,7 @@ describe('WorkflowBuilderService', () => { MockedAiWorkflowBuilderService.mockImplementation(((...args: any[]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const telemetryCallback = args[6]; // onTelemetryEvent is the 7th parameter + const telemetryCallback = args[7]; // onTelemetryEvent is the 8th parameter (after n8nVersion) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment capturedTelemetryCallback = telemetryCallback; return mockAiService; @@ -461,6 +471,7 @@ describe('WorkflowBuilderService', () => { it('should handle telemetry events with empty properties', async () => { const mockPayload = { message: 'test message', + id: '12345', workflowContext: {}, }; @@ -477,7 +488,7 @@ describe('WorkflowBuilderService', () => { MockedAiWorkflowBuilderService.mockImplementation(((...args: any[]) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const telemetryCallback = args[6]; // onTelemetryEvent is the 7th parameter + const telemetryCallback = args[7]; // onTelemetryEvent is the 8th parameter (after n8nVersion) // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment capturedTelemetryCallback = telemetryCallback; return mockAiService; @@ -497,6 +508,92 @@ describe('WorkflowBuilderService', () => { }); }); + describe('license certificate refresh', () => { + it('should register for license certificate updates when client is created', async () => { + mockConfig.aiAssistant.baseUrl = 'https://ai-assistant.test.com'; + + const mockPayload = { + message: 'test message', + id: '12345', + workflowContext: {}, + }; + + const mockChatGenerator = (async function* () { + yield { messages: ['response'] }; + })(); + + const mockAiService = mock(); + (mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator); + MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService); + + const generator = service.chat(mockPayload, mockUser); + await generator.next(); + + expect(mockLicense.onCertRefresh).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should update client license cert when callback is invoked', async () => { + mockConfig.aiAssistant.baseUrl = 'https://ai-assistant.test.com'; + + const mockPayload = { + message: 'test message', + id: '12345', + workflowContext: {}, + }; + + const mockChatGenerator = (async function* () { + yield { messages: ['response'] }; + })(); + + const mockAiService = mock(); + (mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator); + MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService); + + // Capture the callback passed to onCertRefresh + let capturedCallback: ((cert: string) => void) | undefined; + (mockLicense.onCertRefresh as jest.Mock).mockImplementation((cb: (cert: string) => void) => { + capturedCallback = cb; + return () => {}; + }); + + const generator = service.chat(mockPayload, mockUser); + await generator.next(); + + expect(capturedCallback).toBeDefined(); + + // Get the mocked client instance + const mockClientInstance = MockedAiAssistantClient.mock.instances[0]; + + // Invoke the callback with a new cert + capturedCallback!('new-cert-value'); + + expect(mockClientInstance.updateLicenseCert).toHaveBeenCalledWith('new-cert-value'); + }); + + it('should not register for license updates when no baseUrl is configured', async () => { + mockConfig.aiAssistant.baseUrl = ''; + + const mockPayload = { + message: 'test message', + id: '12345', + workflowContext: {}, + }; + + const mockChatGenerator = (async function* () { + yield { messages: ['response'] }; + })(); + + const mockAiService = mock(); + (mockAiService.chat as jest.Mock).mockReturnValue(mockChatGenerator); + MockedAiWorkflowBuilderService.mockImplementation(() => mockAiService); + + const generator = service.chat(mockPayload, mockUser); + await generator.next(); + + expect(mockLicense.onCertRefresh).not.toHaveBeenCalled(); + }); + }); + describe('getBuilderInstanceCredits', () => { it('should return builder instance credits', async () => { const expectedCredits = { diff --git a/packages/cli/src/services/__tests__/ai.service.test.ts b/packages/cli/src/services/__tests__/ai.service.test.ts index dbdcaa3e718..45dafc8debb 100644 --- a/packages/cli/src/services/__tests__/ai.service.test.ts +++ b/packages/cli/src/services/__tests__/ai.service.test.ts @@ -109,6 +109,48 @@ describe('AiService', () => { }); }); + describe('license certificate refresh', () => { + it('should register for license certificate updates on init', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + license.loadCertStr.mockResolvedValue('mock-license-cert'); + license.getConsumerId.mockReturnValue('mock-consumer-id'); + + await aiService.init(); + + expect(license.onCertRefresh).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should update client license cert when callback is invoked', async () => { + license.isAiAssistantEnabled.mockReturnValue(true); + license.loadCertStr.mockResolvedValue('mock-license-cert'); + license.getConsumerId.mockReturnValue('mock-consumer-id'); + + // Capture the callback passed to onCertRefresh + let capturedCallback: ((cert: string) => void) | undefined; + license.onCertRefresh.mockImplementation((cb: (cert: string) => void) => { + capturedCallback = cb; + return () => {}; + }); + + await aiService.init(); + + expect(capturedCallback).toBeDefined(); + + // Invoke the callback with a new cert + capturedCallback!('new-cert-value'); + + expect(client.updateLicenseCert).toHaveBeenCalledWith('new-cert-value'); + }); + + it('should not register for license updates when AI assistant is disabled', async () => { + license.isAiAssistantEnabled.mockReturnValue(false); + + await aiService.init(); + + expect(license.onCertRefresh).not.toHaveBeenCalled(); + }); + }); + describe('askAi', () => { const payload = mock(); diff --git a/packages/cli/src/services/__tests__/banner.service.test.ts b/packages/cli/src/services/__tests__/banner.service.test.ts index f2bc5582972..14bacd5dacd 100644 --- a/packages/cli/src/services/__tests__/banner.service.test.ts +++ b/packages/cli/src/services/__tests__/banner.service.test.ts @@ -1,4 +1,3 @@ -import type { BannerName } from '@n8n/api-types'; import type { SettingsRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { ErrorReporter } from 'n8n-core'; @@ -17,15 +16,14 @@ describe('BannerService', () => { describe('dismissBanner', () => { const key = 'ui.banners.dismissed'; - const bannerName: BannerName = 'TRIAL'; it('should save the banner name to settings if no banners are dismissed yet', async () => { settingsRepo.findOneBy.mockResolvedValue(null); - await bannerService.dismissBanner(bannerName); + await bannerService.dismissBanner('TRIAL'); expect(settingsRepo.save).toHaveBeenCalledWith( - { key, value: JSON.stringify([bannerName]), loadOnStartup: true }, + { key, value: JSON.stringify(['TRIAL']), loadOnStartup: true }, { transaction: false }, ); }); @@ -38,11 +36,27 @@ describe('BannerService', () => { loadOnStartup: false, }); - await bannerService.dismissBanner(bannerName); + await bannerService.dismissBanner('TRIAL'); expect(settingsRepo.update).toHaveBeenCalledWith( { key }, - { value: JSON.stringify([bannerName, 'TRIAL_OVER']), loadOnStartup: true }, + { value: JSON.stringify(['TRIAL', 'TRIAL_OVER']), loadOnStartup: true }, + ); + }); + + it('should not create duplicate entries if already dismissed', async () => { + const dismissedBanners = ['TRIAL', 'TRIAL_OVER']; + settingsRepo.findOneBy.mockResolvedValue({ + key, + value: JSON.stringify(dismissedBanners), + loadOnStartup: false, + }); + + await bannerService.dismissBanner('TRIAL'); + + expect(settingsRepo.update).toHaveBeenCalledWith( + { key }, + { value: JSON.stringify(['TRIAL', 'TRIAL_OVER']), loadOnStartup: true }, ); }); @@ -51,7 +65,7 @@ describe('BannerService', () => { settingsRepo.findOneBy.mockResolvedValue(null); settingsRepo.save.mockRejectedValue(error); - await bannerService.dismissBanner(bannerName); + await bannerService.dismissBanner('TRIAL'); expect(errorReporter.error).toHaveBeenCalledWith(error); }); diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts index 9da6558e4e2..94c061f809f 100644 --- a/packages/cli/src/services/__tests__/frontend.service.test.ts +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -14,6 +14,7 @@ import type { PushConfig } from '@/push/push.config'; import { FrontendService, type PublicFrontendSettings } from '@/services/frontend.service'; import type { UrlService } from '@/services/url.service'; import type { UserManagementMailer } from '@/user-management/email'; +import type { OwnershipService } from '../ownership.service'; // Mock the workflow history helper functions to avoid DI container issues in tests jest.mock('@/workflows/workflow-history/workflow-history-helper', () => ({ @@ -148,6 +149,10 @@ describe('FrontendService', () => { isMFAEnforced: jest.fn().mockReturnValue(false), }); + const ownershipService = mock({ + hasInstanceOwner: jest.fn().mockReturnValue(false), + }); + const createMockService = () => { Container.set( CommunityPackagesConfig, @@ -173,6 +178,7 @@ describe('FrontendService', () => { licenseState, moduleRegistry, mfaService, + ownershipService, ), license, }; @@ -188,9 +194,9 @@ describe('FrontendService', () => { }); describe('getSettings', () => { - it('should return frontend settings', () => { + it('should return frontend settings', async () => { const { service } = createMockService(); - const settings = service.getSettings(); + const settings = await service.getSettings(); expect(settings).toEqual( expect.objectContaining({ @@ -201,7 +207,7 @@ describe('FrontendService', () => { }); describe('getPublicSettings', () => { - it('should return public settings', () => { + it('should return public settings', async () => { const expectedPublicSettings: PublicFrontendSettings = { settingsMode: 'public', userManagement: { @@ -223,7 +229,7 @@ describe('FrontendService', () => { }; const { service } = createMockService(); - const settings = service.getPublicSettings(); + const settings = await service.getPublicSettings(); expect(settings).toEqual(expectedPublicSettings); }); @@ -282,29 +288,31 @@ describe('FrontendService', () => { }); describe('settings integration', () => { - it('should include envFeatureFlags in initial settings', () => { + it('should include envFeatureFlags in initial settings', async () => { process.env = { N8N_ENV_FEAT_INIT_FLAG: 'true', N8N_ENV_FEAT_ANOTHER_FLAG: 'false', }; const { service } = createMockService(); + const settings = await service.getSettings(); - expect(service.settings.envFeatureFlags).toEqual({ + expect(settings.envFeatureFlags).toEqual({ N8N_ENV_FEAT_INIT_FLAG: 'true', N8N_ENV_FEAT_ANOTHER_FLAG: 'false', }); }); - it('should refresh envFeatureFlags when getSettings is called', () => { + it('should refresh envFeatureFlags when getSettings is called', async () => { process.env = { N8N_ENV_FEAT_INITIAL_FLAG: 'true', }; const { service } = createMockService(); + const initialSettings = await service.getSettings(); // Verify initial state - expect(service.settings.envFeatureFlags).toEqual({ + expect(initialSettings.envFeatureFlags).toEqual({ N8N_ENV_FEAT_INITIAL_FLAG: 'true', }); @@ -315,7 +323,7 @@ describe('FrontendService', () => { }; // getSettings should refresh the flags - const settings = service.getSettings(); + const settings = await service.getSettings(); expect(settings.envFeatureFlags).toEqual({ N8N_ENV_FEAT_INITIAL_FLAG: 'false', @@ -326,33 +334,33 @@ describe('FrontendService', () => { }); describe('aiBuilder setting', () => { - it('should initialize aiBuilder setting as disabled by default', () => { + it('should initialize aiBuilder setting as disabled by default', async () => { const { service } = createMockService(); - - expect(service.settings.aiBuilder).toEqual({ + const initialSettings = await service.getSettings(); + expect(initialSettings.aiBuilder).toEqual({ enabled: false, setup: false, }); }); - it('should set aiBuilder.enabled to true when license has feat:aiBuilder', () => { + it('should set aiBuilder.enabled to true when license has feat:aiBuilder', async () => { const { service, license } = createMockService(); license.isLicensed.mockImplementation((feature) => { return feature === 'feat:aiBuilder'; }); - const settings = service.getSettings(); + const settings = await service.getSettings(); expect(settings.aiBuilder.enabled).toBe(true); }); - it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', () => { + it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', async () => { const { service, license } = createMockService(); license.isLicensed.mockReturnValue(false); - const settings = service.getSettings(); + const settings = await service.getSettings(); expect(settings.aiBuilder.enabled).toBe(false); }); diff --git a/packages/cli/src/services/__tests__/ownership.service.integration.test.ts b/packages/cli/src/services/__tests__/ownership.service.integration.test.ts new file mode 100644 index 00000000000..30c0fabdfc1 --- /dev/null +++ b/packages/cli/src/services/__tests__/ownership.service.integration.test.ts @@ -0,0 +1,60 @@ +import { testDb } from '@n8n/backend-test-utils'; +import { GLOBAL_OWNER_ROLE } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { HooksService } from '@/services/hooks.service'; +import { OwnershipService } from '@/services/ownership.service'; +import { createUserShell } from '@test-integration/db/users'; + +let hookService: HooksService; +let ownershipService: OwnershipService; + +// See PAY-4247 - This test case can be deleted when the ticket is complete +describe('Ownership Service integration test', () => { + beforeEach(async () => { + await testDb.truncate(['User']); + await createUserShell(GLOBAL_OWNER_ROLE); + jest.clearAllMocks(); + }); + + beforeAll(async () => { + await testDb.init(); + hookService = Container.get(HooksService); + ownershipService = Container.get(OwnershipService); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + it('should recognise ownership creation from cloud hooks', async () => { + expect(await ownershipService.hasInstanceOwner()).toBeFalsy(); + const shellOwnerUser = await hookService.findOneUser({ + where: { + role: { + slug: GLOBAL_OWNER_ROLE.slug, + }, + }, + }); + // @ts-expect-error - this is how this function is called in the cloud hook so I match it here + await hookService.saveUser({ + firstName: 'FN', + lastName: 'LN', + email: 'fn@ln.com', + password: '', + id: shellOwnerUser!.id, + }); + expect(await ownershipService.hasInstanceOwner()).toBeTruthy(); + }); + + it('should recognise ownership creation from api', async () => { + expect(await ownershipService.hasInstanceOwner()).toBeFalsy(); + await ownershipService.setupOwner({ + firstName: 'TEST', + lastName: 'LN', + password: 'PW', + email: 'EM@em.com', + }); + expect(await ownershipService.hasInstanceOwner()).toBeTruthy(); + }); +}); diff --git a/packages/cli/src/services/__tests__/ownership.service.test.ts b/packages/cli/src/services/__tests__/ownership.service.test.ts index 824e2756d10..d9e5a278d9b 100644 --- a/packages/cli/src/services/__tests__/ownership.service.test.ts +++ b/packages/cli/src/services/__tests__/ownership.service.test.ts @@ -1,5 +1,5 @@ +import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; -import type { SharedCredentials } from '@n8n/db'; import { Project, SharedWorkflow, @@ -12,10 +12,15 @@ import { GLOBAL_OWNER_ROLE, PROJECT_OWNER_ROLE, } from '@n8n/db'; +import type { SharedCredentials, SettingsRepository } from '@n8n/db'; import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; +import { mock } from 'jest-mock-extended'; import { v4 as uuid } from 'uuid'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { EventService } from '@/events/event.service'; import { OwnershipService } from '@/services/ownership.service'; +import { PasswordUtility } from '@/services/password.utility'; import { mockCredential, mockProject } from '@test/mock-objects'; import { CacheService } from '../cache/cache.service'; @@ -25,11 +30,20 @@ describe('OwnershipService', () => { const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository); const projectRelationRepository = mockInstance(ProjectRelationRepository); const cacheService = mockInstance(CacheService); + const passwordUtility = mockInstance(PasswordUtility); + const logger = mockInstance(Logger); + const eventService = mock(); + const settingsRepository = mock(); + const ownershipService = new OwnershipService( cacheService, - userRepository, + eventService, + logger, + passwordUtility, projectRelationRepository, sharedWorkflowRepository, + userRepository, + settingsRepository, ); beforeEach(() => { @@ -67,7 +81,8 @@ describe('OwnershipService', () => { owner.role = GLOBAL_OWNER_ROLE; const projectRelation = new ProjectRelation(); projectRelation.role = PROJECT_OWNER_ROLE; - (projectRelation.project = project), (projectRelation.user = owner); + projectRelation.project = project; + projectRelation.user = owner; projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]); @@ -94,8 +109,9 @@ describe('OwnershipService', () => { owner.id = uuid(); owner.role = GLOBAL_OWNER_ROLE; const projectRelation = new ProjectRelation(); - projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any; - (projectRelation.project = project), (projectRelation.user = owner); + projectRelation.role = PROJECT_OWNER_ROLE; + projectRelation.project = project; + projectRelation.user = owner; cacheService.getHashValue.mockResolvedValueOnce(owner); userRepository.create.mockReturnValueOnce(owner); @@ -226,4 +242,50 @@ describe('OwnershipService', () => { }); }); }); + + describe('setupOwner()', () => { + it('should throw a BadRequestError if the instance owner is already setup', async () => { + jest.spyOn(userRepository, 'exists').mockResolvedValueOnce(true); + + await expect(ownershipService.setupOwner(mock())).rejects.toThrowError( + new BadRequestError('Instance owner already setup'), + ); + + expect(userRepository.save).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + 'Request to claim instance ownership failed because instance owner already exists', + ); + }); + + it('should setup the instance owner successfully', async () => { + const user = mock({ + id: 'userId', + role: GLOBAL_OWNER_ROLE, + authIdentities: [], + }); + + const payload = { + email: 'valid@email.com', + password: 'NewPassword123', + firstName: 'Jane', + lastName: 'Doe', + }; + + // not quite perfect as we hash the password. + const expected = { ...user, ...payload, id: 'newUserId' }; + + userRepository.exists.mockResolvedValueOnce(false); + userRepository.findOneOrFail.mockResolvedValueOnce(user); + userRepository.save.mockResolvedValueOnce(expected); + + const actual = await ownershipService.setupOwner(payload); + + expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false }); + expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { + userId: 'newUserId', + }); + expect(actual.id).toEqual('newUserId'); + }); + }); }); diff --git a/packages/cli/src/services/__tests__/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts index ae9c7a19b19..034f5431897 100644 --- a/packages/cli/src/services/__tests__/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -1,7 +1,16 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; -import type { Project } from '@n8n/db'; -import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, Role, User, UserRepository } from '@n8n/db'; +import { Project } from '@n8n/db'; +import { + GLOBAL_ADMIN_ROLE, + GLOBAL_MEMBER_ROLE, + ProjectRelation, + ProjectRepository, + Role, + User, + UserRepository, +} from '@n8n/db'; +import { PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG } from '@n8n/permissions'; import type { EntityManager } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; import { v4 as uuid } from 'uuid'; @@ -11,8 +20,8 @@ import { UrlService } from '@/services/url.service'; import { UserService } from '@/services/user.service'; import type { UserManagementMailer } from '@/user-management/email'; +import type { PublicApiKeyService } from '../public-api-key.service'; import type { RoleService } from '../role.service'; -import { type PublicApiKeyService } from '../public-api-key.service'; describe('UserService', () => { const globalConfig = mockInstance(GlobalConfig, { @@ -28,12 +37,16 @@ describe('UserService', () => { const userRepository = mockInstance(UserRepository, { manager, }); + const projectRepository = mockInstance(ProjectRepository, { + manager, + }); const roleService = mock(); const mailer = mock(); const publicApiKeyService = mock(); const userService = new UserService( mock(), userRepository, + projectRepository, mailer, urlService, mock(), @@ -364,6 +377,7 @@ describe('UserService', () => { { role: { slug: 'global:admin' } }, ); expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).not.toHaveBeenCalled(); }); it('removes higher privilege scopes from API tokens of user who is demoted from admin', async () => { @@ -381,6 +395,144 @@ describe('UserService', () => { { role: { slug: 'global:member' } }, ); expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).not.toHaveBeenCalled(); + }); + + it('removes project roles of user who is demoted to chat user from member', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:member'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + const projectId = uuid(); + manager.find.mockResolvedValueOnce([ + Object.assign(new ProjectRelation(), { + userId: user.id, + role: Object.assign(new Role(), { slug: PROJECT_VIEWER_ROLE_SLUG }), + projectId, + }), + ]); + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:chatUser' }); + + expect(manager.delete).toHaveBeenCalledTimes(1); + expect(manager.delete).toHaveBeenCalledWith(ProjectRelation, { + userId: user.id, + projectId, + }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Ensure all their API keys are revoked + expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).toHaveBeenCalledWith(user, manager); + }); + + it('assigns chat user project:viewer on their personal project when demoted from member', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:member'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + manager.find.mockResolvedValueOnce([]); + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:chatUser' }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Ensure all their API keys are revoked + expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).toHaveBeenCalledWith(user, manager); + }); + + it('assigns chat user project:viewer on their personal project when demoted from admin', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:admin'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + manager.find.mockResolvedValueOnce([]); + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:chatUser' }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Ensure all their API keys are revoked. + expect(publicApiKeyService.removeOwnerOnlyScopesFromApiKeys).not.toHaveBeenCalled(); + expect(publicApiKeyService.deleteAllApiKeysForUser).toHaveBeenCalledWith(user, manager); + }); + + it('assigns chat user project:personalOwner when upgraded to member', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:chatUser'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + const personalProject = new Project(); + personalProject.id = uuid(); + personalProject.type = 'personal'; + personalProject.creatorId = user.id; + + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValueOnce(personalProject); + + await userService.changeUserRole(user, { newRoleName: 'global:member' }); + + expect(manager.update).toHaveBeenCalledWith( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_VIEWER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + ); }); }); }); diff --git a/packages/cli/src/services/ai-workflow-builder.service.ts b/packages/cli/src/services/ai-workflow-builder.service.ts index 41e7e808a0f..1deff55e2e5 100644 --- a/packages/cli/src/services/ai-workflow-builder.service.ts +++ b/packages/cli/src/services/ai-workflow-builder.service.ts @@ -23,6 +23,8 @@ import { Telemetry } from '@/telemetry'; export class WorkflowBuilderService { private service: AiWorkflowBuilderService | undefined; + private client: AiAssistantClient | undefined; + constructor( private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly license: License, @@ -36,20 +38,23 @@ export class WorkflowBuilderService { private async getService(): Promise { if (!this.service) { - let client: AiAssistantClient | undefined; - // Create AiAssistantClient if baseUrl is configured const baseUrl = this.config.aiAssistant.baseUrl; if (baseUrl) { const licenseCert = await this.license.loadCertStr(); const consumerId = this.license.getConsumerId(); - client = new AiAssistantClient({ + this.client = new AiAssistantClient({ licenseCert, consumerId, baseUrl, n8nVersion: N8N_VERSION, }); + + // Register for license certificate updates + this.license.onCertRefresh((cert) => { + this.client?.updateLicenseCert(cert); + }); } // Create callback that uses the push service @@ -75,10 +80,11 @@ export class WorkflowBuilderService { this.service = new AiWorkflowBuilderService( nodeTypeDescriptions, - client, + this.client, this.logger, this.instanceSettings.instanceId, this.urlService.getInstanceBaseUrl(), + N8N_VERSION, onCreditsUpdated, onTelemetryEvent, ); diff --git a/packages/cli/src/services/ai.service.ts b/packages/cli/src/services/ai.service.ts index 32c6c8b0f9f..b3d11cffaa0 100644 --- a/packages/cli/src/services/ai.service.ts +++ b/packages/cli/src/services/ai.service.ts @@ -39,6 +39,11 @@ export class AiService { baseUrl, logLevel, }); + + // Register for license certificate updates + this.licenseService.onCertRefresh((cert) => { + this.client?.updateLicenseCert(cert); + }); } async chat(payload: AiChatRequestDto, user: IUser) { diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index 95fa1ace2b7..a61903075b9 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -87,6 +87,11 @@ export class CacheService extends TypedEmitter { return this.cache.kind === 'memory'; } + async exists(key: string) { + const ttl = await this.cache?.store.ttl(key); + return !!ttl; + } + // ---------------------------------- // storing // ---------------------------------- diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 36902840555..ff7759f6f5b 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -20,6 +20,7 @@ import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { MfaService } from '@/mfa/mfa.service'; +import { OwnershipService } from '@/services/ownership.service'; import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config'; import type { CommunityPackagesService } from '@/modules/community-packages/community-packages.service'; import { isApiEnabled } from '@/public-api'; @@ -93,7 +94,7 @@ export type PublicFrontendSettings = { @Service() export class FrontendService { - settings: FrontendSettings; + private settings: FrontendSettings; private communityPackagesService?: CommunityPackagesService; @@ -113,12 +114,10 @@ export class FrontendService { private readonly licenseState: LicenseState, private readonly moduleRegistry: ModuleRegistry, private readonly mfaService: MfaService, + private readonly ownershipService: OwnershipService, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); - - this.initSettings(); - // @TODO: Move to community-packages module if (Container.get(CommunityPackagesConfig).enabled) { void import('@/modules/community-packages/community-packages.service').then( @@ -141,7 +140,7 @@ export class FrontendService { return envFeatureFlags; } - private initSettings() { + private async initSettings() { const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); const restEndpoint = this.globalConfig.endpoints.rest; @@ -230,7 +229,7 @@ export class FrontendService { defaultLocale: this.globalConfig.defaultLocale, userManagement: { quota: this.license.getUsersLimit(), - showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'), + showSetupOnFirstLoad: !(await this.ownershipService.hasInstanceOwner()), smtpSetup: this.mailer.isEmailSetUp, authenticationMethod: getCurrentAuthenticationMethod(), }, @@ -374,7 +373,10 @@ export class FrontendService { this.writeStaticJSON('credentials', credentials); } - getSettings(): FrontendSettings { + async getSettings(): Promise { + if (!this.settings) { + await this.initSettings(); + } const restEndpoint = this.globalConfig.endpoints.rest; // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` @@ -390,7 +392,7 @@ export class FrontendService { Object.assign(this.settings.userManagement, { quota: this.license.getUsersLimit(), authenticationMethod: getCurrentAuthenticationMethod(), - showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'), + showSetupOnFirstLoad: !(await this.ownershipService.hasInstanceOwner()), }); let dismissedBanners: string[] = []; @@ -517,7 +519,7 @@ export class FrontendService { * Only add settings that are absolutely necessary for non-authenticated pages * @returns Public settings for unauthenticated users */ - getPublicSettings(): PublicFrontendSettings { + async getPublicSettings(): Promise { // Get full settings to ensure all required properties are initialized const { userManagement: { authenticationMethod, showSetupOnFirstLoad, smtpSetup }, @@ -525,7 +527,7 @@ export class FrontendService { authCookie, previewMode, enterprise: { saml, ldap, oidc }, - } = this.getSettings(); + } = await this.getSettings(); const publicSettings: PublicFrontendSettings = { settingsMode: 'public', diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index 196fc39c914..46c44d45181 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -7,19 +7,31 @@ import { SharedWorkflowRepository, UserRepository, Role, + SettingsRepository, Scope, } from '@n8n/db'; import { Service } from '@n8n/di'; - +import { Logger } from '@n8n/backend-common'; import { CacheService } from '@/services/cache/cache.service'; +import { OwnerSetupRequestDto } from '@n8n/api-types'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { EventService } from '@/events/event.service'; +import { PasswordUtility } from './password.utility'; +import { IsNull } from '@n8n/typeorm/find-options/operator/IsNull'; +import { Not } from '@n8n/typeorm/find-options/operator/Not'; +import config from '@/config'; @Service() export class OwnershipService { constructor( private cacheService: CacheService, - private userRepository: UserRepository, + private eventService: EventService, + private logger: Logger, + private passwordUtility: PasswordUtility, private projectRelationRepository: ProjectRelationRepository, private sharedWorkflowRepository: SharedWorkflowRepository, + private userRepository: UserRepository, + private settingsRepository: SettingsRepository, ) {} // To make use of the cache service we should store POJOs, these @@ -179,4 +191,64 @@ export class OwnershipService { where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, }); } + + async hasInstanceOwner() { + return await this.userRepository.exists({ + where: [ + { + role: { slug: GLOBAL_OWNER_ROLE.slug }, + // We use this to avoid selecting the "shell" user + lastActiveAt: Not(IsNull()), + }, + // OR + // This condition only exists because of PAY-4247 + { + role: { slug: GLOBAL_OWNER_ROLE.slug }, + // We use this to avoid selecting the "shell" user + password: Not(IsNull()), + }, + ], + relations: ['role'], + }); + } + + async setupOwner(payload: OwnerSetupRequestDto) { + const { email, firstName, lastName, password } = payload; + if (await this.hasInstanceOwner()) { + this.logger.debug( + 'Request to claim instance ownership failed because instance owner already exists', + ); + throw new BadRequestError('Instance owner already setup'); + } + + let shellUser = await this.userRepository.findOneOrFail({ + where: { role: { slug: GLOBAL_OWNER_ROLE.slug } }, + relations: ['role'], + }); + + shellUser.email = email; + shellUser.firstName = firstName; + shellUser.lastName = lastName; + shellUser.lastActiveAt = new Date(); + shellUser.password = await this.passwordUtility.hash(password); + + shellUser = await this.userRepository.save(shellUser, { transaction: false }); + + this.logger.info('Owner was set up successfully'); + this.eventService.emit('instance-owner-setup', { userId: shellUser.id }); + + // The next block needs to be deleted and is temporary for now + // See packages/cli/src/config/schema.ts for more info + // We update the SettingsRepository so when we "startup" next time + // the config state is restored. + // #region Delete me + await this.settingsRepository.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(true) }, + ); + config.set('userManagement.isInstanceOwnerSetUp', true); + // #endregion + + return shellUser; + } } diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index ec2f17f9bbc..d8a31f7bba9 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -237,7 +237,7 @@ export class ProjectService { const project = await trx.save( Project, - this.projectRepository.create({ ...data, type: 'team' }), + this.projectRepository.create({ ...data, type: 'team', creatorId: adminUser.id }), ); // Link admin diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index f67badd2458..0f152b7dc54 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -1,6 +1,6 @@ import type { CreateApiKeyRequestDto, UnixTimestamp, UpdateApiKeyRequestDto } from '@n8n/api-types'; import type { AuthenticatedRequest, User } from '@n8n/db'; -import { ApiKey, ApiKeyRepository, UserRepository } from '@n8n/db'; +import { ApiKey, ApiKeyRepository, UserRepository, withTransaction } from '@n8n/db'; import { Service } from '@n8n/di'; import type { ApiKeyScope, AuthPrincipal } from '@n8n/permissions'; import { getApiKeyScopesForRole, getOwnerOnlyApiKeyScopes } from '@n8n/permissions'; @@ -73,6 +73,18 @@ export class PublicApiKeyService { await this.apiKeyRepository.delete({ userId: user.id, id: apiKeyId }); } + async deleteAllApiKeysForUser(user: User, tx?: EntityManager) { + return await withTransaction(this.apiKeyRepository.manager, tx, async (em) => { + const userApiKeys = await em.find(ApiKey, { + where: { userId: user.id, audience: API_KEY_AUDIENCE }, + }); + + return await Promise.all( + userApiKeys.map(async (apiKey) => await em.delete(ApiKey, { id: apiKey.id })), + ); + }); + } + async updateApiKeyForUser( user: User, apiKeyId: string, diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 69f46fbbd5c..959923f0ad2 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,11 +1,18 @@ import type { RoleChangeRequestDto } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; import type { PublicUser } from '@n8n/db'; -import { User, UserRepository } from '@n8n/db'; +import { ProjectRelation, User, UserRepository, ProjectRepository, Not, In } from '@n8n/db'; import { Service } from '@n8n/di'; -import { getGlobalScopes, type AssignableGlobalRole } from '@n8n/permissions'; +import { + getGlobalScopes, + PROJECT_ADMIN_ROLE_SLUG, + PROJECT_OWNER_ROLE_SLUG, + PROJECT_VIEWER_ROLE_SLUG, + type AssignableGlobalRole, +} from '@n8n/permissions'; import type { IUserSettings } from 'n8n-workflow'; -import { UnexpectedError } from 'n8n-workflow'; +import { UnexpectedError, UserError } from 'n8n-workflow'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { EventService } from '@/events/event.service'; @@ -17,13 +24,13 @@ import { UserManagementMailer } from '@/user-management/email'; import { PublicApiKeyService } from './public-api-key.service'; import { RoleService } from './role.service'; -import { GlobalConfig } from '@n8n/config'; @Service() export class UserService { constructor( private readonly logger: Logger, private readonly userRepository: UserRepository, + private readonly projectRepository: ProjectRepository, private readonly mailer: UserManagementMailer, private readonly urlService: UrlService, private readonly eventService: EventService, @@ -276,11 +283,83 @@ export class UserService { return await this.userRepository.manager.transaction(async (trx) => { await trx.update(User, { id: user.id }, { role: { slug: newRole.newRoleName } }); - const adminDowngradedToMember = - user.role.slug === 'global:admin' && newRole.newRoleName === 'global:member'; + const isAdminRole = (roleName: string) => { + return roleName === 'global:admin' || roleName === 'global:owner'; + }; - if (adminDowngradedToMember) { + const isDowngradedToChatUser = + user.role.slug !== 'global:chatUser' && newRole.newRoleName === 'global:chatUser'; + const isUpgradedChatUser = + user.role.slug === 'global:chatUser' && newRole.newRoleName !== 'global:chatUser'; + const isDowngradedAdmin = isAdminRole(user.role.slug) && !isAdminRole(newRole.newRoleName); + + if (isDowngradedToChatUser) { + // Revoke user's project roles in any shared projects they have access to. + const projectRelations = await trx.find(ProjectRelation, { + where: { userId: user.id, role: { slug: Not(PROJECT_OWNER_ROLE_SLUG) } }, + relations: ['role'], + }); + for (const relation of projectRelations) { + if (relation.role.slug === PROJECT_ADMIN_ROLE_SLUG) { + // Ensure there is at least one other admin in the project + const adminCount = await trx.count(ProjectRelation, { + where: { + projectId: relation.projectId, + role: { slug: In([PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG]) }, + userId: Not(user.id), + }, + }); + if (adminCount === 0) { + throw new UserError( + `Cannot downgrade user as they are the only project admin in project "${relation.projectId}".`, + ); + } + } + + await trx.delete(ProjectRelation, { + userId: user.id, + projectId: relation.projectId, + }); + } + + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + trx, + ); + + // Revoke 'project:personalOwner' role on their personal project + // and grant 'project:viewer' role instead. + await trx.update( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_OWNER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_VIEWER_ROLE_SLUG } }, + ); + + // Revoke all API keys from chat users + await this.publicApiKeyService.deleteAllApiKeysForUser(user, trx); + } else if (isDowngradedAdmin) { await this.publicApiKeyService.removeOwnerOnlyScopesFromApiKeys(user, trx); + } else if (isUpgradedChatUser) { + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + user.id, + trx, + ); + + // Revoke previous 'project:viewer' role on their personal project + // and grant 'project:personalOwner' role instead. + await trx.update( + ProjectRelation, + { + userId: user.id, + role: { slug: PROJECT_VIEWER_ROLE_SLUG }, + projectId: personalProject.id, + }, + { role: { slug: PROJECT_OWNER_ROLE_SLUG } }, + ); } }); } diff --git a/packages/cli/src/task-runners/task-runner-process-py.ts b/packages/cli/src/task-runners/task-runner-process-py.ts index c43cb200996..f0324df42aa 100644 --- a/packages/cli/src/task-runners/task-runner-process-py.ts +++ b/packages/cli/src/task-runners/task-runner-process-py.ts @@ -49,6 +49,12 @@ export class PyTaskRunnerProcess extends TaskRunnerProcessBase { N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(), N8N_RUNNERS_TASK_TIMEOUT: this.runnerConfig.taskTimeout.toString(), N8N_RUNNERS_HEARTBEAT_INTERVAL: this.runnerConfig.heartbeatInterval.toString(), + + // n8n + N8N_RUNNERS_STDLIB_ALLOW: process.env.N8N_RUNNERS_STDLIB_ALLOW, + N8N_RUNNERS_EXTERNAL_ALLOW: process.env.N8N_RUNNERS_EXTERNAL_ALLOW, + N8N_RUNNERS_BUILTINS_DENY: process.env.N8N_RUNNERS_BUILTINS_DENY, + N8N_BLOCK_RUNNER_ENV_ACCESS: process.env.N8N_BLOCK_RUNNER_ENV_ACCESS, }, }); } diff --git a/packages/cli/src/telemetry/__tests__/telemetry.test.ts b/packages/cli/src/telemetry/__tests__/telemetry.test.ts index 6252a0c237d..1523d3a569b 100644 --- a/packages/cli/src/telemetry/__tests__/telemetry.test.ts +++ b/packages/cli/src/telemetry/__tests__/telemetry.test.ts @@ -384,6 +384,64 @@ describe('Telemetry', () => { }), ); }); + + test('should include instance_id, version_cli, and user_id in track properties', () => { + const eventName = 'Test Event'; + const properties = { user_id: '1234', custom_prop: 'value' }; + + telemetry.track(eventName, properties); + + expect(mockRudderStack.track).toHaveBeenCalledWith( + expect.objectContaining({ + event: eventName, + properties: expect.objectContaining({ + instance_id: instanceId, + user_id: '1234', + version_cli: expect.any(String), + custom_prop: 'value', + }), + }), + ); + }); + + test('should format userId with user_id when provided', () => { + const eventName = 'Test Event'; + const properties = { user_id: '5678' }; + + telemetry.track(eventName, properties); + + expect(mockRudderStack.track).toHaveBeenCalledWith( + expect.objectContaining({ + userId: `${instanceId}#5678`, + }), + ); + }); + + test('should format userId without user_id when not provided', () => { + const eventName = 'Test Event'; + + telemetry.track(eventName, {}); + + expect(mockRudderStack.track).toHaveBeenCalledWith( + expect.objectContaining({ + userId: instanceId, + }), + ); + }); + + test('should set user_id to undefined when not provided in properties', () => { + const eventName = 'Test Event'; + + telemetry.track(eventName, {}); + + expect(mockRudderStack.track).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + user_id: undefined, + }), + }), + ); + }); }); }); diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 35899bea707..f4a1f929d78 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -225,6 +225,7 @@ export class Telemetry { const updatedProperties = { ...properties, instance_id: instanceId, + user_id: user_id ?? undefined, version_cli: N8N_VERSION, }; diff --git a/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts index b3b4515d68e..75352e11fa3 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts @@ -13,12 +13,18 @@ describe('TestWebhookRegistrationsService', () => { ); const registration = mock({ + version: 1, webhook: { httpMethod: 'GET', path: 'hello', webhookId: undefined }, }); const webhookKey = 'GET|hello'; const cacheKey = 'test-webhooks'; + beforeEach(() => { + jest.resetAllMocks(); + cacheService.exists.mockResolvedValue(true); + }); + describe('register()', () => { test('should register a test webhook registration', async () => { await registrations.register(registration); @@ -31,6 +37,14 @@ describe('TestWebhookRegistrationsService', () => { expect(cacheService.expire).not.toHaveBeenCalled(); }); + + test('should throw an error if the registration fails', async () => { + cacheService.exists.mockResolvedValue(false); + + await expect(registrations.register(registration)).rejects.toThrow( + 'Test webhook registration failed: workflow is too big. Remove pinned data', + ); + }); }); describe('deregister()', () => { @@ -59,6 +73,15 @@ describe('TestWebhookRegistrationsService', () => { await expect(promise).resolves.toBeUndefined(); }); + + test('should skip registrations with outdated version', async () => { + const { version, ...outdatedRegistration } = registration; // remove the version property to simulate outdated registration + cacheService.getHashValue.mockResolvedValueOnce(outdatedRegistration); + + const promise = registrations.get(webhookKey); + + await expect(promise).resolves.toBeUndefined(); + }); }); describe('getAllKeys()', () => { @@ -73,7 +96,10 @@ describe('TestWebhookRegistrationsService', () => { describe('getAllRegistrations()', () => { test('should retrieve all test webhook registrations', async () => { - cacheService.getHash.mockResolvedValueOnce({ [webhookKey]: registration }); + cacheService.getHash.mockResolvedValueOnce({ + [webhookKey]: registration, + ANOTHER_KEY: { invalid: 'data' }, // invalid registration to test filtering + }); const result = await registrations.getAllRegistrations(); diff --git a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts index c4f4067e841..d2b00b2cb14 100644 --- a/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhooks.test.ts @@ -149,6 +149,72 @@ describe('TestWebhooks', () => { expect(webhookService.createWebhookIfNotExists.mock.calls[0][1].node).toBe(webhook2.node); expect(needsWebhook).toBe(true); }); + + test.each([ + { published: true, withSingleWebhookTrigger: true, shouldThrow: true }, + { published: true, withSingleWebhookTrigger: false, shouldThrow: false }, + { published: false, withSingleWebhookTrigger: true, shouldThrow: false }, + { published: false, withSingleWebhookTrigger: false, shouldThrow: false }, + ] satisfies Array<{ + published: boolean; + withSingleWebhookTrigger: boolean; + shouldThrow: boolean; + }>)( + 'handles single webhook trigger when workflowIsActive=%s', + async ({ published: workflowIsActive, withSingleWebhookTrigger, shouldThrow }) => { + const workflow = mock(); + const regularWebhook = mock({ + node: 'Webhook', + httpMethod, + path: 'regular-path', + workflowId: workflowEntity.id, + userId, + }); + const telegramWebhook = mock({ + node: 'Telegram Trigger', + httpMethod, + path: 'telegram-path', + workflowId: workflowEntity.id, + userId, + }); + const webhookNode = mock({ + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + }); + const telegramNode = mock({ + name: 'Telegram Trigger', + type: 'n8n-nodes-base.telegramTrigger', + }); + + jest.spyOn(testWebhooks, 'toWorkflow').mockReturnValueOnce(workflow); + jest + .spyOn(WebhookHelpers, 'getWorkflowWebhooks') + .mockReturnValue([regularWebhook, telegramWebhook]); + jest.spyOn(workflow, 'getNode').mockImplementation((name: string) => { + if (name === 'Webhook') return webhookNode; + if (name === 'Telegram Trigger' && withSingleWebhookTrigger) return telegramNode; + return null; + }); + + if (shouldThrow) { + const promise = testWebhooks.needsWebhook({ + ...args, + workflowIsActive, + }); + + await expect(promise).rejects.toThrow( + "Because of limitations in Telegram Trigger, n8n can't listen for test executions at the same time as listening for production ones. Unpublish the workflow to execute.", + ); + } else { + const needsWebhook = await testWebhooks.needsWebhook({ + ...args, + workflowIsActive, + }); + + expect(needsWebhook).toBe(true); + } + }, + ); }); describe('executeWebhook()', () => { @@ -186,7 +252,9 @@ describe('TestWebhooks', () => { describe('deactivateWebhooks()', () => { test('should add additional data to workflow', async () => { - registrations.getAllRegistrations.mockResolvedValue([{ workflowEntity, webhook }]); + registrations.getAllRegistrations.mockResolvedValue([ + { version: 1, workflowEntity, webhook }, + ]); const workflow = testWebhooks.toWorkflow(workflowEntity); @@ -229,6 +297,7 @@ describe('TestWebhooks', () => { registrations.getRegistrationsHash.mockImplementation(async () => { return { [registrations.toKey(webhookData)]: { + version: 1, workflowEntity: mock(), webhook: webhookData, }, @@ -256,6 +325,7 @@ describe('TestWebhooks', () => { registrations.getRegistrationsHash.mockImplementation(async () => { return { [registrations.toKey(webhookData)]: { + version: 1, workflowEntity: mock(), webhook: webhookData as IWebhookData, }, diff --git a/packages/cli/src/webhooks/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts index eeaa137b467..13815f07dd2 100644 --- a/packages/cli/src/webhooks/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -1,18 +1,40 @@ import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import type { IWebhookData, IWorkflowBase } from 'n8n-workflow'; +import { + type IWebhookData, + type IWorkflowBase, + type IDestinationNode, + UserError, +} from 'n8n-workflow'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; import { CacheService } from '@/services/cache/cache.service'; +import { isObjectLiteral } from '@n8n/backend-common'; + +const TEST_WEBHOOK_REGISTRATION_VERSION = 1; export type TestWebhookRegistration = { + // A simple versioning to be safe. If you make a breaking change in the type, bump the version. + // Any old records in the cache will just be ignored. + version: typeof TEST_WEBHOOK_REGISTRATION_VERSION; pushRef?: string; workflowEntity: IWorkflowBase; - // TODO: update this type to close CAT-1265 properly - destinationNode?: string; + destinationNode?: IDestinationNode; webhook: IWebhookData; }; +// Type guard for TestWebhookRegistration. +// NOTE: we could have a more robust validation, but this is probably good enough for now. +function isTestWebhookRegistration(obj: unknown): obj is TestWebhookRegistration { + if (!isObjectLiteral(obj)) { + return false; + } + + if (!('version' in obj)) return false; + + return obj.version === TEST_WEBHOOK_REGISTRATION_VERSION; +} + @Service() export class TestWebhookRegistrationsService { constructor( @@ -27,6 +49,14 @@ export class TestWebhookRegistrationsService { await this.cacheService.setHash(this.cacheKey, { [hashKey]: registration }); + const isCached = await this.cacheService.exists(this.cacheKey); + + if (!isCached) { + throw new UserError( + 'Test webhook registration failed: workflow is too big. Remove pinned data', + ); + } + if (this.instanceSettings.isSingleMain) return; /** @@ -52,8 +82,9 @@ export class TestWebhookRegistrationsService { } } - async get(key: string) { - return await this.cacheService.getHashValue(this.cacheKey, key); + async get(key: string): Promise { + const val = await this.cacheService.getHashValue(this.cacheKey, key); + return isTestWebhookRegistration(val) ? val : undefined; } async getAllKeys() { @@ -69,11 +100,17 @@ export class TestWebhookRegistrationsService { if (!hash) return []; - return Object.values(hash); + return Object.values(hash).filter(isTestWebhookRegistration); } async getRegistrationsHash() { - return await this.cacheService.getHash(this.cacheKey); + const val = await this.cacheService.getHash(this.cacheKey); + for (const key in val) { + if (!isTestWebhookRegistration(val[key])) { + delete val[key]; + } + } + return val; } async deregisterAll() { diff --git a/packages/cli/src/webhooks/test-webhooks.ts b/packages/cli/src/webhooks/test-webhooks.ts index 4f340a88915..d58808dfe1d 100644 --- a/packages/cli/src/webhooks/test-webhooks.ts +++ b/packages/cli/src/webhooks/test-webhooks.ts @@ -25,6 +25,7 @@ import type { import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; +import { SingleWebhookTriggerError } from '@/errors/single-webhook-trigger.error'; import { WorkflowMissingIdError } from '@/errors/workflow-missing-id.error'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; @@ -36,6 +37,12 @@ import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import type { WorkflowRequest } from '@/workflows/workflow.request'; +const SINGLE_WEBHOOK_TRIGGERS = [ + 'n8n-nodes-base.telegramTrigger', + 'n8n-nodes-base.slackTrigger', + 'n8n-nodes-base.facebookLeadAdsTrigger', +]; + /** * Service for handling the execution of webhooks of manual executions * that use the [Test URL](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/#webhook-urls). @@ -105,14 +112,7 @@ export class TestWebhooks implements IWebhookManager { }); } - const { pushRef, workflowEntity, webhook: testWebhook } = registration; - // TODO(CAT-1265): support destination node mode in test webhook registration. - const destinationNode: IDestinationNode | undefined = registration.destinationNode - ? { - nodeName: registration.destinationNode, - mode: 'inclusive', - } - : undefined; + const { pushRef, workflowEntity, webhook: testWebhook, destinationNode } = registration; const workflow = this.toWorkflow(workflowEntity); @@ -279,6 +279,7 @@ export class TestWebhooks implements IWebhookManager { pushRef?: string; destinationNode?: IDestinationNode; triggerToStartFrom?: WorkflowRequest.FullManualExecutionFromKnownTriggerPayload['triggerToStartFrom']; + workflowIsActive?: boolean; }) { const { userId, @@ -288,6 +289,7 @@ export class TestWebhooks implements IWebhookManager { pushRef, destinationNode, triggerToStartFrom, + workflowIsActive, } = options; if (!workflowEntity.id) throw new WorkflowMissingIdError(workflowEntity); @@ -317,6 +319,18 @@ export class TestWebhooks implements IWebhookManager { return false; // no webhooks found to start a workflow } + // Check if any webhook is a single webhook trigger and workflow is active + if (workflowIsActive) { + const singleWebhookTrigger = webhooks.find((w) => + SINGLE_WEBHOOK_TRIGGERS.includes(workflow.getNode(w.node)?.type ?? ''), + ); + if (singleWebhookTrigger) { + throw new SingleWebhookTriggerError( + workflow.getNode(singleWebhookTrigger.node)?.name ?? '', + ); + } + } + const timeout = setTimeout( async () => await this.cancelWebhook(workflow.id), TEST_WEBHOOK_TIMEOUT, @@ -352,11 +366,11 @@ export class TestWebhooks implements IWebhookManager { cacheableWebhook.userId = userId; - // TODO(CAT-1265): support destination node mode in test webhook registration. const registration: TestWebhookRegistration = { + version: 1, pushRef, workflowEntity, - destinationNode: destinationNode?.nodeName, + destinationNode, webhook: cacheableWebhook as IWebhookData, }; diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 1ebefd0ffc4..92ca97a4f81 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -234,6 +234,7 @@ async function startExecution( executionId, workflowData, additionalData.userId, + options.parentExecution, ); additionalDataIntegrated.executionId = executionId; additionalDataIntegrated.parentCallbackManager = options.parentCallbackManager; diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index 7d0d9b51f07..29b5cec72f3 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -15,6 +15,7 @@ import { import type { IWorkflowErrorData } from '@/interfaces'; import type { NodeTypes } from '@/node-types'; +import type { TestWebhooks } from '@/webhooks/test-webhooks'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import type { WorkflowRunner } from '@/workflow-runner'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; @@ -336,6 +337,66 @@ describe('WorkflowExecutionService', () => { expect(callArgs.executionMode).toBe('manual'); expect(result).toEqual({ executionId }); }); + + test('should pass workflowIsActive to testWebhooks.needsWebhook', async () => { + const userId = 'user-id'; + const user = mock({ id: userId }); + const testWebhooks = mock(); + const workflowRepositoryMock = mock(); + const telegramTrigger: INode = { + id: '1', + typeVersion: 1, + position: [1, 2], + parameters: {}, + name: 'Telegram Trigger', + type: 'n8n-nodes-base.telegramTrigger', + }; + const activeWorkflowData = { + id: 'workflow-id', + name: 'Test Workflow', + active: true, + activeVersionId: 'version-123', + isArchived: false, + nodes: [telegramTrigger], + connections: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + workflowRepositoryMock.isActive.mockResolvedValue(true); + const service = new WorkflowExecutionService( + mock(), + mock(), + mock(), + workflowRepositoryMock, + nodeTypes, + testWebhooks, + workflowRunner, + mock(), + mock(), + mock(), + ); + + const runPayload: WorkflowRequest.FullManualExecutionFromKnownTriggerPayload = { + workflowData: activeWorkflowData, + triggerToStartFrom: { name: telegramTrigger.name }, + }; + + testWebhooks.needsWebhook.mockRejectedValue( + new Error( + 'Cannot test webhook for node "Telegram Trigger" while workflow is active. Please deactivate the workflow first.', + ), + ); + + await expect(service.executeManually(runPayload, user)).rejects.toThrow( + 'Cannot test webhook for node "Telegram Trigger" while workflow is active. Please deactivate the workflow first.', + ); + + expect(testWebhooks.needsWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + workflowIsActive: true, + }), + ); + }); }); describe('selectPinnedTrigger()', () => { diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index eb1b1fa5070..aaaf6b9c3cc 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -101,6 +101,9 @@ export class WorkflowExecutionService { user: User, pushRef?: string, ): Promise<{ executionId: string } | { waitingForWebhook: boolean }> { + // Check whether this workflow is active. + const workflowIsActive = await this.workflowRepository.isActive(payload.workflowData.id); + // For manual testing always set to not active payload.workflowData.active = false; payload.workflowData.activeVersionId = null; @@ -146,6 +149,7 @@ export class WorkflowExecutionService { pushRef, triggerToStartFrom: payload.triggerToStartFrom, destinationNode: payload.destinationNode, + workflowIsActive, })) ) { return { waitingForWebhook: true }; @@ -182,6 +186,7 @@ export class WorkflowExecutionService { }), pushRef, destinationNode: payload.destinationNode, + workflowIsActive, })) ) { return { waitingForWebhook: true }; diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts index 6543633576b..0a957c5d801 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts @@ -84,8 +84,20 @@ export class WorkflowHistoryService { return hist; } + /** + * Find a workflow history version without permission checks. + */ + async findVersion(workflowId: string, versionId: string): Promise { + return await this.workflowHistoryRepository.findOne({ + where: { + workflowId, + versionId, + }, + }); + } + async saveVersion( - user: User, + user: User | string, workflow: IWorkflowBase, workflowId: string, transactionManager?: EntityManager, @@ -96,13 +108,15 @@ export class WorkflowHistoryService { ); } + const authors = typeof user === 'string' ? user : `${user.firstName} ${user.lastName}`; + const repository = transactionManager ? transactionManager.getRepository(WorkflowHistory) : this.workflowHistoryRepository; try { await repository.insert({ - authors: user.firstName + ' ' + user.lastName, + authors, connections: workflow.connections, nodes: workflow.nodes, versionId: workflow.versionId, diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index efca8a253f3..2a39d47d3ab 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -28,6 +28,7 @@ export declare namespace WorkflowRequest { parentFolderId?: string; uiContext?: string; expectedChecksum?: string; + aiBuilderAssisted?: boolean; }>; // TODO: Use a discriminator when CAT-1809 lands diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 5c9067f502e..6419d2e40f2 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -232,6 +232,7 @@ export class WorkflowService { forceSave?: boolean; publicApi?: boolean; publishIfActive?: boolean; + aiBuilderAssisted?: boolean; expectedChecksum?: string; } = {}, ): Promise { @@ -242,6 +243,7 @@ export class WorkflowService { forceSave = false, publicApi = false, publishIfActive = false, + aiBuilderAssisted = false, } = options; const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, @@ -349,10 +351,10 @@ export class WorkflowService { // do not update active fields ]; - const updatePayload: QueryDeepPartialEntity = pick( + const updatePayload = pick( workflowUpdateData, fieldsToUpdate, - ); + ) as QueryDeepPartialEntity; // Save the workflow to history first, so we can retrieve the complete version object for the update if (saveNewVersion) { @@ -411,6 +413,8 @@ export class WorkflowService { user, workflow: updatedWorkflow, publicApi, + previousWorkflow: workflow, + aiBuilderAssisted, }); // Activate workflow if requested, or diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index ecbc6355462..027715ca254 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -155,30 +155,34 @@ export class WorkflowsController { const { manager: dbManager } = this.projectRepository; - let project: Project | null; + let project: Project | null = null; const savedWorkflow = await dbManager.transaction(async (transactionManager) => { - const { projectId, parentFolderId } = req.body; - project = - projectId === undefined - ? await this.projectRepository.getPersonalProjectForUser(req.user.id, transactionManager) - : await this.projectService.getProjectWithScope( - req.user, - projectId, - ['workflow:create'], - transactionManager, - ); + const { parentFolderId } = req.body; + let { projectId } = req.body; - if (typeof projectId === 'string' && project === null) { + if (projectId === undefined) { + const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail( + req.user.id, + transactionManager, + ); + // Chat users are not allowed to create workflows even within their personal project, + // so even though we found the project ensure it gets found via expected scope too. + projectId = personalProject.id; + } + + project = await this.projectService.getProjectWithScope( + req.user, + projectId, + ['workflow:create'], + transactionManager, + ); + + if (project === null) { throw new BadRequestError( "You don't have the permissions to save the workflow in this project.", ); } - // Safe guard in case the personal project does not exist for whatever reason. - if (project === null) { - throw new UnexpectedError('No personal project found'); - } - const workflow = await transactionManager.save(newWorkflow); if (parentFolderId) { @@ -421,7 +425,7 @@ export class WorkflowsController { const forceSave = req.query.forceSave === 'true'; let updateData = new WorkflowEntity(); - const { tags, parentFolderId, expectedChecksum, ...rest } = req.body; + const { tags, parentFolderId, aiBuilderAssisted, expectedChecksum, ...rest } = req.body; // TODO: Add zod validation for entire `rest` object before assigning to `updateData` if ( @@ -447,6 +451,7 @@ export class WorkflowsController { parentFolderId, forceSave: isSharingEnabled ? forceSave : true, expectedChecksum, + aiBuilderAssisted, }); const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId); diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 1b00906357e..83600874b83 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -102,7 +102,12 @@ beforeAll(async () => { afterEach(async () => { await activeWorkflowManager.removeAll(); - await testDb.truncate(['WorkflowEntity', 'WebhookEntity', 'WorkflowHistory']); + await testDb.truncate([ + 'WorkflowEntity', + 'WebhookEntity', + 'WorkflowHistory', + 'WorkflowPublishHistory', + ]); jest.clearAllMocks(); }); @@ -137,24 +142,6 @@ describe('init()', () => { }); }); -describe('isActive()', () => { - it('should return `true` for active workflow in storage', async () => { - const dbWorkflow = await createActiveWorkflow(); - - await activeWorkflowManager.init(); - - await expect(activeWorkflowManager.isActive(dbWorkflow.id)).resolves.toBe(true); - }); - - it('should return `false` for inactive workflow in storage', async () => { - const dbWorkflow = await createInactiveWorkflow(); - - await activeWorkflowManager.init(); - - await expect(activeWorkflowManager.isActive(dbWorkflow.id)).resolves.toBe(false); - }); -}); - describe('add()', () => { describe('in single-main mode', () => { test.each(['activate', 'update'])( diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index 5187c60bedc..1bd4f4d5794 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -326,7 +326,6 @@ describe('Member', () => { password: memberPassword, role: GLOBAL_MEMBER_ROLE, }); - await utils.setInstanceOwnerSetUp(true); }); test('POST /api-keys should create an api key with no expiration', async () => { diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index b4f0aa87e50..bc7ae310552 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -31,7 +31,6 @@ beforeAll(async () => { beforeEach(async () => { await testDb.truncate(['User']); config.set('ldap.disabled', true); - await utils.setInstanceOwnerSetUp(true); }); describe('POST /login', () => { diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index e1262d5fe3e..728bf72dbbc 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -7,7 +7,6 @@ import { } from '@n8n/backend-test-utils'; import { CredentialsEntity, - SettingsRepository, CredentialsRepository, SharedCredentialsRepository, SharedWorkflowRepository, @@ -54,12 +53,6 @@ test('user-management:reset should reset DB to default user state', async () => await encryptCredentialData(Object.assign(new CredentialsEntity(), randomCredentialPayload())), ); - // mark instance as set up - await Container.get(SettingsRepository).update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: 'true' }, - ); - // // ACT // @@ -100,9 +93,4 @@ test('user-management:reset should reset DB to default user state', async () => await expect( Container.get(SharedCredentialsRepository).findBy({ credentialsId: danglingCredential.id }), ).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]); - - // the instance is marked as not set up: - await expect( - Container.get(SettingsRepository).findBy({ key: 'userManagement.isInstanceOwnerSetUp' }), - ).resolves.toMatchObject([{ value: 'false' }]); }); diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts index e098d7c95b9..18bbfc373ef 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -5,8 +5,8 @@ import { response as Response } from 'express'; import nock from 'nock'; import { parse as parseQs } from 'querystring'; -import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; import { CredentialsHelper } from '@/credentials-helper'; +import { OauthService } from '@/oauth/oauth.service'; import { saveCredential } from '@test-integration/db/credentials'; import { createMember, createOwner } from '@test-integration/db/users'; import type { SuperAgentTest } from '@test-integration/types'; @@ -52,8 +52,8 @@ describe('OAuth2 API', () => { }); it('should return a valid auth URL when the auth flow is initiated', async () => { - const controller = Container.get(OAuth2CredentialController); - const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); + const oauthService = Container.get(OauthService); + const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear(); const response = await ownerAgent .get('/oauth2-credential/auth') @@ -76,8 +76,8 @@ describe('OAuth2 API', () => { }); it('should fail on auth when callback is called as another user', async () => { - const controller = Container.get(OAuth2CredentialController); - const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); + const oauthService = Container.get(OauthService); + const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear(); const renderSpy = (Response.render = jest.fn(function () { this.end(); })); @@ -98,8 +98,8 @@ describe('OAuth2 API', () => { }); it('should handle a valid callback without auth', async () => { - const controller = Container.get(OAuth2CredentialController); - const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); + const oauthService = Container.get(OauthService); + const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear(); const renderSpy = (Response.render = jest.fn(function () { this.end(); })); diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 726e13e15f4..7647c663618 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -109,6 +109,23 @@ describe('POST /credentials', () => { "You don't have the permissions to save the credential in this project.", ); }); + + test('chat users cannot create credentials', async () => { + const chatUser = await createUser({ role: { slug: 'global:chatUser' } }); + const chatUserPersonalProject = await projectRepository.getPersonalProjectForUserOrFail( + chatUser.id, + ); + + const response = await testServer + .authAgentFor(chatUser) + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: chatUserPersonalProject.id }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); }); // ---------------------------------------- diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index d413d3d72ab..5f62bcd0e7f 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -30,7 +30,13 @@ import { shareCredentialWithProjects, shareCredentialWithUsers, } from '../shared/db/credentials'; -import { createAdmin, createManyUsers, createMember, createOwner } from '../shared/db/users'; +import { + createAdmin, + createChatUser, + createManyUsers, + createMember, + createOwner, +} from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import { initCredentialsTypes, setupTestServer } from '../shared/utils'; @@ -38,6 +44,7 @@ const { any } = expect; const testServer = setupTestServer({ endpointGroups: ['credentials'], + enabledFeatures: ['feat:sharing'], }); let owner: User; @@ -47,6 +54,7 @@ let secondMember: User; let ownerPersonalProject: Project; let memberPersonalProject: Project; +let teamProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; @@ -130,6 +138,28 @@ describe('GET /credentials', () => { expect(member1Credential.id).toBe(savedCredential1.id); }); + test('should return only own creds for chat user', async () => { + const [chatUser1, chatUser2] = await createManyUsers(2, { + role: { slug: 'global:chatUser' }, + }); + + const [savedCredential1] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: chatUser1, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: chatUser2, role: 'credential:owner' }), + ]); + + const response = await testServer.authAgentFor(chatUser1).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); // member retrieved only own cred + + const [chatUser1Credential] = response.body.data; + + validateMainCredentialData(chatUser1Credential); + expect(chatUser1Credential.data).toBeUndefined(); + expect(chatUser1Credential.id).toBe(savedCredential1.id); + }); + test('should return scopes when ?includeScopes=true', async () => { const [member1, member2] = await createManyUsers(2, { role: { slug: 'global:member' }, @@ -936,6 +966,49 @@ describe('POST /credentials', () => { }); }); + test('should fail when viewer user tries to create credential in team project', async () => { + const viewer = await createMember(); + teamProject = await createTeamProject(undefined, admin); + await linkUserToProject(viewer, teamProject, 'project:viewer'); + + const response = await testServer + .authAgentFor(viewer) + .post('/credentials') + .send({ ...randomCredentialPayload(), projectId: teamProject.id }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); + + test('should allow viewer user to create credential in their personal project', async () => { + const viewer = await createMember(); + teamProject = await createTeamProject(undefined, admin); + await linkUserToProject(viewer, teamProject, 'project:viewer'); + + const response = await testServer + .authAgentFor(viewer) + .post('/credentials') + .send({ ...randomCredentialPayload() }); + + expect(response.statusCode).toBe(200); + }); + + test('should fail when chat user tries to create credential in their personal project', async () => { + const chatUser = await createChatUser(); + + const response = await testServer + .authAgentFor(chatUser) + .post('/credentials') + .send({ ...randomCredentialPayload() }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); + test('should fail when member tries to create credential with isGlobal=true', async () => { const response = await authMemberAgent .post('/credentials') @@ -973,6 +1046,19 @@ describe('POST /credentials', () => { expect(credential.isGlobal).toBe(false); }); + test('should not allow chat user to create credential with isGlobal=false', async () => { + const chatUser = await createChatUser(); + const response = await testServer + .authAgentFor(chatUser) + .post('/credentials') + .send({ ...randomCredentialPayload(), isGlobal: false }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); + test('should allow member to create credential without passing isGlobal', async () => { const payload = randomCredentialPayload(); delete payload.isGlobal; @@ -986,6 +1072,18 @@ describe('POST /credentials', () => { }); expect(credential.isGlobal).toBe(false); }); + + test('should not allow chat user to create credential without passing isGlobal', async () => { + const chatUser = await createChatUser(); + const payload = randomCredentialPayload(); + delete payload.isGlobal; + + const response = await testServer.authAgentFor(chatUser).post('/credentials').send(payload); + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe( + "You don't have the permissions to save the credential in this project.", + ); + }); }); describe('DELETE /credentials/:id', () => { diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index 9f6f4594afd..3274f5a6ef6 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -6,6 +6,7 @@ import { createWorkflow, testDb, getWorkflowById, + setActiveVersion, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db'; @@ -21,7 +22,12 @@ describe('WorkflowRepository', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowDependency', 'WorkflowEntity', 'WorkflowHistory']); + await testDb.truncate([ + 'WorkflowDependency', + 'WorkflowEntity', + 'WorkflowHistory', + 'WorkflowPublishHistory', + ]); }); afterAll(async () => { @@ -262,6 +268,25 @@ describe('WorkflowRepository', () => { }); }); + describe('isActive()', () => { + it('should return `true` for active workflow in storage', async () => { + const workflowRepository = Container.get(WorkflowRepository); + + const workflow = await createWorkflowWithHistory(); + await setActiveVersion(workflow.id, workflow.versionId); + + await expect(workflowRepository.isActive(workflow.id)).resolves.toBe(true); + }); + + it('should return `false` for inactive workflow in storage', async () => { + const workflowRepository = Container.get(WorkflowRepository); + + const workflow = await createWorkflowWithHistory(); + + await expect(workflowRepository.isActive(workflow.id)).resolves.toBe(false); + }); + }); + // NOTE: these tests use the workflow dependency repository, which is not enabled // on legacy Sqlite. const globalConfig = Container.get(GlobalConfig); diff --git a/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts b/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts new file mode 100644 index 00000000000..dbc47214cd5 --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials.ee/credential-resolvers.api.test.ts @@ -0,0 +1,222 @@ +import { LicenseState } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; + +import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository'; +import { DynamicCredentialResolverService } from '@/modules/dynamic-credentials.ee/services/credential-resolver.service'; +import { Telemetry } from '@/telemetry'; + +import { createUser } from '../shared/db/users'; +import type { SuperAgentTest } from '../shared/types'; +import * as utils from '../shared/utils/'; + +mockInstance(Telemetry); + +const licenseMock = mock(); +licenseMock.isLicensed.mockReturnValue(true); +Container.set(LicenseState, licenseMock); + +process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true'; + +const testServer = utils.setupTestServer({ + endpointGroups: ['credentials'], + enabledFeatures: ['feat:externalSecrets'], + modules: ['dynamic-credentials'], +}); + +describe('Credential Resolvers API', () => { + let owner: User; + let member: User; + let ownerAgent: SuperAgentTest; + let memberAgent: SuperAgentTest; + let service: DynamicCredentialResolverService; + let repository: DynamicCredentialResolverRepository; + + beforeAll(async () => { + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + member = await createUser({ role: GLOBAL_MEMBER_ROLE }); + + ownerAgent = testServer.authAgentFor(owner); + memberAgent = testServer.authAgentFor(member); + + service = Container.get(DynamicCredentialResolverService); + repository = Container.get(DynamicCredentialResolverRepository); + }); + + beforeEach(async () => { + await repository.delete({}); + }); + + describe('GET /credential-resolvers', () => { + it('should return empty list when no resolvers exist', async () => { + const response = await ownerAgent.get('/credential-resolvers').expect(200); + expect(response.body.data).toEqual([]); + }); + + it('should list all resolvers', async () => { + // Create resolvers using service + await service.create({ + name: 'Resolver 1', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test1-' }, + }); + await service.create({ + name: 'Resolver 2', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test2-' }, + }); + + const response = await ownerAgent.get('/credential-resolvers').expect(200); + + expect(response.body.data).toHaveLength(2); + expect(response.body.data[0]).toMatchObject({ + id: expect.any(String), + name: 'Resolver 1', + type: 'credential-resolver.stub-1.0', + }); + }); + + it('should reject access for members', async () => { + await memberAgent.get('/credential-resolvers').expect(403); + }); + }); + + describe('GET /credential-resolvers/types', () => { + it('should return available resolver types', async () => { + const response = await ownerAgent.get('/credential-resolvers/types').expect(200); + + expect(response.body.data).toBeInstanceOf(Array); + expect(response.body.data.length).toBeGreaterThan(0); + + // Verify the stub resolver is present + const stubResolver = response.body.data.find( + (type: { name: string }) => type.name === 'credential-resolver.stub-1.0', + ); + expect(stubResolver).toBeDefined(); + expect(stubResolver).toMatchObject({ + name: 'credential-resolver.stub-1.0', + displayName: 'Stub Resolver', + description: 'A stub credential resolver for testing purposes', + options: expect.any(Array), + }); + }); + + it('should reject access for members', async () => { + await memberAgent.get('/credential-resolvers/types').expect(403); + }); + }); + + describe('POST /credential-resolvers', () => { + it('should create a resolver', async () => { + const payload = { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }; + + const response = await ownerAgent.post('/credential-resolvers').send(payload).expect(200); + + expect(response.body.data).toMatchObject({ + id: expect.any(String), + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + }); + expect(response.body.data.decryptedConfig).toEqual({ prefix: 'test-' }); + + // Verify it was actually created + const resolvers = await repository.find(); + expect(resolvers).toHaveLength(1); + }); + + it('should reject unknown resolver type', async () => { + const payload = { + name: 'Test Resolver', + type: 'unknown-type', + config: {}, + }; + + const response = await ownerAgent.post('/credential-resolvers').send(payload).expect(400); + expect(response.body.message).toContain('Unknown resolver type'); + }); + + it('should reject access for members', async () => { + const payload = { + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }; + + await memberAgent.post('/credential-resolvers').send(payload).expect(403); + }); + }); + + describe('GET /resolvers/:id', () => { + it('should return a specific resolver', async () => { + const resolver = await service.create({ + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }); + + const response = await ownerAgent.get(`/credential-resolvers/${resolver.id}`).expect(200); + + expect(response.body.data).toMatchObject({ + id: resolver.id, + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + }); + }); + + it('should return 404 for non-existent resolver', async () => { + await ownerAgent.get('/credential-resolvers/non-existent-id').expect(404); + }); + }); + + describe('PATCH /resolvers/:id', () => { + it('should update resolver name', async () => { + const resolver = await service.create({ + name: 'Original Name', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }); + + const response = await ownerAgent + .patch(`/credential-resolvers/${resolver.id}`) + .send({ name: 'Updated Name' }) + .expect(200); + + expect(response.body.data.name).toBe('Updated Name'); + }); + + it('should return 404 for non-existent resolver', async () => { + await ownerAgent + .patch('/credential-resolvers/non-existent-id') + .send({ name: 'New Name' }) + .expect(404); + }); + }); + + describe('DELETE /resolvers/:id', () => { + it('should delete a resolver', async () => { + const resolver = await service.create({ + name: 'Test Resolver', + type: 'credential-resolver.stub-1.0', + config: { prefix: 'test-' }, + }); + + const response = await ownerAgent.delete(`/credential-resolvers/${resolver.id}`).expect(200); + expect(response.body.data).toEqual({ success: true }); + + // Verify it was actually deleted + const remaining = await repository.find(); + expect(remaining).toHaveLength(0); + }); + + it('should return 404 for non-existent resolver', async () => { + await ownerAgent.delete('/credential-resolvers/non-existent-id').expect(404); + }); + }); +}); diff --git a/packages/cli/test/integration/dynamic-credentials/dynamic-credential-entry-storage.test.ts b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-entry-storage.test.ts new file mode 100644 index 00000000000..0f888d53d3e --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-entry-storage.test.ts @@ -0,0 +1,267 @@ +import { testDb, testModules } from '@n8n/backend-test-utils'; +import { Container } from '@n8n/di'; + +import { DynamicCredentialEntryStorage } from '@/modules/dynamic-credentials.ee/credential-resolvers/storage/dynamic-credential-entry-storage'; + +import { createDynamicCredentialResolver } from './shared/db-helpers'; +import { createCredentials } from '../shared/db/credentials'; + +describe('DynamicCredentialEntryStorage', () => { + let storage: DynamicCredentialEntryStorage; + let previousEnvVar: string | undefined; + + beforeAll(async () => { + previousEnvVar = process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS; + process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true'; + await testModules.loadModules(['dynamic-credentials']); + await testDb.init(); + storage = Container.get(DynamicCredentialEntryStorage); + }); + + afterAll(async () => { + process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = previousEnvVar; + await testDb.terminate(); + }); + + beforeEach(async () => { + await testDb.truncate([ + 'DynamicCredentialEntry', + 'DynamicCredentialResolver', + 'CredentialsEntity', + ]); + }); + + it('should store and retrieve credential data', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + const testData = 'encrypted-credential-data'; + + // ACT - Store + await storage.setCredentialData(credential.id, 'test-subject', resolver.id, testData, {}); + + // ACT - Retrieve + const retrievedData = await storage.getCredentialData( + credential.id, + 'test-subject', + resolver.id, + {}, + ); + + // ASSERT + expect(retrievedData).toBe(testData); + }); + + it('should update existing credential data (upsert)', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + // ACT - Insert + await storage.setCredentialData( + credential.id, + 'upsert-subject', + resolver.id, + 'original-data', + {}, + ); + + // ACT - Update + await storage.setCredentialData( + credential.id, + 'upsert-subject', + resolver.id, + 'updated-data', + {}, + ); + + // ACT - Retrieve + const data = await storage.getCredentialData(credential.id, 'upsert-subject', resolver.id, {}); + + // ASSERT + expect(data).toBe('updated-data'); + }); + + it('should delete credential data', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + const testData = 'data-to-delete'; + + // Store data first + await storage.setCredentialData(credential.id, 'delete-subject', resolver.id, testData, {}); + + // Verify it exists + const beforeDelete = await storage.getCredentialData( + credential.id, + 'delete-subject', + resolver.id, + {}, + ); + expect(beforeDelete).toBe(testData); + + // ACT - Delete + await storage.deleteCredentialData(credential.id, 'delete-subject', resolver.id, {}); + + // ASSERT - Verify it's gone + const afterDelete = await storage.getCredentialData( + credential.id, + 'delete-subject', + resolver.id, + {}, + ); + expect(afterDelete).toBeNull(); + }); + + it('should isolate entries by composite key (multiple entries do not affect each other)', async () => { + // ARRANGE + const credential1 = await createCredentials({ + name: 'Credential 1', + type: 'testType', + data: 'test-data-1', + }); + const credential2 = await createCredentials({ + name: 'Credential 2', + type: 'testType', + data: 'test-data-2', + }); + const resolver1 = await createDynamicCredentialResolver({ + name: 'resolver-1', + type: 'test', + config: 'test-config-1', + }); + const resolver2 = await createDynamicCredentialResolver({ + name: 'resolver-2', + type: 'test', + config: 'test-config-2', + }); + + // ACT - Create multiple entries with different combinations + // Same credential, different subjects + await storage.setCredentialData( + credential1.id, + 'subject-A', + resolver1.id, + 'data-cred1-subjA-res1', + {}, + ); + await storage.setCredentialData( + credential1.id, + 'subject-B', + resolver1.id, + 'data-cred1-subjB-res1', + {}, + ); + + // Same credential and subject, different resolver + await storage.setCredentialData( + credential1.id, + 'subject-A', + resolver2.id, + 'data-cred1-subjA-res2', + {}, + ); + + // Different credential, same subject and resolver + await storage.setCredentialData( + credential2.id, + 'subject-A', + resolver1.id, + 'data-cred2-subjA-res1', + {}, + ); + + // ASSERT - Each entry should be isolated and return correct data + const data1 = await storage.getCredentialData(credential1.id, 'subject-A', resolver1.id, {}); + expect(data1).toBe('data-cred1-subjA-res1'); + + const data2 = await storage.getCredentialData(credential1.id, 'subject-B', resolver1.id, {}); + expect(data2).toBe('data-cred1-subjB-res1'); + + const data3 = await storage.getCredentialData(credential1.id, 'subject-A', resolver2.id, {}); + expect(data3).toBe('data-cred1-subjA-res2'); + + const data4 = await storage.getCredentialData(credential2.id, 'subject-A', resolver1.id, {}); + expect(data4).toBe('data-cred2-subjA-res1'); + + // ACT - Update one entry + await storage.setCredentialData( + credential1.id, + 'subject-A', + resolver1.id, + 'updated-data-cred1-subjA-res1', + {}, + ); + + // ASSERT - Only the updated entry should change, others remain unchanged + const updatedData1 = await storage.getCredentialData( + credential1.id, + 'subject-A', + resolver1.id, + {}, + ); + expect(updatedData1).toBe('updated-data-cred1-subjA-res1'); + + const unchangedData2 = await storage.getCredentialData( + credential1.id, + 'subject-B', + resolver1.id, + {}, + ); + expect(unchangedData2).toBe('data-cred1-subjB-res1'); + + const unchangedData3 = await storage.getCredentialData( + credential1.id, + 'subject-A', + resolver2.id, + {}, + ); + expect(unchangedData3).toBe('data-cred1-subjA-res2'); + + // ACT - Delete one entry + await storage.deleteCredentialData(credential1.id, 'subject-A', resolver1.id, {}); + + // ASSERT - Deleted entry should be gone, others remain + const deletedData = await storage.getCredentialData( + credential1.id, + 'subject-A', + resolver1.id, + {}, + ); + expect(deletedData).toBeNull(); + + const stillExistingData = await storage.getCredentialData( + credential1.id, + 'subject-B', + resolver1.id, + {}, + ); + expect(stillExistingData).toBe('data-cred1-subjB-res1'); + }); +}); diff --git a/packages/cli/test/integration/dynamic-credentials/dynamic-credential-entry.repository.test.ts b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-entry.repository.test.ts new file mode 100644 index 00000000000..b24d697510f --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials/dynamic-credential-entry.repository.test.ts @@ -0,0 +1,463 @@ +import { testDb, testModules } from '@n8n/backend-test-utils'; +import { CredentialsRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { DynamicCredentialEntry } from '@/modules/dynamic-credentials.ee/database/entities/dynamic-credential-entry'; +import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository'; +import { DynamicCredentialEntryRepository } from '@/modules/dynamic-credentials.ee/database/repositories/dynamic-credential-entry.repository'; + +import { createDynamicCredentialResolver } from './shared/db-helpers'; +import { createCredentials } from '../shared/db/credentials'; + +describe('DynamicCredentialEntryRepository', () => { + let repository: DynamicCredentialEntryRepository; + let previousEnvVar: string | undefined; + + beforeAll(async () => { + previousEnvVar = process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS; + process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true'; + await testModules.loadModules(['dynamic-credentials']); + await testDb.init(); + repository = Container.get(DynamicCredentialEntryRepository); + }); + + afterAll(async () => { + process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = previousEnvVar; + await testDb.terminate(); + }); + + beforeEach(async () => { + await testDb.truncate([ + 'DynamicCredentialEntry', + 'DynamicCredentialResolver', + 'CredentialsEntity', + ]); + }); + + it('should save and retrieve a dynamic credential entry', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + const entry = new DynamicCredentialEntry(); + entry.credentialId = credential.id; + entry.subjectId = 'subject-123'; + entry.resolverId = resolver.id; + entry.data = 'encrypted-test-data'; + + // ACT + const savedEntry = await repository.save(entry); + + // Retrieve it back + const foundEntry = await repository.findOne({ + where: { + credentialId: credential.id, + subjectId: 'subject-123', + resolverId: resolver.id, + }, + }); + + // ASSERT + expect(savedEntry).toBeDefined(); + expect(savedEntry.credentialId).toBe(credential.id); + expect(savedEntry.subjectId).toBe('subject-123'); + expect(savedEntry.resolverId).toBe(resolver.id); + expect(savedEntry.data).toBe('encrypted-test-data'); + expect(savedEntry.createdAt).toBeInstanceOf(Date); + expect(savedEntry.updatedAt).toBeInstanceOf(Date); + + expect(foundEntry).toBeDefined(); + expect(foundEntry?.data).toBe('encrypted-test-data'); + }); + + it('should cascade delete entries when credential is deleted', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + // Create multiple entries for the same credential + const entry1 = new DynamicCredentialEntry(); + entry1.credentialId = credential.id; + entry1.subjectId = 'subject-1'; + entry1.resolverId = resolver.id; + entry1.data = 'data-1'; + + const entry2 = new DynamicCredentialEntry(); + entry2.credentialId = credential.id; + entry2.subjectId = 'subject-2'; + entry2.resolverId = resolver.id; + entry2.data = 'data-2'; + + await repository.save(entry1); + await repository.save(entry2); + + // Verify entries exist + const entriesBeforeDelete = await repository.find({ + where: { + credentialId: credential.id, + }, + }); + expect(entriesBeforeDelete).toHaveLength(2); + + // ACT - Delete the credential + const credentialsRepository = Container.get(CredentialsRepository); + await credentialsRepository.delete({ id: credential.id }); + + // ASSERT - All entries for this credential should be cascade deleted + const entriesAfterDelete = await repository.find({ + where: { + credentialId: credential.id, + }, + }); + expect(entriesAfterDelete).toHaveLength(0); + }); + + it('should cascade delete entries when resolver is deleted', async () => { + // ARRANGE + const credential1 = await createCredentials({ + name: 'Credential 1', + type: 'testType', + data: 'test-data-1', + }); + const credential2 = await createCredentials({ + name: 'Credential 2', + type: 'testType', + data: 'test-data-2', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + // Create entries for multiple credentials using the same resolver + const entry1 = new DynamicCredentialEntry(); + entry1.credentialId = credential1.id; + entry1.subjectId = 'subject-1'; + entry1.resolverId = resolver.id; + entry1.data = 'data-1'; + + const entry2 = new DynamicCredentialEntry(); + entry2.credentialId = credential2.id; + entry2.subjectId = 'subject-2'; + entry2.resolverId = resolver.id; + entry2.data = 'data-2'; + + await repository.save(entry1); + await repository.save(entry2); + + // Verify entries exist + const entriesBeforeDelete = await repository.find({ + where: { + resolverId: resolver.id, + }, + }); + expect(entriesBeforeDelete).toHaveLength(2); + + // ACT - Delete the resolver + const resolverRepository = Container.get(DynamicCredentialResolverRepository); + await resolverRepository.delete({ id: resolver.id }); + + // ASSERT - All entries for this resolver should be cascade deleted + const entriesAfterDelete = await repository.find({ + where: { + resolverId: resolver.id, + }, + }); + expect(entriesAfterDelete).toHaveLength(0); + }); + + it('should fetch CredentialsEntity through ManyToOne relationship', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential for Relationship', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + const entry = new DynamicCredentialEntry(); + entry.credentialId = credential.id; + entry.subjectId = 'subject-123'; + entry.resolverId = resolver.id; + entry.data = 'encrypted-test-data'; + + await repository.save(entry); + + // ACT - Fetch entry with credential relationship loaded + const foundEntry = await repository.findOne({ + where: { + credentialId: credential.id, + subjectId: 'subject-123', + resolverId: resolver.id, + }, + relations: ['credential'], + }); + + // ASSERT + expect(foundEntry).toBeDefined(); + expect(foundEntry?.credential).toBeDefined(); + expect(foundEntry?.credential.id).toBe(credential.id); + expect(foundEntry?.credential.name).toBe('Test Credential for Relationship'); + expect(foundEntry?.credential.type).toBe('testType'); + }); + + it('should fetch DynamicCredentialResolver through ManyToOne relationship', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver-for-relationship', + type: 'test-type', + config: 'test-config-data', + }); + + const entry = new DynamicCredentialEntry(); + entry.credentialId = credential.id; + entry.subjectId = 'subject-456'; + entry.resolverId = resolver.id; + entry.data = 'encrypted-test-data'; + + await repository.save(entry); + + // ACT - Fetch entry with resolver relationship loaded + const foundEntry = await repository.findOne({ + where: { + credentialId: credential.id, + subjectId: 'subject-456', + resolverId: resolver.id, + }, + relations: ['resolver'], + }); + + // ASSERT + expect(foundEntry).toBeDefined(); + expect(foundEntry?.resolver).toBeDefined(); + expect(foundEntry?.resolver.id).toBe(resolver.id); + expect(foundEntry?.resolver.name).toBe('test-resolver-for-relationship'); + expect(foundEntry?.resolver.type).toBe('test-type'); + }); + + it('should filter entries by credential type using find method', async () => { + // ARRANGE + const credential1 = await createCredentials({ + name: 'OAuth Credential', + type: 'oAuth2Api', + data: 'oauth-data', + }); + const credential2 = await createCredentials({ + name: 'API Key Credential', + type: 'apiKeyAuth', + data: 'api-key-data', + }); + const credential3 = await createCredentials({ + name: 'Another OAuth Credential', + type: 'oAuth2Api', + data: 'oauth-data-2', + }); + const resolver = await createDynamicCredentialResolver({ + name: 'test-resolver', + type: 'test', + config: 'test-data', + }); + + // Create entries for different credential types + const entry1 = new DynamicCredentialEntry(); + entry1.credentialId = credential1.id; + entry1.subjectId = 'subject-1'; + entry1.resolverId = resolver.id; + entry1.data = 'data-1'; + + const entry2 = new DynamicCredentialEntry(); + entry2.credentialId = credential2.id; + entry2.subjectId = 'subject-2'; + entry2.resolverId = resolver.id; + entry2.data = 'data-2'; + + const entry3 = new DynamicCredentialEntry(); + entry3.credentialId = credential3.id; + entry3.subjectId = 'subject-3'; + entry3.resolverId = resolver.id; + entry3.data = 'data-3'; + + await repository.save([entry1, entry2, entry3]); + + // ACT - Query entries where credential type is 'oAuth2Api' + const oauthEntries = await repository.find({ + where: { + credential: { + type: 'oAuth2Api', + }, + }, + relations: ['credential'], + }); + + // ASSERT + expect(oauthEntries).toHaveLength(2); + expect(oauthEntries.every((entry) => entry.credential.type === 'oAuth2Api')).toBe(true); + expect(oauthEntries.map((e) => e.subjectId).sort()).toEqual(['subject-1', 'subject-3']); + }); + + it('should filter entries by resolver type using find method', async () => { + // ARRANGE + const credential = await createCredentials({ + name: 'Test Credential', + type: 'testType', + data: 'test-data', + }); + const resolver1 = await createDynamicCredentialResolver({ + name: 'AWS Resolver', + type: 'aws-secrets-manager', + config: 'aws-config', + }); + const resolver2 = await createDynamicCredentialResolver({ + name: 'Azure Resolver', + type: 'azure-key-vault', + config: 'azure-config', + }); + const resolver3 = await createDynamicCredentialResolver({ + name: 'Another AWS Resolver', + type: 'aws-secrets-manager', + config: 'aws-config-2', + }); + + // Create entries for different resolver types + const entry1 = new DynamicCredentialEntry(); + entry1.credentialId = credential.id; + entry1.subjectId = 'subject-1'; + entry1.resolverId = resolver1.id; + entry1.data = 'data-1'; + + const entry2 = new DynamicCredentialEntry(); + entry2.credentialId = credential.id; + entry2.subjectId = 'subject-2'; + entry2.resolverId = resolver2.id; + entry2.data = 'data-2'; + + const entry3 = new DynamicCredentialEntry(); + entry3.credentialId = credential.id; + entry3.subjectId = 'subject-3'; + entry3.resolverId = resolver3.id; + entry3.data = 'data-3'; + + await repository.save([entry1, entry2, entry3]); + + // ACT - Query entries where resolver type is 'aws-secrets-manager' + const awsEntries = await repository.find({ + where: { + resolver: { + type: 'aws-secrets-manager', + }, + }, + relations: ['resolver'], + }); + + // ASSERT + expect(awsEntries).toHaveLength(2); + expect(awsEntries.every((entry) => entry.resolver.type === 'aws-secrets-manager')).toBe(true); + expect(awsEntries.map((e) => e.subjectId).sort()).toEqual(['subject-1', 'subject-3']); + }); + + it('should filter entries by both credential type and resolver type using find method', async () => { + // ARRANGE + const credential1 = await createCredentials({ + name: 'OAuth Credential 1', + type: 'oAuth2Api', + data: 'oauth-data-1', + }); + const credential2 = await createCredentials({ + name: 'OAuth Credential 2', + type: 'oAuth2Api', + data: 'oauth-data-2', + }); + const credential3 = await createCredentials({ + name: 'API Key Credential', + type: 'apiKeyAuth', + data: 'api-key-data', + }); + + const resolver1 = await createDynamicCredentialResolver({ + name: 'AWS Resolver', + type: 'aws-secrets-manager', + config: 'aws-config', + }); + const resolver2 = await createDynamicCredentialResolver({ + name: 'Azure Resolver', + type: 'azure-key-vault', + config: 'azure-config', + }); + + // Create entries with various combinations + const entry1 = new DynamicCredentialEntry(); + entry1.credentialId = credential1.id; + entry1.subjectId = 'subject-1'; + entry1.resolverId = resolver1.id; + entry1.data = 'data-1'; + + const entry2 = new DynamicCredentialEntry(); + entry2.credentialId = credential1.id; + entry2.subjectId = 'subject-2'; + entry2.resolverId = resolver2.id; + entry2.data = 'data-2'; + + const entry3 = new DynamicCredentialEntry(); + entry3.credentialId = credential2.id; + entry3.subjectId = 'subject-3'; + entry3.resolverId = resolver1.id; + entry3.data = 'data-3'; + + const entry4 = new DynamicCredentialEntry(); + entry4.credentialId = credential3.id; + entry4.subjectId = 'subject-4'; + entry4.resolverId = resolver1.id; + entry4.data = 'data-4'; + + await repository.save([entry1, entry2, entry3, entry4]); + + // ACT - Query entries where credential type is 'oAuth2Api' AND resolver type is 'aws-secrets-manager' + const filteredEntries = await repository.find({ + where: { + credential: { + type: 'oAuth2Api', + }, + resolver: { + type: 'aws-secrets-manager', + }, + }, + relations: ['credential', 'resolver'], + }); + + // ASSERT - Should only return entries with both OAuth credentials and AWS resolver + expect(filteredEntries).toHaveLength(2); + expect( + filteredEntries.every( + (entry) => + entry.credential.type === 'oAuth2Api' && entry.resolver.type === 'aws-secrets-manager', + ), + ).toBe(true); + expect(filteredEntries.map((e) => e.subjectId).sort()).toEqual(['subject-1', 'subject-3']); + }); +}); diff --git a/packages/cli/test/integration/dynamic-credentials/shared/db-helpers.ts b/packages/cli/test/integration/dynamic-credentials/shared/db-helpers.ts new file mode 100644 index 00000000000..a03f70906f7 --- /dev/null +++ b/packages/cli/test/integration/dynamic-credentials/shared/db-helpers.ts @@ -0,0 +1,22 @@ +import { Container } from '@n8n/di'; + +import type { DynamicCredentialResolver } from '@/modules/dynamic-credentials.ee/database/entities/credential-resolver'; +import { DynamicCredentialResolverRepository } from '@/modules/dynamic-credentials.ee/database/repositories/credential-resolver.repository'; + +/** + * Creates a dynamic credential resolver for testing + */ +export async function createDynamicCredentialResolver( + attributes: Partial, +): Promise { + const repository = Container.get(DynamicCredentialResolverRepository); + + const resolver = repository.create({ + name: attributes.name ?? 'test-resolver', + type: attributes.type ?? 'test-type', + config: attributes.config ?? '{}', + ...attributes, + }); + + return await repository.save(resolver); +} diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 781b43c83e2..fd4e39d20c9 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -3,7 +3,7 @@ import { createTeamProject, getPersonalProject, linkUserToProject, - createWorkflow, + createWorkflowWithHistory, randomCredentialPayload, testDb, mockInstance, @@ -19,11 +19,13 @@ import { type WorkflowEntity, WorkflowRepository, WorkflowTagMappingRepository, + WorkflowHistoryRepository, } from '@n8n/db'; import { FolderRepository, ProjectRepository, SharedCredentialsRepository, + SharedWorkflowRepository, UserRepository, } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -40,6 +42,7 @@ import { SourceControlScopedService } from '@/environments.ee/source-control/sou import type { ExportableCredential } from '@/environments.ee/source-control/types/exportable-credential'; import { SourceControlContext } from '@/environments.ee/source-control/types/source-control-context'; import type { IWorkflowToImport } from '@/interfaces'; +import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { createFolder } from '@test-integration/db/folders'; import { assignTagToWorkflow, createTag } from '@test-integration/db/tags'; @@ -52,15 +55,19 @@ describe('SourceControlImportService', () => { let credentialsRepository: CredentialsRepository; let projectRepository: ProjectRepository; let sharedCredentialsRepository: SharedCredentialsRepository; + let sharedWorkflowRepository: SharedWorkflowRepository; let userRepository: UserRepository; let folderRepository: FolderRepository; let service: SourceControlImportService; let workflowRepository: WorkflowRepository; let tagRepository: TagRepository; let workflowTagMappingRepository: WorkflowTagMappingRepository; + let workflowHistoryRepository: WorkflowHistoryRepository; + let workflowHistoryService: WorkflowHistoryService; let sourceControlScopedService: SourceControlScopedService; const cipher = mockInstance(Cipher); + const mockFileData = new Map(); beforeAll(async () => { await testDb.init(); @@ -68,11 +75,14 @@ describe('SourceControlImportService', () => { credentialsRepository = Container.get(CredentialsRepository); projectRepository = Container.get(ProjectRepository); sharedCredentialsRepository = Container.get(SharedCredentialsRepository); + sharedWorkflowRepository = Container.get(SharedWorkflowRepository); userRepository = Container.get(UserRepository); folderRepository = Container.get(FolderRepository); workflowRepository = Container.get(WorkflowRepository); tagRepository = Container.get(TagRepository); workflowTagMappingRepository = Container.get(WorkflowTagMappingRepository); + workflowHistoryRepository = Container.get(WorkflowHistoryRepository); + workflowHistoryService = Container.get(WorkflowHistoryService); sourceControlScopedService = Container.get(SourceControlScopedService); service = new SourceControlImportService( mock(), @@ -82,7 +92,7 @@ describe('SourceControlImportService', () => { credentialsRepository, projectRepository, tagRepository, - mock(), + sharedWorkflowRepository, sharedCredentialsRepository, userRepository, mock(), @@ -95,11 +105,21 @@ describe('SourceControlImportService', () => { mock({ n8nFolder: '/some-path' }), sourceControlScopedService, mock(), + workflowHistoryService, ); }); afterEach(async () => { - await testDb.truncate(['CredentialsEntity', 'SharedCredentials']); + await testDb.truncate([ + 'WorkflowPublishHistory', + 'WorkflowHistory', + 'SharedWorkflow', + 'WorkflowTagMapping', + 'SharedCredentials', + 'WorkflowEntity', + 'CredentialsEntity', + 'TagEntity', + ]); jest.restoreAllMocks(); }); @@ -173,7 +193,7 @@ describe('SourceControlImportService', () => { let teamAdmin: User; let team1: Project; - beforeAll(async () => { + beforeEach(async () => { [globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([ createAdmin(), createOwner(), @@ -194,7 +214,14 @@ describe('SourceControlImportService', () => { ]); fsReadFile.mockImplementation(async (path) => { - switch (path) { + // Check if this file has mock data in the map (used by some tests) + const pathStr = typeof path === 'string' ? path : path.toString(); + if (mockFileData.has(pathStr)) { + return mockFileData.get(pathStr)!; + } + + // Otherwise use the predefined mock data + switch (pathStr) { case mockWorkflow1File: return JSON.stringify({ ...mockWorkflow1Data, @@ -226,7 +253,7 @@ describe('SourceControlImportService', () => { }, }); } - throw new Error(`Trying to access invalid file in test: ${path}`); + throw new Error(`Trying to access invalid file in test: ${pathStr}`); }); }); @@ -297,7 +324,7 @@ describe('SourceControlImportService', () => { let projectAdminWorkflows: WorkflowEntity[]; let projectMemberWorkflows: WorkflowEntity[]; - beforeAll(async () => { + beforeEach(async () => { [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ getGlobalOwner(), createMember(), @@ -312,33 +339,33 @@ describe('SourceControlImportService', () => { await linkUserToProject(projectMember, teamProjectB, 'project:editor'); teamAWorkflows = await Promise.all([ - await createWorkflow({}, teamProjectA), - await createWorkflow({}, teamProjectA), - await createWorkflow({}, teamProjectA), + await createWorkflowWithHistory({}, teamProjectA), + await createWorkflowWithHistory({}, teamProjectA), + await createWorkflowWithHistory({}, teamProjectA), ]); teamBWorkflows = await Promise.all([ - await createWorkflow({}, teamProjectB), - await createWorkflow({}, teamProjectB), - await createWorkflow({}, teamProjectB), + await createWorkflowWithHistory({}, teamProjectB), + await createWorkflowWithHistory({}, teamProjectB), + await createWorkflowWithHistory({}, teamProjectB), ]); instanceOwnerWorkflows = await Promise.all([ - await createWorkflow({}, instanceOwner), - await createWorkflow({}, instanceOwner), - await createWorkflow({}, instanceOwner), + await createWorkflowWithHistory({}, instanceOwner), + await createWorkflowWithHistory({}, instanceOwner), + await createWorkflowWithHistory({}, instanceOwner), ]); projectAdminWorkflows = await Promise.all([ - await createWorkflow({}, projectAdmin), - await createWorkflow({}, projectAdmin), - await createWorkflow({}, projectAdmin), + await createWorkflowWithHistory({}, projectAdmin), + await createWorkflowWithHistory({}, projectAdmin), + await createWorkflowWithHistory({}, projectAdmin), ]); projectMemberWorkflows = await Promise.all([ - await createWorkflow({}, projectMember), - await createWorkflow({}, projectMember), - await createWorkflow({}, projectMember), + await createWorkflowWithHistory({}, projectMember), + await createWorkflowWithHistory({}, projectMember), + await createWorkflowWithHistory({}, projectMember), ]); }); @@ -443,7 +470,7 @@ describe('SourceControlImportService', () => { let teamAdmin: User; let team1: Project; - beforeAll(async () => { + beforeEach(async () => { [globalAdmin, globalOwner, globalMember, teamAdmin] = await Promise.all([ createAdmin(), createOwner(), @@ -671,7 +698,7 @@ describe('SourceControlImportService', () => { let foldersProjectA: Folder[]; let foldersProjectB: Folder[]; - beforeAll(async () => { + beforeEach(async () => { [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ getGlobalOwner(), createMember(), @@ -820,21 +847,21 @@ describe('SourceControlImportService', () => { ]); workflowTeam1 = await Promise.all([ - await createWorkflow( + await createWorkflowWithHistory( { id: 'wf1', name: 'Workflow 1', }, team1, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'wf2', name: 'Workflow 2', }, team1, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'wf3', name: 'Workflow 3', @@ -844,21 +871,21 @@ describe('SourceControlImportService', () => { ]); await Promise.all([ - await createWorkflow( + await createWorkflowWithHistory( { id: 'wf4', name: 'Workflow 4', }, team2, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'wf5', name: 'Workflow 5', }, team2, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'wf6', name: 'Workflow 6', @@ -868,8 +895,6 @@ describe('SourceControlImportService', () => { ]); }); - beforeEach(async () => {}); - it('should show all remote tags and all remote mappings for instance admins', async () => { const result = await service.getRemoteTagsAndMappingsFromFile( new SourceControlContext(globalAdmin), @@ -932,7 +957,7 @@ describe('SourceControlImportService', () => { let workflowsProjectB: WorkflowEntity[]; let mappings: Array<[TagEntity, WorkflowEntity]>; - beforeAll(async () => { + beforeEach(async () => { [instanceOwner, projectAdmin, projectMember, teamProjectA, teamProjectB] = await Promise.all([ getGlobalOwner(), createMember(), @@ -959,21 +984,21 @@ describe('SourceControlImportService', () => { ]); workflowsProjectA = await Promise.all([ - await createWorkflow( + await createWorkflowWithHistory( { id: 'workflow1', name: 'Workflow 1', }, teamProjectA, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'workflow2', name: 'Workflow 2', }, teamProjectA, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'workflow3', name: 'Workflow 3', @@ -983,21 +1008,21 @@ describe('SourceControlImportService', () => { ]); workflowsProjectB = await Promise.all([ - await createWorkflow( + await createWorkflowWithHistory( { id: 'workflow4', name: 'Workflow 4', }, teamProjectB, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'workflow5', name: 'Workflow 5', }, teamProjectB, ), - await createWorkflow( + await createWorkflowWithHistory( { id: 'workflow6', name: 'Workflow 6', @@ -1081,7 +1106,7 @@ describe('SourceControlImportService', () => { it('should assign credential ownership to original user', async () => { const [importingUser, member] = await Promise.all([getGlobalOwner(), createMember()]); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1118,7 +1143,7 @@ describe('SourceControlImportService', () => { it('should assign credential ownership to importing user', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1155,7 +1180,7 @@ describe('SourceControlImportService', () => { it('should assign credential ownership to importing user', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1193,7 +1218,7 @@ describe('SourceControlImportService', () => { it('should assign the credential ownership to the importing user if it was owned by a personal project in the source instance', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1231,7 +1256,7 @@ describe('SourceControlImportService', () => { it('should create a new team project if the credential was owned by a team project in the source instance', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1290,7 +1315,7 @@ describe('SourceControlImportService', () => { it('should use the existing team project if credential owning project is found', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1331,7 +1356,7 @@ describe('SourceControlImportService', () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const targetProject = await createTeamProject('Marketing'); const credential = await saveCredential(randomCredentialPayload(), { @@ -1395,7 +1420,7 @@ describe('SourceControlImportService', () => { it('should import global credentials with isGlobal flag set to true', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1430,7 +1455,7 @@ describe('SourceControlImportService', () => { it('should import non-global credentials with isGlobal flag set to false', async () => { const importingUser = await getGlobalOwner(); - fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content')); + jest.spyOn(fsp, 'readFile').mockResolvedValue(Buffer.from('some-content')); const CREDENTIAL_ID = nanoid(); @@ -1462,4 +1487,185 @@ describe('SourceControlImportService', () => { expect(importedCredential?.type).toBe('standardCredentialType'); }); }); + + describe('importWorkflowFromWorkFolder()', () => { + const globMock = fastGlob.default as unknown as jest.Mock, string[]>; + const fsReadFile = jest.spyOn(fsp, 'readFile'); + + const putWorkflowFile = (workflowId: string, workflow: IWorkflowToImport) => { + const file = `/mock/${workflowId}.json`; + globMock.mockResolvedValue([file]); + mockFileData.set(file, JSON.stringify(workflow)); + return file; + }; + + const makeWorkflowImport = (overrides: Partial = {}): IWorkflowToImport => ({ + id: overrides.id ?? nanoid(), + name: overrides.name ?? 'Test Workflow', + versionId: overrides.versionId ?? nanoid(), + nodes: + overrides.nodes ?? + ([ + { + id: 'node-1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: {}, + }, + ] as IWorkflowToImport['nodes']), + connections: overrides.connections ?? {}, + settings: overrides.settings ?? {}, + parentFolderId: overrides.parentFolderId ?? null, + active: overrides.active ?? false, + isArchived: overrides.isArchived ?? false, + activeVersionId: overrides.activeVersionId ?? null, + }); + + beforeEach(() => { + mockFileData.clear(); + + fsReadFile.mockImplementation(async (path) => { + const pathStr = typeof path === 'string' ? path : path.toString(); + if (!mockFileData.has(pathStr)) { + throw new Error(`Trying to access invalid file in test: ${pathStr}`); + } + return mockFileData.get(pathStr)!; + }); + }); + + describe('workflow history', () => { + it('should create workflow history for new workflow on import', async () => { + const importingUser = await getGlobalOwner(); + + const workflow = makeWorkflowImport(); + const file = putWorkflowFile(workflow.id, workflow); + + await service.importWorkflowFromWorkFolder( + [mock({ id: workflow.id, file })], + importingUser.id, + ); + + // Verify workflow history was created + const historyRecord = await workflowHistoryRepository.findOne({ + where: { versionId: workflow.versionId, workflowId: workflow.id }, + }); + + expect(historyRecord).toBeTruthy(); + expect(historyRecord?.nodes).toEqual(workflow.nodes); + expect(historyRecord?.connections).toEqual(workflow.connections); + expect(historyRecord?.authors).toBe(`${importingUser.firstName} ${importingUser.lastName}`); + }); + + it('should update workflow history when versionId exists but nodes changed', async () => { + const importingUser = await getGlobalOwner(); + const workflowId = nanoid(); + const versionId = nanoid(); + + // Create initial workflow and history + const initialNodes = [ + { + id: 'node-1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: {}, + }, + ]; + + await createWorkflowWithHistory( + { + id: workflowId, + name: 'Test Workflow', + versionId, + nodes: initialNodes, + connections: {}, + }, + importingUser, + ); + + // Import with updated nodes + const updatedNodes = [ + ...initialNodes, + { + id: 'node-2', + name: 'Set', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [450, 300] as [number, number], + parameters: {}, + }, + ]; + + const workflow = makeWorkflowImport({ id: workflowId, versionId, nodes: updatedNodes }); + const file = putWorkflowFile(workflowId, workflow); + + await service.importWorkflowFromWorkFolder( + [mock({ id: workflowId, file })], + importingUser.id, + ); + + // Verify workflow history was updated + const historyRecord = await workflowHistoryRepository.findOne({ + where: { versionId, workflowId }, + }); + + expect(historyRecord).toBeTruthy(); + expect(historyRecord?.nodes).toEqual(updatedNodes); + expect(historyRecord?.authors).toBe(`${importingUser.firstName} ${importingUser.lastName}`); + }); + + it('should not update workflow history when versionId exists and content unchanged', async () => { + const importingUser = await getGlobalOwner(); + const workflowId = nanoid(); + const versionId = nanoid(); + + const nodes = [ + { + id: 'node-1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: {}, + }, + ]; + + // Create initial workflow and history + await createWorkflowWithHistory( + { + id: workflowId, + name: 'Test Workflow', + versionId, + nodes, + connections: {}, + }, + importingUser, + ); + + const historyBefore = await workflowHistoryRepository.findOne({ + where: { versionId, workflowId }, + }); + + const workflow = makeWorkflowImport({ id: workflowId, versionId, nodes }); + const file = putWorkflowFile(workflowId, workflow); + + await service.importWorkflowFromWorkFolder( + [mock({ id: workflowId, file })], + importingUser.id, + ); + + // Verify workflow history was NOT updated (authors should remain unchanged) + const historyAfter = await workflowHistoryRepository.findOne({ + where: { versionId, workflowId }, + }); + + expect(historyAfter).toBeTruthy(); + expect(historyAfter?.authors).toBe(historyBefore?.authors); // Should not have changed + expect(historyAfter?.updatedAt?.getTime()).toBe(historyBefore?.updatedAt?.getTime()); + }); + }); + }); }); diff --git a/packages/cli/test/integration/environments/source-control.service.test.ts b/packages/cli/test/integration/environments/source-control.service.test.ts index c05f5da159f..09073d1c9ac 100644 --- a/packages/cli/test/integration/environments/source-control.service.test.ts +++ b/packages/cli/test/integration/environments/source-control.service.test.ts @@ -4,6 +4,7 @@ import { CredentialsEntity, type Folder, GLOBAL_ADMIN_ROLE, + GLOBAL_CHAT_USER_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, Project, @@ -147,6 +148,7 @@ describe('SourceControlService', () => { let globalAdmin: User; let globalOwner: User; let globalMember: User; + let globalChatUser: User; let projectAdmin: User; let projectA: Project; @@ -201,10 +203,11 @@ describe('SourceControlService', () => { /* Set up test conditions: - 4 users: + 5 users: globalAdmin globalOwner globalMember + globalChatUser projectAdmin 2 Team projects: @@ -220,11 +223,12 @@ describe('SourceControlService', () => { 1. Workflow moved in git to other project */ - [globalAdmin, globalOwner, globalMember, projectAdmin] = await Promise.all([ + [globalAdmin, globalOwner, globalMember, projectAdmin, globalChatUser] = await Promise.all([ createUser({ role: GLOBAL_ADMIN_ROLE }), createUser({ role: GLOBAL_OWNER_ROLE }), createUser({ role: GLOBAL_MEMBER_ROLE }), createUser({ role: GLOBAL_MEMBER_ROLE }), + createUser({ role: GLOBAL_CHAT_USER_ROLE }), ]); [projectA, projectB] = await Promise.all([ @@ -678,6 +682,18 @@ describe('SourceControlService', () => { }); }); + describe('global:chatUser user', () => { + it('should see nothing', async () => { + const result = await service.getStatus(globalChatUser, { + direction: 'push', + preferLocalVersion: true, + verbose: false, + }); + + expect(result).toBeEmptyArray(); + }); + }); + describe('project:Admin user', () => { it('should see only workflows in correct scope', async () => { const result = await service.getStatus(projectAdmin, { diff --git a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts index 2f4aa357027..e4328176b7a 100644 --- a/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts +++ b/packages/cli/test/integration/external-secrets/external-secrets.api.test.ts @@ -6,7 +6,6 @@ import { mock } from 'jest-mock-extended'; import { Cipher } from 'n8n-core'; import type { IDataObject } from 'n8n-workflow'; -import config from '@/config'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import type { EventService } from '@/events/event.service'; import { License } from '@/license'; @@ -112,7 +111,6 @@ beforeAll(async () => { authOwnerAgent = testServer.authAgentFor(owner); const member = await createUser(); authMemberAgent = testServer.authAgentFor(member); - config.set('userManagement.isInstanceOwnerSetUp', true); Container.set( ExternalSecretsManager, new ExternalSecretsManager( diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index d64d15c5b79..920d7053960 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -54,7 +54,16 @@ const activeWorkflowManager = mockInstance(ActiveWorkflowManager); beforeEach(async () => { testServer.license.enable('feat:folders'); - await testDb.truncate(['Folder', 'SharedWorkflow', 'TagEntity', 'Project', 'ProjectRelation']); + await testDb.truncate([ + 'Folder', + 'SharedWorkflow', + 'TagEntity', + 'Project', + 'ProjectRelation', + 'WorkflowEntity', + 'WorkflowHistory', + 'WorkflowPublishHistory', + ]); projectRepository = Container.get(ProjectRepository); folderRepository = Container.get(FolderRepository); diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index 3bd0209904c..9017ec74d59 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -63,7 +63,14 @@ describe('ImportService', () => { }); afterEach(async () => { - await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'TagEntity', 'WorkflowTagMapping']); + await testDb.truncate([ + 'WorkflowEntity', + 'SharedWorkflow', + 'TagEntity', + 'WorkflowTagMapping', + 'WorkflowHistory', + 'WorkflowPublishHistory', + ]); }); afterAll(async () => { diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 29e9473447e..cdcfffe1f97 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -64,8 +64,6 @@ beforeEach(async () => { jest.mock('@/telemetry'); - config.set('userManagement.isInstanceOwnerSetUp', true); - await setCurrentAuthenticationMethod('email'); }); @@ -79,6 +77,16 @@ test('Member role should not be able to access ldap routes', async () => { await authAgent.get('/ldap/sync').expect(403); }); +test('Chat user role should not be able to access ldap routes', async () => { + const chatUser = await createUser({ role: { slug: 'global:chatUser' } }); + const authAgent = testServer.authAgentFor(chatUser); + await authAgent.get('/ldap/config').expect(403); + await authAgent.put('/ldap/config').expect(403); + await authAgent.post('/ldap/test-connection').expect(403); + await authAgent.post('/ldap/sync').expect(403); + await authAgent.get('/ldap/sync').expect(403); +}); + describe('PUT /ldap/config', () => { test('route should validate payload', async () => { const invalidValuePayload = { diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 469ddb80d58..708c53f88b4 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -144,7 +144,6 @@ describe('Member', () => { role: { slug: 'global:member' }, }); authMemberAgent = testServer.authAgentFor(member); - await utils.setInstanceOwnerSetUp(true); }); test('PATCH /me should succeed with valid inputs', async () => { @@ -276,6 +275,147 @@ describe('Member', () => { }); }); +describe('Chat User', () => { + let member: User; + let authMemberAgent: SuperAgentTest; + + beforeEach(async () => { + member = await createUser({ + password: memberPassword, + role: { slug: 'global:chatUser' }, + }); + authMemberAgent = testServer.authAgentFor(member); + }); + + test('PATCH /me should succeed with valid inputs', async () => { + for (const validPayload of getValidPatchMePayloads('chatUser')) { + const response = await authMemberAgent.patch('/me').send(validPayload).expect(200); + + const { id, email, firstName, lastName, personalizationAnswers, role, password, isPending } = + response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(validPayload.email.toLowerCase()); + expect(firstName).toBe(validPayload.firstName); + expect(lastName).toBe(validPayload.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(isPending).toBe(false); + expect(role).toBe('global:chatUser'); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({ id }); + + expect(storedMember.email).toBe(validPayload.email.toLowerCase()); + expect(storedMember.firstName).toBe(validPayload.firstName); + expect(storedMember.lastName).toBe(validPayload.lastName); + + const storedPersonalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); + } + }); + + test('PATCH /me should fail with invalid inputs', async () => { + for (const invalidPayload of getInvalidPatchMePayloads('chatUser')) { + const response = await authMemberAgent.patch('/me').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.email).toBe(member.email); + expect(storedMember.firstName).toBe(member.firstName); + expect(storedMember.lastName).toBe(member.lastName); + + const storedPersonalProject = await Container.get( + ProjectRepository, + ).getPersonalProjectForUserOrFail(storedMember.id); + + expect(storedPersonalProject.name).toBe(storedMember.createPersonalProjectName()); + } + }); + + test('PATCH /me should fail when changing email without currentPassword', async () => { + const payloadWithoutPassword = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + }; + + const response = await authMemberAgent.patch('/me').send(payloadWithoutPassword); + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain('Current password is required to change email'); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.email).toBe(member.email); + }); + + test('PATCH /me should fail when changing email with wrong currentPassword', async () => { + const payloadWithWrongPassword = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + currentPassword: 'WrongPassword123', + }; + + const response = await authMemberAgent.patch('/me').send(payloadWithWrongPassword); + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain( + 'Unable to update profile. Please check your credentials and try again.', + ); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.email).toBe(member.email); + }); + + test('PATCH /me/password should succeed with valid inputs', async () => { + const validPayload = { + currentPassword: memberPassword, + newPassword: randomValidPassword(), + }; + + const response = await authMemberAgent.patch('/me/password').send(validPayload); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + expect(storedMember.password).not.toBe(member.password); + expect(storedMember.password).not.toBe(validPayload.newPassword); + }); + + test('PATCH /me/password should fail with invalid inputs', async () => { + for (const payload of INVALID_PASSWORD_PAYLOADS) { + const response = await authMemberAgent.patch('/me/password').send(payload); + expect([400, 500].includes(response.statusCode)).toBe(true); + + const storedMember = await Container.get(UserRepository).findOneByOrFail({}); + + if (payload.newPassword) { + expect(storedMember.password).not.toBe(payload.newPassword); + } + if (payload.currentPassword) { + expect(storedMember.password).not.toBe(payload.currentPassword); + } + } + }); + + test('POST /me/survey should succeed with valid inputs', async () => { + const validPayloads = [SURVEY, EMPTY_SURVEY]; + + for (const validPayload of validPayloads) { + const response = await authMemberAgent.post('/me/survey').send(validPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const { personalizationAnswers: storedAnswers } = await Container.get( + UserRepository, + ).findOneByOrFail({}); + + expect(storedAnswers).toEqual(validPayload); + } + }); +}); + describe('Owner', () => { test('PATCH /me should succeed with valid inputs', async () => { const owner = await createUser({ @@ -352,7 +492,7 @@ const EMPTY_SURVEY: IPersonalizationSurveyAnswersV4 = { personalization_survey_n8n_version: '1.0.0', }; -function getValidPatchMePayloads(userType: 'owner' | 'member') { +function getValidPatchMePayloads(userType: 'owner' | 'member' | 'chatUser') { return VALID_PATCH_ME_PAYLOADS.map((payload) => { if (userType === 'owner') { return { ...payload, currentPassword: ownerPassword }; @@ -361,7 +501,7 @@ function getValidPatchMePayloads(userType: 'owner' | 'member') { }); } -function getInvalidPatchMePayloads(userType: 'owner' | 'member') { +function getInvalidPatchMePayloads(userType: 'owner' | 'member' | 'chatUser') { return INVALID_PATCH_ME_PAYLOADS.map((payload) => { if (userType === 'owner') { return { ...payload, currentPassword: ownerPassword }; diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index f1f096e6258..52c1eb8deca 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -7,11 +7,10 @@ import { } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db'; +import { OwnershipService } from '@/services/ownership.service'; import { Container } from '@n8n/di'; import validator from 'validator'; -import config from '@/config'; - import { createUserShell } from './shared/db/users'; import * as utils from './shared/utils/'; @@ -21,7 +20,6 @@ let ownerShell: User; beforeEach(async () => { ownerShell = await createUserShell(GLOBAL_OWNER_ROLE); - config.set('userManagement.isInstanceOwnerSetUp', false); }); afterEach(async () => { @@ -71,10 +69,7 @@ describe('POST /owner/setup', () => { expect(storedOwner.firstName).toBe(newOwnerData.firstName); expect(storedOwner.lastName).toBe(newOwnerData.lastName); - const isInstanceOwnerSetUpConfig = config.getEnv('userManagement.isInstanceOwnerSetUp'); - expect(isInstanceOwnerSetUpConfig).toBe(true); - - const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp(); + const isInstanceOwnerSetUpSetting = await Container.get(OwnershipService).hasInstanceOwner(); expect(isInstanceOwnerSetUpSetting).toBe(true); }); diff --git a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts index 3cd452a8fce..38d76e4babe 100644 --- a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts +++ b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts @@ -322,6 +322,33 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { expect(formerAdminApiKey.scopes).not.toContain(ownerScope); } }); + + it('should remove all API keys when user downgrading to chatUser', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + + const owner = await createOwnerWithApiKey({ scopes: ['user:changeRole'] }); + const admin = await createAdminWithApiKey(); + const payload = { newRoleName: 'global:chatUser' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${admin.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(204); + + const formerAdminApiKey = await apiKeyRepository.findOneBy({ userId: admin.id }); + expect(formerAdminApiKey).toBeNull(); + }); }); describe('DELETE /users/:id', () => { @@ -1069,6 +1096,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { name: 'some-project', icon: null, type: 'team', + creatorId: owner.id, description: null, id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f0b675c8e27..e73423f18a9 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -143,6 +143,7 @@ describe('Projects in Public API', () => { name: 'some-project', icon: null, type: 'team', + creatorId: owner.id, description: null, id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 1705a64df35..130d322ef86 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -9,7 +9,12 @@ import { } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db'; -import { ProjectRepository, WorkflowHistoryRepository, SharedWorkflowRepository } from '@n8n/db'; +import { + WorkflowRepository, + ProjectRepository, + WorkflowHistoryRepository, + SharedWorkflowRepository, +} from '@n8n/db'; import { Container } from '@n8n/di'; import { Not } from '@n8n/typeorm'; import { InstanceSettings } from 'n8n-core'; @@ -37,6 +42,7 @@ let memberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; let activeWorkflowManager: ActiveWorkflowManager; +let workflowRepository: WorkflowRepository; const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); const license = testServer.license; @@ -61,6 +67,7 @@ beforeAll(async () => { await utils.initNodeTypes(); activeWorkflowManager = Container.get(ActiveWorkflowManager); + workflowRepository = Container.get(WorkflowRepository); await activeWorkflowManager.init(); }); @@ -73,6 +80,7 @@ beforeEach(async () => { 'WorkflowEntity', 'CredentialsEntity', 'WorkflowHistory', + 'WorkflowPublishHistory', ]); authOwnerAgent = testServer.publicApiAgentFor(owner); @@ -898,7 +906,7 @@ describe('POST /workflows/:id/activate', () => { expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); // check whether the workflow is on the active workflow runner - expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true); + expect(await workflowRepository.isActive(workflow.id)).toBe(true); }); test('should set activeVersionId when activating workflow', async () => { @@ -974,7 +982,7 @@ describe('POST /workflows/:id/activate', () => { expect(sharedWorkflow?.workflow.activeVersionId).toBe(workflow.versionId); // check whether the workflow is on the active workflow runner - expect(await activeWorkflowManager.isActive(workflow.id)).toBe(true); + expect(await workflowRepository.isActive(workflow.id)).toBe(true); }); }); @@ -1039,7 +1047,7 @@ describe('POST /workflows/:id/deactivate', () => { // check whether the workflow is deactivated in the database expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); - expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false); + expect(await workflowRepository.isActive(workflow.id)).toBe(false); }); test('should clear activeVersionId when deactivating workflow', async () => { @@ -1126,7 +1134,7 @@ describe('POST /workflows/:id/deactivate', () => { expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); - expect(await activeWorkflowManager.isActive(workflow.id)).toBe(false); + expect(await workflowRepository.isActive(workflow.id)).toBe(false); }); }); diff --git a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts index 7bbfd0eb094..f90598cb802 100644 --- a/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/credentials-risk-reporter.test.ts @@ -30,7 +30,13 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity', 'CredentialsEntity', 'ExecutionEntity']); + await testDb.truncate([ + 'WorkflowEntity', + 'CredentialsEntity', + 'ExecutionEntity', + 'WorkflowHistory', + 'WorkflowPublishHistory', + ]); }); afterAll(async () => { diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index d46996dae33..de96cf731f7 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -29,7 +29,7 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory', 'WorkflowPublishHistory']); }); afterAll(async () => { diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index a3f684fb260..eb59e4a48e5 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -3,6 +3,7 @@ import { AuthIdentity, AuthIdentityRepository, GLOBAL_ADMIN_ROLE, + GLOBAL_CHAT_USER_ROLE, GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type Role, @@ -42,13 +43,14 @@ async function handlePasswordSetup(password: string | null | undefined): Promise /** Store a new user object, defaulting to a `member` */ export async function newUser(attributes: DeepPartial = {}): Promise { - const { email, password, firstName, lastName, role, ...rest } = attributes; + const { email, password, firstName, lastName, role, lastActiveAt, ...rest } = attributes; return Container.get(UserRepository).create({ email: email ?? randomEmail(), password: await handlePasswordSetup(password), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), role: role ?? GLOBAL_MEMBER_ROLE, + lastActiveAt: lastActiveAt ?? new Date(), ...rest, }); } @@ -154,6 +156,10 @@ export async function createAdmin() { return await createUser({ role: GLOBAL_ADMIN_ROLE }); } +export async function createChatUser() { + return await createUser({ role: GLOBAL_CHAT_USER_ROLE }); +} + export async function createUserShell(role: Role): Promise { const shell: DeepPartial = { role }; diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 9b4e1d495be..d5afbe47696 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -48,7 +48,13 @@ type EndpointGroup = | 'third-party-licenses' | 'mcp'; -type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table' | 'mcp'; +type ModuleName = + | 'insights' + | 'external-secrets' + | 'community-packages' + | 'data-table' + | 'mcp' + | 'dynamic-credentials'; export interface SetupProps { endpointGroups?: EndpointGroup[]; diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index ce9e02457a8..8157e56f1e5 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -1,6 +1,6 @@ import type { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; -import { SettingsRepository, WorkflowEntity } from '@n8n/db'; +import { WorkflowEntity } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { @@ -25,7 +25,6 @@ import type { INodeTypeData, INode } from 'n8n-workflow'; import type request from 'supertest'; import { v4 as uuid } from 'uuid'; -import config from '@/config'; import { AUTH_COOKIE_NAME } from '@/constants'; import { ExecutionService } from '@/executions/execution.service'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; @@ -159,27 +158,6 @@ export function getAuthToken(response: request.Response, authCookieName = AUTH_C return match.groups.token; } -// ---------------------------------- -// settings -// ---------------------------------- - -export async function isInstanceOwnerSetUp() { - const { value } = await Container.get(SettingsRepository).findOneByOrFail({ - key: 'userManagement.isInstanceOwnerSetUp', - }); - - return Boolean(value); -} - -export const setInstanceOwnerSetUp = async (value: boolean) => { - config.set('userManagement.isInstanceOwnerSetUp', value); - - await Container.get(SettingsRepository).update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(value) }, - ); -}; - // ---------------------------------- // community nodes // ---------------------------------- diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 5a6a8ed9239..e10388f23d6 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -10,7 +10,6 @@ import request from 'supertest'; import { URL } from 'url'; import { AuthService } from '@/auth/auth.service'; -import config from '@/config'; import { AUTH_COOKIE_NAME } from '@/constants'; import { ControllerRegistry } from '@/controller.registry'; import { License } from '@/license'; @@ -129,7 +128,6 @@ export const setupTestServer = ({ await testDb.init(); Container.get(GlobalConfig).userManagement.jwtSecret = 'My JWT secret'; - config.set('userManagement.isInstanceOwnerSetUp', true); testServer.license.mock(Container.get(License)); testServer.license.mockLicenseState(Container.get(LicenseState)); diff --git a/packages/cli/test/integration/user.repository.test.ts b/packages/cli/test/integration/user.repository.test.ts index f4328ac3724..7a6f5f27d68 100644 --- a/packages/cli/test/integration/user.repository.test.ts +++ b/packages/cli/test/integration/user.repository.test.ts @@ -2,7 +2,7 @@ import { randomEmail, testDb } from '@n8n/backend-test-utils'; import { ProjectRelationRepository, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; -import { createAdmin, createMember, createOwner } from './shared/db/users'; +import { createAdmin, createChatUser, createMember, createOwner } from './shared/db/users'; describe('UserRepository', () => { let userRepository: UserRepository; @@ -28,6 +28,7 @@ describe('UserRepository', () => { createMember(), createMember(), createMember(), + createChatUser(), ]); const usersByRole = await userRepository.countUsersByRole(); @@ -36,6 +37,7 @@ describe('UserRepository', () => { 'global:admin': 2, 'global:member': 3, 'global:owner': 1, + 'global:chatUser': 1, }); }); }); @@ -59,5 +61,24 @@ describe('UserRepository', () => { expect(projectRelation.project.id).toBe(project.id); }); + + test('should create personal project for a chat user', async () => { + const { user, project } = await userRepository.createUserWithProject({ + email: randomEmail(), + role: { slug: 'global:chatUser' }, + }); + + const projectRelation = await Container.get(ProjectRelationRepository).findOneOrFail({ + where: { + userId: user.id, + project: { + type: 'personal', + }, + }, + relations: ['project'], + }); + + expect(projectRelation.project.id).toBe(project.id); + }); }); }); diff --git a/packages/cli/test/integration/workflow-history-manager.test.ts b/packages/cli/test/integration/workflow-history-manager.test.ts index 79e48454d83..91224957946 100644 --- a/packages/cli/test/integration/workflow-history-manager.test.ts +++ b/packages/cli/test/integration/workflow-history-manager.test.ts @@ -29,7 +29,7 @@ describe('Workflow History Manager', () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory', 'WorkflowPublishHistory']); jest.clearAllMocks(); globalConfig.workflowHistory.pruneTime = -1; diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index 6b8e5bcc479..804fc8cb271 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -27,7 +27,12 @@ beforeAll(async () => { }); beforeEach(async () => { - await testDb.truncate(['WorkflowEntity', 'SharedWorkflow']); + await testDb.truncate([ + 'WorkflowEntity', + 'SharedWorkflow', + 'WorkflowHistory', + 'WorkflowPublishHistory', + ]); }); describe('PUT /:workflowId/transfer', () => { diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 2c7bac53ea8..64351b9a98e 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -98,7 +98,13 @@ beforeEach(async () => { activeWorkflowManager.add.mockReset(); activeWorkflowManager.remove.mockReset(); - await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'WorkflowHistory', 'TagEntity']); + await testDb.truncate([ + 'WorkflowEntity', + 'SharedWorkflow', + 'WorkflowHistory', + 'WorkflowPublishHistory', + 'TagEntity', + ]); }); afterEach(() => { diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 5b424f93c9f..188477cb640 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -38,7 +38,7 @@ import { v4 as uuid } from 'uuid'; import { saveCredential } from '../shared/db/credentials'; import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles'; import { assignTagToWorkflow, createTag } from '../shared/db/tags'; -import { createManyUsers, createMember, createOwner } from '../shared/db/users'; +import { createChatUser, createManyUsers, createMember, createOwner } from '../shared/db/users'; import { createWorkflowHistoryItem } from '../shared/db/workflow-history'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; @@ -56,7 +56,6 @@ let anotherMember: User; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; - const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], enabledFeatures: ['feat:sharing'], @@ -84,6 +83,7 @@ beforeEach(async () => { 'Folder', 'WorkflowEntity', 'WorkflowHistory', + 'WorkflowPublishHistory', 'TagEntity', 'Project', 'User', @@ -171,6 +171,7 @@ describe('POST /workflows', () => { [ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'workflow:read', 'workflow:share', @@ -449,6 +450,29 @@ describe('POST /workflows', () => { }); }); + test('does not create the workflow in a personal project if the user is chat user', async () => { + // + // ARRANGE + // + const chatUser = await createChatUser(); + const workflow = makeWorkflow(); + + // + // ACT + // + await testServer + .authAgentFor(chatUser) + .post('/workflows') + .send({ ...workflow }) + // + // ASSERT + // + .expect(400, { + code: 400, + message: "You don't have the permissions to save the workflow in this project.", + }); + }); + test('create link workflow with folder if one is provided', async () => { // // ARRANGE @@ -797,6 +821,7 @@ describe('GET /workflows', () => { [ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'workflow:read', 'workflow:update', @@ -805,7 +830,9 @@ describe('GET /workflows', () => { // Shared workflow expect(wf2.id).toBe(savedWorkflow2.id); - expect(wf2.scopes).toEqual(['workflow:read', 'workflow:update', 'workflow:execute'].sort()); + expect(wf2.scopes).toEqual( + ['workflow:read', 'workflow:update', 'workflow:execute', 'workflow:execute-chat'].sort(), + ); } { @@ -823,6 +850,7 @@ describe('GET /workflows', () => { expect(wf1.scopes).toEqual([ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:read', 'workflow:update', ]); @@ -833,6 +861,7 @@ describe('GET /workflows', () => { [ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'workflow:read', 'workflow:share', @@ -858,6 +887,7 @@ describe('GET /workflows', () => { 'workflow:create', 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:list', 'workflow:move', 'workflow:read', @@ -873,6 +903,7 @@ describe('GET /workflows', () => { 'workflow:create', 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:list', 'workflow:move', 'workflow:read', @@ -1922,6 +1953,7 @@ describe('GET /workflows?includeFolders=true', () => { [ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'workflow:read', 'workflow:update', @@ -1930,7 +1962,9 @@ describe('GET /workflows?includeFolders=true', () => { // Shared workflow expect(wf2.id).toBe(savedWorkflow2.id); - expect(wf2.scopes).toEqual(['workflow:read', 'workflow:update', 'workflow:execute'].sort()); + expect(wf2.scopes).toEqual( + ['workflow:read', 'workflow:update', 'workflow:execute', 'workflow:execute-chat'].sort(), + ); expect(f1.id).toBe(savedFolder1.id); } @@ -1953,6 +1987,7 @@ describe('GET /workflows?includeFolders=true', () => { expect(wf1.scopes).toEqual([ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:read', 'workflow:update', ]); @@ -1963,6 +1998,7 @@ describe('GET /workflows?includeFolders=true', () => { [ 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:move', 'workflow:read', 'workflow:share', @@ -1993,6 +2029,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:create', 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:list', 'workflow:move', 'workflow:read', @@ -2008,6 +2045,7 @@ describe('GET /workflows?includeFolders=true', () => { 'workflow:create', 'workflow:delete', 'workflow:execute', + 'workflow:execute-chat', 'workflow:list', 'workflow:move', 'workflow:read', diff --git a/packages/core/src/__tests__/html-sandbox.test.ts b/packages/core/src/__tests__/html-sandbox.test.ts index def14bb261c..c65b15b6d8c 100644 --- a/packages/core/src/__tests__/html-sandbox.test.ts +++ b/packages/core/src/__tests__/html-sandbox.test.ts @@ -75,11 +75,15 @@ describe('isHtmlRenderedContentType', () => { }); }); + it('should handle content type with extra spaces', () => { + expect(isHtmlRenderedContentType(' text/html')).toBe(true); + expect(isHtmlRenderedContentType('text/html ')).toBe(true); + expect(isHtmlRenderedContentType(' text/html ')).toBe(true); + }); + it('should handle edge cases', () => { expect(isHtmlRenderedContentType('text/htmlsomething')).toBe(true); expect(isHtmlRenderedContentType('application/xhtml+xmlsomething')).toBe(true); - expect(isHtmlRenderedContentType(' text/html')).toBe(false); - expect(isHtmlRenderedContentType('text/html ')).toBe(true); }); }); diff --git a/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts b/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts index 16d7725f2e8..69f0643d70d 100644 --- a/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts +++ b/packages/core/src/execution-engine/__tests__/execution-context.service.test.ts @@ -297,8 +297,7 @@ describe('ExecutionContextService', () => { }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockImplementation((name: string) => { @@ -362,8 +361,7 @@ describe('ExecutionContextService', () => { }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(mockHook); mockCipher.decrypt.mockReturnValue('{}'); @@ -398,8 +396,7 @@ describe('ExecutionContextService', () => { mockHook2.execute.mockResolvedValue({ triggerItems: item3 }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockImplementation((name: string) => { @@ -433,8 +430,7 @@ describe('ExecutionContextService', () => { const startItem = createMockStartItem(hookConfig); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(undefined); mockCipher.decrypt.mockReturnValue('{}'); @@ -472,8 +468,7 @@ describe('ExecutionContextService', () => { }); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockImplementation((name: string) => { @@ -514,8 +509,7 @@ describe('ExecutionContextService', () => { mockHook.execute.mockRejectedValue(hookError); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(mockHook); mockCipher.decrypt.mockReturnValue('{}'); @@ -551,8 +545,7 @@ describe('ExecutionContextService', () => { mockHook.execute.mockResolvedValue({}); toExecutionContextEstablishmentHookParameter.mockReturnValue({ - success: true, - data: hookConfig, + data: { contextEstablishmentHooks: hookConfig }, }); mockRegistry.getHookByName.mockReturnValue(mockHook); mockCipher.decrypt.mockReturnValue('{"version":1,"identity":"decrypted"}'); diff --git a/packages/core/src/execution-engine/execution-context-hook-registry.service.ts b/packages/core/src/execution-engine/execution-context-hook-registry.service.ts index d79f2e74d9e..89901f9bb9a 100644 --- a/packages/core/src/execution-engine/execution-context-hook-registry.service.ts +++ b/packages/core/src/execution-engine/execution-context-hook-registry.service.ts @@ -97,8 +97,8 @@ export class ExecutionContextHookRegistry { * @returns Array of applicable hooks (may be empty) */ getHookForTriggerType(triggerType: string): IContextEstablishmentHook[] { - return Array.from(this.hookMap.values()).filter((hook) => - hook.isApplicableToTriggerNode(triggerType), - ); + return Array.from(this.hookMap.values()).filter((hook) => { + return hook.isApplicableToTriggerNode(triggerType); + }); } } diff --git a/packages/core/src/execution-engine/execution-context.service.ts b/packages/core/src/execution-engine/execution-context.service.ts index 798460eeab2..b8f9370c992 100644 --- a/packages/core/src/execution-engine/execution-context.service.ts +++ b/packages/core/src/execution-engine/execution-context.service.ts @@ -68,7 +68,10 @@ export class ExecutionContextService { let currentTriggerItems = startItem.data['main'][0]; - const contextEstablishmentHookParameters = startItem.node.parameters?.contextEstablishmentHooks; + const contextEstablishmentHookParameters = { + ...(workflow.getNode(startItem.node.name)?.parameters ?? {}), + ...startItem.node.parameters, + }; const startNodeParametersResult = toExecutionContextEstablishmentHookParameter( contextEstablishmentHookParameters, @@ -97,7 +100,7 @@ export class ExecutionContextService { // based on startNodeParameters, startNodeType and currentTriggerItems we can now // iterate over the different hooks to extract specific data for the runtime context - for (const hookParameters of startNodeParameters.hooks) { + for (const hookParameters of startNodeParameters.contextEstablishmentHooks.hooks) { const hook = this.executionContextHookRegistry.getHookByName(hookParameters.hookName); if (!hook) { diff --git a/packages/core/src/execution-engine/execution-context.ts b/packages/core/src/execution-engine/execution-context.ts index 1af786c74b4..b312af8e1fc 100644 --- a/packages/core/src/execution-engine/execution-context.ts +++ b/packages/core/src/execution-engine/execution-context.ts @@ -1,3 +1,4 @@ +import { Logger } from '@n8n/backend-common'; import { Container } from '@n8n/di'; import { type IWorkflowExecuteAdditionalData, @@ -175,16 +176,22 @@ export const establishExecutionContext = async ( // Call the execution context service to augment the context with any hook-based data const executionContextService = Container.get(ExecutionContextService); - const { context, triggerItems } = await executionContextService.augmentExecutionContextWithHooks( - workflow, - startItem, - executionData.runtimeData, - ); + try { + const { context, triggerItems } = + await executionContextService.augmentExecutionContextWithHooks( + workflow, + startItem, + executionData.runtimeData, + ); - executionData.runtimeData = context; + executionData.runtimeData = context; - // If the trigger items were modified by hooks, update the start item accordingly - if (triggerItems) { - startItem.data['main'][0] = triggerItems; + // If the trigger items were modified by hooks, update the start item accordingly + if (triggerItems) { + startItem.data['main'][0] = triggerItems; + } + } catch (error) { + // Log the error but proceed with the established context + Container.get(Logger).error('Failed to augment execution context with hooks.', { error }); } }; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts index 3692d3fd257..f5d8bf9921b 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts @@ -269,7 +269,7 @@ export const describeCommonTests = ( describe('executeWorkflow', () => { const data = [[{ json: { test: true } }]]; - const executeWorkflowData = mock(); + const executeWorkflowData = mock({ data }); const workflowInfo = mock(); const parentExecution: RelatedExecution = { executionId: 'parent_execution_id', @@ -278,23 +278,18 @@ export const describeCommonTests = ( it('should execute workflow and return data', async () => { additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData); - binaryDataService.duplicateBinaryData.mockResolvedValue(data); const result = await context.executeWorkflow(workflowInfo, undefined, undefined, { parentExecution, }); expect(result.data).toEqual(data); - expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith( - { type: 'execution', workflowId: workflow.id, executionId: additionalData.executionId }, - executeWorkflowData.data, - ); + expect(result).toBe(executeWorkflowData); }); it('should put execution to wait if waitTill is returned', async () => { const waitTill = new Date(); additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill }); - binaryDataService.duplicateBinaryData.mockResolvedValue(data); const result = await context.executeWorkflow(workflowInfo, undefined, undefined, { parentExecution, diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 63b58815885..e7feebf6895 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -1,4 +1,3 @@ -import { Container } from '@n8n/di'; import get from 'lodash/get'; import type { Workflow, @@ -33,14 +32,9 @@ import { createEnvProviderState, } from 'n8n-workflow'; -import { BinaryDataService } from '@/binary-data/binary-data.service'; -import { FileLocation } from '@/binary-data/utils'; - import { NodeExecutionContext } from './node-execution-context'; export class BaseExecuteContext extends NodeExecutionContext { - protected readonly binaryDataService = Container.get(BinaryDataService); - constructor( workflow: Workflow, node: INode, @@ -155,11 +149,7 @@ export class BaseExecuteContext extends NodeExecutionContext { await this.putExecutionToWait(WAIT_INDEFINITELY); } - const data = await this.binaryDataService.duplicateBinaryData( - FileLocation.ofExecution(this.workflow.id, this.additionalData.executionId!), - result.data, - ); - return { ...result, data }; + return result; } async getExecutionDataById(executionId: string): Promise { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts index 6710ff04fdf..6e1385e1987 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts @@ -1,7 +1,7 @@ import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; -import { createReadStream } from 'node:fs'; +import { constants, createReadStream } from 'node:fs'; import { access as fsAccess, realpath as fsRealpath } from 'node:fs/promises'; import { join } from 'node:path'; @@ -15,7 +15,7 @@ import { } from '@/constants'; import { InstanceSettings } from '@/instance-settings'; -import { getFileSystemHelperFunctions, isFilePathBlocked } from '../file-system-helper-functions'; +import { getFileSystemHelperFunctions } from '../file-system-helper-functions'; jest.mock('node:fs'); jest.mock('node:fs/promises'); @@ -39,78 +39,80 @@ beforeEach(() => { }); describe('isFilePathBlocked', () => { + const node = { type: 'TestNode' } as INode; + const { isFilePathBlocked, resolvePath } = getFileSystemHelperFunctions(node); beforeEach(() => { process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; }); it('should return true for static cache dir', async () => { const filePath = instanceSettings.staticCacheDir; - expect(await isFilePathBlocked(filePath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(filePath))).toBe(true); }); it('should return true for restricted paths', async () => { const restrictedPath = instanceSettings.n8nFolder; - expect(await isFilePathBlocked(restrictedPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(true); }); it('should handle empty allowed paths', async () => { securityConfig.restrictFileAccessTo = ''; - const result = await isFilePathBlocked('/some/random/path'); + const result = isFilePathBlocked(await resolvePath('/some/random/path')); expect(result).toBe(false); }); it('should handle multiple allowed paths', async () => { securityConfig.restrictFileAccessTo = '/path1;/path2;/path3'; const allowedPath = '/path2/somefile'; - expect(await isFilePathBlocked(allowedPath)).toBe(false); + expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false); }); it('should handle empty strings in allowed paths', async () => { securityConfig.restrictFileAccessTo = '/path1;;/path2'; const allowedPath = '/path2/somefile'; - expect(await isFilePathBlocked(allowedPath)).toBe(false); + expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false); }); it('should trim whitespace in allowed paths', async () => { securityConfig.restrictFileAccessTo = ' /path1 ; /path2 ; /path3 '; const allowedPath = '/path2/somefile'; - expect(await isFilePathBlocked(allowedPath)).toBe(false); + expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false); }); it('should return false when BLOCK_FILE_ACCESS_TO_N8N_FILES is false', async () => { process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'false'; const restrictedPath = instanceSettings.n8nFolder; - expect(await isFilePathBlocked(restrictedPath)).toBe(false); + expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(false); }); it('should return true when path is in allowed paths but still restricted', async () => { securityConfig.restrictFileAccessTo = '/some/allowed/path'; const restrictedPath = instanceSettings.n8nFolder; - expect(await isFilePathBlocked(restrictedPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(true); }); it('should return false when path is in allowed paths', async () => { const allowedPath = '/some/allowed/path'; securityConfig.restrictFileAccessTo = allowedPath; - expect(await isFilePathBlocked(allowedPath)).toBe(false); + expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(false); }); it('should return true when file paths in CONFIG_FILES', async () => { process.env[CONFIG_FILES] = '/path/to/config1,/path/to/config2'; const configPath = '/path/to/config1/somefile'; - expect(await isFilePathBlocked(configPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(configPath))).toBe(true); }); it('should return true when file paths in CUSTOM_EXTENSION_ENV', async () => { process.env[CUSTOM_EXTENSION_ENV] = '/path/to/extensions1;/path/to/extensions2'; const extensionPath = '/path/to/extensions1/somefile'; - expect(await isFilePathBlocked(extensionPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(extensionPath))).toBe(true); }); it('should return true when file paths in BINARY_DATA_STORAGE_PATH', async () => { process.env[BINARY_DATA_STORAGE_PATH] = '/path/to/binary/storage'; const binaryPath = '/path/to/binary/storage/somefile'; - expect(await isFilePathBlocked(binaryPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(binaryPath))).toBe(true); }); it('should block file paths in email template paths', async () => { @@ -120,8 +122,8 @@ describe('isFilePathBlocked', () => { const invitePath = '/path/to/invite/templates/invite.html'; const pwResetPath = '/path/to/pwreset/templates/reset.html'; - expect(await isFilePathBlocked(invitePath)).toBe(true); - expect(await isFilePathBlocked(pwResetPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(invitePath))).toBe(true); + expect(isFilePathBlocked(await resolvePath(pwResetPath))).toBe(true); }); it('should block access to n8n files if restrict and block are set', async () => { @@ -131,7 +133,7 @@ describe('isFilePathBlocked', () => { securityConfig.restrictFileAccessTo = userHome; process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; const restrictedPath = instanceSettings.n8nFolder; - expect(await isFilePathBlocked(restrictedPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(restrictedPath))).toBe(true); }); it('should allow access to parent folder if restrict and block are set', async () => { @@ -140,8 +142,8 @@ describe('isFilePathBlocked', () => { securityConfig.restrictFileAccessTo = userHome; process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; - const restrictedPath = join(userHome, 'somefile.txt'); - expect(await isFilePathBlocked(restrictedPath)).toBe(false); + const restrictedPath = await resolvePath(join(userHome, 'somefile.txt')); + expect(isFilePathBlocked(restrictedPath)).toBe(false); }); it('should not block similar paths', async () => { @@ -150,8 +152,8 @@ describe('isFilePathBlocked', () => { securityConfig.restrictFileAccessTo = userHome; process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; - const restrictedPath = join(userHome, '.n8n_x'); - expect(await isFilePathBlocked(restrictedPath)).toBe(false); + const restrictedPath = await resolvePath(join(userHome, '.n8n_x')); + expect(isFilePathBlocked(restrictedPath)).toBe(false); }); it('should return true for a symlink in a allowed path to a restricted path', async () => { @@ -161,7 +163,7 @@ describe('isFilePathBlocked', () => { (fsRealpath as jest.Mock).mockImplementation((path: string) => path === allowedPath ? actualPath : path, ); - expect(await isFilePathBlocked(allowedPath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(allowedPath))).toBe(true); }); it('should handle non-existent file when it is allowed', async () => { @@ -170,7 +172,7 @@ describe('isFilePathBlocked', () => { // @ts-expect-error undefined property error.code = 'ENOENT'; (fsRealpath as jest.Mock).mockRejectedValueOnce(error); - expect(await isFilePathBlocked(filePath)).toBe(false); + expect(isFilePathBlocked(await resolvePath(filePath))).toBe(false); }); it('should handle non-existent file when it is not allowed', async () => { @@ -181,7 +183,7 @@ describe('isFilePathBlocked', () => { // @ts-expect-error undefined property error.code = 'ENOENT'; (fsRealpath as jest.Mock).mockRejectedValueOnce(error); - expect(await isFilePathBlocked(filePath)).toBe(true); + expect(isFilePathBlocked(await resolvePath(filePath))).toBe(true); }); }); @@ -213,17 +215,17 @@ describe('getFileSystemHelperFunctions', () => { error.code = 'ENOENT'; (fsAccess as jest.Mock).mockRejectedValueOnce(error); - await expect(helperFunctions.createReadStream(filePath)).rejects.toThrow( - `The file "${filePath}" could not be accessed.`, - ); + await expect( + helperFunctions.createReadStream(await helperFunctions.resolvePath(filePath)), + ).rejects.toThrow(`The file "${filePath}" could not be accessed.`); }); it('should throw when file access is blocked', async () => { securityConfig.restrictFileAccessTo = '/allowed/path'; (fsAccess as jest.Mock).mockResolvedValueOnce({}); - await expect(helperFunctions.createReadStream('/blocked/path')).rejects.toThrow( - 'Access to the file is not allowed', - ); + await expect( + helperFunctions.createReadStream(await helperFunctions.resolvePath('/blocked/path')), + ).rejects.toThrow('Access to the file is not allowed'); }); it('should not reveal if file exists if it is within restricted path', async () => { @@ -234,16 +236,63 @@ describe('getFileSystemHelperFunctions', () => { error.code = 'ENOENT'; (fsAccess as jest.Mock).mockRejectedValueOnce(error); - await expect(helperFunctions.createReadStream('/blocked/path')).rejects.toThrow( - 'Access to the file is not allowed', - ); + await expect( + helperFunctions.createReadStream(await helperFunctions.resolvePath('/blocked/path')), + ).rejects.toThrow('Access to the file is not allowed'); }); it('should create a read stream if file access is permitted', async () => { const filePath = '/allowed/path'; (fsAccess as jest.Mock).mockResolvedValueOnce({}); - await helperFunctions.createReadStream(filePath); - expect(createReadStream).toHaveBeenCalledWith(filePath); + + // Mock createReadStream to return a proper stream-like object + const mockStream: { once: jest.Mock } = { + once: jest.fn((event: string, callback: (error?: Error) => void): typeof mockStream => { + if (event === 'open') { + // Immediately call the open callback + setImmediate(() => callback()); + } + return mockStream; + }), + }; + (createReadStream as jest.Mock).mockReturnValueOnce(mockStream); + + await helperFunctions.createReadStream(await helperFunctions.resolvePath(filePath)); + expect(createReadStream).toHaveBeenCalledWith( + filePath, + expect.objectContaining({ + flags: expect.any(Number), + }), + ); + }); + + it('should reject symlinks with O_NOFOLLOW to prevent TOCTOU attacks', async () => { + const filePath = '/allowed/path/file'; + + // Clear previous mocks and set up fresh mocks + (fsAccess as jest.Mock).mockReset(); + (fsAccess as jest.Mock).mockResolvedValue(undefined); + + // Simulate the ELOOP error that occurs when O_NOFOLLOW encounters a symlink + const eloopError = new Error('ELOOP: too many symbolic links encountered'); + // @ts-expect-error undefined property + eloopError.code = 'ELOOP'; + + // Mock createReadStream to return a stream that emits an error event + const mockStream: { once: jest.Mock } = { + once: jest.fn((event: string, callback: (error?: Error) => void): typeof mockStream => { + if (event === 'error') { + // Emit the error asynchronously + setImmediate(() => callback(eloopError)); + } + return mockStream; + }), + }; + (createReadStream as jest.Mock).mockReturnValueOnce(mockStream); + + await expect( + helperFunctions.createReadStream(await helperFunctions.resolvePath(filePath)), + ).rejects.toThrow('ELOOP: too many symbolic links encountered'); }); }); @@ -253,9 +302,9 @@ describe('getFileSystemHelperFunctions', () => { await expect( helperFunctions.writeContentToFile( - instanceSettings.n8nFolder + '/test.txt', + await helperFunctions.resolvePath(instanceSettings.n8nFolder + '/test.txt'), 'content', - 'w', + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, ), ).rejects.toThrow('not writable'); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts index 24c0c27360d..b4ad30bcdd7 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts @@ -1,9 +1,10 @@ import { isContainedWithin, safeJoinPath } from '@n8n/backend-common'; import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; -import type { FileSystemHelperFunctions, INode } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; -import { createReadStream } from 'node:fs'; +import type { FileSystemHelperFunctions, INode, ResolvedFilePath } from 'n8n-workflow'; +import type { PathLike } from 'node:fs'; +import { constants, createReadStream } from 'node:fs'; import { access as fsAccess, writeFile as fsWriteFile, @@ -35,18 +36,19 @@ const getAllowedPaths = () => { return allowedPaths; }; -export async function isFilePathBlocked(filePath: string): Promise { - const allowedPaths = getAllowedPaths(); - let resolvedFilePath = ''; +async function resolvePath(path: PathLike): Promise { try { - resolvedFilePath = await fsRealpath(filePath); + return (await fsRealpath(path)) as ResolvedFilePath; // apply brand, since we know it's resolved now } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - resolvedFilePath = resolve(filePath); - } else { - throw error; + return resolve(path.toString()) as ResolvedFilePath; // apply brand, since we know it's resolved now } + throw error; } +} + +function isFilePathBlocked(resolvedFilePath: ResolvedFilePath): boolean { + const allowedPaths = getAllowedPaths(); const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false'; const restrictedPaths = blockFileAccessToN8nFiles ? getN8nRestrictedPaths() : []; @@ -64,8 +66,8 @@ export async function isFilePathBlocked(filePath: string): Promise { } export const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => ({ - async createReadStream(filePath) { - if (await isFilePathBlocked(filePath.toString())) { + async createReadStream(resolvedFilePath) { + if (isFilePathBlocked(resolvedFilePath)) { const allowedPaths = getAllowedPaths(); const message = allowedPaths.length ? ` Allowed paths: ${allowedPaths.join(', ')}` : ''; throw new NodeOperationError(node, `Access to the file is not allowed.${message}`, { @@ -74,34 +76,61 @@ export const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunct } try { - await fsAccess(filePath); + await fsAccess(resolvedFilePath); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access throw error.code === 'ENOENT' ? // eslint-disable-next-line @typescript-eslint/no-unsafe-argument new NodeOperationError(node, error, { - message: `The file "${String(filePath)}" could not be accessed.`, + message: `The file "${String(resolvedFilePath)}" could not be accessed.`, level: 'warning', }) : error; } - return createReadStream(filePath); + // Use O_NOFOLLOW to prevent createReadStream from following symlinks. We require that the path + // already be resolved beforehand. + const stream = createReadStream(resolvedFilePath, { + flags: (constants.O_RDONLY | constants.O_NOFOLLOW) as unknown as string, + }); + + return await new Promise>((resolve, reject) => { + stream.once('error', (error) => { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + reject( + new NodeOperationError(node, error, { + level: 'warning', + description: 'Symlinks are not allowed.', + }), + ); + } else { + reject(error); + } + }); + stream.once('open', () => resolve(stream)); + }); }, getStoragePath() { return safeJoinPath(Container.get(InstanceSettings).n8nFolder, `storage/${node.type}`); }, - async writeContentToFile(filePath, content, flag) { - if (await isFilePathBlocked(filePath as string)) { - throw new NodeOperationError(node, `The file "${String(filePath)}" is not writable.`, { - level: 'warning', - }); + async writeContentToFile(resolvedFilePath, content, flag) { + if (isFilePathBlocked(resolvedFilePath)) { + throw new NodeOperationError( + node, + `The file "${String(resolvedFilePath)}" is not writable.`, + { + level: 'warning', + }, + ); } - return await fsWriteFile(filePath, content, { encoding: 'binary', flag }); + return await fsWriteFile(resolvedFilePath, content, { + encoding: 'binary', + flag: (flag ?? 0) | constants.O_NOFOLLOW, + }); }, - + resolvePath, isFilePathBlocked, }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts index 30da6e563be..788cc7e6c8e 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts @@ -624,5 +624,46 @@ describe('findStartNodes', () => { expect(startNodes.size).toBe(1); expect(startNodes).toContainEqual(afterLoop); }); + + // done (empty) + // ┌────► + // ┌─────────┐1 ┌────┐1 │ + // │ trigger ┼──►loop┼─┬┘ ►► + // └─────────┘ └────┘ │ ┌────────┐ + // └─►│in loop │ + // └────────┘ + test('if a loop node does not actually form a loop in the graph, it uses loop output instead of done output', () => { + // ARRANGE + const trigger = createNodeData({ name: 'trigger' }); + const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' }); + const inLoop = createNodeData({ name: 'inLoop' }); + const graph = new DirectedGraph().addNodes(trigger, loop, inLoop).addConnections( + { from: trigger, to: loop }, + // Note: loop connects to inLoop via output 1, but there's no connection + // back to loop, so it's not actually a loop + { from: loop, outputIndex: 1, to: inLoop }, + ); + const runData: IRunData = { + [trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])], + // The loop node has data on output 1 (the first output), but not on output 0 (done) + [loop.name]: [toITaskData([{ outputIndex: 1, data: { name: 'loop' } }])], + }; + + // ACT + const startNodes = findStartNodes({ + graph, + trigger, + destination: inLoop, + runData, + pinData: {}, + }); + + // ASSERT + // Because the loop node doesn't form an actual loop, it should check output 1 + // for run data (not output 0). Since output 1 has data, the loop node should + // not be a start node, and we should continue to inLoop. + expect(startNodes.size).toBe(1); + expect(startNodes).toContainEqual(inLoop); + }); }); }); diff --git a/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts b/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts index 7b9a5f424ff..cb4e1b11083 100644 --- a/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts @@ -83,7 +83,11 @@ function findStartNodesRecursive( // last run -1, NodeConnectionTypes.Main, - 0, + // Although this is a Loop node, the graph may not actually have a loop here e.g., + // while the workflow is under development. If there's not a loop, we treat the loop + // node as a normal node and take the data from the first output at index 1. + // If there *is* a loop, we take the data from the `done` output at index 0. + isALoop(graph, current) ? 0 : 1, ); if (nodeRunData === null || nodeRunData.length === 0) { @@ -130,6 +134,10 @@ function findStartNodesRecursive( return startNodes; } +function isALoop(graph: DirectedGraph, node: INode): boolean { + return graph.getChildren(node).has(node); +} + /** * The start node is the node from which a partial execution starts. The start * node will be executed or re-executed. diff --git a/packages/core/src/html-sandbox.ts b/packages/core/src/html-sandbox.ts index 8c52e8692d1..dcaa0d95c51 100644 --- a/packages/core/src/html-sandbox.ts +++ b/packages/core/src/html-sandbox.ts @@ -17,7 +17,7 @@ export const getWebhookSandboxCSP = (): string => { * as HTML. */ export const isHtmlRenderedContentType = (contentType: string) => { - const contentTypeLower = contentType.toLowerCase(); + const contentTypeLower = contentType.trim().toLowerCase(); return ( // The content-type can also contain a charset, e.g. "text/html; charset=utf-8" diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts index 27140126268..b9c29ca8f54 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts @@ -289,13 +289,20 @@ describe('AskAssistantChat', () => { // We can verify the component receives the prop via snapshot }); - describe('collapseToolMessages', () => { + describe('groupToolMessagesIntoThinking', () => { + // Tool messages are now grouped into thinking-group messages and rendered by ThinkingMessage component + // instead of MessageWrapper. These tests verify the grouping behavior. + + const ThinkingMessageMock = vi.fn(() => ({ + template: '
', + })); const MessageWrapperMock = vi.fn(() => ({ template: '
', })); - const stubsWithCustomMessageWrapper = { + const stubsWithMocks = { ...Object.fromEntries(stubs.map((stub) => [stub, true])), MessageWrapper: MessageWrapperMock, + ThinkingMessage: ThinkingMessageMock, }; const createToolMessage = ( @@ -313,8 +320,9 @@ describe('AskAssistantChat', () => { const renderWithMessages = (messages: ChatUI.AssistantMessage[], extraProps = {}) => { MessageWrapperMock.mockClear(); + ThinkingMessageMock.mockClear(); return render(AskAssistantChat, { - global: { stubs: stubsWithCustomMessageWrapper }, + global: { stubs: stubsWithMocks }, props: { user: { firstName: 'Kobi', lastName: 'Dog' }, messages, @@ -325,10 +333,11 @@ describe('AskAssistantChat', () => { const renderWithDirectives = (messages: ChatUI.AssistantMessage[], extraProps = {}) => { MessageWrapperMock.mockClear(); + ThinkingMessageMock.mockClear(); return render(AskAssistantChat, { global: { directives: { n8nHtml }, - stubs: stubsWithCustomMessageWrapper, + stubs: stubsWithMocks, }, props: { user: { firstName: 'Kobi', lastName: 'Dog' }, @@ -338,24 +347,19 @@ describe('AskAssistantChat', () => { }); }; + const getThinkingMessageProps = (callIndex = 0) => { + const mockCall = ThinkingMessageMock.mock.calls[callIndex]; + expect(mockCall).toBeDefined(); + return mockCall as unknown as [{ items: ChatUI.ThinkingItem[]; latestStatusText: string }]; + }; + const getMessageWrapperProps = (callIndex = 0): MessageWrapperProps => { const mockCall = MessageWrapperMock.mock.calls[callIndex]; expect(mockCall).toBeDefined(); return (mockCall as unknown as [props: MessageWrapperProps])[0]; }; - const expectMessageWrapperCalledTimes = (times: number) => { - expect(MessageWrapperMock).toHaveBeenCalledTimes(times); - }; - - const expectToolMessage = ( - props: MessageWrapperProps, - expectedProps: Partial, - ) => { - expect(props.message).toEqual(expect.objectContaining(expectedProps)); - }; - - it('should not collapse single tool message', () => { + it('should group single tool message into thinking-group', () => { const message = createToolMessage({ id: '1', displayTitle: 'Search Results', @@ -364,16 +368,16 @@ describe('AskAssistantChat', () => { renderWithMessages([message]); - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); + // Tool messages are rendered as ThinkingMessage, not MessageWrapper + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); + expect(MessageWrapperMock).toHaveBeenCalledTimes(0); - expectToolMessage(props, { - ...message, - read: true, - }); + const props = getThinkingMessageProps()[0]; + expect(props.items).toHaveLength(1); + expect(props.items[0].displayTitle).toBe('Search Results'); }); - it('should collapse consecutive tool messages with same toolName', () => { + it('should group consecutive tool messages with same toolName into single thinking-group', () => { const messages = [ createToolMessage({ id: '1', @@ -398,27 +402,16 @@ describe('AskAssistantChat', () => { renderWithMessages(messages); - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); + // All tool messages with same toolName should be grouped into one thinking-group + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); + expect(MessageWrapperMock).toHaveBeenCalledTimes(0); - expectToolMessage(props, { - id: '3', - role: 'assistant', - type: 'tool', - toolName: 'search', - status: 'running', - displayTitle: 'Still searching...', - customDisplayTitle: 'Custom Search Title', - updates: [ - { type: 'progress', data: { status: 'Initializing search' } }, - { type: 'progress', data: { status: 'Processing results' } }, - { type: 'output', data: { result: 'Found 10 items' } }, - ], - read: true, - }); + const props = getThinkingMessageProps()[0]; + // Should have 1 item after deduplication by toolName + expect(props.items).toHaveLength(1); }); - it('should collapse tool messages with same toolName with hidden messages in between', () => { + it('should group tool messages with same toolName even with hidden messages in between', () => { const messages: Array = [ createToolMessage({ id: '1', @@ -439,19 +432,6 @@ describe('AskAssistantChat', () => { ], read: true, }, - createToolMessage({ - id: '2', - status: 'running', - displayTitle: 'Still searching...', - customDisplayTitle: 'Custom Search Title', - updates: [{ type: 'progress', data: { status: 'Processing results' } }], - }), - { - id: 'test', - role: 'assistant', - type: 'workflow-updated', - codeSnippet: '', - }, createToolMessage({ id: '3', status: 'completed', @@ -462,27 +442,12 @@ describe('AskAssistantChat', () => { renderWithMessages(messages); - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); - - expectToolMessage(props, { - id: '3', - role: 'assistant', - type: 'tool', - toolName: 'search', - status: 'running', - displayTitle: 'Still searching...', - customDisplayTitle: 'Custom Search Title', - updates: [ - { type: 'progress', data: { status: 'Initializing search' } }, - { type: 'progress', data: { status: 'Processing results' } }, - { type: 'output', data: { result: 'Found 10 items' } }, - ], - read: true, - }); + // Hidden messages are filtered out, so tool messages are grouped together + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); + expect(MessageWrapperMock).toHaveBeenCalledTimes(0); }); - it('should not collapse tool messages with different toolNames', () => { + it('should show different tools as separate items in thinking-group', () => { const messages = [ createToolMessage({ id: '1', @@ -500,113 +465,15 @@ describe('AskAssistantChat', () => { renderWithMessages(messages); - expectMessageWrapperCalledTimes(2); + // Both tools should be in the same thinking-group but as separate items + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); + expect(MessageWrapperMock).toHaveBeenCalledTimes(0); - const firstProps = getMessageWrapperProps(0); - expectToolMessage(firstProps, { - id: '1', - toolName: 'search', - status: 'completed', - displayTitle: 'Search Results', - }); - - const secondProps = getMessageWrapperProps(1); - expectToolMessage(secondProps, { - id: '2', - toolName: 'fetch', - status: 'completed', - displayTitle: 'Data Fetched', - }); + const props = getThinkingMessageProps()[0]; + expect(props.items).toHaveLength(2); }); - it('should collapse completed and error statuses', () => { - const messages = [ - createToolMessage({ - id: '1', - status: 'completed', - displayTitle: 'Search Complete', - updates: [{ type: 'output', data: { result: 'Found some items' } }], - }), - createToolMessage({ - id: '2', - status: 'error', - displayTitle: 'Search error', - customDisplayTitle: 'Custom Running Title', - updates: [{ type: 'progress', data: { status: 'Processing more results' } }], - }), - createToolMessage({ - id: '3', - status: 'completed', - displayTitle: 'Final Search Complete', - updates: [{ type: 'output', data: { result: 'All done' } }], - }), - ]; - - renderWithMessages(messages); - - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); - - expectToolMessage(props, { - id: '3', - status: 'error', - displayTitle: 'Search error', - customDisplayTitle: undefined, - updates: [ - { type: 'output', data: { result: 'Found some items' } }, - { type: 'progress', data: { status: 'Processing more results' } }, - { type: 'output', data: { result: 'All done' } }, - ], - }); - }); - - it('should collapse running, completed and error statuses into running', () => { - const messages = [ - createToolMessage({ - id: '1', - status: 'running', - displayTitle: 'Search Running', - customDisplayTitle: 'Custom Search Title', - updates: [{ type: 'output', data: { result: 'Found some items' } }], - }), - createToolMessage({ - id: '2', - status: 'error', - displayTitle: 'Search error', - customDisplayTitle: 'Custom Error Title', - updates: [{ type: 'progress', data: { status: 'Processing more results' } }], - }), - createToolMessage({ - id: '3', - status: 'completed', - displayTitle: 'Final Search Complete', - updates: [{ type: 'output', data: { result: 'All done' } }], - }), - ]; - - renderWithMessages(messages); - - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); - - expectToolMessage(props, { - id: '3', - role: 'assistant', - type: 'tool', - toolName: 'search', - status: 'running', - displayTitle: 'Search Running', - customDisplayTitle: 'Custom Search Title', - updates: [ - { type: 'output', data: { result: 'Found some items' } }, - { type: 'progress', data: { status: 'Processing more results' } }, - { type: 'output', data: { result: 'All done' } }, - ], - read: true, - }); - }); - - it('should preserve running status when collapsing messages with running status', () => { + it('should show running status when there is a running tool', () => { const messages = [ createToolMessage({ id: '1', @@ -617,74 +484,20 @@ describe('AskAssistantChat', () => { createToolMessage({ id: '2', status: 'running', - displayTitle: 'Still searching...', - customDisplayTitle: 'Custom Running Title', + displayTitle: 'Fetching data...', + toolName: 'fetch', updates: [{ type: 'progress', data: { status: 'Processing more results' } }], }), - createToolMessage({ - id: '3', - status: 'completed', - displayTitle: 'Final Search Complete', - updates: [{ type: 'output', data: { result: 'All done' } }], - }), ]; renderWithMessages(messages); - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); - - expectToolMessage(props, { - id: '3', - status: 'running', - displayTitle: 'Still searching...', - customDisplayTitle: 'Custom Running Title', - updates: [ - { type: 'output', data: { result: 'Found some items' } }, - { type: 'progress', data: { status: 'Processing more results' } }, - { type: 'output', data: { result: 'All done' } }, - ], - }); + // Should render as ThinkingMessage with running tool + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); + expect(MessageWrapperMock).toHaveBeenCalledTimes(0); }); - it('should combine all updates from collapsed messages', () => { - const messages = [ - createToolMessage({ - id: '1', - status: 'running', - displayTitle: 'Searching...', - updates: [ - { type: 'progress', data: { status: 'Starting search' } }, - { type: 'input', data: { query: 'test query' } }, - ], - }), - createToolMessage({ - id: '2', - status: 'completed', - displayTitle: 'Search Complete', - updates: [ - { type: 'progress', data: { status: 'Processing results' } }, - { type: 'output', data: { result: 'Found 10 items' } }, - ], - }), - ]; - - renderWithMessages(messages); - - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); - - const toolMessage = props.message as ChatUI.ToolMessage; - expect(toolMessage.status).toEqual('running'); - expect(toolMessage.updates).toEqual([ - { type: 'progress', data: { status: 'Starting search' } }, - { type: 'input', data: { query: 'test query' } }, - { type: 'progress', data: { status: 'Processing results' } }, - { type: 'output', data: { result: 'Found 10 items' } }, - ]); - }); - - it('should not collapse tool messages separated by non-tool messages', () => { + it('should not group tool messages separated by visible non-tool messages', () => { const messages = [ createToolMessage({ id: '1', @@ -708,62 +521,18 @@ describe('AskAssistantChat', () => { renderWithDirectives(messages); - expectMessageWrapperCalledTimes(3); + // Should have 2 thinking-groups and 1 text message + expect(ThinkingMessageMock).toHaveBeenCalledTimes(2); + expect(MessageWrapperMock).toHaveBeenCalledTimes(1); - const firstProps = getMessageWrapperProps(0); - expectToolMessage(firstProps, { - id: '1', - type: 'tool', - toolName: 'search', - displayTitle: 'First Search', - }); - - const secondProps = getMessageWrapperProps(1); - expect(secondProps.message).toEqual( + const textMessageProps = getMessageWrapperProps(0); + expect(textMessageProps.message).toEqual( expect.objectContaining({ id: '2', type: 'text', content: 'Here are the search results', }), ); - - const thirdProps = getMessageWrapperProps(2); - expectToolMessage(thirdProps, { - id: '3', - type: 'tool', - toolName: 'search', - displayTitle: 'Second Search', - }); - }); - - it('should handle customDisplayTitle correctly for running status', () => { - const messages = [ - createToolMessage({ - id: '1', - status: 'completed', - displayTitle: 'Search Complete', - customDisplayTitle: 'Should be ignored for completed', - updates: [{ type: 'output', data: { result: 'Found items' } }], - }), - createToolMessage({ - id: '2', - status: 'running', - displayTitle: 'Searching...', - customDisplayTitle: 'Custom Running Title', - updates: [{ type: 'progress', data: { status: 'In progress' } }], - }), - ]; - - renderWithMessages(messages); - - expectMessageWrapperCalledTimes(1); - const props = getMessageWrapperProps(); - - expectToolMessage(props, { - status: 'running', - displayTitle: 'Searching...', - customDisplayTitle: 'Custom Running Title', - }); }); it('should handle mixed message types correctly', () => { @@ -796,7 +565,9 @@ describe('AskAssistantChat', () => { renderWithDirectives(messages); - expectMessageWrapperCalledTimes(3); + // 2 text messages via MessageWrapper, 1 thinking-group via ThinkingMessage + expect(MessageWrapperMock).toHaveBeenCalledTimes(2); + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); const firstProps = getMessageWrapperProps(0); expect(firstProps.message).toEqual( @@ -809,20 +580,7 @@ describe('AskAssistantChat', () => { ); const secondProps = getMessageWrapperProps(1); - expectToolMessage(secondProps, { - id: '3', - role: 'assistant', - type: 'tool', - toolName: 'search', - status: 'running', - updates: [ - { type: 'progress', data: { status: 'Starting' } }, - { type: 'output', data: { result: 'Found results' } }, - ], - }); - - const thirdProps = getMessageWrapperProps(2); - expect(thirdProps.message).toEqual( + expect(secondProps.message).toEqual( expect.objectContaining({ id: '4', role: 'assistant', @@ -831,6 +589,18 @@ describe('AskAssistantChat', () => { }), ); }); + + it('should show initial thinking-group when streaming with no tool messages', () => { + renderWithMessages([], { streaming: true, loadingMessage: 'Thinking...' }); + + // Should create an initial thinking-group + expect(ThinkingMessageMock).toHaveBeenCalledTimes(1); + + const props = getThinkingMessageProps()[0]; + expect(props.items).toHaveLength(1); + expect(props.items[0].displayTitle).toBe('Thinking...'); + expect(props.items[0].status).toBe('running'); + }); }); describe('Quick Replies', () => { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 139e597d018..b2d35f4c157 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -2,9 +2,10 @@ import { computed, nextTick, onUnmounted, ref, useCssModule, watch } from 'vue'; import MessageWrapper from './messages/MessageWrapper.vue'; +import ThinkingMessage from './messages/ThinkingMessage.vue'; import { useI18n } from '../../composables/useI18n'; import type { ChatUI, RatingFeedback, WorkflowSuggestion } from '../../types/assistant'; -import { isTaskAbortedMessage, isToolMessage } from '../../types/assistant'; +import { isTaskAbortedMessage, isToolMessage, isThinkingGroupMessage } from '../../types/assistant'; import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue'; import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue'; import AssistantText from '../AskAssistantText/AssistantText.vue'; @@ -17,6 +18,7 @@ import N8nScrollArea from '../N8nScrollArea/N8nScrollArea.vue'; import { getSupportedMessageComponent } from './messages/helpers'; const { t } = useI18n(); +const $style = useCssModule(); interface Props { user?: { @@ -78,79 +80,147 @@ function filterOutHiddenMessages(messages: ChatUI.AssistantMessage[]): ChatUI.As ); } -function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { +function groupToolMessagesIntoThinking( + messages: ChatUI.AssistantMessage[], + options: { streaming?: boolean; loadingMessage?: string } = {}, +): ChatUI.AssistantMessage[] { const result: ChatUI.AssistantMessage[] = []; let i = 0; while (i < messages.length) { const currentMsg = messages[i]; - // If it's not a tool message, add it as-is and continue + // If it's not a tool message, add it as-is if (!isToolMessage(currentMsg)) { result.push(currentMsg); i++; continue; } - // Collect consecutive tool messages with the same toolName - const toolMessagesGroup = [currentMsg]; + // Collect ALL consecutive tool messages + const toolGroup: ChatUI.ToolMessage[] = [currentMsg]; let j = i + 1; while (j < messages.length) { - const nextMsg = messages[j]; - if (isToolMessage(nextMsg) && nextMsg.toolName === currentMsg.toolName) { - toolMessagesGroup.push(nextMsg); - j++; - } else { - break; - } + const msg = messages[j]; + if (!isToolMessage(msg)) break; + toolGroup.push(msg); + j++; } - // If we have multiple tool messages with the same toolName, collapse them - if (toolMessagesGroup.length > 1) { - // Determine the status to show based on priority rules - const lastMessage = toolMessagesGroup[toolMessagesGroup.length - 1]; - let titleSource = lastMessage; + // Deduplicate tool messages by toolName, keeping the latest status for each unique tool type + // This matches the original behavior where multiple calls to the same tool (e.g., get_node_details) + // are collapsed into a single entry showing the most recent status + const uniqueToolsMap = new Map(); + for (const tool of toolGroup) { + // Group by toolName so multiple calls to the same tool are collapsed + const key = tool.toolName; + // Later messages in the array have the most recent status, so they overwrite earlier ones + uniqueToolsMap.set(key, tool); + } + const uniqueTools = Array.from(uniqueToolsMap.values()); - // Check if we have running messages - if so, show the last running one and use its titles - const runningMessages = toolMessagesGroup.filter((msg) => msg.status === 'running'); - const errorMessage = toolMessagesGroup.find((msg) => msg.status === 'error'); - if (runningMessages.length > 0) { - const lastRunning = runningMessages[runningMessages.length - 1]; - titleSource = lastRunning; - } else if (errorMessage) { - titleSource = errorMessage; - } + // Check if this is the last group of tools in the messages + const isLastToolGroup = j >= messages.length; + const allToolsCompleted = uniqueTools.every((m) => m.status === 'completed'); + const hasRunningTool = uniqueTools.some((m) => m.status === 'running'); - // Combine all updates from all messages in the group - const combinedUpdates = toolMessagesGroup.flatMap((msg) => msg.updates || []); + // Build the items array - use toolName as id since we dedupe by toolName + const items: ChatUI.ThinkingItem[] = uniqueTools.map((m) => ({ + id: `tool-${m.toolName}`, + displayTitle: + m.customDisplayTitle || + m.displayTitle || + m.toolName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '), + status: m.status, + })); - // Create collapsed message using last message as base, but with titles from titleSource - const collapsedMessage: ChatUI.ToolMessage = { - ...lastMessage, - displayTitle: titleSource.displayTitle, - customDisplayTitle: - titleSource.status === 'running' ? titleSource.customDisplayTitle : undefined, - status: titleSource.status, - updates: combinedUpdates, - }; + // If this is the last group, all tools completed, and we're still streaming, + // add a "Thinking..." item to show the AI is processing + if (isLastToolGroup && allToolsCompleted && options.streaming && options.loadingMessage) { + items.push({ + id: 'thinking-item', + displayTitle: options.loadingMessage, + status: 'running', + }); + } - result.push(collapsedMessage); + // Determine the latest status text - prioritize running tools, then thinking state, then completed + const runningTool = uniqueTools.find((m) => m.status === 'running'); + let latestStatus: string; + + if (hasRunningTool) { + latestStatus = + runningTool?.customDisplayTitle || + runningTool?.displayTitle || + runningTool?.toolName + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') || + 'Processing...'; + } else if ( + isLastToolGroup && + allToolsCompleted && + options.streaming && + options.loadingMessage + ) { + // Still streaming after tools completed - show thinking message + latestStatus = options.loadingMessage; + } else if (allToolsCompleted) { + // All tools completed and not streaming - show "Workflow generated" + latestStatus = 'Workflow generated'; } else { - // Single tool message, add as-is - result.push(currentMsg); + latestStatus = 'Processing...'; } + // Create a ThinkingGroup message with deduplicated items + // Use a stable ID so Vue preserves component state when items update + const thinkingGroup: ChatUI.ThinkingGroupMessage = { + id: 'thinking-group', + role: 'assistant', + type: 'thinking-group', + items, + latestStatusText: latestStatus, + }; + + result.push(thinkingGroup); i = j; } + // If streaming with a loadingMessage but no thinking-group exists yet (no tool messages received), + // create an initial thinking-group with just the "Thinking..." item + // Use the same stable ID as tool-based thinking-groups so Vue preserves component state + const hasThinkingGroup = result.some((msg) => msg.type === 'thinking-group'); + if (options.streaming && options.loadingMessage && !hasThinkingGroup) { + const initialThinkingGroup: ChatUI.ThinkingGroupMessage = { + id: 'thinking-group', + role: 'assistant', + type: 'thinking-group', + items: [ + { + id: 'thinking-item', + displayTitle: options.loadingMessage, + status: 'running', + }, + ], + latestStatusText: options.loadingMessage, + }; + result.push(initialThinkingGroup); + } + return result; } -// Ensure all messages have required id and read properties, and collapse tool messages +// Ensure all messages have required id and read properties, and group tool messages into thinking blocks const normalizedMessages = computed(() => { const normalized = normalizeMessages(props.messages); - return collapseToolMessages(filterOutHiddenMessages(normalized)); + return groupToolMessagesIntoThinking(filterOutHiddenMessages(normalized), { + streaming: props.streaming, + loadingMessage: props.loadingMessage, + }); }); // Get quickReplies from the last message in the original messages (before filtering) @@ -186,6 +256,12 @@ const showSuggestions = computed(() => { return showPlaceholder.value && props.suggestions && props.suggestions.length > 0; }); +// Check if we have any thinking group - hides the generic loading message when tool status is shown +// The ThinkingMessage component handles displaying the current status with shimmer animation +const hasAnyThinkingGroup = computed(() => { + return normalizedMessages.value.some((msg) => msg.type === 'thinking-group'); +}); + const showBottomInput = computed(() => { // Hide bottom input when showing suggestions (blank state with suggestions) return !showSuggestions.value; @@ -272,16 +348,15 @@ onUnmounted(() => { }); function getMessageStyles(message: ChatUI.AssistantMessage, messageCount: number) { - const $style = useCssModule(); + const isToolOrThinking = message.type === 'tool' || message.type === 'thinking-group'; + const nextMsg = normalizedMessages.value[messageCount + 1]; + const nextIsToolOrThinking = nextMsg?.type === 'tool' || nextMsg?.type === 'thinking-group'; + return { - [$style.firstToolMessage]: - message.type === 'tool' && - (messageCount === 0 || normalizedMessages.value[messageCount - 1].type !== 'tool'), [$style.lastToolMessage]: - message.type === 'tool' && + isToolOrThinking && ((messageCount === normalizedMessages.value.length - 1 && !props.loadingMessage) || - (messageCount < normalizedMessages.value.length - 1 && - normalizedMessages.value[messageCount + 1]?.type !== 'tool')), + (messageCount < normalizedMessages.value.length - 1 && !nextIsToolOrThinking)), }; } @@ -333,7 +408,18 @@ defineExpose({ message.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user' " > + + + +
@@ -416,6 +499,7 @@ defineExpose({ :max-length="maxCharacterLength" :min-lines="2" data-test-id="chat-suggestions-input" + autofocus @upgrade-click="emit('upgrade-click')" @submit="onSendMessage" @stop="emit('stop')" @@ -466,6 +550,7 @@ defineExpose({ :max-length="maxCharacterLength" :refocus-after-send="true" data-test-id="chat-input" + autofocus @upgrade-click="emit('upgrade-click')" @submit="onSendMessage" @stop="emit('stop')" @@ -513,6 +598,7 @@ defineExpose({ border-top: 0; border-bottom: 0; position: relative; + line-height: var(--line-height--xl); pre, code { @@ -548,20 +634,21 @@ defineExpose({ .messagesContent { padding: var(--spacing--xs); padding-bottom: var(--spacing--xl); // Extra padding for fade area + + // Override p line-height from reset.scss (1.8) to use chat standard (1.5) + :global(p) { + line-height: var(--line-height--xl); + } } .message { margin-bottom: var(--spacing--sm); - font-size: var(--font-size--2xs); + font-size: var(--font-size--sm); line-height: var(--line-height--xl); } -.firstToolMessage { - margin-top: var(--spacing--md); -} - .lastToolMessage { - margin-bottom: var(--spacing--lg); + margin-bottom: var(--spacing--sm); } .chatTitle { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap index a70b1db3a10..08f8d6fb67a 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/__snapshots__/AskAssistantChat.test.ts.snap @@ -86,6 +86,7 @@ exports[`AskAssistantChat > limits maximum input length when maxCharacterLength data-test-id="chat-input-wrapper" > renders chat with messages correctly 1`] = ` + + +
renders chat with messages correctly 1`] = ` > {"id":"1","type":"text","role":"assistant","content":"Hi Max! Here is my top solution to fix the error in your **Transform data** node👇","read":false}
+
+ + +
renders chat with messages correctly 1`] = ` > {"id":"1","type":"code-diff","role":"assistant","description":"Short solution description here that can spill over to two lines","codeDiff":"@@ -1,7 +1,6 @@\\n-The Way that can be told of is not the eternal Way;\\n-The name that can be named is not the eternal name.\\nThe Nameless is the origin of Heaven and Earth;\\n-The Named is the mother of all things.\\n+The named is the mother of all things.\\n+\\nTherefore let there always be non-being,\\nso we may see their subtlety,\\nAnd let there always be being,\\n@@ -9,3 +8,6 @@\\n The two are the same,\\n But after they are produced,\\n they have different names.\\n+They both may be called deep and profound.\\n+Deeper and more profound,\\n+The door of all subtleties!","suggestionId":"test","quickReplies":[{"type":"new-suggestion","text":"Give me another solution"},{"type":"resolved","text":"All good"}],"read":false}
+
+ + +
renders chat with messages correctly 1`] = ` > {"id":"2","type":"text","role":"user","content":"Give it to me **ignore this markdown**","read":false}
+
+ + +
renders chat with messages correctly 1`] = ` > {"id":"2","type":"block","role":"assistant","title":"Credential doesn’t have correct permissions to send a message","content":"Solution steps:\\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\\n2. Copy this into somewhere\\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \\n Testing more code \\n - Unordered item 1 \\n - Unordered item 2","read":false}
+
+ + +
renders chat with messages correctly 1`] = ` > {"id":"2","type":"code-diff","role":"assistant","description":"Short solution with min height","codeDiff":"@@ -1,7 +1,6 @@\\n-The Way that can be told of is not the eternal Way;\\n-The name that can be named is not the eternal name.\\n+The door of all subtleties!","quickReplies":[{"type":"new-suggestion","text":"Give me another solution"},{"type":"resolved","text":"All good"}],"suggestionId":"test","read":false}
+
@@ -320,6 +341,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = ` data-test-id="chat-input-wrapper" > renders default placeholder chat correctly 1`] = ` data-test-id="chat-input-wrapper" > renders end of session chat correctly 1`] = ` + + +
renders end of session chat correctly 1`] = ` > {"id":"1","type":"text","role":"assistant","content":"Hi Max! Here is my top solution to fix the error in your **Transform data** node👇","read":false}
+
+ + +
renders end of session chat correctly 1`] = ` > {"id":"123","role":"assistant","type":"event","eventName":"end-session","read":false}
+
@@ -567,6 +598,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = ` data-test-id="chat-input-wrapper" > renders error message correctly with retry button 1` + + +
renders error message correctly with retry button 1` > {"id":"1","role":"assistant","type":"error","content":"This is an error message.","read":false}
+
@@ -697,6 +733,7 @@ exports[`AskAssistantChat > renders error message correctly with retry button 1` data-test-id="chat-input-wrapper" > renders message with code snippet 1`] = ` + + +
renders message with code snippet 1`] = ` > {"id":"1","type":"text","role":"assistant","content":"Hi Max! Here is my top solution to fix the error in your **Transform data** node👇","codeSnippet":"node.on('input', function(msg) {\\n if (msg.seed) { dummyjson.seed = msg.seed; }\\n try {\\n var value = dummyjson.parse(node.template, {mockdata: msg});\\n if (node.syntax === 'json') {\\n try { value = JSON.parse(value); }\\n catch(e) { node.error(RED._('datagen.errors.json-error')); }\\n }\\n if (node.fieldType === 'msg') {\\n RED.util.setMessageProperty(msg,node.field,value);\\n }\\n else if (node.fieldType === 'flow') {\\n node.context().flow.set(node.field,value);\\n }\\n else if (node.fieldType === 'global') {\\n node.context().global.set(node.field,value);\\n }\\n node.send(msg);\\n }\\n catch(e) {","read":false}
+
@@ -827,6 +868,7 @@ exports[`AskAssistantChat > renders message with code snippet 1`] = ` data-test-id="chat-input-wrapper" > renders streaming chat correctly 1`] = ` + + +
renders streaming chat correctly 1`] = ` > {"id":"1","type":"text","role":"assistant","content":"Hi Max! Here is my top solution to fix the error in your **Transform data** node👇","read":false}
+
@@ -957,6 +1003,7 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = ` data-test-id="chat-input-wrapper" > (); -const { t } = useI18n(); - const isUserMessage = computed(() => props.message.role === 'user'); function onRate(rating: RatingFeedback) { @@ -33,19 +28,6 @@ function onRate(rating: RatingFeedback) { diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue index 615d20ee3fa..955e3b9fb43 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue @@ -15,6 +15,7 @@ import { IS_DRAFT_PUBLISH_ENABLED, WORKFLOW_SHARE_MODAL_KEY, EnterpriseEditionFeature, + WORKFLOW_DESCRIPTION_MODAL_KEY, } from '@/app/constants'; import { hasPermission } from '@/app/utils/rbac/permissions'; import { useRoute } from 'vue-router'; @@ -163,6 +164,11 @@ const workflowMenuItems = computed { switch (action) { + case WORKFLOW_MENU_ACTIONS.EDIT_DESCRIPTION: { + const workflowId = getWorkflowId(props.id, route.params.name); + if (!workflowId) return; + + const workflowDescription = workflowsStore.getWorkflowById(workflowId).description; + uiStore.openModalWithData({ + name: WORKFLOW_DESCRIPTION_MODAL_KEY, + data: { + workflowId, + workflowDescription, + }, + }); + break; + } case WORKFLOW_MENU_ACTIONS.DUPLICATE: { uiStore.openModalWithData({ name: DUPLICATE_MODAL_KEY, diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts deleted file mode 100644 index 8a690211772..00000000000 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -import { createComponentRenderer } from '@/__tests__/render'; -import { type MockedStore, mockedStore } from '@/__tests__/utils'; -import { createTestingPinia } from '@pinia/testing'; -import userEvent from '@testing-library/user-event'; -import { nextTick } from 'vue'; -import WorkflowDescriptionPopover from '@/app/components/MainHeader/WorkflowDescriptionPopover.vue'; -import { useWorkflowsStore } from '@/app/stores/workflows.store'; -import { useUIStore } from '@/app/stores/ui.store'; -import { useSettingsStore } from '@/app/stores/settings.store'; -import { useToast } from '@/app/composables/useToast'; -import { useTelemetry } from '@/app/composables/useTelemetry'; -import { STORES } from '@n8n/stores'; - -vi.mock('@/app/composables/useToast', () => { - const showError = vi.fn(); - return { - useToast: () => ({ - showError, - }), - }; -}); - -vi.mock('@/app/composables/useTelemetry', () => { - const track = vi.fn(); - return { - useTelemetry: () => ({ - track, - }), - }; -}); - -const initialState = { - [STORES.SETTINGS]: { - settings: { - modules: { - mcp: { - enabled: false, - }, - }, - }, - }, -}; - -const renderComponent = createComponentRenderer(WorkflowDescriptionPopover, { - pinia: createTestingPinia({ initialState }), -}); - -describe('WorkflowDescriptionPopover', () => { - let workflowsStore: MockedStore; - let uiStore: MockedStore; - let settingsStore: MockedStore; - let telemetry: ReturnType; - let toast: ReturnType; - - beforeEach(() => { - workflowsStore = mockedStore(useWorkflowsStore); - uiStore = mockedStore(useUIStore); - settingsStore = mockedStore(useSettingsStore); - telemetry = useTelemetry(); - toast = useToast(); - - // Reset mocks - workflowsStore.saveWorkflowDescription = vi.fn().mockResolvedValue(undefined); - workflowsStore.workflow = { - id: 'test-workflow-id', - name: 'Test Workflow', - active: false, - activeVersionId: null, - isArchived: false, - createdAt: Date.now(), - updatedAt: Date.now(), - versionId: '1', - nodes: [], - connections: {}, - }; - uiStore.stateIsDirty = false; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('Component rendering', () => { - it('should render the description button and default description', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - const button = getByTestId('workflow-description-button'); - await userEvent.click(button); - - const textarea = getByTestId('workflow-description-input'); - expect(textarea).toHaveValue('Initial description'); - }); - - it('should render empty string if there is no description', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - }, - }); - - const button = getByTestId('workflow-description-button'); - await userEvent.click(button); - - const textarea = getByTestId('workflow-description-input'); - expect(textarea).toHaveValue(''); - }); - }); - - describe('Popover interaction', () => { - it('should open popover when button is clicked', async () => { - const { getByTestId, queryByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Test description', - }, - }); - - const button = getByTestId('workflow-description-button'); - expect(queryByTestId('workflow-description-edit-content')).not.toBeInTheDocument(); - - await userEvent.click(button); - expect(getByTestId('workflow-description-edit-content')).toBeInTheDocument(); - }); - - it('should focus textarea when popover opens', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - }, - }); - - const button = getByTestId('workflow-description-button'); - await userEvent.click(button); - await nextTick(); - - const textarea = getByTestId('workflow-description-input'); - expect(textarea).toHaveFocus(); - }); - - it('should save description when popover closes', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - const button = getByTestId('workflow-description-button'); - await userEvent.click(button); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'Updated description'); - - // Click outside to close popover - await userEvent.click(document.body); - - expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith( - 'test-workflow-id', - 'Updated description', - ); - }); - }); - - describe('Save and Cancel functionality', () => { - it('should save description when save button is clicked', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'New description'); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith( - 'test-workflow-id', - 'New description', - ); - expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', { - workflow_id: 'test-workflow-id', - description: 'New description', - }); - }); - - it('should save empty string when description is cleared', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith('test-workflow-id', ''); - expect(telemetry.track).toHaveBeenCalledWith('User set workflow description', { - workflow_id: 'test-workflow-id', - description: '', - }); - }); - - it('should disable save button when description has not changed', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const saveButton = getByTestId('workflow-description-save-button'); - expect(saveButton).toBeDisabled(); - }); - - it('should disable save button when whitespace-only changes result in same trimmed value', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - // Type only whitespace - await userEvent.type(textarea, ' '); - - const saveButton = getByTestId('workflow-description-save-button'); - // Should be disabled since trimmed value is still empty - expect(saveButton).toBeDisabled(); - }); - - it('should not save on Enter key when only whitespace is entered', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - // Type only whitespace - await userEvent.type(textarea, ' '); - await userEvent.keyboard('{Enter}'); - - // Should not save since canSave is false - expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled(); - }); - - it('should enable save button when description changes', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - const saveButton = getByTestId('workflow-description-save-button'); - expect(saveButton).not.toBeDisabled(); - }); - - it('should revert changes when cancel button is clicked', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'Changed description'); - - const cancelButton = getByTestId('workflow-description-cancel-button'); - await userEvent.click(cancelButton); - - // Re-open popover to check value - await userEvent.click(getByTestId('workflow-description-button')); - const textareaAfterCancel = getByTestId('workflow-description-input'); - expect(textareaAfterCancel).toHaveValue('Initial description'); - }); - - it('should disable cancel button during save', async () => { - workflowsStore.saveWorkflowDescription = vi.fn( - async () => await new Promise((resolve) => setTimeout(resolve, 100)), - ); - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - const saveButton = getByTestId('workflow-description-save-button'); - const cancelButton = getByTestId('workflow-description-cancel-button'); - - await userEvent.click(saveButton); - - // During save, cancel should be disabled - expect(cancelButton).toBeDisabled(); - }); - }); - - describe('Keyboard shortcuts', () => { - it('should save when Enter key is pressed', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'New description'); - await userEvent.keyboard('{Enter}'); - - expect(workflowsStore.saveWorkflowDescription).toHaveBeenCalledWith( - 'test-workflow-id', - 'New description', - ); - }); - - it('should allow new lines with Shift+Enter', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, 'Line 1'); - await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); - await userEvent.type(textarea, 'Line 2'); - - expect(textarea).toHaveValue('Line 1\nLine 2'); - expect(workflowsStore.saveWorkflowDescription).not.toHaveBeenCalled(); - }); - - it('should cancel when Escape key is pressed', async () => { - const { getByTestId, queryByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'Changed description'); - await userEvent.keyboard('{Escape}'); - - // Check that popover is closed - expect(queryByTestId('workflow-description-edit-content')).not.toBeInTheDocument(); - - // Re-open to verify changes were reverted - await userEvent.click(getByTestId('workflow-description-button')); - const textareaAfterEscape = getByTestId('workflow-description-input'); - expect(textareaAfterEscape).toHaveValue('Initial description'); - }); - }); - - describe('Error handling', () => { - it('should show error toast when save fails', async () => { - const error = new Error('Save failed'); - workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error); - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - await vi.waitFor(() => { - expect(toast.showError).toHaveBeenCalledWith( - error, - 'Problem updating workflow description', - ); - }); - }); - - it('should revert to last saved value on error', async () => { - const error = new Error('Save failed'); - workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(error); - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'Failed update'); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - await vi.waitFor(() => { - expect(textarea).toHaveValue('Initial description'); - }); - }); - }); - - describe('Dirty state management', () => { - it('should set dirty flag when description changes', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - expect(uiStore.stateIsDirty).toBe(false); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - expect(uiStore.stateIsDirty).toBe(true); - }); - - it('should clear dirty flag when saving', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - expect(uiStore.stateIsDirty).toBe(true); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - await vi.waitFor(() => { - expect(uiStore.stateIsDirty).toBe(false); - }); - }); - - it('should clear dirty flag when canceling', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - expect(uiStore.stateIsDirty).toBe(true); - - const cancelButton = getByTestId('workflow-description-cancel-button'); - await userEvent.click(cancelButton); - - expect(uiStore.stateIsDirty).toBe(false); - }); - - it('should handle whitespace-only changes correctly', async () => { - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: ' Initial ', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.clear(textarea); - await userEvent.type(textarea, 'Initial'); - - // Should not be dirty since trimmed values are the same - expect(uiStore.stateIsDirty).toBe(false); - }); - }); - - describe('MCP tooltips', () => { - it('should show base tooltip when MCP is disabled', async () => { - // Ensure MCP is disabled - settingsStore.isModuleActive = vi.fn().mockReturnValue(false); - settingsStore.moduleSettings.mcp = { mcpAccessEnabled: false }; - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - // The tooltip text appears as placeholder in the textarea - const textarea = getByTestId('workflow-description-input'); - const placeholder = textarea.getAttribute('placeholder'); - - expect(placeholder).toContain('Edit workflow description'); - expect(placeholder).not.toContain('MCP clients'); - }); - - it('should show MCP tooltip when MCP is enabled', async () => { - // Enable MCP module - settingsStore.isModuleActive = vi.fn().mockReturnValue(true); - settingsStore.moduleSettings.mcp = { mcpAccessEnabled: true }; - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: '', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - const placeholder = textarea.getAttribute('placeholder'); - - expect(placeholder).toContain('MCP clients'); - expect(placeholder).not.toContain('Edit workflow description'); - }); - }); - - describe('UI state tracking', () => { - it('should track active actions during save', async () => { - const addActiveActionSpy = vi.spyOn(uiStore, 'addActiveAction'); - const removeActiveActionSpy = vi.spyOn(uiStore, 'removeActiveAction'); - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - expect(addActiveActionSpy).toHaveBeenCalledWith('workflowSaving'); - - await vi.waitFor(() => { - expect(removeActiveActionSpy).toHaveBeenCalledWith('workflowSaving'); - }); - }); - - it('should remove active action even on error', async () => { - const removeActiveActionSpy = vi.spyOn(uiStore, 'removeActiveAction'); - workflowsStore.saveWorkflowDescription = vi.fn().mockRejectedValue(new Error('Failed')); - - const { getByTestId } = renderComponent({ - props: { - workflowId: 'test-workflow-id', - workflowDescription: 'Initial description', - }, - }); - - await userEvent.click(getByTestId('workflow-description-button')); - - const textarea = getByTestId('workflow-description-input'); - await userEvent.type(textarea, ' updated'); - - const saveButton = getByTestId('workflow-description-save-button'); - await userEvent.click(saveButton); - - await vi.waitFor(() => { - expect(removeActiveActionSpy).toHaveBeenCalledWith('workflowSaving'); - }); - }); - }); -}); diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue deleted file mode 100644 index 504f6098642..00000000000 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDescriptionPopover.vue +++ /dev/null @@ -1,239 +0,0 @@ - - - - diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue index c05d89052f1..dd16f09acf2 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue @@ -42,8 +42,6 @@ import { } from 'vue'; import { useRoute, useRouter } from 'vue-router'; -import WorkflowDescriptionPopover from './WorkflowDescriptionPopover.vue'; - import { N8nBadge, N8nInlineTextEdit } from '@n8n/design-system'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useUIStore } from '@/app/stores/ui.store'; @@ -549,11 +547,6 @@ onBeforeUnmount(() => { > {{ locale.baseText('workflows.item.archived') }} - diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue index 2af3abe5b5f..3e5dc4c7e27 100644 --- a/packages/frontend/editor-ui/src/app/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/MainSidebar.vue @@ -29,7 +29,6 @@ import { EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY, } from '@/app/constants'; import { EXTERNAL_LINKS } from '@/app/constants/externalLinks'; -import { CHAT_VIEW } from '@/features/ai/chatHub/constants'; import { hasPermission } from '@/app/utils/rbac/permissions'; import { useCloudPlanStore } from '@/app/stores/cloudPlan.store'; import { useRootStore } from '@n8n/stores/useRootStore'; @@ -58,6 +57,7 @@ import ProjectNavigation from '@/features/collaboration/projects/components/Proj import KeyboardShortcutTooltip from './KeyboardShortcutTooltip.vue'; import { useCommandBar } from '@/features/shared/commandBar/composables/useCommandBar'; import MainSidebarSourceControl from './MainSidebarSourceControl.vue'; +import MainSidebarTrialUpgrade from './MainSidebarTrialUpgrade.vue'; import TemplateTooltip from '@/experiments/personalizedTemplatesV3/components/TemplateTooltip.vue'; import { useSidebarLayout } from '../composables/useSidebarLayout'; import { useSettingsItems } from '../composables/useSettingsItems'; @@ -122,16 +122,6 @@ const mainMenuItems = computed(() => [ icon: 'cloud', available: settingsStore.isCloudDeployment && hasPermission(['instanceOwner']), }, - { - id: 'chat', - icon: 'message-circle', - label: 'Chat', - position: 'bottom', - route: { to: { name: CHAT_VIEW } }, - available: - settingsStore.isChatFeatureEnabled && - hasPermission(['rbac'], { rbac: { scope: 'chatHub:message' } }), - }, { // Link to in-app pre-built agent templates, available experiment is enabled id: 'templates', @@ -668,6 +658,7 @@ const onLogout = () => {
+ diff --git a/packages/frontend/editor-ui/src/app/components/MainSidebarTrialUpgrade.vue b/packages/frontend/editor-ui/src/app/components/MainSidebarTrialUpgrade.vue new file mode 100644 index 00000000000..c2e0b96590e --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/MainSidebarTrialUpgrade.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/app/components/Modals.vue b/packages/frontend/editor-ui/src/app/components/Modals.vue index 10c4c67dc8a..ffe10afa352 100644 --- a/packages/frontend/editor-ui/src/app/components/Modals.vue +++ b/packages/frontend/editor-ui/src/app/components/Modals.vue @@ -30,6 +30,7 @@ import { EXPERIMENT_TEMPLATE_RECO_V3_KEY, EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY, CONFIRM_PASSWORD_MODAL_KEY, + WORKFLOW_DESCRIPTION_MODAL_KEY, WORKFLOW_PUBLISH_MODAL_KEY, WORKFLOW_HISTORY_PUBLISH_MODAL_KEY, } from '@/app/constants'; @@ -113,6 +114,7 @@ import NodeRecommendationModalV2 from '@/experiments/templateRecoV2/components/N import NodeRecommendationModalV3 from '@/experiments/personalizedTemplatesV3/components/NodeRecommendationModal.vue'; import NodeRecommendationModalTDQ from '@/experiments/templatesDataQuality/components/NodeRecommendationModal.vue'; import VariableModal from '@/features/settings/environments.ee/components/VariableModal.vue'; +import WorkflowDescriptionModal from '@/app/components/WorkflowDescriptionModal.vue'; import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue'; import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryPublishModal.vue'; import UpdatesPanel from './UpdatesPanel.vue'; @@ -419,6 +421,12 @@ import UpdatesPanel from './UpdatesPanel.vue'; + + + + @@ -62,28 +69,17 @@ function handleEditClick() { list-style: none; display: flex; align-items: center; - padding: var(--spacing--3xs) 0; - border-bottom: 1px solid var(--color--foreground--tint-1); + padding: var(--spacing--2xs) 0; cursor: pointer; &:hover { color: var(--color--primary); } - - &:first-child { - padding-top: 0; - } - - &:last-child { - border-bottom: none; - } } .nodeIcon { margin-right: var(--spacing--2xs); - margin-top: var(--spacing--4xs); flex-shrink: 0; - align-self: flex-start; } .nodeName { @@ -97,10 +93,10 @@ function handleEditClick() { line-height: var(--line-height--md); } -.editButton { - --button--border-color: transparent; - margin-left: auto; +.chevron { + width: 16px; + height: 16px; flex-shrink: 0; - align-self: center; + color: var(--color--text); } diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.test.ts index 9b295487751..bf93588641a 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.test.ts @@ -10,6 +10,11 @@ vi.mock('@n8n/i18n', () => ({ }), })); +// Mock generateShortId to return a predictable value for testing +vi.mock('../builder.utils', () => ({ + generateShortId: () => 'abc123', +})); + describe('useBuilderMessages', () => { let builderMessages: ReturnType; @@ -36,7 +41,7 @@ describe('useBuilderMessages', () => { expect(result.messages).toHaveLength(1); expect(result.messages[0]).toMatchObject({ - id: 'test-id-0', + id: 'test-id--abc123--0', role: 'assistant', type: 'text', content: 'Hello, how can I help?', @@ -72,7 +77,7 @@ describe('useBuilderMessages', () => { expect(result.messages).toHaveLength(1); const toolMessage = result.messages[0] as ChatUI.ToolMessage; expect(toolMessage).toMatchObject({ - id: 'call-123', // Should use toolCallId as ID + id: 'test-id--abc123--0-call-123', // Format is messageId-toolCallId role: 'assistant', type: 'tool', toolName: 'add_nodes', @@ -164,7 +169,7 @@ describe('useBuilderMessages', () => { expect(result.messages).toHaveLength(1); expect(result.messages[0]).toMatchObject({ - id: 'test-id-0', // Should fall back to generated ID + id: 'test-id--abc123--0-undefined', // Format is messageId-toolCallId (undefined when missing) type: 'tool', toolName: 'some_tool', }); @@ -224,7 +229,7 @@ describe('useBuilderMessages', () => { expect(result.messages).toHaveLength(1); expect(result.messages[0]).toMatchObject({ - id: 'test-id-0', + id: 'test-id--abc123--0', type: 'workflow-updated', codeSnippet: '{"nodes": [], "connections": {}}', read: false, @@ -262,11 +267,11 @@ describe('useBuilderMessages', () => { expect(result.messages).toHaveLength(3); expect(result.messages[0].type).toBe('text'); - expect(result.messages[0].id).toBe('batch-id-0'); + expect(result.messages[0].id).toBe('batch-id--abc123--0'); expect(result.messages[1].type).toBe('tool'); - expect(result.messages[1].id).toBe('call-123'); // Uses toolCallId + expect(result.messages[1].id).toBe('batch-id--abc123--1-call-123'); // Format is messageId-toolCallId expect(result.messages[2].type).toBe('workflow-updated'); - expect(result.messages[2].id).toBe('batch-id-2'); + expect(result.messages[2].id).toBe('batch-id--abc123--2'); }); it('should show tool name when tool is in progress with displayTitle', () => { @@ -988,7 +993,7 @@ describe('useBuilderMessages', () => { ); expect(result.messages).toHaveLength(1); - expect(result.messages[0].id).toBe('test-id-0'); + expect(result.messages[0].id).toBe('test-id--abc123--0-undefined'); // Format is messageId-toolCallId (undefined when missing) }); it('should handle workflow-updated messages with invalid JSON', () => { @@ -1526,7 +1531,7 @@ describe('useBuilderMessages', () => { expect(result.messages).toHaveLength(1); const errorMessage = result.messages[0] as ChatUI.ErrorMessage; expect(errorMessage).toMatchObject({ - id: 'test-id-0', + id: 'test-id--abc123--0', role: 'assistant', type: 'error', content: 'Something went wrong', diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts index 76c304ccb7c..20884eb7639 100644 --- a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderMessages.ts @@ -2,6 +2,7 @@ import type { ChatUI } from '@n8n/design-system/types/assistant'; import type { ChatRequest } from '../assistant.types'; import { useI18n } from '@n8n/i18n'; import { isTextMessage, isWorkflowUpdatedMessage, isToolMessage } from '../assistant.types'; +import { generateShortId } from '../builder.utils'; export interface MessageProcessingResult { messages: ChatUI.AssistantMessage[]; @@ -98,9 +99,6 @@ export function useBuilderMessages() { msg: ChatRequest.ToolMessage, messageId: string, ): void { - // Use toolCallId as the message ID for consistency across updates - const toolMessageId = msg.toolCallId ?? messageId; - // Check if we already have this tool message const existingIndex = msg.toolCallId ? messages.findIndex((m) => m.type === 'tool' && m.toolCallId === msg.toolCallId) @@ -111,6 +109,7 @@ export function useBuilderMessages() { const existing = messages[existingIndex] as ChatUI.ToolMessage; const toolMessage: ChatUI.ToolMessage = { ...existing, + id: `${messageId}-${msg.toolCallId}`, status: msg.status, updates: [...(existing.updates || []), ...(msg.updates || [])], }; @@ -118,7 +117,7 @@ export function useBuilderMessages() { } else { // Add new tool message const toolMessage: ChatUI.AssistantMessage = { - id: toolMessageId, + id: `${messageId}-${msg.toolCallId}`, role: 'assistant', type: 'tool', toolName: msg.toolName, @@ -269,15 +268,18 @@ export function useBuilderMessages() { function processAssistantMessages( currentMessages: ChatUI.AssistantMessage[], newMessages: ChatRequest.MessageResponse[], - baseId: string, + userMessageId: string, retry?: () => Promise, ): MessageProcessingResult { const mutableMessages = [...currentMessages]; let shouldClearThinking = false; + const messageGroupId = generateShortId(); + newMessages.forEach((msg, index) => { - // Generate unique ID for each message in the batch - const messageId = `${baseId}-${index}`; + // Generate unique ID for each message in the batch, based on original user message id. + // Used in telemetry to track events related to a specific user message + const messageId = `${userMessageId}--${messageGroupId}--${index}`; const clearThinking = processSingleMessage(mutableMessages, msg, messageId, retry); shouldClearThinking = shouldClearThinking || clearThinking; }); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderTodos.test.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderTodos.test.ts new file mode 100644 index 00000000000..a54e551b193 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderTodos.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect } from 'vitest'; +import { + extractPlaceholderLabel, + findPlaceholderDetails, + formatPlaceholderPath, + isPlaceholderValue, +} from './useBuilderTodos'; + +describe('useBuilderTodos', () => { + describe('extractPlaceholderLabel', () => { + it('returns null for non-string values', () => { + expect(extractPlaceholderLabel(123)).toBeNull(); + expect(extractPlaceholderLabel(true)).toBeNull(); + expect(extractPlaceholderLabel(null)).toBeNull(); + expect(extractPlaceholderLabel(undefined)).toBeNull(); + expect(extractPlaceholderLabel({})).toBeNull(); + expect(extractPlaceholderLabel([])).toBeNull(); + }); + + it('returns null for strings without placeholder format', () => { + expect(extractPlaceholderLabel('regular string')).toBeNull(); + expect(extractPlaceholderLabel('https://example.com')).toBeNull(); + expect(extractPlaceholderLabel('')).toBeNull(); + }); + + it('returns null for partial placeholder format', () => { + expect(extractPlaceholderLabel('<__PLACEHOLDER_VALUE__missing end')).toBeNull(); + expect(extractPlaceholderLabel('PLACEHOLDER__test__>')).toBeNull(); + expect(extractPlaceholderLabel('__PLACEHOLDER_VALUE__test__>')).toBeNull(); + }); + + it('returns null for empty label', () => { + expect(extractPlaceholderLabel('<__PLACEHOLDER_VALUE____>')).toBeNull(); + }); + + it('returns null for whitespace-only label', () => { + expect(extractPlaceholderLabel('<__PLACEHOLDER_VALUE__ __>')).toBeNull(); + }); + + it('extracts label from valid placeholder', () => { + expect(extractPlaceholderLabel('<__PLACEHOLDER_VALUE__Enter URL__>')).toBe('Enter URL'); + expect(extractPlaceholderLabel('<__PLACEHOLDER_VALUE__API Key__>')).toBe('API Key'); + }); + + it('trims whitespace from label', () => { + expect(extractPlaceholderLabel('<__PLACEHOLDER_VALUE__ Enter URL __>')).toBe('Enter URL'); + }); + }); + + describe('findPlaceholderDetails', () => { + it('returns empty array for primitive non-placeholder values', () => { + expect(findPlaceholderDetails('regular string')).toEqual([]); + expect(findPlaceholderDetails(123)).toEqual([]); + expect(findPlaceholderDetails(true)).toEqual([]); + expect(findPlaceholderDetails(null)).toEqual([]); + }); + + it('returns empty array for empty object', () => { + expect(findPlaceholderDetails({})).toEqual([]); + }); + + it('returns empty array for empty array', () => { + expect(findPlaceholderDetails([])).toEqual([]); + }); + + it('finds placeholder at root level', () => { + const result = findPlaceholderDetails('<__PLACEHOLDER_VALUE__Enter URL__>'); + expect(result).toEqual([{ path: [], label: 'Enter URL' }]); + }); + + it('finds placeholder in simple object', () => { + const result = findPlaceholderDetails({ + url: '<__PLACEHOLDER_VALUE__Enter URL__>', + }); + expect(result).toEqual([{ path: ['url'], label: 'Enter URL' }]); + }); + + it('finds multiple placeholders in object', () => { + const result = findPlaceholderDetails({ + url: '<__PLACEHOLDER_VALUE__Enter URL__>', + body: '<__PLACEHOLDER_VALUE__Enter Body__>', + }); + expect(result).toHaveLength(2); + expect(result).toContainEqual({ path: ['url'], label: 'Enter URL' }); + expect(result).toContainEqual({ path: ['body'], label: 'Enter Body' }); + }); + + it('finds placeholder in nested object', () => { + const result = findPlaceholderDetails({ + options: { + headers: { + authorization: '<__PLACEHOLDER_VALUE__Enter API Key__>', + }, + }, + }); + expect(result).toEqual([ + { path: ['options', 'headers', 'authorization'], label: 'Enter API Key' }, + ]); + }); + + it('finds placeholder in array', () => { + const result = findPlaceholderDetails([ + 'regular value', + '<__PLACEHOLDER_VALUE__Enter Value__>', + ]); + expect(result).toEqual([{ path: ['[1]'], label: 'Enter Value' }]); + }); + + it('finds placeholder in array of objects', () => { + const result = findPlaceholderDetails({ + headers: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'Authorization', value: '<__PLACEHOLDER_VALUE__Enter Token__>' }, + ], + }); + expect(result).toEqual([{ path: ['headers', '[1]', 'value'], label: 'Enter Token' }]); + }); + + it('finds placeholders in mixed structure', () => { + const result = findPlaceholderDetails({ + url: '<__PLACEHOLDER_VALUE__Enter URL__>', + options: { + items: [{ key: '<__PLACEHOLDER_VALUE__Enter Key__>' }, { value: 'static' }], + }, + }); + expect(result).toHaveLength(2); + expect(result).toContainEqual({ path: ['url'], label: 'Enter URL' }); + expect(result).toContainEqual({ + path: ['options', 'items', '[0]', 'key'], + label: 'Enter Key', + }); + }); + + it('ignores non-placeholder strings in object', () => { + const result = findPlaceholderDetails({ + url: 'https://example.com', + method: 'GET', + placeholder: '<__PLACEHOLDER_VALUE__Enter Value__>', + }); + expect(result).toEqual([{ path: ['placeholder'], label: 'Enter Value' }]); + }); + + it('handles custom starting path', () => { + const result = findPlaceholderDetails({ url: '<__PLACEHOLDER_VALUE__Enter URL__>' }, [ + 'parameters', + ]); + expect(result).toEqual([{ path: ['parameters', 'url'], label: 'Enter URL' }]); + }); + }); + + describe('formatPlaceholderPath', () => { + it('returns "parameters" for empty path', () => { + expect(formatPlaceholderPath([])).toBe('parameters'); + }); + + it('formats single segment path', () => { + expect(formatPlaceholderPath(['url'])).toBe('url'); + }); + + it('formats multi-segment path with dot notation', () => { + expect(formatPlaceholderPath(['options', 'headers', 'authorization'])).toBe( + 'options.headers.authorization', + ); + }); + + it('formats path with array indices without leading dot', () => { + expect(formatPlaceholderPath(['headers', '[0]', 'value'])).toBe('headers[0].value'); + }); + + it('formats path starting with array index', () => { + expect(formatPlaceholderPath(['[0]', 'key'])).toBe('[0].key'); + }); + + it('formats path with multiple array indices', () => { + expect(formatPlaceholderPath(['items', '[0]', 'options', '[1]', 'value'])).toBe( + 'items[0].options[1].value', + ); + }); + + it('formats path with consecutive array indices', () => { + expect(formatPlaceholderPath(['matrix', '[0]', '[1]'])).toBe('matrix[0][1]'); + }); + }); + + describe('isPlaceholderValue', () => { + it('returns true for placeholder values', () => { + expect(isPlaceholderValue('<__PLACEHOLDER_VALUE__API endpoint URL__>')).toBe(true); + expect(isPlaceholderValue('<__PLACEHOLDER_VALUE__label__>')).toBe(true); + expect(isPlaceholderValue('<__PLACEHOLDER_VALUE____>')).toBe(true); + }); + + it('returns false for non-placeholder strings', () => { + expect(isPlaceholderValue('regular string')).toBe(false); + expect(isPlaceholderValue('')).toBe(false); + expect(isPlaceholderValue('https://api.example.com')).toBe(false); + expect(isPlaceholderValue('={{ $json.field }}')).toBe(false); + }); + + it('returns false for malformed placeholders missing suffix', () => { + // Has prefix but missing suffix - should be false + expect(isPlaceholderValue('<__PLACEHOLDER_VALUE__missing suffix')).toBe(false); + expect(isPlaceholderValue('<__PLACEHOLDER_VALUE__some text without end')).toBe(false); + }); + + it('returns false for malformed placeholders missing prefix', () => { + // Has suffix but missing prefix - should be false + expect(isPlaceholderValue('missing prefix__>')).toBe(false); + expect(isPlaceholderValue('some text without start__>')).toBe(false); + }); + + it('returns false for non-string values', () => { + expect(isPlaceholderValue(123)).toBe(false); + expect(isPlaceholderValue(null)).toBe(false); + expect(isPlaceholderValue(undefined)).toBe(false); + expect(isPlaceholderValue({ key: 'value' })).toBe(false); + expect(isPlaceholderValue(['array'])).toBe(false); + expect(isPlaceholderValue(true)).toBe(false); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderTodos.ts b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderTodos.ts new file mode 100644 index 00000000000..eb1549481dc --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/assistant/composables/useBuilderTodos.ts @@ -0,0 +1,172 @@ +import { computed } from 'vue'; +import { useI18n } from '@n8n/i18n'; +import { useWorkflowsStore } from '@/app/stores/workflows.store'; +import type { WorkflowValidationIssue } from '@/Interface'; + +const PLACEHOLDER_PREFIX = '<__PLACEHOLDER_VALUE__'; +const PLACEHOLDER_SUFFIX = '__>'; + +export interface PlaceholderDetail { + path: string[]; + label: string; +} + +export interface TodoTrackingItem { + type: string; + node_type: string | undefined; + label: string | string[]; +} + +export interface TodosTrackingPayload { + credentials_todo_count: number; + placeholders_todo_count: number; + todos: TodoTrackingItem[]; +} + +/** + * Extracts the label from a placeholder value string. + * Returns null if the value is not a valid placeholder. + */ +export function extractPlaceholderLabel(value: unknown): string | null { + if (typeof value !== 'string') return null; + if (!value.startsWith(PLACEHOLDER_PREFIX) || !value.endsWith(PLACEHOLDER_SUFFIX)) return null; + + const label = value + .slice(PLACEHOLDER_PREFIX.length, value.length - PLACEHOLDER_SUFFIX.length) + .trim(); + return label.length > 0 ? label : null; +} + +/** + * Recursively searches through a value (object, array, or primitive) to find + * all placeholder values and their paths. + */ +export function findPlaceholderDetails(value: unknown, path: string[] = []): PlaceholderDetail[] { + const label = extractPlaceholderLabel(value); + if (label) return [{ path, label }]; + + if (Array.isArray(value)) { + return value.flatMap((item, index) => findPlaceholderDetails(item, [...path, `[${index}]`])); + } + + if (value !== null && typeof value === 'object') { + return Object.entries(value).flatMap(([key, nested]) => + findPlaceholderDetails(nested, [...path, key]), + ); + } + + return []; +} + +/** + * Formats a path array into a dot-notation string for display. + * Array indices are preserved as [N] without leading dots. + */ +export function formatPlaceholderPath(path: string[]): string { + if (path.length === 0) return 'parameters'; + + return path + .map((segment, index) => (segment.startsWith('[') || index === 0 ? segment : `.${segment}`)) + .join(''); +} + +/** + * Checks if a value is a placeholder value + */ +export function isPlaceholderValue(value: unknown): boolean { + if (typeof value !== 'string') return false; + return value.startsWith(PLACEHOLDER_PREFIX) && value.endsWith(PLACEHOLDER_SUFFIX); +} + +/** + * Composable for managing workflow todos (validation issues and placeholders) + * used by the AI builder. + */ +export function useBuilderTodos() { + const workflowsStore = useWorkflowsStore(); + const locale = useI18n(); + + /** + * Base workflow validation issues filtered to only credentials and parameters types. + */ + const baseWorkflowIssues = computed(() => + workflowsStore.workflowValidationIssues.filter((issue) => + ['credentials', 'parameters'].includes(issue.type), + ), + ); + + /** + * Placeholder issues detected in workflow node parameters. + * These are values with the format <__PLACEHOLDER_VALUE__label__>. + */ + const placeholderIssues = computed(() => { + const issues: WorkflowValidationIssue[] = []; + const seen = new Set(); + + for (const node of workflowsStore.workflow.nodes) { + if (!node?.parameters) continue; + + const placeholders = findPlaceholderDetails(node.parameters); + if (placeholders.length === 0) continue; + + const existingParameterIssues = node.issues?.parameters ?? {}; + + for (const placeholder of placeholders) { + const path = formatPlaceholderPath(placeholder.path); + const message = locale.baseText('aiAssistant.builder.executeMessage.fillParameter', { + interpolate: { label: placeholder.label }, + }); + const rawMessages = existingParameterIssues[path]; + const existingMessages = rawMessages + ? Array.isArray(rawMessages) + ? rawMessages + : [rawMessages] + : []; + + if (existingMessages.includes(message)) continue; + + const key = `${node.name}|${path}|${placeholder.label}`; + if (seen.has(key)) continue; + seen.add(key); + + issues.push({ + node: node.name, + type: 'parameters', + value: message, + }); + } + } + + return issues; + }); + + /** + * Combined list of all workflow todos (base issues + placeholder issues). + */ + const workflowTodos = computed(() => [...baseWorkflowIssues.value, ...placeholderIssues.value]); + + /** + * Returns todos data formatted for telemetry tracking. + */ + function getTodosToTrack(): TodosTrackingPayload { + const credentials_todo_count = workflowsStore.workflowValidationIssues.filter( + (issue) => issue.type === 'credentials', + ).length; + const placeholders_todo_count = placeholderIssues.value.length; + return { + credentials_todo_count, + placeholders_todo_count, + todos: workflowTodos.value.map((todo) => ({ + type: todo.type, + node_type: workflowsStore.getNodeByName(todo.node)?.type, + label: todo.value, + })), + }; + } + + return { + workflowTodos, + placeholderIssues, + getTodosToTrack, + }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue index a8c1b4c1331..9771cef8f60 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -9,6 +9,7 @@ import { findOneFromModelsResponse, isLlmProvider, unflattenModel, + createMimeTypes, } from '@/features/ai/chatHub/chat.utils'; import ChatConversationHeader from '@/features/ai/chatHub/components/ChatConversationHeader.vue'; import ChatMessage from '@/features/ai/chatHub/components/ChatMessage.vue'; @@ -31,7 +32,7 @@ import { type ChatModelDto, } from '@n8n/api-types'; import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system'; -import { useLocalStorage, useMediaQuery, useScroll } from '@vueuse/core'; +import { useElementSize, useLocalStorage, useMediaQuery, useScroll } from '@vueuse/core'; import { v4 as uuidv4 } from 'uuid'; import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; @@ -47,6 +48,7 @@ import { chatHubConversationModelWithCachedDisplayNameSchema, } from '@/features/ai/chatHub/chat.types'; import { useI18n } from '@n8n/i18n'; +import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent'; const router = useRouter(); const route = useRoute(); @@ -60,20 +62,26 @@ const i18n = useI18n(); const headerRef = useTemplateRef('headerRef'); const inputRef = useTemplateRef('inputRef'); +const scrollableRef = useTemplateRef('scrollable'); + +const scrollableSize = useElementSize(scrollableRef); + const sessionId = computed(() => typeof route.params.id === 'string' ? route.params.id : uuidv4(), ); const isResponding = computed(() => chatStore.isResponding(sessionId.value)); const isNewSession = computed(() => sessionId.value !== route.params.id); -const scrollableRef = useTemplateRef('scrollable'); const scrollContainerRef = computed(() => scrollableRef.value?.parentElement ?? null); const currentConversation = computed(() => sessionId.value ? chatStore.sessions.byId[sessionId.value] : undefined, ); const currentConversationTitle = computed(() => currentConversation.value?.title); -// TODO: This also depends on the model, not all base LLM models support tools. -const canSelectTools = computed(() => isLlmProvider(selectedModel.value?.model.provider)); +const canSelectTools = computed( + () => + selectedModel.value?.model.provider === 'custom-agent' || + !!selectedModel.value?.metadata.capabilities.functionCalling, +); const { arrivedState, measure } = useScroll(scrollContainerRef, { throttle: 100, @@ -99,8 +107,8 @@ const defaultModel = useLocalStorage - defaultModel.value ? chatStore.getAgent(defaultModel.value).name : undefined, +const defaultAgent = computed(() => + defaultModel.value ? chatStore.getAgent(defaultModel.value) : undefined, ); const defaultTools = useLocalStorage( @@ -122,22 +130,8 @@ const defaultTools = useLocalStorage( }, ); -const toolsSelection = ref(null); const shouldSkipNextScrollTrigger = ref(false); -const selectedTools = computed(() => { - if (currentConversation.value?.tools) { - return currentConversation.value.tools; - } - - // As soon as the user selects tools use the selection over the default - if (toolsSelection.value !== null) { - return toolsSelection.value; - } - - return defaultTools.value ?? []; -}); - const modelFromQuery = computed(() => { const agentId = route.query.agentId; const workflowId = route.query.workflowId; @@ -186,6 +180,25 @@ const selectedModel = computed(() => { return chatStore.getAgent(defaultModel.value, defaultModel.value.cachedDisplayName); }); +const customAgentId = computed(() => + selectedModel.value?.model.provider === 'custom-agent' + ? selectedModel.value.model.agentId + : undefined, +); +const customAgent = useCustomAgent(customAgentId); + +const selectedTools = computed(() => { + if (customAgent.value) { + return customAgent.value.tools; + } + + if (currentConversation.value?.tools) { + return currentConversation.value.tools; + } + + return modelFromQuery.value ? [] : (defaultTools.value ?? []); +}); + const { credentialsByProvider, selectCredential } = useChatCredentials( usersStore.currentUserId ?? 'anonymous', ); @@ -240,7 +253,7 @@ const didSubmitInCurrentSession = ref(false); const canAcceptFiles = computed( () => editingMessageId.value === undefined && - !!selectedModel.value?.allowFileUploads && + !!createMimeTypes(selectedModel.value?.metadata.inputModalities ?? []) && !isMissingSelectedCredential.value, ); @@ -307,12 +320,13 @@ watch( [sessionId, isNewSession], async ([id, isNew]) => { didSubmitInCurrentSession.value = false; + editingMessageId.value = undefined; if (!isNew && !chatStore.getConversation(id)) { try { await chatStore.fetchMessages(id); } catch (error) { - toast.showError(error, 'Error fetching a conversation'); + toast.showError(error, i18n.baseText('chatHub.error.fetchConversationFailed')); await router.push({ name: CHAT_VIEW }); } } @@ -350,10 +364,18 @@ watch( // Keep cached display name up-to-date watch( - defaultModelName, - (name) => { - if (defaultModel.value && name) { - defaultModel.value = { ...defaultModel.value, cachedDisplayName: name }; + defaultAgent, + (agent, prevAgent) => { + if (defaultModel.value && agent?.name && agent.name !== prevAgent?.name) { + defaultModel.value = { ...defaultModel.value, cachedDisplayName: agent.name }; + } + + if ( + agent && + !agent.metadata.capabilities.functionCalling && + (defaultTools.value ?? []).length > 0 + ) { + defaultTools.value = []; } }, { immediate: true }, @@ -451,10 +473,13 @@ async function handleSelectModel(selection: ChatHubConversationModel, displayNam try { await chatStore.updateSessionModel(sessionId.value, selection, agentName); } catch (error) { - toast.showError(error, 'Could not update selected model'); + toast.showError(error, i18n.baseText('chatHub.error.updateModelFailed')); } } else { defaultModel.value = { ...selection, cachedDisplayName: agentName }; + + // Remove query params (if exists) and focus input + await router.push({ name: CHAT_VIEW, force: true }); // remove query params } } @@ -476,14 +501,13 @@ function handleConfigureModel() { } async function handleUpdateTools(newTools: INode[]) { - toolsSelection.value = newTools; defaultTools.value = newTools; if (currentConversation.value) { try { await chatStore.updateToolsInSession(sessionId.value, newTools); } catch (error) { - toast.showError(error, 'Could not update selected tools'); + toast.showError(error, i18n.baseText('chatHub.error.updateToolsFailed')); } } } @@ -494,7 +518,7 @@ function handleEditAgent(agentId: string) { data: { agentId, credentials: credentialsByProvider, - onCreateCustomAgent: handleSelectModel, + onCreateCustomAgent: handleSelectAgent, }, }); } @@ -504,7 +528,7 @@ function openNewAgentCreator() { name: AGENT_EDITOR_MODAL_KEY, data: { credentials: credentialsByProvider, - onCreateCustomAgent: handleSelectModel, + onCreateCustomAgent: handleSelectAgent, }, }); } @@ -546,7 +570,8 @@ function onFilesDropped(files: File[]) { :selected-model="selectedModel" :credentials="credentialsByProvider" :ready-to-show-model-selector="isNewSession || !!currentConversation" - @select-model="handleSelectAgent" + :is-new-session="isNewSession" + @select-model="handleSelectModel" @edit-custom-agent="handleEditAgent" @create-custom-agent="openNewAgentCreator" @select-credential="selectCredential" @@ -584,6 +609,7 @@ function onFilesDropped(files: File[]) { ? scrollContainerRef.offsetHeight - 30 /* padding-top */ - 200 /* padding-bottom */ : undefined " + :container-width="scrollableSize.width.value ?? 0" @start-edit="handleStartEditMessage(message.id)" @cancel-edit="handleCancelEditMessage" @regenerate="handleRegenerateMessage" @@ -617,6 +643,7 @@ function onFilesDropped(files: File[]) { @select-model="handleConfigureModel" @select-tools="handleUpdateTools" @set-credentials="handleConfigureCredentials" + @edit-agent="handleEditAgent" />
diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts new file mode 100644 index 00000000000..1448f228d70 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/__test__/data.ts @@ -0,0 +1,27 @@ +import type {} from '@n8n/api-types'; +import type { ChatMessage } from '../chat.types'; + +export function createTestChatMessage(overrides: Partial = {}): ChatMessage { + return { + id: 'm0', + type: 'ai', + content: 'test message', + status: 'success', + sessionId: 's0', + createdAt: new Date().toISOString(), + model: 'm0', + provider: 'anthropic', + alternatives: [], + attachments: [], + responses: [], + name: '', + workflowId: null, + agentId: null, + executionId: null, + updatedAt: new Date().toISOString(), + previousMessageId: null, + retryOfMessageId: null, + revisionOfMessageId: null, + ...overrides, + }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index 4d004f27dee..b9c9e2609fb 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia'; import { CHAT_STORE } from './constants'; import { computed, ref } from 'vue'; import { v4 as uuidv4 } from 'uuid'; +import { useI18n } from '@n8n/i18n'; import { fetchChatModelsApi, sendMessageApi, @@ -58,13 +59,16 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; import { deepCopy, type INode } from 'n8n-workflow'; import type { ChatHubLLMProvider, ChatProviderSettingsDto } from '@n8n/api-types'; import { convertFileToBinaryData } from '@/app/utils/fileUtils'; +import { ResponseError } from '@n8n/rest-api-client'; export const useChatStore = defineStore(CHAT_STORE, () => { const rootStore = useRootStore(); const toast = useToast(); const telemetry = useTelemetry(); + const i18n = useI18n(); const agents = ref(); + const customAgents = ref>>({}); const sessions = ref<{ byId: Partial>; ids: string[] | null; @@ -449,29 +453,41 @@ export const useChatStore = defineStore(CHAT_STORE, () => { await fetchSessions(true); } + function getErrorMessageByStatusCode( + statusCode: number | undefined, + message: string | undefined, + ): string { + const errorMessages: Record = { + [413]: i18n.baseText('chatHub.error.payloadTooLarge'), + [400]: message ?? i18n.baseText('chatHub.error.badRequest'), + [403]: i18n.baseText('chatHub.error.forbidden'), + [500]: message + ? i18n.baseText('chatHub.error.serverErrorWithReason', { + interpolate: { error: message }, + }) + : i18n.baseText('chatHub.error.serverError'), + }; + + return ( + (statusCode && errorMessages[statusCode]) || message || i18n.baseText('chatHub.error.unknown') + ); + } + function onStreamError(error: Error) { if (!streaming.value) { return; } - toast.showError(error, 'Could not send message'); + const cause = + error instanceof ResponseError + ? new Error(getErrorMessageByStatusCode(error.httpStatusCode, error.message)) + : error.message.includes('Failed to fetch') + ? new Error(i18n.baseText('chatHub.error.noConnection')) + : error; - const { sessionId } = streaming.value; + toast.showError(cause, i18n.baseText('chatHub.error.sendMessageFailed')); streaming.value = undefined; - - const conversation = getConversation(sessionId); - if (!conversation) { - return; - } - - // TODO: Not sure if we want to mark all running messages as errored? - for (const messageId of conversation.activeMessageChain) { - const message = conversation.messages[messageId]; - if (message.status === 'running') { - updateMessage(sessionId, messageId, 'error'); - } - } } async function sendMessage( @@ -547,6 +563,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { tools, attachments, agentName, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, onStreamMessage, onStreamDone, @@ -617,11 +634,19 @@ export const useChatStore = defineStore(CHAT_STORE, () => { messageId: promptId, message: content, credentials, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, onStreamMessage, onStreamDone, onStreamError, ); + + telemetry.track('User edited chat hub message', { + ...flattenModel(model), + is_custom: model.provider === 'custom-agent', + chat_session_id: sessionId, + chat_message_id: editId, + }); } function regenerateMessage( @@ -653,11 +678,19 @@ export const useChatStore = defineStore(CHAT_STORE, () => { { model, credentials, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, onStreamMessage, onStreamDone, onStreamError, ); + + telemetry.track('User regenerated chat hub message', { + ...flattenModel(model), + is_custom: model.provider === 'custom-agent', + chat_session_id: sessionId, + chat_message_id: retryId, + }); } async function stopStreamingMessage(sessionId: ChatSessionId) { @@ -730,8 +763,10 @@ export const useChatStore = defineStore(CHAT_STORE, () => { conversation.activeMessageChain = computeActiveChain(conversation.messages, messageId); } - async function fetchCustomAgent(agentId: string): Promise { - return await fetchAgentApi(rootStore.restApiContext, agentId); + async function fetchCustomAgent(agentId: string) { + const customAgent = await fetchAgentApi(rootStore.restApiContext, agentId); + + customAgents.value[agentId] = customAgent; } function getCustomAgent(agentId: string) { @@ -744,26 +779,33 @@ export const useChatStore = defineStore(CHAT_STORE, () => { payload: ChatHubCreateAgentRequest, credentials: CredentialsMap, ): Promise { - const agent = await createAgentApi(rootStore.restApiContext, payload); - const agentModel = { + const customAgent = await createAgentApi(rootStore.restApiContext, payload); + const baseModel = agents.value?.[customAgent.provider]?.models.find( + (model) => model.name === customAgent.model, + ); + const agent: ChatModelDto = { model: { provider: 'custom-agent' as const, - agentId: agent.id, + agentId: customAgent.id, + }, + name: customAgent.name, + description: customAgent.description ?? null, + createdAt: customAgent.createdAt, + updatedAt: customAgent.updatedAt, + metadata: baseModel?.metadata ?? { + capabilities: { functionCalling: false }, + inputModalities: [], + available: true, }, - name: agent.name, - description: agent.description ?? null, - createdAt: agent.createdAt, - updatedAt: agent.updatedAt, - tools: agent.tools, - allowFileUploads: true, }; - agents.value?.['custom-agent'].models.push(agentModel); + agents.value?.['custom-agent'].models.push(agent); + customAgents.value[customAgent.id] = customAgent; await fetchAgents(credentials); telemetry.track('User created agent', { ...flattenModel(payload) }); - return agentModel; + return agent; } async function updateCustomAgent( @@ -771,18 +813,22 @@ export const useChatStore = defineStore(CHAT_STORE, () => { payload: ChatHubUpdateAgentRequest, credentials: CredentialsMap, ): Promise { - const agent = await updateAgentApi(rootStore.restApiContext, agentId, payload); + const customAgent = await updateAgentApi(rootStore.restApiContext, agentId, payload); // Update the agent in models as well if (agents.value?.['custom-agent']) { agents.value['custom-agent'].models = agents.value['custom-agent'].models.map((model) => - 'agentId' in model && model.agentId === agentId ? { ...model, name: agent.name } : model, + 'agentId' in model && model.agentId === agentId + ? { ...model, name: customAgent.name } + : model, ); } + customAgents.value[agentId] = customAgent; + await fetchAgents(credentials); - return agent; + return customAgent; } async function deleteCustomAgent(agentId: string, credentials: CredentialsMap) { @@ -795,6 +841,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => { ); } + delete customAgents.value[agentId]; + await fetchAgents(credentials); } @@ -813,7 +861,14 @@ export const useChatStore = defineStore(CHAT_STORE, () => { description: null, createdAt: null, updatedAt: null, - allowFileUploads: true, + // Assume file attachment and tools are supported + metadata: { + inputModalities: ['text', 'file'], + capabilities: { + functionCalling: true, + }, + available: true, + }, }; } @@ -858,6 +913,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => { */ agents: computed(() => agents.value ?? emptyChatModelsResponse), agentsReady: computed(() => agents.value !== undefined), + customAgents, getAgent, fetchAgents, getCustomAgent, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts index a1839cf745c..457d57e1c85 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.utils.ts @@ -8,6 +8,7 @@ import { type ChatMessageId, type ChatHubProvider, type ChatHubLLMProvider, + type ChatHubInputModality, } from '@n8n/api-types'; import type { ChatMessage, @@ -347,3 +348,26 @@ export function createSessionFromStreamingState(streaming: ChatStreamingState): ...flattenModel(streaming.model), }; } + +export function createMimeTypes(modalities: ChatHubInputModality[]): string { + // If 'file' modality is present, accept all file types + if (modalities.includes('file')) { + return '*/*'; + } + + const mimeTypes: string[] = ['text/*']; + + for (const modality of modalities) { + if (modality === 'image') { + mimeTypes.push('image/*'); + } + if (modality === 'audio') { + mimeTypes.push('audio/*'); + } + if (modality === 'video') { + mimeTypes.push('video/*'); + } + } + + return mimeTypes.join(','); +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue index 3b625b44ccd..77854c8e4de 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/AgentEditorModal.vue @@ -4,17 +4,24 @@ import { useMessage } from '@/app/composables/useMessage'; import { useToast } from '@/app/composables/useToast'; import { useChatStore } from '@/features/ai/chatHub/chat.store'; import ModelSelector from '@/features/ai/chatHub/components/ModelSelector.vue'; -import type { ChatHubBaseLLMModel, ChatHubProvider, ChatModelDto } from '@n8n/api-types'; +import type { + ChatHubBaseLLMModel, + ChatHubConversationModel, + ChatHubProvider, + ChatModelDto, +} from '@n8n/api-types'; import { N8nButton, N8nHeading, N8nInput, N8nInputLabel, N8nSpinner } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import { assert } from '@n8n/utils/assert'; import { createEventBus } from '@n8n/utils/event-bus'; -import { computed, ref, watch } from 'vue'; +import { computed, ref, useTemplateRef, watch } from 'vue'; import type { CredentialsMap } from '../chat.types'; import type { INode } from 'n8n-workflow'; import ToolsSelector from './ToolsSelector.vue'; import { isLlmProviderModel } from '@/features/ai/chatHub/chat.utils'; import { useCustomAgent } from '@/features/ai/chatHub/composables/useCustomAgent'; +import { useUIStore } from '@/app/stores/ui.store'; +import { TOOLS_SELECTOR_MODAL_KEY } from '@/features/ai/chatHub/constants'; const props = defineProps<{ modalName: string; @@ -30,6 +37,8 @@ const chatStore = useChatStore(); const i18n = useI18n(); const toast = useToast(); const message = useMessage(); +const uiStore = useUIStore(); + const modalBus = ref(createEventBus()); const customAgent = useCustomAgent(props.data.agentId); @@ -39,7 +48,9 @@ const systemPrompt = ref(''); const selectedModel = ref(null); const isSaving = ref(false); const isDeleting = ref(false); +const isOpened = ref(false); const tools = ref([]); +const nameInputRef = useTemplateRef('nameInput'); const agentSelectedCredentials = ref({}); const credentialIdForSelectedModelProvider = computed( @@ -78,6 +89,25 @@ const agentMergedCredentials = computed((): CredentialsMap => { }; }); +const canSelectTools = computed( + () => selectedAgent.value?.metadata.capabilities.functionCalling ?? false, +); + +modalBus.value.once('opened', () => { + isOpened.value = true; +}); + +// If the agent doesn't support tools anymore, reset tools +watch( + selectedAgent, + (agent) => { + if (agent && !agent.metadata.capabilities.functionCalling) { + tools.value = []; + } + }, + { immediate: true }, +); + watch( customAgent, (agent) => { @@ -96,6 +126,18 @@ watch( { immediate: true }, ); +watch( + [isOpened, isLoadingAgent, nameInputRef], + async ([opened, isLoading, name]) => { + if (opened && !isLoading) { + // autofocus attribute doesn't work in modal + // https://github.com/element-plus/element-plus/issues/15250 + name?.focus(); + } + }, + { immediate: true, flush: 'post' }, +); + function onCredentialSelected(provider: ChatHubProvider, credentialId: string | null) { agentSelectedCredentials.value = { ...agentSelectedCredentials.value, @@ -103,9 +145,9 @@ function onCredentialSelected(provider: ChatHubProvider, credentialId: string | }; } -function onModelChange(agent: ChatModelDto) { - assert(isLlmProviderModel(agent.model)); - selectedModel.value = agent.model; +function onModelChange(model: ChatHubConversationModel) { + assert(isLlmProviderModel(model)); + selectedModel.value = model; } async function onSave() { @@ -182,8 +224,16 @@ async function onDelete() { } } -function onSelectTools(newTools: INode[]) { - tools.value = newTools; +function onSelectTools() { + uiStore.openModalWithData({ + name: TOOLS_SELECTOR_MODAL_KEY, + data: { + selected: tools.value, + onConfirm: (newTools: INode[]) => { + tools.value = newTools; + }, + }, + }); } @@ -217,6 +267,7 @@ function onSelectTools(newTools: INode[]) { :required="true" >
- +
@@ -333,6 +393,7 @@ function onSelectTools(newTools: INode[]) { .row { display: flex; flex-direction: row; + gap: var(--spacing--sm); } .footer { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue index 45b3416b34c..f176cce9103 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/ChatConversationHeader.vue @@ -1,10 +1,12 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue index 4f1823db3c6..c1517b1be4d 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/components/CredentialSelectorModal.vue @@ -1,6 +1,6 @@