-
Notifications
You must be signed in to change notification settings - Fork 13.6k
feat: add licenses.validate endpoint to preview a license before applying #40858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import type { | |
| LicenseBehavior, | ||
| LicenseInfo, | ||
| LicenseValidationOptions, | ||
| LicenseValidationResult, | ||
| LimitContext, | ||
| LicenseModule, | ||
| } from '@rocket.chat/core-typings'; | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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, | ||
| }); | ||
|
|
||
|
|
@@ -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, { | ||
| behaviors: [...licenseValidationBehaviors, 'prevent_action'], | ||
| isNewLicense: true, | ||
| suppressLog: true, | ||
| }); | ||
|
Comment on lines
+333
to
+361
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mirror the apply-path readiness guard before running preview validation.
Suggested fix public async validateLicenseForPreview(encryptedLicense: string): Promise<LicenseValidationResult> {
+ if (!isReadyForValidation.call(this)) {
+ throw new NotReadyForValidation();
+ }
+
const workspaceUrl = this.getWorkspaceUrl();🤖 Prompt for AI Agents |
||
|
|
||
| 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(); | ||
|
|
@@ -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 | ||
|
|
@@ -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]; | ||
| }), | ||
| ); | ||
|
|
||
|
|
||
| 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'); | ||
| }); | ||
| }); |
| 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[]; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Missing
isReadyForValidationguard before callingrunValidation. BothvalidateLicense()andsetLicense()checkisReadyForValidation.call(this)and throwNotReadyForValidationwhen 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