diff --git a/cypress/e2e/21-community-nodes.cy.ts b/cypress/e2e/21-community-nodes.cy.ts index 283c08d557..df534b5bf4 100644 --- a/cypress/e2e/21-community-nodes.cy.ts +++ b/cypress/e2e/21-community-nodes.cy.ts @@ -18,6 +18,18 @@ import { getVisibleSelect } from '../utils'; const credentialsModal = new CredentialsModal(); const nodeCreatorFeature = new NodeCreator(); const workflowPage = new WorkflowPage(); +const ADD_TO_WORKFLOW_BUTTON = 'Add to workflow'; + +const addCommunityNodeToCanvas = (name: string) => { + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type(name); + + nodeCreatorFeature.getters.getCreatorItem(name).find('.el-tooltip__trigger').should('exist'); + nodeCreatorFeature.actions.selectNode(name); + + cy.contains('span', name).should('be.visible'); + cy.contains(ADD_TO_WORKFLOW_BUTTON).should('be.visible').click(); +}; // We separate-out the custom nodes because they require injecting nodes and credentials // so the /nodes and /credentials endpoints are intercepted and non-cached. @@ -48,23 +60,36 @@ describe('Community and custom nodes in canvas', () => { }); }); + // next intercepts are not strictly needed, but they make the tests faster + // - intercept request to vetted community types, returning empty list + // - intercept request to vetted community type details, return null + // - intercept request npm registry, return 404 + // -------------------------------------------------------------------------- + cy.intercept('/community-node-types', (req) => { + req.reply({ + statusCode: 200, + body: { + data: [], + }, + }); + }); + + cy.intercept('GET', '/community-node-types/*', { + statusCode: 200, + body: null, + }); + + cy.intercept('GET', 'https://registry.npmjs.org/*', { + statusCode: 404, + body: {}, + }); + // -------------------------------------------------------------------------- + workflowPage.actions.visit(); }); it('should render and select community node', () => { - const customNode = 'E2E Node'; - - nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode); - - nodeCreatorFeature.getters - .getCreatorItem(customNode) - .find('.el-tooltip__trigger') - .should('exist'); - nodeCreatorFeature.actions.selectNode(customNode); - - // TODO: Replace once we have canvas feature utils - cy.get('.data-display .node-name').contains(customNode).should('exist'); + addCommunityNodeToCanvas('E2E Node'); const nodeParameters = () => cy.getByTestId('node-parameters'); const firstParameter = () => nodeParameters().find('.parameter-item').eq(0); @@ -87,7 +112,7 @@ describe('Community and custom nodes in canvas', () => { it('should render custom node with n8n credential', () => { workflowPage.actions.addNodeToCanvas('Manual'); - workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true); + addCommunityNodeToCanvas('E2E Node with native n8n credential'); workflowPage.getters.nodeCredentialsLabel().click(); workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.editCredentialModal().should('be.visible'); @@ -96,7 +121,7 @@ describe('Community and custom nodes in canvas', () => { it('should render custom node with custom credential', () => { workflowPage.actions.addNodeToCanvas('Manual'); - workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true); + addCommunityNodeToCanvas('E2E Node with custom credential'); workflowPage.getters.nodeCredentialsLabel().click(); workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.editCredentialModal().should('be.visible'); diff --git a/packages/@n8n/api-types/src/community-node-types.ts b/packages/@n8n/api-types/src/community-node-types.ts new file mode 100644 index 0000000000..fc543a762b --- /dev/null +++ b/packages/@n8n/api-types/src/community-node-types.ts @@ -0,0 +1,23 @@ +import type { INodeTypeDescription } from 'n8n-workflow'; + +export interface CommunityNodeAttributes { + authorGithubUrl: string; + authorName: string; + checksum: string; + description: string; + displayName: string; + name: string; + numberOfStars: number; + numberOfDownloads: number; + packageName: string; + createdAt: string; + updatedAt: string; + npmVersion: string; +} + +export interface CommunityNodeData { + id: number; + attributes: CommunityNodeAttributes & { + nodeDescription: INodeTypeDescription; + }; +} diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index c08fdce17a..1a4f1cfc17 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -111,6 +111,7 @@ export interface FrontendSettings { isMultiMain: boolean; pushBackend: 'sse' | 'websocket'; communityNodesEnabled: boolean; + unverifiedCommunityNodesEnabled: boolean; aiAssistant: { enabled: boolean; }; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 6fabab9a47..c3561e1629 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -5,6 +5,7 @@ export type * from './scaling'; export type * from './frontend-settings'; export type * from './user'; export type * from './api-keys'; +export type * from './community-node-types'; export type { Collaborator } from './push/collaboration'; export type { HeartbeatMessage } from './push/heartbeat'; diff --git a/packages/@n8n/config/src/configs/nodes.config.ts b/packages/@n8n/config/src/configs/nodes.config.ts index 577c4055ab..d3c43e1f95 100644 --- a/packages/@n8n/config/src/configs/nodes.config.ts +++ b/packages/@n8n/config/src/configs/nodes.config.ts @@ -33,6 +33,18 @@ class CommunityPackagesConfig { /** Whether to reinstall any missing community packages */ @Env('N8N_REINSTALL_MISSING_PACKAGES') reinstallMissing: boolean = false; + + /** Whether to block installation of not verified packages */ + @Env('N8N_UNVERIFIED_PACKAGES_ENABLED') + unverifiedEnabled: boolean = true; + + /** Whether to enable and show search suggestion of packages verified by n8n */ + @Env('N8N_VERIFIED_PACKAGES_ENABLED') + verifiedEnabled: boolean = true; + + /** Whether to load community packages */ + @Env('N8N_COMMUNITY_PACKAGES_PREVENT_LOADING') + preventLoading: boolean = false; } @Config diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 384e970bc9..02e30e0387 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -120,6 +120,9 @@ describe('GlobalConfig', () => { enabled: true, registry: 'https://registry.npmjs.org', reinstallMissing: false, + unverifiedEnabled: true, + verifiedEnabled: true, + preventLoading: false, }, errorTriggerType: 'n8n-nodes-base.errorTrigger', include: [], diff --git a/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts b/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts new file mode 100644 index 0000000000..1f1bfc3d6c --- /dev/null +++ b/packages/cli/src/controllers/__tests__/community-packages.controller.test.ts @@ -0,0 +1,84 @@ +import type { CommunityNodeAttributes } from '@n8n/api-types'; +import type { InstalledPackages } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; +import type { INodeTypeDescription } from 'n8n-workflow'; + +import { CommunityPackagesController } from '@/controllers/community-packages.controller'; +import type { NodeRequest } from '@/requests'; + +import type { EventService } from '../../events/event.service'; +import type { Push } from '../../push'; +import type { CommunityNodeTypesService } from '../../services/community-node-types.service'; +import type { CommunityPackagesService } from '../../services/community-packages.service'; + +describe('CommunityPackagesController', () => { + const push = mock(); + const communityPackagesService = mock(); + const eventService = mock(); + const communityNodeTypesService = mock(); + + const controller = new CommunityPackagesController( + push, + communityPackagesService, + eventService, + communityNodeTypesService, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('installPackage', () => { + it('should throw error if verify in options but no checksum', async () => { + const request = mock({ + user: { id: 'user123' }, + body: { name: 'n8n-nodes-test', verify: true }, + }); + communityNodeTypesService.findVetted.mockReturnValue(undefined); + await expect(controller.installPackage(request)).rejects.toThrow( + 'Package n8n-nodes-test is not vetted for installation', + ); + }); + + it('should have correct version', async () => { + const request = mock({ + user: { id: 'user123' }, + body: { name: 'n8n-nodes-test', verify: true, version: '1.0.0' }, + }); + communityNodeTypesService.findVetted.mockReturnValue( + mock({ + checksum: 'checksum', + }), + ); + communityPackagesService.parseNpmPackageName.mockReturnValue({ + rawString: 'n8n-nodes-test', + packageName: 'n8n-nodes-test', + version: '1.1.1', + }); + communityPackagesService.isPackageInstalled.mockResolvedValue(false); + communityPackagesService.hasPackageLoaded.mockReturnValue(false); + communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ + status: 'OK', + }); + communityPackagesService.installPackage.mockResolvedValue( + mock({ + installedNodes: [], + }), + ); + + await controller.installPackage(request); + + expect(communityPackagesService.installPackage).toHaveBeenCalledWith( + 'n8n-nodes-test', + '1.0.0', + 'checksum', + ); + expect(eventService.emit).toHaveBeenCalledWith( + 'community-package-installed', + expect.objectContaining({ + packageVersion: '1.0.0', + }), + ); + }); + }); +}); diff --git a/packages/cli/src/controllers/community-node-types.controller.ts b/packages/cli/src/controllers/community-node-types.controller.ts new file mode 100644 index 0000000000..23c46bc742 --- /dev/null +++ b/packages/cli/src/controllers/community-node-types.controller.ts @@ -0,0 +1,20 @@ +import type { CommunityNodeAttributes } from '@n8n/api-types'; +import { Get, RestController } from '@n8n/decorators'; +import { Request } from 'express'; + +import { CommunityNodeTypesService } from '@/services/community-node-types.service'; + +@RestController('/community-node-types') +export class CommunityNodeTypesController { + constructor(private readonly communityNodeTypesService: CommunityNodeTypesService) {} + + @Get('/:name') + async getCommunityNodeAttributes(req: Request): Promise { + return this.communityNodeTypesService.getCommunityNodeAttributes(req.params.name); + } + + @Get('/') + async getCommunityNodeTypes() { + return await this.communityNodeTypesService.getDescriptions(); + } +} diff --git a/packages/cli/src/controllers/community-packages.controller.ts b/packages/cli/src/controllers/community-packages.controller.ts index 12f448c6db..9d900f1d18 100644 --- a/packages/cli/src/controllers/community-packages.controller.ts +++ b/packages/cli/src/controllers/community-packages.controller.ts @@ -14,6 +14,8 @@ import { Push } from '@/push'; import { NodeRequest } from '@/requests'; import { CommunityPackagesService } from '@/services/community-packages.service'; +import { CommunityNodeTypesService } from '../services/community-node-types.service'; + const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED, @@ -37,17 +39,28 @@ export class CommunityPackagesController { private readonly push: Push, private readonly communityPackagesService: CommunityPackagesService, private readonly eventService: EventService, + private readonly communityNodeTypesService: CommunityNodeTypesService, ) {} @Post('/') @GlobalScope('communityPackage:install') async installPackage(req: NodeRequest.Post) { - const { name } = req.body; + const { name, verify, version } = req.body; if (!name) { throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); } + let checksum: string | undefined = undefined; + + // Get the checksum for the package if flagged to verify + if (verify) { + checksum = this.communityNodeTypesService.findVetted(name)?.checksum; + if (!checksum) { + throw new BadRequestError(`Package ${name} is not vetted for installation`); + } + } + let parsed: CommunityPackages.ParsedPackageName; try { @@ -85,11 +98,13 @@ export class CommunityPackagesController { throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`); } + const packageVersion = version ?? parsed.version; let installedPackage: InstalledPackages; try { installedPackage = await this.communityPackagesService.installPackage( parsed.packageName, - parsed.version, + packageVersion, + checksum, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; @@ -99,7 +114,7 @@ export class CommunityPackagesController { inputString: name, packageName: parsed.packageName, success: false, - packageVersion: parsed.version, + packageVersion, failureReason: errorMessage, }); @@ -130,7 +145,7 @@ export class CommunityPackagesController { inputString: name, packageName: parsed.packageName, success: true, - packageVersion: parsed.version, + packageVersion, packageNodeNames: installedPackage.installedNodes.map((node) => node.name), packageAuthor: installedPackage.authorName, packageAuthorEmail: installedPackage.authorEmail, @@ -266,7 +281,7 @@ export class CommunityPackagesController { this.push.broadcast({ type: 'reloadNodeType', data: { - name: node.name, + name: node.type, version: node.latestVersion, }, }); diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index ad076c53e5..2c6dd429d0 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -94,11 +94,13 @@ export class LoadNodesAndCredentials { await this.loadNodesFromNodeModules(nodeModulesDir, '@n8n/n8n-nodes-langchain'); } - // Load nodes from any other `n8n-nodes-*` packages in the download directory - // This includes the community nodes - await this.loadNodesFromNodeModules( - path.join(this.instanceSettings.nodesDownloadDir, 'node_modules'), - ); + if (!this.globalConfig.nodes.communityPackages.preventLoading) { + // Load nodes from any other `n8n-nodes-*` packages in the download directory + // This includes the community nodes + await this.loadNodesFromNodeModules( + path.join(this.instanceSettings.nodesDownloadDir, 'node_modules'), + ); + } await this.loadNodesFromCustomDirectories(); await this.postProcessLoaders(); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 9fdee440e9..7027b454b0 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -217,7 +217,7 @@ export declare namespace AnnotationTagsRequest { export declare namespace NodeRequest { type GetAll = AuthenticatedRequest; - type Post = AuthenticatedRequest<{}, {}, { name?: string }>; + type Post = AuthenticatedRequest<{}, {}, { name?: string; verify?: boolean; version?: string }>; type Delete = AuthenticatedRequest<{}, {}, {}, { name: string }>; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 605893e698..ee7eec4f4d 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -124,6 +124,7 @@ export class Server extends AbstractServer { if (this.globalConfig.nodes.communityPackages.enabled) { await import('@/controllers/community-packages.controller'); + await import('@/controllers/community-node-types.controller'); } if (inE2ETests) { diff --git a/packages/cli/src/services/__tests__/community-packages.service.test.ts b/packages/cli/src/services/__tests__/community-packages.service.test.ts index 5cb4721ae9..d7f7bb88dd 100644 --- a/packages/cli/src/services/__tests__/community-packages.service.test.ts +++ b/packages/cli/src/services/__tests__/community-packages.service.test.ts @@ -1,14 +1,15 @@ import type { GlobalConfig } from '@n8n/config'; +import { LICENSE_FEATURES } from '@n8n/constants'; import { InstalledNodes } from '@n8n/db'; import { InstalledPackages } from '@n8n/db'; import { InstalledNodesRepository } from '@n8n/db'; import { InstalledPackagesRepository } from '@n8n/db'; import axios from 'axios'; import { exec } from 'child_process'; -import { mkdir as fsMkdir } from 'fs/promises'; +import { mkdir as fsMkdir, readFile, writeFile } from 'fs/promises'; import { mocked } from 'jest-mock'; import { mock } from 'jest-mock-extended'; -import type { PackageDirectoryLoader } from 'n8n-core'; +import type { Logger, InstanceSettings, PackageDirectoryLoader } from 'n8n-core'; import type { PublicInstalledPackage } from 'n8n-workflow'; import { @@ -17,9 +18,11 @@ import { NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES, } from '@/constants'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import type { CommunityPackages } from '@/interfaces'; import type { License } from '@/license'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import type { Publisher } from '@/scaling/pubsub/publisher.service'; import { CommunityPackagesService } from '@/services/community-packages.service'; import { mockInstance } from '@test/mocking'; import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants'; @@ -45,6 +48,7 @@ describe('CommunityPackagesService', () => { communityPackages: { reinstallMissing: false, registry: 'some.random.host', + unverifiedEnabled: true, }, }, }); @@ -69,12 +73,19 @@ describe('CommunityPackagesService', () => { }); }); + const instanceSettings = mock({ + nodesDownloadDir: '/tmp/n8n-jest-global-downloads', + }); + + const logger = mock(); + const publisher = mock(); + const communityPackagesService = new CommunityPackagesService( - mock(), - mock(), - mock(), + instanceSettings, + logger, + installedPackageRepository, loadNodesAndCredentials, - mock(), + publisher, license, globalConfig, ); @@ -129,7 +140,9 @@ describe('CommunityPackagesService', () => { describe('executeCommand()', () => { beforeEach(() => { mocked(fsMkdir).mockReset(); + mocked(fsMkdir).mockResolvedValue(undefined); mocked(exec).mockReset(); + mocked(exec).mockImplementation(execMock); }); test('should call command with valid options', async () => { @@ -359,68 +372,169 @@ describe('CommunityPackagesService', () => { Object.assign(communityPackagesService, { missingPackages }); }; - describe('updateNpmModule', () => { - const installedPackage = mock({ packageName: mockPackageName() }); - const packageDirectoryLoader = mock({ - loadedNodes: [{ name: nodeName, version: 1 }], + describe('updatePackage', () => { + const PACKAGE_NAME = 'n8n-nodes-test'; + const installedPackageForUpdateTest = mock({ + packageName: PACKAGE_NAME, }); - beforeEach(async () => { + const packageDirectoryLoader = mock({ + loadedNodes: [{ name: 'a-node-from-the-loader', version: 1 }], + }); + + const testBlockDownloadDir = instanceSettings.nodesDownloadDir; + const testBlockPackageDir = `${testBlockDownloadDir}/node_modules/${PACKAGE_NAME}`; + const testBlockTarballName = `${PACKAGE_NAME}-latest.tgz`; + const testBlockRegistry = globalConfig.nodes.communityPackages.registry; + const testBlockNpmInstallArgs = [ + '--audit=false', + '--fund=false', + '--bin-links=false', + '--install-strategy=shallow', + '--ignore-scripts=true', + '--package-lock=false', + `--registry=${testBlockRegistry}`, + ].join(' '); + + const execMockForThisBlock = (command: string, optionsOrCallback: any, callback?: any) => { + const actualCallback = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + if (command.startsWith('npm pack') && command.includes(PACKAGE_NAME)) { + actualCallback(null, { stdout: testBlockTarballName, stderr: '' }); + } else { + actualCallback(null, 'Done', ''); + } + }; + + beforeEach(() => { jest.clearAllMocks(); + mocked(exec).mockImplementation(execMockForThisBlock as typeof exec); + + mocked(fsMkdir).mockResolvedValue(undefined); + mocked(readFile).mockResolvedValue( + JSON.stringify({ + name: PACKAGE_NAME, + version: '1.0.0', // Mocked version from package.json inside tarball + dependencies: { 'some-actual-dep': '1.2.3' }, + devDependencies: { 'a-dev-dep': '1.0.0' }, + peerDependencies: { 'a-peer-dep': '2.0.0' }, + optionalDependencies: { 'an-optional-dep': '3.0.0' }, + }), + ); + mocked(writeFile).mockResolvedValue(undefined); + loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader); - mocked(exec).mockImplementation(execMock); + loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined); + loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined); + + installedPackageRepository.remove.mockResolvedValue(undefined as any); + installedPackageRepository.saveInstalledPackageWithNodes.mockResolvedValue( + installedPackageForUpdateTest, + ); + + publisher.publishCommand.mockResolvedValue(undefined); }); - test('should call `exec` with the correct command and registry', async () => { - // + test('should call `exec` with the correct sequence of commands, handle file ops, and interact with services', async () => { // ARRANGE - // license.isCustomNpmRegistryEnabled.mockReturnValue(true); - // // ACT - // - await communityPackagesService.updatePackage(installedPackage.packageName, installedPackage); + await communityPackagesService.updatePackage( + installedPackageForUpdateTest.packageName, + installedPackageForUpdateTest, + ); - // - // ASSERT - // + // ASSERT: + expect(exec).toHaveBeenCalledTimes(5); - expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenNthCalledWith( 1, - `npm install ${installedPackage.packageName}@latest --audit=false --fund=false --bin-links=false --install-strategy=shallow --omit=dev --omit=optional --omit=peer --registry=some.random.host`, - expect.any(Object), + `rm -rf ${testBlockPackageDir}`, expect.any(Function), ); - expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith( - installedPackage.packageName, + + expect(exec).toHaveBeenNthCalledWith( + 2, + `npm pack ${PACKAGE_NAME}@latest --registry=${testBlockRegistry} --quiet`, + { cwd: testBlockDownloadDir }, + expect.any(Function), ); - expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith( - installedPackage.packageName, + + expect(exec).toHaveBeenNthCalledWith( + 3, + `tar -xzf ${testBlockTarballName} -C ${testBlockPackageDir} --strip-components=1`, + { cwd: testBlockDownloadDir }, + expect.any(Function), ); + + expect(exec).toHaveBeenNthCalledWith( + 4, + `npm install ${testBlockNpmInstallArgs}`, + { cwd: testBlockPackageDir }, + expect.any(Function), + ); + + expect(exec).toHaveBeenNthCalledWith( + 5, + `rm ${testBlockTarballName}`, + { cwd: testBlockDownloadDir }, + expect.any(Function), + ); + + expect(fsMkdir).toHaveBeenCalledWith(testBlockPackageDir, { recursive: true }); + expect(readFile).toHaveBeenCalledWith(`${testBlockPackageDir}/package.json`, 'utf-8'); + expect(writeFile).toHaveBeenCalledWith( + `${testBlockPackageDir}/package.json`, + JSON.stringify( + { + name: PACKAGE_NAME, + version: '1.0.0', + dependencies: { 'some-actual-dep': '1.2.3' }, + }, + null, + 2, + ), + 'utf-8', + ); + + expect(loadNodesAndCredentials.unloadPackage).toHaveBeenCalledWith(PACKAGE_NAME); + expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME); + expect(loadNodesAndCredentials.postProcessLoaders).toHaveBeenCalledTimes(1); + + expect(installedPackageRepository.remove).toHaveBeenCalledWith(installedPackageForUpdateTest); + expect(installedPackageRepository.saveInstalledPackageWithNodes).toHaveBeenCalledWith( + packageDirectoryLoader, + ); + + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'community-package-update', + payload: { packageName: PACKAGE_NAME, packageVersion: 'latest' }, + }); }); - test('should throw when not licensed', async () => { - // + test('should throw when not licensed for custom registry if custom registry is different from default', async () => { // ARRANGE - // license.isCustomNpmRegistryEnabled.mockReturnValue(false); - // - // ACT - // - const promise = communityPackagesService.updatePackage( - installedPackage.packageName, - installedPackage, + // ACT & ASSERT + await expect( + communityPackagesService.updatePackage( + installedPackageForUpdateTest.packageName, + installedPackageForUpdateTest, + ), + ).rejects.toThrow( + new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY), ); + }); + }); - // - // ASSERT - // - await expect(promise).rejects.toThrow( - 'Your license does not allow for feat:communityNodes:customRegistry.', + describe('installPackage', () => { + test('should throw when installation of not vetted packages is forbidden', async () => { + globalConfig.nodes.communityPackages.unverifiedEnabled = false; + globalConfig.nodes.communityPackages.registry = 'https://registry.npmjs.org'; + await expect(communityPackagesService.installPackage('package', '0.1.0')).rejects.toThrow( + 'Installation of non-vetted community packages is forbidden!', ); }); }); diff --git a/packages/cli/src/services/community-node-types.service.ts b/packages/cli/src/services/community-node-types.service.ts new file mode 100644 index 0000000000..1beb536b21 --- /dev/null +++ b/packages/cli/src/services/community-node-types.service.ts @@ -0,0 +1,106 @@ +import type { CommunityNodeAttributes, CommunityNodeData } from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; +import { Logger } from 'n8n-core'; +import type { INodeTypeDescription } from 'n8n-workflow'; +import { ensureError } from 'n8n-workflow'; + +import { CommunityPackagesService } from './community-packages.service'; +import { paginatedRequest } from '../utils/community-nodes-request-utils'; + +const UPDATE_INTERVAL = 8 * 60 * 60 * 1000; + +const N8N_VETTED_NODE_TYPES_STAGING_URL = 'https://api-staging.n8n.io/api/community-nodes'; +const N8N_VETTED_NODE_TYPES_PRODUCTION_URL = 'https://api.n8n.io/api/community-nodes'; + +@Service() +export class CommunityNodeTypesService { + private communityNodes: { + [key: string]: CommunityNodeAttributes & { + nodeDescription: INodeTypeDescription; + }; + } = {}; + + private lastUpdateTimestamp = 0; + + constructor( + private readonly logger: Logger, + private globalConfig: GlobalConfig, + private communityPackagesService: CommunityPackagesService, + ) {} + + private async fetchNodeTypes() { + try { + let data: CommunityNodeData[] = []; + if ( + this.globalConfig.nodes.communityPackages.enabled && + this.globalConfig.nodes.communityPackages.verifiedEnabled + ) { + const environment = this.globalConfig.license.tenantId === 1 ? 'production' : 'staging'; + const url = + environment === 'production' + ? N8N_VETTED_NODE_TYPES_PRODUCTION_URL + : N8N_VETTED_NODE_TYPES_STAGING_URL; + data = await paginatedRequest(url); + } + + this.updateData(data); + } catch (error) { + this.logger.error('Failed to fetch community node types', { error: ensureError(error) }); + } + } + + private updateData(data: CommunityNodeData[]) { + if (!data?.length) return; + + this.resetData(); + + for (const entry of data) { + this.communityNodes[entry.attributes.name] = entry.attributes; + } + + this.lastUpdateTimestamp = Date.now(); + } + + private resetData() { + this.communityNodes = {}; + } + + private updateRequired() { + if (!this.lastUpdateTimestamp) return true; + return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL; + } + + async getDescriptions(): Promise { + const nodesDescriptions: INodeTypeDescription[] = []; + + if (this.updateRequired() || !Object.keys(this.communityNodes).length) { + await this.fetchNodeTypes(); + } + + const installedPackages = ( + (await this.communityPackagesService.getAllInstalledPackages()) ?? [] + ).map((p) => p.packageName); + + for (const node of Object.values(this.communityNodes)) { + if (installedPackages.includes(node.name.split('.')[0])) continue; + nodesDescriptions.push(node.nodeDescription); + } + + return nodesDescriptions; + } + + getCommunityNodeAttributes(type: string): CommunityNodeAttributes | null { + const node = this.communityNodes[type]; + if (!node) return null; + const { nodeDescription, ...attributes } = node; + return attributes; + } + + findVetted(packageName: string) { + const vettedTypes = Object.keys(this.communityNodes); + const nodeName = vettedTypes.find((t) => t.includes(packageName)); + if (!nodeName) return; + return this.communityNodes[nodeName]; + } +} diff --git a/packages/cli/src/services/community-packages.service.ts b/packages/cli/src/services/community-packages.service.ts index 52931bb4d3..fa7355fc92 100644 --- a/packages/cli/src/services/community-packages.service.ts +++ b/packages/cli/src/services/community-packages.service.ts @@ -5,7 +5,7 @@ import { InstalledPackagesRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import axios from 'axios'; import { exec } from 'child_process'; -import { mkdir as fsMkdir } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import type { PackageDirectoryLoader } from 'n8n-core'; import { InstanceSettings, Logger } from 'n8n-core'; import { UnexpectedError, UserError, type PublicInstalledPackage } from 'n8n-workflow'; @@ -25,14 +25,15 @@ import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { toError } from '@/utils'; +import { verifyIntegrity } from '../utils/npm-utils'; + const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const NPM_COMMON_ARGS = ['--audit=false', '--fund=false']; const NPM_INSTALL_ARGS = [ '--bin-links=false', '--install-strategy=shallow', - '--omit=dev', - '--omit=optional', - '--omit=peer', + '--ignore-scripts=true', + '--package-lock=false', ]; const { @@ -133,6 +134,7 @@ export class CommunityPackagesService { return { packageName, scope, version, rawString }; } + /** @deprecated */ async executeNpmCommand(command: string, options?: { doNotHandleError?: boolean }) { const downloadFolder = this.instanceSettings.nodesDownloadDir; @@ -146,7 +148,7 @@ export class CommunityPackagesService { }, }; - await fsMkdir(downloadFolder, { recursive: true }); + await mkdir(downloadFolder, { recursive: true }); try { const commandResult = await asyncExec(command, execOptions); @@ -308,8 +310,12 @@ export class CommunityPackagesService { ); } - async installPackage(packageName: string, version?: string): Promise { - return await this.installOrUpdatePackage(packageName, { version }); + async installPackage( + packageName: string, + version?: string, + checksum?: string, + ): Promise { + return await this.installOrUpdatePackage(packageName, { version, checksum }); } async updatePackage( @@ -328,24 +334,44 @@ export class CommunityPackagesService { }); } - private getNpmInstallArgs() { + private getNpmRegistry() { const { registry } = this.globalConfig.nodes.communityPackages; if (registry !== DEFAULT_REGISTRY && !this.license.isCustomNpmRegistryEnabled()) { throw new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY); } - return [...NPM_COMMON_ARGS, ...NPM_INSTALL_ARGS, `--registry=${registry}`].join(' '); + return registry; + } + + private getNpmInstallArgs() { + return [...NPM_COMMON_ARGS, ...NPM_INSTALL_ARGS, `--registry=${this.getNpmRegistry()}`].join( + ' ', + ); + } + + private checkInstallPermissions(isUpdate: boolean, checksumProvided: boolean) { + if (isUpdate) return; + + if (!this.globalConfig.nodes.communityPackages.unverifiedEnabled && !checksumProvided) { + throw new UnexpectedError('Installation of non-vetted community packages is forbidden!'); + } } private async installOrUpdatePackage( packageName: string, - options: { version?: string } | { installedPackage: InstalledPackages }, + options: { version?: string; checksum?: string } | { installedPackage: InstalledPackages }, ) { const isUpdate = 'installedPackage' in options; const packageVersion = isUpdate || !options.version ? 'latest' : options.version; - const command = `npm install ${packageName}@${packageVersion} ${this.getNpmInstallArgs()}`; + + const shouldValidateChecksum = 'checksum' in options && Boolean(options.checksum); + this.checkInstallPermissions(isUpdate, shouldValidateChecksum); + + if (!isUpdate && options.checksum) { + await verifyIntegrity(packageName, packageVersion, this.getNpmRegistry(), options.checksum); + } try { - await this.executeNpmCommand(command); + await this.downloadPackage(packageName, packageVersion); } catch (error) { if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { throw new UserError('npm package not found', { extra: { packageName } }); @@ -360,7 +386,7 @@ export class CommunityPackagesService { } catch (error) { // Remove this package since loading it failed try { - await this.executeNpmCommand(`npm remove ${packageName}`); + await this.deletePackageDirectory(packageName); } catch {} throw new UnexpectedError(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); } @@ -388,25 +414,73 @@ export class CommunityPackagesService { } else { // Remove this package since it contains no loadable nodes try { - await this.executeNpmCommand(`npm remove ${packageName}`); + await this.deletePackageDirectory(packageName); } catch {} throw new UnexpectedError(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); } } async installOrUpdateNpmPackage(packageName: string, packageVersion: string) { - await this.executeNpmCommand( - `npm install ${packageName}@${packageVersion} ${this.getNpmInstallArgs()}`, - ); + await this.downloadPackage(packageName, packageVersion); await this.loadNodesAndCredentials.loadPackage(packageName); await this.loadNodesAndCredentials.postProcessLoaders(); this.logger.info(`Community package installed: ${packageName}`); } async removeNpmPackage(packageName: string) { - await this.executeNpmCommand(`npm remove ${packageName}`); + await this.deletePackageDirectory(packageName); await this.loadNodesAndCredentials.unloadPackage(packageName); await this.loadNodesAndCredentials.postProcessLoaders(); this.logger.info(`Community package uninstalled: ${packageName}`); } + + private resolvePackageDirectory(packageName: string) { + const downloadFolder = this.instanceSettings.nodesDownloadDir; + return `${downloadFolder}/node_modules/${packageName}`; + } + + private async downloadPackage(packageName: string, packageVersion: string): Promise { + const registry = this.getNpmRegistry(); + const downloadFolder = this.instanceSettings.nodesDownloadDir; + const packageDirectory = this.resolvePackageDirectory(packageName); + + // (Re)create the packageDir + await this.deletePackageDirectory(packageName); + await mkdir(packageDirectory, { recursive: true }); + + // TODO: make sure that this works for scoped packages as well + // if (packageName.startsWith('@') && packageName.includes('/')) {} + + const { stdout: tarOutput } = await asyncExec( + `npm pack ${packageName}@${packageVersion} --registry=${registry} --quiet`, + { cwd: downloadFolder }, + ); + + const tarballName = tarOutput?.trim(); + + try { + await asyncExec(`tar -xzf ${tarballName} -C ${packageDirectory} --strip-components=1`, { + cwd: downloadFolder, + }); + + // Strip dev, optional, and peer dependencies before running `npm install` + const packageJsonPath = `${packageDirectory}/package.json`; + const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { devDependencies, peerDependencies, optionalDependencies, ...packageJson } = + JSON.parse(packageJsonContent); + await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8'); + + await asyncExec(`npm install ${this.getNpmInstallArgs()}`, { cwd: packageDirectory }); + } finally { + await asyncExec(`rm ${tarballName}`, { cwd: downloadFolder }); + } + + return packageDirectory; + } + + private async deletePackageDirectory(packageName: string) { + const packageDirectory = this.resolvePackageDirectory(packageName); + await asyncExec(`rm -rf ${packageDirectory}`); + } } diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 7f2286b476..be64b7dcf3 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -178,6 +178,7 @@ export class FrontendService { isMultiMain: this.instanceSettings.isMultiMain, pushBackend: this.pushConfig.backend, communityNodesEnabled: this.globalConfig.nodes.communityPackages.enabled, + unverifiedCommunityNodesEnabled: this.globalConfig.nodes.communityPackages.unverifiedEnabled, deployment: { type: config.getEnv('deployment.type'), }, diff --git a/packages/cli/src/utils/__tests__/community-nodes-request.test.ts b/packages/cli/src/utils/__tests__/community-nodes-request.test.ts new file mode 100644 index 0000000000..723f84da5a --- /dev/null +++ b/packages/cli/src/utils/__tests__/community-nodes-request.test.ts @@ -0,0 +1,125 @@ +import type { CommunityNodeData } from '@n8n/api-types'; +import nock from 'nock'; + +import { paginatedRequest } from '../community-nodes-request-utils'; + +describe('strapiPaginatedRequest', () => { + const baseUrl = 'https://strapi.test/api/nodes'; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should fetch and combine multiple pages of data', async () => { + const page1 = [ + { + id: 1, + attributes: { name: 'Node1', nodeDescription: { name: 'n1', version: 1 } } as any, + }, + ]; + + const page2 = [ + { + id: 2, + attributes: { name: 'Node2', nodeDescription: { name: 'n2', version: 2 } } as any, + }, + ]; + + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: page1, + meta: { + pagination: { + page: 1, + pageSize: 25, + pageCount: 2, + total: 2, + }, + }, + }); + + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: page2, + meta: { + pagination: { + page: 2, + pageSize: 25, + pageCount: 2, + total: 2, + }, + }, + }); + + const result = await paginatedRequest('https://strapi.test/api/nodes'); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(1); + expect(result[1].id).toBe(2); + }); + + it('should return empty array if no data', async () => { + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: [], + meta: { + pagination: { + page: 1, + pageSize: 25, + pageCount: 0, + total: 0, + }, + }, + }); + + const result = await paginatedRequest(baseUrl); + expect(result).toEqual([]); + }); + + it('should return single page data', async () => { + const singlePage: CommunityNodeData[] = [ + { + id: 1, + attributes: { + name: 'NodeSingle', + nodeDescription: { name: 'n1', version: 1 }, + } as any, + }, + ]; + + nock('https://strapi.test') + .get('/api/nodes') + .query(true) + .reply(200, { + data: singlePage, + meta: { + pagination: { + page: 1, + pageSize: 25, + pageCount: 1, + total: 1, + }, + }, + }); + + const result = await paginatedRequest(baseUrl); + expect(result).toHaveLength(1); + expect(result[0].attributes.name).toBe('NodeSingle'); + }); + + it('should return an empty array if the request fails', async () => { + const endpoint = '/nodes'; + + nock(baseUrl).get(endpoint).query(true).replyWithError('Request failed'); + + const result = await paginatedRequest(`${baseUrl}${endpoint}`); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/__tests__/npm-utils.test.ts b/packages/cli/src/utils/__tests__/npm-utils.test.ts new file mode 100644 index 0000000000..ed8166e312 --- /dev/null +++ b/packages/cli/src/utils/__tests__/npm-utils.test.ts @@ -0,0 +1,67 @@ +import { UnexpectedError } from 'n8n-workflow'; +import nock from 'nock'; + +import { verifyIntegrity } from '../npm-utils'; + +describe('verifyIntegrity', () => { + const registryUrl = 'https://registry.npmjs.org'; + const packageName = 'test-package'; + const version = '1.0.0'; + const integrity = 'sha512-hash=='; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should verify integrity successfully', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .reply(200, { + dist: { integrity }, + }); + + await expect( + verifyIntegrity(packageName, version, registryUrl, integrity), + ).resolves.not.toThrow(); + }); + + it('should throw error if checksum does not match', async () => { + const wrongHash = 'sha512-nottherighthash=='; + + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .reply(200, { + dist: { integrity }, + }); + + await expect(verifyIntegrity(packageName, version, registryUrl, wrongHash)).rejects.toThrow( + UnexpectedError, + ); + }); + + it('should throw error if metadata request fails', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .reply(500); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(); + }); + + it('should throw UnexpectedError and preserve original error as cause', async () => { + const integrity = 'sha512-somerandomhash=='; + + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + try { + await verifyIntegrity(packageName, version, registryUrl, integrity); + throw new Error('Expected error was not thrown'); + } catch (error: any) { + expect(error).toBeInstanceOf(UnexpectedError); + expect(error.message).toBe('Checksum verification failed'); + expect(error.cause).toBeDefined(); + expect(error.cause.message).toContain('Network failure'); + } + }); +}); diff --git a/packages/cli/src/utils/community-nodes-request-utils.ts b/packages/cli/src/utils/community-nodes-request-utils.ts new file mode 100644 index 0000000000..da63ef24c5 --- /dev/null +++ b/packages/cli/src/utils/community-nodes-request-utils.ts @@ -0,0 +1,68 @@ +import type { CommunityNodeData } from '@n8n/api-types'; +import { Container } from '@n8n/di'; +import axios from 'axios'; +import { ErrorReporter, Logger } from 'n8n-core'; + +interface ResponseData { + data: CommunityNodeData[]; + meta: Meta; +} + +interface Meta { + pagination: Pagination; +} + +interface Pagination { + page: number; + pageSize: number; + pageCount: number; + total: number; +} + +export async function paginatedRequest(url: string): Promise { + let returnData: CommunityNodeData[] = []; + let responseData: CommunityNodeData[] | undefined = []; + + const params = { + pagination: { + page: 1, + pageSize: 25, + }, + }; + + do { + let response; + try { + response = await axios.get(url, { + headers: { 'Content-Type': 'application/json' }, + params, + }); + } catch (error) { + Container.get(ErrorReporter).error(error, { + tags: { source: 'communityNodesPaginatedRequest' }, + }); + Container.get(Logger).error( + `Error while fetching community nodes: ${(error as Error).message}`, + ); + break; + } + + responseData = response?.data?.data; + + if (!responseData?.length) break; + + returnData = returnData.concat(responseData); + + if (response?.data?.meta?.pagination) { + const { page, pageCount } = response?.data.meta.pagination; + + if (page === pageCount) { + break; + } + } + + params.pagination.page++; + } while (responseData?.length); + + return returnData; +} diff --git a/packages/cli/src/utils/npm-utils.ts b/packages/cli/src/utils/npm-utils.ts new file mode 100644 index 0000000000..873daae19d --- /dev/null +++ b/packages/cli/src/utils/npm-utils.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; +import { UnexpectedError } from 'n8n-workflow'; + +const REQUEST_TIMEOUT = 30000; + +export async function verifyIntegrity( + packageName: string, + version: string, + registryUrl: string, + expectedIntegrity: string, +) { + const timeoutOption = { timeout: REQUEST_TIMEOUT }; + + try { + const url = `${registryUrl.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`; + const metadata = await axios.get<{ dist: { integrity: string } }>( + `${url}/${version}`, + timeoutOption, + ); + + if (metadata?.data?.dist?.integrity !== expectedIntegrity) { + throw new UnexpectedError('Checksum verification failed. Package integrity does not match.'); + } + } catch (error) { + throw new UnexpectedError('Checksum verification failed', { cause: error }); + } +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue index b58f864c7c..1cb98a2499 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue @@ -113,6 +113,8 @@ const { t } = useI18n(); } .tooltipIcon { margin-left: var(--spacing-3xs); + color: var(--color-text-base); + font-size: var(--font-size-2xs); } .panelArrow { font-size: var(--font-size-2xs); diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 47fc2a6d7b..f3af21136d 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -11,6 +11,7 @@ export const defaultSettings: FrontendSettings = { }, allowedModules: {}, communityNodesEnabled: false, + unverifiedCommunityNodesEnabled: true, defaultLocale: '', endpointForm: '', endpointFormTest: '', diff --git a/packages/frontend/editor-ui/src/api/communityNodes.ts b/packages/frontend/editor-ui/src/api/communityNodes.ts index 07c73beae4..f376fa59bd 100644 --- a/packages/frontend/editor-ui/src/api/communityNodes.ts +++ b/packages/frontend/editor-ui/src/api/communityNodes.ts @@ -12,8 +12,10 @@ export async function getInstalledCommunityNodes( export async function installNewPackage( context: IRestApiContext, name: string, + verify?: boolean, + version?: string, ): Promise { - return await post(context.baseUrl, '/community-packages', { name }); + return await post(context.baseUrl, '/community-packages', { name, verify, version }); } export async function uninstallPackage(context: IRestApiContext, name: string): Promise { diff --git a/packages/frontend/editor-ui/src/api/nodeTypes.ts b/packages/frontend/editor-ui/src/api/nodeTypes.ts index 01537a8717..a1860e4a20 100644 --- a/packages/frontend/editor-ui/src/api/nodeTypes.ts +++ b/packages/frontend/editor-ui/src/api/nodeTypes.ts @@ -6,19 +6,51 @@ import type { } from '@n8n/api-types'; import { makeRestApiRequest } from '@/utils/apiUtils'; import type { INodeTranslationHeaders, IRestApiContext } from '@/Interface'; -import type { - INodeListSearchResult, - INodePropertyOptions, - INodeTypeDescription, - INodeTypeNameVersion, - NodeParameterValueType, - ResourceMapperFields, +import type { CommunityNodeAttributes } from '@n8n/api-types'; +import { + type INodeListSearchResult, + type INodePropertyOptions, + type INodeTypeDescription, + type INodeTypeNameVersion, + type NodeParameterValueType, + type ResourceMapperFields, + sleep, } from 'n8n-workflow'; import axios from 'axios'; +async function fetchNodeTypesJsonWithRetry(url: string, retries = 5, delay = 500) { + for (let attempt = 0; attempt < retries; attempt++) { + const response = await axios.get(url, { withCredentials: true }); + + if (typeof response.data === 'object' && response.data !== null) { + return response.data; + } + + await sleep(delay * attempt); + } + + throw new Error('Could not fetch node types'); +} + export async function getNodeTypes(baseUrl: string) { - const { data } = await axios.get(baseUrl + 'types/nodes.json', { withCredentials: true }); - return data; + return await fetchNodeTypesJsonWithRetry(baseUrl + 'types/nodes.json'); +} + +export async function fetchCommunityNodeTypes( + context: IRestApiContext, +): Promise { + return await makeRestApiRequest(context, 'GET', '/community-node-types'); +} + +export async function fetchCommunityNodeAttributes( + context: IRestApiContext, + type: string, +): Promise { + return await makeRestApiRequest( + context, + 'GET', + `/community-node-types/${encodeURIComponent(type)}`, + ); } export async function getNodeTranslationHeaders( diff --git a/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue b/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue index 4a0acf7946..3d93020d83 100644 --- a/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue +++ b/packages/frontend/editor-ui/src/components/ButtonParameter/ButtonParameter.vue @@ -109,7 +109,7 @@ async function onSubmit() { ); if (!updateInformation) return; - //updade code parameter + //update code parameter emit('valueChanged', updateInformation); //update code generated for prompt parameter diff --git a/packages/frontend/editor-ui/src/components/CommunityPackageCard.vue b/packages/frontend/editor-ui/src/components/CommunityPackageCard.vue index db58a6c6cd..0562df64d0 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPackageCard.vue +++ b/packages/frontend/editor-ui/src/components/CommunityPackageCard.vue @@ -4,6 +4,7 @@ import type { PublicInstalledPackage } from 'n8n-workflow'; import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; +import { useSettingsStore } from '@/stores/settings.store'; interface Props { communityPackage?: PublicInstalledPackage | null; @@ -19,6 +20,7 @@ const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallCon useUIStore(); const i18n = useI18n(); const telemetry = useTelemetry(); +const settingsStore = useSettingsStore(); const packageActions = [ { @@ -95,7 +97,10 @@ function onUpdateClick() { - + -