From eae979fea54a3b7f5433723a400ab491ab26e80a Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 2 Jul 2026 09:27:23 -0300 Subject: [PATCH 01/10] feat: no egress for offline licenses --- apps/meteor/ee/server/apps/appRequestsCron.ts | 5 +++ apps/meteor/ee/server/apps/cron.ts | 5 +++ .../apps/marketplace/MarketplaceAPIClient.ts | 8 ++++ apps/meteor/ee/server/lib/license/startup.ts | 21 ++++++++++ .../lib/errors/CloudOfflineLicenseError.ts | 5 +++ .../server/lib/cloud/connectWorkspace.ts | 3 ++ .../lib/cloud/finishOAuthAuthorization.ts | 3 ++ .../server/lib/cloud/getConfirmationPoll.ts | 3 ++ .../lib/cloud/getOAuthAuthorizationUrl.ts | 3 ++ .../lib/cloud/getWorkspaceAccessToken.ts | 5 +++ .../cloud/getWorkspaceAccessTokenWithScope.ts | 5 +++ .../meteor/server/lib/cloud/offlineLicense.ts | 25 ++++++++++++ .../cloud/registerPreIntentWorkspaceWizard.ts | 5 +++ .../lib/cloud/startRegisterWorkspace.ts | 3 ++ .../startRegisterWorkspaceSetupWizard.ts | 3 ++ .../supportedVersionsToken.ts | 5 ++- .../server/lib/cloud/syncWorkspace/index.ts | 7 ++++ apps/meteor/server/lib/cloud/userLogout.ts | 3 +- .../functions/checkVersionUpdate.ts | 5 +++ .../server/lib/notifications/push/push.ts | 12 ++++++ .../statistics/functions/sendUsageReport.ts | 3 +- .../core-apps/cloudAnnouncements.module.ts | 3 ++ .../license/src/MockedLicenseBuilder.ts | 5 +++ ee/packages/license/src/license.spec.ts | 38 +++++++++++++++++++ ee/packages/license/src/license.ts | 4 ++ 25 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/lib/errors/CloudOfflineLicenseError.ts create mode 100644 apps/meteor/server/lib/cloud/offlineLicense.ts 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/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/lib/license/startup.ts b/apps/meteor/ee/server/lib/license/startup.ts index 4131d1752ee4a..0ffed6d64fdc1 100644 --- a/apps/meteor/ee/server/lib/license/startup.ts +++ b/apps/meteor/ee/server/lib/license/startup.ts @@ -8,9 +8,27 @@ 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'; +const logOfflineLicense = (() => { + let logged = false; + return () => { + if (!License.hasOfflineLicense()) { + logged = false; + return; + } + + if (!logged) { + SystemLogger.info( + 'Offline license detected: outbound connections to Rocket.Chat Cloud services and the Rocket.Chat Push Gateway are disabled', + ); + logged = true; + } + }; +})(); + export const startLicense = async () => { settings.watch('Site_Url', (value) => { if (value) { @@ -125,6 +143,9 @@ export const startLicense = async () => { } } + logOfflineLicense(); + License.onValidateLicense(logOfflineLicense); + // 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..3ccffe7cb62af 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts @@ -3,6 +3,7 @@ import { WorkspaceCredentials } from '@rocket.chat/models'; import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; import { workspaceScopes } from './oauthScopes'; +import { hasOfflineLicense } from './offlineLicense'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { SystemLogger } from '../logger/system'; @@ -26,6 +27,10 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return ''; } + if (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..a102548290172 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts @@ -3,6 +3,7 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getRedirectUri } from './getRedirectUri'; import { CloudWorkspaceAccessTokenError } from './getWorkspaceAccessToken'; import { workspaceScopes } from './oauthScopes'; +import { hasOfflineLicense } from './offlineLicense'; import { removeWorkspaceRegistrationInfo } from './removeWorkspaceRegistrationInfo'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { settings } from '../../settings'; @@ -31,6 +32,10 @@ export async function getWorkspaceAccessTokenWithScope({ return tokenResponse; } + if (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..2abbbad561566 --- /dev/null +++ b/apps/meteor/server/lib/cloud/offlineLicense.ts @@ -0,0 +1,25 @@ +import { License } from '@rocket.chat/license'; + +import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; + +/** + * Whether the applied license was issued for offline (air-gapped) workspaces. + * + * When this returns true the workspace must never initiate outbound connections + * to Rocket.Chat Cloud services (registration, sync, marketplace, telemetry) or + * to the Rocket.Chat Push Gateway. Calls must be suppressed at the source — not + * by relying on the requests failing. + */ +export function hasOfflineLicense(): boolean { + return License.hasOfflineLicense(); +} + +/** + * Guard for interactive cloud flows (registration, OAuth, billing). Background + * jobs should instead skip silently via {@link hasOfflineLicense}. + */ +export function assertNotOfflineLicense(): void { + if (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..70f8b9a449443 100644 --- a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts @@ -3,10 +3,15 @@ import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { hasOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function registerPreIntentWorkspaceWizard(): Promise { + if (hasOfflineLicense()) { + return false; + } + const firstUser = (await Users.getOldest({ projection: { name: 1, emails: 1 } })) as IUser | undefined; const email = firstUser?.emails?.find((address) => address)?.address; 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..5f1896d9ae1aa 100644 --- a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts @@ -141,7 +141,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..8d837843b06da 100644 --- a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts +++ b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts @@ -1,6 +1,7 @@ import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../logger/system'; import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken'; +import { hasOfflineLicense } from '../offlineLicense'; import { announcementSync } from './announcementSync'; import { legacySyncWorkspace } from './legacySyncWorkspace'; import { syncCloudData } from './syncCloudData'; @@ -12,6 +13,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 (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..28f4c705f4b25 100644 --- a/apps/meteor/server/lib/cloud/userLogout.ts +++ b/apps/meteor/server/lib/cloud/userLogout.ts @@ -1,6 +1,7 @@ import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { hasOfflineLicense } from './offlineLicense'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { userLoggedOut } from './userLoggedOut'; import { settings } from '../../settings'; @@ -9,7 +10,7 @@ import { SystemLogger } from '../logger/system'; export async function userLogout(userId: string): Promise { const { workspaceRegistered } = await retrieveRegistrationStatus(); - if (!workspaceRegistered) { + if (!workspaceRegistered || hasOfflineLicense()) { return ''; } 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..1408615053fd5 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,7 @@ 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(); + const shouldSendToCollector = shouldReportStatistics() && !License.hasOfflineLicense(); return tracerSpan('generateStatistics', {}, async () => { const last = await Statistics.findLast(); 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/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); From b1d75307a471ea26ff0f6402c60ab8c5e74ecf83 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Thu, 2 Jul 2026 14:30:54 -0300 Subject: [PATCH 02/10] proposal: offline license capability enforcement --- .../offline-license-capability-enforcement.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/proposals/offline-license-capability-enforcement.md diff --git a/docs/proposals/offline-license-capability-enforcement.md b/docs/proposals/offline-license-capability-enforcement.md new file mode 100644 index 0000000000000..e0d62fd364a0a --- /dev/null +++ b/docs/proposals/offline-license-capability-enforcement.md @@ -0,0 +1,167 @@ +# Proposal: Type-System Enforcement for Offline-License Cloud Guards + +## Status + +Draft + +## Problem + +Workspaces with an offline (air-gapped) license must never initiate outbound connections to Rocket.Chat-owned endpoints — Cloud/Fleet Command (`cloud.rocket.chat`), Marketplace, the usage collector, `releases.rocket.chat`, NPS, and the Push Gateway. For the customers this serves, the *attempt* itself is a compliance violation, regardless of whether it succeeds. + +Today this is enforced by runtime checks — `License.hasOfflineLicense()` guards scattered across ~15 files: + +- `apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts` (sync entry) +- `apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts` and `getWorkspaceAccessTokenWithScope.ts` (OAuth token funnel) +- `apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts`, `ee/server/apps/cron.ts`, `ee/server/apps/appRequestsCron.ts` +- `apps/meteor/app/statistics/server/functions/sendUsageReport.ts` +- `apps/meteor/app/version-check/server/functions/checkVersionUpdate.ts` +- `apps/meteor/app/push/server/push.ts` (`shouldUseGateway()` and the send loop) +- `apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts` +- `assertNotOfflineLicense()` calls in the interactive registration/OAuth/billing functions under `apps/meteor/app/cloud/server/functions/` + +These checks work, but they are a **side-condition the compiler knows nothing about**. Any new feature can `import { serverFetch } from '@rocket.chat/server-fetch'`, build a URL from `Cloud_Url` (or hardcode a domain), and ship a compliance violation that no type error, no test, and no reviewer checklist reliably catches. The guard has to be *remembered*, and knowledge of *which* endpoints require it lives only in developers' heads. + +## Proposed Solution + +Make the ability to contact a Rocket.Chat-owned endpoint a **capability**: a value of a branded type that can only be produced by a factory that performs the offline-license check. Every function that performs cloud I/O requires that value in its signature. New code physically cannot typecheck a cloud call without going through the guard — forgetting it becomes a compile error instead of a compliance incident. + +### The capability module + +One new module, `apps/meteor/app/cloud/server/cloudClient.ts`, becomes the single construction site: + +```ts +import { License } from '@rocket.chat/license'; +import { serverFetch, type ExtendedFetchOptions, type Response } from '@rocket.chat/server-fetch'; + +import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; +import { SystemLogger } from '../../../server/lib/logger/system'; + +declare const cloudConnectionBrand: unique symbol; // NOT exported — unforgeable outside this module + +/** + * Capability proving the offline-license check has been performed for this attempt. + * + * Acquire one per attempt, at the top of the operation. NEVER store a connection on a + * class field, module scope, or a retry/timer closure — the license can change at + * runtime, and a cached connection would keep a stale "online" verdict alive. + */ +export type CloudConnection = { + readonly [cloudConnectionBrand]: true; + fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): Promise; +}; + +// Consumers get their fetch types from here, not from @rocket.chat/server-fetch. +export type { ExtendedFetchOptions, Response }; + +const createConnection = (): CloudConnection => + ({ + fetch: (input, options, allowSelfSignedCerts) => serverFetch(input, options, allowSelfSignedCerts), + }) as CloudConnection; // the only cast, inside the only allowed module + +/** Background jobs: `null` means offline — skip silently. */ +export function tryGetCloudConnection(context?: string): CloudConnection | null { + if (License.hasOfflineLicense()) { + SystemLogger.debug({ msg: 'Skipping cloud communication: workspace has an offline license', context }); + return null; + } + return createConnection(); +} + +/** Interactive flows: throws the typed error surfaced to the caller/UI. */ +export function getCloudConnectionOrThrow(message?: string): CloudConnection { + if (License.hasOfflineLicense()) { + throw new CloudOfflineLicenseError( + message ?? 'Cloud connectivity is disabled by the offline license applied to this workspace', + ); + } + return createConnection(); +} +``` + +Design notes: + +- **The factories are synchronous.** `License.hasOfflineLicense()` is a sync read of the in-memory license, so per-attempt acquisition is free — removing any temptation to cache the connection. +- **The two factories mirror the existing runtime helper pair** in `apps/meteor/app/cloud/server/functions/offlineLicense.ts` (`hasOfflineLicense` for silent background skips, `assertNotOfflineLicense` for interactive throws), so migration is mechanical and behavior-preserving: same silent skips, same `CloudOfflineLicenseError`, same single informational log at license application. +- **The `message` parameter** preserves context-specific error text (e.g. the Marketplace-specific message currently thrown by `MarketplaceAPIClient.fetch`). +- **The workspace access token is deliberately NOT bundled into the capability.** The OAuth token exchange is itself a guarded fetch (`getWorkspaceAccessTokenWithScope.ts` posts to `${Cloud_Url}/api/oauth/token`), several guarded endpoints are unauthenticated (releases, collector, pre-registration), scopes vary per caller, and push has its own authorization flow. Instead, the token funnel migrates to the capability internally — which transitively guards its ~30 consumers exactly as the runtime checks do today, preserving the `''`-token-when-offline contract. + +### Signatures carry the requirement + +The payoff is in function signatures. Helpers that perform cloud I/O take the capability as a parameter: + +```ts +// before — nothing in the signature says this talks to Rocket.Chat Cloud: +export async function fetchWorkspaceSyncPayload({ token, data }: { ... }): Promise<...> + +// after — cloud I/O is visible, and the compiler forces callers through the guard: +export async function fetchWorkspaceSyncPayload( + connection: CloudConnection, + { token, data }: { ... }, +): Promise<...> +``` + +A developer adding a new cloud endpoint follows the types: they need a `CloudConnection`, the only way to get one is a factory whose name and JSDoc explain the offline rule, and the choice between the two factories forces them to *decide* the offline behavior (skip vs. throw) instead of ignoring it. + +### Migration patterns + +Roughly 27 files fetch Rocket.Chat-owned endpoints. They fall into four shapes: + +**A — Interactive flows** (registration, OAuth, checkout, license removal, announcement interactions: `startRegisterWorkspace.ts`, `connectWorkspace.ts`, `finishOAuthAuthorization.ts`, `getCheckoutUrl.ts`, `cloudAnnouncements.module.ts`, …). The existing `assertNotOfflineLicense()` line and the raw fetch collapse into: + +```ts +const connection = getCloudConnectionOrThrow(); +const response = await connection.fetch(`${cloudUrl}/api/v2/register/workspace`, { ... }); +``` + +**B — Background jobs** (`syncWorkspace/index.ts`, `sendUsageReport.ts`, the NPS pair, `getNewUpdates.ts`). Null means skip, keeping existing side effects: + +```ts +const connection = tryGetCloudConnection('syncWorkspace'); +if (!connection) { + await getCachedSupportedVersionsToken.reset(); // still refreshed locally from the license/build + return; +} +await announcementSync(connection); +await syncCloudData(connection); +``` + +Inner helpers only reachable from a guarded entry point (`announcementSync`, `fetchWorkspaceSyncPayload`, `legacySyncWorkspace`) take `connection` as a parameter rather than re-acquiring — acceptable within one logical operation with no timers between acquisition and use. + +**C — Class-based client** (`MarketplaceAPIClient.ts`). The strategy pattern (real vs. mock fetch for tests) is preserved; the strategy signature gains a leading `connection` parameter, acquired per `fetch()` call and never stored on the instance. All `orchestrator.getMarketplaceClient().fetch(...)` consumers keep their call sites unchanged. + +**D — Retry closures** (push gateway `sendGatewayPush` in `apps/meteor/app/push/server/push.ts`, the `supportedVersionsToken` retry chain). The rule: **acquire at the top of each attempt; the timer closure holds no connection.** + +```ts +private async sendGatewayPush(gateway, service, token, notification, retryOptions): Promise { + const connection = tryGetCloudConnection('push-gateway'); + if (!connection) { + return; // license went offline between scheduling and this attempt — drop, incl. pending retries + } + // ... + setTimeout(() => this.sendGatewayPush(...), ms); // next attempt re-checks +} +``` + +This is strictly better than the current runtime checks: today a retry chain started while online keeps fetching if the license flips to offline mid-flight; per-attempt acquisition stops it. + +**Carve-out**: the two fetches in `ee/server/apps/communication/rest.ts` that download an app package from an **admin-supplied URL** (install-from-URL) are not Rocket.Chat endpoints and must keep working under offline licenses — installing private apps from a URL or file is core to air-gapped operation. They stay on raw `serverFetch`. + +### What stays as-is + +The `offlineLicense.ts` helpers remain for **behavior** gates that don't themselves fetch: `shouldUseGateway()` in push, the marketplace cron-body early returns, and the fail-fast assert in `getOAuthAuthorizationUrl` (which only builds a URL). These gate control flow, not I/O, and don't need a capability. + +### Migration ordering and test impact + +1. `cloudClient.ts` + unit tests (no behavior change). +2. Token funnel (`getWorkspaceAccessTokenWithScope.ts`) — transitively guards all token consumers. +3. The 15 other `app/cloud/server/functions/**` files. +4. Marketplace (`MarketplaceAPIClient.ts` strategy, `appRequestNotifyUsers.ts`). +5. Telemetry, NPS, version-check, announcements, push gateway. + +Every step is independently green. Test impact is confined to proxyquire maps: `sendUsageReport.spec.ts` and `push.spec.ts` swap their `@rocket.chat/server-fetch` stubs for a `cloudClient` stub — and the offline assertions get *stronger*, since specs can now assert the connection was never requested at all. The `ee/packages/license` jest suite is untouched. + +## Limitations + +TypeScript cannot forbid an import. A brand-new file can still `import { serverFetch }` directly and hardcode `cloud.rocket.chat`, bypassing the capability entirely; `{} as CloudConnection` likewise defeats the brand (though it is greppable and glaring in review). What the type system guarantees is narrower but valuable: **all code routed through the typed cloud helpers cannot skip the guard**, the offline decision (skip vs. throw) is forced explicitly at every new call site, and violations shrink to two obvious review signals — a raw `serverFetch` import next to a Rocket.Chat domain, or a forged cast. + +A directory-scoped lint rule (`no-restricted-imports` on `@rocket.chat/server-fetch` with `cloudClient.ts` as the sole exemption) or a CI grep could close the remaining hole; both were considered and deliberately left out of this proposal in favor of a types-only approach. From 8aa5a3a9c41c25014544923dc1bcd6725d07fe35 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Mon, 6 Jul 2026 10:30:38 -0300 Subject: [PATCH 03/10] fix: race before license is applied causes egress --- apps/meteor/ee/server/lib/license/startup.ts | 3 ++- .../server/lib/statistics/functions/sendUsageReport.ts | 8 +++++--- apps/meteor/server/main.ts | 7 +++++++ apps/meteor/server/startup/index.ts | 5 +++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/meteor/ee/server/lib/license/startup.ts b/apps/meteor/ee/server/lib/license/startup.ts index 0ffed6d64fdc1..b976b6dc59254 100644 --- a/apps/meteor/ee/server/lib/license/startup.ts +++ b/apps/meteor/ee/server/lib/license/startup.ts @@ -21,7 +21,8 @@ const logOfflineLicense = (() => { } if (!logged) { - SystemLogger.info( + // 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', ); logged = true; diff --git a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts index 1408615053fd5..374a560bfb9e2 100644 --- a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts +++ b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts @@ -41,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() && !License.hasOfflineLicense(); + // 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(); @@ -60,7 +62,7 @@ export async function sendUsageReport(logger: Logger): Promise startCronJobs()); + await startupApp(); await startRestAPI(); 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()) { From 7082ba1a67a2f8de52dabbfc1ae0c0fb755d73d7 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Tue, 7 Jul 2026 11:08:15 -0300 Subject: [PATCH 04/10] fix: gravatar egress with offline license --- .../server/lib/users/getAvatarSuggestionForUser.ts | 11 ++++++++++- apps/meteor/server/lib/users/saveUser/saveNewUser.ts | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts b/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts index 32d522c2f7d49..ea322ee2e1cc6 100644 --- a/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts +++ b/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts @@ -4,6 +4,7 @@ import Gravatar from 'gravatar'; import { check } from 'meteor/check'; import { ServiceConfiguration } from 'meteor/service-configuration'; +import { hasOfflineLicense } from '../cloud/offlineLicense'; import { settings } from '../../settings'; const avatarProviders = { @@ -101,7 +102,15 @@ const avatarProviders = { }, emails(user: IUser) { - const avatars = []; + const avatars: { service: string; url: string }[] = []; + + // Offline (air-gapped) licenses suppress Gravatar lookups: every suggested + // URL is fetched server-side below, and gravatar.com is not admin-configured + // infrastructure (unlike OAuth provider avatars, which keep working). + if (hasOfflineLicense()) { + return avatars; + } + if (user.emails && user.emails.length > 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..088f4f99eb189 100644 --- a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts +++ b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts @@ -5,6 +5,7 @@ import { Accounts } from 'meteor/accounts-base'; import { notifyOnUserChangeById } from '../../notifyListener'; import { validateEmailDomain } from '../../validateEmailDomain'; +import { hasOfflineLicense } from '../../cloud/offlineLicense'; import { setUserAvatar } from '../setUserAvatar'; import { handleBio } from './handleBio'; import { handleNickname } from './handleNickname'; @@ -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 && !hasOfflineLicense()) { const gravatarUrl = Gravatar.url(userData.email, { default: '404', size: '200', From c9478fcf5cfbba82da01ec893d11c84507958d39 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Jul 2026 09:23:35 -0300 Subject: [PATCH 05/10] chore(docs): update proposal --- .../offline-license-capability-enforcement.md | 189 +++++++----------- 1 file changed, 67 insertions(+), 122 deletions(-) diff --git a/docs/proposals/offline-license-capability-enforcement.md b/docs/proposals/offline-license-capability-enforcement.md index e0d62fd364a0a..1cde53385fe39 100644 --- a/docs/proposals/offline-license-capability-enforcement.md +++ b/docs/proposals/offline-license-capability-enforcement.md @@ -1,167 +1,112 @@ -# Proposal: Type-System Enforcement for Offline-License Cloud Guards +# Proposal: License Capability Enforcement via Branded Proofs ## Status -Draft +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 -Workspaces with an offline (air-gapped) license must never initiate outbound connections to Rocket.Chat-owned endpoints — Cloud/Fleet Command (`cloud.rocket.chat`), Marketplace, the usage collector, `releases.rocket.chat`, NPS, and the Push Gateway. For the customers this serves, the *attempt* itself is a compliance violation, regardless of whether it succeeds. +License enforcement today is a runtime side-condition the compiler knows nothing about, in two distinct places: -Today this is enforced by runtime checks — `License.hasOfflineLicense()` guards scattered across ~15 files: +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). -- `apps/meteor/app/cloud/server/functions/syncWorkspace/index.ts` (sync entry) -- `apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts` and `getWorkspaceAccessTokenWithScope.ts` (OAuth token funnel) -- `apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts`, `ee/server/apps/cron.ts`, `ee/server/apps/appRequestsCron.ts` -- `apps/meteor/app/statistics/server/functions/sendUsageReport.ts` -- `apps/meteor/app/version-check/server/functions/checkVersionUpdate.ts` -- `apps/meteor/app/push/server/push.ts` (`shouldUseGateway()` and the send loop) -- `apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts` -- `assertNotOfflineLicense()` calls in the interactive registration/OAuth/billing functions under `apps/meteor/app/cloud/server/functions/` - -These checks work, but they are a **side-condition the compiler knows nothing about**. Any new feature can `import { serverFetch } from '@rocket.chat/server-fetch'`, build a URL from `Cloud_Url` (or hardcode a domain), and ship a compliance violation that no type error, no test, and no reviewer checklist reliably catches. The guard has to be *remembered*, and knowledge of *which* endpoints require it lives only in developers' heads. +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 the ability to contact a Rocket.Chat-owned endpoint a **capability**: a value of a branded type that can only be produced by a factory that performs the offline-license check. Every function that performs cloud I/O requires that value in its signature. New code physically cannot typecheck a cloud call without going through the guard — forgetting it becomes a compile error instead of a compliance incident. +Make every license check return an unforgeable **proof value**, and make guarded code demand that proof in its signature. The pattern has three parts. -### The capability module +### 1. The lock — branded proof types -One new module, `apps/meteor/app/cloud/server/cloudClient.ts`, becomes the single construction site: +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 -import { License } from '@rocket.chat/license'; -import { serverFetch, type ExtendedFetchOptions, type Response } from '@rocket.chat/server-fetch'; - -import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; -import { SystemLogger } from '../../../server/lib/logger/system'; - -declare const cloudConnectionBrand: unique symbol; // NOT exported — unforgeable outside this module - -/** - * Capability proving the offline-license check has been performed for this attempt. - * - * Acquire one per attempt, at the top of the operation. NEVER store a connection on a - * class field, module scope, or a retry/timer closure — the license can change at - * runtime, and a cached connection would keep a stale "online" verdict alive. - */ -export type CloudConnection = { - readonly [cloudConnectionBrand]: true; - fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): Promise; -}; - -// Consumers get their fetch types from here, not from @rocket.chat/server-fetch. -export type { ExtendedFetchOptions, Response }; - -const createConnection = (): CloudConnection => - ({ - fetch: (input, options, allowSelfSignedCerts) => serverFetch(input, options, allowSelfSignedCerts), - }) as CloudConnection; // the only cast, inside the only allowed module - -/** Background jobs: `null` means offline — skip silently. */ -export function tryGetCloudConnection(context?: string): CloudConnection | null { - if (License.hasOfflineLicense()) { - SystemLogger.debug({ msg: 'Skipping cloud communication: workspace has an offline license', context }); - return null; - } - return createConnection(); -} - -/** Interactive flows: throws the typed error surfaced to the caller/UI. */ -export function getCloudConnectionOrThrow(message?: string): CloudConnection { - if (License.hasOfflineLicense()) { - throw new CloudOfflineLicenseError( - message ?? 'Cloud connectivity is disabled by the offline license applied to this workspace', - ); - } - return createConnection(); -} -``` - -Design notes: +// 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; -- **The factories are synchronous.** `License.hasOfflineLicense()` is a sync read of the in-memory license, so per-attempt acquisition is free — removing any temptation to cache the connection. -- **The two factories mirror the existing runtime helper pair** in `apps/meteor/app/cloud/server/functions/offlineLicense.ts` (`hasOfflineLicense` for silent background skips, `assertNotOfflineLicense` for interactive throws), so migration is mechanical and behavior-preserving: same silent skips, same `CloudOfflineLicenseError`, same single informational log at license application. -- **The `message` parameter** preserves context-specific error text (e.g. the Marketplace-specific message currently thrown by `MarketplaceAPIClient.fetch`). -- **The workspace access token is deliberately NOT bundled into the capability.** The OAuth token exchange is itself a guarded fetch (`getWorkspaceAccessTokenWithScope.ts` posts to `${Cloud_Url}/api/oauth/token`), several guarded endpoints are unauthenticated (releases, collector, pre-registration), scopes vary per caller, and push has its own authorization flow. Instead, the token funnel migrates to the capability internally — which transitively guards its ~30 consumers exactly as the runtime checks do today, preserving the `''`-token-when-offline contract. +// ee/packages/license/src/proofs.ts (new) +declare const LicenseAuthorized: unique symbol; // NOT exported — unforgeable outside the package -### Signatures carry the requirement - -The payoff is in function signatures. Helpers that perform cloud I/O take the capability as a parameter: +/** Proof that the current license grants module M. */ +export type ModuleProof = { + readonly [LicenseAuthorized]: M; +}; -```ts -// before — nothing in the signature says this talks to Rocket.Chat Cloud: -export async function fetchWorkspaceSyncPayload({ token, data }: { ... }): Promise<...> - -// after — cloud I/O is visible, and the compiler forces callers through the guard: -export async function fetchWorkspaceSyncPayload( - connection: CloudConnection, - { token, data }: { ... }, -): Promise<...> +/** 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'; +}; ``` -A developer adding a new cloud endpoint follows the types: they need a `CloudConnection`, the only way to get one is a factory whose name and JSDoc explain the offline rule, and the choice between the two factories forces them to *decide* the offline behavior (skip vs. throw) instead of ignoring it. - -### Migration patterns +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. -Roughly 27 files fetch Rocket.Chat-owned endpoints. They fall into four shapes: +### 2. The keymaker — the license package owns construction -**A — Interactive flows** (registration, OAuth, checkout, license removal, announcement interactions: `startRegisterWorkspace.ts`, `connectWorkspace.ts`, `finishOAuthAuthorization.ts`, `getCheckoutUrl.ts`, `cloudAnnouncements.module.ts`, …). The existing `assertNotOfflineLicense()` line and the raw fetch collapse into: +The only casts live inside `ee/packages/license`, next to the state they attest to: ```ts -const connection = getCloudConnectionOrThrow(); -const response = await connection.fetch(`${cloudUrl}/api/v2/register/workspace`, { ... }); -``` - -**B — Background jobs** (`syncWorkspace/index.ts`, `sendUsageReport.ts`, the NPS pair, `getNewUpdates.ts`). Null means skip, keeping existing side effects: +// on LicenseManager — additive API; the existing boolean hasModule() stays +public proveModule(module: M): ModuleProof | undefined { + return this.hasModule(module) ? (PROOF as ModuleProof) : undefined; +} -```ts -const connection = tryGetCloudConnection('syncWorkspace'); -if (!connection) { - await getCachedSupportedVersionsToken.reset(); // still refreshed locally from the license/build - return; +public proveCloudEgress(): CloudEgressProof | undefined { + return this.hasOfflineLicense() ? undefined : (PROOF as CloudEgressProof); } -await announcementSync(connection); -await syncCloudData(connection); ``` -Inner helpers only reachable from a guarded entry point (`announcementSync`, `fetchWorkspaceSyncPayload`, `legacySyncWorkspace`) take `connection` as a parameter rather than re-acquiring — acceptable within one logical operation with no timers between acquisition and use. +Design decisions: -**C — Class-based client** (`MarketplaceAPIClient.ts`). The strategy pattern (real vs. mock fetch for tests) is preserved; the strategy signature gains a leading `connection` parameter, acquired per `fetch()` call and never stored on the instance. All `orchestrator.getMarketplaceClient().fetch(...)` consumers keep their call sites unchanged. +- **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. -**D — Retry closures** (push gateway `sendGatewayPush` in `apps/meteor/app/push/server/push.ts`, the `supportedVersionsToken` retry chain). The rule: **acquire at the top of each attempt; the timer closure holds no connection.** +### 3. The guard — signatures demand proofs ```ts -private async sendGatewayPush(gateway, service, token, notification, retryOptions): Promise { - const connection = tryGetCloudConnection('push-gateway'); - if (!connection) { - return; // license went offline between scheduling and this attempt — drop, incl. pending retries - } - // ... - setTimeout(() => this.sendGatewayPush(...), ms); // next attempt re-checks +// 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; ``` -This is strictly better than the current runtime checks: today a retry chain started while online keeps fetching if the license flips to offline mid-flight; per-attempt acquisition stops it. +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. + +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 -**Carve-out**: the two fetches in `ee/server/apps/communication/rest.ts` that download an app package from an **admin-supplied URL** (install-from-URL) are not Rocket.Chat endpoints and must keep working under offline licenses — installing private apps from a URL or file is core to air-gapped operation. They stay on raw `serverFetch`. +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: -### What stays as-is +- 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. -The `offlineLicense.ts` helpers remain for **behavior** gates that don't themselves fetch: `shouldUseGateway()` in push, the marketplace cron-body early returns, and the fail-fast assert in `getOAuthAuthorizationUrl` (which only builds a URL). These gate control flow, not I/O, and don't need a capability. +Rules, to be stated in the proofs' JSDoc and enforced in review: -### Migration ordering and test impact +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`. -1. `cloudClient.ts` + unit tests (no behavior change). -2. Token funnel (`getWorkspaceAccessTokenWithScope.ts`) — transitively guards all token consumers. -3. The 15 other `app/cloud/server/functions/**` files. -4. Marketplace (`MarketplaceAPIClient.ts` strategy, `appRequestNotifyUsers.ts`). -5. Telemetry, NPS, version-check, announcements, push gateway. +## Migration -Every step is independently green. Test impact is confined to proxyquire maps: `sendUsageReport.spec.ts` and `push.spec.ts` swap their `@rocket.chat/server-fetch` stubs for a `cloudClient` stub — and the offline assertions get *stronger*, since specs can now assert the connection was never requested at all. The `ee/packages/license` jest suite is untouched. +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 brand-new file can still `import { serverFetch }` directly and hardcode `cloud.rocket.chat`, bypassing the capability entirely; `{} as CloudConnection` likewise defeats the brand (though it is greppable and glaring in review). What the type system guarantees is narrower but valuable: **all code routed through the typed cloud helpers cannot skip the guard**, the offline decision (skip vs. throw) is forced explicitly at every new call site, and violations shrink to two obvious review signals — a raw `serverFetch` import next to a Rocket.Chat domain, or a forged cast. +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` as the sole exemption) or a CI grep could close the remaining hole; both were considered and deliberately left out of this proposal in favor of a types-only approach. +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. From d08b9d1fc3dc9d861d4fd449681c29869e0125ec Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Jul 2026 09:24:16 -0300 Subject: [PATCH 06/10] chore: add changeset --- .changeset/offline-license-no-egress.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/offline-license-no-egress.md diff --git a/.changeset/offline-license-no-egress.md b/.changeset/offline-license-no-egress.md new file mode 100644 index 0000000000000..968aec57722c7 --- /dev/null +++ b/.changeset/offline-license-no-egress.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/license': minor +--- + +Added support for the `offline` license flag issued by Fleet Command. When the applied license carries this flag, the workspace no longer initiates any outbound connection to Rocket.Chat Cloud services or the Rocket.Chat Push Gateway: workspace sync (license sync and cloud announcements), supported-versions and version-update checks, marketplace requests (admin UI and scheduled jobs), usage/telemetry reports, NPS surveys, cloud OAuth token requests, and push gateway sends are all suppressed at the source. Default Gravatar avatar fetches and Gravatar avatar suggestions are suppressed as well (admin-configured OAuth provider avatars keep working). Interactive cloud actions (registration, cloud login, billing) fail fast with a clear error instead of attempting to connect. A single informational log entry is emitted when offline mode is detected. Cron jobs now start only after the license is applied, so scheduled jobs honor the flag from their very first run. Workspaces with standard licenses are unaffected. From c18dc41119adc0e958dac2bd7234b3a1175b0485 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Jul 2026 11:44:42 -0300 Subject: [PATCH 07/10] fix: review comments --- .../ee/server/apps/communication/rest.ts | 12 +++- .../apps/marketplace/fetchMarketplaceApps.ts | 7 ++ .../marketplace/fetchMarketplaceCategories.ts | 7 ++ apps/meteor/ee/server/lib/license/startup.ts | 4 ++ .../cloud/registerPreIntentWorkspaceWizard.ts | 6 ++ .../supportedVersionsToken.ts | 6 ++ apps/meteor/server/main.ts | 7 +- .../marketplace/MarketplaceAPIClient.spec.ts | 57 +++++++++++++++++ .../registerPreIntentWorkspaceWizard.spec.ts | 64 +++++++++++++++++++ .../offline-license-capability-enforcement.md | 2 + 10 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts create mode 100644 apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts 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/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 b976b6dc59254..a27cd8928aab1 100644 --- a/apps/meteor/ee/server/lib/license/startup.ts +++ b/apps/meteor/ee/server/lib/license/startup.ts @@ -146,6 +146,10 @@ export const startLicense = async () => { logOfflineLicense(); License.onValidateLicense(logOfflineLicense); + // Also run on invalidate/remove so the once-per-activation flag resets and a + // subsequently re-applied offline license is logged again. + License.onInvalidateLicense(logOfflineLicense); + License.onRemoveLicense(logOfflineLicense); // 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/server/lib/cloud/registerPreIntentWorkspaceWizard.ts b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts index 70f8b9a449443..a28fd8aedf9be 100644 --- a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts @@ -21,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 (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/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts index 5f1896d9ae1aa..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, diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 55ac0168ad03d..195f7039e79ab 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -9,6 +9,7 @@ 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'; @@ -33,7 +34,11 @@ await startRocketChat(); // Cron jobs start only after startRocketChat() has applied the license, so jobs // that contact Rocket.Chat Cloud on boot (e.g. the usage report) respect the // offline license flag from their very first run. -setImmediate(() => startCronJobs()); +setImmediate(() => { + startCronJobs().catch((err) => { + SystemLogger.error({ msg: 'Failed to start cron jobs', err }); + }); +}); await startupApp(); await startRestAPI(); 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..90c3110dc69fb --- /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/models': { Users: { getOldest: getOldestStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './buildRegistrationData': { buildWorkspaceRegistrationData: buildRegistrationDataStub }, + './offlineLicense': { hasOfflineLicense: hasOfflineLicenseStub }, + '../../../app/settings/server': { 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/docs/proposals/offline-license-capability-enforcement.md b/docs/proposals/offline-license-capability-enforcement.md index 1cde53385fe39..99e6d624eb176 100644 --- a/docs/proposals/offline-license-capability-enforcement.md +++ b/docs/proposals/offline-license-capability-enforcement.md @@ -84,6 +84,8 @@ export function cloudFetch(proof: CloudEgressProof, input: string, options?: Ext 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 From 641e5ef944192541de3f2a2ab53416dc67a7e6c6 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Wed, 15 Jul 2026 11:51:34 -0300 Subject: [PATCH 08/10] fix: sensitive cloud tokens not cleared on user logout in offline workspaces --- apps/meteor/server/lib/cloud/userLogout.ts | 8 ++- .../unit/server/lib/cloud/userLogout.spec.ts | 72 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts diff --git a/apps/meteor/server/lib/cloud/userLogout.ts b/apps/meteor/server/lib/cloud/userLogout.ts index 28f4c705f4b25..7dd6aa099c2b6 100644 --- a/apps/meteor/server/lib/cloud/userLogout.ts +++ b/apps/meteor/server/lib/cloud/userLogout.ts @@ -10,7 +10,7 @@ import { SystemLogger } from '../logger/system'; export async function userLogout(userId: string): Promise { const { workspaceRegistered } = await retrieveRegistrationStatus(); - if (!workspaceRegistered || hasOfflineLicense()) { + if (!workspaceRegistered) { return ''; } @@ -18,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 (hasOfflineLicense()) { + return userLoggedOut(userId); + } + const user = await Users.findOneById(userId); if (user?.services?.cloud?.refreshToken) { 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..980f7927e77d2 --- /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/models': { Users: { findOneById: findOneByIdStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './offlineLicense': { hasOfflineLicense: hasOfflineLicenseStub }, + './retrieveRegistrationStatus': { retrieveRegistrationStatus: retrieveRegistrationStatusStub }, + './userLoggedOut': { userLoggedOut: userLoggedOutStub }, + '../../../app/settings/server': { 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; + }); +}); From f4d0df1686452f613bb38859b5cca8dbadecaaaa Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 16 Jul 2026 08:57:37 -0600 Subject: [PATCH 09/10] fix: address review comments on offline license no-egress (#41419) --- apps/meteor/ee/server/lib/license/startup.ts | 32 +++++-------------- .../lib/cloud/getWorkspaceAccessToken.ts | 4 +-- .../cloud/getWorkspaceAccessTokenWithScope.ts | 4 +-- .../meteor/server/lib/cloud/offlineLicense.ts | 16 ++-------- .../cloud/registerPreIntentWorkspaceWizard.ts | 6 ++-- .../server/lib/cloud/syncWorkspace/index.ts | 5 +-- apps/meteor/server/lib/cloud/userLogout.ts | 4 +-- .../lib/users/getAvatarSuggestionForUser.ts | 4 +-- .../server/lib/users/saveUser/saveNewUser.ts | 4 +-- apps/meteor/server/main.ts | 3 -- .../registerPreIntentWorkspaceWizard.spec.ts | 4 +-- .../unit/server/lib/cloud/userLogout.spec.ts | 4 +-- 12 files changed, 30 insertions(+), 60 deletions(-) diff --git a/apps/meteor/ee/server/lib/license/startup.ts b/apps/meteor/ee/server/lib/license/startup.ts index a27cd8928aab1..75f7a4993de66 100644 --- a/apps/meteor/ee/server/lib/license/startup.ts +++ b/apps/meteor/ee/server/lib/license/startup.ts @@ -12,24 +12,6 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener'; import { settings } from '../../../../server/settings'; -const logOfflineLicense = (() => { - let logged = false; - return () => { - if (!License.hasOfflineLicense()) { - logged = false; - return; - } - - if (!logged) { - // 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', - ); - logged = true; - } - }; -})(); - export const startLicense = async () => { settings.watch('Site_Url', (value) => { if (value) { @@ -144,12 +126,14 @@ export const startLicense = async () => { } } - logOfflineLicense(); - License.onValidateLicense(logOfflineLicense); - // Also run on invalidate/remove so the once-per-activation flag resets and a - // subsequently re-applied offline license is logged again. - License.onInvalidateLicense(logOfflineLicense); - License.onRemoveLicense(logOfflineLicense); + 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/server/lib/cloud/getWorkspaceAccessToken.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts index 3ccffe7cb62af..e49913bd56f77 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts @@ -1,9 +1,9 @@ import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { WorkspaceCredentials } from '@rocket.chat/models'; import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; import { workspaceScopes } from './oauthScopes'; -import { hasOfflineLicense } from './offlineLicense'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { SystemLogger } from '../logger/system'; @@ -27,7 +27,7 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return ''; } - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { return ''; } diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts index a102548290172..bf8693eea7278 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts @@ -1,9 +1,9 @@ +import { License } from '@rocket.chat/license'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getRedirectUri } from './getRedirectUri'; import { CloudWorkspaceAccessTokenError } from './getWorkspaceAccessToken'; import { workspaceScopes } from './oauthScopes'; -import { hasOfflineLicense } from './offlineLicense'; import { removeWorkspaceRegistrationInfo } from './removeWorkspaceRegistrationInfo'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { settings } from '../../settings'; @@ -32,7 +32,7 @@ export async function getWorkspaceAccessTokenWithScope({ return tokenResponse; } - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { return tokenResponse; } diff --git a/apps/meteor/server/lib/cloud/offlineLicense.ts b/apps/meteor/server/lib/cloud/offlineLicense.ts index 2abbbad561566..7a3b5f52b01a4 100644 --- a/apps/meteor/server/lib/cloud/offlineLicense.ts +++ b/apps/meteor/server/lib/cloud/offlineLicense.ts @@ -2,24 +2,12 @@ import { License } from '@rocket.chat/license'; import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; -/** - * Whether the applied license was issued for offline (air-gapped) workspaces. - * - * When this returns true the workspace must never initiate outbound connections - * to Rocket.Chat Cloud services (registration, sync, marketplace, telemetry) or - * to the Rocket.Chat Push Gateway. Calls must be suppressed at the source — not - * by relying on the requests failing. - */ -export function hasOfflineLicense(): boolean { - return License.hasOfflineLicense(); -} - /** * Guard for interactive cloud flows (registration, OAuth, billing). Background - * jobs should instead skip silently via {@link hasOfflineLicense}. + * jobs should instead skip silently by checking {@link License.hasOfflineLicense}. */ export function assertNotOfflineLicense(): void { - if (hasOfflineLicense()) { + 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 a28fd8aedf9be..b85311fd731d2 100644 --- a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts @@ -1,14 +1,14 @@ 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'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; -import { hasOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function registerPreIntentWorkspaceWizard(): Promise { - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { return false; } @@ -23,7 +23,7 @@ export async function registerPreIntentWorkspaceWizard(): Promise { // Re-validated at dispatch time: an offline license applied while the // registration data was being built must still suppress the request. - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { return false; } diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts index 8d837843b06da..aa76435e2b18e 100644 --- a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts +++ b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts @@ -1,7 +1,8 @@ +import { License } from '@rocket.chat/license'; + import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../logger/system'; import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken'; -import { hasOfflineLicense } from '../offlineLicense'; import { announcementSync } from './announcementSync'; import { legacySyncWorkspace } from './legacySyncWorkspace'; import { syncCloudData } from './syncCloudData'; @@ -13,7 +14,7 @@ import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/suppo * @throws {Error} - If there is an unexpected error during sync like a network error */ export async function syncWorkspace() { - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { SystemLogger.debug({ msg: 'Skipping cloud sync: workspace has an offline license', function: 'syncWorkspace' }); await getCachedSupportedVersionsToken.reset(); return; diff --git a/apps/meteor/server/lib/cloud/userLogout.ts b/apps/meteor/server/lib/cloud/userLogout.ts index 7dd6aa099c2b6..ed2f0bee6d58c 100644 --- a/apps/meteor/server/lib/cloud/userLogout.ts +++ b/apps/meteor/server/lib/cloud/userLogout.ts @@ -1,7 +1,7 @@ +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import { hasOfflineLicense } from './offlineLicense'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { userLoggedOut } from './userLoggedOut'; import { settings } from '../../settings'; @@ -20,7 +20,7 @@ export async function userLogout(userId: string): Promise { // Offline (air-gapped) licenses forbid the outbound token-revocation call, but // the local cloud credentials must still be destroyed on logout. - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { return userLoggedOut(userId); } diff --git a/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts b/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts index ea322ee2e1cc6..25298098e97c3 100644 --- a/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts +++ b/apps/meteor/server/lib/users/getAvatarSuggestionForUser.ts @@ -1,10 +1,10 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import Gravatar from 'gravatar'; import { check } from 'meteor/check'; import { ServiceConfiguration } from 'meteor/service-configuration'; -import { hasOfflineLicense } from '../cloud/offlineLicense'; import { settings } from '../../settings'; const avatarProviders = { @@ -107,7 +107,7 @@ const avatarProviders = { // Offline (air-gapped) licenses suppress Gravatar lookups: every suggested // URL is fetched server-side below, and gravatar.com is not admin-configured // infrastructure (unlike OAuth provider avatars, which keep working). - if (hasOfflineLicense()) { + if (License.hasOfflineLicense()) { return avatars; } diff --git a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts index 088f4f99eb189..367a7bf8dd3c8 100644 --- a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts +++ b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts @@ -1,11 +1,11 @@ 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'; import { notifyOnUserChangeById } from '../../notifyListener'; import { validateEmailDomain } from '../../validateEmailDomain'; -import { hasOfflineLicense } from '../../cloud/offlineLicense'; import { setUserAvatar } from '../setUserAvatar'; import { handleBio } from './handleBio'; import { handleNickname } from './handleNickname'; @@ -73,7 +73,7 @@ export const saveNewUser = async function (userData: SaveUserData, sendPassword: // 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 && !hasOfflineLicense()) { + 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 195f7039e79ab..70e26cc4d0881 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -31,9 +31,6 @@ await Promise.all([configureServer(settings), registerServices(), startup()]); await startRocketChat(); -// Cron jobs start only after startRocketChat() has applied the license, so jobs -// that contact Rocket.Chat Cloud on boot (e.g. the usage report) respect the -// offline license flag from their very first run. setImmediate(() => { startCronJobs().catch((err) => { SystemLogger.error({ msg: 'Failed to start cron jobs', err }); diff --git a/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts index 90c3110dc69fb..644bc279468e7 100644 --- a/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts +++ b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts @@ -11,11 +11,11 @@ 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 }, - './offlineLicense': { hasOfflineLicense: hasOfflineLicenseStub }, - '../../../app/settings/server': { settings: { get: sinon.stub().returns('https://cloud.rocket.chat') } }, + '../../settings': { settings: { get: sinon.stub().returns('https://cloud.rocket.chat') } }, '../logger/system': { SystemLogger: { error: sinon.stub() } }, }); diff --git a/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts index 980f7927e77d2..e5cd54e72722d 100644 --- a/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts +++ b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts @@ -11,12 +11,12 @@ 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 }, - './offlineLicense': { hasOfflineLicense: hasOfflineLicenseStub }, './retrieveRegistrationStatus': { retrieveRegistrationStatus: retrieveRegistrationStatusStub }, './userLoggedOut': { userLoggedOut: userLoggedOutStub }, - '../../../app/settings/server': { settings: { get: settingsGetStub } }, + '../../settings': { settings: { get: settingsGetStub } }, '../logger/system': { SystemLogger: { error: sinon.stub() } }, }); From 2685a1c3f4927458593c171fe94091e3089a2c70 Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Jul 2026 09:00:14 -0300 Subject: [PATCH 10/10] chore: shorten changeset --- .changeset/offline-license-no-egress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/offline-license-no-egress.md b/.changeset/offline-license-no-egress.md index 968aec57722c7..43b8259ea417f 100644 --- a/.changeset/offline-license-no-egress.md +++ b/.changeset/offline-license-no-egress.md @@ -3,4 +3,4 @@ '@rocket.chat/license': minor --- -Added support for the `offline` license flag issued by Fleet Command. When the applied license carries this flag, the workspace no longer initiates any outbound connection to Rocket.Chat Cloud services or the Rocket.Chat Push Gateway: workspace sync (license sync and cloud announcements), supported-versions and version-update checks, marketplace requests (admin UI and scheduled jobs), usage/telemetry reports, NPS surveys, cloud OAuth token requests, and push gateway sends are all suppressed at the source. Default Gravatar avatar fetches and Gravatar avatar suggestions are suppressed as well (admin-configured OAuth provider avatars keep working). Interactive cloud actions (registration, cloud login, billing) fail fast with a clear error instead of attempting to connect. A single informational log entry is emitted when offline mode is detected. Cron jobs now start only after the license is applied, so scheduled jobs honor the flag from their very first run. Workspaces with standard licenses are unaffected. +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.