From f6c557931742f881eeb7293d6db6561eda1003d8 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 17 Jul 2026 13:45:56 -0300 Subject: [PATCH 01/12] fix: Restore Away to quick status menu (#41414) Co-authored-by: dougfabris --- .changeset/ready-taxis-join.md | 5 +++++ apps/meteor/client/components/UserStatusMenu.tsx | 9 ++------- apps/meteor/client/lib/getUserInitialStatus.ts | 3 +-- .../UserMenu/hooks/useStatusItems.tsx | 5 +---- 4 files changed, 9 insertions(+), 13 deletions(-) create mode 100644 .changeset/ready-taxis-join.md diff --git a/.changeset/ready-taxis-join.md b/.changeset/ready-taxis-join.md new file mode 100644 index 0000000000000..cc8fcdb1db16c --- /dev/null +++ b/.changeset/ready-taxis-join.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Restores Away as a selectable preset in the quick status menu, custom status modal, and account profile page. diff --git a/apps/meteor/client/components/UserStatusMenu.tsx b/apps/meteor/client/components/UserStatusMenu.tsx index 59364ca66ec4c..2ced358d5d0de 100644 --- a/apps/meteor/client/components/UserStatusMenu.tsx +++ b/apps/meteor/client/components/UserStatusMenu.tsx @@ -37,21 +37,16 @@ const UserStatusMenu = ({ const statuses: Array<[value: UserStatusType, label: ReactNode]> = [ [UserStatusType.ONLINE, renderOption(UserStatusType.ONLINE, t('Online'))], + [UserStatusType.AWAY, renderOption(UserStatusType.AWAY, t('Away'))], [UserStatusType.BUSY, renderOption(UserStatusType.BUSY, t('Busy'))], ]; - // Away is no longer manually selectable, but surface it if the user is currently on it - // (e.g., set in a previous version or auto-applied by the server) so they can switch off. - if (status === UserStatusType.AWAY) { - statuses.push([UserStatusType.AWAY, renderOption(UserStatusType.AWAY, t('Away'))]); - } - if (allowInvisibleStatus) { statuses.push([UserStatusType.OFFLINE, renderOption(UserStatusType.OFFLINE, t('Offline'))]); } return statuses; - }, [t, allowInvisibleStatus, status]); + }, [t, allowInvisibleStatus]); const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] = useCursor(-1, options, ([selected], [, hide]) => { setStatus(selected); diff --git a/apps/meteor/client/lib/getUserInitialStatus.ts b/apps/meteor/client/lib/getUserInitialStatus.ts index cdc1e0f1fd87f..dd411d39be85a 100644 --- a/apps/meteor/client/lib/getUserInitialStatus.ts +++ b/apps/meteor/client/lib/getUserInitialStatus.ts @@ -15,8 +15,7 @@ export const getUserStatusInitialValues = (user: IUser | null, initialStatusText return { statusText: user?.statusText ?? initialStatusText ?? '', - statusType: - user?.status === UserStatusType.AWAY ? (user?.statusDefault ?? UserStatusType.ONLINE) : (user?.status ?? UserStatusType.ONLINE), + statusType: user?.status ?? UserStatusType.ONLINE, statusDuration: initialExpiration ? 'custom' : '', statusCustomDate: initialDate.toLocaleDateString('en-CA'), statusCustomTime: initialDate.toTimeString().slice(0, 5), diff --git a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx index 579e4649eaf0a..618dde497dcef 100644 --- a/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx +++ b/apps/meteor/client/navbar/NavBarSettingsToolbar/UserMenu/hooks/useStatusItems.tsx @@ -1,5 +1,4 @@ -import type { ICustomUserStatus, IUser } from '@rocket.chat/core-typings'; -import { UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; +import type { ICustomUserStatus, IUser, UserStatus as UserStatusEnum } from '@rocket.chat/core-typings'; import { Box, Icon, RadioButton } from '@rocket.chat/fuselage'; import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; import { clientCallbacks } from '@rocket.chat/ui-client'; @@ -131,12 +130,10 @@ export const useStatusItems = (user?: IUser): GenericMenuItemProps[] => { }); } - // Presets: filter to Online / Busy / Offline. Keep Away only if user is currently on Away (legacy). const isPresetSelected = (statusType: UserStatusEnum): boolean => !user?.statusText && !customStatusExpiration && user?.status === statusType; const presetItems = (statuses ?? []) .filter((s) => userStatuses.isValidType(s.id)) - .filter((s) => s.statusType !== UserStatusEnum.AWAY || isPresetSelected(UserStatusEnum.AWAY)) .map( (status): GenericMenuItemProps => ({ id: status.id, From 6a94ee415f5138f7fe6f454c3c01b15550bd2b04 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 17 Jul 2026 19:30:43 -0600 Subject: [PATCH 02/12] ci: speed up merge queue runs (#41368) --- .github/workflows/ci.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9ce17ea9b287..67c6f544cc524 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,9 @@ on: - '**.md' concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + # merge_group runs group by the merge-group ref so a recreated group cancels the stale run; + # push/release runs group by run_id on purpose (never cancel a deploy in progress) + group: ${{ github.workflow }}-${{ github.head_ref || (github.event_name == 'merge_group' && github.ref) || github.run_id }} cancel-in-progress: true env: @@ -155,6 +157,7 @@ jobs: name: 🚀 Notify external services - draft runs-on: ubuntu-24.04-arm needs: [release-versions] + if: github.event_name == 'release' steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -195,7 +198,7 @@ jobs: packages-build: name: 📦 Build Packages - needs: [release-versions, notify-draft-services] + needs: [release-versions] runs-on: ubuntu-24.04-arm steps: - name: Cache build @@ -327,12 +330,18 @@ jobs: type: # if running in a PR build with coverage - ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }} + exclude: + # when images aren't published (merge queue, fork PRs) only amd64 tars are uploaded + # as artifacts, so arm64 builds would be discarded — skip them + - arch: ${{ (github.event.pull_request.head.repo.full_name != github.repository && github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'arm64' || '' }} include: # if not, build with coverage for tests - arch: amd64 service: [rocketchat] type: coverage - - arch: arm64 + # resolves to amd64 when arm64 is excluded above, matching the entry before this + # one instead of re-adding an arm64 job (includes bypass exclude) + - arch: ${{ (github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop') && 'arm64' || 'amd64' }} service: [rocketchat] type: coverage From 302f0c46d791570e9bf7c7d8e3b83c3a0f4626a7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 22:38:57 -0300 Subject: [PATCH 03/12] chore: add dom lib for WebRTC/DOM globals (TS7 compat) (#41262) --- ee/packages/media-calls/tsconfig.json | 1 + packages/apps/tsconfig.json | 1 + .../src/mediaCalls/IMediaCallNegotiation.ts | 1 + .../src/mediaCalls/RTCSessionDescription.ts | 15 +++++++++++++++ packages/core-typings/src/mediaCalls/index.ts | 1 + .../src/models/IMediaCallNegotiationsModel.ts | 2 +- .../models/src/models/MediaCallNegotiations.ts | 7 ++++++- 7 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 packages/core-typings/src/mediaCalls/RTCSessionDescription.ts diff --git a/ee/packages/media-calls/tsconfig.json b/ee/packages/media-calls/tsconfig.json index 552c12b4d9522..0c90104d2d61c 100644 --- a/ee/packages/media-calls/tsconfig.json +++ b/ee/packages/media-calls/tsconfig.json @@ -4,6 +4,7 @@ "rootDir": "./src", "outDir": "./dist", "declaration": true, + "lib": ["es2023", "dom"], }, "include": ["./src/**/*"], } diff --git a/packages/apps/tsconfig.json b/packages/apps/tsconfig.json index 3e395a0d35b89..e9877efed42b9 100644 --- a/packages/apps/tsconfig.json +++ b/packages/apps/tsconfig.json @@ -4,6 +4,7 @@ "declaration": true, "rootDir": "./src", "outDir": "./dist", + "lib": ["es2023", "dom"], "strict": false, "noUnusedParameters": false, "noImplicitOverride": false, diff --git a/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts b/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts index 034c57f857d54..296ae8e7870dd 100644 --- a/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts +++ b/packages/core-typings/src/mediaCalls/IMediaCallNegotiation.ts @@ -1,4 +1,5 @@ import type { IRocketChatRecord } from '../IRocketChatRecord'; +import type { RTCSessionDescriptionInit } from './RTCSessionDescription'; export type MediaCallNegotiationStream = { tag: string; diff --git a/packages/core-typings/src/mediaCalls/RTCSessionDescription.ts b/packages/core-typings/src/mediaCalls/RTCSessionDescription.ts new file mode 100644 index 0000000000000..a6daf1f1d4d43 --- /dev/null +++ b/packages/core-typings/src/mediaCalls/RTCSessionDescription.ts @@ -0,0 +1,15 @@ +export type RTCSdpType = 'answer' | 'offer' | 'pranswer' | 'rollback'; + +// Server-side copy of the WebRTC RTCSessionDescriptionInit shape, so packages that +// don't ship the DOM lib (models, model-typings) can type SDP payloads. +// TODO: Evaluate a proprietary backend SDP type instead of mirroring the browser RTCSessionDescriptionInit +// This alias is a hand-maintained copy of the WebRTC/DOM shape, kept only so pure-server +// packages (models, model-typings) can type SDP payloads without pulling in the whole dom lib. +// Downsides: it can silently drift from the real browser type, and it carries browser-oriented +// fields the backend never persists or acts on. A backend-owned SDP type would decouple server +// typings from the DOM, model only what we actually store/validate, and drop the dom-lib coupling +// on the media stack (media-signaling and media-calls still rely on the ambient DOM type today). +export type RTCSessionDescriptionInit = { + sdp?: string; + type: RTCSdpType; +}; diff --git a/packages/core-typings/src/mediaCalls/index.ts b/packages/core-typings/src/mediaCalls/index.ts index 6b69d08b955e2..cb81170c0df8f 100644 --- a/packages/core-typings/src/mediaCalls/index.ts +++ b/packages/core-typings/src/mediaCalls/index.ts @@ -1,2 +1,3 @@ export type * from './IMediaCall'; export type * from './IMediaCallNegotiation'; +export type * from './RTCSessionDescription'; diff --git a/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts b/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts index e749a45251a16..1fd96f09f7110 100644 --- a/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts +++ b/packages/model-typings/src/models/IMediaCallNegotiationsModel.ts @@ -1,4 +1,4 @@ -import type { IMediaCallNegotiation, MediaCallNegotiationStream } from '@rocket.chat/core-typings'; +import type { IMediaCallNegotiation, MediaCallNegotiationStream, RTCSessionDescriptionInit } from '@rocket.chat/core-typings'; import type { Document, FindOptions, UpdateResult } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; diff --git a/packages/models/src/models/MediaCallNegotiations.ts b/packages/models/src/models/MediaCallNegotiations.ts index 53bf9708d8321..206473cca9476 100644 --- a/packages/models/src/models/MediaCallNegotiations.ts +++ b/packages/models/src/models/MediaCallNegotiations.ts @@ -1,4 +1,9 @@ -import type { RocketChatRecordDeleted, IMediaCallNegotiation, MediaCallNegotiationStream } from '@rocket.chat/core-typings'; +import type { + RocketChatRecordDeleted, + IMediaCallNegotiation, + MediaCallNegotiationStream, + RTCSessionDescriptionInit, +} from '@rocket.chat/core-typings'; import type { IMediaCallNegotiationsModel } from '@rocket.chat/model-typings'; import type { IndexDescription, Collection, Db, FindOptions, Document, UpdateResult } from 'mongodb'; From 761966961d268b145d3fd7b147c9706342f67e03 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 17 Jul 2026 20:13:43 -0600 Subject: [PATCH 04/12] ci: fix dynamic-import response truncation flake (proxy transport race) + socket instrumentation (#41399) --- .github/workflows/ci-test-e2e.yml | 4 + .../server/startup/httpSocketTimeout.ts | 17 ++++ apps/meteor/server/startup/index.ts | 1 + .../api/http-response-truncation.ts | 86 +++++++++++++++++++ docker-compose-ci.yml | 14 ++- 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/server/startup/httpSocketTimeout.ts create mode 100644 apps/meteor/tests/end-to-end/api/http-response-truncation.ts diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 3504c5a8172d7..be682452ba934 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -320,6 +320,10 @@ jobs: if: failure() run: docker compose -f docker-compose-ci.yml logs mongo + - name: Show traefik logs if E2E test failed + if: failure() + run: docker compose -f docker-compose-ci.yml logs traefik + - name: Store coverage if: inputs.coverage == matrix.mongodb-version uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/apps/meteor/server/startup/httpSocketTimeout.ts b/apps/meteor/server/startup/httpSocketTimeout.ts new file mode 100644 index 0000000000000..275f9bc10fd1d --- /dev/null +++ b/apps/meteor/server/startup/httpSocketTimeout.ts @@ -0,0 +1,17 @@ +import { WebApp } from 'meteor/webapp'; + +// meteor's webapp arms a 5s idle-socket timeout (and re-arms it after every +// response), which kills keep-alive conns a fronting proxy may be about to +// reuse — the root of the CI dynamic-import truncation flake (PR #41399). +// Overriding it is opt-in: only CI sets HTTP_SOCKET_TIMEOUT_MS, pointing it +// above traefik's idleConnTimeout so the proxy always closes conns first. +const timeout = parseInt(process.env.HTTP_SOCKET_TIMEOUT_MS ?? '', 10); +if (timeout > 0) { + const { httpServer, _timeoutAdjustmentRequestCallback } = WebApp as typeof WebApp & { + _timeoutAdjustmentRequestCallback: (req: unknown, res: unknown) => void; + }; + httpServer.removeListener('request', _timeoutAdjustmentRequestCallback); + httpServer.setTimeout(timeout); + httpServer.keepAliveTimeout = timeout; + httpServer.headersTimeout = timeout + 1000; +} diff --git a/apps/meteor/server/startup/index.ts b/apps/meteor/server/startup/index.ts index d808423188074..b5fc18d36a9b5 100644 --- a/apps/meteor/server/startup/index.ts +++ b/apps/meteor/server/startup/index.ts @@ -7,6 +7,7 @@ import './serverRunning'; import './coreApps'; import { generateFederationKeys } from './generateKeys'; import './presenceTroubleshoot'; +import './httpSocketTimeout'; import '../hooks'; import '../lib/rooms/roomTypes'; import '../lib/settingsRegenerator'; diff --git a/apps/meteor/tests/end-to-end/api/http-response-truncation.ts b/apps/meteor/tests/end-to-end/api/http-response-truncation.ts new file mode 100644 index 0000000000000..1a6a07a0cb9f9 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/http-response-truncation.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { apiUrl } from '../../data/api-data'; + +// Regression test for the dynamic-import response truncation flake (PR #41399). +// +// Through traefik with `serverstransport.maxidleconnsperhost=-1` (upstream +// keep-alive disabled), Go's transport rarely closes its upstream connection +// while the ReverseProxy is still copying a chunked gzip response body +// ("use of closed network connection"), truncating the response mid-stream: +// the browser gets 200 headers and then ERR_INCOMPLETE_CHUNKED_ENCODING, and +// the meteor module loader hangs the page. Rate is ~1.6e-4 per request under +// load, so this test replays the exact request shape (real dynamic-import +// bodies captured from a failing CI run — the fatal one is the same +// AppLayoutThemeWrapper.tsx request seen in every reproduction) at high +// volume and concurrency: red on the broken proxy config, green once +// upstream keep-alive is restored. +// +// The bodies are build-dependent fixtures: if the app tree renames these +// modules the responses shrink below the gzip threshold and lose the +// chunked+gzip shape the race needs, so the test asserts that shape first. + +const FETCH_URL = `${apiUrl}/__meteor__/dynamic-import/fetch`; + +const BODIES = [ + '{"client":{"components":{"AppLayoutThemeWrapper.tsx":1}}}', + '{"client":{"meteor":{"login":{"index.ts":1,"cas.ts":1,"crowd.ts":1,"facebook.ts":1,"oauth.ts":1,"LoginCancelledError.ts":1,"google.ts":1,"ldap.ts":1,"meteorDeveloperAccount.ts":1,"password.ts":1,"saml.ts":1,"twitter.ts":1}},"lib":{"2fa":{"overrideLoginMethod.ts":1},"wrapRequestCredentialFn.ts":1,"loginServices.ts":1}},"node_modules":{"@rocket.chat":{"string-helpers":{"package.json":1,"dist":{"esm":{"index.js":1}}}}}}', +]; + +const TOTAL_REQUESTS = 30000; +const CONCURRENCY = 64; + +const fetchModuleTree = async (body: string): Promise<{ error?: string }> => { + try { + const response = await fetch(FETCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + body, + }); + if (response.status !== 200) { + return { error: `unexpected status ${response.status}` }; + } + await response.text(); + return {}; + } catch (error) { + return { error: `${error} (cause: ${(error as { cause?: unknown }).cause})` }; + } +}; + +describe('dynamic-import response delivery through the proxy', () => { + it('should keep the chunked gzip response shape the race depends on', async () => { + const response = await fetch(FETCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + body: BODIES[1], + }); + const text = await response.text(); + expect(response.status).to.equal(200); + expect( + text.length, + 'fixture went stale: response too small to be gzip-chunked, update BODIES from a fresh browser trace', + ).to.be.greaterThan(10000); + }); + + it(`should deliver ${TOTAL_REQUESTS} concurrent dynamic-import responses without a single truncation`, async function () { + this.timeout(10 * 60 * 1000); + + const failures: string[] = []; + let issued = 0; + + const worker = async (): Promise => { + while (issued < TOTAL_REQUESTS && failures.length === 0) { + const current = issued++; + const { error } = await fetchModuleTree(BODIES[current % BODIES.length]); + if (error) { + failures.push(`request #${current}: ${error}`); + } + } + }; + + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + + expect(failures).to.deep.equal([]); + }); +}); diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index c2c23488913f6..cef50ced7cbf4 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -29,6 +29,9 @@ services: - Federation_Service_Enabled=true - 'Federation_Service_Domain=rc.host' - HEAP_USAGE_PERCENT=99 + # keep server sockets alive longer than traefik's 90s idleConnTimeout so + # the proxy always closes pooled conns first and never reuses a dead one + - HTTP_SOCKET_TIMEOUT_MS=120000 depends_on: - traefik - mongo @@ -37,7 +40,17 @@ services: traefik.http.services.rocketchat.loadbalancer.server.port: 3000 traefik.http.routers.rocketchat.service: rocketchat traefik.http.routers.rocketchat.rule: PathPrefix(`/`) + traefik.http.routers.rocketchat.middlewares: test-retry traefik.http.middlewares.test-retry.retry.attempts: 4 + # dedicated route for dynamic-import: buffering coalesces the tiny POST + # into a single upstream write, closing the go-transport early-response + # race window (response beating the request-write bookkeeping kills the + # conn mid body copy); scoped here because suite-wide buffering broke + # 404/CORS response semantics + traefik.http.routers.rocketchat-dynimport.rule: PathPrefix(`/__meteor__/dynamic-import`) + traefik.http.routers.rocketchat-dynimport.service: rocketchat + traefik.http.routers.rocketchat-dynimport.middlewares: test-buffer,test-retry + traefik.http.middlewares.test-buffer.buffering.retryExpression: IsNetworkError() && Attempts() < 4 healthcheck: interval: 2s timeout: 5s @@ -217,7 +230,6 @@ services: image: traefik:v3.6.6 command: - --providers.docker=true - - '--serverstransport.maxidleconnsperhost=-1' ports: - 3000:80 volumes: From 6041285601ce6f9586f85cff72fb167642893245 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 17 Jul 2026 11:05:09 -0600 Subject: [PATCH 05/12] fix: pagination and projection options silently ignored by model queries (#41402) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .changeset/strict-find-options.md | 6 + .../omnichannel/AutoTransferChatScheduler.ts | 8 +- .../ee/server/lib/omnichannel/Department.ts | 4 +- apps/meteor/server/api/v1/groups.ts | 2 +- .../api/v1/omnichannel/lib/customFields.ts | 2 +- .../server/api/v1/omnichannel/lib/transfer.ts | 2 +- .../server/api/v1/omnichannel/lib/triggers.ts | 2 +- apps/meteor/server/api/v1/rooms.ts | 7 +- .../lib/notifications/processDirectEmail.ts | 6 +- apps/meteor/tests/end-to-end/api/groups.ts | 126 +++++++++++++++++ apps/meteor/tests/end-to-end/api/rooms.ts | 131 ++++++++++++++++++ ee/packages/omni-core-ee/package.json | 3 +- .../validators/canSendMessage.ts | 4 +- .../model-typings/src/models/IBaseModel.ts | 5 +- packages/omni-core/package.json | 3 +- 15 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 .changeset/strict-find-options.md diff --git a/.changeset/strict-find-options.md b/.changeset/strict-find-options.md new file mode 100644 index 0000000000000..97e5552c67a61 --- /dev/null +++ b/.changeset/strict-find-options.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/model-typings': patch +--- + +Fixes broken pagination on `rooms.bannedUsers` and on omnichannel department listing endpoints, which ignored the `offset` parameter and always returned results from the first page. Also reduces payload over-fetching on several endpoints that unintentionally loaded full documents (`rooms.hide`, private group lookups, direct email replies, omnichannel auto-transfer), and removes the permissive model query typings that allowed these invalid find options to compile unnoticed. diff --git a/apps/meteor/ee/server/lib/omnichannel/AutoTransferChatScheduler.ts b/apps/meteor/ee/server/lib/omnichannel/AutoTransferChatScheduler.ts index cde6280ecce5d..73e8d23cb9527 100644 --- a/apps/meteor/ee/server/lib/omnichannel/AutoTransferChatScheduler.ts +++ b/apps/meteor/ee/server/lib/omnichannel/AutoTransferChatScheduler.ts @@ -78,13 +78,7 @@ export class AutoTransferChatSchedulerClass { private async transferRoom(roomId: string): Promise { this.logger.debug({ msg: 'Transferring room', roomId }); - const room = await LivechatRooms.findOneById(roomId, { - _id: 1, - v: 1, - servedBy: 1, - open: 1, - departmentId: 1, - }); + const room = await LivechatRooms.findOneById(roomId); if (!room?.open || !room?.servedBy?._id) { throw new Error('Room is not open or is not being served by an agent'); } diff --git a/apps/meteor/ee/server/lib/omnichannel/Department.ts b/apps/meteor/ee/server/lib/omnichannel/Department.ts index 764a795dd0cd9..206e58edbce61 100644 --- a/apps/meteor/ee/server/lib/omnichannel/Department.ts +++ b/apps/meteor/ee/server/lib/omnichannel/Department.ts @@ -24,7 +24,7 @@ export const findAllDepartmentsAvailable = async ( query = await applyDepartmentRestrictions(query, uid); } - const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { limit: count, offset, sort: { name: 1 } }); + const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { limit: count, skip: offset, sort: { name: 1 } }); const [departments, total] = await Promise.all([cursor.toArray(), totalCount]); @@ -40,7 +40,7 @@ export const findAllDepartmentsByUnit = async ( { ancestors: { $in: [unitId] }, }, - { limit: count, offset }, + { limit: count, skip: offset, sort: { name: 1, _id: 1 } }, ); const [departments, total] = await Promise.all([cursor.toArray(), totalCount]); diff --git a/apps/meteor/server/api/v1/groups.ts b/apps/meteor/server/api/v1/groups.ts index c1c85defeb810..ee7194f3d6c32 100644 --- a/apps/meteor/server/api/v1/groups.ts +++ b/apps/meteor/server/api/v1/groups.ts @@ -106,7 +106,7 @@ async function findPrivateGroupByIdOrName({ }> { const room = await getRoomFromParams(params); - const user = await Users.findOneById(userId, { projections: { username: 1 } }); + const user = await Users.findOneById(userId, { projection: { username: 1, roles: 1, abacAttributes: 1 } }); if (!room || !user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-room-not-found', 'The required "roomId" or "roomName" param provided does not match any group'); diff --git a/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts b/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts index 8b1d459b2d8e5..7b1ffb228ac94 100644 --- a/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts +++ b/apps/meteor/server/api/v1/omnichannel/lib/customFields.ts @@ -9,7 +9,7 @@ export async function findLivechatCustomFields({ pagination: { offset, count, sort }, }: { text?: string; - pagination: { offset: number; count: number; sort: Record }; + pagination: { offset: number; count: number; sort: Record }; }): Promise }>> { const query = { ...(text && { diff --git a/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts b/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts index d01d85402cae5..bcaba342e2841 100644 --- a/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts +++ b/apps/meteor/server/api/v1/omnichannel/lib/transfer.ts @@ -11,7 +11,7 @@ export async function findLivechatTransferHistory({ pagination: { offset, count, sort }, }: { rid: string; - pagination: { offset: number; count: number; sort: Record }; + pagination: { offset: number; count: number; sort: Record }; }): Promise> { const { cursor, totalCount } = Messages.findPaginated( { rid, t: 'livechat_transfer_history' }, diff --git a/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts b/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts index 4cbafcb0dc737..6650df3a1ccdf 100644 --- a/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts +++ b/apps/meteor/server/api/v1/omnichannel/lib/triggers.ts @@ -5,7 +5,7 @@ import type { PaginatedResult } from '@rocket.chat/rest-typings'; export async function findTriggers({ pagination: { offset, count, sort }, }: { - pagination: { offset: number; count: number; sort: Record }; + pagination: { offset: number; count: number; sort: Record }; }): Promise }>> { const { cursor, totalCount } = LivechatTrigger.findPaginated( {}, diff --git a/apps/meteor/server/api/v1/rooms.ts b/apps/meteor/server/api/v1/rooms.ts index ea8f7cbc658bf..b73339de453b0 100644 --- a/apps/meteor/server/api/v1/rooms.ts +++ b/apps/meteor/server/api/v1/rooms.ts @@ -1301,7 +1301,7 @@ API.v1.post( return API.v1.unauthorized(); } - const user = await Users.findOneById(this.userId, { projections: { _id: 1 } }); + const user = await Users.findOneById(this.userId, { projection: { _id: 1 } }); if (!user) { return API.v1.failure('error-invalid-user'); @@ -1706,7 +1706,10 @@ export const roomEndpoints = API.v1 const { offset, count } = await getPaginationItems(this.queryParams); - const { cursor, totalCount } = Subscriptions.findPaginated({ rid: roomId, status: 'BANNED' as const }, { offset, count }); + const { cursor, totalCount } = Subscriptions.findPaginated( + { rid: roomId, status: 'BANNED' as const }, + { sort: { ts: 1 }, skip: offset, limit: count, projection: { 'u._id': 1 } }, + ); const [bannedSubs, total] = await Promise.all([cursor.toArray(), totalCount]); diff --git a/apps/meteor/server/lib/notifications/processDirectEmail.ts b/apps/meteor/server/lib/notifications/processDirectEmail.ts index 7ad8848090834..4b89232953d17 100644 --- a/apps/meteor/server/lib/notifications/processDirectEmail.ts +++ b/apps/meteor/server/lib/notifications/processDirectEmail.ts @@ -50,8 +50,10 @@ export const processDirectEmail = async function (email: ParsedMail): Promise { }); }); + describe('group access as a regular member', () => { + let testGroup: IRoom; + let member: TestUser; + let memberCredentials: Credentials; + let outsider: TestUser; + let outsiderCredentials: Credentials; + const memberGroupName = `member-access-group-${Date.now()}-${Math.random()}`; + + before(async () => { + member = await createUser(); + memberCredentials = await login(member.username, password); + outsider = await createUser(); + outsiderCredentials = await login(outsider.username, password); + + const result = await createRoom({ type: 'p', name: memberGroupName, members: [member.username] }); + testGroup = result.body.group; + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: testGroup._id }); + await Promise.all([deleteUser(member), deleteUser(outsider)]); + }); + + it('should return the group info to a regular member', async () => { + const res = await request + .get(api('groups.info')) + .set(memberCredentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('group._id', testGroup._id); + expect(res.body).to.have.nested.property('group.name', memberGroupName); + expect(res.body).to.have.nested.property('group.t', 'p'); + }); + + it('should not return the group info to a non-member', async () => { + const res = await request + .get(api('groups.info')) + .set(outsiderCredentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }); + + it('should return the group history to a regular member', async () => { + const res = await request + .get(api('groups.history')) + .set(memberCredentials) + .query({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); + + it('should close the group for a regular member', async () => { + await request + .post(api('groups.close')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should report the group as already closed on a second close (subscription open flag is read)', async () => { + await request + .post(api('groups.close')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The private group, ${memberGroupName}, is already closed to the sender`); + }); + }); + + it('should open the group back for a regular member', async () => { + await request + .post(api('groups.open')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should report the group as already open on a second open (subscription open flag is read)', async () => { + await request + .post(api('groups.open')) + .set(memberCredentials) + .send({ + roomId: testGroup._id, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `The private group, ${memberGroupName}, is already open for the sender`); + }); + }); + }); + describe('/groups.list', () => { it('should list the groups the caller is part of', (done) => { void request diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 8e85c20edcf80..aa13edd3afa87 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -5073,4 +5073,135 @@ describe('[Rooms]', () => { }); }); }); + + describe('/rooms.bannedUsers', () => { + let testChannel: IRoom; + let bannedUsers: TestUser[]; + let bannedUserIds: string[]; + + before(async () => { + const result = await createRoom({ type: 'c', name: `banned-users-list-${Date.now()}-${Math.random()}` }); + testChannel = result.body.channel; + + bannedUsers = await Promise.all([createUser(), createUser(), createUser()]); + bannedUserIds = bannedUsers.map((user) => user._id); + + for (const user of bannedUsers) { + await request + .post(api('channels.invite')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: user._id, + }) + .expect(200); + + await request + .post(api('rooms.banUser')) + .set(credentials) + .send({ + roomId: testChannel._id, + userId: user._id, + }) + .expect(200); + } + }); + + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await Promise.all(bannedUsers.map((user) => deleteUser(user))); + }); + + it('should list every banned user of the room with only the projected fields', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', bannedUsers.length); + expect(res.body).to.have.property('count', bannedUsers.length); + expect(res.body).to.have.property('offset', 0); + expect(res.body.bannedUsers).to.be.an('array').with.lengthOf(bannedUsers.length); + expect(res.body.bannedUsers.map((u: IUser) => u._id)).to.have.members(bannedUserIds); + + res.body.bannedUsers.forEach((user: IUser) => { + expect(user).to.have.property('_id').that.is.a('string'); + expect(user).to.have.property('username').that.is.a('string'); + expect(Object.keys(user)).to.satisfy( + (keys: string[]) => keys.every((key) => ['_id', 'username', 'name'].includes(key)), + 'response should only contain the projected fields (_id, username, name)', + ); + }); + }); + + it('should limit the number of banned users returned when count is provided', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', bannedUsers.length); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('offset', 0); + expect(res.body.bannedUsers).to.be.an('array').with.lengthOf(2); + }); + + it('should skip banned users when offset is provided', async () => { + const res = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + offset: 2, + count: 2, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('total', bannedUsers.length); + expect(res.body).to.have.property('offset', 2); + expect(res.body.bannedUsers) + .to.be.an('array') + .with.lengthOf(bannedUsers.length - 2); + }); + + it('should paginate through all banned users without overlapping results', async () => { + const firstPage = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + count: 2, + }) + .expect(200); + + const secondPage = await request + .get(api('rooms.bannedUsers')) + .set(credentials) + .query({ + roomId: testChannel._id, + offset: 2, + count: 2, + }) + .expect(200); + + const firstPageIds = firstPage.body.bannedUsers.map((u: IUser) => u._id); + const secondPageIds = secondPage.body.bannedUsers.map((u: IUser) => u._id); + + expect(firstPageIds.filter((id: string) => secondPageIds.includes(id))).to.be.empty; + expect([...firstPageIds, ...secondPageIds]).to.have.members(bannedUserIds); + }); + }); }); diff --git a/ee/packages/omni-core-ee/package.json b/ee/packages/omni-core-ee/package.json index e0bd777d70629..96aca7e54e361 100644 --- a/ee/packages/omni-core-ee/package.json +++ b/ee/packages/omni-core-ee/package.json @@ -13,7 +13,8 @@ "lint": "eslint .", "lint:fix": "eslint --fix .", "test": "jest", - "testunit": "jest" + "testunit": "jest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@rocket.chat/core-services": "workspace:^", diff --git a/ee/packages/omni-core-ee/src/outbound-communication/validators/canSendMessage.ts b/ee/packages/omni-core-ee/src/outbound-communication/validators/canSendMessage.ts index e8cdcfd948955..6e1d6796c7802 100644 --- a/ee/packages/omni-core-ee/src/outbound-communication/validators/canSendMessage.ts +++ b/ee/packages/omni-core-ee/src/outbound-communication/validators/canSendMessage.ts @@ -25,7 +25,9 @@ export async function canSendOutboundMessage(userId: string, agentId?: string, d query = await addQueryRestrictionsToDepartmentsModel(query, userId); } - const department = await LivechatDepartment.findOne>(query, { _id: 1, enabled: 1 }); + const department = await LivechatDepartment.findOne>(query, { + projection: { _id: 1, enabled: 1 }, + }); if (!department?.enabled) { throw new Error('error-invalid-department'); } diff --git a/packages/model-typings/src/models/IBaseModel.ts b/packages/model-typings/src/models/IBaseModel.ts index 247461b962cf6..abf1b3dede0e2 100644 --- a/packages/model-typings/src/models/IBaseModel.ts +++ b/packages/model-typings/src/models/IBaseModel.ts @@ -61,11 +61,9 @@ export interface IBaseModel< findOneById(_id: T['_id'], options?: FindOptions | undefined): Promise; findOneById

(_id: T['_id'], options?: FindOptions

): Promise

; - findOneById(_id: T['_id'], options?: any): Promise; findOne(query?: Filter | T['_id'], options?: undefined): Promise; - findOne

(query: Filter | T['_id'], options: FindOptions

): Promise

; - findOne

(query: Filter | T['_id'], options?: any): Promise | WithId

| null>; + findOne

(query: Filter | T['_id'], options?: FindOptions

): Promise

; find(query?: Filter): FindCursor>; find

(query: Filter, options: FindOptions

): FindCursor

; @@ -75,7 +73,6 @@ export interface IBaseModel< ): FindCursor> | FindCursor>; findPaginated

(query: Filter, options?: FindOptions

): FindPaginated>>; - findPaginated(query: Filter, options?: any): FindPaginated>>; update( filter: Filter, diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index 78ccc6c453b0b..9690c37ab7c53 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -13,7 +13,8 @@ "lint": "eslint .", "lint:fix": "eslint --fix .", "test": "jest", - "testunit": "jest" + "testunit": "jest", + "typecheck": "tsc --noEmit" }, "dependencies": { "@rocket.chat/models": "workspace:^", From 1bf84cbe288df03fc622fbddbc0e434bda291c2f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 17 Jul 2026 15:56:14 -0300 Subject: [PATCH 06/12] feat: validate password policy length on settings save (#41173) --- .changeset/empty-garlics-reply.md | 6 + apps/meteor/server/api/v1/settings.ts | 35 ++-- .../server/lib/settingValidationRules.ts | 128 +++++++++++++++ .../meteor-methods/settings/saveSetting.ts | 10 ++ .../meteor-methods/settings/saveSettings.ts | 18 ++- apps/meteor/server/settings/accounts.ts | 3 + .../settings/functions/getSettingDefaults.ts | 1 + .../functions/validationRuleBuilders.ts | 14 ++ .../server/settings/lib/saveSettingsBulk.ts | 3 + apps/meteor/tests/end-to-end/api/users.ts | 11 ++ .../server/lib/settingValidationRules.spec.ts | 151 ++++++++++++++++++ packages/core-typings/src/ISetting.ts | 8 + packages/i18n/src/locales/en.i18n.json | 2 + 13 files changed, 374 insertions(+), 16 deletions(-) create mode 100644 .changeset/empty-garlics-reply.md create mode 100644 apps/meteor/server/lib/settingValidationRules.ts create mode 100644 apps/meteor/server/settings/functions/validationRuleBuilders.ts create mode 100644 apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts diff --git a/.changeset/empty-garlics-reply.md b/.changeset/empty-garlics-reply.md new file mode 100644 index 0000000000000..6233cf9bf525e --- /dev/null +++ b/.changeset/empty-garlics-reply.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes the password policy allowing a maximum length lower than the minimum length to be saved — a combination that made it impossible to set any valid password. The server now rejects such configurations when password policy settings are saved and shows an error explaining the constraint. diff --git a/apps/meteor/server/api/v1/settings.ts b/apps/meteor/server/api/v1/settings.ts index 5cf3ce03e55c1..7093d05fbd144 100644 --- a/apps/meteor/server/api/v1/settings.ts +++ b/apps/meteor/server/api/v1/settings.ts @@ -26,6 +26,7 @@ import _ from 'underscore'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { SettingValidationError, validateSettingRules } from '../../lib/settingValidationRules'; import { disableCustomScripts } from '../../lib/shared/disableCustomScripts'; import { addOAuthServiceMethod } from '../../meteor-methods/auth/addOAuthService'; import { SettingsEvents, settings } from '../../settings'; @@ -334,12 +335,7 @@ API.v1.post( body: settingsUpdateBodySchema, response: { 200: settingByIdPostResponseSchema, - 400: ajv.compile({ - type: 'object', - properties: { success: { type: 'boolean', enum: [false] } }, - required: ['success'], - additionalProperties: true, - }), + 400: validateBadRequestErrorResponse, 401: validateUnauthorizedErrorResponse, 403: validateForbiddenErrorResponse, }, @@ -393,8 +389,18 @@ API.v1.post( } if (isSettingsUpdatePropDefault(bodyParams)) { + // TODO(next major): unify both validations into one function with a common API error response checkSettingValueBounds(setting, bodyParams.value); + try { + validateSettingRules([{ _id, value: bodyParams.value }]); + } catch (error) { + if (error instanceof SettingValidationError) { + return API.v1.failure(error.message, 'error-setting-validation-failed'); + } + throw error; + } + const { matchedCount } = await auditSettingOperation(Settings.updateValueNotHiddenById, _id, bodyParams.value); if (!matchedCount) { @@ -433,11 +439,18 @@ API.v1.post( }, }, async function action() { - await saveSettingsBulk(this.userId, this.bodyParams.settings, { - username: this.user.username ?? '', - ip: this.requestIp ?? '', - useragent: this.request.headers.get('user-agent') ?? '', - }); + try { + await saveSettingsBulk(this.userId, this.bodyParams.settings, { + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + } catch (error) { + if (error instanceof SettingValidationError) { + return API.v1.failure(error.message, 'error-setting-validation-failed'); + } + throw error; + } return API.v1.success(); }, diff --git a/apps/meteor/server/lib/settingValidationRules.ts b/apps/meteor/server/lib/settingValidationRules.ts new file mode 100644 index 0000000000000..f729cde6eaf30 --- /dev/null +++ b/apps/meteor/server/lib/settingValidationRules.ts @@ -0,0 +1,128 @@ +import type { ISetting, SettingValidationRule } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; +import { isRecord } from '@rocket.chat/tools'; + +import { settings } from '../settings'; + +const logger = new Logger('SettingValidation'); + +export class SettingValidationError extends Error {} + +const isAppliesWhenCondition = (value: unknown): value is { _id: ISetting['_id']; value: unknown } => + isRecord(value) && typeof value._id === 'string' && 'value' in value; + +const isValidationRule = (rule: unknown): rule is SettingValidationRule => + isRecord(rule) && + isRecord(rule.query) && + (rule.appliesWhen === undefined || + isAppliesWhenCondition(rule.appliesWhen) || + (Array.isArray(rule.appliesWhen) && rule.appliesWhen.every(isAppliesWhenCondition))); + +const isValidationRuleArray = (value: unknown): value is SettingValidationRule[] => Array.isArray(value) && value.every(isValidationRule); + +const parseValidationRules = (settingId: ISetting['_id'], validation: NonNullable): SettingValidationRule[] => { + if (typeof validation !== 'string') { + return validation; + } + + let parsed: unknown; + try { + parsed = JSON.parse(validation); + } catch (err) { + logger.error({ msg: 'Failed to parse setting validation rules', settingId, err }); + return []; + } + + if (!isValidationRuleArray(parsed)) { + logger.error({ msg: 'Setting validation rules are not well-formed', settingId }); + return []; + } + + return parsed; +}; + +const isSettingReference = (value: unknown): value is { $setting: ISetting['_id'] } => + isRecord(value) && typeof value.$setting === 'string'; + +/** + * Evaluates a setting's validation rule against the value being saved. Returns `false` only when the rule's filter + * rejects the candidate. A rule that references a setting which does not exist is logged and treated as passing, so a + * broken declaration never blocks a save. + */ +const evaluateSettingValidationRule = ( + settingId: ISetting['_id'], + rule: SettingValidationRule, + get: (id: ISetting['_id']) => unknown, +): boolean => { + const references: Record = {}; + + // true when `filter` matches the current value of the setting `id` + const matches = (filter: Record, id: ISetting['_id']): boolean => + createPredicateFromFilter<{ _id: ISetting['_id']; value: unknown }>(filter)({ _id: id, value: get(id) }); + + // replaces every `{ $setting: '' }` in the filter with that setting's value, + // recording each in `references` + const resolveObject = (filter: Record): Record => + Object.fromEntries(Object.entries(filter).map(([key, value]) => [key, resolveNode(value)])); + + const resolveNode = (node: unknown): unknown => { + if (isSettingReference(node)) { + references[node.$setting] = get(node.$setting); + return references[node.$setting]; + } + if (Array.isArray(node)) { + return node.map(resolveNode); + } + if (isRecord(node)) { + return resolveObject(node); + } + return node; + }; + + const conditions = [rule.appliesWhen ?? []].flat(); + const query = resolveObject(rule.query); + for (const condition of conditions) { + references[condition._id] = get(condition._id); + } + + const unknownReferences = Object.keys(references).filter((id) => references[id] === undefined); + if (unknownReferences.length) { + logger.error({ msg: 'Setting validation rule references unknown settings', settingId, references: unknownReferences }); + return true; + } + + // the rule only applies while all of its `appliesWhen` conditions hold, + // otherwise it is not relevant and passes + const conditionHolds = (condition: { _id: ISetting['_id']; value: unknown }): boolean => + matches({ value: condition.value }, condition._id); + if (!conditions.every(conditionHolds)) { + return true; + } + + return matches(query, settingId); +}; + +export const validateSettingRules = (changes: { _id: ISetting['_id']; value: ISetting['value'] }[]): void => { + // a value being saved in this batch wins over the stored one, + // so rules never compare against stale values + const getValueOf = (id: ISetting['_id']): unknown => { + const beingSaved = changes.find(({ _id }) => _id === id); + return beingSaved ? beingSaved.value : settings.get(id); + }; + + for (const { _id } of changes) { + const setting = settings.getSetting(_id); + if (!setting?.validation) { + continue; + } + + for (const rule of parseValidationRules(setting._id, setting.validation)) { + if (evaluateSettingValidationRule(setting._id, rule, getValueOf)) { + continue; + } + + throw new SettingValidationError(`${setting._id}_Invalid`); + } + } +}; diff --git a/apps/meteor/server/meteor-methods/settings/saveSetting.ts b/apps/meteor/server/meteor-methods/settings/saveSetting.ts index a39c594cb6822..4a4aae6f7fa5f 100644 --- a/apps/meteor/server/meteor-methods/settings/saveSetting.ts +++ b/apps/meteor/server/meteor-methods/settings/saveSetting.ts @@ -9,6 +9,7 @@ import { twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../lib/authorization/hasPermission'; import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; import { notifyOnSettingChanged } from '../../lib/notifyListener'; +import { SettingValidationError, validateSettingRules } from '../../lib/settingValidationRules'; import { disableCustomScripts } from '../../lib/shared/disableCustomScripts'; import { updateAuditedByUser } from '../../settings/lib/auditedSettingUpdates'; @@ -69,6 +70,15 @@ Meteor.methods({ break; } + try { + validateSettingRules([{ _id, value }]); + } catch (error) { + if (error instanceof SettingValidationError) { + throw new Meteor.Error('error-setting-validation-failed', error.message); + } + throw error; + } + const auditSettingOperation = updateAuditedByUser({ _id: uid, username: (await Meteor.userAsync())!.username!, diff --git a/apps/meteor/server/meteor-methods/settings/saveSettings.ts b/apps/meteor/server/meteor-methods/settings/saveSettings.ts index b574b4ab98ed1..ff6137d6bf365 100644 --- a/apps/meteor/server/meteor-methods/settings/saveSettings.ts +++ b/apps/meteor/server/meteor-methods/settings/saveSettings.ts @@ -4,6 +4,7 @@ import { Meteor } from 'meteor/meteor'; import { twoFactorRequired } from '../../lib/2fa/twoFactorRequired'; import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; +import { SettingValidationError } from '../../lib/settingValidationRules'; import { saveSettingsBulk } from '../../settings/lib/saveSettingsBulk'; declare module '@rocket.chat/ddp-client' { @@ -34,11 +35,18 @@ Meteor.methods({ }); } - await saveSettingsBulk(uid, params, { - username: (await Meteor.userAsync())!.username!, - ip: this.connection.clientAddress || '', - useragent: this.connection.httpHeaders['user-agent'] || '', - }); + try { + await saveSettingsBulk(uid, params, { + username: (await Meteor.userAsync())!.username!, + ip: this.connection.clientAddress || '', + useragent: this.connection.httpHeaders['user-agent'] || '', + }); + } catch (error) { + if (error instanceof SettingValidationError) { + throw new Meteor.Error('error-setting-validation-failed', error.message); + } + throw error; + } return true; }, {}), diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index 0268e8f5c9be2..d0ac7a8242d3a 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -1,6 +1,7 @@ import { Random } from '@rocket.chat/random'; import { settingsRegistry } from '.'; +import { positiveOrDisabled, notGreaterThanSetting, notLowerThanSetting } from './functions/validationRuleBuilders'; export const createAccountSettings = () => settingsRegistry.addGroup('Accounts', async function () { @@ -826,12 +827,14 @@ export const createAccountSettings = () => type: 'int', public: true, enableQuery, + validation: [positiveOrDisabled(), notGreaterThanSetting('Accounts_Password_Policy_MaxLength')], }); await this.add('Accounts_Password_Policy_MaxLength', -1, { type: 'int', public: true, enableQuery, + validation: [positiveOrDisabled(), notLowerThanSetting('Accounts_Password_Policy_MinLength')], }); await this.add('Accounts_Password_Policy_ForbidRepeatingCharacters', true, { diff --git a/apps/meteor/server/settings/functions/getSettingDefaults.ts b/apps/meteor/server/settings/functions/getSettingDefaults.ts index cc69d4e3604cc..e865fcfc085d9 100644 --- a/apps/meteor/server/settings/functions/getSettingDefaults.ts +++ b/apps/meteor/server/settings/functions/getSettingDefaults.ts @@ -26,6 +26,7 @@ export const getSettingDefaults = ( _updatedAt: options._updatedAt ?? new Date(), ...options, ...(options.enableQuery ? { enableQuery: JSON.stringify(options.enableQuery) } : undefined), + ...(options.validation ? { validation: JSON.stringify(options.validation) } : undefined), i18nLabel: options.i18nLabel || _id, hidden: options.hidden || hiddenSettings.has(_id), blocked: options.blocked || blockedSettings.has(_id), diff --git a/apps/meteor/server/settings/functions/validationRuleBuilders.ts b/apps/meteor/server/settings/functions/validationRuleBuilders.ts new file mode 100644 index 0000000000000..cfa738e788911 --- /dev/null +++ b/apps/meteor/server/settings/functions/validationRuleBuilders.ts @@ -0,0 +1,14 @@ +import type { ISetting, SettingValidationRule } from '@rocket.chat/core-typings'; + +export const positiveOrDisabled = (): SettingValidationRule => ({ + query: { $or: [{ value: -1 }, { value: { $gte: 1 } }] }, +}); + +export const notGreaterThanSetting = (otherId: ISetting['_id']): SettingValidationRule => ({ + query: { $or: [{ value: { $lt: 1 } }, { value: { $lte: { $setting: otherId } } }] }, + appliesWhen: { _id: otherId, value: { $gte: 1 } }, +}); + +export const notLowerThanSetting = (otherId: ISetting['_id']): SettingValidationRule => ({ + query: { $or: [{ value: { $lt: 1 } }, { value: { $gte: { $setting: otherId } } }] }, +}); diff --git a/apps/meteor/server/settings/lib/saveSettingsBulk.ts b/apps/meteor/server/settings/lib/saveSettingsBulk.ts index 8d57a038f27f1..2cc22399a6b28 100644 --- a/apps/meteor/server/settings/lib/saveSettingsBulk.ts +++ b/apps/meteor/server/settings/lib/saveSettingsBulk.ts @@ -9,6 +9,7 @@ import { updateAuditedByUser } from './auditedSettingUpdates'; import { getSettingPermissionId } from '../../../app/authorization/lib'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { notifyOnSettingChangedById } from '../../lib/notifyListener'; +import { validateSettingRules } from '../../lib/settingValidationRules'; import { disableCustomScripts } from '../../lib/shared/disableCustomScripts'; import { checkSettingValueBounds } from '../checkSettingValueBonds'; @@ -112,6 +113,8 @@ export const saveSettingsBulk = async ( }); } + validateSettingRules(params); + const auditSettingOperation = updateAuditedByUser({ _id: uid, username: audit.username, diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 69c9e0b863207..62aa727c21542 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -3631,14 +3631,23 @@ describe('[Users]', () => { }); describe('[Password Policy]', () => { + let previousMinLength: Awaited>; + let previousMaxLength: Awaited>; + before(async () => { await updateSetting('Accounts_AllowPasswordChange', true); await updateSetting('Accounts_TwoFactorAuthentication_Enabled', false); + [previousMinLength, previousMaxLength] = await Promise.all([ + getSettingValueById('Accounts_Password_Policy_MinLength'), + getSettingValueById('Accounts_Password_Policy_MaxLength'), + ]); }); after(async () => { await updateSetting('Accounts_AllowPasswordChange', true); await updateSetting('Accounts_TwoFactorAuthentication_Enabled', true); + await updateSetting('Accounts_Password_Policy_MaxLength', previousMaxLength); + await updateSetting('Accounts_Password_Policy_MinLength', previousMinLength); }); it('should throw an error if the password length is less than the minimum length', async () => { @@ -3667,6 +3676,8 @@ describe('[Users]', () => { }); it('should throw an error if the password length is greater than the maximum length', async () => { + // max must stay >= min, so lower the minimum before capping the maximum at 5 + await updateSetting('Accounts_Password_Policy_MinLength', 1); await updateSetting('Accounts_Password_Policy_MaxLength', 5); const expectedError = { diff --git a/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts b/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts new file mode 100644 index 0000000000000..77a763448784e --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/settingValidationRules.spec.ts @@ -0,0 +1,151 @@ +import { expect } from 'chai'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +import { + positiveOrDisabled, + notGreaterThanSetting, + notLowerThanSetting, +} from '../../../../server/settings/functions/validationRuleBuilders'; + +const settingsGetMock = sinon.stub(); +const settingsGetSettingMock = sinon.stub(); + +const { validateSettingRules, SettingValidationError } = p.noCallThru().load('../../../../server/lib/settingValidationRules.ts', { + '@rocket.chat/logger': { + Logger: class { + error = (): undefined => undefined; + }, + }, + '../settings': { settings: { get: settingsGetMock, getSetting: settingsGetSettingMock } }, +}); + +const validationBySettingId: Record = { + Accounts_Password_Policy_MinLength: [positiveOrDisabled(), notGreaterThanSetting('Accounts_Password_Policy_MaxLength')], + Accounts_Password_Policy_MaxLength: [positiveOrDisabled(), notLowerThanSetting('Accounts_Password_Policy_MinLength')], +}; + +describe('validateSettingRules', () => { + beforeEach(() => { + settingsGetMock.reset(); + settingsGetSettingMock.reset(); + + settingsGetSettingMock.callsFake((_id: string) => ({ + _id, + type: 'int', + ...(validationBySettingId[_id] ? { validation: JSON.stringify(validationBySettingId[_id]) } : {}), + })); + }); + + it('rejects an incoherent batch with the failed rule i18n key as the error message', () => { + const error = (() => { + try { + validateSettingRules([ + { _id: 'Accounts_Password_Policy_MinLength', value: 6 }, + { _id: 'Accounts_Password_Policy_MaxLength', value: 4 }, + ]); + } catch (thrown) { + return thrown as Error; + } + })(); + + expect(error).to.be.instanceOf(SettingValidationError); + expect(error?.message).to.equal('Accounts_Password_Policy_MinLength_Invalid'); + }); + + it('resolves values batch-first, not from the stale cached value', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(14); + + expect(() => + validateSettingRules([ + { _id: 'Accounts_Password_Policy_MinLength', value: 6 }, + { _id: 'Accounts_Password_Policy_MaxLength', value: 10 }, + ]), + ).to.not.throw(); + }); + + it('rejects raising the minimum above the stored maximum, with the minimum-side message', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(10); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 12 }])).to.throw( + 'Accounts_Password_Policy_MinLength_Invalid', + ); + }); + + it('rejects lowering the maximum below the stored minimum, with the maximum-side message', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 4 }])).to.throw( + 'Accounts_Password_Policy_MaxLength_Invalid', + ); + }); + + it('treats exactly -1 as a disabled bound, on either side', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(-1); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 20 }])).to.not.throw(); + + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(-1); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 5 }])).to.not.throw(); + + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: -1 }])).to.not.throw(); + }); + + it('rejects bound values below 1 that are not exactly -1', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MaxLength', value: 0 }])).to.throw( + 'Accounts_Password_Policy_MaxLength_Invalid', + ); + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: -5 }])).to.throw( + 'Accounts_Password_Policy_MinLength_Invalid', + ); + }); + + it('validates the pair even when the same batch disables the policy', () => { + expect(() => + validateSettingRules([ + { _id: 'Accounts_Password_Policy_Enabled', value: false }, + { _id: 'Accounts_Password_Policy_MinLength', value: 6 }, + { _id: 'Accounts_Password_Policy_MaxLength', value: 4 }, + ]), + ).to.throw('Accounts_Password_Policy_MinLength_Invalid'); + }); + + it('does not evaluate rules of settings that are not in the batch', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MinLength').returns(6); + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(4); + + expect(() => validateSettingRules([{ _id: 'Some_Unrelated_Setting', value: 10 }])).to.not.throw(); + }); + + it('passes a rule referencing a setting that does not exist', () => { + settingsGetSettingMock.withArgs('Ref_Setting').returns({ + _id: 'Ref_Setting', + type: 'int', + validation: '[{"query":{"value":{"$lte":{"$setting":"Missing_Setting"}}}}]', + }); + + expect(() => validateSettingRules([{ _id: 'Ref_Setting', value: 999 }])).to.not.throw(); + }); + + it('skips malformed persisted rules instead of crashing the save', () => { + const malformed = [ + '[{"query":null}]', + '[{"query":{"value":{"$gte":1}},"appliesWhen":"junk"}]', + '[{"query":{"value":{"$gte":1}},"appliesWhen":{"_id":"Gate"}}]', + ]; + + for (const validation of malformed) { + settingsGetSettingMock.withArgs('Malformed_Setting').returns({ _id: 'Malformed_Setting', type: 'int', validation }); + + expect(() => validateSettingRules([{ _id: 'Malformed_Setting', value: 0 }]), validation).to.not.throw(); + } + }); + + it('does not enforce the value type, leaving those checks to each save path', () => { + settingsGetMock.withArgs('Accounts_Password_Policy_MaxLength').returns(10); + + expect(() => validateSettingRules([{ _id: 'Accounts_Password_Policy_MinLength', value: 5.5 }])).to.not.throw(); + }); +}); diff --git a/packages/core-typings/src/ISetting.ts b/packages/core-typings/src/ISetting.ts index f9340d85ef9a5..69d13ed175a94 100644 --- a/packages/core-typings/src/ISetting.ts +++ b/packages/core-typings/src/ISetting.ts @@ -31,6 +31,13 @@ export type ISetting = ISettingBase | ISettingEnterprise | ISettingColor | ISett type EnableQuery = string | { _id: string; value: any } | { _id: string; value: any }[]; +export type SettingValidationRule = { + query: Record; + appliesWhen?: { _id: string; value: unknown } | { _id: string; value: unknown }[]; +}; + +type SettingValidation = SettingValidationRule[] | string; + export interface ISettingBase extends IRocketChatRecord { type: | 'boolean' @@ -65,6 +72,7 @@ export interface ISettingBase extends IRocketChatRecord { blocked: boolean; enableQuery?: EnableQuery; displayQuery?: EnableQuery; + validation?: SettingValidation; sorter: number; properties?: unknown; enterprise?: boolean; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c914befca05a3..b96c22d3b557d 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -453,8 +453,10 @@ "Accounts_Password_Policy_ForbidRepeatingCharacters_Description": "Ensures passwords do not contain the same character repeating next to each other.", "Accounts_Password_Policy_MaxLength": "Maximum Length", "Accounts_Password_Policy_MaxLength_Description": "Ensures that passwords do not have more than this amount of characters. Use `-1` to disable.", + "Accounts_Password_Policy_MaxLength_Invalid": "Maximum length must be -1 to disable it, or at least 1 and not less than the minimum length.", "Accounts_Password_Policy_MinLength": "Minimum Length", "Accounts_Password_Policy_MinLength_Description": "Ensures that passwords must have at least this amount of characters. Use `-1` to disable.", + "Accounts_Password_Policy_MinLength_Invalid": "Minimum length must be -1 to disable it, or at least 1 and not greater than the maximum length.", "Accounts_RegistrationForm": "Registration Form", "Accounts_RegistrationForm_Disabled": "Disabled", "Accounts_RegistrationForm_LinkReplacementText": "Registration Form Link Replacement Text", From b7bf28492818a0aa7213018d2827dc3189bd42b5 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Sat, 18 Jul 2026 00:48:33 -0300 Subject: [PATCH 07/12] chore: TS7 low-risk type fixes (assertions, casts) (#41264) --- packages/apps-engine/src/definition/api/ApiEndpoint.ts | 2 +- packages/apps/src/server/accessors/DiscussionBuilder.ts | 2 +- packages/http-router/src/Router.ts | 2 +- packages/jest-presets/src/client/jest-setup.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/apps-engine/src/definition/api/ApiEndpoint.ts b/packages/apps-engine/src/definition/api/ApiEndpoint.ts index afdcb1062fce7..737f51c021e72 100644 --- a/packages/apps-engine/src/definition/api/ApiEndpoint.ts +++ b/packages/apps-engine/src/definition/api/ApiEndpoint.ts @@ -9,7 +9,7 @@ export abstract class ApiEndpoint implements IApiEndpoint { * The last part of the api URL. Example: https://{your-server-address}/api/apps/public/{your-app-id}/{path} * or https://{your-server-address}/api/apps/private/{your-app-id}/{private-hash}/{path} */ - public path: string; + public path!: string; constructor(public app: IApp) {} diff --git a/packages/apps/src/server/accessors/DiscussionBuilder.ts b/packages/apps/src/server/accessors/DiscussionBuilder.ts index 4c65baa6c3f04..38391c871d928 100644 --- a/packages/apps/src/server/accessors/DiscussionBuilder.ts +++ b/packages/apps/src/server/accessors/DiscussionBuilder.ts @@ -7,7 +7,7 @@ import type { IRoom } from '@rocket.chat/apps-engine/definition/rooms/IRoom'; import { RoomBuilder } from './RoomBuilder'; export class DiscussionBuilder extends RoomBuilder implements IDiscussionBuilder { - public kind: RocketChatAssociationModel.DISCUSSION; + public declare kind: RocketChatAssociationModel.DISCUSSION; private reply: string; diff --git a/packages/http-router/src/Router.ts b/packages/http-router/src/Router.ts index ebe27c148ea9b..794874587b970 100644 --- a/packages/http-router/src/Router.ts +++ b/packages/http-router/src/Router.ts @@ -159,7 +159,7 @@ export class Router< const contentType = request.header('content-type') || ''; if (contentType.includes('application/json')) { - return await request.raw.clone().json(); + return (await request.raw.clone().json()) as NonNullable; } if (contentType.includes('application/x-www-form-urlencoded')) { diff --git a/packages/jest-presets/src/client/jest-setup.ts b/packages/jest-presets/src/client/jest-setup.ts index f513cfdca20fc..38479ed23b0d8 100644 --- a/packages/jest-presets/src/client/jest-setup.ts +++ b/packages/jest-presets/src/client/jest-setup.ts @@ -68,7 +68,7 @@ globalThis.IntersectionObserver = class IntersectionObserver { unobserve() { return null; } -}; +} as unknown as typeof IntersectionObserver; globalThis.TextEncoder = TextEncoder as any; globalThis.TextDecoder = TextDecoder as any; From 34aa635daff431600a5afdfe38e4e73e3f83eb11 Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:26:50 +0530 Subject: [PATCH 08/12] chore: Ensure page always reloads when app is crashes (#41453) --- apps/meteor/client/views/root/AppErrorPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/client/views/root/AppErrorPage.tsx b/apps/meteor/client/views/root/AppErrorPage.tsx index b3822394eb4fe..aa123aebb9c99 100644 --- a/apps/meteor/client/views/root/AppErrorPage.tsx +++ b/apps/meteor/client/views/root/AppErrorPage.tsx @@ -17,12 +17,12 @@ const AppErrorPage = () => { { const result = indexedDB.deleteDatabase('MeteorDynamicImportCache'); - result.onsuccess = () => { - window.location.reload(); - }; - result.onerror = () => { + const reload = () => { window.location.reload(); }; + result.onsuccess = reload; + result.onerror = reload; + result.onblocked = reload; }} > Reload Application From 74d6cacb77cc2b029dfd1a12890cfcae7fd540cb Mon Sep 17 00:00:00 2001 From: Matheus Cardoso Date: Fri, 17 Jul 2026 16:24:16 -0300 Subject: [PATCH 09/12] feat: no egress for offline licenses (#41148) Co-authored-by: Kevin Aleman --- .changeset/offline-license-no-egress.md | 6 + apps/meteor/ee/server/apps/appRequestsCron.ts | 5 + .../ee/server/apps/communication/rest.ts | 12 +- apps/meteor/ee/server/apps/cron.ts | 5 + .../apps/marketplace/MarketplaceAPIClient.ts | 8 ++ .../apps/marketplace/fetchMarketplaceApps.ts | 7 ++ .../marketplace/fetchMarketplaceCategories.ts | 7 ++ apps/meteor/ee/server/lib/license/startup.ts | 10 ++ .../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 | 13 ++ .../cloud/registerPreIntentWorkspaceWizard.ts | 11 ++ .../lib/cloud/startRegisterWorkspace.ts | 3 + .../startRegisterWorkspaceSetupWizard.ts | 3 + .../supportedVersionsToken.ts | 11 +- .../server/lib/cloud/syncWorkspace/index.ts | 8 ++ apps/meteor/server/lib/cloud/userLogout.ts | 7 ++ .../functions/checkVersionUpdate.ts | 5 + .../server/lib/notifications/push/push.ts | 12 ++ .../statistics/functions/sendUsageReport.ts | 9 +- .../lib/users/getAvatarSuggestionForUser.ts | 11 +- .../server/lib/users/saveUser/saveNewUser.ts | 5 +- apps/meteor/server/main.ts | 9 ++ .../core-apps/cloudAnnouncements.module.ts | 3 + apps/meteor/server/startup/index.ts | 5 +- .../marketplace/MarketplaceAPIClient.spec.ts | 57 +++++++++ .../registerPreIntentWorkspaceWizard.spec.ts | 64 ++++++++++ .../unit/server/lib/cloud/userLogout.spec.ts | 72 +++++++++++ .../offline-license-capability-enforcement.md | 114 ++++++++++++++++++ .../license/src/MockedLicenseBuilder.ts | 5 + ee/packages/license/src/license.spec.ts | 38 ++++++ ee/packages/license/src/license.ts | 4 + 37 files changed, 546 insertions(+), 10 deletions(-) create mode 100644 .changeset/offline-license-no-egress.md create mode 100644 apps/meteor/lib/errors/CloudOfflineLicenseError.ts create mode 100644 apps/meteor/server/lib/cloud/offlineLicense.ts 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 create mode 100644 apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts create mode 100644 docs/proposals/offline-license-capability-enforcement.md diff --git a/.changeset/offline-license-no-egress.md b/.changeset/offline-license-no-egress.md new file mode 100644 index 0000000000000..43b8259ea417f --- /dev/null +++ b/.changeset/offline-license-no-egress.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/license': minor +--- + +Adds support for the `offline` license flag, suppressing every outbound connection to Rocket.Chat Cloud services and the Push Gateway at its source, so air-gapped workspaces never initiate calls that would violate their security compliance. diff --git a/apps/meteor/ee/server/apps/appRequestsCron.ts b/apps/meteor/ee/server/apps/appRequestsCron.ts index 6536d45fd0fe8..dbfb5fb910c51 100644 --- a/apps/meteor/ee/server/apps/appRequestsCron.ts +++ b/apps/meteor/ee/server/apps/appRequestsCron.ts @@ -1,4 +1,5 @@ import { cronJobs } from '@rocket.chat/cron'; +import { License } from '@rocket.chat/license'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; import { appRequestNotififyForUsers } from './marketplace/appRequestNotifyUsers'; @@ -8,6 +9,10 @@ import { settings } from '../../../server/settings'; const appsNotifyAppRequests = async function _appsNotifyAppRequests() { try { + if (License.hasOfflineLicense()) { + return; + } + const installedApps = await Apps.installedApps({ enabled: true }); if (!installedApps || installedApps.length === 0) { return; diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index e9f289ed952da..fb938c74228fd 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -17,6 +17,7 @@ import { registerAppLogsExportHandler } from './endpoints/appLogsExportHandler'; import { registerAppLogsHandler } from './endpoints/appLogsHandler'; import { registerAppsCountHandler } from './endpoints/appsCountHandler'; import { Info } from '../../../../app/utils/rocketchat.info'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; import { API } from '../../../../server/api'; import type { APIClass } from '../../../../server/api/ApiClass'; import { getUploadFormData } from '../../../../server/api/lib/getUploadFormData'; @@ -96,6 +97,13 @@ export class AppsRestApi { const manager = this._manager; const handleError = (message: string, err: any) => { + // Offline (air-gapped) licenses suppress marketplace requests at the source; + // report the real reason instead of a generic connectivity failure. + if (err instanceof CloudOfflineLicenseError) { + orchestrator.getRocketChatLogger().info({ msg: err.message }); + return API.v1.failure({ error: err.message }); + } + // when there is no `response` field in the error, it means the request // couldn't even make it to the server if (!err.hasOwnProperty('response')) { @@ -149,7 +157,7 @@ export class AppsRestApi { const apps = await fetchMarketplaceApps({ ...(this.queryParams.isAdminUser === 'false' && { endUserID: this.user._id }) }); return API.v1.success(apps); } catch (err) { - if (err instanceof MarketplaceConnectionError) { + if (err instanceof MarketplaceConnectionError || err instanceof CloudOfflineLicenseError) { return handleError('Unable to access Marketplace. Does the server has access to the internet?', err); } @@ -178,7 +186,7 @@ export class AppsRestApi { return API.v1.success(categories); } catch (err) { orchestrator.getRocketChatLogger().error({ msg: 'Error fetching categories from Marketplace:', err }); - if (err instanceof MarketplaceConnectionError) { + if (err instanceof MarketplaceConnectionError || err instanceof CloudOfflineLicenseError) { return handleError('Unable to access Marketplace. Does the server has access to the internet?', err); } diff --git a/apps/meteor/ee/server/apps/cron.ts b/apps/meteor/ee/server/apps/cron.ts index fc655f087dbd3..8dad4b557d554 100644 --- a/apps/meteor/ee/server/apps/cron.ts +++ b/apps/meteor/ee/server/apps/cron.ts @@ -1,6 +1,7 @@ import type { ProxiedApp } from '@rocket.chat/apps/dist/server/ProxiedApp'; import { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import { cronJobs } from '@rocket.chat/cron'; +import { License } from '@rocket.chat/license'; import { Settings, Users } from '@rocket.chat/models'; import { Apps } from './orchestrator'; @@ -75,6 +76,10 @@ const notifyAdminsAboutRenewedApps = async function _notifyAdminsAboutRenewedApp }; const appsUpdateMarketplaceInfo = async function _appsUpdateMarketplaceInfo() { + if (License.hasOfflineLicense()) { + return; + } + const token = await getWorkspaceAccessToken(); const workspaceIdSetting = await Settings.getValueById('Cloud_Workspace_Id'); diff --git a/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts b/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts index af95b1979fce4..f5c86e4f56620 100644 --- a/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts +++ b/apps/meteor/ee/server/apps/marketplace/MarketplaceAPIClient.ts @@ -1,6 +1,8 @@ +import { License } from '@rocket.chat/license'; import { type ExtendedFetchOptions, Response, serverFetch } from '@rocket.chat/server-fetch'; import { isTesting } from './isTesting'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; export class MarketplaceAPIClient { #fetchStrategy: (input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean) => Promise; @@ -41,6 +43,12 @@ export class MarketplaceAPIClient { } public fetch(input: string, options?: ExtendedFetchOptions, allowSelfSignedCerts?: boolean): ReturnType { + if (License.hasOfflineLicense()) { + return Promise.reject( + new CloudOfflineLicenseError('Marketplace connectivity is disabled by the offline license applied to this workspace'), + ); + } + if (!input.startsWith('http://') && !input.startsWith('https://')) { input = this.getMarketplaceUrl().concat(!input.startsWith('/') ? '/' : '', input); } diff --git a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts index 6b2f4d39404b7..deaee1f722ed4 100644 --- a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts +++ b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; import { getMarketplaceHeaders } from './getMarketplaceHeaders'; import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; import { getWorkspaceAccessToken } from '../../../../server/lib/cloud'; import { settings } from '../../../../server/settings'; import { Apps } from '../orchestrator'; @@ -157,6 +158,12 @@ export async function fetchMarketplaceApps({ endUserID }: FetchMarketplaceAppsPa }, }); } catch (error) { + // Offline (air-gapped) licenses reject before any request is made; keep the + // typed error so the REST layer can report the real reason instead of a + // generic connectivity failure. + if (error instanceof CloudOfflineLicenseError) { + throw error; + } throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection'); } diff --git a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts index 295b9ec50975e..e2964a47f795a 100644 --- a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts +++ b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceCategories.ts @@ -3,6 +3,7 @@ import * as z from 'zod'; import { getMarketplaceHeaders } from './getMarketplaceHeaders'; import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors'; +import { CloudOfflineLicenseError } from '../../../../lib/errors/CloudOfflineLicenseError'; import { getWorkspaceAccessToken } from '../../../../server/lib/cloud'; import { settings } from '../../../../server/settings'; import { Apps } from '../orchestrator'; @@ -41,6 +42,12 @@ export async function fetchMarketplaceCategories(): Promise { allowList: settings.get('SSRF_Allowlist'), }); } catch (error) { + // Offline (air-gapped) licenses reject before any request is made; keep the + // typed error so the REST layer can report the real reason instead of a + // generic connectivity failure. + if (error instanceof CloudOfflineLicenseError) { + throw error; + } throw new MarketplaceConnectionError('Marketplace_Bad_Marketplace_Connection'); } diff --git a/apps/meteor/ee/server/lib/license/startup.ts b/apps/meteor/ee/server/lib/license/startup.ts index 4131d1752ee4a..75f7a4993de66 100644 --- a/apps/meteor/ee/server/lib/license/startup.ts +++ b/apps/meteor/ee/server/lib/license/startup.ts @@ -8,6 +8,7 @@ import moment from 'moment'; import { getAppCount } from './lib/getAppCount'; import { callbacks } from '../../../../server/lib/callbacks'; import { syncWorkspace } from '../../../../server/lib/cloud/syncWorkspace'; +import { SystemLogger } from '../../../../server/lib/logger/system'; import { notifyOnSettingChangedById } from '../../../../server/lib/notifyListener'; import { settings } from '../../../../server/settings'; @@ -125,6 +126,15 @@ export const startLicense = async () => { } } + License.onInstall(() => { + if (License.hasOfflineLicense()) { + // startup level so it is visible at the default Log_Level, like 'License installed' + SystemLogger.startup( + 'Offline license detected: outbound connections to Rocket.Chat Cloud services and the Rocket.Chat Push Gateway are disabled', + ); + } + }); + // After the current license is already loaded, watch the setting value to react to new licenses being applied. settings.change('Enterprise_License', (license) => applyLicenseOrRemove(license, true)); diff --git a/apps/meteor/lib/errors/CloudOfflineLicenseError.ts b/apps/meteor/lib/errors/CloudOfflineLicenseError.ts new file mode 100644 index 0000000000000..93ab61cbf3ef0 --- /dev/null +++ b/apps/meteor/lib/errors/CloudOfflineLicenseError.ts @@ -0,0 +1,5 @@ +import { CloudWorkspaceError } from './CloudWorkspaceError'; + +export class CloudOfflineLicenseError extends CloudWorkspaceError { + override name = CloudOfflineLicenseError.name; +} diff --git a/apps/meteor/server/lib/cloud/connectWorkspace.ts b/apps/meteor/server/lib/cloud/connectWorkspace.ts index a3f283a90af52..c8f62bbb52fd2 100644 --- a/apps/meteor/server/lib/cloud/connectWorkspace.ts +++ b/apps/meteor/server/lib/cloud/connectWorkspace.ts @@ -1,6 +1,7 @@ import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getRedirectUri } from './getRedirectUri'; +import { assertNotOfflineLicense } from './offlineLicense'; import { saveRegistrationData } from './saveRegistrationData'; import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspaceConnectionError'; import { settings } from '../../settings'; @@ -47,6 +48,8 @@ const fetchRegistrationDataPayload = async ({ }; export async function connectWorkspace(token: string) { + assertNotOfflineLicense(); + if (!token) { throw new CloudWorkspaceConnectionError('Invalid registration token'); } diff --git a/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts b/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts index 60078b1d2eaa2..cd0e1d3cb13ed 100644 --- a/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts +++ b/apps/meteor/server/lib/cloud/finishOAuthAuthorization.ts @@ -4,10 +4,13 @@ import { Meteor } from 'meteor/meteor'; import { getRedirectUri } from './getRedirectUri'; import { userScopes } from './oauthScopes'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function finishOAuthAuthorization(code: string, state: string) { + assertNotOfflineLicense(); + if (settings.get('Cloud_Workspace_Registration_State') !== state) { throw new Meteor.Error('error-invalid-state', 'Invalid state provided', { method: 'cloud:finishOAuthAuthorization', diff --git a/apps/meteor/server/lib/cloud/getConfirmationPoll.ts b/apps/meteor/server/lib/cloud/getConfirmationPoll.ts index b03e263d2ef1f..1c965f5fdcd3e 100644 --- a/apps/meteor/server/lib/cloud/getConfirmationPoll.ts +++ b/apps/meteor/server/lib/cloud/getConfirmationPoll.ts @@ -1,10 +1,13 @@ import type { CloudConfirmationPollData } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function getConfirmationPoll(deviceCode: string): Promise { + assertNotOfflineLicense(); + try { const cloudUrl = settings.get('Cloud_Url'); const response = await fetch(`${cloudUrl}/api/v2/register/workspace/poll`, { diff --git a/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts b/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts index cb60238175061..38ebac82a70fb 100644 --- a/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts +++ b/apps/meteor/server/lib/cloud/getOAuthAuthorizationUrl.ts @@ -3,11 +3,14 @@ import { Random } from '@rocket.chat/random'; import { getRedirectUri } from './getRedirectUri'; import { userScopes } from './oauthScopes'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { updateAuditedBySystem } from '../../settings/lib/auditedSettingUpdates'; import { notifyOnSettingChangedById } from '../notifyListener'; export async function getOAuthAuthorizationUrl() { + assertNotOfflineLicense(); + const state = Random.id(); await updateAuditedBySystem({ diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts index e88b05319be07..e49913bd56f77 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessToken.ts @@ -1,4 +1,5 @@ import type { IWorkspaceCredentials } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { WorkspaceCredentials } from '@rocket.chat/models'; import { getWorkspaceAccessTokenWithScope } from './getWorkspaceAccessTokenWithScope'; @@ -26,6 +27,10 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save return ''; } + if (License.hasOfflineLicense()) { + return ''; + } + // Note: If no scope is given, it means we should assume the default scope, we store the default scopes // in the global variable workspaceScopes. if (scope === '') { diff --git a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts index 40f9bdd760b31..bf8693eea7278 100644 --- a/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts +++ b/apps/meteor/server/lib/cloud/getWorkspaceAccessTokenWithScope.ts @@ -1,3 +1,4 @@ +import { License } from '@rocket.chat/license'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { getRedirectUri } from './getRedirectUri'; @@ -31,6 +32,10 @@ export async function getWorkspaceAccessTokenWithScope({ return tokenResponse; } + if (License.hasOfflineLicense()) { + return tokenResponse; + } + // eslint-disable-next-line @typescript-eslint/naming-convention const client_id = settings.get('Cloud_Workspace_Client_Id'); if (!client_id) { diff --git a/apps/meteor/server/lib/cloud/offlineLicense.ts b/apps/meteor/server/lib/cloud/offlineLicense.ts new file mode 100644 index 0000000000000..7a3b5f52b01a4 --- /dev/null +++ b/apps/meteor/server/lib/cloud/offlineLicense.ts @@ -0,0 +1,13 @@ +import { License } from '@rocket.chat/license'; + +import { CloudOfflineLicenseError } from '../../../lib/errors/CloudOfflineLicenseError'; + +/** + * Guard for interactive cloud flows (registration, OAuth, billing). Background + * jobs should instead skip silently by checking {@link License.hasOfflineLicense}. + */ +export function assertNotOfflineLicense(): void { + if (License.hasOfflineLicense()) { + throw new CloudOfflineLicenseError('Cloud connectivity is disabled by the offline license applied to this workspace'); + } +} diff --git a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts index 9e1ded06da825..b85311fd731d2 100644 --- a/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts +++ b/apps/meteor/server/lib/cloud/registerPreIntentWorkspaceWizard.ts @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -7,6 +8,10 @@ import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function registerPreIntentWorkspaceWizard(): Promise { + if (License.hasOfflineLicense()) { + return false; + } + const firstUser = (await Users.getOldest({ projection: { name: 1, emails: 1 } })) as IUser | undefined; const email = firstUser?.emails?.find((address) => address)?.address; @@ -16,6 +21,12 @@ export async function registerPreIntentWorkspaceWizard(): Promise { const regInfo = await buildWorkspaceRegistrationData(email); + // Re-validated at dispatch time: an offline license applied while the + // registration data was being built must still suppress the request. + if (License.hasOfflineLicense()) { + return false; + } + try { const cloudUrl = settings.get('Cloud_Url'); const response = await fetch(`${cloudUrl}/api/v2/register/workspace/pre-intent`, { diff --git a/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts b/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts index 09713b271e9b1..399c54c7ffe83 100644 --- a/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts +++ b/apps/meteor/server/lib/cloud/startRegisterWorkspace.ts @@ -2,6 +2,7 @@ import { Settings } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { assertNotOfflineLicense } from './offlineLicense'; import { retrieveRegistrationStatus } from './retrieveRegistrationStatus'; import { syncWorkspace } from './syncWorkspace'; import { settings } from '../../settings'; @@ -10,6 +11,8 @@ import { SystemLogger } from '../logger/system'; import { notifyOnSettingChangedById } from '../notifyListener'; export async function startRegisterWorkspace(resend = false) { + assertNotOfflineLicense(); + const { workspaceRegistered } = await retrieveRegistrationStatus(); if (workspaceRegistered || process.env.TEST_MODE) { await syncWorkspace(); diff --git a/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts b/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts index 1eb0c906c3e71..9e461af7825b4 100644 --- a/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts +++ b/apps/meteor/server/lib/cloud/startRegisterWorkspaceSetupWizard.ts @@ -2,10 +2,13 @@ import type { CloudRegistrationIntentData } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { buildWorkspaceRegistrationData } from './buildRegistrationData'; +import { assertNotOfflineLicense } from './offlineLicense'; import { settings } from '../../settings'; import { SystemLogger } from '../logger/system'; export async function startRegisterWorkspaceSetupWizard(resend = false, email: string): Promise { + assertNotOfflineLicense(); + const regInfo = await buildWorkspaceRegistrationData(email); let payload; diff --git a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts index db0ae510fbe72..dd9118a453c34 100644 --- a/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/server/lib/cloud/supportedVersionsToken/supportedVersionsToken.ts @@ -114,6 +114,12 @@ const getSupportedVersionsFromCloud = async () => { const headers = await generateWorkspaceBearerHttpHeader(); + // Re-validated at dispatch time: an offline license applied while this async + // operation was in flight must still suppress the request. + if (License.hasOfflineLicense()) { + return { success: true, result: undefined } as const; + } + const response = await handleResponse( fetch(releaseEndpoint, { headers, @@ -141,7 +147,10 @@ const getSupportedVersionsToken = async (retry = 0) => { * Gets the latest version * return the token */ - const [versionsFromLicense, cloudResponse] = await Promise.all([License.getLicense(), getSupportedVersionsFromCloud()]); + const [versionsFromLicense, cloudResponse] = await Promise.all([ + License.getLicense(), + License.hasOfflineLicense() ? ({ success: true, result: undefined } as const) : getSupportedVersionsFromCloud(), + ]); const supportedVersions = await supportedVersionsChooseLatest( supportedVersionsFromBuild, diff --git a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts index 3f30e557a6b68..aa76435e2b18e 100644 --- a/apps/meteor/server/lib/cloud/syncWorkspace/index.ts +++ b/apps/meteor/server/lib/cloud/syncWorkspace/index.ts @@ -1,3 +1,5 @@ +import { License } from '@rocket.chat/license'; + import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError'; import { SystemLogger } from '../../logger/system'; import { CloudWorkspaceAccessTokenEmptyError, CloudWorkspaceAccessTokenError, isAbortError } from '../getWorkspaceAccessToken'; @@ -12,6 +14,12 @@ import { getCachedSupportedVersionsToken } from '../supportedVersionsToken/suppo * @throws {Error} - If there is an unexpected error during sync like a network error */ export async function syncWorkspace() { + if (License.hasOfflineLicense()) { + SystemLogger.debug({ msg: 'Skipping cloud sync: workspace has an offline license', function: 'syncWorkspace' }); + await getCachedSupportedVersionsToken.reset(); + return; + } + try { await announcementSync(); await syncCloudData(); diff --git a/apps/meteor/server/lib/cloud/userLogout.ts b/apps/meteor/server/lib/cloud/userLogout.ts index cf3e8124621d2..ed2f0bee6d58c 100644 --- a/apps/meteor/server/lib/cloud/userLogout.ts +++ b/apps/meteor/server/lib/cloud/userLogout.ts @@ -1,3 +1,4 @@ +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -17,6 +18,12 @@ export async function userLogout(userId: string): Promise { return ''; } + // Offline (air-gapped) licenses forbid the outbound token-revocation call, but + // the local cloud credentials must still be destroyed on logout. + if (License.hasOfflineLicense()) { + return userLoggedOut(userId); + } + const user = await Users.findOneById(userId); if (user?.services?.cloud?.refreshToken) { diff --git a/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts b/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts index e494cd4324d9a..f238c8c3401ad 100644 --- a/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts +++ b/apps/meteor/server/lib/cloud/version-check/functions/checkVersionUpdate.ts @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import { i18n } from '../../../i18n'; @@ -43,6 +44,10 @@ const getMessagesToSendToAdmins = async ( * @deprecated */ export const checkVersionUpdate = async () => { + if (License.hasOfflineLicense()) { + return; + } + logger.info('Checking for version updates'); const { versions, alerts } = await getNewUpdates(); diff --git a/apps/meteor/server/lib/notifications/push/push.ts b/apps/meteor/server/lib/notifications/push/push.ts index bd325d9df1d27..de643fb3e82ab 100644 --- a/apps/meteor/server/lib/notifications/push/push.ts +++ b/apps/meteor/server/lib/notifications/push/push.ts @@ -1,4 +1,5 @@ import type { IPushToken, RequiredField, Optional, IPushNotificationConfig } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { PushToken } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings'; import type { ExtendedFetchOptions } from '@rocket.chat/server-fetch'; @@ -181,6 +182,10 @@ class PushClass { } private shouldUseGateway(): boolean { + if (License.hasOfflineLicense()) { + return false; + } + return Boolean(!!this.options.gateways && settings.get('Register_Server') && settings.get('Cloud_Service_Agree_PrivacyTerms')); } @@ -397,6 +402,13 @@ class PushClass { continue; } + // The workspace is configured to send through a push gateway, but the offline + // license forbids contacting it — skip quietly instead of falling back to the + // (unconfigured) native providers. + if (this.options.gateways && License.hasOfflineLicense()) { + continue; + } + await this.sendNotificationNative(app, notification, countApn, countGcm); } diff --git a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts index 0594c917754ae..374a560bfb9e2 100644 --- a/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts +++ b/apps/meteor/server/lib/statistics/functions/sendUsageReport.ts @@ -1,4 +1,5 @@ import type { IStats } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import type { Logger } from '@rocket.chat/logger'; import { Statistics } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; @@ -40,7 +41,9 @@ async function sendStats(logger: Logger, cronStatistics: IStats): Promise { // Even when disabled, we still generate statistics locally to avoid breaking // internal processes, such as restriction checks for air-gapped workspaces. - const shouldSendToCollector = shouldReportStatistics(); + // Evaluated at send time (not entry) so the verdict cannot go stale while the + // statistics are being generated — e.g. an offline license applied during startup. + const shouldSendToCollector = () => shouldReportStatistics() && !License.hasOfflineLicense(); return tracerSpan('generateStatistics', {}, async () => { const last = await Statistics.findLast(); @@ -59,7 +62,7 @@ export async function sendUsageReport(logger: Logger): Promise 0) { for (const email of user.emails) { if (email.verified === true) { diff --git a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts index d20e02ee043d1..367a7bf8dd3c8 100644 --- a/apps/meteor/server/lib/users/saveUser/saveNewUser.ts +++ b/apps/meteor/server/lib/users/saveUser/saveNewUser.ts @@ -1,4 +1,5 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; import { Users } from '@rocket.chat/models'; import Gravatar from 'gravatar'; import { Accounts } from 'meteor/accounts-base'; @@ -70,7 +71,9 @@ export const saveNewUser = async function (userData: SaveUserData, sendPassword: userData._id = _id; - if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email) { + // Offline (air-gapped) licenses suppress the default Gravatar fetch — a + // default-on outbound call the workspace must never initiate on its own. + if (settings.get('Accounts_SetDefaultAvatar') === true && userData.email && !License.hasOfflineLicense()) { const gravatarUrl = Gravatar.url(userData.email, { default: '404', size: '200', diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 17f2557a4e4f8..70e26cc4d0881 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -9,9 +9,11 @@ import './settings/definitions'; import { startRestAPI } from './api/api'; import { configureServer } from './configuration'; +import { SystemLogger } from './lib/logger/system'; import { registerServices } from './services/startup'; import { settings } from './settings'; import { startup } from './startup'; +import { startCronJobs } from './startup/cron'; import { startupApp } from '../ee/server'; import { startRocketChat } from '../startRocketChat'; @@ -28,5 +30,12 @@ import './features/EmailInbox/index'; await Promise.all([configureServer(settings), registerServices(), startup()]); await startRocketChat(); + +setImmediate(() => { + startCronJobs().catch((err) => { + SystemLogger.error({ msg: 'Failed to start cron jobs', err }); + }); +}); + await startupApp(); await startRestAPI(); diff --git a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts index 560b250e52cbe..e72a83732d4f4 100644 --- a/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts +++ b/apps/meteor/server/modules/core-apps/cloudAnnouncements.module.ts @@ -15,6 +15,7 @@ import { CloudWorkspaceConnectionError } from '../../../lib/errors/CloudWorkspac import { InvalidCloudAnnouncementInteractionError } from '../../../lib/errors/InvalidCloudAnnouncementInteractionError'; import { InvalidCoreAppInteractionError } from '../../../lib/errors/InvalidCoreAppInteractionError'; import { getWorkspaceAccessToken } from '../../lib/cloud'; +import { assertNotOfflineLicense } from '../../lib/cloud/offlineLicense'; import { syncWorkspace } from '../../lib/cloud/syncWorkspace'; import { SystemLogger } from '../../lib/logger/system'; import { settings } from '../../settings'; @@ -255,6 +256,8 @@ export class CloudAnnouncementsModule implements IUiKitCoreApp { interactant: CloudAnnouncementInteractant, userInteraction: UiKit.UserInteraction, ): Promise { + assertNotOfflineLicense(); + const token = await this.getWorkspaceAccessToken(); const request: CloudAnnouncementInteractionRequest = { diff --git a/apps/meteor/server/startup/index.ts b/apps/meteor/server/startup/index.ts index b5fc18d36a9b5..a30733674c943 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'; @@ -19,7 +18,9 @@ export const startup = async () => { await generateFederationKeys(); - setImmediate(() => startCronJobs()); + // NOTE: cron jobs are started from main.ts after startRocketChat(), so jobs + // that contact Rocket.Chat Cloud on boot (e.g. the usage report) run only + // after the license — including the offline flag — has been applied. setImmediate(() => ensureMessagesTextIndex()); // only starts network broker if running in micro services mode if (!isRunningMs()) { diff --git a/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts b/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts new file mode 100644 index 0000000000000..dd7f6df7f61f6 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/apps/marketplace/MarketplaceAPIClient.spec.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const serverFetchStub = sinon.stub(); + +class CloudOfflineLicenseError extends Error {} + +const { MarketplaceAPIClient } = proxyquire.noCallThru().load('../../../../../../ee/server/apps/marketplace/MarketplaceAPIClient.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/server-fetch': { serverFetch: serverFetchStub, Response: class {} }, + './isTesting': { isTesting: () => false }, + '../../../../lib/errors/CloudOfflineLicenseError': { CloudOfflineLicenseError }, +}); + +describe('MarketplaceAPIClient', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + serverFetchStub.reset(); + }); + + it('should reject with CloudOfflineLicenseError and never invoke the fetch strategy when the license is offline', async () => { + hasOfflineLicenseStub.returns(true); + + const client = new MarketplaceAPIClient(); + + await expect(client.fetch('v1/apps')).to.be.rejectedWith(CloudOfflineLicenseError); + expect(serverFetchStub.called).to.be.false; + }); + + it('should re-evaluate the license on every call, not cache the verdict on the instance', async () => { + const client = new MarketplaceAPIClient(); + + hasOfflineLicenseStub.returns(false); + serverFetchStub.resolves({ status: 200 }); + await client.fetch('v1/apps'); + expect(serverFetchStub.calledOnce).to.be.true; + + // license flips to offline at runtime: the same instance must now reject + hasOfflineLicenseStub.returns(true); + await expect(client.fetch('v1/apps')).to.be.rejectedWith(CloudOfflineLicenseError); + expect(serverFetchStub.calledOnce).to.be.true; + }); + + it('should fetch from the marketplace when the license is not offline', async () => { + hasOfflineLicenseStub.returns(false); + serverFetchStub.resolves({ status: 200 }); + + const client = new MarketplaceAPIClient(); + await client.fetch('v1/apps'); + + expect(serverFetchStub.calledOnce).to.be.true; + expect(serverFetchStub.firstCall.args[0]).to.equal('https://marketplace.rocket.chat/v1/apps'); + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts new file mode 100644 index 0000000000000..644bc279468e7 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/registerPreIntentWorkspaceWizard.spec.ts @@ -0,0 +1,64 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const getOldestStub = sinon.stub(); +const fetchStub = sinon.stub(); +const buildRegistrationDataStub = sinon.stub(); + +const { registerPreIntentWorkspaceWizard } = proxyquire + .noCallThru() + .load('../../../../../server/lib/cloud/registerPreIntentWorkspaceWizard.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/models': { Users: { getOldest: getOldestStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './buildRegistrationData': { buildWorkspaceRegistrationData: buildRegistrationDataStub }, + '../../settings': { settings: { get: sinon.stub().returns('https://cloud.rocket.chat') } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, + }); + +describe('registerPreIntentWorkspaceWizard', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + getOldestStub.reset(); + fetchStub.reset(); + buildRegistrationDataStub.reset(); + }); + + it('should return false without querying users or fetching when the license is offline', async () => { + hasOfflineLicenseStub.returns(true); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.false; + expect(getOldestStub.called).to.be.false; + expect(fetchStub.called).to.be.false; + }); + + it('should not fetch when an offline license is applied while registration data is being built', async () => { + // entry check passes, dispatch-time re-check catches the license change mid-flight + hasOfflineLicenseStub.onFirstCall().returns(false); + hasOfflineLicenseStub.onSecondCall().returns(true); + getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] }); + buildRegistrationDataStub.resolves({}); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.false; + expect(fetchStub.called).to.be.false; + }); + + it('should contact the cloud when the license is not offline', async () => { + hasOfflineLicenseStub.returns(false); + getOldestStub.resolves({ emails: [{ address: 'admin@example.com' }] }); + buildRegistrationDataStub.resolves({}); + fetchStub.resolves({ ok: true }); + + const result = await registerPreIntentWorkspaceWizard(); + + expect(result).to.be.true; + expect(fetchStub.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts new file mode 100644 index 0000000000000..e5cd54e72722d --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/cloud/userLogout.spec.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const hasOfflineLicenseStub = sinon.stub(); +const retrieveRegistrationStatusStub = sinon.stub(); +const userLoggedOutStub = sinon.stub(); +const fetchStub = sinon.stub(); +const findOneByIdStub = sinon.stub(); +const settingsGetStub = sinon.stub(); + +const { userLogout } = proxyquire.noCallThru().load('../../../../../server/lib/cloud/userLogout.ts', { + '@rocket.chat/license': { License: { hasOfflineLicense: hasOfflineLicenseStub } }, + '@rocket.chat/models': { Users: { findOneById: findOneByIdStub } }, + '@rocket.chat/server-fetch': { serverFetch: fetchStub }, + './retrieveRegistrationStatus': { retrieveRegistrationStatus: retrieveRegistrationStatusStub }, + './userLoggedOut': { userLoggedOut: userLoggedOutStub }, + '../../settings': { settings: { get: settingsGetStub } }, + '../logger/system': { SystemLogger: { error: sinon.stub() } }, +}); + +describe('userLogout', () => { + beforeEach(() => { + hasOfflineLicenseStub.reset(); + retrieveRegistrationStatusStub.reset(); + userLoggedOutStub.reset(); + fetchStub.reset(); + findOneByIdStub.reset(); + settingsGetStub.reset(); + }); + + it('should still destroy local cloud credentials on logout under an offline license, without calling the revocation endpoint', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true }); + hasOfflineLicenseStub.returns(true); + userLoggedOutStub.resolves(true); + + const result = await userLogout('user-id'); + + expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true; + expect(fetchStub.called).to.be.false; + expect(result).to.be.true; + }); + + it('should revoke the refresh token and destroy local cloud credentials when the license is not offline', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: true }); + hasOfflineLicenseStub.returns(false); + findOneByIdStub.resolves({ _id: 'user-id', services: { cloud: { refreshToken: 'refresh-token' } } }); + settingsGetStub.withArgs('Cloud_Workspace_Client_Id').returns('client-id'); + settingsGetStub.withArgs('Cloud_Workspace_Client_Secret').returns('client-secret'); + settingsGetStub.withArgs('Cloud_Url').returns('https://cloud.rocket.chat'); + fetchStub.resolves({}); + userLoggedOutStub.resolves(true); + + const result = await userLogout('user-id'); + + expect(fetchStub.calledOnce).to.be.true; + expect(fetchStub.firstCall.args[0]).to.equal('https://cloud.rocket.chat/api/oauth/revoke'); + expect(userLoggedOutStub.calledOnceWithExactly('user-id')).to.be.true; + expect(result).to.be.true; + }); + + it('should do nothing when the workspace is not registered', async () => { + retrieveRegistrationStatusStub.resolves({ workspaceRegistered: false }); + + const result = await userLogout('user-id'); + + expect(result).to.equal(''); + expect(fetchStub.called).to.be.false; + expect(userLoggedOutStub.called).to.be.false; + }); +}); diff --git a/docs/proposals/offline-license-capability-enforcement.md b/docs/proposals/offline-license-capability-enforcement.md new file mode 100644 index 0000000000000..99e6d624eb176 --- /dev/null +++ b/docs/proposals/offline-license-capability-enforcement.md @@ -0,0 +1,114 @@ +# Proposal: License Capability Enforcement via Branded Proofs + +## Status + +Draft — v2. Supersedes the earlier `CloudConnection`-only draft: the single-purpose connection capability is generalized into one proof mechanism that can guard any license-controlled capability, with the specific entitlement captured in the type. + +## Problem + +License enforcement today is a runtime side-condition the compiler knows nothing about, in two distinct places: + +1. **Module entitlements.** `License.hasModule('auditing')` returns a `boolean`. Nothing ties that boolean to the code it guards: a feature entry point can be called without any check, a check for the *wrong* module still typechecks, and a refactor that moves code out from under its `if` block breaks enforcement silently. +2. **Offline (air-gapped) licenses.** When `license.information.offline` is true, the workspace must never initiate outbound connections to Rocket.Chat-owned endpoints. This is enforced by ~15 scattered `hasOfflineLicense()` checks (sync, marketplace, telemetry, push gateway, Gravatar). Any new feature can `import { serverFetch }` and ship a compliance violation no type error catches — and two real bugs of exactly this class were found during QA (a startup race in the usage report, and a stale verdict held by the push retry chain). + +Both are the same underlying flaw: **the license check produces no evidence**. Nothing forces the check to happen, to happen for the right entitlement, or to happen at the right time. + +## Proposed Solution + +Make every license check return an unforgeable **proof value**, and make guarded code demand that proof in its signature. The pattern has three parts. + +### 1. The lock — branded proof types + +One brand mechanism, parameterized by what it proves. The generic parameter captures the exact literal passed to the checker, so proofs for different modules are not interchangeable: + +```ts +// packages/core-typings/src/license/LicenseModule.ts (already exists) +export type InternalModuleName = (typeof CoreModules)[number]; +export type ExternalModuleName = `${string}.${string}`; +export type LicenseModule = InternalModuleName | ExternalModuleName; + +// ee/packages/license/src/proofs.ts (new) +declare const LicenseAuthorized: unique symbol; // NOT exported — unforgeable outside the package + +/** Proof that the current license grants module M. */ +export type ModuleProof = { + readonly [LicenseAuthorized]: M; +}; + +/** Proof that the current license permits outbound calls to Rocket.Chat-owned + * endpoints (absent when the license carries the offline flag). */ +export type CloudEgressProof = { + readonly [LicenseAuthorized]: 'cloud:egress'; +}; +``` + +The brand property is phantom — no such field exists at runtime. Proofs are frozen empty objects: zero allocation cost (a shared singleton per kind), zero serialization surface. + +### 2. The keymaker — the license package owns construction + +The only casts live inside `ee/packages/license`, next to the state they attest to: + +```ts +// on LicenseManager — additive API; the existing boolean hasModule() stays +public proveModule(module: M): ModuleProof | undefined { + return this.hasModule(module) ? (PROOF as ModuleProof) : undefined; +} + +public proveCloudEgress(): CloudEgressProof | undefined { + return this.hasOfflineLicense() ? undefined : (PROOF as CloudEgressProof); +} +``` + +Design decisions: + +- **Additive, not a signature change.** `hasModule(): boolean` has hundreds of call sites, including display logic and the REST surface the client consumes. Those keep the boolean. `proveModule()` is what *server-side entry points* migrate to. +- **Synchronous.** License state is in memory; proofs are free to acquire, which matters for the per-attempt rule below. +- **The offline gate is a proof family, not a module.** The offline flag has inverted semantics — it *revokes* a permission rather than granting a feature — but the same brand expresses it: `proveCloudEgress()` returns `undefined` exactly when the offline license forbids egress. A `proveCloudEgressOrThrow(message?)` variant throws the existing `CloudOfflineLicenseError` for interactive flows (registration, cloud login, billing), preserving today's error contract. + +### 3. The guard — signatures demand proofs + +```ts +// Module-gated feature entry point: +function initAuditing(proof: ModuleProof<'auditing'>) { ... } + +initAuditing(); // ❌ compile error: expected 1 argument +const token = License.proveModule('auditing'); +initAuditing(token); // ❌ compile error: possibly undefined +if (token) { + initAuditing(token); // ✅ narrowed to ModuleProof<'auditing'> +} + +// Cloud I/O — the single fetch wrapper in apps/meteor/server/lib/cloud/cloudClient.ts: +export function cloudFetch(proof: CloudEgressProof, input: string, options?: ExtendedFetchOptions): Promise; +``` + +The developer experience does the enforcement: to call the function you need the token; the only way to get the token is the checker whose name and JSDoc explain the rule; the `| undefined` return forces an explicit decision about the denied case (skip silently vs. throw); and truthiness narrowing makes the happy path read naturally. + +The proof is compile-time evidence, not the runtime gate itself: `cloudFetch` **re-validates `hasOfflineLicense()` immediately before dispatching** and drops the request if the license changed while the operation was in flight. The proof parameter guarantees the check can't be forgotten; the dispatch-time re-check guarantees it can't go stale. + +Inner helpers that are only reachable from a guarded entry point take the proof as a parameter rather than re-checking — the signature documents "this function performs cloud I/O / module-X work", and the compiler walks the requirement up the call graph to wherever the proof is legitimately acquired. + +## The staleness rule: acquire per attempt, never cache + +Licenses change at runtime — swapped, upgraded, invalidated, or an offline license applied mid-flight. A proof is a **point-in-time verdict**, and both QA-discovered bugs in the offline work were staleness bugs: + +- the usage report evaluated its gate at function entry, then spent seconds generating statistics before fetching (the verdict predated license application); +- the push gateway retry chain captured its decision in `setTimeout` closures that outlived a license change. + +Rules, to be stated in the proofs' JSDoc and enforced in review: + +1. Acquire the proof at the top of each operation *attempt*; retry closures re-acquire on every attempt (the factories are sync — this costs nothing). +2. Never store a proof on a class field, module scope, or queue payload. +3. A proof is not a subscription. Long-lived module features (services started when a module is granted) must still use the existing lifecycle events — `License.onValidFeature` / `onInvalidFeature` / `onToggledFeature` — for startup and teardown. Proofs guard entry points; events manage lifetimes. +4. Proofs are server-only and must never cross the API boundary; the client keeps boolean checks driven by `licenses.info`. + +## Migration + +1. **Cloud egress first** (the offline license feature, already enforced at runtime in ~27 files): introduce `proveCloudEgress()` and `cloudFetch(proof, ...)`, then convert the four established patterns — interactive flows (`OrThrow`), background jobs (`undefined` → keep side effects, skip), the marketplace client (proof acquired per `fetch()` call, never stored on the instance), and retry closures (per-attempt). The existing `hasOfflineLicense()` helpers remain for behavior gates that don't perform I/O themselves (`shouldUseGateway()`, cron-body skips, Gravatar suggestion filtering). +2. **Module proofs opportunistically**: as EE features are touched, their server entry points gain `ModuleProof<'...'>` parameters, starting with features whose checks have historically drifted from their code. No big-bang rewrite; the boolean API keeps working throughout. + +## Limitations + +TypeScript cannot forbid an import: a new file can still `import { serverFetch }` directly and hardcode an endpoint, and `{} as ModuleProof<'auditing'>` defeats the brand (both are greppable and glaring in review — the cast requires importing a type whose only documented constructor is the license manager). What the type system guarantees is narrower but real: **code routed through proof-demanding signatures cannot skip the check, cannot check the wrong module, and must handle the denied case explicitly** — and violations shrink to two obvious review signals: a raw `serverFetch` near a Rocket.Chat domain, or a forged cast. + +A directory-scoped lint rule (`no-restricted-imports` on `@rocket.chat/server-fetch` with `cloudClient.ts` exempt) or a CI grep could close the import hole; both were considered and deliberately left out in favor of a types-only approach. diff --git a/ee/packages/license/src/MockedLicenseBuilder.ts b/ee/packages/license/src/MockedLicenseBuilder.ts index 60a1e98e242cf..878061458a1a3 100644 --- a/ee/packages/license/src/MockedLicenseBuilder.ts +++ b/ee/packages/license/src/MockedLicenseBuilder.ts @@ -172,6 +172,11 @@ export class MockedLicenseBuilder { return this; } + public withOffline(offline = true): this { + this.information.offline = offline; + return this; + } + grantedModules: GrantedModules = []; limits: { diff --git a/ee/packages/license/src/license.spec.ts b/ee/packages/license/src/license.spec.ts index 378591215c797..80c801626a176 100644 --- a/ee/packages/license/src/license.spec.ts +++ b/ee/packages/license/src/license.spec.ts @@ -497,3 +497,41 @@ describe('License.removeLicense', () => { expect(licenseManager.hasValidLicense()).toBe(false); }); }); + +describe('Offline license', () => { + it('should not report an offline license when no license is applied', async () => { + const licenseManager = await getReadyLicenseManager(); + + expect(licenseManager.hasOfflineLicense()).toBe(false); + }); + + it('should not report an offline license for a standard license', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder(); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + expect(licenseManager.hasOfflineLicense()).toBe(false); + }); + + it('should report an offline license when the license has the offline flag', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withOffline(); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + expect(licenseManager.hasOfflineLicense()).toBe(true); + }); + + it('should stop reporting an offline license once the license is removed', async () => { + const licenseManager = await getReadyLicenseManager(); + + const license = await new MockedLicenseBuilder().withOffline(); + + await expect(licenseManager.setLicense(await license.sign())).resolves.toBe(true); + expect(licenseManager.hasOfflineLicense()).toBe(true); + + licenseManager.remove(); + expect(licenseManager.hasOfflineLicense()).toBe(false); + }); +}); diff --git a/ee/packages/license/src/license.ts b/ee/packages/license/src/license.ts index f557dd681a909..b5f045514cd07 100644 --- a/ee/packages/license/src/license.ts +++ b/ee/packages/license/src/license.ts @@ -453,6 +453,10 @@ export abstract class LicenseManager extends Emitter { return undefined; } + public hasOfflineLicense(): boolean { + return this.getLicense()?.information.offline ?? false; + } + public syncShouldPreventActionResults(actions: Record): void { for (const [action, shouldPreventAction] of Object.entries(actions)) { this.shouldPreventActionResults.set(action as LicenseLimitKind, shouldPreventAction); From aeb746731c344ba5fa483b2eed396ea2f2d1c4b1 Mon Sep 17 00:00:00 2001 From: Yasmim Nagat <117310290+yasnagat@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:15:18 -0300 Subject: [PATCH 10/12] chore(deps): bump websocket-driver (#41427) --- package.json | 2 ++ yarn.lock | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a64c0b6effc61..e85e5a4a011cb 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,8 @@ "ws@npm:^8": "^8.20.1", "ws@npm:^8.2.3": "^8.20.1", "ws@npm:^8.18.0": "^8.20.1", + "websocket-driver@npm:>=0.5.1": "0.7.5", + "websocket-driver@npm:^0.7.4": "0.7.5", "undici@npm:^6.19.5": "^6.27.0", "undici@npm:^6.23.0": "^6.27.0", "webpack": "~5.104.0", diff --git a/yarn.lock b/yarn.lock index 4cbd7de1ef8bd..39203784671ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34047,7 +34047,7 @@ __metadata: dependencies: faye-websocket: "npm:^0.11.3" uuid: "npm:^8.3.2" - websocket-driver: "npm:^0.7.4" + websocket-driver: "npm:^0.7.5" checksum: 10/36312ec9772a0e536b69b72e9d1c76bd3d6ecf885c5d8fd6e59811485c916b8ce75f46ec57532f436975815ee14aa9a0e22ae3d9e5c0b18ea37b56d0aaaf439c languageName: node linkType: hard @@ -37392,14 +37392,14 @@ __metadata: languageName: node linkType: hard -"websocket-driver@npm:>=0.5.1, websocket-driver@npm:^0.7.4": - version: 0.7.4 - resolution: "websocket-driver@npm:0.7.4" +"websocket-driver@npm:0.7.5, websocket-driver@npm:^0.7.5": + version: 0.7.5 + resolution: "websocket-driver@npm:0.7.5" dependencies: http-parser-js: "npm:>=0.5.1" safe-buffer: "npm:>=5.1.0" websocket-extensions: "npm:>=0.1.1" - checksum: 10/17197d265d5812b96c728e70fd6fe7d067471e121669768fe0c7100c939d997ddfc807d371a728556e24fc7238aa9d58e630ea4ff5fd4cfbb40f3d0a240ef32d + checksum: 10/0671fef8c79ba1b1b2fa6b7d95cb034d059410e7c4cfd7ef42063a254e12ca2917806c3a5026206b33abf25a5d44a651c547b8f74d9ec94c9fe4df6fa1bc63f7 languageName: node linkType: hard From adc15707128bc3fbe1ccd1cd57e9d30a702fa6ca Mon Sep 17 00:00:00 2001 From: Abhinav Kumar Date: Sat, 18 Jul 2026 03:54:09 +0530 Subject: [PATCH 11/12] regression: combined emojis displayed as separate emojis and some flags displayed as a black flag (#41441) Signed-off-by: Abhinav Kumar --- .changeset/emoji-zwj-tag-sequences.md | 5 +++++ packages/message-parser/src/grammar.pegjs | 8 +++++-- packages/message-parser/tests/emoji.test.ts | 25 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 .changeset/emoji-zwj-tag-sequences.md diff --git a/.changeset/emoji-zwj-tag-sequences.md b/.changeset/emoji-zwj-tag-sequences.md new file mode 100644 index 0000000000000..e649ef534bd12 --- /dev/null +++ b/.changeset/emoji-zwj-tag-sequences.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/message-parser': patch +--- + +Fixes an issue in which some combined emojis like 😶‍🌫️, 😮‍💨 and 😵‍💫 were being displayed as two separate emojis, and the flags of some countries like England 🏴󠁧󠁢󠁥󠁮󠁧󠁿, Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿 and Wales 🏴󠁧󠁢󠁷󠁬󠁳󠁿 were being displayed as a plain black flag diff --git a/packages/message-parser/src/grammar.pegjs b/packages/message-parser/src/grammar.pegjs index 45b886ed72829..fb90d2a2bb340 100644 --- a/packages/message-parser/src/grammar.pegjs +++ b/packages/message-parser/src/grammar.pegjs @@ -794,11 +794,12 @@ EmoticonBackslash /* Unicode emojis */ UnicodeEmoji - = UnicodeEmojiEmoticon + = UnicodeEmojiTagSequence / $( (UnicodeEmojiZwjComponent [\u200D])* UnicodeEmojiZwjComponent ) + / UnicodeEmojiEmoticon / UnicodeEmojiTransportAndMapSymbols / UnicodeEmojiMiscellaneousTechnical / UnicodeEmojiMiscellaneousSymbols @@ -810,10 +811,13 @@ UnicodeEmojiEmoticon = $([\uD83D] [\uDE00-\uDE4F]) UnicodeEmojiSupplementalSymbolsAndPictographs = $([\uD83E] [\uDD00-\uDFFF]) UnicodeEmojiZwjComponent - = (UnicodeEmojiSupplementalSymbolsAndPictographs / UnicodeEmojiMiscellaneousSymbolsAndPictographs) UnicodeEmojiMiscellaneousSymbolsAndPictographsFitzpatrickModifiers? + = (UnicodeEmojiSupplementalSymbolsAndPictographs / UnicodeEmojiMiscellaneousSymbolsAndPictographs / UnicodeEmojiEmoticon) UnicodeEmojiMiscellaneousSymbolsAndPictographsFitzpatrickModifiers? / UnicodeEmojiDingbats / UnicodeEmojiMiscellaneousSymbols +/* Emoji tag sequence: Black Flag + tag characters (U+E0020-U+E007E) + Cancel Tag (U+E007F), e.g. England/Scotland/Wales flags */ +UnicodeEmojiTagSequence = $([\uD83C] [\uDFF4] ([\uDB40] [\uDC20-\uDC7E])+ [\uDB40] [\uDC7F]) + UnicodeEmojiMiscellaneousSymbolsAndPictographs = $([\uD83C] [\uDF00-\uDFFF] [\uFE00-\uFE0F]?) / $([\uD83D] [\uDC00-\uDDFF] [\uFE00-\uFE0F]?) UnicodeEmojiMiscellaneousSymbolsAndPictographsFitzpatrickModifiers = $([\uD83C] [\uDFFB-\uDFFF]) diff --git a/packages/message-parser/tests/emoji.test.ts b/packages/message-parser/tests/emoji.test.ts index 785d30b2634cc..b0acb2405515d 100644 --- a/packages/message-parser/tests/emoji.test.ts +++ b/packages/message-parser/tests/emoji.test.ts @@ -88,6 +88,31 @@ test.each([ ['🫱🏿‍🫲🏻', [bigEmoji([emojiUnicode('🫱🏿‍🫲🏻')])]], // v14 ['🫩', [bigEmoji([emojiUnicode('🫩')])]], // v16 ['🇨🇶', [bigEmoji([emojiUnicode('🇨🇶')])]], // v16 + // ZWJ sequences anchored by an Emoticon (U+1F600-U+1F64F) + ['😶‍🌫️', [bigEmoji([emojiUnicode('😶‍🌫️')])]], + ['😮‍💨', [bigEmoji([emojiUnicode('😮‍💨')])]], + ['😵‍💫', [bigEmoji([emojiUnicode('😵‍💫')])]], + ['Hi 😶‍🌫️', [paragraph([plain('Hi '), emojiUnicode('😶‍🌫️')])]], + ['😶‍🌫️😮‍💨', [bigEmoji([emojiUnicode('😶‍🌫️'), emojiUnicode('😮‍💨')])]], + ['🙋🏽‍♂️', [bigEmoji([emojiUnicode('🙋🏽‍♂️')])]], + ['😀', [bigEmoji([emojiUnicode('😀')])]], // standalone emoticon still matches + // Emoji tag sequences — England/Scotland/Wales flags + ['🏴󠁧󠁢󠁥󠁮󠁧󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')])]], + ['🏴󠁧󠁢󠁳󠁣󠁴󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁳󠁣󠁴󠁿')])]], + ['🏴󠁧󠁢󠁷󠁬󠁳󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁷󠁬󠁳󠁿')])]], + ['Hi 🏴󠁧󠁢󠁥󠁮󠁧󠁿', [paragraph([plain('Hi '), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')])]], + ['🏴', [bigEmoji([emojiUnicode('🏴')])]], // standalone black flag still matches + ['🏴‍☠️', [bigEmoji([emojiUnicode('🏴‍☠️')])]], // pirate flag (ZWJ, not tag sequence) still matches + // Consecutive sequence emojis — each sequence must be consumed whole, without stealing from its neighbor + ['🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁷󠁬󠁳󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), emojiUnicode('🏴󠁧󠁢󠁳󠁣󠁴󠁿'), emojiUnicode('🏴󠁧󠁢󠁷󠁬󠁳󠁿')])]], + ['🏴🏴󠁧󠁢󠁥󠁮󠁧󠁿', [bigEmoji([emojiUnicode('🏴'), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')])]], + ['🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), emojiUnicode('🏴')])]], + ['😶‍🌫️😮‍💨😵‍💫', [bigEmoji([emojiUnicode('😶‍🌫️'), emojiUnicode('😮‍💨'), emojiUnicode('😵‍💫')])]], + ['😀😶‍🌫️', [bigEmoji([emojiUnicode('😀'), emojiUnicode('😶‍🌫️')])]], + ['😶‍🌫️🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴‍☠️', [bigEmoji([emojiUnicode('😶‍🌫️'), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), emojiUnicode('🏴‍☠️')])]], + // More than 3 emojis: no BigEmoji, plain paragraph of emoji tokens + ['😶‍🌫️😮‍💨😵‍💫👍', [paragraph([emojiUnicode('😶‍🌫️'), emojiUnicode('😮‍💨'), emojiUnicode('😵‍💫'), emojiUnicode('👍')])]], + ['England 🏴󠁧󠁢󠁥󠁮󠁧󠁿 and Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿', [paragraph([plain('England '), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), plain(' and Scotland '), emojiUnicode('🏴󠁧󠁢󠁳󠁣󠁴󠁿')])]], ])('parses %p', (input, output) => { expect(parse(input)).toEqual(output); }); From c103cf5d5ede96bbedb4e145f7595101c6d64487 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:48:28 -0300 Subject: [PATCH 12/12] chore: make screen share not rely on bundles from previous negotiations (#41369) --- .../src/lib/services/webrtc/Processor.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/media-signaling/src/lib/services/webrtc/Processor.ts b/packages/media-signaling/src/lib/services/webrtc/Processor.ts index a4159dee123ec..419807369fbff 100644 --- a/packages/media-signaling/src/lib/services/webrtc/Processor.ts +++ b/packages/media-signaling/src/lib/services/webrtc/Processor.ts @@ -270,9 +270,21 @@ export class MediaCallWebRTCProcessor implements IWebRTCProcessor { } public async waitForIceGathering(): Promise { - if (this.stopped || this.peer.iceGatheringState === 'complete') { + if (this.stopped) { return; } + + if (this.peer.iceGatheringState === 'complete') { + // If the peer state is 'complete', wait long enough for a macrotask to complete to ensure this state is not outdated + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + + if (this.stopped || this.peer.iceGatheringState === 'complete') { + return; + } + } + this.config.logger?.debug('MediaCallWebRTCProcessor.waitForIceGathering'); await this.initialization;