diff --git a/.changeset/offline-license-no-egress.md b/.changeset/offline-license-no-egress.md new file mode 100644 index 0000000000000..43b8259ea417f --- /dev/null +++ b/.changeset/offline-license-no-egress.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/license': minor +--- + +Adds support for the `offline` license flag, suppressing every outbound connection to Rocket.Chat Cloud services and the Push Gateway at its source, so air-gapped workspaces never initiate calls that would violate their security compliance. diff --git a/apps/meteor/ee/server/apps/appRequestsCron.ts b/apps/meteor/ee/server/apps/appRequestsCron.ts index 6536d45fd0fe8..dbfb5fb910c51 100644 --- a/apps/meteor/ee/server/apps/appRequestsCron.ts +++ b/apps/meteor/ee/server/apps/appRequestsCron.ts @@ -1,4 +1,5 @@ import { cronJobs } from '@rocket.chat/cron'; +import { License } from '@rocket.chat/license'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { appRequestNotififyForUsers } from './marketplace/appRequestNotifyUsers'; @@ -8,6 +9,10 @@ import { settings } from '../../../server/settings'; const appsNotifyAppRequests = async function _appsNotifyAppRequests() { try { + if (License.hasOfflineLicense()) { + return; + } + const installedApps = await Apps.installedApps({ enabled: true }); if (!installedApps || installedApps.length === 0) { return; diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index e9f289ed952da..fb938c74228fd 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -17,6 +17,7 @@ import { registerAppLogsExportHandler } from './endpoints/appLogsExportHandler'; import { registerAppLogsHandler } from './endpoints/appLogsHandler'; import { registerAppsCountHandler } from './endpoints/appsCountHandler'; import { Info } from '../../../../app/utils/rocketchat.info'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; import { API } from '../../../../server/api'; import type { APIClass } from '../../../../server/api/ApiClass'; import { getUploadFormData } from '../../../../server/api/lib/getUploadFormData'; @@ -96,6 +97,13 @@ export class AppsRestApi { const manager = this._manager; const handleError = (message: string, err: any) => { + // Offline (air-gapped) licenses suppress marketplace requests at the source; + // report the real reason instead of a generic connectivity failure. + if (err instanceof CloudOfflineLicenseError) { + orchestrator.getRocketChatLogger().info({ msg: err.message }); + return API.v1.failure({ error: err.message }); + } + // when there is no `response` field in the error, it means the request // couldn't even make it to the server if (!err.hasOwnProperty('response')) { @@ -149,7 +157,7 @@ export class AppsRestApi { const apps = await fetchMarketplaceApps({ ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }) }); return API.v1.success(apps); } catch (err) { - if (err instanceof MarketplaceConnectionError) { + if (err instanceof MarketplaceConnectionError || err instanceof CloudOfflineLicenseError) { return handleError('Unable to access Marketplace. Does the server has access to the internet?', err); } @@ -178,7 +186,7 @@ export class AppsRestApi { return API.v1.success(categories); } catch (err) { orchestrator.getRocketChatLogger().error({ msg: 'Error fetching categories from Marketplace:', err }); - if (err instanceof MarketplaceConnectionError) { + if (err instanceof MarketplaceConnectionError || err instanceof CloudOfflineLicenseError) { return handleError('Unable to access Marketplace. Does the server has access to the internet?', err); } diff --git a/apps/meteor/ee/server/apps/cron.ts b/apps/meteor/ee/server/apps/cron.ts index fc655f087dbd3..8dad4b557d554 100644 --- a/apps/meteor/ee/server/apps/cron.ts +++ b/apps/meteor/ee/server/apps/cron.ts @@ -1,6 +1,7 @@ import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp'; import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import { cronJobs } from '@rocket.chat/cron'; +import { License } from '@rocket.chat/license'; import { Settings, Users } from '@rocket.chat/models'; import { Apps } from './orchestrator'; @@ -75,6 +76,10 @@ const notifyAdminsAboutRenewedApps = async function _notifyAdminsAboutRenewedApp }; const appsUpdateMarketplaceInfo = async function _appsUpdateMarketplaceInfo() { + if (License.hasOfflineLicense()) { + return; + } + const token = await getWorkspaceAccessToken(); const workspaceIdSetting = await Settings.getValueById('Cloud_Workspace_Id'); diff --git a/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts b/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts index af95b1979fce4..f5c86e4f56620 100644 --- a/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts +++ b/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts @@ -1,6 +1,8 @@ +import { License } from '@rocket.chat/license'; import { type ExtendedFetchOptions, Response, serverFetch } from '@rocket.chat/server-fetch'; import { isTesting } from './isTesting'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; export class MarketplaceAPIClient { #fetchStrategy: (input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean) => Promise; @@ -41,6 +43,12 @@ export class MarketplaceAPIClient { } public fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): ReturnType { + if (License.hasOfflineLicense()) { + return Promise.reject( + new CloudOfflineLicenseError('Marketplace connectivity is disabled by the offline license applied to this workspace'), + ); + } + if (!input.startsWith('http://') && !input.startsWith('https://')) { input = this.getMarketplaceUrl().concat(!input.startsWith('/') ? '/' : '', input); } diff --git a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts index 6b2f4d39404b7..deaee1f722ed4 100644 --- a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts +++ b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; import { getMarketplaceHeaders } from './getMarketplaceHeaders'; import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; import { getWorkspaceAccessToken } from '../../../../server/lib/cloud'; import { settings } from '../../../../server/settings'; import { Apps } from '../orchestrator'; @@ -157,6 +158,12 @@ export async function fetchMarketplaceApps({ endUserID }: FetchMarketplaceAppsPa }, }); } catch (error) { + // Offline (air-gapped) licenses reject before any request is made; keep the + // typed error so the REST layer can report the real reason instead of a + // generic connectivity failure. + if (error instanceof CloudOfflineLicenseError) { + throw error; + } throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection'); } diff --git a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts index 295b9ec50975e..e2964a47f795a 100644 --- a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts +++ b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; import { getMarketplaceHeaders } from './getMarketplaceHeaders'; import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; import { getWorkspaceAccessToken } from '../../../../server/lib/cloud'; import { settings } from '../../../../server/settings'; import { Apps } from '../orchestrator'; @@ -41,6 +42,12 @@ export async function fetchMarketplaceCategories(): Promise { allowList: settings.get('SSRF_Allowlist'), }); } catch (error) { + // Offline (air-gapped) licenses reject before any request is made; keep the + // typed error so the REST layer can report the real reason instead of a + // generic connectivity failure. + if (error instanceof CloudOfflineLicenseError) { + throw error; + } throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection'); } diff --git a/apps/meteor/ee/server/lib/license/startup.ts b/apps/meteor/ee/server/lib/license/startup.ts index 4131d1752ee4a..75f7a4993de66 100644 --- a/apps/meteor/ee/server/lib/license/startup.ts +++ b/apps/meteor/ee/server/lib/license/startup.ts @@ -8,6 +8,7 @@ import moment from 'moment'; import { getAppCount } from './lib/getAppCount'; import { callbacks } from '../../../../server/lib/callbacks'; import { syncWorkspace } from '../../../../server/lib/cloud/syncWorkspace'; +import { SystemLogger } from '../../../../server/lib/logger/system'; import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener'; import { settings } from '../../../../server/settings'; @@ -125,6 +126,15 @@ export const startLicense = async () => { } } + License.onInstall(() => { + if (License.hasOfflineLicense()) { + // startup level so it is visible at the default Log_Level, like 'License installed' + SystemLogger.startup( + 'Offline license detected: outbound connections to Rocket.Chat Cloud services and the Rocket.Chat Push Gateway are disabled', + ); + } + }); + // After the current license is already loaded, watch the setting value to react to new licenses being applied. settings.change('Enterprise_License', (license) => applyLicenseOrRemove(license, true)); diff --git a/apps/meteor/lib/errors/CloudOfflineLicenseError.ts b/apps/meteor/lib/errors/CloudOfflineLicenseError.ts new file mode 100644 index 0000000000000..93ab61cbf3ef0 --- /dev/null +++ b/apps/meteor/lib/errors/CloudOfflineLicenseError.ts @@ -0,0 +1,5 @@ +import { CloudWorkspaceError } from './CloudWorkspaceError'; + +export class CloudOfflineLicenseError extends CloudWorkspaceError { + override name = CloudOfflineLicenseError.name; +} diff --git a/apps/meteor/server/lib/cloud/connectWorkspace.ts b/apps/meteor/server/lib/cloud/connectWorkspace.ts index a3f283a90af52..c8f62bbb52fd2 100644 --- a/apps/meteor/server/lib/cloud/connectWorkspace.ts +++ b/apps/meteor/server/lib/cloud/connectWorkspace.ts @@ -1,6 +1,7 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getRedirectUri } from './getRedirectUri'; +import { assertNotOfflineLicense } from './offlineLicense'; import { saveRegistrationData } from './saveRegistrationData'; import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError'; import { settings } from '../../settings'; @@ -47,6 +48,8 @@ const fetchRegistrationDataPayload = async ({ }; export async function connectWorkspace(token: string) { + assertNotOfflineLicense(); + if (!token) { throw new CloudWorkspaceConnectionError('Invalid registration token'); } diff --git a/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts b/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts index 60078b1d2eaa2..cd0e1d3cb13ed 100644 --- a/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts +++ b/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts @@ -4,10 +4,13 @@ import { Meteor } from 'meteor/meteor'; import { getRedirectUri } from './getRedirectUri'; import { userScopes } from './oauthScopes'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function finishOAuthAuthorization(code: string, state: string) { + assertNotOfflineLicense(); + if (settings.get('Cloud_Workspace_Registration_State') !== state) { throw new Meteor.Error('error-invalid-state', 'Invalid state provided', { method: 'cloud:finishOAuthAuthorization', diff --git a/apps/meteor/server/lib/cloud/getConfirmationPoll.ts b/apps/meteor/server/lib/cloud/getConfirmationPoll.ts index b03e263d2ef1f..1c965f5fdcd3e 100644 --- a/apps/meteor/server/lib/cloud/getConfirmationPoll.ts +++ b/apps/meteor/server/lib/cloud/getConfirmationPoll.ts @@ -1,10 +1,13 @@ import type { CloudConfirmationPollData } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function getConfirmationPoll(deviceCode: string): Promise { + assertNotOfflineLicense(); + try { const cloudUrl = settings.get('Cloud_Url'); const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { diff --git a/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts b/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts index cb60238175061..38ebac82a70fb 100644 --- a/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts +++ b/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts @@ -3,11 +3,14 @@ import { Random } from '@rocket.chat/random'; import { getRedirectUri } from './getRedirectUri'; import { userScopes } from './oauthScopes'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../notifyListener'; export async function getOAuthAuthorizationUrl() { + assertNotOfflineLicense(); + const state = Random.id(); await updateAuditedBySystem({ diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts index e88b05319be07..e49913bd56f77 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts @@ -1,4 +1,5 @@ import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { WorkspaceCredentials } from '@rocket.chat/models'; import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; @@ -26,6 +27,10 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return ''; } + if (License.hasOfflineLicense()) { + return ''; + } + // Note: If no scope is given, it means we should assume the default scope, we store the default scopes // in the global variable workspaceScopes. if (scope === '') { diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts index 40f9bdd760b31..bf8693eea7278 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts @@ -1,3 +1,4 @@ +import { License } from '@rocket.chat/license'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getRedirectUri } from './getRedirectUri'; @@ -31,6 +32,10 @@ export async function getWorkspaceAccessTokenWithScope({ return tokenResponse; } + if (License.hasOfflineLicense()) { + return tokenResponse; + } + // eslint-disable-next-line @typescript-eslint/naming-convention const client_id = settings.get('Cloud_Workspace_Client_Id'); if (!client_id) { diff --git a/apps/meteor/server/lib/cloud/offlineLicense.ts b/apps/meteor/server/lib/cloud/offlineLicense.ts new file mode 100644 index 0000000000000..7a3b5f52b01a4 --- /dev/null +++ b/apps/meteor/server/lib/cloud/offlineLicense.ts @@ -0,0 +1,13 @@ +import { License } from '@rocket.chat/license'; + +import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; + +/** + * Guard for interactive cloud flows (registration, OAuth, billing). Background + * jobs should instead skip silently by checking {@link License.hasOfflineLicense}. + */ +export function assertNotOfflineLicense(): void { + if (License.hasOfflineLicense()) { + throw new CloudOfflineLicenseError('Cloud connectivity is disabled by the offline license applied to this workspace'); + } +} diff --git a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts index 9e1ded06da825..b85311fd731d2 100644 --- a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -7,6 +8,10 @@ import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function registerPreIntentWorkspaceWizard(): Promise { + if (License.hasOfflineLicense()) { + return false; + } + const firstUser = (await Users.getOldest({ projection: { name: 1, emails: 1 } })) as IUser | undefined; const email = firstUser?.emails?.find((address) => address)?.address; @@ -16,6 +21,12 @@ export async function registerPreIntentWorkspaceWizard(): Promise { const regInfo = await buildWorkspaceRegistrationData(email); + // Re-validated at dispatch time: an offline license applied while the + // registration data was being built must still suppress the request. + if (License.hasOfflineLicense()) { + return false; + } + try { const cloudUrl = settings.get('Cloud_Url'); const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { diff --git a/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts b/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts index 09713b271e9b1..399c54c7ffe83 100644 --- a/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts +++ b/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts @@ -2,6 +2,7 @@ import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { assertNotOfflineLicense } from './offlineLicense'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { syncWorkspace } from './syncWorkspace'; import { settings } from '../../settings'; @@ -10,6 +11,8 @@ import { SystemLogger } from '../logger/system'; import { notifyOnSettingChangedById } from '../notifyListener'; export async function startRegisterWorkspace(resend = false) { + assertNotOfflineLicense(); + const { workspaceRegistered } = await retrieveRegistrationStatus(); if (workspaceRegistered || process.env.TEST_MODE) { await syncWorkspace(); diff --git a/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts b/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts index 1eb0c906c3e71..9e461af7825b4 100644 --- a/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts +++ b/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts @@ -2,10 +2,13 @@ import type { CloudRegistrationIntentData } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function startRegisterWorkspaceSetupWizard(resend = false, email: string): Promise { + assertNotOfflineLicense(); + const regInfo = await buildWorkspaceRegistrationData(email); let payload; diff --git a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts index db0ae510fbe72..dd9118a453c34 100644 --- a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts @@ -114,6 +114,12 @@ const getSupportedVersionsFromCloud = async () => { const headers = await generateWorkspaceBearerHttpHeader(); + // Re-validated at dispatch time: an offline license applied while this async + // operation was in flight must still suppress the request. + if (License.hasOfflineLicense()) { + return { success: true, result: undefined } as const; + } + const response = await handleResponse( fetch(releaseEndpoint, { headers, @@ -141,7 +147,10 @@ const getSupportedVersionsToken = async (retry = 0) => { * Gets the latest version * return the token */ - const [versionsFromLicense, cloudResponse] = await Promise.all([License.getLicense(), getSupportedVersionsFromCloud()]); + const [versionsFromLicense, cloudResponse] = await Promise.all([ + License.getLicense(), + License.hasOfflineLicense() ? ({ success: true, result: undefined } as const) : getSupportedVersionsFromCloud(), + ]); const supportedVersions = await supportedVersionsChooseLatest( supportedVersionsFromBuild, diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts index 3f30e557a6b68..aa76435e2b18e 100644 --- a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts +++ b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts @@ -1,3 +1,5 @@ +import { License } from '@rocket.chat/license'; + import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../logger/system'; import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken'; @@ -12,6 +14,12 @@ import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/suppo * @throws {Error} - If there is an unexpected error during sync like a network error */ export async function syncWorkspace() { + if (License.hasOfflineLicense()) { + SystemLogger.debug({ msg: 'Skipping cloud sync: workspace has an offline license', function: 'syncWorkspace' }); + await getCachedSupportedVersionsToken.reset(); + return; + } + try { await announcementSync(); await syncCloudData(); diff --git a/apps/meteor/server/lib/cloud/userLogout.ts b/apps/meteor/server/lib/cloud/userLogout.ts index cf3e8124621d2..ed2f0bee6d58c 100644 --- a/apps/meteor/server/lib/cloud/userLogout.ts +++ b/apps/meteor/server/lib/cloud/userLogout.ts @@ -1,3 +1,4 @@ +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -17,6 +18,12 @@ export async function userLogout(userId: string): Promise { return ''; } + // Offline (air-gapped) licenses forbid the outbound token-revocation call, but + // the local cloud credentials must still be destroyed on logout. + if (License.hasOfflineLicense()) { + return userLoggedOut(userId); + } + const user = await Users.findOneById(userId); if (user?.services?.cloud?.refreshToken) { diff --git a/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts b/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts index e494cd4324d9a..f238c8c3401ad 100644 --- a/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts +++ b/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { i18n } from '../../../i18n'; @@ -43,6 +44,10 @@ const getMessagesToSendToAdmins = async ( * @deprecated */ export const checkVersionUpdate = async () => { + if (License.hasOfflineLicense()) { + return; + } + logger.info('Checking for version updates'); const { versions, alerts } = await getNewUpdates(); diff --git a/apps/meteor/server/lib/notifications/push/push.ts b/apps/meteor/server/lib/notifications/push/push.ts index bd325d9df1d27..de643fb3e82ab 100644 --- a/apps/meteor/server/lib/notifications/push/push.ts +++ b/apps/meteor/server/lib/notifications/push/push.ts @@ -1,4 +1,5 @@ import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { PushToken } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; @@ -181,6 +182,10 @@ class PushClass { } private shouldUseGateway(): boolean { + if (License.hasOfflineLicense()) { + return false; + } + return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms')); } @@ -397,6 +402,13 @@ class PushClass { continue; } + // The workspace is configured to send through a push gateway, but the offline + // license forbids contacting it — skip quietly instead of falling back to the + // (unconfigured) native providers. + if (this.options.gateways && License.hasOfflineLicense()) { + continue; + } + await this.sendNotificationNative(app, notification, countApn, countGcm); } diff --git a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts index 0594c917754ae..374a560bfb9e2 100644 --- a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts +++ b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts @@ -1,4 +1,5 @@ import type { IStats } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import type { Logger } from '@rocket.chat/logger'; import { Statistics } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -40,7 +41,9 @@ async function sendStats(logger: Logger, cronStatistics: IStats): Promise { // Even when disabled, we still generate statistics locally to avoid breaking // internal processes, such as restriction checks for air-gapped workspaces. - const shouldSendToCollector = shouldReportStatistics(); + // Evaluated at send time (not entry) so the verdict cannot go stale while the + // statistics are being generated — e.g. an offline license applied during startup. + const shouldSendToCollector = () => shouldReportStatistics() && !License.hasOfflineLicense(); return tracerSpan('generateStatistics', {}, async () => { const last = await Statistics.findLast(); @@ -59,7 +62,7 @@ export async function sendUsageReport(logger: Logger): Promise 0) { for (const email of user.emails) { if (email.verified === true) { diff --git a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts index d20e02ee043d1..367a7bf8dd3c8 100644 --- a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts +++ b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import Gravatar from 'gravatar'; import { Accounts } from 'meteor/accounts-base'; @@ -70,7 +71,9 @@ export const saveNewUser = async function (userData: SaveUserData, sendPassword: userData._id = _id; - if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { + // Offline (air-gapped) licenses suppress the default Gravatar fetch — a + // default-on outbound call the workspace must never initiate on its own. + if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email && !License.hasOfflineLicense()) { const gravatarUrl = Gravatar.url(userData.email, { default: '404', size: '200', diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 17f2557a4e4f8..70e26cc4d0881 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -9,9 +9,11 @@ import './settings/definitions'; import { startRestAPI } from './api/api'; import { configureServer } from './configuration'; +import { SystemLogger } from './lib/logger/system'; import { registerServices } from './services/startup'; import { settings } from './settings'; import { startup } from './startup'; +import { startCronJobs } from './startup/cron'; import { startupApp } from '../ee/server'; import { startRocketChat } from '../startRocketChat'; @@ -28,5 +30,12 @@ import './features/EmailInbox/index'; await Promise.all([configureServer(settings), registerServices(), startup()]); await startRocketChat(); + +setImmediate(() => { + startCronJobs().catch((err) => { + SystemLogger.error({ msg: 'Failed to start cron jobs', err }); + }); +}); + await startupApp(); await startRestAPI(); diff --git a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts index 560b250e52cbe..e72a83732d4f4 100644 --- a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts +++ b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts @@ -15,6 +15,7 @@ import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspac import { InvalidCloudAnnouncementInteractionError } from '../../../lib/errors/InvalidCloudAnnouncementInteractionError'; import { InvalidCoreAppInteractionError } from '../../../lib/errors/InvalidCoreAppInteractionError'; import { getWorkspaceAccessToken } from '../../lib/cloud'; +import { assertNotOfflineLicense } from '../../lib/cloud/offlineLicense'; import { syncWorkspace } from '../../lib/cloud/syncWorkspace'; import { SystemLogger } from '../../lib/logger/system'; import { settings } from '../../settings'; @@ -255,6 +256,8 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { interactant: CloudAnnouncementInteractant, userInteraction: UiKit.UserInteraction, ): Promise { + assertNotOfflineLicense(); + const token = await this.getWorkspaceAccessToken(); const request: CloudAnnouncementInteractionRequest = { diff --git a/apps/meteor/server/startup/index.ts b/apps/meteor/server/startup/index.ts index d808423188074..46a13aaec4bf1 100644 --- a/apps/meteor/server/startup/index.ts +++ b/apps/meteor/server/startup/index.ts @@ -1,6 +1,5 @@ import './appcache'; import './callbacks'; -import { startCronJobs } from './cron'; import { ensureMessagesTextIndex } from './ensureMessagesTextIndex'; import './initialData'; import './serverRunning'; @@ -18,7 +17,9 @@ export const startup = async () => { await generateFederationKeys(); - setImmediate(() => startCronJobs()); + // NOTE: cron jobs are started from main.ts after startRocketChat(), so jobs + // that contact Rocket.Chat Cloud on boot (e.g. the usage report) run only + // after the license — including the offline flag — has been applied. setImmediate(() => ensureMessagesTextIndex()); // only starts network broker if running in micro services mode if (!isRunningMs()) { diff --git a/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts b/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts new file mode 100644 index 0000000000000..dd7f6df7f61f6 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const serverFetchStub = sinon.stub(); + +class CloudOfflineLicenseError extends Error {} + +const { MarketplaceAPIClient } = proxyquire.noCallThru().load('../../../../../../ee/server/apps/marketplace/MarketplaceAPIClient.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/server-fetch': { serverFetch: serverFetchStub, Response: class {} }, + './isTesting': { isTesting: () => false }, + '../../../../lib/errors/CloudOfflineLicenseError': { CloudOfflineLicenseError }, +}); + +describe('MarketplaceAPIClient', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + serverFetchStub.reset(); + }); + + it('should reject with CloudOfflineLicenseError and never invoke the fetch strategy when the license is offline', async () => { + hasOfflineLicenseStub.returns(true); + + const client = new MarketplaceAPIClient(); + + await expect(client.fetch('v1/apps')).to.be.rejectedWith(CloudOfflineLicenseError); + expect(serverFetchStub.called).to.be.false; + }); + + it('should re-evaluate the license on every call, not cache the verdict on the instance', async () => { + const client = new MarketplaceAPIClient(); + + hasOfflineLicenseStub.returns(false); + serverFetchStub.resolves({ status: 200 }); + await client.fetch('v1/apps'); + expect(serverFetchStub.calledOnce).to.be.true; + + // license flips to offline at runtime: the same instance must now reject + hasOfflineLicenseStub.returns(true); + await expect(client.fetch('v1/apps')).to.be.rejectedWith(CloudOfflineLicenseError); + expect(serverFetchStub.calledOnce).to.be.true; + }); + + it('should fetch from the marketplace when the license is not offline', async () => { + hasOfflineLicenseStub.returns(false); + serverFetchStub.resolves({ status: 200 }); + + const client = new MarketplaceAPIClient(); + await client.fetch('v1/apps'); + + expect(serverFetchStub.calledOnce).to.be.true; + expect(serverFetchStub.firstCall.args[0]).to.equal('https://marketplace.rocket.chat/v1/apps'); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts new file mode 100644 index 0000000000000..644bc279468e7 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const getOldestStub = sinon.stub(); +const fetchStub = sinon.stub(); +const buildRegistrationDataStub = sinon.stub(); + +const { registerPreIntentWorkspaceWizard } = proxyquire + .noCallThru() + .load('../../../../../server/lib/cloud/registerPreIntentWorkspaceWizard.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/models': { Users: { getOldest: getOldestStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './buildRegistrationData': { buildWorkspaceRegistrationData: buildRegistrationDataStub }, + '../../settings': { settings: { get: sinon.stub().returns('https://cloud.rocket.chat') } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, + }); + +describe('registerPreIntentWorkspaceWizard', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + getOldestStub.reset(); + fetchStub.reset(); + buildRegistrationDataStub.reset(); + }); + + it('should return false without querying users or fetching when the license is offline', async () => { + hasOfflineLicenseStub.returns(true); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.false; + expect(getOldestStub.called).to.be.false; + expect(fetchStub.called).to.be.false; + }); + + it('should not fetch when an offline license is applied while registration data is being built', async () => { + // entry check passes, dispatch-time re-check catches the license change mid-flight + hasOfflineLicenseStub.onFirstCall().returns(false); + hasOfflineLicenseStub.onSecondCall().returns(true); + getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] }); + buildRegistrationDataStub.resolves({}); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.false; + expect(fetchStub.called).to.be.false; + }); + + it('should contact the cloud when the license is not offline', async () => { + hasOfflineLicenseStub.returns(false); + getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] }); + buildRegistrationDataStub.resolves({}); + fetchStub.resolves({ ok: true }); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.true; + expect(fetchStub.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts new file mode 100644 index 0000000000000..e5cd54e72722d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const retrieveRegistrationStatusStub = sinon.stub(); +const userLoggedOutStub = sinon.stub(); +const fetchStub = sinon.stub(); +const findOneByIdStub = sinon.stub(); +const settingsGetStub = sinon.stub(); + +const { userLogout } = proxyquire.noCallThru().load('../../../../../server/lib/cloud/userLogout.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/models': { Users: { findOneById: findOneByIdStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './retrieveRegistrationStatus': { retrieveRegistrationStatus: retrieveRegistrationStatusStub }, + './userLoggedOut': { userLoggedOut: userLoggedOutStub }, + '../../settings': { settings: { get: settingsGetStub } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, +}); + +describe('userLogout', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + retrieveRegistrationStatusStub.reset(); + userLoggedOutStub.reset(); + fetchStub.reset(); + findOneByIdStub.reset(); + settingsGetStub.reset(); + }); + + it('should still destroy local cloud credentials on logout under an offline license, without calling the revocation endpoint', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true }); + hasOfflineLicenseStub.returns(true); + userLoggedOutStub.resolves(true); + + const result = await userLogout('user-id'); + + expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true; + expect(fetchStub.called).to.be.false; + expect(result).to.be.true; + }); + + it('should revoke the refresh token and destroy local cloud credentials when the license is not offline', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true }); + hasOfflineLicenseStub.returns(false); + findOneByIdStub.resolves({ _id: 'user-id', services: { cloud: { refreshToken: 'refresh-token' } } }); + settingsGetStub.withArgs('Cloud_Workspace_Client_Id').returns('client-id'); + settingsGetStub.withArgs('Cloud_Workspace_Client_Secret').returns('client-secret'); + settingsGetStub.withArgs('Cloud_Url').returns('https://cloud.rocket.chat'); + fetchStub.resolves({}); + userLoggedOutStub.resolves(true); + + const result = await userLogout('user-id'); + + expect(fetchStub.calledOnce).to.be.true; + expect(fetchStub.firstCall.args[0]).to.equal('https://cloud.rocket.chat/api/oauth/revoke'); + expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true; + expect(result).to.be.true; + }); + + it('should do nothing when the workspace is not registered', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: false }); + + const result = await userLogout('user-id'); + + expect(result).to.equal(''); + expect(fetchStub.called).to.be.false; + expect(userLoggedOutStub.called).to.be.false; + }); +}); diff --git a/docs/proposals/offline-license-capability-enforcement.md b/docs/proposals/offline-license-capability-enforcement.md new file mode 100644 index 0000000000000..99e6d624eb176 --- /dev/null +++ b/docs/proposals/offline-license-capability-enforcement.md @@ -0,0 +1,114 @@ +# Proposal: License Capability Enforcement via Branded Proofs + +## Status + +Draft — v2. Supersedes the earlier `CloudConnection`-only draft: the single-purpose connection capability is generalized into one proof mechanism that can guard any license-controlled capability, with the specific entitlement captured in the type. + +## Problem + +License enforcement today is a runtime side-condition the compiler knows nothing about, in two distinct places: + +1. **Module entitlements.** `License.hasModule('auditing')` returns a `boolean`. Nothing ties that boolean to the code it guards: a feature entry point can be called without any check, a check for the *wrong* module still typechecks, and a refactor that moves code out from under its `if` block breaks enforcement silently. +2. **Offline (air-gapped) licenses.** When `license.information.offline` is true, the workspace must never initiate outbound connections to Rocket.Chat-owned endpoints. This is enforced by ~15 scattered `hasOfflineLicense()` checks (sync, marketplace, telemetry, push gateway, Gravatar). Any new feature can `import { serverFetch }` and ship a compliance violation no type error catches — and two real bugs of exactly this class were found during QA (a startup race in the usage report, and a stale verdict held by the push retry chain). + +Both are the same underlying flaw: **the license check produces no evidence**. Nothing forces the check to happen, to happen for the right entitlement, or to happen at the right time. + +## Proposed Solution + +Make every license check return an unforgeable **proof value**, and make guarded code demand that proof in its signature. The pattern has three parts. + +### 1. The lock — branded proof types + +One brand mechanism, parameterized by what it proves. The generic parameter captures the exact literal passed to the checker, so proofs for different modules are not interchangeable: + +```ts +// packages/core-typings/src/license/LicenseModule.ts (already exists) +export type InternalModuleName = (typeof CoreModules)[number]; +export type ExternalModuleName = `${string}.${string}`; +export type LicenseModule = InternalModuleName | ExternalModuleName; + +// ee/packages/license/src/proofs.ts (new) +declare const LicenseAuthorized: unique symbol; // NOT exported — unforgeable outside the package + +/** Proof that the current license grants module M. */ +export type ModuleProof = { + readonly [LicenseAuthorized]: M; +}; + +/** Proof that the current license permits outbound calls to Rocket.Chat-owned + * endpoints (absent when the license carries the offline flag). */ +export type CloudEgressProof = { + readonly [LicenseAuthorized]: 'cloud:egress'; +}; +``` + +The brand property is phantom — no such field exists at runtime. Proofs are frozen empty objects: zero allocation cost (a shared singleton per kind), zero serialization surface. + +### 2. The keymaker — the license package owns construction + +The only casts live inside `ee/packages/license`, next to the state they attest to: + +```ts +// on LicenseManager — additive API; the existing boolean hasModule() stays +public proveModule(module: M): ModuleProof | undefined { + return this.hasModule(module) ? (PROOF as ModuleProof) : undefined; +} + +public proveCloudEgress(): CloudEgressProof | undefined { + return this.hasOfflineLicense() ? undefined : (PROOF as CloudEgressProof); +} +``` + +Design decisions: + +- **Additive, not a signature change.** `hasModule(): boolean` has hundreds of call sites, including display logic and the REST surface the client consumes. Those keep the boolean. `proveModule()` is what *server-side entry points* migrate to. +- **Synchronous.** License state is in memory; proofs are free to acquire, which matters for the per-attempt rule below. +- **The offline gate is a proof family, not a module.** The offline flag has inverted semantics — it *revokes* a permission rather than granting a feature — but the same brand expresses it: `proveCloudEgress()` returns `undefined` exactly when the offline license forbids egress. A `proveCloudEgressOrThrow(message?)` variant throws the existing `CloudOfflineLicenseError` for interactive flows (registration, cloud login, billing), preserving today's error contract. + +### 3. The guard — signatures demand proofs + +```ts +// Module-gated feature entry point: +function initAuditing(proof: ModuleProof<'auditing'>) { ... } + +initAuditing(); // ❌ compile error: expected 1 argument +const token = License.proveModule('auditing'); +initAuditing(token); // ❌ compile error: possibly undefined +if (token) { + initAuditing(token); // ✅ narrowed to ModuleProof<'auditing'> +} + +// Cloud I/O — the single fetch wrapper in apps/meteor/server/lib/cloud/cloudClient.ts: +export function cloudFetch(proof: CloudEgressProof, input: string, options?: ExtendedFetchOptions): Promise; +``` + +The developer experience does the enforcement: to call the function you need the token; the only way to get the token is the checker whose name and JSDoc explain the rule; the `| undefined` return forces an explicit decision about the denied case (skip silently vs. throw); and truthiness narrowing makes the happy path read naturally. + +The proof is compile-time evidence, not the runtime gate itself: `cloudFetch` **re-validates `hasOfflineLicense()` immediately before dispatching** and drops the request if the license changed while the operation was in flight. The proof parameter guarantees the check can't be forgotten; the dispatch-time re-check guarantees it can't go stale. + +Inner helpers that are only reachable from a guarded entry point take the proof as a parameter rather than re-checking — the signature documents "this function performs cloud I/O / module-X work", and the compiler walks the requirement up the call graph to wherever the proof is legitimately acquired. + +## The staleness rule: acquire per attempt, never cache + +Licenses change at runtime — swapped, upgraded, invalidated, or an offline license applied mid-flight. A proof is a **point-in-time verdict**, and both QA-discovered bugs in the offline work were staleness bugs: + +- the usage report evaluated its gate at function entry, then spent seconds generating statistics before fetching (the verdict predated license application); +- the push gateway retry chain captured its decision in `setTimeout` closures that outlived a license change. + +Rules, to be stated in the proofs' JSDoc and enforced in review: + +1. Acquire the proof at the top of each operation *attempt*; retry closures re-acquire on every attempt (the factories are sync — this costs nothing). +2. Never store a proof on a class field, module scope, or queue payload. +3. A proof is not a subscription. Long-lived module features (services started when a module is granted) must still use the existing lifecycle events — `License.onValidFeature` / `onInvalidFeature` / `onToggledFeature` — for startup and teardown. Proofs guard entry points; events manage lifetimes. +4. Proofs are server-only and must never cross the API boundary; the client keeps boolean checks driven by `licenses.info`. + +## Migration + +1. **Cloud egress first** (the offline license feature, already enforced at runtime in ~27 files): introduce `proveCloudEgress()` and `cloudFetch(proof, ...)`, then convert the four established patterns — interactive flows (`OrThrow`), background jobs (`undefined` → keep side effects, skip), the marketplace client (proof acquired per `fetch()` call, never stored on the instance), and retry closures (per-attempt). The existing `hasOfflineLicense()` helpers remain for behavior gates that don't perform I/O themselves (`shouldUseGateway()`, cron-body skips, Gravatar suggestion filtering). +2. **Module proofs opportunistically**: as EE features are touched, their server entry points gain `ModuleProof<'...'>` parameters, starting with features whose checks have historically drifted from their code. No big-bang rewrite; the boolean API keeps working throughout. + +## Limitations + +TypeScript cannot forbid an import: a new file can still `import { serverFetch }` directly and hardcode an endpoint, and `{} as ModuleProof<'auditing'>` defeats the brand (both are greppable and glaring in review — the cast requires importing a type whose only documented constructor is the license manager). What the type system guarantees is narrower but real: **code routed through proof-demanding signatures cannot skip the check, cannot check the wrong module, and must handle the denied case explicitly** — and violations shrink to two obvious review signals: a raw `serverFetch` near a Rocket.Chat domain, or a forged cast. + +A directory-scoped lint rule (`no-restricted-imports` on `@rocket.chat/server-fetch` with `cloudClient.ts` exempt) or a CI grep could close the import hole; both were considered and deliberately left out in favor of a types-only approach. diff --git a/ee/packages/license/src/MockedLicenseBuilder.ts b/ee/packages/license/src/MockedLicenseBuilder.ts index 60a1e98e242cf..878061458a1a3 100644 --- a/ee/packages/license/src/MockedLicenseBuilder.ts +++ b/ee/packages/license/src/MockedLicenseBuilder.ts @@ -172,6 +172,11 @@ export class MockedLicenseBuilder { return this; } + public withOffline(offline = true): this { + this.information.offline = offline; + return this; + } + grantedModules: GrantedModules = []; limits: { diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 378591215c797..80c801626a176 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -497,3 +497,41 @@ describe('License.removeLicense', () => { 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); + }); +}); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index f557dd681a909..b5f045514cd07 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -453,6 +453,10 @@ export abstract class LicenseManager extends Emitter { return undefined; } + public hasOfflineLicense(): boolean { + return this.getLicense()?.information.offline ?? false; + } + public syncShouldPreventActionResults(actions: Record): void { for (const [action, shouldPreventAction] of Object.entries(actions)) { this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction);