snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
import { LicenseImp } from '../src';
describe('Community Restrictions', () => {
describe('Apps from marketplace', () => {
it('should respect the default if there is no license applied', async () => {
const license = new LicenseImp();
license.setLicenseLimitCounter('marketplaceApps', () => 1);
await expect(await license.shouldPreventAction('marketplaceApps')).toBe(false);
license.setLicenseLimitCounter('marketplaceApps', () => 10);
await expect(await license.shouldPreventAction('marketplaceApps')).toBe(true);
});
});
describe('Private Apps', () => {
it('should respect the default if there is no license applied', async () => {
const license = new LicenseImp();
license.setLicenseLimitCounter('privateApps', () => 0);
await expect(await license.shouldPreventAction('privateApps')).toBe(true);
license.setLicenseLimitCounter('privateApps', () => 10);
await expect(await license.shouldPreventAction('privateApps')).toBe(true);
});
});
describe('Active Users', () => {
it('should respect the default if there is no license applied', async () => {
const license = new LicenseImp();
license.setLicenseLimitCounter('activeUsers', () => 1);
await expect(await license.shouldPreventAction('activeUsers')).toBe(false);
license.setLicenseLimitCounter('activeUsers', () => 10);
await expect(await license.shouldPreventAction('activeUsers')).toBe(false);
license.setLicenseLimitCounter('activeUsers', () => 100000);
await expect(await license.shouldPreventAction('activeUsers')).toBe(false);
});
});
});

View File

@@ -0,0 +1,16 @@
import { LicenseImp } from '../src';
export { MockedLicenseBuilder } from '../src/MockedLicenseBuilder';
export const getReadyLicenseManager = async () => {
const license = new LicenseImp();
await license.setWorkspaceUrl('http://localhost:3000');
license.setLicenseLimitCounter('activeUsers', () => 0);
license.setLicenseLimitCounter('guestUsers', () => 0);
license.setLicenseLimitCounter('roomsPerGuest', async () => 0);
license.setLicenseLimitCounter('privateApps', () => 0);
license.setLicenseLimitCounter('marketplaceApps', () => 0);
license.setLicenseLimitCounter('monthlyActiveContacts', async () => 0);
return license;
};

View File

@@ -0,0 +1,87 @@
/**
* @jest-environment node
*/
import { MockedLicenseBuilder, getReadyLicenseManager } from './MockedLicenseBuilder';
import { applyNewestLicense } from '../src/applyLicense';
const OLDER = new Date('2026-01-01T00:00:00.000Z');
const NEWER = new Date('2026-06-01T00:00:00.000Z');
describe('applyNewestLicense', () => {
it('should apply the provided license when it is newer than the stored one', async () => {
const manager = await getReadyLicenseManager();
const stored = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['auditing']).sign();
const provided = await new MockedLicenseBuilder().withCreatedAt(NEWER).withGrantedModules(['livechat-enterprise']).sign();
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
expect(manager.hasValidLicense()).toBe(true);
expect(manager.hasModule('livechat-enterprise')).toBe(true);
expect(manager.hasModule('auditing')).toBe(false);
});
it('should apply the dated provided license when the stored one has no issue date', async () => {
const manager = await getReadyLicenseManager();
// V2 licenses carry no signature-verified issue date, so they must sort as the oldest
const storedBuilder = new MockedLicenseBuilder().withGrantedModules(['auditing']);
storedBuilder.information.createdAt = 'not-a-date';
const stored = await storedBuilder.sign();
const provided = await new MockedLicenseBuilder().withCreatedAt(NEWER).withGrantedModules(['livechat-enterprise']).sign();
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
expect(manager.hasModule('livechat-enterprise')).toBe(true);
expect(manager.hasModule('auditing')).toBe(false);
});
it('should apply the stored license when it is newer than the provided one', async () => {
const manager = await getReadyLicenseManager();
const stored = await new MockedLicenseBuilder().withCreatedAt(NEWER).withGrantedModules(['auditing']).sign();
const provided = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['livechat-enterprise']).sign();
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
expect(manager.hasModule('auditing')).toBe(true);
expect(manager.hasModule('livechat-enterprise')).toBe(false);
});
it('should apply the provided license when there is no stored license', async () => {
const manager = await getReadyLicenseManager();
const provided = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['livechat-enterprise']).sign();
await expect(applyNewestLicense(manager, '', provided)).resolves.toBe(true);
expect(manager.hasModule('livechat-enterprise')).toBe(true);
});
it('should fall back to the provided license when the newer stored one fails to apply', async () => {
const manager = await getReadyLicenseManager();
const stored = await new MockedLicenseBuilder().withCreatedAt(NEWER).withExpiredDate().withGrantedModules(['auditing']).sign();
const provided = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['livechat-enterprise']).sign();
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
expect(manager.hasModule('livechat-enterprise')).toBe(true);
});
it('should fall back to the stored license when the newer provided one is corrupted', async () => {
const manager = await getReadyLicenseManager();
const stored = await new MockedLicenseBuilder().withCreatedAt(OLDER).withGrantedModules(['auditing']).sign();
const provided = `${await new MockedLicenseBuilder().withCreatedAt(NEWER).sign()}corrupted`;
await expect(applyNewestLicense(manager, stored, provided)).resolves.toBe(true);
expect(manager.hasModule('auditing')).toBe(true);
});
it('should return false when neither license is provided', async () => {
const manager = await getReadyLicenseManager();
await expect(applyNewestLicense(manager, '', '')).resolves.toBe(false);
expect(manager.hasValidLicense()).toBe(false);
});
});

View File

@@ -0,0 +1,359 @@
/**
* @jest-environment node
*/
import { MockedLicenseBuilder, getReadyLicenseManager } from './MockedLicenseBuilder';
describe('Event License behaviors', () => {
it('should call the module as they are enabled/disabled', async () => {
const license = await getReadyLicenseManager();
const validFn = jest.fn();
const invalidFn = jest.fn();
license.onValidFeature('livechat-enterprise', validFn);
license.onInvalidFeature('livechat-enterprise', invalidFn);
const mocked = await new MockedLicenseBuilder();
const oldToken = await mocked.sign();
const newToken = await mocked.withGrantedModules(['livechat-enterprise']).sign();
// apply license
await expect(license.setLicense(oldToken)).resolves.toBe(true);
await expect(license.hasValidLicense()).toBe(true);
await expect(license.hasModule('livechat-enterprise')).toBe(false);
await expect(validFn).not.toHaveBeenCalled();
await expect(invalidFn).toHaveBeenCalledTimes(1);
// apply license containing livechat-enterprise module
validFn.mockClear();
invalidFn.mockClear();
await expect(license.setLicense(newToken)).resolves.toBe(true);
await expect(license.hasValidLicense()).toBe(true);
await expect(license.hasModule('livechat-enterprise')).toBe(true);
await expect(validFn).toHaveBeenCalledTimes(1);
await expect(invalidFn).toHaveBeenCalledTimes(0);
// apply the old license again
validFn.mockClear();
invalidFn.mockClear();
await expect(license.setLicense(oldToken)).resolves.toBe(true);
await expect(license.hasValidLicense()).toBe(true);
await expect(license.hasModule('livechat-enterprise')).toBe(false);
await expect(validFn).toHaveBeenCalledTimes(0);
await expect(invalidFn).toHaveBeenCalledTimes(1);
});
it('should call `onValidateLicense` when a valid license is applied', async () => {
const license = await getReadyLicenseManager();
const fn = jest.fn();
license.onValidateLicense(fn);
const mocked = await new MockedLicenseBuilder();
const token = await mocked.sign();
await expect(license.setLicense(token)).resolves.toBe(true);
await expect(license.hasValidLicense()).toBe(true);
await expect(fn).toHaveBeenCalledTimes(1);
});
describe('behavior:prevent_action event', () => {
it('should emit `behavior:prevent_action` event when the limit is reached', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
licenseManager.onBehaviorTriggered('prevent_action', fn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(fn).toHaveBeenCalledTimes(1);
await expect(fn).toHaveBeenCalledWith({
reason: 'limit',
limit: 'activeUsers',
});
});
it('should emit `limitReached:activeUsers` event when the limit is reached', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
licenseManager.onLimitReached('activeUsers', fn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(fn).toHaveBeenCalledTimes(1);
await expect(fn).toHaveBeenCalledWith(undefined);
});
});
/**
* This event is used to sync multiple instances of license manager
* The sync event is triggered when the license is changed, but if the validation is running due to a previous change, no sync should be triggered, avoiding multiple/loops syncs
*/
describe('sync event', () => {
it('should emit `sync` event when the license is changed', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
licenseManager.onChange(fn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
{
max: 20,
behavior: 'invalidate_license',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 21);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(fn).toHaveBeenCalledTimes(1);
});
it('should not emit `sync` event when the license validation was triggered by a the sync method', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
licenseManager.onChange(fn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
{
max: 20,
behavior: 'invalidate_license',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 21);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(fn).toHaveBeenCalledTimes(1);
fn.mockClear();
await expect(licenseManager.sync()).resolves.toBe(undefined);
await expect(fn).toHaveBeenCalledTimes(0);
});
});
/**
* this is only called when the prevent_action behavior is triggered for the first time
* it will not be called again until the behavior is toggled
*/
describe('Toggled behaviors', () => {
it('should emit `behaviorToggled:prevent_action` event when the limit is reached once but `behavior:prevent_action` twice', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
const toggleFn = jest.fn();
licenseManager.onBehaviorTriggered('prevent_action', fn);
licenseManager.onBehaviorToggled('prevent_action', toggleFn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(fn).toHaveBeenCalledTimes(2);
await expect(toggleFn).toHaveBeenCalledTimes(1);
await expect(fn).toHaveBeenCalledWith({
reason: 'limit',
limit: 'activeUsers',
});
});
it('should emit `behaviorToggled:allow_action` event when the limit is not reached once but `behavior:allow_action` twice', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
const toggleFn = jest.fn();
licenseManager.onBehaviorTriggered('allow_action', fn);
licenseManager.onBehaviorToggled('allow_action', toggleFn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 9);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(fn).toHaveBeenCalledTimes(2);
await expect(toggleFn).toHaveBeenCalledTimes(0);
await expect(fn).toHaveBeenCalledWith({
reason: 'limit',
limit: 'activeUsers',
});
});
it('should emit `behaviorToggled:prevent_action` and `behaviorToggled:allow_action` events when the shouldPreventAction function changes the result', async () => {
const licenseManager = await getReadyLicenseManager();
const preventFn = jest.fn();
const preventToggleFn = jest.fn();
const allowFn = jest.fn();
const allowToggleFn = jest.fn();
licenseManager.onBehaviorTriggered('prevent_action', preventFn);
licenseManager.onBehaviorToggled('prevent_action', preventToggleFn);
licenseManager.onBehaviorTriggered('allow_action', allowFn);
licenseManager.onBehaviorToggled('allow_action', allowToggleFn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
expect(preventFn).toHaveBeenCalledTimes(0);
expect(preventToggleFn).toHaveBeenCalledTimes(0);
expect(allowFn).toHaveBeenCalledTimes(1);
expect(allowToggleFn).toHaveBeenCalledTimes(0);
preventFn.mockClear();
preventToggleFn.mockClear();
allowFn.mockClear();
allowToggleFn.mockClear();
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
expect(preventFn).toHaveBeenCalledTimes(0);
expect(preventToggleFn).toHaveBeenCalledTimes(0);
expect(allowFn).toHaveBeenCalledTimes(1);
expect(allowToggleFn).toHaveBeenCalledTimes(0);
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
preventFn.mockClear();
preventToggleFn.mockClear();
allowFn.mockClear();
allowToggleFn.mockClear();
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
expect(preventFn).toHaveBeenCalledTimes(1);
expect(preventToggleFn).toHaveBeenCalledTimes(1);
expect(allowFn).toHaveBeenCalledTimes(0);
expect(allowToggleFn).toHaveBeenCalledTimes(0);
preventFn.mockClear();
preventToggleFn.mockClear();
allowFn.mockClear();
allowToggleFn.mockClear();
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
expect(preventFn).toHaveBeenCalledTimes(1);
expect(preventToggleFn).toHaveBeenCalledTimes(0);
expect(allowFn).toHaveBeenCalledTimes(0);
expect(allowToggleFn).toHaveBeenCalledTimes(0);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
preventFn.mockClear();
preventToggleFn.mockClear();
allowFn.mockClear();
allowToggleFn.mockClear();
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
expect(preventFn).toHaveBeenCalledTimes(0);
expect(preventToggleFn).toHaveBeenCalledTimes(0);
expect(allowFn).toHaveBeenCalledTimes(1);
expect(allowToggleFn).toHaveBeenCalledTimes(1);
});
});
describe('Allow actions', () => {
it('should emit `behavior:allow_action` event when the limit is not reached', async () => {
const licenseManager = await getReadyLicenseManager();
const fn = jest.fn();
const preventFn = jest.fn();
licenseManager.onBehaviorTriggered('allow_action', fn);
licenseManager.onBehaviorTriggered('prevent_action', preventFn);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 9);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(fn).toHaveBeenCalledTimes(1);
await expect(preventFn).toHaveBeenCalledTimes(0);
await expect(fn).toHaveBeenCalledWith({
reason: 'limit',
limit: 'activeUsers',
});
});
});
});

View File

@@ -0,0 +1,30 @@
/**
* @jest-environment node
*/
import { MockedLicenseBuilder } from './MockedLicenseBuilder';
import { getLicenseCreatedAt } from '../src/getLicenseCreatedAt';
describe('getLicenseCreatedAt', () => {
it('should return the createdAt of a signed V3 license', async () => {
const createdAt = new Date('2026-01-02T03:04:05.000Z');
const encrypted = await new MockedLicenseBuilder().withCreatedAt(createdAt).sign();
await expect(getLicenseCreatedAt(encrypted)).resolves.toEqual(createdAt);
});
it('should return undefined for an empty license string', async () => {
await expect(getLicenseCreatedAt('')).resolves.toBeUndefined();
await expect(getLicenseCreatedAt(' ')).resolves.toBeUndefined();
});
it('should return undefined for a non-V3 license string', async () => {
await expect(getLicenseCreatedAt('not-a-license')).resolves.toBeUndefined();
});
it('should return undefined for a V3 license with a broken signature', async () => {
const encrypted = await new MockedLicenseBuilder().sign();
await expect(getLicenseCreatedAt(`${encrypted}corrupted`)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,206 @@
/**
* @jest-environment node
*/
import { LicenseImp } from '../src';
import { MockedLicenseBuilder, getReadyLicenseManager } from './MockedLicenseBuilder';
import { DuplicatedLicenseError } from '../src/errors/DuplicatedLicenseError';
import { InvalidLicenseError } from '../src/errors/InvalidLicenseError';
import { NotReadyForValidation } from '../src/errors/NotReadyForValidation';
// Same license used on ci tasks so no I didnt leak it
const VALID_LICENSE =
process.env.ENTERPRISE_LICENSE ||
'MK+bpK5NveUuNlWGaQXGoy+8b74Luet82M3ZGcBB8b5P9Y+m67NEtpW64dc1d5lEWi6d0nFjCjtCMneVD7bKxodz/Cml8URKEo5P7cQb/9wmeT0MzAhYNaRFZlIGkZ3ITF59pDV2u4HZuosEDJikVRwnaJ5ZoU/pOsHSPUPhTyGNIqLeKynODtUpfwDdIKEmHxpf2yVkKjgRiIJmbWjM6A4k+MNNYXWVXHzye7GggqWVg/ZcT7nKU1CCadpLhTJiIrgrrPzil1G5DQ4xnLs3Q2tu2dILSDiW5OYw/ywu2yCMicTjMq4MLL5SXDQJj6WoJzZ54HosbvsDzOXvsdC9gI1CjhPL2uRuvC8XLrzn3vL2UgXnifzD1VrLTtdZ+aSADveqtlzYlRWtqoUFBbNw8o+YVHdhbZGR0beMoAyRbHi5EMpxpad3L+NyztUIT/Uh/IjQ/C2SQZ6jB0GKPBOPxFLN56FNhTGrffLFR++TVoBu0Iquc7kajWkNit3bVbZvbx+oFcVW2PcjQ/+i2jpJjbgtUFUKrTKxGMAXTWoDzIQQ35zNzGAy268IM4Ymp5JmsVEnBOEUkbF9yx6fzkO6xZhpsHf0muklnW0kA+Tlore/TUrBWh1/RwWlQeZlxM5NyWoRM5onQmr/k/4BmObtL1Hpmbk8oMG29z89xtE9y/4=';
describe('License set license procedures', () => {
describe('Invalid formats', () => {
it('should have no license by default', async () => {
const license = new LicenseImp();
expect(license.hasValidLicense()).toBe(false);
expect(license.getLicense()).toBeUndefined();
});
it('should throw an error if the license applied is empty', async () => {
const license = new LicenseImp();
await expect(license.setLicense('')).rejects.toThrow(InvalidLicenseError);
});
it('should throw an error if the license applied is invalid', async () => {
const license = new LicenseImp();
await expect(license.setLicense('invalid')).rejects.toThrow(InvalidLicenseError);
});
});
describe('Invalid periods', () => {
it('should throw an error if the license is expired', async () => {
const license = await getReadyLicenseManager();
const mocked = new MockedLicenseBuilder();
const token = await mocked.withExpiredDate().sign();
await license.setLicense(token);
expect(license.hasValidLicense()).toBe(false);
});
describe('license that is not not started yet is applied', () => {
it('should throw an error if the license is not started yet', async () => {
const license = await getReadyLicenseManager();
const mocked = new MockedLicenseBuilder();
const token = await mocked.withNotStartedDate().sign();
await license.setLicense(token);
expect(license.hasValidLicense()).toBe(false);
});
it('should be allowed to set the same license again if the license is not started yet', async () => {
const license = await getReadyLicenseManager();
const mocked = new MockedLicenseBuilder();
const as = mocked.resetValidPeriods().withNotStartedDate();
const token = await as.sign();
await license.setLicense(token);
expect(license.hasValidLicense()).toBe(false);
// 5 minutes in the future
const mockedData = new Date();
mockedData.setMinutes(mockedData.getMinutes() + 5);
jest.useFakeTimers();
jest.setSystemTime(mockedData);
await license.setLicense(token);
jest.useRealTimers();
expect(license.hasValidLicense()).toBe(true);
});
});
});
it('should throw an error if the license is duplicated', async () => {
const license = await getReadyLicenseManager();
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(DuplicatedLicenseError);
});
it('should keep a valid license if a new invalid formatted license is applied', async () => {
const license = await getReadyLicenseManager();
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
expect(license.hasValidLicense()).toBe(true);
await expect(license.setLicense('invalid')).rejects.toThrow(InvalidLicenseError);
expect(license.hasValidLicense()).toBe(true);
});
describe('Pending cases', () => {
it('should return an error if the license is not ready for validation yet - missing workspace url', async () => {
const license = new LicenseImp();
await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(NotReadyForValidation);
});
it('should return an error if the license is not ready for validation yet - missing counters', async () => {
const license = new LicenseImp();
await license.setWorkspaceUrl('http://localhost:3000');
expect(license.getWorkspaceUrl()).toBe('localhost:3000');
await expect(license.setLicense(VALID_LICENSE)).rejects.toThrow(NotReadyForValidation);
expect(license.hasValidLicense()).toBe(false);
});
it('should return a valid license if the license is ready for validation', async () => {
const license = await getReadyLicenseManager();
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
expect(license.hasValidLicense()).toBe(true);
});
});
describe('License V3', () => {
it('should return a valid license if the license is ready for validation', async () => {
const license = await getReadyLicenseManager();
const token = await new MockedLicenseBuilder().sign();
await expect(license.setLicense(token)).resolves.toBe(true);
expect(license.hasValidLicense()).toBe(true);
});
it('should accept new licenses', async () => {
const license = await getReadyLicenseManager();
const mocked = new MockedLicenseBuilder();
const oldToken = await mocked.sign();
const newToken = await mocked.withGrantedModules(['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 () => {
const license = await getReadyLicenseManager();
const validateCallback = jest.fn();
license.onValidateLicense(validateCallback);
await expect(license.setLicense(VALID_LICENSE)).resolves.toBe(true);
expect(license.hasValidLicense()).toBe(true);
expect(validateCallback).toHaveBeenCalledTimes(1);
});
describe('License limits', () => {
describe('invalidate license', () => {
it('should trigger an invalidation event when a license with invalid limits is set after a valid one', async () => {
const invalidationCallback = jest.fn();
const licenseManager = await getReadyLicenseManager();
const mocked = new MockedLicenseBuilder();
const oldToken = await mocked
.withLimits('activeUsers', [
{
max: 10,
behavior: 'invalidate_license',
},
])
.sign();
const newToken = await mocked
.withLimits('activeUsers', [
{
max: 1,
behavior: 'invalidate_license',
},
])
.sign();
licenseManager.onInvalidateLicense(invalidationCallback);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.setLicense(oldToken)).resolves.toBe(true);
expect(licenseManager.hasValidLicense()).toBe(true);
await expect(licenseManager.setLicense(newToken)).resolves.toBe(true);
expect(licenseManager.hasValidLicense()).toBe(false);
expect(invalidationCallback).toHaveBeenCalledTimes(1);
});
});
});
});
});

View File

@@ -0,0 +1,7 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
modulePathIgnorePatterns: ['<rootDir>/__tests__/MockedLicenseBuilder'],
} satisfies Config;

View File

@@ -0,0 +1,39 @@
{
"name": "@rocket.chat/license",
"version": "1.2.0",
"private": true,
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"files": [
"/dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch --preserveWatchOutput",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"test": "jest",
"testunit": "jest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/jwt": "workspace:^",
"@rocket.chat/logger": "workspace:^",
"bcrypt": "^6.0.0"
},
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/jest": "~30.0.0",
"@types/ws": "^8.5.14",
"eslint": "~9.39.5",
"jest": "~30.2.0",
"jest-websocket-mock": "~2.5.0",
"typescript": "~5.9.3"
},
"volta": {
"extends": "../../../package.json"
}
}

View File

@@ -0,0 +1,113 @@
import { AirGappedRestriction } from './AirGappedRestriction';
import { StatsTokenBuilder } from './MockedLicenseBuilder';
import { License } from './licenseImp';
jest.mock('./licenseImp', () => ({
License: {
hasValidLicense: jest.fn().mockReturnValue(false),
},
}));
describe('AirGappedRestriction', () => {
describe('#computeRestriction()', () => {
it('should notify remaining days = 0 (apply restriction) when token is not a string', async () => {
const handler = jest.fn();
AirGappedRestriction.on('remainingDays', handler);
await AirGappedRestriction.computeRestriction();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
days: 0,
});
expect(AirGappedRestriction.restricted).toBe(true);
});
it('should notify remaining days = 0 (apply restriction) when it was not possible to decrypt the stats token', async () => {
const handler = jest.fn();
AirGappedRestriction.on('remainingDays', handler);
await AirGappedRestriction.computeRestriction('invalid-token');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
days: 0,
});
expect(AirGappedRestriction.restricted).toBe(true);
});
it('should notify remaining days (8) within the accepted range (1 - 10) when the last reported stats happened 2 days ago', async () => {
const now = new Date();
now.setDate(now.getDate() - 2);
const token = await new StatsTokenBuilder().withTimeStamp(now).sign();
const handler = jest.fn();
AirGappedRestriction.on('remainingDays', handler);
await AirGappedRestriction.computeRestriction(token);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
days: 8,
});
expect(AirGappedRestriction.restricted).toBe(false);
});
it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened more than 10 days ago', async () => {
const now = new Date();
now.setDate(now.getDate() - 11);
const token = await new StatsTokenBuilder().withTimeStamp(now).sign();
const handler = jest.fn();
AirGappedRestriction.on('remainingDays', handler);
await AirGappedRestriction.computeRestriction(token);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
days: 0,
});
expect(AirGappedRestriction.restricted).toBe(true);
});
it('should notify remaining days = 0 (apply restrictions) when the last reported stats happened 10 days ago', async () => {
const now = new Date();
now.setDate(now.getDate() - 10);
const token = await new StatsTokenBuilder().withTimeStamp(now).sign();
const handler = jest.fn();
AirGappedRestriction.on('remainingDays', handler);
await AirGappedRestriction.computeRestriction(token);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
days: 0,
});
expect(AirGappedRestriction.restricted).toBe(true);
});
it('should notify remaining days = -1 when has valid license', () => {
const handler = jest.fn();
(License.hasValidLicense as jest.Mock).mockReturnValueOnce(true);
AirGappedRestriction.on('remainingDays', handler);
AirGappedRestriction.computeRestriction();
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({
days: -1,
});
expect(AirGappedRestriction.restricted).toBe(false);
});
});
describe('#isWarningPeriod', () => {
it('should return true if value is between or exactly 0 and 7', async () => {
expect(AirGappedRestriction.isWarningPeriod(0)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(1)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(2)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(3)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(4)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(5)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(6)).toBe(true);
expect(AirGappedRestriction.isWarningPeriod(7)).toBe(true);
});
it('should return false if value is lesser than 0 or bigger than 7', async () => {
expect(AirGappedRestriction.isWarningPeriod(-1)).toBe(false);
expect(AirGappedRestriction.isWarningPeriod(8)).toBe(false);
expect(AirGappedRestriction.isWarningPeriod(10)).toBe(false);
});
});
});

View File

@@ -0,0 +1,73 @@
import EventEmitter from 'node:events';
import { License } from '.';
import { decryptStatsToken } from './token';
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const NO_ACTION_PERIOD_IN_DAYS = 3;
const WARNING_PERIOD_IN_DAYS = 7;
class AirGappedRestrictionClass extends EventEmitter {
#restricted = true;
public get restricted(): boolean {
return this.#restricted;
}
public async computeRestriction(encryptedToken?: string): Promise<void> {
if (License.hasValidLicense()) {
this.removeRestrictionsUnderLicense();
return;
}
if (typeof encryptedToken !== 'string') {
this.applyRestrictions();
return;
}
return this.checkRemainingDaysSinceLastStatsReport(encryptedToken);
}
private async checkRemainingDaysSinceLastStatsReport(encryptedToken: string): Promise<void> {
try {
const { timestamp: lastStatsReportTimestamp } = JSON.parse(await decryptStatsToken(encryptedToken));
const now = new Date();
const lastStatsReport = new Date(lastStatsReportTimestamp);
const nowUTC = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
const lastStatsReportUTC = Date.UTC(lastStatsReport.getFullYear(), lastStatsReport.getMonth(), lastStatsReport.getDate());
const daysSinceLastStatsReport = Math.floor((nowUTC - lastStatsReportUTC) / DAY_IN_MS);
this.notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport);
} catch {
this.applyRestrictions();
}
}
private applyRestrictions(): void {
this.notifyRemainingDaysUntilRestriction(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS);
}
private removeRestrictionsUnderLicense(): void {
this.#restricted = false;
this.emit('remainingDays', { days: -1 });
}
public isWarningPeriod(days: number) {
if (days < 0) {
return false;
}
return days <= WARNING_PERIOD_IN_DAYS;
}
private notifyRemainingDaysUntilRestriction(daysSinceLastStatsReport: number): void {
const remainingDaysUntilRestriction = Math.max(NO_ACTION_PERIOD_IN_DAYS + WARNING_PERIOD_IN_DAYS - daysSinceLastStatsReport, 0);
this.#restricted = remainingDaysUntilRestriction === 0;
this.emit('remainingDays', { days: remainingDaysUntilRestriction });
}
}
const airGappedRestriction = new AirGappedRestrictionClass();
export { airGappedRestriction as AirGappedRestriction };

View File

@@ -0,0 +1,280 @@
import { CoreModules } from '@rocket.chat/core-typings';
import type {
InternalModuleName,
GrantedModules,
ILicenseTag,
ILicenseV3,
LicenseLimit,
LicenseModule,
LicensePeriod,
} from '@rocket.chat/core-typings';
import { encrypt, encryptStatsToken } from './token';
export class MockedLicenseBuilder {
information: {
id?: string;
autoRenew: boolean;
visualExpiration?: string;
notifyAdminsAt?: string;
notifyUsersAt?: string;
trial: boolean;
offline: boolean;
createdAt: string;
grantedBy: {
method: 'manual' | 'self-service' | 'sales' | 'support' | 'reseller';
seller?: string;
};
grantedTo?: {
name?: string;
company?: string;
email?: string;
};
legalText?: string;
notes?: string;
tags?: ILicenseTag[];
};
validation: {
serverUrls: {
value: string;
type: 'url' | 'regex' | 'hash';
}[];
serverVersions?: {
value: string;
}[];
serverUniqueId?: string;
cloudWorkspaceId?: string;
validPeriods: LicensePeriod[];
legalTextAgreement?: {
type: 'required' | 'not-required' | 'accepted';
acceptedVia?: 'cloud';
};
statisticsReport: {
required: boolean;
allowedStaleInDays?: number;
};
};
constructor() {
this.information = {
autoRenew: true,
// expires in 1 year
visualExpiration: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(),
// 15 days before expiration
notifyAdminsAt: new Date(new Date().setDate(new Date().getDate() + 15)).toISOString(),
// 30 days before expiration
notifyUsersAt: new Date(new Date().setDate(new Date().getDate() + 30)).toISOString(),
trial: false,
offline: false,
createdAt: new Date().toISOString(),
grantedBy: {
method: 'manual',
seller: 'Rocket.Cat',
},
tags: [
{
name: 'Test',
color: 'blue',
},
],
};
this.validation = {
serverUrls: [
{
value: 'localhost:3000',
type: 'url',
},
],
serverVersions: [
{
value: '3.0.0',
},
],
serverUniqueId: '1234567890',
cloudWorkspaceId: '1234567890',
validPeriods: [
{
invalidBehavior: 'disable_modules',
modules: ['livechat-enterprise'],
validFrom: new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString(),
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(),
},
],
statisticsReport: {
required: true,
allowedStaleInDays: 30,
},
};
}
public withCreatedAt(date: Date): this {
this.information.createdAt = date.toISOString();
return this;
}
public withExpiredDate(): this {
// expired 1 minute ago
const date = new Date();
date.setMinutes(date.getMinutes() - 1);
const expired = date.toISOString();
const rule = this.validation.validPeriods.find((period) => period.invalidBehavior === 'invalidate_license');
if (rule) {
rule.validUntil = expired;
} else {
this.validation.validPeriods.push({
invalidBehavior: 'invalidate_license',
validFrom: new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString(),
validUntil: expired,
});
}
return this;
}
public withNotStartedDate(): this {
// starts in 1 minute
const date = new Date();
date.setMinutes(date.getMinutes() + 1);
const starts = date.toISOString();
const rule = this.validation.validPeriods.find((period) => period.invalidBehavior === 'invalidate_license');
if (rule) {
rule.validFrom = starts;
} else {
this.validation.validPeriods.push({
invalidBehavior: 'invalidate_license',
validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString(),
validFrom: starts,
});
}
return this;
}
public resetValidPeriods(): this {
this.validation.validPeriods = [];
return this;
}
public withValidPeriod(period: LicensePeriod): this {
this.validation.validPeriods.push(period);
return this;
}
public withGrantedTo(grantedTo: { name?: string; company?: string; email?: string }): this {
this.information.grantedTo = grantedTo;
return this;
}
public withOffline(offline = true): this {
this.information.offline = offline;
return this;
}
grantedModules: GrantedModules = [];
limits: {
activeUsers?: LicenseLimit[];
guestUsers?: LicenseLimit[];
roomsPerGuest?: LicenseLimit<'prevent_action'>[];
privateApps?: LicenseLimit[];
marketplaceApps?: LicenseLimit[];
monthlyActiveContacts?: LicenseLimit[];
} = {};
cloudMeta?: Record<string, any>;
public withServerUrls(urls: { value: string; type: 'url' | 'regex' | 'hash' }): this {
this.validation.serverUrls = this.validation.serverUrls ?? [];
this.validation.serverUrls.push(urls);
return this;
}
public withServerVersions(versions: { value: string }): this {
this.validation.serverVersions = this.validation.serverVersions ?? [];
this.validation.serverVersions.push(versions);
return this;
}
public withGrantedModules(modules: LicenseModule[]): this {
this.grantedModules = this.grantedModules ?? [];
this.grantedModules.push(
...(modules.map((module) => ({ module, external: !CoreModules.includes(module as InternalModuleName) })) as GrantedModules),
);
return this;
}
withNoGrantedModules(modules: LicenseModule[]): this {
this.grantedModules = this.grantedModules ?? [];
this.grantedModules = this.grantedModules.filter(({ module }) => !modules.includes(module));
return this;
}
public withLimits<K extends keyof ILicenseV3['limits']>(key: K, value: ILicenseV3['limits'][K]): this {
this.limits = this.limits ?? {};
this.limits[key] = value;
return this;
}
public build(): ILicenseV3 {
return {
version: '3.0',
information: this.information,
validation: this.validation,
grantedModules: [...new Set(this.grantedModules)],
limits: {
activeUsers: [],
guestUsers: [],
roomsPerGuest: [],
privateApps: [],
marketplaceApps: [],
monthlyActiveContacts: [],
...this.limits,
},
cloudMeta: this.cloudMeta,
};
}
public sign(): Promise<string> {
return encrypt(this.build());
}
}
export class StatsTokenBuilder {
private token: Record<string, any>;
constructor() {
this.token = {
workspaceId: '123456789',
uniqueId: '123456789',
recordId: '123456789',
timestamp: new Date().toISOString(),
info: {},
};
}
public withTimeStamp(date: Date): StatsTokenBuilder {
this.token.timestamp = date.toISOString();
return this;
}
public build(): Record<string, any> {
return this.token;
}
public sign(): Promise<string> {
return encryptStatsToken(this.token);
}
}

View File

@@ -0,0 +1,73 @@
import { getLicenseCreatedAt } from './getLicenseCreatedAt';
import type { LicenseImp } from './licenseImp';
import { License } from './licenseImp';
export const applyLicense = async (license: string, isNewLicense: boolean, manager: LicenseImp = License): Promise<boolean> => {
const enterpriseLicense = (license ?? '').trim();
if (!enterpriseLicense) {
return false;
}
if (enterpriseLicense === manager.encryptedLicense) {
return false;
}
try {
return manager.setLicense(enterpriseLicense, isNewLicense);
} catch {
return false;
}
};
export const applyLicenseOrRemove = async (license: string, isNewLicense: boolean, manager: LicenseImp = License): Promise<boolean> => {
const enterpriseLicense = (license ?? '').trim();
if (!enterpriseLicense) {
manager.remove();
return false;
}
if (enterpriseLicense === manager.encryptedLicense) {
return false;
}
try {
return manager.setLicense(enterpriseLicense, isNewLicense);
} catch {
manager.remove();
return false;
}
};
/**
* Applies the most recently issued of the given licenses, falling back to the
* next one whenever the newest doesn't result in a valid license (note that
* `setLicense` reports success even for licenses applied in an invalid/expired
* state, so validity — not the apply result — drives the fallback).
*
* The first license is the one already known to the workspace (applied as not
* new); the others are externally supplied, e.g. through an environment
* variable (applied as new). Issue dates come from signature-verified payloads
* only — licenses without one (V2, invalid or empty strings) sort as oldest,
* preserving the given order among themselves.
*
* Returns whether the workspace ended up with a valid license.
*/
export const applyNewestLicense = async (manager: LicenseImp = License, ...licenses: Array<string>): Promise<boolean> => {
const candidates = licenses
.map((license, index) => ({ license: (license ?? '').trim(), isNewLicense: index > 0 }))
.filter(({ license }) => license);
const sortedLicenses = (
await Promise.all(candidates.map(async (candidate) => ({ ...candidate, createdAt: await getLicenseCreatedAt(candidate.license) })))
).sort((a, b) => (b.createdAt?.getTime() ?? 0) - (a.createdAt?.getTime() ?? 0));
for (const { license, isNewLicense } of sortedLicenses) {
await applyLicense(license, isNewLicense, manager);
if (manager.hasValidLicense()) {
return true;
}
}
return manager.hasValidLicense();
};

View File

@@ -0,0 +1,39 @@
import type { ILicenseV3, LicenseLimitKind } from '@rocket.chat/core-typings';
import type { LicenseManager } from './license';
import { getModules } from './modules';
import { defaultLimits } from './validation/validateDefaultLimits';
export const getLicenseLimit = (license: ILicenseV3 | undefined, kind: LicenseLimitKind) => {
const limitList = license ? license.limits[kind] : defaultLimits[kind as keyof typeof defaultLimits];
if (!limitList?.length) {
return -1;
}
return Math.min(...limitList.map(({ max }) => max));
};
// #TODO: Remove references to those functions
export function getMaxActiveUsers(this: LicenseManager) {
return getLicenseLimit(this.getLicense(), 'activeUsers') ?? 0;
}
export function getAppsConfig(this: LicenseManager) {
return {
maxPrivateApps: getLicenseLimit(this.getLicense(), 'privateApps'),
maxMarketplaceApps: getLicenseLimit(this.getLicense(), 'marketplaceApps'),
};
}
export function getUnmodifiedLicenseAndModules(this: LicenseManager) {
if (this.valid && this.unmodifiedLicense) {
return {
license: this.unmodifiedLicense,
modules: getModules.call(this),
};
}
return undefined;
}

View File

@@ -0,0 +1,6 @@
export class DuplicatedLicenseError extends Error {
constructor(message = 'Duplicated license') {
super(message);
this.name = 'DuplicatedLicense';
}
}

View File

@@ -0,0 +1,6 @@
export class InvalidLicenseError extends Error {
constructor(message = 'Invalid license') {
super(message);
this.name = 'InvalidLicenseError';
}
}

View File

@@ -0,0 +1,6 @@
export class NotReadyForValidation extends Error {
constructor(message = 'Not ready for validation') {
super(message);
this.name = 'NotReadyForValidation';
}
}

View File

@@ -0,0 +1,13 @@
import type { LicenseModule } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { hasModule } from '../modules';
// #TODO: Remove this onLicense handler
export function onLicense(this: LicenseManager, feature: LicenseModule, cb: (...args: any[]) => void): void | Promise<void> {
if (hasModule.call(this, feature)) {
return cb();
}
this.once(`valid:${feature}`, cb);
}

View File

@@ -0,0 +1,93 @@
import type { BehaviorWithContext, LicenseModule } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { logger } from '../logger';
import { isInternalModuleName } from '../modules';
export function moduleValidated(this: LicenseManager, module: LicenseModule) {
try {
const external = !isInternalModuleName(module);
this.emit('module', { module, external, valid: true });
} catch (err) {
logger.error({ msg: 'Error running module (valid: true) event', module, err });
}
try {
this.emit(`valid:${module}`);
} catch (err) {
logger.error({ msg: 'Error running module added event', module, err });
}
}
export function moduleRemoved(this: LicenseManager, module: LicenseModule) {
try {
const external = !isInternalModuleName(module);
this.emit('module', { module, external, valid: false });
} catch (err) {
logger.error({ msg: 'Error running module (valid: false) event', module, err });
}
try {
this.emit(`invalid:${module}`);
} catch (err) {
logger.error({ msg: 'Error running module removed event', module, err });
}
}
export function behaviorTriggered(this: LicenseManager, options: BehaviorWithContext) {
const { behavior, reason, modules: _, ...rest } = options;
try {
this.emit(`behavior:${behavior}`, {
reason,
...rest,
});
} catch (err) {
logger.error({ msg: 'Error running behavior triggered event', behavior, err });
}
if (!['prevent_action'].includes(behavior)) {
return;
}
if (reason !== 'limit' || !(`limit` in rest) || !rest.limit) {
return;
}
try {
this.emit(`limitReached:${rest.limit}`);
} catch (err) {
logger.error({ msg: 'Error running limit reached event', limit: rest.limit, err });
}
}
export function behaviorTriggeredToggled(this: LicenseManager, options: BehaviorWithContext) {
const { behavior, reason, modules: _, ...rest } = options;
try {
this.emit(`behaviorToggled:${behavior}`, {
reason,
...rest,
});
} catch (err) {
logger.error({ msg: 'Error running behavior triggered event', behavior, err });
}
}
export function licenseValidated(this: LicenseManager) {
try {
this.emit('validate');
} catch (err) {
logger.error({ msg: 'Error running license validated event', err });
}
}
export function licenseInvalidated(this: LicenseManager) {
try {
this.emit('invalidate');
} catch (err) {
logger.error({ msg: 'Error running license invalidated event', err });
}
}

View File

@@ -0,0 +1,116 @@
import type { LicenseLimitKind, LicenseModule, BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { hasModule } from '../modules';
/**
* Invoked when the license changes some internal state. it's called to sync the license with other instances.
*/
export function onChange(this: LicenseManager, cb: () => void) {
this.on('sync', cb);
}
export function onInstall(this: LicenseManager, cb: () => void) {
if (this.hasValidLicense()) {
cb();
}
this.on('installed', cb);
}
export function onRemoveLicense(this: LicenseManager, cb: () => void) {
this.on('removed', cb);
}
export function onInvalidate(this: LicenseManager, cb: () => void) {
if (!this.hasValidLicense()) {
cb();
}
this.on('invalidate', cb);
}
export function onValidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) {
this.on(`valid:${feature}`, cb);
if (hasModule.call(this, feature)) {
cb();
}
return (): void => {
this.off(`valid:${feature}`, cb);
};
}
export function onInvalidFeature(this: LicenseManager, feature: LicenseModule, cb: () => void) {
this.on(`invalid:${feature}`, cb);
if (!hasModule.call(this, feature)) {
cb();
}
return (): void => {
this.off(`invalid:${feature}`, cb);
};
}
export function onToggledFeature(
this: LicenseManager,
feature: LicenseModule,
{ up, down }: { up?: () => Promise<void> | void; down?: () => Promise<void> | void },
): () => void {
let enabled = hasModule.call(this, feature);
const offValidFeature = onValidFeature.bind(this)(feature, () => {
if (!enabled) {
void up?.();
enabled = true;
}
});
const offInvalidFeature = onInvalidFeature.bind(this)(feature, () => {
if (enabled) {
void down?.();
enabled = false;
}
});
if (enabled) {
void up?.();
}
return (): void => {
offValidFeature();
offInvalidFeature();
};
}
export function onModule(this: LicenseManager, cb: (data: { module: LicenseModule; external: boolean; valid: boolean }) => void) {
this.on('module', cb);
}
export function onValidateLicense(this: LicenseManager, cb: () => void) {
this.on('validate', cb);
}
export function onInvalidateLicense(this: LicenseManager, cb: () => void) {
this.on('invalidate', cb);
}
export function onBehaviorTriggered(
this: LicenseManager,
behavior: Exclude<LicenseBehavior, 'prevent_installation'>,
cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void,
) {
this.on(`behavior:${behavior}`, cb);
}
export function onBehaviorToggled(
this: LicenseManager,
behavior: Exclude<LicenseBehavior, 'prevent_installation'>,
cb: (data: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind }) => void,
) {
this.on(`behaviorToggled:${behavior}`, cb);
}
export function onLimitReached(this: LicenseManager, limitKind: LicenseLimitKind, cb: () => void) {
this.on(`limitReached:${limitKind}`, cb);
}

View File

@@ -0,0 +1,27 @@
import type { LicenseModule } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { onLicense } from './deprecated';
interface IOverrideClassProperties {
[key: string]: (...args: any[]) => any;
}
type Class = { new (...args: any[]): any };
export async function overwriteClassOnLicense(
this: LicenseManager,
license: LicenseModule,
original: Class,
overwrite: IOverrideClassProperties,
): Promise<void> {
await onLicense.call(this, license, () => {
Object.entries(overwrite).forEach(([key, value]) => {
const originalFn = original.prototype[key];
original.prototype[key] = function (...args: any[]): any {
return value.call(this, originalFn, ...args);
};
});
});
}

View File

@@ -0,0 +1,29 @@
import type { ILicenseV3 } from '@rocket.chat/core-typings';
import { decrypt } from './token';
/**
* Returns the signature-verified `information.createdAt` of an encrypted license,
* without applying it.
*
* Returns `undefined` when the string is empty or invalid, or when the license
* carries no issue date (V2 licenses) — callers ordering licenses by age should
* treat those as the oldest.
*/
export async function getLicenseCreatedAt(encryptedLicense: string): Promise<Date | undefined> {
const license = (encryptedLicense ?? '').trim();
if (!license.startsWith('RCV3_')) {
return undefined;
}
try {
const decrypted: ILicenseV3 = JSON.parse(await decrypt(license));
const createdAt = new Date(decrypted.information.createdAt);
return Number.isNaN(createdAt.getTime()) ? undefined : createdAt;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,27 @@
import { LicenseImp } from '.';
import { getAppsConfig } from './deprecated';
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
describe('Marketplace Restrictions', () => {
it('should respect the default if there is no license applied', async () => {
const LicenseManager = new LicenseImp();
expect(getAppsConfig.call(LicenseManager)).toEqual({
maxPrivateApps: 0,
maxMarketplaceApps: 5,
});
});
it('should return unlimited if there is license but no limits', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder();
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
await expect(getAppsConfig.call(licenseManager)).toEqual({
maxPrivateApps: -1,
maxMarketplaceApps: -1,
});
});
});

View File

@@ -0,0 +1,7 @@
export { DuplicatedLicenseError } from './errors/DuplicatedLicenseError';
export * from './licenseImp';
export * from './license';
export * from './MockedLicenseBuilder';
export * from './applyLicense';
export * from './getLicenseCreatedAt';
export * from './AirGappedRestriction';

View File

@@ -0,0 +1,10 @@
import type { LicenseLimitKind, LicenseBehavior, LicenseValidationOptions } from '@rocket.chat/core-typings';
const isItemAllowed = <T>(item: T, allowList?: T[]): boolean => {
return !allowList || allowList.includes(item);
};
export const isLimitAllowed = (item: LicenseLimitKind, options: LicenseValidationOptions): boolean => isItemAllowed(item, options.limits);
export const isBehaviorAllowed = (item: LicenseBehavior, options: LicenseValidationOptions): boolean =>
isItemAllowed(item, options.behaviors) && (options.isNewLicense || item !== 'prevent_installation');

View File

@@ -0,0 +1,537 @@
import { LicenseImp } from '.';
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
it('should not prevent if there is no license', async () => {
const license = await getReadyLicenseManager();
const result = await license.shouldPreventAction('activeUsers');
expect(result).toBe(false);
});
it('should not prevent if the counter is under the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
});
it('should not prevent actions if there is no limit set in the license', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder();
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false);
});
it('should prevent if the counter is equal or over the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
});
it('should not prevent an action if another limit is over the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder()
.withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
])
.withLimits('monthlyActiveContacts', [
{
max: 10,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
licenseManager.setLicenseLimitCounter('monthlyActiveContacts', () => 2);
await expect(licenseManager.shouldPreventAction('monthlyActiveContacts')).resolves.toBe(false);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
});
describe('Validate License Limits', () => {
describe('prevent_action behavior', () => {
describe('during the licensing apply', () => {
it('should not trigger the event even if the counter is over the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const preventActionCallback = jest.fn();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
expect(preventActionCallback).toHaveBeenCalledTimes(0);
});
});
});
describe('fair usage behavior', () => {
it('should change the `prevent_action` flag to true if the counter is equal or over the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const fairUsageCallback = jest.fn();
const preventActionCallback = jest.fn();
licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback);
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
{
max: 10,
behavior: 'start_fair_policy',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
expect(preventActionCallback).toHaveBeenCalledTimes(0);
preventActionCallback.mockClear();
fairUsageCallback.mockClear();
licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
expect(fairUsageCallback).toHaveBeenCalledTimes(1);
expect(preventActionCallback).toHaveBeenCalledTimes(1);
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
preventActionCallback.mockClear();
fairUsageCallback.mockClear();
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
expect(preventActionCallback).toHaveBeenCalledTimes(4);
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
});
it('should trigger the toggle event if the counter is under the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const fairUsageCallback = jest.fn();
const preventActionCallback = jest.fn();
licenseManager.onBehaviorToggled('start_fair_policy', fairUsageCallback);
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 12,
behavior: 'prevent_action',
},
{
max: 10,
behavior: 'start_fair_policy',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 12);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
expect(fairUsageCallback).toHaveBeenCalledTimes(1);
expect(preventActionCallback).toHaveBeenCalledTimes(1);
preventActionCallback.mockClear();
fairUsageCallback.mockClear();
licenseManager.setLicenseLimitCounter('activeUsers', () => 12);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
expect(preventActionCallback).toHaveBeenCalledTimes(1);
preventActionCallback.mockClear();
fairUsageCallback.mockClear();
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
expect(fairUsageCallback).toHaveBeenCalledTimes(1);
expect(preventActionCallback).toHaveBeenCalledTimes(0);
});
});
describe('invalidate_license behavior', () => {
it('should invalidate the license if the counter is over the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const invalidateCallback = jest.fn();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
{
max: 10,
behavior: 'invalidate_license',
},
]);
licenseManager.on('invalidate', invalidateCallback);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(licenseManager.hasValidLicense()).toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
await expect(licenseManager.hasValidLicense()).toBe(true);
await licenseManager.setLicenseLimitCounter('activeUsers', () => 10);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(licenseManager.hasValidLicense()).toBe(true);
expect(invalidateCallback).toHaveBeenCalledTimes(0);
await licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
await expect(licenseManager.hasValidLicense()).toBe(false);
expect(invalidateCallback).toHaveBeenCalledTimes(1);
});
});
describe('prevent action for future limits', () => {
it('should prevent if the counter plus the extra value is equal or over the limit', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 10,
behavior: 'prevent_action',
},
]);
const fairUsageCallback = jest.fn();
const preventActionCallback = jest.fn();
licenseManager.onBehaviorTriggered('start_fair_policy', fairUsageCallback);
licenseManager.onBehaviorTriggered('prevent_action', preventActionCallback);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 5);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
expect(preventActionCallback).toHaveBeenCalledTimes(0);
for await (const extraCount of [1, 2, 3, 4, 5]) {
await expect(licenseManager.shouldPreventAction('activeUsers', extraCount)).resolves.toBe(false);
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
expect(preventActionCallback).toHaveBeenCalledTimes(0);
}
/**
* if we are testing the current count 10 should prevent the action, if we are testing the future count 10 should not prevent the action but 11
*/
await expect(licenseManager.shouldPreventAction('activeUsers', 6)).resolves.toBe(true);
expect(fairUsageCallback).toHaveBeenCalledTimes(0);
expect(preventActionCallback).toHaveBeenCalledTimes(0);
});
it('should check roomsPerGuest with per-user context', async () => {
const licenseManager = await getReadyLicenseManager();
const license = new MockedLicenseBuilder().withLimits('roomsPerGuest', [
{
max: 3,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('roomsPerGuest', (context) => {
switch (context?.userId) {
case 'user1':
return 2;
case 'user2':
return 3;
default:
return 0;
}
});
await expect(licenseManager.shouldPreventAction('roomsPerGuest', 1, { userId: 'user1' })).resolves.toBe(false);
await expect(licenseManager.shouldPreventAction('roomsPerGuest', 1, { userId: 'user2' })).resolves.toBe(true);
});
});
});
describe('License.getInfo', () => {
describe('Marketplace Restrictions', () => {
it('should respect the default if there is no license applied', async () => {
const licenseManager = new LicenseImp();
licenseManager.setLicenseLimitCounter('privateApps', () => 0);
licenseManager.setLicenseLimitCounter('marketplaceApps', () => 0);
expect(
(
await licenseManager.getInfo({
limits: true,
currentValues: false,
license: false,
})
).limits,
).toMatchObject({
privateApps: { max: 0 },
marketplaceApps: { max: 5 },
});
});
it('should return unlimited if there is license but no limits', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder();
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
await expect(
(
await licenseManager.getInfo({
limits: true,
currentValues: false,
license: false,
})
).limits,
).toMatchObject({
privateApps: { max: -1 },
marketplaceApps: { max: -1 },
});
});
});
});
describe('License.setLicense', () => {
it('should trigger the validate event even if the module callback throws an error', async () => {
const licenseManager = await getReadyLicenseManager();
const validateCallback = jest.fn();
const moduleCallback = jest.fn(() => {
throw new Error('Error');
});
const syncCallback = jest.fn();
licenseManager.on('validate', validateCallback);
licenseManager.on('sync', syncCallback);
licenseManager.on('module', moduleCallback);
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing']);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
expect(validateCallback).toHaveBeenCalledTimes(1);
expect(moduleCallback).toHaveBeenCalledTimes(1);
expect(syncCallback).toHaveBeenCalledTimes(0);
});
it('should trigger the sync event only from the sync method', async () => {
const licenseManager = await getReadyLicenseManager();
const validateCallback = jest.fn();
const moduleCallback = jest.fn();
const syncCallback = jest.fn();
licenseManager.on('validate', validateCallback);
licenseManager.on('sync', syncCallback);
licenseManager.on('module', moduleCallback);
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing']).withLimits('activeUsers', [
{
max: 10,
behavior: 'disable_modules',
modules: ['auditing'],
},
]);
await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true);
expect(validateCallback).toHaveBeenCalledTimes(1);
expect(moduleCallback).toHaveBeenCalledTimes(1);
expect(syncCallback).toHaveBeenCalledTimes(0);
validateCallback.mockClear();
moduleCallback.mockClear();
syncCallback.mockClear();
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
await licenseManager.revalidateLicense();
expect(validateCallback).toHaveBeenCalledTimes(1);
expect(moduleCallback).toHaveBeenCalledTimes(1);
expect(syncCallback).toHaveBeenCalledTimes(1);
});
it('should trigger the sync event even if the module callback throws an error', async () => {
const licenseManager = await getReadyLicenseManager();
const validateCallback = jest.fn();
const moduleCallback = jest.fn(() => {
throw new Error('Error');
});
const syncCallback = jest.fn();
licenseManager.on('validate', validateCallback);
licenseManager.on('sync', syncCallback);
licenseManager.on('module', moduleCallback);
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing']).withLimits('activeUsers', [
{
max: 10,
behavior: 'disable_modules',
modules: ['auditing'],
},
]);
await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true);
expect(validateCallback).toHaveBeenCalledTimes(1);
expect(moduleCallback).toHaveBeenCalledTimes(1);
expect(syncCallback).toHaveBeenCalledTimes(0);
validateCallback.mockClear();
moduleCallback.mockClear();
syncCallback.mockClear();
licenseManager.setLicenseLimitCounter('activeUsers', () => 11);
await licenseManager.revalidateLicense();
expect(validateCallback).toHaveBeenCalledTimes(1);
expect(moduleCallback).toHaveBeenCalledTimes(1);
expect(syncCallback).toHaveBeenCalledTimes(1);
});
});
describe('License.removeLicense', () => {
it('should trigger the removed event', async () => {
const licenseManager = await getReadyLicenseManager();
const removeLicense = jest.fn();
const moduleCallback = jest.fn();
licenseManager.on('removed', removeLicense);
licenseManager.onModule(moduleCallback);
const license = await new MockedLicenseBuilder().withGrantedModules(['auditing', 'chat.rocket.test-addon']).withLimits('activeUsers', [
{
max: 10,
behavior: 'disable_modules',
modules: ['auditing'],
},
]);
await expect(licenseManager.setLicense(await license.sign(), true)).resolves.toBe(true);
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();
licenseManager.remove();
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,
});
expect(licenseManager.hasValidLicense()).toBe(false);
});
});
describe('Offline license', () => {
it('should not report an offline license when no license is applied', async () => {
const licenseManager = await getReadyLicenseManager();
expect(licenseManager.hasOfflineLicense()).toBe(false);
});
it('should not report an offline license for a standard license', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder();
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
expect(licenseManager.hasOfflineLicense()).toBe(false);
});
it('should report an offline license when the license has the offline flag', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withOffline();
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
expect(licenseManager.hasOfflineLicense()).toBe(true);
});
it('should stop reporting an offline license once the license is removed', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withOffline();
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
expect(licenseManager.hasOfflineLicense()).toBe(true);
licenseManager.remove();
expect(licenseManager.hasOfflineLicense()).toBe(false);
});
});

View File

@@ -0,0 +1,643 @@
import crypto from 'node:crypto';
import type {
ILicenseTag,
LicenseEvents,
ILicenseV2,
ILicenseV3,
LicenseLimitKind,
BehaviorWithContext,
LicenseBehavior,
LicenseInfo,
LicenseValidationOptions,
LimitContext,
LicenseModule,
} from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { getLicenseLimit } from './deprecated';
import type { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated';
import { DuplicatedLicenseError } from './errors/DuplicatedLicenseError';
import { InvalidLicenseError } from './errors/InvalidLicenseError';
import { NotReadyForValidation } from './errors/NotReadyForValidation';
import type { onLicense } from './events/deprecated';
import { behaviorTriggered, behaviorTriggeredToggled, licenseInvalidated, licenseValidated } from './events/emitter';
import type {
onBehaviorTriggered,
onInvalidFeature,
onInvalidateLicense,
onLimitReached,
onModule,
onToggledFeature,
onValidFeature,
onValidateLicense,
} from './events/listeners';
import type { overwriteClassOnLicense } from './events/overwriteClassOnLicense';
import { logger } from './logger';
import type { getModuleDefinition, hasModule } from './modules';
import { getExternalModules, getModules, invalidateAll, replaceModules } from './modules';
import { applyPendingLicense, clearPendingLicense, hasPendingLicense, isPendingLicense, setPendingLicense } from './pendingLicense';
import type { getTags } from './tags';
import { replaceTags } from './tags';
import { decrypt } from './token';
import { convertToV3 } from './v2/convertToV3';
import { filterBehaviorsResult } from './validation/filterBehaviorsResult';
import type { setLicenseLimitCounter } from './validation/getCurrentValueForLicenseLimit';
import { getCurrentValueForLicenseLimit } from './validation/getCurrentValueForLicenseLimit';
import { getModulesToDisable } from './validation/getModulesToDisable';
import { isBehaviorsInResult } from './validation/isBehaviorsInResult';
import { isReadyForValidation } from './validation/isReadyForValidation';
import { runValidation } from './validation/runValidation';
import { validateDefaultLimits } from './validation/validateDefaultLimits';
import { validateFormat } from './validation/validateFormat';
import { validateLicenseLimits } from './validation/validateLicenseLimits';
const globalLimitKinds: LicenseLimitKind[] = ['activeUsers', 'guestUsers', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts'];
// The behaviors that decide whether (and how) a license is installed. Shared between the actual
// validation performed on apply and the dry-run preview so both stay in sync if a behavior is added.
const licenseValidationBehaviors: LicenseBehavior[] = [
'invalidate_license',
'start_fair_policy',
'prevent_installation',
'disable_modules',
];
export abstract class LicenseManager extends Emitter<LicenseEvents> {
abstract validateFormat: typeof validateFormat;
abstract hasModule: typeof hasModule;
abstract getModules: typeof getModules;
abstract getModuleDefinition: typeof getModuleDefinition;
abstract getExternalModules: typeof getExternalModules;
abstract getTags: typeof getTags;
abstract overwriteClassOnLicense: typeof overwriteClassOnLicense;
abstract setLicenseLimitCounter: typeof setLicenseLimitCounter;
abstract getCurrentValueForLicenseLimit: typeof getCurrentValueForLicenseLimit;
abstract isLimitReached<T extends LicenseLimitKind>(action: T, context?: Partial<LimitContext<T>>): Promise<boolean>;
abstract onValidFeature: typeof onValidFeature;
abstract onInvalidFeature: typeof onInvalidFeature;
abstract onToggledFeature: typeof onToggledFeature;
abstract onModule: typeof onModule;
abstract onValidateLicense: typeof onValidateLicense;
abstract onInvalidateLicense: typeof onInvalidateLicense;
abstract onLimitReached: typeof onLimitReached;
abstract onBehaviorTriggered: typeof onBehaviorTriggered;
// Deprecated:
abstract onLicense: typeof onLicense;
// Deprecated:
abstract getMaxActiveUsers: typeof getMaxActiveUsers;
// Deprecated:
abstract getAppsConfig: typeof getAppsConfig;
// Deprecated:
abstract getUnmodifiedLicenseAndModules: typeof getUnmodifiedLicenseAndModules;
dataCounters = new Map<LicenseLimitKind, (context?: LimitContext<LicenseLimitKind>) => Promise<number>>();
pendingLicense = '';
tags = new Set<ILicenseTag>();
modules = new Set<LicenseModule>();
private workspaceUrl: string | undefined;
protected _license: ILicenseV3 | undefined;
private _unmodifiedLicense: ILicenseV2 | ILicenseV3 | undefined;
private _valid: boolean | undefined;
protected _lockedLicense: string | undefined;
private states = new Map<LicenseBehavior, Map<LicenseLimitKind, boolean>>();
public get shouldPreventActionResults() {
const state = this.states.get('prevent_action') ?? new Map<LicenseLimitKind, boolean>();
this.states.set('prevent_action', state);
return state;
}
public get license(): ILicenseV3 | undefined {
return this._license;
}
public get unmodifiedLicense(): ILicenseV2 | ILicenseV3 | undefined {
return this._unmodifiedLicense;
}
public get valid(): boolean | undefined {
return this._valid;
}
public get encryptedLicense(): string | undefined {
if (!this.hasValidLicense()) {
return undefined;
}
return this._lockedLicense;
}
public async setWorkspaceUrl(url: string) {
this.workspaceUrl = url.replace(/\/$/, '').replace(/^https?:\/\/(.*)$/, '$1');
if (hasPendingLicense.call(this)) {
await applyPendingLicense.call(this);
}
}
public getWorkspaceUrl() {
return this.workspaceUrl;
}
public hashWorkspaceUrl(url: string) {
return crypto.createHash('sha256').update(url).digest('hex');
}
public getHashedWorkspaceUrl() {
const workspaceUrl = this.getWorkspaceUrl();
if (!workspaceUrl) {
return undefined;
}
return this.hashWorkspaceUrl(workspaceUrl);
}
public async revalidateLicense(options: Omit<LicenseValidationOptions, 'isNewLicense'> = {}): Promise<void> {
if (!this.hasValidLicense()) {
return;
}
try {
await this.validateLicense({ ...options, isNewLicense: false, triggerSync: true });
} catch (e) {
if (e instanceof InvalidLicenseError) {
this.invalidateLicense();
this.emit('sync');
}
}
}
/**
* The sync method should be called when a license from a different instance is has changed, so the local instance
* needs to be updated. This method will validate the license and update the local instance if the license is valid, but will not trigger the onSync event.
*/
public async sync(options: Omit<LicenseValidationOptions, 'isNewLicense'> = {}): Promise<void> {
if (!this.hasValidLicense()) {
return;
}
try {
await this.validateLicense({ ...options, isNewLicense: false, triggerSync: false });
} catch (e) {
if (e instanceof InvalidLicenseError) {
this.invalidateLicense();
}
}
}
private clearLicenseData(): void {
this._license = undefined;
this._unmodifiedLicense = undefined;
this._valid = false;
this._lockedLicense = undefined;
this.states.clear();
clearPendingLicense.call(this);
}
private invalidateLicense(): void {
this._valid = false;
this.states.clear();
invalidateAll.call(this);
licenseInvalidated.call(this);
}
public remove(): void {
if (!this._license) {
return;
}
this.clearLicenseData();
invalidateAll.call(this);
this.emit('removed');
}
private async setLicenseV3(
newLicense: ILicenseV3,
encryptedLicense: string,
originalLicense?: ILicenseV2 | ILicenseV3,
isNewLicense?: boolean,
): Promise<void> {
const hadValidLicense = this.hasValidLicense();
this.clearLicenseData();
try {
this._unmodifiedLicense = originalLicense || newLicense;
this._license = newLicense;
this._lockedLicense = encryptedLicense;
await this.validateLicense({ isNewLicense });
} catch (e) {
if (e instanceof InvalidLicenseError) {
if (hadValidLicense) {
this.invalidateLicense();
}
}
}
}
private async setLicenseV2(newLicense: ILicenseV2, encryptedLicense: string, isNewLicense?: boolean): Promise<void> {
return this.setLicenseV3(convertToV3(newLicense), encryptedLicense, newLicense, isNewLicense);
}
private isLicenseDuplicated(encryptedLicense: string): boolean {
return Boolean(this._lockedLicense && this._lockedLicense === encryptedLicense);
}
private async validateLicense(
options: LicenseValidationOptions = {
triggerSync: true,
},
): Promise<void> {
if (!this._license) {
throw new InvalidLicenseError();
}
if (!isReadyForValidation.call(this)) {
throw new NotReadyForValidation();
}
const validationResult = await runValidation.call(this, this._license, {
behaviors: licenseValidationBehaviors,
...options,
});
if (isBehaviorsInResult(validationResult, ['invalidate_license', 'prevent_installation'])) {
throw new InvalidLicenseError();
}
const shouldLogModules = !this._valid || options.isNewLicense;
this._valid = true;
if (this._license.information.tags) {
replaceTags.call(this, this._license.information.tags);
}
const disabledModules = getModulesToDisable(validationResult);
const modulesToEnable = this._license.grantedModules.filter(({ module }) => !disabledModules.includes(module));
const modulesChanged = replaceModules.call(
this,
modulesToEnable.map(({ module }) => module),
);
if (shouldLogModules || modulesChanged) {
logger.log({ msg: 'License validated', modules: modulesToEnable });
}
if (!options.isNewLicense) {
this.triggerBehaviorEvents(validationResult);
}
licenseValidated.call(this);
// If something changed in the license and the sync option is enabled, trigger a sync
if (
((!options.isNewLicense &&
filterBehaviorsResult(validationResult, ['invalidate_license', 'start_fair_policy', 'prevent_installation'])) ||
modulesChanged) &&
options.triggerSync
) {
this.emit('sync');
}
}
/**
* Validates a license against the current workspace state without applying it.
*
* Runs the same validation pipeline used when a license is set (URL, periods and limits),
* but does not store the license, change validity, replace modules/tags nor emit events.
* It is meant to power a preview of whether a license would be accepted before committing
* to it from the admin UI.
*
* Returns whether the license would be accepted and, when not, the behaviors that reject it.
* A malformed string is reported as invalid with no reasons rather than thrown, so callers can
* treat every rejected license uniformly. Mirrors the apply path in throwing
* `NotReadyForValidation` while the workspace can't validate yet.
*/
public async validateLicenseForPreview(
encryptedLicense: string,
): Promise<{ valid: true } | { valid: false; reasons: BehaviorWithContext[] }> {
if (!isReadyForValidation.call(this)) {
throw new NotReadyForValidation();
}
let license: ILicenseV3;
try {
await validateFormat(encryptedLicense);
const decrypted = JSON.parse(await decrypt(encryptedLicense));
license = encryptedLicense.startsWith('RCV3_') ? decrypted : convertToV3(decrypted);
} catch (err) {
logger.error({ msg: 'Invalid raw license provided for validation preview', err });
return { valid: false, reasons: [] };
}
const validationResult = await runValidation.call(this, license, {
behaviors: licenseValidationBehaviors,
isNewLicense: true,
suppressLog: true,
});
const reasons = filterBehaviorsResult(validationResult, ['invalidate_license', 'prevent_installation']);
return reasons.length ? { valid: false, reasons } : { valid: true };
}
public async setLicense(encryptedLicense: string, isNewLicense = true): Promise<boolean> {
if (!(await validateFormat(encryptedLicense))) {
throw new InvalidLicenseError();
}
if (this.isLicenseDuplicated(encryptedLicense)) {
// If there is a pending license but the user is trying to revert to the license that is currently active
if (hasPendingLicense.call(this) && !isPendingLicense.call(this, encryptedLicense)) {
// simply remove the pending license
clearPendingLicense.call(this);
throw new Error('Invalid license');
}
/**
* The license can be set with future minimum date, failing during the first set,
* but if the user tries to set the same license again later it can be valid or not, so we need to check it again
*/
if (this.hasValidLicense()) {
throw new DuplicatedLicenseError();
}
}
if (!isReadyForValidation.call(this)) {
// If we can't validate the license data yet, but is a valid license string, store it to validate when we can
setPendingLicense.call(this, encryptedLicense);
throw new NotReadyForValidation();
}
logger.info('New Enterprise License');
try {
const decrypted = JSON.parse(await decrypt(encryptedLicense));
logger.debug({ msg: 'license', decrypted });
if (!encryptedLicense.startsWith('RCV3_')) {
await this.setLicenseV2(decrypted, encryptedLicense, isNewLicense);
return true;
}
await this.setLicenseV3(decrypted, encryptedLicense, decrypted, isNewLicense);
this.emit('installed');
return true;
} catch (err) {
logger.error({ msg: 'Invalid raw license', encryptedLicense, err });
throw new InvalidLicenseError();
}
}
private triggerBehaviorEvents(validationResult: BehaviorWithContext[]): void {
for (const { ...options } of validationResult) {
behaviorTriggered.call(this, { ...options });
}
}
private triggerBehaviorEventsToggled(validationResult: BehaviorWithContext[]): void {
for (const { ...options } of validationResult) {
behaviorTriggeredToggled.call(this, { ...options });
}
}
public hasValidLicense(): boolean {
return Boolean(this.getLicense());
}
public getLicense(): ILicenseV3 | undefined {
if (this._valid && this._license) {
return this._license;
}
return undefined;
}
public hasOfflineLicense(): boolean {
return this.getLicense()?.information.offline ?? false;
}
public syncShouldPreventActionResults(actions: Record<LicenseLimitKind, boolean>): void {
for (const [action, shouldPreventAction] of Object.entries(actions)) {
this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction);
}
}
public async shouldPreventActionResultsMap(): Promise<{
[key in LicenseLimitKind]: boolean;
}> {
const keys: LicenseLimitKind[] = [
'activeUsers',
'guestUsers',
'roomsPerGuest',
'privateApps',
'marketplaceApps',
'monthlyActiveContacts',
];
const license = this.getLicense();
const items = await Promise.all(
keys.map(async (limit) => {
const cached = this.shouldPreventActionResults.get(limit);
if (cached !== undefined) {
return [limit, cached];
}
const fresh = license
? isBehaviorsInResult(
await validateLicenseLimits.call(this, license, {
behaviors: ['prevent_action'],
limits: [limit],
}),
['prevent_action'],
)
: isBehaviorsInResult(await validateDefaultLimits.call(this, { behaviors: ['prevent_action'], limits: [limit] }), [
'prevent_action',
]);
this.shouldPreventActionResults.set(limit, fresh);
return [limit, fresh];
}),
);
return Object.fromEntries(items);
}
public async shouldPreventAction<T extends LicenseLimitKind>(
action: T,
extraCount = 0,
context: Partial<LimitContext<T>> = {},
{ suppressLog }: Pick<LicenseValidationOptions, 'suppressLog'> = {
suppressLog: process.env.LICENSE_VALIDATION_SUPPRESS_LOG !== 'false',
},
): Promise<boolean> {
const options: LicenseValidationOptions = {
...(extraCount && { behaviors: ['prevent_action'] }),
isNewLicense: false,
suppressLog: !!suppressLog,
limits: [action],
context: {
[action]: {
extraCount,
...context,
},
},
};
const license = this.getLicense();
if (!license) {
return isBehaviorsInResult(await validateDefaultLimits.call(this, options), ['prevent_action']);
}
const validationResult = await runValidation.call(this, license, options);
const shouldPreventAction = isBehaviorsInResult(validationResult, ['prevent_action']);
// extra values should not call events since they are not actually reaching the limit just checking if they would
if (extraCount) {
return shouldPreventAction;
}
// check if any of the behaviors that should trigger a sync changed
if (
(['invalidate_license', 'disable_modules', 'start_fair_policy'] as const).some((behavior) => {
const hasChanged = this.consolidateBehaviorState(action, behavior, isBehaviorsInResult(validationResult, [behavior]));
if (hasChanged && behavior === 'start_fair_policy') {
this.triggerBehaviorEventsToggled([
{
behavior: 'start_fair_policy',
reason: 'limit',
limit: action,
},
]);
}
return hasChanged;
})
) {
await this.revalidateLicense();
}
const eventsToEmit = shouldPreventAction
? filterBehaviorsResult(validationResult, ['prevent_action'])
: [
{
behavior: 'allow_action',
modules: [],
reason: 'limit',
limit: action,
} as BehaviorWithContext,
];
if (this.consolidateBehaviorState(action, 'prevent_action', shouldPreventAction)) {
this.triggerBehaviorEventsToggled(eventsToEmit);
}
this.triggerBehaviorEvents(eventsToEmit);
return shouldPreventAction;
}
private consolidateBehaviorState<T extends LicenseLimitKind>(action: T, behavior: LicenseBehavior, triggered: boolean): boolean {
// check if the behavior changed
const state = this.states.get(behavior) ?? new Map<LicenseLimitKind, boolean>();
const currentState = state.get(action) ?? false;
if (currentState === triggered) {
return false;
}
// if it changed, update the state
state.set(action, triggered);
this.states.set(behavior, state);
return true;
}
public async getInfo({
limits: includeLimits,
currentValues: loadCurrentValues,
license: includeLicense,
}: {
limits: boolean;
currentValues: boolean;
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
const limits = Object.fromEntries(
(includeLimits &&
(await Promise.all(
globalLimitKinds
.map((limitKey) => [limitKey, getLicenseLimit(license, limitKey)] as const)
.map(async ([limitKey, max]) => {
return [
limitKey,
{
...(loadCurrentValues && { value: await getCurrentValueForLicenseLimit.call(this, limitKey) }),
max,
},
];
}),
))) ||
[],
);
return {
license: (includeLicense && license) || undefined,
activeModules,
externalModules,
preventedActions: await this.shouldPreventActionResultsMap(),
limits: limits as Record<LicenseLimitKind, { max: number; value: number }>,
tags: license?.information.tags || [],
trial: Boolean(license?.information.trial),
hasValidLicense: this.hasValidLicense(),
};
}
}

View File

@@ -0,0 +1,118 @@
import type { LicenseLimitKind, LimitContext } from '@rocket.chat/core-typings';
import { getAppsConfig, getMaxActiveUsers, getUnmodifiedLicenseAndModules } from './deprecated';
import { onLicense } from './events/deprecated';
import {
onBehaviorToggled,
onBehaviorTriggered,
onInvalidFeature,
onInvalidateLicense,
onLimitReached,
onModule,
onChange,
onToggledFeature,
onValidFeature,
onValidateLicense,
onInstall,
onInvalidate,
onRemoveLicense,
} from './events/listeners';
import { overwriteClassOnLicense } from './events/overwriteClassOnLicense';
import { LicenseManager } from './license';
import { logger } from './logger';
import { getExternalModules, getModuleDefinition, getModules, hasModule } from './modules';
import { showLicense } from './showLicense';
import { getTags } from './tags';
import { getCurrentValueForLicenseLimit, setLicenseLimitCounter } from './validation/getCurrentValueForLicenseLimit';
import { validateFormat } from './validation/validateFormat';
export class LicenseImp extends LicenseManager {
constructor() {
super();
this.onValidateLicense(() => showLicense.call(this, this.getLicense(), this.hasValidLicense()));
this.onValidateLicense(() => {
logger.startup({
msg: 'License installed',
version: this.getLicense()?.version,
hash: this._lockedLicense?.slice(-8),
});
});
this.onRemoveLicense(() => {
logger.startup({
msg: 'License removed',
});
});
this.onInvalidateLicense(() => {
logger.startup({
msg: 'License invalidated',
});
});
}
validateFormat = validateFormat;
hasModule = hasModule;
getModules = getModules;
getModuleDefinition = getModuleDefinition;
getExternalModules = getExternalModules;
getTags = getTags;
overwriteClassOnLicense = overwriteClassOnLicense;
public setLicenseLimitCounter = setLicenseLimitCounter;
getCurrentValueForLicenseLimit = getCurrentValueForLicenseLimit;
public async isLimitReached<T extends LicenseLimitKind>(action: T, context?: Partial<LimitContext<T>>): Promise<boolean> {
return this.shouldPreventAction(action, 0, context);
}
onChange = onChange;
onInstall = onInstall;
onRemoveLicense = onRemoveLicense;
onInvalidate = onInvalidate;
onValidFeature = onValidFeature;
onInvalidFeature = onInvalidFeature;
onToggledFeature = onToggledFeature;
onModule = onModule;
onValidateLicense = onValidateLicense;
onInvalidateLicense = onInvalidateLicense;
onLimitReached = onLimitReached;
onBehaviorTriggered = onBehaviorTriggered;
onBehaviorToggled = onBehaviorToggled;
// Deprecated:
onLicense = onLicense;
// Deprecated:
getMaxActiveUsers = getMaxActiveUsers;
// Deprecated:
getAppsConfig = getAppsConfig;
// Deprecated:
getUnmodifiedLicenseAndModules = getUnmodifiedLicenseAndModules;
}
const license = new LicenseImp();
export { license as License };

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('License');

View File

@@ -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().withGrantedModules(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().withGrantedModules(['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().withGrantedModules(['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().withGrantedModules(['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().withGrantedModules(['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().withGrantedModules(['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);
});
});

View File

@@ -0,0 +1,83 @@
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);
moduleValidated.call(this, module);
});
}
export function notifyInvalidatedModules(this: LicenseManager, licenseModules: LicenseModule[]) {
licenseModules.forEach((module) => {
moduleRemoved.call(this, module);
this.modules.delete(module);
});
}
export function invalidateAll(this: LicenseManager) {
notifyInvalidatedModules.call(this, [...this.modules]);
this.modules.clear();
}
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);
}
export function replaceModules(this: LicenseManager, newModules: LicenseModule[]): boolean {
let anyChange = false;
for (const moduleName of newModules) {
if (this.modules.has(moduleName)) {
continue;
}
this.modules.add(moduleName);
moduleValidated.call(this, moduleName);
anyChange = true;
}
for (const moduleName of this.modules) {
if (newModules.includes(moduleName)) {
continue;
}
moduleRemoved.call(this, moduleName);
this.modules.delete(moduleName);
anyChange = true;
}
return anyChange;
}

View File

@@ -0,0 +1,32 @@
import type { LicenseManager } from './license';
import { logger } from './logger';
export function setPendingLicense(this: LicenseManager, encryptedLicense: string) {
this.pendingLicense = encryptedLicense;
if (this.pendingLicense) {
logger.info('Storing license as pending validation.');
}
}
export async function applyPendingLicense(this: LicenseManager) {
if (this.pendingLicense) {
logger.info('Applying pending license.');
await this.setLicense(this.pendingLicense);
}
}
export function hasPendingLicense(this: LicenseManager) {
return Boolean(this.pendingLicense);
}
export function isPendingLicense(this: LicenseManager, encryptedLicense: string) {
return !!this.pendingLicense && this.pendingLicense === encryptedLicense;
}
export function clearPendingLicense(this: LicenseManager) {
if (this.pendingLicense) {
logger.info('Removing pending license.');
}
this.pendingLicense = '';
}

View File

@@ -0,0 +1,28 @@
import type { ILicenseV3 } from '@rocket.chat/core-typings';
import type { LicenseManager } from './license';
import { getModules } from './modules';
export function showLicense(this: LicenseManager, license: ILicenseV3 | undefined, valid: boolean | undefined) {
if (!process.env.LICENSE_DEBUG || process.env.LICENSE_DEBUG === 'false') {
return;
}
if (!license || !valid) {
return;
}
const {
validation: { serverUrls, validPeriods },
limits,
} = license;
const modules = getModules.call(this);
console.log('---- License enabled ----');
console.log(' url ->', JSON.stringify(serverUrls));
console.log(' periods ->', JSON.stringify(validPeriods));
console.log(' limits ->', JSON.stringify(limits));
console.log(' modules ->', modules.join(', '));
console.log('-------------------------');
}

View File

@@ -0,0 +1,25 @@
import type { ILicenseTag } from '@rocket.chat/core-typings';
import type { LicenseManager } from './license';
export function addTag(this: LicenseManager, tag: ILicenseTag) {
// make sure to not add duplicated tag names
for (const addedTag of this.tags) {
if (addedTag.name.toLowerCase() === tag.name.toLowerCase()) {
return;
}
}
this.tags.add(tag);
}
export function replaceTags(this: LicenseManager, newTags: ILicenseTag[]) {
this.tags.clear();
for (const tag of newTags) {
addTag.call(this, tag);
}
}
export function getTags(this: LicenseManager) {
return [...this.tags];
}

View File

@@ -0,0 +1,89 @@
import crypto from 'node:crypto';
import type { ILicenseV3 } from '@rocket.chat/core-typings';
import { verify, sign, getPairs } from '@rocket.chat/jwt';
const base64Decode = (key: string): string => Buffer.from(key, 'base64').toString('utf-8');
const PUBLIC_LICENSE_KEY_V2 =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUFxV1Nza2Q5LzZ6Ung4a3lQY2ljcwpiMzJ3Mnd4VnV3N3lCVDk2clEvOEQreU1lQ01POXdTU3BIYS85bkZ5d293RXRpZ3B0L3dyb1BOK1ZHU3didHdQCkZYQmVxRWxCbmRHRkFsODZlNStFbGlIOEt6L2hHbkNtSk5tWHB4RUsyUkUwM1g0SXhzWVg3RERCN010eC9pcXMKY2pCL091dlNCa2ppU2xlUzdibE5JVC9kQTdLNC9DSjNvaXUwMmJMNEV4Y2xDSGVwenFOTWVQM3dVWmdweE9uZgpOT3VkOElYWUs3M3pTY3VFOEUxNTdZd3B6Q0twVmFIWDdaSmY4UXVOc09PNVcvYUlqS2wzTDYyNjkrZUlPRXJHCndPTm1hSG56Zmc5RkxwSmh6Z3BPMzhhVm43NnZENUtLakJhaldza1krNGEyZ1NRbUtOZUZxYXFPb3p5RUZNMGUKY0ZXWlZWWjNMZWg0dkVNb1lWUHlJeng5Nng4ZjIveW1QbmhJdXZRdjV3TjRmeWVwYTdFWTVVQ2NwNzF6OGtmUAo0RmNVelBBMElEV3lNaWhYUi9HNlhnUVFaNEdiL3FCQmh2cnZpSkNGemZZRGNKZ0w3RmVnRllIUDNQR0wwN1FnCnZMZXZNSytpUVpQcnhyYnh5U3FkUE9rZ3VyS2pWclhUVXI0QTlUZ2lMeUlYNVVsSnEzRS9SVjdtZk9xWm5MVGEKU0NWWEhCaHVQbG5DR1pSMDFUb1RDZktoTUcxdTBDRm5MMisxNWhDOWZxT21XdjlRa2U0M3FsSjBQZ0YzVkovWAp1eC9tVHBuazlnbmJHOUpIK21mSDM5Um9GdlROaW5Zd1NNdll6dXRWT242OXNPemR3aERsYTkwbDNBQ2g0eENWCks3Sk9YK3VIa29OdTNnMmlWeGlaVU0wQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=';
const PUBLIC_STATS_KEY_V2 =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuT0IvR3lCUXg1cVJZOC83dGxhTApkd0hSOWZBWHVzQ3ZBVU9YRjFPYjExaWx4ejdqY0pqWitaRjE2UTk3bjF3UDlvQnJMUDg5M3ZXUlc1bUtDdFpDClJQNTdJU1o2YjlHOXoyNStnV0NEa3ZZUUg1djJlMGoxUnE5TnNYMktlTWViOUZXenRQVFEvdDRvUW1SdUpiTEIKV013ajRRbHZ4OStpdWpkdGdQYmx5S0VFM0I3T1RPWk8xNzNOK2RDZW8wOWlQcStyKzBiRjNGSTRVcVFiZkFrdApOdGd0OUxVbjdlalNaQXNhSTZPbVBmOVVvSzM4NUdxWUxvbk1WbXBFbmZyRDlHSHl0OXFOY2pCTmZ1MUJLZWlUClBwVit6WE1FViszLzZ2bi84OW1id28zS0ZtTnRxVTJpRk5Yak5Cb2w1ZE82N3VlM0pURXBVQmJWRHZ2V3gwTk8KWndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==';
const PUBLIC_LICENSE_KEY_V3 = base64Decode(PUBLIC_LICENSE_KEY_V2);
let TEST_KEYS: [string, string] | undefined = undefined;
export async function decryptStatsToken(encrypted: string): Promise<string> {
if (process.env.NODE_ENV?.toLowerCase() === 'test') {
TEST_KEYS = TEST_KEYS ?? (await getPairs());
if (!TEST_KEYS) {
throw new Error('Missing PUBLIC_STATS_KEY_V3');
}
const [spki] = TEST_KEYS;
const [payload] = await verify(encrypted, spki);
return JSON.stringify(payload);
}
const [payload] = await verify(encrypted, base64Decode(PUBLIC_STATS_KEY_V2));
return JSON.stringify(payload);
}
export async function decrypt(encrypted: string): Promise<string> {
if (process.env.NODE_ENV === 'test') {
if (encrypted.startsWith('RCV3_')) {
const jwt = encrypted.substring(5);
TEST_KEYS = TEST_KEYS ?? (await getPairs());
if (!TEST_KEYS) {
throw new Error('Missing PUBLIC_LICENSE_KEY_V3');
}
const [spki] = TEST_KEYS;
const [payload] = await verify(jwt, spki);
return JSON.stringify(payload);
}
}
// handle V3
if (encrypted.startsWith('RCV3_')) {
const jwt = encrypted.substring(5);
const [payload] = await verify(jwt, PUBLIC_LICENSE_KEY_V3);
return JSON.stringify(payload);
}
const decrypted = crypto.publicDecrypt(Buffer.from(PUBLIC_LICENSE_KEY_V2, 'base64').toString('utf-8'), Buffer.from(encrypted, 'base64'));
return decrypted.toString('utf-8');
}
export async function encrypt(license: ILicenseV3): Promise<string> {
if (process.env.NODE_ENV !== 'test') {
throw new Error('This function should only be used in tests');
}
TEST_KEYS = TEST_KEYS ?? (await getPairs());
const [, pkcs8] = TEST_KEYS;
return `RCV3_${await sign(license, pkcs8)}`;
}
export async function encryptStatsToken(data: Record<string, any>): Promise<string> {
if (process.env.NODE_ENV !== 'test') {
throw new Error('This function should only be used in tests');
}
TEST_KEYS = TEST_KEYS ?? (await getPairs());
const [, pkcs8] = TEST_KEYS;
return sign(data, pkcs8);
}

View File

@@ -0,0 +1,45 @@
import { CoreModules, type LicenseModule } from '@rocket.chat/core-typings';
interface IBundle {
[key: string]: readonly LicenseModule[];
}
const bundles: IBundle = {
enterprise: CoreModules,
pro: [],
};
export const getBundleFromModule = (moduleName: string): string | undefined => {
const match = moduleName.match(/(.*):\*$/);
if (!match) {
return;
}
return match[1];
};
export function isBundle(moduleName: string): boolean {
if (moduleName === '*') {
return true;
}
const bundle = getBundleFromModule(moduleName);
if (!bundle) {
return false;
}
return true;
}
export function getBundleModules(moduleName: string): readonly string[] {
if (moduleName === '*') {
return Object.keys(bundles).reduce<string[]>((modules, bundle) => modules.concat(bundles[bundle]), []);
}
const bundle = getBundleFromModule(moduleName);
if (!bundle || !bundles[bundle]) {
return [];
}
return bundles[bundle];
}

View File

@@ -0,0 +1,112 @@
/**
* FromV2ToV3
* Transform a License V2 into a V3 representation.
*/
import type { ILicenseV2, ILicenseV3, InternalModuleName } from '@rocket.chat/core-typings';
import { isBundle, getBundleFromModule, getBundleModules } from './bundles';
import { getTagColor } from './getTagColor';
export const convertToV3 = (v2: ILicenseV2): ILicenseV3 => {
return {
version: '3.0',
information: {
autoRenew: false,
...((v2.meta?.trialEnd || v2.expiry) && { visualExpiration: new Date(Date.parse(v2.meta?.trialEnd || v2.expiry)).toISOString() }),
trial: v2.meta?.trial || false,
offline: false,
createdAt: new Date().toISOString(),
grantedBy: {
method: 'manual',
seller: 'V2',
},
// if no tag present, it means it is an old license, so try check for bundles and use them as tags
tags: v2.tag
? [v2.tag]
: [
...(v2.modules.filter(isBundle).map(getBundleFromModule).filter(Boolean) as string[]).map((tag) => ({
name: tag,
color: getTagColor(tag),
})),
],
},
validation: {
serverUrls: [
{
value: v2.url,
type: 'regex',
},
],
validPeriods: [
{
validUntil: new Date(Date.parse(v2.expiry)).toISOString(),
invalidBehavior: 'invalidate_license',
},
],
statisticsReport: {
required: true,
},
},
grantedModules: [
...new Set(
['outbound-messaging', 'teams-voip', 'contact-id-verification', 'hide-watermark', ...v2.modules]
.flatMap((licenseModule) => (isBundle(licenseModule) ? getBundleModules(licenseModule) : [licenseModule]))
.map((licenseModule) => ({ module: licenseModule as InternalModuleName })),
),
],
limits: {
...(v2.maxActiveUsers
? {
activeUsers: [
{
max: v2.maxActiveUsers,
behavior: 'prevent_action',
},
],
}
: {}),
...(v2.maxGuestUsers
? {
guestUsers: [
{
max: v2.maxGuestUsers,
behavior: 'prevent_action',
},
],
}
: {}),
...(v2.maxRoomsPerGuest
? {
roomsPerGuest: [
{
max: v2.maxRoomsPerGuest,
behavior: 'prevent_action',
},
],
}
: {}),
...(v2.apps?.maxPrivateApps
? {
privateApps: [
{
max: v2.apps.maxPrivateApps,
behavior: 'prevent_action',
},
],
}
: {}),
...(v2.apps?.maxMarketplaceApps
? {
marketplaceApps: [
{
max: v2.apps.maxMarketplaceApps,
behavior: 'prevent_action',
},
],
}
: {}),
},
cloudMeta: v2.meta,
};
};

View File

@@ -0,0 +1,12 @@
export function getTagColor(tag: string): string {
switch (tag) {
case 'bronze':
return '#BD5A0B';
case 'silver':
return '#9EA2A8';
case 'gold':
return '#F3BE08';
default:
return '#4411DD';
}
}

View File

@@ -0,0 +1,92 @@
import { LicenseImp } from '.';
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';
import { NotReadyForValidation } from './errors/NotReadyForValidation';
describe('validateLicenseForPreview', () => {
it('should report a non-license string as invalid with no reasons', async () => {
const licenseManager = await getReadyLicenseManager();
const result = await licenseManager.validateLicenseForPreview('not-a-license');
expect(result).toEqual({ valid: false, reasons: [] });
});
it('should throw NotReadyForValidation when the workspace cannot validate yet', async () => {
const licenseManager = new LicenseImp();
const license = await new MockedLicenseBuilder().sign();
await expect(licenseManager.validateLicenseForPreview(license)).rejects.toThrow(NotReadyForValidation);
});
it('should report a valid license as valid and not apply it', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise', 'engagement-dashboard']).sign();
const result = await licenseManager.validateLicenseForPreview(license);
expect(result).toEqual({ valid: true });
// previewing must not apply the license
expect(licenseManager.hasValidLicense()).toBe(false);
expect(licenseManager.getModules()).toEqual([]);
});
it('should not apply a license even when it is already active and being previewed again', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise']).sign();
await licenseManager.setLicense(license);
expect(licenseManager.hasValidLicense()).toBe(true);
const result = await licenseManager.validateLicenseForPreview(license);
expect(result).toEqual({ valid: true });
});
it('should report a reason when the workspace URL does not match', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withServerUrls({ value: 'another-workspace.com', type: 'url' }).sign();
const result = await licenseManager.validateLicenseForPreview(license);
expect(result.valid).toBe(false);
expect(result.valid === false && result.reasons).toEqual(
expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'url' })]),
);
});
it('should report a reason when an invalidating period has expired', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().resetValidPeriods().withExpiredDate().sign();
const result = await licenseManager.validateLicenseForPreview(license);
expect(result.valid).toBe(false);
expect(result.valid === false && result.reasons).toEqual(
expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'period' })]),
);
});
it('should report valid when only a disable_modules period has expired', async () => {
const licenseManager = await getReadyLicenseManager();
// MockedLicenseBuilder seeds a `disable_modules` period for `livechat-enterprise`; expire it.
const builder = new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise', 'engagement-dashboard']);
builder.validation.validPeriods = [
{
invalidBehavior: 'disable_modules',
modules: ['livechat-enterprise'],
validUntil: new Date(new Date().setMinutes(new Date().getMinutes() - 1)).toISOString(),
},
];
const result = await licenseManager.validateLicenseForPreview(await builder.sign());
// disabling modules does not invalidate the license, so it would still be accepted
expect(result).toEqual({ valid: true });
});
});

View File

@@ -0,0 +1,4 @@
import type { BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
export const filterBehaviorsResult = (result: BehaviorWithContext[], expectedBehaviors: LicenseBehavior[]) =>
result.filter(({ behavior }) => expectedBehaviors.includes(behavior));

View File

@@ -0,0 +1,37 @@
import type { LicenseLimitKind, LimitContext } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { logger } from '../logger';
import { applyPendingLicense, hasPendingLicense } from '../pendingLicense';
export function setLicenseLimitCounter<T extends LicenseLimitKind>(
this: LicenseManager,
limitKey: T,
fn: (context?: LimitContext<T>) => Promise<number> | number,
) {
this.dataCounters.set(limitKey, fn as (context?: LimitContext<LicenseLimitKind>) => Promise<number>);
if (hasPendingLicense.call(this) && hasAllDataCounters.call(this)) {
void applyPendingLicense.call(this);
}
}
export async function getCurrentValueForLicenseLimit<T extends LicenseLimitKind>(
this: LicenseManager,
limitKey: T,
context?: Partial<LimitContext<T>>,
): Promise<number> {
const counterFn = this.dataCounters.get(limitKey);
if (!counterFn) {
logger.error({ msg: 'Unable to validate license limit due to missing data counter.', limitKey });
throw new Error(`Unable to validate license limit due to missing ${limitKey} data counter`);
}
return counterFn(context as LimitContext<LicenseLimitKind> | undefined);
}
export function hasAllDataCounters(this: LicenseManager) {
return (
['activeUsers', 'guestUsers', 'roomsPerGuest', 'privateApps', 'marketplaceApps', 'monthlyActiveContacts'] as LicenseLimitKind[]
).every((limitKey) => this.dataCounters.has(limitKey));
}

View File

@@ -0,0 +1,14 @@
import type { BehaviorWithContext, LicenseBehavior, LicenseModule } from '@rocket.chat/core-typings';
const filterValidationResult = (result: BehaviorWithContext[], expectedBehavior: LicenseBehavior) =>
result.filter(({ behavior }) => behavior === expectedBehavior) as BehaviorWithContext[];
export const getModulesToDisable = (validationResult: BehaviorWithContext[]): LicenseModule[] => {
return [
...new Set([
...filterValidationResult(validationResult, 'disable_modules')
.map(({ modules }) => modules || [])
.flat(),
]),
];
};

View File

@@ -0,0 +1,25 @@
import type { LicenseLimitKind, BehaviorWithContext, LicenseLimit, LicensePeriod } from '@rocket.chat/core-typings';
export const getResultingBehavior = (
data: LicenseLimit | LicensePeriod | Partial<Omit<BehaviorWithContext, 'reason'>>,
{ reason, limit }: { reason: BehaviorWithContext['reason']; limit?: LicenseLimitKind },
): BehaviorWithContext => {
const behavior = 'invalidBehavior' in data ? data.invalidBehavior : data.behavior;
switch (behavior) {
case 'disable_modules':
return {
behavior,
modules: ('modules' in data && data.modules) || [],
reason,
limit,
};
default:
return {
behavior,
reason,
limit,
} as BehaviorWithContext;
}
};

View File

@@ -0,0 +1,4 @@
import type { BehaviorWithContext, LicenseBehavior } from '@rocket.chat/core-typings';
export const isBehaviorsInResult = (result: BehaviorWithContext[], expectedBehaviors: LicenseBehavior[]) =>
result.some(({ behavior }) => expectedBehaviors.includes(behavior));

View File

@@ -0,0 +1,7 @@
import type { LicenseManager } from '../license';
import { hasAllDataCounters } from './getCurrentValueForLicenseLimit';
// Can only validate licenses once the workspace URL and the data counter functions are set
export function isReadyForValidation(this: LicenseManager) {
return Boolean(this.getWorkspaceUrl() && hasAllDataCounters.call(this));
}

View File

@@ -0,0 +1,11 @@
import type { LicenseBehavior } from '@rocket.chat/core-typings';
export const logKind = (behavior: LicenseBehavior) => {
switch (behavior) {
case 'prevent_installation':
case 'invalidate_license':
return 'error';
default:
return 'info';
}
};

View File

@@ -0,0 +1,38 @@
/**
* @jest-environment node
*/
import { runValidation } from './runValidation';
import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder';
describe('Validation behaviors', () => {
it('should return a behavior if the license period is invalid', async () => {
const licenseManager = await getReadyLicenseManager();
// two days ago
const validFrom = new Date(new Date().setDate(new Date().getDate() - 2));
// one day ago
const validUntil = new Date(new Date().setDate(new Date().getDate() - 1));
const license = await new MockedLicenseBuilder().resetValidPeriods().withValidPeriod({
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
invalidBehavior: 'disable_modules',
modules: ['livechat-enterprise'],
});
await expect(
runValidation.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).resolves.toStrictEqual([
{
behavior: 'disable_modules',
limit: undefined,
modules: ['livechat-enterprise'],
reason: 'period',
},
]);
});
});

View File

@@ -0,0 +1,18 @@
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { validateLicenseLimits } from './validateLicenseLimits';
import { validateLicensePeriods } from './validateLicensePeriods';
import { validateLicenseUrl } from './validateLicenseUrl';
export async function runValidation(
this: LicenseManager,
license: ILicenseV3,
options: LicenseValidationOptions,
): Promise<BehaviorWithContext[]> {
return [
...validateLicenseUrl.call(this, license, options),
...validateLicensePeriods(license, options),
...(await validateLicenseLimits.call(this, license, options)),
];
}

View File

@@ -0,0 +1,27 @@
import type { BehaviorWithContext, LicenseLimit, LicenseValidationOptions } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { validateLimits } from './validateLimits';
export const defaultLimits: {
privateApps: LicenseLimit[];
marketplaceApps: LicenseLimit[];
// monthlyActiveContacts?: LicenseLimit[];
} = {
privateApps: [
{
behavior: 'prevent_action',
max: 0,
},
],
marketplaceApps: [
{
behavior: 'prevent_action',
max: 5,
},
],
};
export async function validateDefaultLimits(this: LicenseManager, options: LicenseValidationOptions): Promise<BehaviorWithContext[]> {
return validateLimits.call(this, defaultLimits, options);
}

View File

@@ -0,0 +1,16 @@
import { InvalidLicenseError } from '../errors/InvalidLicenseError';
import { decrypt } from '../token';
export const validateFormat = async (encryptedLicense: string): Promise<boolean> => {
if (!encryptedLicense || String(encryptedLicense).trim() === '') {
throw new InvalidLicenseError('Empty license');
}
try {
await decrypt(encryptedLicense);
} catch (e) {
throw new InvalidLicenseError();
}
return true;
};

View File

@@ -0,0 +1,38 @@
import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder';
describe('validateLicenseLimits', () => {
describe('limit max: -1', () => {
it('should return prevent_action when the limit is reached', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: -1,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 99999999);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(false);
});
});
describe('limit max: 0', () => {
it('should return prevent_action when the limit is reached', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withLimits('activeUsers', [
{
max: 0,
behavior: 'prevent_action',
},
]);
await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true);
licenseManager.setLicenseLimitCounter('activeUsers', () => 99999999);
await expect(licenseManager.shouldPreventAction('activeUsers')).resolves.toBe(true);
});
});
});

View File

@@ -0,0 +1,14 @@
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
import type { LicenseManager } from '../license';
import { validateLimits } from './validateLimits';
export async function validateLicenseLimits(
this: LicenseManager,
license: ILicenseV3,
options: LicenseValidationOptions,
): Promise<BehaviorWithContext[]> {
const { limits } = license;
return validateLimits.call(this, limits, options);
}

View File

@@ -0,0 +1,40 @@
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
import { isBehaviorAllowed } from '../isItemAllowed';
import { logger } from '../logger';
import { getResultingBehavior } from './getResultingBehavior';
export const isPeriodInvalid = (from: string | undefined, until: string | undefined) => {
const now = new Date();
if (from && now < new Date(from)) {
return true;
}
if (until && now > new Date(until)) {
return true;
}
return false;
};
export const validateLicensePeriods = (license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] => {
const {
validation: { validPeriods },
} = license;
return validPeriods
.filter(
({ validFrom, validUntil, invalidBehavior }) => isBehaviorAllowed(invalidBehavior, options) && isPeriodInvalid(validFrom, validUntil),
)
.map((period) => {
if (!options.suppressLog) {
logger.error({
msg: 'Period validation failed',
period,
});
}
return getResultingBehavior(period, { reason: 'period' });
});
};

View File

@@ -0,0 +1,163 @@
/**
* @jest-environment node
*/
import crypto from 'node:crypto';
import { validateLicenseUrl } from './validateLicenseUrl';
import { MockedLicenseBuilder, getReadyLicenseManager } from '../../__tests__/MockedLicenseBuilder';
describe('Url Validation', () => {
describe('url method', () => {
it('should return a behavior if the license url is invalid', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withServerUrls({
value: 'localhost:3001',
type: 'url',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).toStrictEqual([
{
behavior: 'invalidate_license',
limit: undefined,
reason: 'url',
},
]);
});
it('should return an empty array if the license url is valid', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withServerUrls({
value: 'localhost:3000',
type: 'url',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).toStrictEqual([]);
});
});
describe('regex method', () => {
it('should return a behavior if the license does not match the regex', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withServerUrls({
value: 'unstable.rocket.*',
type: 'regex',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).toStrictEqual([
{
behavior: 'invalidate_license',
limit: undefined,
reason: 'url',
},
]);
});
it('should return an empty array if the license matches the regex', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withServerUrls({
value: 'localhost:300*',
type: 'regex',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).toStrictEqual([]);
});
});
describe('hash method', () => {
it('should return a behavior if the license does not match the hash', async () => {
const licenseManager = await getReadyLicenseManager();
const hash = crypto.createHash('sha256').update('localhost:3001').digest('hex');
const license = await new MockedLicenseBuilder().withServerUrls({
value: hash,
type: 'hash',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).toStrictEqual([
{
behavior: 'invalidate_license',
limit: undefined,
reason: 'url',
},
]);
});
it('should return an empty array if the license matches the hash', async () => {
const licenseManager = await getReadyLicenseManager();
const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex');
const license = await new MockedLicenseBuilder().withServerUrls({
value: hash,
type: 'hash',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: true,
}),
).toStrictEqual([]);
});
});
describe('type mismatch', () => {
it('should validate as hash if the type is url but the value looks like a hash', async () => {
const licenseManager = await getReadyLicenseManager();
const hash = crypto.createHash('sha256').update('localhost:3000').digest('hex');
const license = await new MockedLicenseBuilder().withServerUrls({
value: hash,
type: 'url',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: false,
}),
).toStrictEqual([]);
});
it('should validate as url if the type is hash but the value looks like a url', async () => {
const licenseManager = await getReadyLicenseManager();
const license = await new MockedLicenseBuilder().withServerUrls({
value: 'localhost:3000',
type: 'hash',
});
await expect(
validateLicenseUrl.call(licenseManager, await license.build(), {
behaviors: ['invalidate_license', 'prevent_installation', 'start_fair_policy', 'disable_modules'],
suppressLog: false,
}),
).toStrictEqual([]);
});
});
});

View File

@@ -0,0 +1,88 @@
import type { ILicenseV3, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
import { isBehaviorAllowed } from '../isItemAllowed';
import type { LicenseManager } from '../license';
import { logger } from '../logger';
import { getResultingBehavior } from './getResultingBehavior';
const validateRegex = (licenseURL: string, url: string) => {
licenseURL = licenseURL
.replace(/\./g, '\\.') // convert dots to literal
.replace(/\*/g, '.*'); // convert * to .*
const regex = new RegExp(`^${licenseURL}$`, 'i');
return !!regex.exec(url);
};
const validateUrl = (licenseURL: string, url: string) => {
return licenseURL.toLowerCase() === url.toLowerCase();
};
const validateHash = (licenseURL: string, hashedUrl: string) => {
return licenseURL === hashedUrl;
};
export function validateLicenseUrl(this: LicenseManager, license: ILicenseV3, options: LicenseValidationOptions): BehaviorWithContext[] {
if (!isBehaviorAllowed('invalidate_license', options)) {
return [];
}
const {
validation: { serverUrls },
} = license;
const workspaceUrl = this.getWorkspaceUrl();
if (!workspaceUrl) {
logger.error('Unable to validate license URL without knowing the workspace URL.');
return [getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' })];
}
const hashedWorkspaceUrl = this.hashWorkspaceUrl(workspaceUrl);
return serverUrls
.filter((url) => {
if (
url.type === 'url' &&
url.value.length === 64 &&
/^[a-f0-9]{64}$/i.test(url.value) &&
validateHash(url.value, hashedWorkspaceUrl)
) {
// If the url type is 'url' but the value looks like a hash, validate it as a hash to avoid invalidating licenses unnecessarily.
logger.warn(
`License URL with type 'url' is actually a hash. Validating as hash to avoid invalidating license unnecessarily. url: ${url.value}`,
);
return false;
}
if (url.type === 'hash' && !/^[a-f0-9]{64}$/i.test(url.value) && validateUrl(url.value, workspaceUrl)) {
// If the url type is 'hash' but the value looks like a url, validate it as a url to avoid invalidating licenses unnecessarily.
logger.warn(
`License URL with type 'hash' does not look like a hash. Validating as url to avoid invalidating license unnecessarily. url: ${url.value}`,
);
return false;
}
switch (url.type) {
case 'regex':
return !validateRegex(url.value, workspaceUrl);
case 'hash':
return !validateHash(url.value, hashedWorkspaceUrl);
case 'url':
return !validateUrl(url.value, workspaceUrl);
default:
return true; // If the type is unknown, consider it invalid to be safe.
}
})
.map((url) => {
if (!options.suppressLog) {
logger.error({
msg: 'Url validation failed',
licenseUrl: url,
workspaceUrl,
hashedWorkspaceUrl,
});
}
return getResultingBehavior({ behavior: 'invalidate_license' }, { reason: 'url' });
});
}

View File

@@ -0,0 +1,62 @@
import type { LicenseBehavior } from '@rocket.chat/core-typings';
/**
* The difference between validateLimit and validateWarnLimit is that the first one
* is used to trigger the client so for start_fair_policy we trigger as soon the limit is reached
* for the visual warning we trigger when the limit is reached + 1
/**
* Validates if the current value is over the limit
* @param max The maximum value allowed
* @param currentValue The current value
* @param behavior The behavior to be applied if the limit is reached
* @param extraCount The extra count to be added to the current value
* @returns
* - true if the limit is reached
* - false if the limit is not reached
*/
export function validateLimit(max: number, currentValue: number, behavior: LicenseBehavior, extraCount = 0) {
switch (behavior) {
case 'invalidate_license':
case 'prevent_installation':
case 'disable_modules':
default:
return currentValue > max;
case 'start_fair_policy':
return currentValue >= max;
case 'prevent_action':
/**
* if we are validating the current count the limit should be equal or over the max, if we are validating the future count the limit should be over the max
*/
return extraCount ? currentValue > max : currentValue >= max;
}
}
/**
* Validates if the current value is over the limit
* @param max The maximum value allowed
* @param currentValue The current value
* @param behavior The behavior to be applied if the limit is reached
* @param extraCount The extra count to be added to the current value
* @returns
* - true if the limit is reached
* - false if the limit is not reached
*/
export function validateWarnLimit(max: number, currentValue: number, behavior: LicenseBehavior, extraCount = 0) {
switch (behavior) {
case 'invalidate_license':
case 'prevent_installation':
case 'disable_modules':
case 'start_fair_policy':
default:
return currentValue > max;
case 'prevent_action':
/**
* if we are validating the current count the limit should be equal or over the max, if we are validating the future count the limit should be over the max
*/
return extraCount ? currentValue > max : currentValue >= max;
}
}

View File

@@ -0,0 +1,55 @@
import type { ILicenseV3, LicenseLimitKind, BehaviorWithContext, LicenseValidationOptions } from '@rocket.chat/core-typings';
import { isLimitAllowed, isBehaviorAllowed } from '../isItemAllowed';
import type { LicenseManager } from '../license';
import { logger } from '../logger';
import { getCurrentValueForLicenseLimit } from './getCurrentValueForLicenseLimit';
import { getResultingBehavior } from './getResultingBehavior';
import { logKind } from './logKind';
import { validateLimit } from './validateLimit';
export async function validateLimits(
this: LicenseManager,
limits: ILicenseV3['limits'],
options: LicenseValidationOptions,
): Promise<BehaviorWithContext[]> {
const limitKeys = (Object.keys(limits) as LicenseLimitKind[]).filter((limit) => isLimitAllowed(limit, options));
return (
await Promise.all(
limitKeys.map(async (limitKey) => {
// Filter the limit list before running any query in the database so we don't end up loading some value we won't use.
const limitList = limits[limitKey]?.filter(({ behavior, max }) => max >= 0 && isBehaviorAllowed(behavior, options));
if (!limitList?.length) {
return [];
}
const extraCount = options.context?.[limitKey]?.extraCount ?? 0;
const currentValue = (await getCurrentValueForLicenseLimit.call(this, limitKey, options.context?.[limitKey])) + extraCount;
return limitList
.filter(({ max, behavior }) => validateLimit(max, currentValue, behavior, extraCount))
.map((limit) => {
if (!options.suppressLog) {
switch (logKind(limit.behavior)) {
case 'error':
logger.error({
msg: 'Limit validation failed',
kind: limitKey,
limit,
});
break;
case 'info':
logger.info({
msg: 'Limit validation failed',
kind: limitKey,
limit,
});
break;
}
}
return getResultingBehavior(limit, { reason: 'limit', limit: limitKey });
});
}),
)
).flat();
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@rocket.chat/tsconfig/server.json",
"compilerOptions": {
"declaration": true,
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["./src/**/*"]
}