diff --git a/.changeset/three-crews-allow.md b/.changeset/three-crews-allow.md new file mode 100644 index 00000000000..534cac8ec6b --- /dev/null +++ b/.changeset/three-crews-allow.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/license': minor +'@rocket.chat/meteor': minor +--- + +Added support for interacting with add-ons issued in the license diff --git a/apps/meteor/ee/app/license/server/canEnableApp.ts b/apps/meteor/ee/app/license/server/canEnableApp.ts index 72220e27aca..c4ad4d5bcf7 100644 --- a/apps/meteor/ee/app/license/server/canEnableApp.ts +++ b/apps/meteor/ee/app/license/server/canEnableApp.ts @@ -1,25 +1,49 @@ import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; import { Apps } from '@rocket.chat/core-services'; -import { License } from '@rocket.chat/license'; +import type { LicenseModule } from '@rocket.chat/core-typings'; +import { License, type LicenseImp } from '@rocket.chat/license'; import { getInstallationSourceFromAppStorageItem } from '../../../../lib/apps/getInstallationSourceFromAppStorageItem'; -export const canEnableApp = async (app: IAppStorageItem): Promise => { +type _canEnableAppDependencies = { + Apps: typeof Apps; + License: LicenseImp; +}; + +export const _canEnableApp = async ({ Apps, License }: _canEnableAppDependencies, app: IAppStorageItem): Promise => { if (!(await Apps.isInitialized())) { - return false; + throw new Error('apps-engine-not-initialized'); } // Migrated apps were installed before the validation was implemented // so they're always allowed to be enabled if (app.migrated) { - return true; + return; + } + + if (app.info.addon && !License.hasModule(app.info.addon as LicenseModule)) { + throw new Error('app-addon-not-valid'); } const source = getInstallationSourceFromAppStorageItem(app); switch (source) { case 'private': - return !(await License.shouldPreventAction('privateApps')); + if (await License.shouldPreventAction('privateApps')) { + throw new Error('license-prevented'); + } + + break; default: - return !(await License.shouldPreventAction('marketplaceApps')); + if (await License.shouldPreventAction('marketplaceApps')) { + throw new Error('license-prevented'); + } + + if (app.marketplaceInfo?.isEnterpriseOnly && !License.hasValidLicense()) { + throw new Error('invalid-license'); + } + + break; } }; + +export const canEnableApp = async (app: IAppStorageItem): Promise => _canEnableApp({ Apps, License }, app); diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 3b80c37f799..fc597d00857 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -1,9 +1,7 @@ import { AppStatus, AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import type { AppManager } from '@rocket.chat/apps-engine/server/AppManager'; -import { AppInstallationSource } from '@rocket.chat/apps-engine/server/storage'; import type { IUser, IMessage } from '@rocket.chat/core-typings'; -import { License } from '@rocket.chat/license'; import { Settings, Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; @@ -20,7 +18,6 @@ import { i18n } from '../../../../server/lib/i18n'; import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; import { canEnableApp } from '../../../app/license/server/canEnableApp'; import { formatAppInstanceForRest } from '../../../lib/misc/formatAppInstanceForRest'; -import { appEnableCheck } from '../marketplace/appEnableCheck'; import { notifyAppInstall } from '../marketplace/appInstall'; import type { AppServerOrchestrator } from '../orchestrator'; import { Apps } from '../orchestrator'; @@ -418,9 +415,13 @@ export class AppsRestApi { void notifyAppInstall(orchestrator.getMarketplaceUrl() as string, 'install', info); - if (await canEnableApp(aff.getApp().getStorageItem())) { + try { + await canEnableApp(aff.getApp().getStorageItem()); + const success = await manager.enable(info.id); info.status = success ? AppStatus.AUTO_ENABLED : info.status; + } catch (error) { + orchestrator.getRocketChatLogger().warn(`App "${info.id}" was installed but could not be enabled: `, error); } void orchestrator.getNotifier().appAdded(info.id); @@ -1157,33 +1158,14 @@ export class AppsRestApi { return API.v1.notFound(`No App found by the id of: ${appId}`); } - const storedApp = prl.getStorageItem(); - const { installationSource, marketplaceInfo } = storedApp; - - if (!License.hasValidLicense() && installationSource === AppInstallationSource.MARKETPLACE) { + if (AppStatusUtils.isEnabled(status)) { try { - const baseUrl = orchestrator.getMarketplaceUrl() as string; - const headers = getDefaultHeaders(); - const { version } = prl.getInfo(); - - await appEnableCheck({ - baseUrl, - headers, - appId, - version, - marketplaceInfo, - status, - logger: orchestrator.getRocketChatLogger(), - }); - } catch (error: any) { - return API.v1.failure(error.message); + await canEnableApp(prl.getStorageItem()); + } catch (error: unknown) { + return API.v1.failure((error as Error).message); } } - if (AppStatusUtils.isEnabled(status) && !(await canEnableApp(storedApp))) { - return API.v1.failure('Enabled apps have been maxed out'); - } - const result = await manager.changeStatus(prl.getID(), status); return API.v1.success({ status: result.getStatus() }); }, diff --git a/apps/meteor/ee/server/apps/marketplace/appEnableCheck.ts b/apps/meteor/ee/server/apps/marketplace/appEnableCheck.ts deleted file mode 100644 index 959b8ff5f8e..00000000000 --- a/apps/meteor/ee/server/apps/marketplace/appEnableCheck.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; -import type { IMarketplaceInfo } from '@rocket.chat/apps-engine/server/marketplace'; -import type { Logger } from '@rocket.chat/logger'; - -import { getMarketplaceAppInfo } from './appInfo'; - -export const appEnableCheck = async ({ - baseUrl, - headers, - appId, - version, - logger, - status, - marketplaceInfo, -}: { - baseUrl: string; - headers: Record; - appId: string; - version: string; - logger: Logger; - status: AppStatus; - marketplaceInfo?: IMarketplaceInfo; -}) => { - let isAppEnterpriseOnly = false; - - if (marketplaceInfo?.isEnterpriseOnly !== undefined) { - isAppEnterpriseOnly = marketplaceInfo.isEnterpriseOnly; - } else { - try { - const { isEnterpriseOnly } = await getMarketplaceAppInfo({ baseUrl, headers, appId, version }); - - isAppEnterpriseOnly = !!isEnterpriseOnly; - } catch (error: any) { - logger.error('Error getting the app info from the Marketplace:', error.message); - throw new Error(error.message); - } - } - - if (![AppStatus.DISABLED, AppStatus.MANUALLY_DISABLED].includes(status) && isAppEnterpriseOnly) { - throw new Error('Invalid environment for enabling enterprise app'); - } -}; diff --git a/apps/meteor/ee/server/apps/orchestrator.js b/apps/meteor/ee/server/apps/orchestrator.js index 1d5e7c8c881..9c1ea6397a7 100644 --- a/apps/meteor/ee/server/apps/orchestrator.js +++ b/apps/meteor/ee/server/apps/orchestrator.js @@ -174,21 +174,16 @@ export class AppServerOrchestrator { // Before enabling each app we verify if there is still room for it const apps = await this.getManager().get(); - /* eslint-disable no-await-in-loop */ // This needs to happen sequentially to keep track of app limits - for (const app of apps) { - const canEnable = await canEnableApp(app.getStorageItem()); - - if (!canEnable) { - this._rocketchatLogger.warn(`App "${app.getInfo().name}" can't be enabled due to CE limits.`); - // We need to continue as the limits are applied depending on the app installation source - // i.e. if one limit is hit, we can't break the loop as the following apps might still be valid - continue; - } + for await (const app of apps) { + try { + await canEnableApp(app.getStorageItem()); - await this.getManager().loadOne(app.getID(), true); + await this.getManager().loadOne(app.getID(), true); + } catch (error) { + this._rocketchatLogger.warn(`App "${app.getInfo().name}" could not be enabled: `, error.message); + } } - /* eslint-enable no-await-in-loop */ await this.getBridges().getSchedulerBridge().startScheduler(); diff --git a/apps/meteor/ee/server/lib/apps/disableAppsWithAddonsCallback.ts b/apps/meteor/ee/server/lib/apps/disableAppsWithAddonsCallback.ts new file mode 100644 index 00000000000..03f7011ba72 --- /dev/null +++ b/apps/meteor/ee/server/lib/apps/disableAppsWithAddonsCallback.ts @@ -0,0 +1,46 @@ +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { LicenseImp } from '@rocket.chat/license'; + +import { i18n } from '../../../../server/lib/i18n'; +import { sendMessagesToAdmins } from '../../../../server/lib/sendMessagesToAdmins'; +import { Apps } from '../../apps'; + +type OnModuleCallbackParameter = Parameters[0]>[0]; + +export async function _disableAppsWithAddonsCallback( + deps: { Apps: typeof Apps; sendMessagesToAdmins: typeof sendMessagesToAdmins }, + { module, external, valid }: OnModuleCallbackParameter, +) { + if (!external || valid) return; + + const enabledApps = await deps.Apps.installedApps({ enabled: true }); + + if (!enabledApps) return; + + const affectedApps: string[] = []; + + await Promise.all( + enabledApps.map(async (app) => { + if (app.getInfo().addon !== module) return; + + affectedApps.push(app.getName()); + + return deps.Apps.getManager()?.disable(app.getID(), AppStatus.DISABLED, false); + }), + ); + + if (!affectedApps.length) return; + + await deps.sendMessagesToAdmins({ + msgs: async ({ adminUser }) => ({ + msg: i18n.t('App_has_been_disabled_addon_message', { + lng: adminUser.language || 'en', + count: affectedApps.length, + appNames: affectedApps, + }), + }), + }); +} + +export const disableAppsWithAddonsCallback = (ctx: OnModuleCallbackParameter) => + _disableAppsWithAddonsCallback({ Apps, sendMessagesToAdmins }, ctx); diff --git a/apps/meteor/ee/server/startup/apps.ts b/apps/meteor/ee/server/startup/apps.ts new file mode 100644 index 00000000000..9cfe0b98f15 --- /dev/null +++ b/apps/meteor/ee/server/startup/apps.ts @@ -0,0 +1,20 @@ +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +import { Apps } from '../apps'; +import { disableAppsWithAddonsCallback } from '../lib/apps/disableAppsWithAddonsCallback'; + +Meteor.startup(() => { + async function migratePrivateAppsCallback() { + if (!Apps.isInitialized) return; + + void Apps.migratePrivateApps(); + void Apps.disableMarketplaceApps(); + } + + License.onInvalidateLicense(migratePrivateAppsCallback); + License.onRemoveLicense(migratePrivateAppsCallback); + + // Disable apps that depend on add-ons (external modules) if they are invalidated + License.onModule(disableAppsWithAddonsCallback); +}); diff --git a/apps/meteor/ee/server/startup/apps/index.ts b/apps/meteor/ee/server/startup/apps/index.ts deleted file mode 100644 index 389658ee535..00000000000 --- a/apps/meteor/ee/server/startup/apps/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './trialExpiration'; diff --git a/apps/meteor/ee/server/startup/apps/trialExpiration.ts b/apps/meteor/ee/server/startup/apps/trialExpiration.ts deleted file mode 100644 index 7874c80c81f..00000000000 --- a/apps/meteor/ee/server/startup/apps/trialExpiration.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { License } from '@rocket.chat/license'; -import { Meteor } from 'meteor/meteor'; - -import { Apps } from '../../apps'; - -Meteor.startup(async () => { - const updateAppsCallback = async () => { - if (!Apps.isInitialized) return; - - void Apps.migratePrivateApps(); - void Apps.disableMarketplaceApps(); - }; - - License.onInvalidateLicense(updateAppsCallback); - License.onRemoveLicense(updateAppsCallback); -}); diff --git a/apps/meteor/tests/mocks/data.ts b/apps/meteor/tests/mocks/data.ts index fd6a0ce9112..777fe196783 100644 --- a/apps/meteor/tests/mocks/data.ts +++ b/apps/meteor/tests/mocks/data.ts @@ -205,6 +205,7 @@ export const createFakeLicenseInfo = (partial: Partial undefined }, + '../../../../server/lib/i18n': { + i18n: { t: () => undefined }, + }, + }); + +/** + * I've used named "empty" functions to spy on as it is easier to + * troubleshoot if the assertion fails. + * If we use `spy()` instead, there is no clear indication on the + * error message which of the spy assertions failed + */ + +describe('disableAppsWithAddonsCallback', () => { + function sendMessagesToAdmins(): any { + return undefined; + } + + it('should not execute anything if not external module', async () => { + function installedApps() { + return []; + } + + function getManagerDisable() { + return undefined; + } + + const mockManager = { + disable: spy(getManagerDisable), + }; + + const AppsMock = { + installedApps: spy(installedApps), + getManager: () => mockManager, + } as unknown as AppServerOrchestrator; + + await _disableAppsWithAddonsCallback({ Apps: AppsMock, sendMessagesToAdmins }, { module: 'auditing', external: false, valid: true }); + + expect(AppsMock.installedApps).to.not.have.been.called(); + expect(AppsMock.getManager()?.disable).to.not.have.been.called(); + }); + + it('should not execute anything if module is external and valid', async () => { + function installedApps() { + return []; + } + + function getManagerDisable() { + return undefined; + } + + const mockManager = { + disable: spy(getManagerDisable), + }; + + const AppsMock = { + installedApps: spy(installedApps), + getManager: () => mockManager, + } as unknown as AppServerOrchestrator; + + await _disableAppsWithAddonsCallback({ Apps: AppsMock, sendMessagesToAdmins }, { module: 'auditing', external: true, valid: true }); + + expect(AppsMock.installedApps).to.not.have.been.called(); + expect(AppsMock.getManager()?.disable).to.not.have.been.called(); + }); + + it('should not throw if there are no apps installed that are enabled', async () => { + function installedApps() { + return []; + } + + function getManagerDisable() { + return undefined; + } + + const mockManager = { + disable: spy(getManagerDisable), + }; + + const AppsMock = { + installedApps: spy(installedApps), + getManager: () => mockManager, + } as unknown as AppServerOrchestrator; + + await expect( + _disableAppsWithAddonsCallback({ Apps: AppsMock, sendMessagesToAdmins }, { module: 'auditing', external: true, valid: false }), + ).to.not.eventually.be.rejected; + + expect(AppsMock.installedApps).to.have.been.called(); + expect(AppsMock.getManager()?.disable).to.not.have.been.called(); + }); + + it('should only disable apps that require addons', async () => { + function installedApps() { + return [ + { + getInfo: () => ({}), + getName: () => 'Test App Without Addon', + getID() { + return 'test-app-without-addon'; + }, + }, + { + getInfo: () => ({ addon: 'chat.rocket.test-addon' }), + getName: () => 'Test App WITH Addon', + getID() { + return 'test-app-with-addon'; + }, + }, + ]; + } + + function getManagerDisable() { + return undefined; + } + + const mockManager = { + disable: spy(getManagerDisable), + }; + + const AppsMock = { + installedApps: spy(installedApps), + getManager: () => mockManager, + } as unknown as AppServerOrchestrator; + + await expect( + _disableAppsWithAddonsCallback( + { Apps: AppsMock, sendMessagesToAdmins }, + { module: 'chat.rocket.test-addon', external: true, valid: false }, + ), + ).to.not.eventually.be.rejected; + + expect(AppsMock.installedApps).to.have.been.called(); + expect(AppsMock.getManager()?.disable).to.have.been.called.once; + expect(AppsMock.getManager()?.disable).to.have.been.called.with('test-app-with-addon'); + }); + + it('should not send messages to admins if no app was disabled', async () => { + function installedApps() { + return [ + { + getInfo: () => ({}), + getName: () => 'Test App Without Addon', + getID() { + return 'test-app-without-addon'; + }, + }, + ]; + } + + function getManagerDisable() { + return undefined; + } + + const mockManager = { + disable: spy(getManagerDisable), + }; + + const AppsMock = { + installedApps: spy(installedApps), + getManager: () => mockManager, + } as unknown as AppServerOrchestrator; + + const sendMessagesToAdminsSpy = spy(sendMessagesToAdmins); + + await expect( + _disableAppsWithAddonsCallback( + { Apps: AppsMock, sendMessagesToAdmins: sendMessagesToAdminsSpy }, + { module: 'chat.rocket.test-addon', external: true, valid: false }, + ), + ).to.not.eventually.be.rejected; + + expect(AppsMock.installedApps).to.have.been.called(); + expect(AppsMock.getManager()?.disable).to.not.have.been.called(); + expect(sendMessagesToAdminsSpy).to.not.have.been.called(); + }); + + it('should send messages to admins if some app has been disabled', async () => { + function installedApps() { + return [ + { + getInfo: () => ({}), + getName: () => 'Test App Without Addon', + getID() { + return 'test-app-without-addon'; + }, + }, + { + getInfo: () => ({ addon: 'chat.rocket.test-addon' }), + getName: () => 'Test App WITH Addon', + getID() { + return 'test-app-with-addon'; + }, + }, + ]; + } + + function getManagerDisable() { + return undefined; + } + + const mockManager = { + disable: spy(getManagerDisable), + }; + + const AppsMock = { + installedApps: spy(installedApps), + getManager: () => mockManager, + } as unknown as AppServerOrchestrator; + + const sendMessagesToAdminsSpy = spy(sendMessagesToAdmins); + + await expect( + _disableAppsWithAddonsCallback( + { Apps: AppsMock, sendMessagesToAdmins: sendMessagesToAdminsSpy }, + { module: 'chat.rocket.test-addon', external: true, valid: false }, + ), + ).to.not.eventually.be.rejected; + + expect(AppsMock.installedApps).to.have.been.called(); + expect(AppsMock.getManager()?.disable).to.have.been.called.once; + expect(sendMessagesToAdminsSpy).to.have.been.called(); + }); +}); diff --git a/apps/meteor/tests/unit/app/license/server/canEnableApp.spec.ts b/apps/meteor/tests/unit/app/license/server/canEnableApp.spec.ts new file mode 100644 index 00000000000..8e5b2c0bc3a --- /dev/null +++ b/apps/meteor/tests/unit/app/license/server/canEnableApp.spec.ts @@ -0,0 +1,129 @@ +import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { IMarketplaceInfo } from '@rocket.chat/apps-engine/server/marketplace'; +import { AppInstallationSource, type IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; +import type { Apps } from '@rocket.chat/core-services'; +import type { LicenseImp } from '@rocket.chat/license'; +import { expect } from 'chai'; + +import { _canEnableApp } from '../../../../../ee/app/license/server/canEnableApp'; + +const getDefaultApp = (): IAppStorageItem => ({ + _id: '6706d9258e0ca97c2f0cc885', + id: '2e14ff6e-b4d5-4c4c-b12b-b1b1d15ec630', + info: { + id: '2e14ff6e-b4d5-4c4c-b12b-b1b1d15ec630', + version: '0.0.1', + requiredApiVersion: '^1.19.0', + iconFile: 'icon.png', + author: { name: 'a', homepage: 'a', support: 'a' }, + name: 'Add-on test', + nameSlug: 'add-on-test', + classFile: 'AddOnTestApp.js', + description: 'a', + implements: [], + iconFileContent: '', + }, + status: AppStatus.UNKNOWN, + settings: {}, + implemented: {}, + installationSource: AppInstallationSource.PRIVATE, + languageContent: {}, + sourcePath: 'GridFS:/6706d9258e0ca97c2f0cc880', + signature: '', + createdAt: new Date('2024-10-09T19:27:33.923Z'), + updatedAt: new Date('2024-10-09T19:27:33.923Z'), +}); + +// We will be passing promises to the `expect` function +/* eslint-disable @typescript-eslint/no-floating-promises */ + +describe('canEnableApp', () => { + it('should throw the message "apps-engine-not-initialized" when appropriate', () => { + const AppsMock = { + isInitialized() { + return false; + }, + } as unknown as typeof Apps; + + const LicenseMock = {} as unknown as LicenseImp; + + const deps = { Apps: AppsMock, License: LicenseMock }; + + return expect(_canEnableApp(deps, getDefaultApp())).to.eventually.be.rejectedWith('apps-engine-not-initialized'); + }); + + const AppsMock = { + isInitialized() { + return true; + }, + } as unknown as typeof Apps; + + const LicenseMock = { + hasModule() { + return false; + }, + shouldPreventAction() { + return true; + }, + hasValidLicense() { + return false; + }, + } as unknown as LicenseImp; + + const deps = { Apps: AppsMock, License: LicenseMock }; + + it('should throw the message "app-addon-not-valid" when appropriate', () => { + const app = getDefaultApp(); + app.info.addon = 'chat.rocket.test-addon'; + + return expect(_canEnableApp(deps, app)).to.eventually.be.rejectedWith('app-addon-not-valid'); + }); + + it('should throw the message "license-prevented" when appropriate', () => { + const privateApp = getDefaultApp(); + const marketplaceApp = getDefaultApp(); + marketplaceApp.installationSource = AppInstallationSource.MARKETPLACE; + + return Promise.all([ + expect(_canEnableApp(deps, privateApp)).to.eventually.be.rejectedWith('license-prevented'), + expect(_canEnableApp(deps, marketplaceApp)).to.eventually.be.rejectedWith('license-prevented'), + ]); + }); + + it('should throw the message "invalid-license" when appropriate', () => { + const License = { ...LicenseMock, shouldPreventAction: () => false } as unknown as LicenseImp; + + const app = getDefaultApp(); + app.installationSource = AppInstallationSource.MARKETPLACE; + app.marketplaceInfo = { isEnterpriseOnly: true } as IMarketplaceInfo; + + const deps = { Apps: AppsMock, License }; + + return expect(_canEnableApp(deps, app)).to.eventually.be.rejectedWith('invalid-license'); + }); + + it('should not throw if app is migrated', () => { + const app = getDefaultApp(); + app.migrated = true; + + return expect(_canEnableApp(deps, app)).to.not.eventually.be.rejected; + }); + + it('should not throw if license allows it', () => { + const License = { + hasModule() { + return true; + }, + shouldPreventAction() { + return false; + }, + hasValidLicense() { + return true; + }, + } as unknown as LicenseImp; + + const deps = { Apps: AppsMock, License }; + + return expect(_canEnableApp(deps, getDefaultApp())).to.not.eventually.be.rejected; + }); +}); diff --git a/ee/packages/license/__tests__/setLicense.spec.ts b/ee/packages/license/__tests__/setLicense.spec.ts index 1caf8cafa2c..41785c756cc 100644 --- a/ee/packages/license/__tests__/setLicense.spec.ts +++ b/ee/packages/license/__tests__/setLicense.spec.ts @@ -140,16 +140,18 @@ describe('License set license procedures', () => { const mocked = new MockedLicenseBuilder(); const oldToken = await mocked.sign(); - const newToken = await mocked.withGratedModules(['livechat-enterprise']).sign(); + const newToken = await mocked.withGratedModules(['livechat-enterprise', 'chat.rocket.test-addon']).sign(); await expect(license.setLicense(oldToken)).resolves.toBe(true); expect(license.hasValidLicense()).toBe(true); expect(license.hasModule('livechat-enterprise')).toBe(false); + expect(license.hasModule('chat.rocket.test-addon')).toBe(false); await expect(license.setLicense(newToken)).resolves.toBe(true); expect(license.hasValidLicense()).toBe(true); expect(license.hasModule('livechat-enterprise')).toBe(true); + expect(license.hasModule('chat.rocket.test-addon')).toBe(true); }); it('should call a validated event after set a valid license', async () => { diff --git a/ee/packages/license/src/MockedLicenseBuilder.ts b/ee/packages/license/src/MockedLicenseBuilder.ts index 4c3cab7b660..d9def5b6b0d 100644 --- a/ee/packages/license/src/MockedLicenseBuilder.ts +++ b/ee/packages/license/src/MockedLicenseBuilder.ts @@ -1,4 +1,14 @@ -import type { ILicenseTag, ILicenseV3, LicenseLimit, LicenseModule, LicensePeriod, Timestamp } from '@rocket.chat/core-typings'; +import type { InternalModuleName } from '@rocket.chat/core-typings'; +import { + CoreModules, + type GrantedModules, + type ILicenseTag, + type ILicenseV3, + type LicenseLimit, + type LicenseModule, + type LicensePeriod, + type Timestamp, +} from '@rocket.chat/core-typings'; import { encrypt } from './token'; @@ -163,9 +173,7 @@ export class MockedLicenseBuilder { return this; } - grantedModules: { - module: LicenseModule; - }[] = []; + grantedModules: GrantedModules = []; limits: { activeUsers?: LicenseLimit[]; @@ -192,7 +200,9 @@ export class MockedLicenseBuilder { public withGratedModules(modules: LicenseModule[]): this { this.grantedModules = this.grantedModules ?? []; - this.grantedModules.push(...modules.map((module) => ({ module }))); + this.grantedModules.push( + ...(modules.map((module) => ({ module, external: !CoreModules.includes(module as InternalModuleName) })) as GrantedModules), + ); return this; } diff --git a/ee/packages/license/src/events/emitter.ts b/ee/packages/license/src/events/emitter.ts index b4406abaa81..fee830618eb 100644 --- a/ee/packages/license/src/events/emitter.ts +++ b/ee/packages/license/src/events/emitter.ts @@ -2,13 +2,17 @@ import type { BehaviorWithContext, LicenseModule } from '@rocket.chat/core-typin import type { LicenseManager } from '../license'; import { logger } from '../logger'; +import { isInternalModuleName } from '../modules'; export function moduleValidated(this: LicenseManager, module: LicenseModule) { try { - this.emit('module', { module, valid: true }); + const external = !isInternalModuleName(module); + + this.emit('module', { module, external, valid: true }); } catch (error) { logger.error({ msg: `Error running module (valid: true) event: ${module}`, error }); } + try { this.emit(`valid:${module}`); } catch (error) { @@ -18,10 +22,13 @@ export function moduleValidated(this: LicenseManager, module: LicenseModule) { export function moduleRemoved(this: LicenseManager, module: LicenseModule) { try { - this.emit('module', { module, valid: false }); + const external = !isInternalModuleName(module); + + this.emit('module', { module, external, valid: false }); } catch (error) { logger.error({ msg: `Error running module (valid: false) event: ${module}`, error }); } + try { this.emit(`invalid:${module}`); } catch (error) { diff --git a/ee/packages/license/src/events/listeners.ts b/ee/packages/license/src/events/listeners.ts index 7b7eaf0baed..97e57afbcc6 100644 --- a/ee/packages/license/src/events/listeners.ts +++ b/ee/packages/license/src/events/listeners.ts @@ -83,7 +83,7 @@ export function onToggledFeature( }; } -export function onModule(this: LicenseManager, cb: (data: { module: LicenseModule; valid: boolean }) => void) { +export function onModule(this: LicenseManager, cb: (data: { module: LicenseModule; external: boolean; valid: boolean }) => void) { this.on('module', cb); } diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 229b7e70978..b4a7f2c0890 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -421,7 +421,7 @@ describe('License.setLicense', () => { }); describe('License.removeLicense', () => { - it('should trigger the sync event even if the module callback throws an error', async () => { + it('should trigger the removed event', async () => { const licenseManager = await getReadyLicenseManager(); const removeLicense = jest.fn(); @@ -431,7 +431,7 @@ describe('License.removeLicense', () => { licenseManager.onModule(moduleCallback); - const license = await new MockedLicenseBuilder().withGratedModules(['auditing']).withLimits('activeUsers', [ + const license = await new MockedLicenseBuilder().withGratedModules(['auditing', 'chat.rocket.test-addon']).withLimits('activeUsers', [ { max: 10, behavior: 'disable_modules', @@ -440,22 +440,34 @@ describe('License.removeLicense', () => { ]); await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true); - await expect(removeLicense).toHaveBeenCalledTimes(0); - await expect(moduleCallback).toHaveBeenNthCalledWith(1, { + expect(removeLicense).toHaveBeenCalledTimes(0); + expect(moduleCallback).toHaveBeenNthCalledWith(1, { module: 'auditing', valid: true, + external: false, + }); + expect(moduleCallback).toHaveBeenNthCalledWith(2, { + module: 'chat.rocket.test-addon', + valid: true, + external: true, }); removeLicense.mockClear(); moduleCallback.mockClear(); - await licenseManager.remove(); + licenseManager.remove(); - await expect(removeLicense).toHaveBeenCalledTimes(1); - await expect(moduleCallback).toHaveBeenNthCalledWith(1, { + expect(removeLicense).toHaveBeenCalledTimes(1); + expect(moduleCallback).toHaveBeenNthCalledWith(1, { module: 'auditing', valid: false, + external: false, + }); + expect(moduleCallback).toHaveBeenNthCalledWith(2, { + module: 'chat.rocket.test-addon', + valid: false, + external: true, }); - await expect(licenseManager.hasValidLicense()).toBe(false); + expect(licenseManager.hasValidLicense()).toBe(false); }); }); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index a8d8f1bca51..beb13a63e83 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -7,9 +7,9 @@ import type { BehaviorWithContext, LicenseBehavior, LicenseInfo, - LicenseModule, LicenseValidationOptions, LimitContext, + LicenseModule, } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; @@ -19,7 +19,7 @@ import { InvalidLicenseError } from './errors/InvalidLicenseError'; import { NotReadyForValidation } from './errors/NotReadyForValidation'; import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter'; import { logger } from './logger'; -import { getModules, invalidateAll, replaceModules } from './modules'; +import { getExternalModules, getModules, invalidateAll, replaceModules } from './modules'; import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense'; import { replaceTags } from './tags'; import { decrypt } from './token'; @@ -472,6 +472,7 @@ export class LicenseManager extends Emitter { license: boolean; }): Promise { const activeModules = getModules.call(this); + const externalModules = getExternalModules.call(this); const license = this.getLicense(); // Get all limits present in the license and their current value @@ -496,6 +497,7 @@ export class LicenseManager extends Emitter { return { license: (includeLicense && license) || undefined, activeModules, + externalModules, preventedActions: await this.shouldPreventActionResultsMap(), limits: limits as Record, tags: license?.information.tags || [], diff --git a/ee/packages/license/src/licenseImp.ts b/ee/packages/license/src/licenseImp.ts index f3946c7e1c8..83056655365 100644 --- a/ee/packages/license/src/licenseImp.ts +++ b/ee/packages/license/src/licenseImp.ts @@ -20,7 +20,7 @@ import { import { overwriteClassOnLicense } from './events/overwriteClassOnLicense'; import { LicenseManager } from './license'; import { logger } from './logger'; -import { getModules, hasModule } from './modules'; +import { getExternalModules, getModuleDefinition, getModules, hasModule } from './modules'; import { showLicense } from './showLicense'; import { getTags } from './tags'; import { getCurrentValueForLicenseLimit, setLicenseLimitCounter } from './validation/getCurrentValueForLicenseLimit'; @@ -31,6 +31,8 @@ interface License { validateFormat: typeof validateFormat; hasModule: typeof hasModule; getModules: typeof getModules; + getModuleDefinition: typeof getModuleDefinition; + getExternalModules: typeof getExternalModules; getTags: typeof getTags; overwriteClassOnLicense: typeof overwriteClassOnLicense; setLicenseLimitCounter: typeof setLicenseLimitCounter; @@ -90,6 +92,10 @@ export class LicenseImp extends LicenseManager implements License { getModules = getModules; + getModuleDefinition = getModuleDefinition; + + getExternalModules = getExternalModules; + getTags = getTags; overwriteClassOnLicense = overwriteClassOnLicense; diff --git a/ee/packages/license/src/modules.spec.ts b/ee/packages/license/src/modules.spec.ts new file mode 100644 index 00000000000..07e1fe6d917 --- /dev/null +++ b/ee/packages/license/src/modules.spec.ts @@ -0,0 +1,101 @@ +import type { LicenseModule } from '@rocket.chat/core-typings'; + +import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder'; + +describe('getModules', () => { + it('should return internal and external', async () => { + const licenseManager = await getReadyLicenseManager(); + + const modules = ['auditing', 'livechat-enterprise', 'ldap-enterprise', 'chat.rocket.test-addon'] as LicenseModule[]; + + const license = await new MockedLicenseBuilder().withGratedModules(modules).sign(); + + await expect(licenseManager.setLicense(license)).resolves.toBe(true); + + expect(licenseManager.getModules()).toContain('auditing'); + expect(licenseManager.getModules()).toContain('livechat-enterprise'); + expect(licenseManager.getModules()).toContain('ldap-enterprise'); + expect(licenseManager.getModules()).toContain('chat.rocket.test-addon'); + }); +}); + +describe('getModuleDefinition', () => { + it('should not return `external` property for an internal module', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing', 'chat.rocket.test-addon']).sign(); + + await licenseManager.setLicense(license); + + const module = licenseManager.getModuleDefinition('auditing'); + + expect(module).toMatchObject({ module: 'auditing' }); + }); + + it('should return `undefined` for a non-existing module', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing', 'chat.rocket.test-addon']).sign(); + + await licenseManager.setLicense(license); + + const module = licenseManager.getModuleDefinition('livechat-enterprise'); + + expect(module).toBeUndefined(); + }); + + it('should return `undefined` if there is no license available', async () => { + const licenseManager = await getReadyLicenseManager(); + + const module = licenseManager.getModuleDefinition('livechat-enterprise'); + + expect(module).toBeUndefined(); + }); + + it('should return `external` property for an external module', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing', 'chat.rocket.test-addon']).sign(); + + await licenseManager.setLicense(license); + + const module = licenseManager.getModuleDefinition('chat.rocket.test-addon'); + + expect(module).toMatchObject({ module: 'chat.rocket.test-addon', external: true }); + }); +}); + +describe('getExternalModules', () => { + it('should return only external modules', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing', 'chat.rocket.test-addon']).sign(); + + await licenseManager.setLicense(license); + + const modules = licenseManager.getExternalModules(); + + expect(modules).toHaveLength(1); + expect(modules[0]).toMatchObject({ external: true, module: 'chat.rocket.test-addon' }); + }); + + it('should return empty array if no external module is present', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withGratedModules(['auditing', 'livechat-enterprise']).sign(); + + await licenseManager.setLicense(license); + + const modules = licenseManager.getExternalModules(); + + expect(modules).toHaveLength(0); + }); + + it('should return empty array if license is not available', async () => { + const licenseManager = await getReadyLicenseManager(); + + const modules = licenseManager.getExternalModules(); + + expect(modules).toHaveLength(0); + }); +}); diff --git a/ee/packages/license/src/modules.ts b/ee/packages/license/src/modules.ts index 7a6f41c07ec..15cebdcde54 100644 --- a/ee/packages/license/src/modules.ts +++ b/ee/packages/license/src/modules.ts @@ -1,8 +1,13 @@ -import type { LicenseModule } from '@rocket.chat/core-typings'; +import type { LicenseModule, InternalModuleName, ExternalModule } from '@rocket.chat/core-typings'; +import { CoreModules } from '@rocket.chat/core-typings'; import { moduleRemoved, moduleValidated } from './events/emitter'; import type { LicenseManager } from './license'; +export function isInternalModuleName(module: string): module is InternalModuleName { + return CoreModules.includes(module as InternalModuleName); +} + export function notifyValidatedModules(this: LicenseManager, licenseModules: LicenseModule[]) { licenseModules.forEach((module) => { this.modules.add(module); @@ -26,6 +31,28 @@ export function getModules(this: LicenseManager) { return [...this.modules]; } +export function getModuleDefinition(this: LicenseManager, moduleName: LicenseModule) { + const license = this.getLicense(); + + if (!license) { + return; + } + + const moduleDefinition = license.grantedModules.find(({ module }) => module === moduleName); + + return moduleDefinition; +} + +export function getExternalModules(this: LicenseManager): ExternalModule[] { + const license = this.getLicense(); + + if (!license) { + return []; + } + + return [...license.grantedModules.filter((value): value is ExternalModule => !isInternalModuleName(value.module))]; +} + export function hasModule(this: LicenseManager, module: LicenseModule) { return this.modules.has(module); } diff --git a/ee/packages/license/src/v2/bundles.ts b/ee/packages/license/src/v2/bundles.ts index 3aad7c9ed71..518fbe41e1b 100644 --- a/ee/packages/license/src/v2/bundles.ts +++ b/ee/packages/license/src/v2/bundles.ts @@ -1,35 +1,11 @@ -import type { LicenseModule } from '@rocket.chat/core-typings'; +import { CoreModules, type LicenseModule } from '@rocket.chat/core-typings'; interface IBundle { - [key: string]: LicenseModule[]; + [key: string]: readonly LicenseModule[]; } const bundles: IBundle = { - enterprise: [ - 'auditing', - 'canned-responses', - 'ldap-enterprise', - 'livechat-enterprise', - 'voip-enterprise', - 'omnichannel-mobile-enterprise', - 'engagement-dashboard', - 'push-privacy', - 'scalability', - 'saml-enterprise', - 'oauth-enterprise', - 'device-management', - 'federation', - 'videoconference-enterprise', - 'message-read-receipt', - 'outlook-calendar', - 'teams-mention', - 'hide-watermark', - 'custom-roles', - 'accessibility-certification', - 'unlimited-presence', - 'contact-id-verification', - 'teams-voip', - ], + enterprise: CoreModules, pro: [], }; @@ -55,7 +31,7 @@ export function isBundle(moduleName: string): boolean { return true; } -export function getBundleModules(moduleName: string): string[] { +export function getBundleModules(moduleName: string): readonly string[] { if (moduleName === '*') { return Object.keys(bundles).reduce((modules, bundle) => modules.concat(bundles[bundle]), []); } diff --git a/ee/packages/license/src/v2/convertToV3.ts b/ee/packages/license/src/v2/convertToV3.ts index 7c38881b79a..539bf10062e 100644 --- a/ee/packages/license/src/v2/convertToV3.ts +++ b/ee/packages/license/src/v2/convertToV3.ts @@ -3,7 +3,7 @@ * Transform a License V2 into a V3 representation. */ -import type { ILicenseV2, ILicenseV3, LicenseModule } from '@rocket.chat/core-typings'; +import type { ILicenseV2, ILicenseV3, InternalModuleName } from '@rocket.chat/core-typings'; import { isBundle, getBundleFromModule, getBundleModules } from './bundles'; import { getTagColor } from './getTagColor'; @@ -53,7 +53,7 @@ export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => { ['teams-voip', 'contact-id-verification', 'hide-watermark', ...v2.modules] .map((licenseModule) => (isBundle(licenseModule) ? getBundleModules(licenseModule) : [licenseModule])) .reduce((prev, curr) => [...prev, ...curr], []) - .map((licenseModule) => ({ module: licenseModule as LicenseModule })), + .map((licenseModule) => ({ module: licenseModule as InternalModuleName })), ), ], limits: { diff --git a/packages/apps-engine/src/definition/metadata/IAppInfo.ts b/packages/apps-engine/src/definition/metadata/IAppInfo.ts index af1d0d62fd1..6cb7515ea6d 100644 --- a/packages/apps-engine/src/definition/metadata/IAppInfo.ts +++ b/packages/apps-engine/src/definition/metadata/IAppInfo.ts @@ -17,4 +17,5 @@ export interface IAppInfo { iconFileContent?: string; essentials?: Array; permissions?: Array; + addon?: string; } diff --git a/packages/core-typings/src/Apps.ts b/packages/core-typings/src/Apps.ts index 3d0afce9d12..d1b0ddac4a5 100644 --- a/packages/core-typings/src/Apps.ts +++ b/packages/core-typings/src/Apps.ts @@ -1,5 +1,7 @@ import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; +import type { ExternalModuleName } from './license'; + export type AppScreenshot = { id: string; appId: string; @@ -92,6 +94,7 @@ export type App = { categories: string[]; version: string; versionIncompatible?: boolean; + addon?: ExternalModuleName; price: number; purchaseType: PurchaseType; pricingPlans: AppPricingPlan[]; diff --git a/packages/core-typings/src/license/ILicenseV3.ts b/packages/core-typings/src/license/ILicenseV3.ts index d99f80c71bf..b6f6fae00eb 100644 --- a/packages/core-typings/src/license/ILicenseV3.ts +++ b/packages/core-typings/src/license/ILicenseV3.ts @@ -1,8 +1,13 @@ import type { ILicenseTag } from './ILicenseTag'; import type { LicenseLimit } from './LicenseLimit'; -import type { LicenseModule } from './LicenseModule'; +import type { ExternalModuleName, InternalModuleName } from './LicenseModule'; import type { LicensePeriod, Timestamp } from './LicensePeriod'; +export type InternalModule = { module: InternalModuleName; external?: false }; +export type ExternalModule = { module: ExternalModuleName; external: true }; + +export type GrantedModules = (InternalModule | ExternalModule)[]; + export interface ILicenseV3 { version: '3.0'; information: { @@ -48,9 +53,7 @@ export interface ILicenseV3 { allowedStaleInDays?: number; }; }; - grantedModules: { - module: LicenseModule; - }[]; + grantedModules: GrantedModules; limits: { activeUsers?: LicenseLimit[]; guestUsers?: LicenseLimit[]; diff --git a/packages/core-typings/src/license/LicenseInfo.ts b/packages/core-typings/src/license/LicenseInfo.ts index 019d1b9e1ca..85e7c3fba50 100644 --- a/packages/core-typings/src/license/LicenseInfo.ts +++ b/packages/core-typings/src/license/LicenseInfo.ts @@ -1,10 +1,11 @@ import type { ILicenseTag } from './ILicenseTag'; -import type { ILicenseV3, LicenseLimitKind } from './ILicenseV3'; +import type { ExternalModule, ILicenseV3, LicenseLimitKind } from './ILicenseV3'; import type { LicenseModule } from './LicenseModule'; export type LicenseInfo = { license?: ILicenseV3; activeModules: LicenseModule[]; + externalModules: ExternalModule[]; preventedActions: Record; limits: Record; tags: ILicenseTag[]; diff --git a/packages/core-typings/src/license/LicenseModule.ts b/packages/core-typings/src/license/LicenseModule.ts index a1be9663afe..faf3332d5f4 100644 --- a/packages/core-typings/src/license/LicenseModule.ts +++ b/packages/core-typings/src/license/LicenseModule.ts @@ -1,24 +1,29 @@ -export type LicenseModule = - | 'auditing' - | 'canned-responses' - | 'ldap-enterprise' - | 'livechat-enterprise' - | 'voip-enterprise' - | 'omnichannel-mobile-enterprise' - | 'engagement-dashboard' - | 'push-privacy' - | 'scalability' - | 'teams-mention' - | 'saml-enterprise' - | 'oauth-enterprise' - | 'device-management' - | 'federation' - | 'videoconference-enterprise' - | 'message-read-receipt' - | 'outlook-calendar' - | 'hide-watermark' - | 'custom-roles' - | 'accessibility-certification' - | 'unlimited-presence' - | 'contact-id-verification' - | 'teams-voip'; +export const CoreModules = [ + 'auditing', + 'canned-responses', + 'ldap-enterprise', + 'livechat-enterprise', + 'voip-enterprise', + 'omnichannel-mobile-enterprise', + 'engagement-dashboard', + 'push-privacy', + 'scalability', + 'teams-mention', + 'saml-enterprise', + 'oauth-enterprise', + 'device-management', + 'federation', + 'videoconference-enterprise', + 'message-read-receipt', + 'outlook-calendar', + 'hide-watermark', + 'custom-roles', + 'accessibility-certification', + 'unlimited-presence', + 'contact-id-verification', + 'teams-voip', +] as const; + +export type InternalModuleName = (typeof CoreModules)[number]; +export type ExternalModuleName = `${string}.${string}`; +export type LicenseModule = InternalModuleName | ExternalModuleName; diff --git a/packages/core-typings/src/license/events.ts b/packages/core-typings/src/license/events.ts index 2156c298849..487d31ae669 100644 --- a/packages/core-typings/src/license/events.ts +++ b/packages/core-typings/src/license/events.ts @@ -19,6 +19,6 @@ export type LicenseEvents = ModuleValidation & removed: undefined; validate: undefined; invalidate: undefined; - module: { module: LicenseModule; valid: boolean }; + module: { module: LicenseModule; external: boolean; valid: boolean }; sync: undefined; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b9494e65046..42422ef477a 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -529,6 +529,8 @@ "App_Info": "App Info", "App_Information": "App Information", "Apps_context_enterprise": "Enterprise", + "App_has_been_disabled_addon_message_one": "The app {{appNames}} has been disabled because of an invalid add-on. A valid add-on subscription is required to re-enable it", + "App_has_been_disabled_addon_message_other": "The apps {{appNames}} have been disabled because of invalid add-ons. A valid add-on subscription is required to re-enable them", "App_Installation": "App Installation", "App_Installation_Deprecation_Title": "Deprecation Warning", "App_Installation_Deprecation": "Install apps from URL is deprecated and will be removed in the next major release.",