feat: license add-ons (external modules) (#33433)

pull/33585/head^2
Douglas Gubert 1 year ago committed by GitHub
parent 2806cb5d3e
commit 81998f3450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/three-crews-allow.md
  2. 36
      apps/meteor/ee/app/license/server/canEnableApp.ts
  3. 36
      apps/meteor/ee/server/apps/communication/rest.ts
  4. 42
      apps/meteor/ee/server/apps/marketplace/appEnableCheck.ts
  5. 19
      apps/meteor/ee/server/apps/orchestrator.js
  6. 46
      apps/meteor/ee/server/lib/apps/disableAppsWithAddonsCallback.ts
  7. 20
      apps/meteor/ee/server/startup/apps.ts
  8. 1
      apps/meteor/ee/server/startup/apps/index.ts
  9. 16
      apps/meteor/ee/server/startup/apps/trialExpiration.ts
  10. 1
      apps/meteor/tests/mocks/data.ts
  11. 233
      apps/meteor/tests/unit/app/lib/server/apps/disableAppsWithAddonsCallback.spec.ts
  12. 129
      apps/meteor/tests/unit/app/license/server/canEnableApp.spec.ts
  13. 4
      ee/packages/license/__tests__/setLicense.spec.ts
  14. 20
      ee/packages/license/src/MockedLicenseBuilder.ts
  15. 11
      ee/packages/license/src/events/emitter.ts
  16. 2
      ee/packages/license/src/events/listeners.ts
  17. 28
      ee/packages/license/src/license.spec.ts
  18. 6
      ee/packages/license/src/license.ts
  19. 8
      ee/packages/license/src/licenseImp.ts
  20. 101
      ee/packages/license/src/modules.spec.ts
  21. 29
      ee/packages/license/src/modules.ts
  22. 32
      ee/packages/license/src/v2/bundles.ts
  23. 4
      ee/packages/license/src/v2/convertToV3.ts
  24. 1
      packages/apps-engine/src/definition/metadata/IAppInfo.ts
  25. 3
      packages/core-typings/src/Apps.ts
  26. 11
      packages/core-typings/src/license/ILicenseV3.ts
  27. 3
      packages/core-typings/src/license/LicenseInfo.ts
  28. 53
      packages/core-typings/src/license/LicenseModule.ts
  29. 2
      packages/core-typings/src/license/events.ts
  30. 2
      packages/i18n/src/locales/en.i18n.json

@ -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

@ -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<boolean> => {
type _canEnableAppDependencies = {
Apps: typeof Apps;
License: LicenseImp;
};
export const _canEnableApp = async ({ Apps, License }: _canEnableAppDependencies, app: IAppStorageItem): Promise<void> => {
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<void> => _canEnableApp({ Apps, License }, app);

@ -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() });
},

@ -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<string, any>;
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');
}
};

@ -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();

@ -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<Parameters<LicenseImp['onModule']>[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);

@ -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);
});

@ -1 +0,0 @@
import './trialExpiration';

@ -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);
});

@ -205,6 +205,7 @@ export const createFakeLicenseInfo = (partial: Partial<Omit<LicenseInfo, 'licens
'custom-roles',
'accessibility-certification',
]),
externalModules: [],
preventedActions: {
activeUsers: faker.datatype.boolean(),
guestUsers: faker.datatype.boolean(),

@ -0,0 +1,233 @@
import { expect, spy } from 'chai';
import proxyquire from 'proxyquire';
import type { AppServerOrchestrator } from '../../../../../../ee/server/apps/orchestrator';
const { _disableAppsWithAddonsCallback } = proxyquire
.noCallThru()
.load('../../../../../../ee/server/lib/apps/disableAppsWithAddonsCallback', {
'../../apps': {},
'../../../../server/lib/sendMessagesToAdmins': { sendMessagesToAdmins: () => 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();
});
});

@ -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;
});
});

@ -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 () => {

@ -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;
}

@ -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) {

@ -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);
}

@ -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);
});
});

@ -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<LicenseEvents> {
license: boolean;
}): Promise<LicenseInfo> {
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<LicenseEvents> {
return {
license: (includeLicense && license) || undefined,
activeModules,
externalModules,
preventedActions: await this.shouldPreventActionResultsMap(),
limits: limits as Record<LicenseLimitKind, { max: number; value: number }>,
tags: license?.information.tags || [],

@ -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;

@ -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);
});
});

@ -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<ExternalModule>((value): value is ExternalModule => !isInternalModuleName(value.module))];
}
export function hasModule(this: LicenseManager, module: LicenseModule) {
return this.modules.has(module);
}

@ -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<string[]>((modules, bundle) => modules.concat(bundles[bundle]), []);
}

@ -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: {

@ -17,4 +17,5 @@ export interface IAppInfo {
iconFileContent?: string;
essentials?: Array<AppInterface>;
permissions?: Array<IPermission>;
addon?: string;
}

@ -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[];

@ -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[];

@ -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<LicenseLimitKind, boolean>;
limits: Record<LicenseLimitKind, { value?: number; max: number }>;
tags: ILicenseTag[];

@ -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;

@ -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;
};

@ -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.",

Loading…
Cancel
Save