Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/license-validate-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/core-typings': minor
'@rocket.chat/license': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

Adds a new `licenses.validate` REST endpoint that validates a Rocket.Chat license (V2 or V3 JWT) against the current workspace's validation structure without applying it, returning the validation details (validity, granted modules and any validation errors) so a license can be previewed before it is applied from the UI.
16 changes: 15 additions & 1 deletion apps/meteor/ee/server/api/licenses.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { License } from '@rocket.chat/license';
import { Settings, Users } from '@rocket.chat/models';
import { isLicensesInfoProps } from '@rocket.chat/rest-typings';
import { isLicensesInfoProps, isLicensesValidateProps } from '@rocket.chat/rest-typings';
import { check } from 'meteor/check';

import { API } from '../../../app/api/server/api';
Expand Down Expand Up @@ -71,6 +71,20 @@ API.v1.addRoute(
},
);

API.v1.addRoute(
'licenses.validate',
{ authRequired: true, permissionsRequired: ['edit-privileged-setting'], validateParams: isLicensesValidateProps },
{
async post() {
const { license } = this.bodyParams;

const validation = await License.validateLicenseForPreview(license);

return API.v1.success({ validation });
},
},
);

API.v1.addRoute(
'licenses.maxActiveUsers',
{ authRequired: true },
Expand Down
73 changes: 68 additions & 5 deletions ee/packages/license/src/license.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
LicenseBehavior,
LicenseInfo,
LicenseValidationOptions,
LicenseValidationResult,
LimitContext,
LicenseModule,
} from '@rocket.chat/core-typings';
Expand Down Expand Up @@ -52,6 +53,15 @@ 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;

Expand Down Expand Up @@ -267,7 +277,7 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
}

const validationResult = await runValidation.call(this, this._license, {
behaviors: ['invalidate_license', 'start_fair_policy', 'prevent_installation', 'disable_modules'],
behaviors: licenseValidationBehaviors,
...options,
});

Expand Down Expand Up @@ -312,6 +322,59 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
}
}

/**
* Validates a license against the current workspace state without applying it.
*
* This decrypts and verifies the license string, then runs the same validation pipeline
* used when a license is set (URL, periods and limits), but does not mutate any internal
* state nor emit any events. It is meant to power a preview of what would happen if the
* license were applied — e.g. before committing to it from the admin UI.
*/
public async validateLicenseForPreview(encryptedLicense: string): Promise<LicenseValidationResult> {
const workspaceUrl = this.getWorkspaceUrl();

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 license provided for validation preview', err });
return {
isFormatValid: false,
isValid: false,
workspaceUrl,
grantedModules: [],
validationErrors: [],
};
}

// Run the full validation pipeline against the current workspace state. Unlike `validateLicense`,
// this does not store the license, change validity, replace modules/tags nor emit events.
// In addition to the behaviors that determine installation, also surface `prevent_action` so the
// preview can report limits that are already exceeded by the current workspace.
const validationResult = await runValidation.call(this, license, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing isReadyForValidation guard before calling runValidation. Both validateLicense() and setLicense() check isReadyForValidation.call(this) and throw NotReadyForValidation when the workspace isn't fully configured (e.g., workspace URL not yet set). Without the same guard here, the preview endpoint can validate against incomplete state and return misleading results. Add the readiness check (returning an appropriate error in the result) before running validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ee/packages/license/src/license.ts, line 357:

<comment>Missing `isReadyForValidation` guard before calling `runValidation`. Both `validateLicense()` and `setLicense()` check `isReadyForValidation.call(this)` and throw `NotReadyForValidation` when the workspace isn't fully configured (e.g., workspace URL not yet set). Without the same guard here, the preview endpoint can validate against incomplete state and return misleading results. Add the readiness check (returning an appropriate error in the result) before running validation.</comment>

<file context>
@@ -312,6 +322,59 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
+		// this does not store the license, change validity, replace modules/tags nor emit events.
+		// In addition to the behaviors that determine installation, also surface `prevent_action` so the
+		// preview can report limits that are already exceeded by the current workspace.
+		const validationResult = await runValidation.call(this, license, {
+			behaviors: [...licenseValidationBehaviors, 'prevent_action'],
+			isNewLicense: true,
</file context>

behaviors: [...licenseValidationBehaviors, 'prevent_action'],
isNewLicense: true,
suppressLog: true,
});
Comment on lines +333 to +361

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mirror the apply-path readiness guard before running preview validation.

setLicense() on Line 400 and validateLicense() on Line 275 both stop when isReadyForValidation.call(this) is false, but validateLicenseForPreview() goes straight into runValidation(). That lets the preview endpoint validate against partial workspace state instead of matching the apply path.

Suggested fix
 public async validateLicenseForPreview(encryptedLicense: string): Promise<LicenseValidationResult> {
+	if (!isReadyForValidation.call(this)) {
+		throw new NotReadyForValidation();
+	}
+
 	const workspaceUrl = this.getWorkspaceUrl();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ee/packages/license/src/license.ts` around lines 333 - 361,
validateLicenseForPreview currently skips the readiness guard and calls
runValidation on partial workspace state; add the same readiness check used in
setLicense/validateLicense by calling isReadyForValidation.call(this) before
invoking runValidation and, if it returns false, short-circuit and return a
LicenseValidationResult matching the other guards (e.g., isFormatValid:
true/false per existing logic, isValid: false, workspaceUrl, empty
grantedModules and validationErrors) so the preview mirrors the apply-path
readiness behavior; reference validateLicenseForPreview, isReadyForValidation,
runValidation, setLicense and validateLicense when making the change.


const isValid = !isBehaviorsInResult(validationResult, ['invalidate_license', 'prevent_installation']);

const disabledModules = getModulesToDisable(validationResult);
const grantedModules = license.grantedModules.filter(({ module }) => !disabledModules.includes(module)).map(({ module }) => module);

return {
isFormatValid: true,
isValid,
license,
workspaceUrl,
grantedModules,
validationErrors: validationResult,
};
}

public async setLicense(encryptedLicense: string, isNewLicense = true): Promise<boolean> {
if (!(await validateFormat(encryptedLicense))) {
throw new InvalidLicenseError();
Expand Down Expand Up @@ -408,10 +471,10 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {

const items = await Promise.all(
keys.map(async (limit) => {
const cached = this.shouldPreventActionResults.get(limit as LicenseLimitKind);
const cached = this.shouldPreventActionResults.get(limit);

if (cached !== undefined) {
return [limit as LicenseLimitKind, cached];
return [limit, cached];
}

const fresh = license
Expand All @@ -426,9 +489,9 @@ export abstract class LicenseManager extends Emitter<LicenseEvents> {
'prevent_action',
]);

this.shouldPreventActionResults.set(limit as LicenseLimitKind, fresh);
this.shouldPreventActionResults.set(limit, fresh);

return [limit as LicenseLimitKind, fresh];
return [limit, fresh];
}),
);

Expand Down
95 changes: 95 additions & 0 deletions ee/packages/license/src/validateLicenseForPreview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { MockedLicenseBuilder, getReadyLicenseManager } from '../__tests__/MockedLicenseBuilder';

describe('validateLicenseForPreview', () => {
it('should report an invalid format for a non-license string', async () => {
const licenseManager = await getReadyLicenseManager();

const result = await licenseManager.validateLicenseForPreview('not-a-license');

expect(result.isFormatValid).toBe(false);
expect(result.isValid).toBe(false);
expect(result.license).toBeUndefined();
expect(result.grantedModules).toEqual([]);
});

it('should preview a valid license without applying 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.isFormatValid).toBe(true);
expect(result.isValid).toBe(true);
expect(result.license?.version).toBe('3.0');
expect(result.grantedModules).toEqual(expect.arrayContaining(['livechat-enterprise', 'engagement-dashboard']));
expect(result.validationErrors).toEqual([]);

// 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 builder = new MockedLicenseBuilder().withGrantedModules(['livechat-enterprise']);
const license = await builder.sign();

await licenseManager.setLicense(license);
expect(licenseManager.hasValidLicense()).toBe(true);

const result = await licenseManager.validateLicenseForPreview(license);

expect(result.isFormatValid).toBe(true);
expect(result.isValid).toBe(true);
});

it('should report an invalid license 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.isFormatValid).toBe(true);
expect(result.isValid).toBe(false);
expect(result.validationErrors).toEqual(
expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'url' })]),
);
});

it('should report an invalid license 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.isFormatValid).toBe(true);
expect(result.isValid).toBe(false);
expect(result.validationErrors).toEqual(
expect.arrayContaining([expect.objectContaining({ behavior: 'invalidate_license', reason: 'period' })]),
);
});

it('should still be valid but exclude modules disabled by an expired period', 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());

expect(result.isValid).toBe(true);
expect(result.grantedModules).toContain('engagement-dashboard');
expect(result.grantedModules).not.toContain('livechat-enterprise');
});
});
33 changes: 33 additions & 0 deletions packages/core-typings/src/license/LicenseValidationResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ILicenseV3 } from './ILicenseV3';
import type { BehaviorWithContext } from './LicenseBehavior';
import type { LicenseModule } from './LicenseModule';

/**
* Result of validating a license without applying it.
*
* Used to preview what would happen if a given license were applied to the current
* workspace, so the outcome can be shown to an admin before committing to it.
*/
export type LicenseValidationResult = {
/**
* Whether the provided string is a well-formed, signature-valid license that could be decoded.
* When `false`, no further details are available.
*/
isFormatValid: boolean;
/**
* Whether the license would be accepted by this workspace, i.e. applying it would not
* invalidate it nor prevent its installation.
*/
isValid: boolean;
/** The decoded license. Present only when `isFormatValid` is `true`. */
license?: ILicenseV3;
/** The workspace URL the license was validated against. */
workspaceUrl?: string;
/** The modules that would be enabled if the license were applied. */
grantedModules: LicenseModule[];
/**
* The validation behaviors triggered while validating the license, describing why it is
* (in)valid — e.g. a workspace URL mismatch, an expired period or an exceeded limit.
*/
validationErrors: BehaviorWithContext[];
};
1 change: 1 addition & 0 deletions packages/core-typings/src/license/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export type * from './LicenseLimit';
export * from './LicenseModule';
export type * from './LicensePeriod';
export type * from './LicenseValidationOptions';
export type * from './LicenseValidationResult';
export type * from './LimitContext';
22 changes: 21 additions & 1 deletion packages/rest-typings/src/v1/licenses.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LicenseInfo, Cloud } from '@rocket.chat/core-typings';
import type { LicenseInfo, LicenseValidationResult, Cloud } from '@rocket.chat/core-typings';

import { ajv, ajvQuery } from './Ajv';

Expand Down Expand Up @@ -36,6 +36,23 @@ const licensesInfoPropsSchema = {

export const isLicensesInfoProps = ajvQuery.compile<licensesInfoProps>(licensesInfoPropsSchema);

type licensesValidateProps = {
license: string;
};

const licensesValidatePropsSchema = {
type: 'object',
properties: {
license: {
type: 'string',
},
},
required: ['license'],
additionalProperties: false,
};

export const isLicensesValidateProps = ajv.compile<licensesValidateProps>(licensesValidatePropsSchema);

export type LicensesEndpoints = {
'/v1/licenses.info': {
GET: (params: licensesInfoProps) => {
Expand All @@ -46,6 +63,9 @@ export type LicensesEndpoints = {
'/v1/licenses.add': {
POST: (params: licensesAddProps) => void;
};
'/v1/licenses.validate': {
POST: (params: licensesValidateProps) => { validation: LicenseValidationResult };
};
'/v1/licenses.maxActiveUsers': {
GET: () => { maxActiveUsers: number | null; activeUsers: number };
};
Expand Down
Loading