diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap
index 6045888e9d15c..4dc8e3b93976d 100644
--- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap
+++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterContextualBar.spec.tsx.snap
@@ -145,7 +145,7 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_8_ timeFilterLabel"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
@@ -155,7 +155,7 @@ exports[`renders AppLogsItem without crashing 1`] = `
/>
@@ -352,19 +352,19 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-_r_g_ react-aria-_r_b_ severityFilterLabel"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap
index 99b82d9c5d158..96c6f9dac75e7 100644
--- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap
+++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap
@@ -103,19 +103,19 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_7_ timeFilterLabel"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="timeFilter"
type="button"
>
@@ -194,19 +194,19 @@ exports[`renders AppLogsItem without crashing 1`] = `
aria-haspopup="listbox"
aria-label="Severity"
aria-labelledby="react-aria-_r_f_ react-aria-_r_a_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="severityFilter"
type="button"
>
diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/__snapshots__/CannedResponseList.spec.tsx.snap b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/__snapshots__/CannedResponseList.spec.tsx.snap
index 32aa5c4188558..958e5ca55691c 100644
--- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/__snapshots__/CannedResponseList.spec.tsx.snap
+++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/__snapshots__/CannedResponseList.spec.tsx.snap
@@ -117,7 +117,7 @@ exports[`CannedResponseList Storybook Stories renders Default without crashing 1
aria-haspopup="listbox"
aria-label="Type"
aria-labelledby="react-aria-_r_8_ react-aria-_r_3_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
diff --git a/apps/meteor/client/views/omnichannel/modals/__snapshots__/ForwardChatModal.spec.tsx.snap b/apps/meteor/client/views/omnichannel/modals/__snapshots__/ForwardChatModal.spec.tsx.snap
index 4b9e06d3cc8bc..1d285da24db8d 100644
--- a/apps/meteor/client/views/omnichannel/modals/__snapshots__/ForwardChatModal.spec.tsx.snap
+++ b/apps/meteor/client/views/omnichannel/modals/__snapshots__/ForwardChatModal.spec.tsx.snap
@@ -56,7 +56,7 @@ exports[`renders Default without crashing 1`] = `
aria-busy="false"
aria-disabled="false"
aria-label="Forward_to_department"
- class="rcx-box rcx-box--full rcx-select rcx-css-cjb8sk"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-cjb8sk"
id="_r_0_"
name="department"
>
@@ -113,7 +113,7 @@ exports[`renders Default without crashing 1`] = `
>
diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/__snapshots__/RoomFiles.spec.tsx.snap b/apps/meteor/client/views/room/contextualBar/RoomFiles/__snapshots__/RoomFiles.spec.tsx.snap
index 7a4ba85095139..2047b596aa7b4 100644
--- a/apps/meteor/client/views/room/contextualBar/RoomFiles/__snapshots__/RoomFiles.spec.tsx.snap
+++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/__snapshots__/RoomFiles.spec.tsx.snap
@@ -122,18 +122,18 @@ exports[`renders Default without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_9_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
All
@@ -340,18 +340,18 @@ exports[`renders Empty without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_j_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
All
@@ -554,18 +554,18 @@ exports[`renders Loading without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_t_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
All
diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/InviteUsers/__snapshots__/InviteUsers.spec.tsx.snap b/apps/meteor/client/views/room/contextualBar/RoomMembers/InviteUsers/__snapshots__/InviteUsers.spec.tsx.snap
index d78d041bdfee1..f2aa3d7092c90 100644
--- a/apps/meteor/client/views/room/contextualBar/RoomMembers/InviteUsers/__snapshots__/InviteUsers.spec.tsx.snap
+++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/InviteUsers/__snapshots__/InviteUsers.spec.tsx.snap
@@ -332,19 +332,19 @@ exports[`renders InviteEdit without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_c_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="_r_3_"
type="button"
>
1
@@ -417,19 +417,19 @@ exports[`renders InviteEdit without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_k_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
id="_r_4_"
type="button"
>
5
diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/__snapshots__/RoomMembers.spec.tsx.snap b/apps/meteor/client/views/room/contextualBar/RoomMembers/__snapshots__/RoomMembers.spec.tsx.snap
index 029768ab8a0d7..4a6ac32494434 100644
--- a/apps/meteor/client/views/room/contextualBar/RoomMembers/__snapshots__/RoomMembers.spec.tsx.snap
+++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/__snapshots__/RoomMembers.spec.tsx.snap
@@ -101,18 +101,18 @@ exports[`renders Default without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_9_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
Online
@@ -321,18 +321,18 @@ exports[`renders Empty without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_j_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
Online
@@ -514,18 +514,18 @@ exports[`renders Loading without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_t_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
Online
@@ -699,18 +699,18 @@ exports[`renders WithABACRoom without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_17_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
Online
@@ -919,18 +919,18 @@ exports[`renders WithInvitedMember without crashing 1`] = `
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="react-aria-_r_1h_"
- class="rcx-box rcx-box--full rcx-select rcx-css-1vw6rc6"
+ class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
Online
diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json
index d6b127944e08b..635867b1f929e 100644
--- a/apps/meteor/ee/server/services/package.json
+++ b/apps/meteor/ee/server/services/package.json
@@ -46,7 +46,7 @@
"ws": "~8.21.3"
},
"devDependencies": {
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@types/cookie": "^0.5.4",
"@types/cookie-parser": "^1.4.10",
"@types/ejson": "^2.2.2",
diff --git a/apps/meteor/package.json b/apps/meteor/package.json
index 4d36af88ea3c2..22797e713d057 100644
--- a/apps/meteor/package.json
+++ b/apps/meteor/package.json
@@ -109,7 +109,7 @@
"@rocket.chat/favicon": "workspace:^",
"@rocket.chat/federation-matrix": "workspace:^",
"@rocket.chat/federation-sdk": "0.7.0",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-forms": "~1.5.1",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-toastbar": "~0.36.1",
@@ -118,7 +118,7 @@
"@rocket.chat/gazzodown": "workspace:^",
"@rocket.chat/http-router": "workspace:^",
"@rocket.chat/i18n": "workspace:^",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/instance-status": "workspace:^",
"@rocket.chat/jwt": "workspace:^",
"@rocket.chat/layout": "^0.36.2",
diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json
index ea3a031e01aac..e7c7c9e7cb433 100644
--- a/apps/uikit-playground/package.json
+++ b/apps/uikit-playground/package.json
@@ -18,12 +18,12 @@
"@lezer/highlight": "^1.2.3",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "^0.33.1",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-toastbar": "~0.36.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/fuselage-ui-kit": "workspace:~",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/logo": "^0.33.2",
"@rocket.chat/styled": "~0.34.1",
"@rocket.chat/ui-avatar": "workspace:^",
diff --git a/packages/core-services/package.json b/packages/core-services/package.json
index 7e546b7d79e50..30a626012298c 100644
--- a/packages/core-services/package.json
+++ b/packages/core-services/package.json
@@ -20,7 +20,7 @@
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/federation-sdk": "0.7.0",
"@rocket.chat/http-router": "workspace:^",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/media-signaling": "workspace:^",
"@rocket.chat/message-parser": "workspace:^",
"@rocket.chat/models": "workspace:^",
diff --git a/packages/core-typings/package.json b/packages/core-typings/package.json
index 4df5b68cfffbf..9de7f20edd2ef 100644
--- a/packages/core-typings/package.json
+++ b/packages/core-typings/package.json
@@ -18,7 +18,7 @@
"test": "echo \"no tests\" && exit 1"
},
"dependencies": {
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/message-parser": "workspace:^",
"@rocket.chat/ui-kit": "workspace:~",
"typia": "patch:typia@npm%3A9.7.2#~/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch",
diff --git a/packages/fuselage-ui-kit/package.json b/packages/fuselage-ui-kit/package.json
index d4d1be8d3bf22..820a7cffb80c7 100644
--- a/packages/fuselage-ui-kit/package.json
+++ b/packages/fuselage-ui-kit/package.json
@@ -46,10 +46,10 @@
"@rocket.chat/apps-engine": "workspace:^",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/mock-providers": "workspace:^",
"@rocket.chat/storybook-config": "workspace:~",
diff --git a/packages/gazzodown/package.json b/packages/gazzodown/package.json
index 58abf91531825..65e5506a25f47 100644
--- a/packages/gazzodown/package.json
+++ b/packages/gazzodown/package.json
@@ -30,10 +30,10 @@
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/message-parser": "workspace:^",
"@rocket.chat/storybook-config": "workspace:~",
diff --git a/packages/storybook-config/package.json b/packages/storybook-config/package.json
index 81f5d487a367b..cea44aaa2afa1 100644
--- a/packages/storybook-config/package.json
+++ b/packages/storybook-config/package.json
@@ -33,8 +33,8 @@
"webpack": "~5.104.1"
},
"devDependencies": {
- "@rocket.chat/fuselage": "^0.86.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/fuselage": "^0.87.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/tsconfig": "workspace:*",
"@storybook/react": "^9.1.20",
"eslint": "~9.39.5",
diff --git a/packages/ui-avatar/package.json b/packages/ui-avatar/package.json
index 92c90222b414b..3a55573d0c097 100644
--- a/packages/ui-avatar/package.json
+++ b/packages/ui-avatar/package.json
@@ -17,10 +17,10 @@
"devDependencies": {
"@rocket.chat/core-typings": "workspace:~",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/ui-contexts": "workspace:^",
"@types/react": "~19.2.18",
"@types/react-dom": "~19.2.4",
diff --git a/packages/ui-client/package.json b/packages/ui-client/package.json
index 029dee0b2ac2a..1fcd433a9f8b3 100644
--- a/packages/ui-client/package.json
+++ b/packages/ui-client/package.json
@@ -27,10 +27,10 @@
"@rocket.chat/core-typings": "workspace:~",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/layout": "^0.36.2",
"@rocket.chat/logo": "^0.33.2",
diff --git a/packages/ui-composer/package.json b/packages/ui-composer/package.json
index 0b589cfc56135..b437bd282d08e 100644
--- a/packages/ui-composer/package.json
+++ b/packages/ui-composer/package.json
@@ -22,10 +22,10 @@
"devDependencies": {
"@react-aria/toolbar": "^3.0.0-nightly.5042",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/mock-providers": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json
index 0e44ac893c971..bcf37fc8c715e 100644
--- a/packages/ui-contexts/package.json
+++ b/packages/ui-contexts/package.json
@@ -23,11 +23,11 @@
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/ddp-client": "workspace:~",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/i18n": "workspace:~",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/rest-typings": "workspace:^",
"@rocket.chat/tools": "workspace:~",
diff --git a/packages/ui-kit/package.json b/packages/ui-kit/package.json
index cde40c8685cff..90cc64fb96426 100644
--- a/packages/ui-kit/package.json
+++ b/packages/ui-kit/package.json
@@ -34,7 +34,7 @@
"typia": "patch:typia@npm%3A9.7.2#~/.yarn/patches/typia-npm-9.7.2-5c5d9c80b4.patch"
},
"devDependencies": {
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/tsconfig": "workspace:*",
"@types/jest": "~30.0.0",
diff --git a/packages/ui-video-conf/package.json b/packages/ui-video-conf/package.json
index 4f4195432b97e..160073be7aa2b 100644
--- a/packages/ui-video-conf/package.json
+++ b/packages/ui-video-conf/package.json
@@ -23,10 +23,10 @@
"devDependencies": {
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "^0.33.1",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
"@rocket.chat/styled": "~0.34.1",
diff --git a/packages/ui-voip/package.json b/packages/ui-voip/package.json
index 689411336cbe3..a465f7871efc0 100644
--- a/packages/ui-voip/package.json
+++ b/packages/ui-voip/package.json
@@ -31,11 +31,11 @@
"@react-spectrum/test-utils": "~1.0.0-alpha.8",
"@rocket.chat/core-typings": "workspace:^",
"@rocket.chat/css-in-js": "^0.33.1",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/fuselage-ui-kit": "workspace:^",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/jest-presets": "workspace:~",
"@rocket.chat/mock-providers": "workspace:~",
"@rocket.chat/storybook-config": "workspace:~",
diff --git a/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap b/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap
index 35c18109ea1a8..14deb5f75b75e 100644
--- a/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap
+++ b/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap
@@ -585,7 +585,7 @@ exports[`renders NewCall without crashing 1`] = `
class="rcx-box rcx-box--full rcx-field__row"
>
diff --git a/packages/web-ui-registration/package.json b/packages/web-ui-registration/package.json
index cde9ab58c8afb..2fc4492d98577 100644
--- a/packages/web-ui-registration/package.json
+++ b/packages/web-ui-registration/package.json
@@ -23,11 +23,11 @@
"@rocket.chat/core-typings": "workspace:~",
"@rocket.chat/css-in-js": "^0.33.1",
"@rocket.chat/emitter": "workspace:~",
- "@rocket.chat/fuselage": "^0.86.0",
+ "@rocket.chat/fuselage": "^0.87.0",
"@rocket.chat/fuselage-hooks": "^0.43.1",
"@rocket.chat/fuselage-tokens": "^0.34.0",
"@rocket.chat/i18n": "workspace:~",
- "@rocket.chat/icons": "^0.48.0",
+ "@rocket.chat/icons": "^0.49.0",
"@rocket.chat/layout": "^0.36.2",
"@rocket.chat/logo": "^0.33.2",
"@rocket.chat/mock-providers": "workspace:~",
diff --git a/yarn.lock b/yarn.lock
index 822121e1ad0b4..9da5dacb8076a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9248,7 +9248,7 @@ __metadata:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/federation-sdk": "npm:0.7.0"
"@rocket.chat/http-router": "workspace:^"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/media-signaling": "workspace:^"
"@rocket.chat/message-parser": "workspace:^"
@@ -9271,7 +9271,7 @@ __metadata:
resolution: "@rocket.chat/core-typings@workspace:packages/core-typings"
dependencies:
"@rocket.chat/apps-engine": "workspace:^"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/message-parser": "workspace:^"
"@rocket.chat/ui-kit": "workspace:~"
"@types/express": "npm:^4.17.25"
@@ -9586,11 +9586,11 @@ __metadata:
"@rocket.chat/apps-engine": "workspace:^"
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
"@rocket.chat/gazzodown": "workspace:^"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/mock-providers": "workspace:^"
"@rocket.chat/storybook-config": "workspace:~"
@@ -9641,9 +9641,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@rocket.chat/fuselage@npm:^0.86.0":
- version: 0.86.0
- resolution: "@rocket.chat/fuselage@npm:0.86.0"
+"@rocket.chat/fuselage@npm:^0.87.0":
+ version: 0.87.0
+ resolution: "@rocket.chat/fuselage@npm:0.87.0"
dependencies:
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
@@ -9659,7 +9659,7 @@ __metadata:
react: "*"
react-dom: "*"
react-virtuoso: "*"
- checksum: 10/55490e32a94159b675d6ec3a4c1798540a730694613dd058fcb558a16332f247934d900382e6a708a5dc0a8341e78024a199fa241ebafc02831e29212470c4a4
+ checksum: 10/12ab9b48b5925c8d9a9e3b5574f46bd24a2bd0cea38c170d19f663498f2fc3aed82a772bcb92dbe8a32342c109e0053a6d70df8322dfbedb7133030573077e50
languageName: node
linkType: hard
@@ -9670,10 +9670,10 @@ __metadata:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/message-parser": "workspace:^"
"@rocket.chat/storybook-config": "workspace:~"
@@ -9763,10 +9763,10 @@ __metadata:
languageName: unknown
linkType: soft
-"@rocket.chat/icons@npm:^0.48.0":
- version: 0.48.0
- resolution: "@rocket.chat/icons@npm:0.48.0"
- checksum: 10/29a8367a541c2dc0ee4d17b6601336aff4a22b84ccd1c743fd85a3d59185695d98bda1703271383142eec966af981b611fdf6f302cc2ba5040c431eeeb7a9536
+"@rocket.chat/icons@npm:^0.49.0":
+ version: 0.49.0
+ resolution: "@rocket.chat/icons@npm:0.49.0"
+ checksum: 10/ebae059c71d32e0862684a2e447f6055fb100f65138b92a633a05639aa47928645c3cd6cc0377c290e592d70d03dbc8c531417782f3d7d2fbbe7ac12a3375716
languageName: node
linkType: hard
@@ -10113,7 +10113,7 @@ __metadata:
"@rocket.chat/favicon": "workspace:^"
"@rocket.chat/federation-matrix": "workspace:^"
"@rocket.chat/federation-sdk": "npm:0.7.0"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-forms": "npm:~1.5.1"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-toastbar": "npm:~0.36.1"
@@ -10122,7 +10122,7 @@ __metadata:
"@rocket.chat/gazzodown": "workspace:^"
"@rocket.chat/http-router": "workspace:^"
"@rocket.chat/i18n": "workspace:^"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/instance-status": "workspace:^"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/jwt": "workspace:^"
@@ -11033,10 +11033,10 @@ __metadata:
resolution: "@rocket.chat/storybook-config@workspace:packages/storybook-config"
dependencies:
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/tsconfig": "workspace:*"
"@storybook/addon-a11y": "npm:^9.1.20"
"@storybook/addon-docs": "npm:^9.1.20"
@@ -11121,10 +11121,10 @@ __metadata:
dependencies:
"@rocket.chat/core-typings": "workspace:~"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/ui-contexts": "workspace:^"
"@types/react": "npm:~19.2.18"
"@types/react-dom": "npm:~19.2.4"
@@ -11148,10 +11148,10 @@ __metadata:
"@rocket.chat/core-typings": "workspace:~"
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/layout": "npm:^0.36.2"
"@rocket.chat/logo": "npm:^0.33.2"
@@ -11206,10 +11206,10 @@ __metadata:
dependencies:
"@react-aria/toolbar": "npm:^3.0.0-nightly.5042"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/mock-providers": "workspace:~"
"@rocket.chat/storybook-config": "workspace:~"
@@ -11247,11 +11247,11 @@ __metadata:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/ddp-client": "workspace:~"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
"@rocket.chat/i18n": "workspace:~"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/password-policies": "workspace:^"
"@rocket.chat/rest-typings": "workspace:^"
@@ -11282,7 +11282,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@rocket.chat/ui-kit@workspace:packages/ui-kit"
dependencies:
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/tsconfig": "workspace:*"
"@types/jest": "npm:~30.0.0"
@@ -11307,10 +11307,10 @@ __metadata:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/storybook-config": "workspace:~"
"@rocket.chat/styled": "npm:~0.34.1"
@@ -11354,11 +11354,11 @@ __metadata:
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/desktop-api": "workspace:^"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
"@rocket.chat/fuselage-ui-kit": "workspace:^"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/jest-presets": "workspace:~"
"@rocket.chat/media-signaling": "workspace:~"
"@rocket.chat/mock-providers": "workspace:~"
@@ -11420,12 +11420,12 @@ __metadata:
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-toastbar": "npm:~0.36.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
"@rocket.chat/fuselage-ui-kit": "workspace:~"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/logo": "npm:^0.33.2"
"@rocket.chat/styled": "npm:~0.34.1"
"@rocket.chat/tsconfig": "workspace:*"
@@ -11458,11 +11458,11 @@ __metadata:
"@rocket.chat/core-typings": "workspace:~"
"@rocket.chat/css-in-js": "npm:^0.33.1"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/fuselage": "npm:^0.86.0"
+ "@rocket.chat/fuselage": "npm:^0.87.0"
"@rocket.chat/fuselage-hooks": "npm:^0.43.1"
"@rocket.chat/fuselage-tokens": "npm:^0.34.0"
"@rocket.chat/i18n": "workspace:~"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/layout": "npm:^0.36.2"
"@rocket.chat/logo": "npm:^0.33.2"
"@rocket.chat/mock-providers": "workspace:~"
@@ -34079,7 +34079,7 @@ __metadata:
"@rocket.chat/core-services": "workspace:^"
"@rocket.chat/core-typings": "workspace:^"
"@rocket.chat/emitter": "workspace:~"
- "@rocket.chat/icons": "npm:^0.48.0"
+ "@rocket.chat/icons": "npm:^0.49.0"
"@rocket.chat/message-parser": "workspace:^"
"@rocket.chat/model-typings": "workspace:^"
"@rocket.chat/models": "workspace:^"
From 5a3fd5267bf3dcaee645b2121539a8802761a2e4 Mon Sep 17 00:00:00 2001
From: Milton Rucks <70927132+milton-rucks@users.noreply.github.com>
Date: Thu, 20 Aug 2026 14:24:50 +0000
Subject: [PATCH 2/4] feat: force End-to-End Encryption (E2EE) on private rooms
(#41095)
Co-authored-by: Claude Opus 4.8
Co-authored-by: Kevin Aleman
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Matheus Cardoso
---
.changeset/force-e2ee-private-rooms.md | 6 +
.../CreateDiscussion/CreateDiscussion.tsx | 6 +-
.../actions/CreateChannelModal.spec.tsx | 28 +++++
.../actions/CreateChannelModal.tsx | 14 ++-
.../actions/CreateTeamModal.spec.tsx | 28 +++++
.../actions/CreateTeamModal.tsx | 9 +-
.../actions/useEncryptedRoomDescription.ts | 4 +
.../meteor/server/lib/e2e/beforeCreateRoom.ts | 17 ++-
.../messages/createDiscussion.ts | 13 +++
apps/meteor/server/settings/e2e.ts | 8 ++
apps/meteor/tests/end-to-end/api/channels.ts | 33 ++++++
apps/meteor/tests/end-to-end/api/groups.ts | 103 ++++++++++++++++++
apps/meteor/tests/end-to-end/api/rooms.ts | 56 ++++++++++
packages/i18n/src/locales/en.i18n.json | 5 +
14 files changed, 321 insertions(+), 9 deletions(-)
create mode 100644 .changeset/force-e2ee-private-rooms.md
diff --git a/.changeset/force-e2ee-private-rooms.md b/.changeset/force-e2ee-private-rooms.md
new file mode 100644
index 0000000000000..48f3320f567e3
--- /dev/null
+++ b/.changeset/force-e2ee-private-rooms.md
@@ -0,0 +1,6 @@
+---
+'@rocket.chat/meteor': minor
+'@rocket.chat/i18n': minor
+---
+
+Adds a workspace setting **Force end-to-end encryption on private rooms** (`E2E_Force_Encryption_For_Private_Rooms`) under **Admin → Settings → End-to-End Encryption**. When enabled, every newly created private room is encrypted by default and users can no longer opt out: the encryption toggle in the create-room modal is locked on for private rooms, and the server rejects any attempt to create a private room with `encrypted: false` (e.g. via `groups.create`) with the error `error-encrypted-private-rooms-enforced`. Public rooms are unaffected. Federated rooms are exempt since federation does not support E2EE. Creating a discussion under an unencrypted private parent room is rejected with a dedicated error instructing the user to make the parent public or enable encryption on it, and the create-discussion dialog now surfaces creation errors as toasts.
diff --git a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx
index c7916e1063a5f..9ee81888019e2 100644
--- a/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx
+++ b/apps/meteor/client/components/CreateDiscussion/CreateDiscussion.tsx
@@ -13,7 +13,7 @@ import {
} from '@rocket.chat/fuselage-forms';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
-import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
+import { useTranslation, useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
@@ -82,6 +82,7 @@ const CreateDiscussion = ({
const createDiscussion = useEndpoint('POST', '/v1/rooms.createDiscussion');
const goToRoom = useGoToRoom();
+ const dispatchToastMessage = useToastMessageDispatch();
const createDiscussionMutation = useMutation({
mutationFn: createDiscussion,
@@ -89,6 +90,9 @@ const CreateDiscussion = ({
goToRoom(discussion._id);
onClose();
},
+ onError: (error) => {
+ dispatchToastMessage({ type: 'error', message: error });
+ },
});
const handleCreate = async ({ name, parentRoom, encrypted, usernames, firstMessage, topic }: CreateDiscussionFormValues) => {
diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx
index e89b15af771ce..4595c4d208bf4 100644
--- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx
+++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.spec.tsx
@@ -176,6 +176,34 @@ describe('CreateChannelModal', () => {
expect(encrypted).not.toBeChecked();
expect(encrypted).toBeDisabled();
});
+
+ it('should render a private channel with encryption checked and disabled for changes when private room encryption is forced', async () => {
+ render( null} />, {
+ wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Force_Encryption_For_Private_Rooms', true).build(),
+ });
+
+ await userEvent.click(screen.getByText('Advanced_settings'));
+
+ const encrypted = screen.getByLabelText('Encrypted') as HTMLInputElement;
+ const priv = screen.getByLabelText('Private') as HTMLInputElement;
+
+ // private by default: encrypted is forced ON and cannot be changed
+ expect(priv).toBeChecked();
+ expect(encrypted).toBeChecked();
+ expect(encrypted).toBeDisabled();
+
+ // Private ON -> OFF: encrypted turns OFF and stays disabled (public rooms cannot be encrypted)
+ await userEvent.click(priv);
+ expect(priv).not.toBeChecked();
+ expect(encrypted).not.toBeChecked();
+ expect(encrypted).toBeDisabled();
+
+ // Private OFF -> ON: encryption is forced back ON and remains disabled
+ await userEvent.click(priv);
+ expect(priv).toBeChecked();
+ expect(encrypted).toBeChecked();
+ expect(encrypted).toBeDisabled();
+ });
});
describe('Federation', () => {
diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx
index 4a100614fb88f..41049d7603acd 100644
--- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx
+++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateChannelModal.tsx
@@ -77,6 +77,7 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled;
+ const e2eEnforcedForPrivate = Boolean(useSetting('E2E_Force_Encryption_For_Private_Rooms')) && Boolean(e2eEnabled);
const getEncryptedHint = useEncryptedRoomDescription('channel');
@@ -109,7 +110,7 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
topic: '',
isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true,
readOnly: false,
- encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false,
+ encrypted: Boolean(e2eEnforcedForPrivate || e2eEnabledForPrivateByDefault),
broadcast: false,
federated: false,
},
@@ -132,6 +133,12 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
}
}, [isPrivate, setValue]);
+ useEffect(() => {
+ if (isPrivate && e2eEnforcedForPrivate && !federated) {
+ setValue('encrypted', true);
+ }
+ }, [isPrivate, e2eEnforcedForPrivate, federated, setValue]);
+
useEffect(() => {
setValue('readOnly', broadcast);
}, [broadcast, setValue]);
@@ -185,7 +192,10 @@ const CreateChannelModal = ({ teamId = '', mainRoom, onClose, reload }: CreateCh
}
};
- const e2eDisabled = useMemo(() => !isPrivate || Boolean(!e2eEnabled) || federated, [e2eEnabled, federated, isPrivate]);
+ const e2eDisabled = useMemo(
+ () => !isPrivate || Boolean(!e2eEnabled) || federated || (e2eEnforcedForPrivate && isPrivate),
+ [e2eEnabled, federated, isPrivate, e2eEnforcedForPrivate],
+ );
const createChannelFormId = useId();
diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx
index a100df356076c..b55b6a3c32d9c 100644
--- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx
+++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.spec.tsx
@@ -175,6 +175,34 @@ describe('CreateTeamModal', () => {
expect(encrypted).toBeDisabled();
});
+ it('should render a private team with encryption checked and disabled for changes when private room encryption is forced', async () => {
+ render( null} />, {
+ wrapper: mockAppRoot().withSetting('E2E_Enable', true).withSetting('E2E_Force_Encryption_For_Private_Rooms', true).build(),
+ });
+
+ await userEvent.click(screen.getByText('Advanced_settings'));
+
+ const encrypted = screen.getByLabelText('Teams_New_Encrypted_Label') as HTMLInputElement;
+ const priv = screen.getByLabelText('Teams_New_Private_Label') as HTMLInputElement;
+
+ // private by default: encrypted is forced ON and cannot be changed
+ expect(priv).toBeChecked();
+ expect(encrypted).toBeChecked();
+ expect(encrypted).toBeDisabled();
+
+ // Private ON -> OFF: encrypted turns OFF and stays disabled (public teams cannot be encrypted)
+ await userEvent.click(priv);
+ expect(priv).not.toBeChecked();
+ expect(encrypted).not.toBeChecked();
+ expect(encrypted).toBeDisabled();
+
+ // Private OFF -> ON: encryption is forced back ON and remains disabled
+ await userEvent.click(priv);
+ expect(priv).toBeChecked();
+ expect(encrypted).toBeChecked();
+ expect(encrypted).toBeDisabled();
+ });
+
it('should disable and turn on ReadOnly toggle when Broadcast is ON and no set-readonly permission', async () => {
render( null} />, {
wrapper: mockAppRoot().build(),
diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx
index 0183e78bc8e5a..d2fc30c086293 100644
--- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx
+++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/CreateTeamModal.tsx
@@ -46,6 +46,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
const t = useTranslation();
const e2eEnabled = useSetting('E2E_Enable');
const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms') && e2eEnabled;
+ const e2eEnforcedForPrivate = Boolean(useSetting('E2E_Force_Encryption_For_Private_Rooms')) && Boolean(e2eEnabled);
const namesValidation = useSetting('UTF8_Channel_Names_Validation');
const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars');
const canSetReadOnly = usePermissionWithScopedRoles('set-readonly', ['owner']);
@@ -93,7 +94,7 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
topic: '',
isPrivate: canOnlyCreateOneType ? canOnlyCreateOneType === 'p' : true,
readOnly: false,
- encrypted: (e2eEnabledForPrivateByDefault as boolean) ?? false,
+ encrypted: Boolean(e2eEnforcedForPrivate || e2eEnabledForPrivateByDefault),
broadcast: false,
members: [],
},
@@ -104,13 +105,15 @@ const CreateTeamModal = ({ onClose }: CreateTeamModalProps) => {
useEffect(() => {
if (!isPrivate) {
setValue('encrypted', false);
+ } else if (e2eEnforcedForPrivate) {
+ setValue('encrypted', true);
}
setValue('readOnly', broadcast);
- }, [watch, setValue, broadcast, isPrivate]);
+ }, [watch, setValue, broadcast, isPrivate, e2eEnforcedForPrivate]);
const readOnlyDisabled = broadcast || !canSetReadOnly;
- const canChangeEncrypted = isPrivate && e2eEnabled;
+ const canChangeEncrypted = isPrivate && e2eEnabled && !e2eEnforcedForPrivate;
const getEncryptedHint = useEncryptedRoomDescription('team');
const goToRoom = useGoToRoom();
diff --git a/apps/meteor/client/navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription.ts b/apps/meteor/client/navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription.ts
index a442946fd2e2e..c77d16b4c88cc 100644
--- a/apps/meteor/client/navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription.ts
+++ b/apps/meteor/client/navbar/NavBarPagesGroup/actions/useEncryptedRoomDescription.ts
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
export const useEncryptedRoomDescription = (roomType: 'channel' | 'team' | 'discussion') => {
const { t } = useTranslation();
const e2eEnabled = useSetting('E2E_Enable');
+ const e2eEnforcedForPrivate = Boolean(useSetting('E2E_Force_Encryption_For_Private_Rooms')) && Boolean(e2eEnabled);
return ({ isPrivate, encrypted }: { isPrivate: boolean; encrypted: boolean }) => {
if (!e2eEnabled) {
@@ -12,6 +13,9 @@ export const useEncryptedRoomDescription = (roomType: 'channel' | 'team' | 'disc
if (!isPrivate) {
return t('Encrypted_not_available', { roomType: t(roomType) });
}
+ if (e2eEnforcedForPrivate && encrypted) {
+ return t('Encrypted_enforced_by_workspace_policy');
+ }
if (encrypted) {
return t('Encrypted_messages', { roomType: t(roomType) });
}
diff --git a/apps/meteor/server/lib/e2e/beforeCreateRoom.ts b/apps/meteor/server/lib/e2e/beforeCreateRoom.ts
index 828ec468318a7..d18d4840c9292 100644
--- a/apps/meteor/server/lib/e2e/beforeCreateRoom.ts
+++ b/apps/meteor/server/lib/e2e/beforeCreateRoom.ts
@@ -1,12 +1,23 @@
+import { MeteorError } from '@rocket.chat/core-services';
+
import { settings } from '../../settings';
import { prepareCreateRoomCallback } from '../callbacks/beforeCreateRoomCallback';
prepareCreateRoomCallback.add(({ type, extraData }) => {
+ if (!settings.get('E2E_Enable')) {
+ return;
+ }
+
if (
- settings.get('E2E_Enable') &&
- ((type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) ||
- (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms')))
+ (type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms')) ||
+ (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms'))
) {
extraData.encrypted = extraData.encrypted ?? true;
}
+ if (type === 'p' && extraData.federated !== true && settings.get('E2E_Force_Encryption_For_Private_Rooms')) {
+ if (extraData.encrypted === false) {
+ throw new MeteorError('error-encrypted-private-rooms-enforced', 'Workspace policy requires all private rooms to be encrypted.');
+ }
+ extraData.encrypted = true;
+ }
});
diff --git a/apps/meteor/server/meteor-methods/messages/createDiscussion.ts b/apps/meteor/server/meteor-methods/messages/createDiscussion.ts
index 6c09d496102d4..304ef3ee50d74 100644
--- a/apps/meteor/server/meteor-methods/messages/createDiscussion.ts
+++ b/apps/meteor/server/meteor-methods/messages/createDiscussion.ts
@@ -151,6 +151,19 @@ const create = async ({
});
}
+ if (
+ type === 'p' &&
+ !encrypted &&
+ settings.get('E2E_Enable') &&
+ settings.get('E2E_Force_Encryption_For_Private_Rooms')
+ ) {
+ throw new Meteor.Error(
+ 'error-encrypted-private-rooms-enforced-discussion',
+ 'Workspace policy requires all private rooms to be encrypted. To create this discussion, make the parent channel public or enable encryption on it.',
+ { method: 'DiscussionCreation' },
+ );
+ }
+
const discussion = await createRoom(
type,
name,
diff --git a/apps/meteor/server/settings/e2e.ts b/apps/meteor/server/settings/e2e.ts
index 26441a2b1656b..03d1c709262f0 100644
--- a/apps/meteor/server/settings/e2e.ts
+++ b/apps/meteor/server/settings/e2e.ts
@@ -28,6 +28,14 @@ export const createE2ESettings = () =>
enableQuery: { _id: 'E2E_Enable', value: true },
});
+ await this.add('E2E_Force_Encryption_For_Private_Rooms', false, {
+ type: 'boolean',
+ i18nLabel: 'Force_Encryption_For_Private_Rooms',
+ i18nDescription: 'Force_Encryption_For_Private_Rooms_Description',
+ public: true,
+ enableQuery: { _id: 'E2E_Enable', value: true },
+ });
+
await this.add('E2E_Enable_Encrypt_Files', true, {
type: 'boolean',
public: true,
diff --git a/apps/meteor/tests/end-to-end/api/channels.ts b/apps/meteor/tests/end-to-end/api/channels.ts
index 5c1523346f756..ac2d4d299d195 100644
--- a/apps/meteor/tests/end-to-end/api/channels.ts
+++ b/apps/meteor/tests/end-to-end/api/channels.ts
@@ -785,6 +785,39 @@ describe('[Channels]', () => {
expect(res.body).to.have.property('error', 'unauthorized');
});
});
+
+ describe('E2E forced encryption for private rooms', () => {
+ let createdRoomId: IRoom['_id'] | undefined;
+
+ before(async () => {
+ await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]);
+ });
+
+ after(async () => {
+ await Promise.all([
+ updateSetting('E2E_Enable', false),
+ updateSetting('E2E_Force_Encryption_For_Private_Rooms', false),
+ ...(createdRoomId ? [deleteRoom({ type: 'c', roomId: createdRoomId })] : []),
+ ]);
+ });
+
+ it('should not force encryption on public channels when private room encryption is forced', async () => {
+ await request
+ .post(api('channels.create'))
+ .set(credentials)
+ .send({
+ name: `forced-e2e-public-${Date.now()}`,
+ })
+ .expect('Content-Type', 'application/json')
+ .expect(200)
+ .expect((res) => {
+ createdRoomId = res.body.channel?._id;
+ expect(res.body).to.have.property('success', true);
+ expect(res.body).to.have.nested.property('channel.t', 'c');
+ expect(res.body).to.not.have.nested.property('channel.encrypted', true);
+ });
+ });
+ });
});
describe('[/channels.info]', () => {
diff --git a/apps/meteor/tests/end-to-end/api/groups.ts b/apps/meteor/tests/end-to-end/api/groups.ts
index 7a2d965abc174..35fe1d5240f3b 100644
--- a/apps/meteor/tests/end-to-end/api/groups.ts
+++ b/apps/meteor/tests/end-to-end/api/groups.ts
@@ -276,6 +276,109 @@ describe('[Groups]', () => {
});
});
+ describe('E2E forced encryption for private rooms', () => {
+ const createdRoomIds: IRoom['_id'][] = [];
+
+ before(async () => {
+ await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]);
+ });
+
+ after(async () => {
+ await Promise.all([
+ updateSetting('E2E_Enable', false),
+ updateSetting('E2E_Force_Encryption_For_Private_Rooms', false),
+ ...createdRoomIds.map((roomId) => deleteRoom({ type: 'p', roomId })),
+ ]);
+ });
+
+ it('should reject creating a private room with encrypted=false when private room encryption is forced', async () => {
+ await request
+ .post(api('groups.create'))
+ .set(credentials)
+ .send({
+ name: `forced-unencrypted-${apiPrivateChannelName}`,
+ extraData: {
+ encrypted: false,
+ },
+ })
+ .expect('Content-Type', 'application/json')
+ .expect(400)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ expect(res.body).to.have.property('errorType', 'error-encrypted-private-rooms-enforced');
+ });
+ });
+
+ it('should create an encrypted private room when encrypted is omitted and private room encryption is forced', async () => {
+ await request
+ .post(api('groups.create'))
+ .set(credentials)
+ .send({
+ name: `forced-default-${apiPrivateChannelName}`,
+ })
+ .expect('Content-Type', 'application/json')
+ .expect(200)
+ .expect((res) => {
+ if (res.body.group?._id) {
+ createdRoomIds.push(res.body.group._id);
+ }
+ expect(res.body).to.have.property('success', true);
+ expect(res.body).to.have.nested.property('group.t', 'p');
+ expect(res.body).to.have.nested.property('group.encrypted', true);
+ });
+ });
+
+ it('should allow creating a private room with encrypted=true when private room encryption is forced', async () => {
+ await request
+ .post(api('groups.create'))
+ .set(credentials)
+ .send({
+ name: `forced-encrypted-${apiPrivateChannelName}`,
+ extraData: {
+ encrypted: true,
+ },
+ })
+ .expect('Content-Type', 'application/json')
+ .expect(200)
+ .expect((res) => {
+ if (res.body.group?._id) {
+ createdRoomIds.push(res.body.group._id);
+ }
+ expect(res.body).to.have.property('success', true);
+ expect(res.body).to.have.nested.property('group.t', 'p');
+ expect(res.body).to.have.nested.property('group.encrypted', true);
+ });
+ });
+
+ it('should not enforce encryption on federated private rooms', async () => {
+ // Federated rooms do not support E2EE, so the forced-encryption policy exempts them.
+ // Depending on the environment the creation may still fail further down the pipeline
+ // (federation unavailable), but it must never fail with the forced-encryption error,
+ // and if it succeeds the room must not have been force-encrypted.
+ await request
+ .post(api('groups.create'))
+ .set(credentials)
+ .send({
+ name: `forced-federated-${apiPrivateChannelName}`,
+ extraData: {
+ broadcast: false,
+ encrypted: false,
+ federated: true,
+ },
+ })
+ .expect('Content-Type', 'application/json')
+ .expect((res) => {
+ if (res.body.group?._id) {
+ createdRoomIds.push(res.body.group._id);
+ }
+ expect(res.body).to.not.have.property('errorType', 'error-encrypted-private-rooms-enforced');
+ if (res.body.success) {
+ expect(res.body).to.not.have.nested.property('group.encrypted', true);
+ }
+ });
+ });
+ });
+
it(`should fail when trying to use an existing room's name`, async () => {
await request
.post(api('groups.create'))
diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts
index 1612e8e3d0b05..12d20cbe0b64c 100644
--- a/apps/meteor/tests/end-to-end/api/rooms.ts
+++ b/apps/meteor/tests/end-to-end/api/rooms.ts
@@ -2001,6 +2001,62 @@ describe('[Rooms]', () => {
expect(res.body.discussion).to.have.property('t').and.to.be.equal('p');
});
});
+
+ describe('E2E forced encryption for private rooms', () => {
+ let unencryptedPrivateParent: IRoom;
+ let encryptedPrivateParent: IRoom;
+ let createdDiscussionId: IRoom['_id'] | undefined;
+
+ before(async () => {
+ // the unencrypted private parent must exist before the policy is enforced
+ unencryptedPrivateParent = (await createRoom({ type: 'p', name: `unencrypted-parent-${Date.now()}` })).body.group;
+ await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Force_Encryption_For_Private_Rooms', true)]);
+ encryptedPrivateParent = (await createRoom({ type: 'p', name: `encrypted-parent-${Date.now()}`, extraData: { encrypted: true } }))
+ .body.group;
+ });
+
+ after(async () => {
+ await Promise.all([
+ updateSetting('E2E_Enable', false),
+ updateSetting('E2E_Force_Encryption_For_Private_Rooms', false),
+ ...(unencryptedPrivateParent?._id ? [deleteRoom({ type: 'p', roomId: unencryptedPrivateParent._id })] : []),
+ ...(encryptedPrivateParent?._id ? [deleteRoom({ type: 'p', roomId: encryptedPrivateParent._id })] : []),
+ ...(createdDiscussionId ? [deleteRoom({ type: 'p', roomId: createdDiscussionId })] : []),
+ ]);
+ });
+
+ it('should reject creating a discussion in an unencrypted private room when private room encryption is forced', async () => {
+ await request
+ .post(api('rooms.createDiscussion'))
+ .set(credentials)
+ .send({
+ prid: unencryptedPrivateParent._id,
+ t_name: `forced-discussion-${Date.now()}`,
+ })
+ .expect(400)
+ .expect((res) => {
+ expect(res.body).to.have.property('success', false);
+ expect(res.body).to.have.property('errorType', 'error-encrypted-private-rooms-enforced-discussion');
+ });
+ });
+
+ it('should create an encrypted discussion in an encrypted private room when private room encryption is forced', async () => {
+ await request
+ .post(api('rooms.createDiscussion'))
+ .set(credentials)
+ .send({
+ prid: encryptedPrivateParent._id,
+ t_name: `forced-discussion-encrypted-${Date.now()}`,
+ })
+ .expect(200)
+ .expect((res) => {
+ createdDiscussionId = res.body.discussion?._id;
+ expect(res.body).to.have.property('success', true);
+ expect(res.body).to.have.nested.property('discussion.t', 'p');
+ expect(res.body).to.have.nested.property('discussion.encrypted', true);
+ });
+ });
+ });
});
describe('/rooms.getDiscussions', () => {
diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json
index d127c25e0d9f6..41369a7c14efa 100644
--- a/packages/i18n/src/locales/en.i18n.json
+++ b/packages/i18n/src/locales/en.i18n.json
@@ -2149,6 +2149,7 @@
"Encrypted_content_cannot_be_searched_and_audited": "Encrypted content cannot be searched and audited",
"Encrypted_content_cannot_be_searched_and_audited_subtitle": "There are one or more encrypted rooms selected for audit.",
"Encrypted_content_will_not_appear_search": "Room encrypted, encrypted content will not appear in search",
+ "Encrypted_enforced_by_workspace_policy": "Enforced by workspace security policy.",
"Encrypted_field_hint": "Messages are end-to-end encrypted, search will not work and notifications may not show message content",
"Encrypted_file_not_allowed": "Encrypted file not allowed",
"Encrypted_message": "Encrypted message",
@@ -2558,6 +2559,8 @@
"For_your_security_you_must_enter_your_current_password_to_continue": "For your security, you must enter your current password to continue",
"Force_Disable_OpLog_For_Cache": "Force Disable OpLog for Cache",
"Force_Disable_OpLog_For_Cache_Description": "Will not use OpLog to sync cache even when it's available",
+ "Force_Encryption_For_Private_Rooms": "Force end-to-end encryption on private rooms",
+ "Force_Encryption_For_Private_Rooms_Description": "When enabled, all newly created private rooms will be encrypted by default, and users will not be able to disable encryption for them.",
"Force_SSL": "Force SSL",
"Force_SSL_Description": "*Caution!* _Force SSL_ should never be used with reverse proxy. If you have a reverse proxy, you should do the redirect THERE. This option exists for deployments like Heroku, that does not allow the redirect configuration at the reverse proxy.",
"Force_Screen_Lock": "Force screen lock",
@@ -6520,6 +6523,8 @@
"error-email-domain-blacklisted": "The email domain is blacklisted",
"error-email-inbox-not-found": "Email Inbox not found",
"error-email-send-failed": "Error trying to send email: {{message}}",
+ "error-encrypted-private-rooms-enforced": "Workspace policy requires all private rooms to be encrypted.",
+ "error-encrypted-private-rooms-enforced-discussion": "Workspace policy requires all private rooms to be encrypted. To create this discussion, make the parent channel public or enable encryption on it.",
"error-essential-app-disabled": "Error: a Rocket.Chat App that is essential for this is disabled. Please contact your administrator",
"error-extension-not-assigned": "Extension not assigned",
"error-extension-not-available": "Extension not available",
From fdd4ed778380babe9b524c4aefe321d30c402572 Mon Sep 17 00:00:00 2001
From: Diego Sampaio
Date: Thu, 20 Aug 2026 14:28:16 +0000
Subject: [PATCH 3/4] chore(api): experimental REST API namespace
(/api/experimental) (#41116)
Co-authored-by: Claude Opus 4.8 (1M context)
---
apps/meteor/server/api/api.ts | 43 +++-
.../api/v1/middlewares/experimental.spec.ts | 86 +++++++
.../server/api/v1/middlewares/experimental.ts | 29 +++
.../server/api/v1/middlewares/metrics.spec.ts | 147 ++++++++++++
.../server/api/v1/middlewares/metrics.ts | 16 +-
docs/experimental-api-endpoints-plan.md | 215 ++++++++++++++++++
docs/experimental-api-endpoints.md | 116 ++++++++++
.../rest-typings/src/experimental/index.ts | 27 +++
packages/rest-typings/src/index.ts | 4 +
9 files changed, 679 insertions(+), 4 deletions(-)
create mode 100644 apps/meteor/server/api/v1/middlewares/experimental.spec.ts
create mode 100644 apps/meteor/server/api/v1/middlewares/experimental.ts
create mode 100644 docs/experimental-api-endpoints-plan.md
create mode 100644 docs/experimental-api-endpoints.md
create mode 100644 packages/rest-typings/src/experimental/index.ts
diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts
index 7d59683f07f8c..56772f2159f76 100644
--- a/apps/meteor/server/api/api.ts
+++ b/apps/meteor/server/api/api.ts
@@ -9,6 +9,7 @@ import { type APIActionHandler, RocketChatAPIRouter } from './router';
import { metrics } from '../lib/metrics';
import { settings } from '../settings';
import { cors } from './v1/middlewares/cors';
+import { experimentalWarningMiddleware } from './v1/middlewares/experimental';
import { loggerMiddleware } from './v1/middlewares/logger';
import { metricsMiddleware } from './v1/middlewares/metrics';
import { remoteAddressMiddleware } from './v1/middlewares/remoteAddressMiddleware';
@@ -42,6 +43,7 @@ const createApi = function _createApi(options: { version?: string; useDefaultAut
export const API: {
api: Router<'/api', any, APIActionHandler>;
v1: APIClass<'/v1'>;
+ experimental: APIClass<'/experimental'>;
default: APIClass;
ApiClass: typeof APIClass;
channels?: {
@@ -73,6 +75,10 @@ export const API: {
version: 'v1',
useDefaultAuth: true,
}),
+ experimental: createApi({
+ version: 'experimental',
+ useDefaultAuth: true,
+ }),
default: createApi({}),
};
@@ -89,14 +95,19 @@ settings.watch('Accounts_CustomFields', (value) => {
}
});
+const reloadRoutesToRefreshRateLimiter = () => {
+ API.v1.reloadRoutesToRefreshRateLimiter();
+ API.experimental.reloadRoutesToRefreshRateLimiter();
+};
+
settings.watch('API_Enable_Rate_Limiter_Limit_Time_Default', (value) => {
defaultRateLimiterOptions.intervalTimeInMS = value;
- API.v1.reloadRoutesToRefreshRateLimiter();
+ reloadRoutesToRefreshRateLimiter();
});
settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => {
defaultRateLimiterOptions.numRequestsAllowed = value;
- API.v1.reloadRoutesToRefreshRateLimiter();
+ reloadRoutesToRefreshRateLimiter();
});
export const startRestAPI = () => {
@@ -113,11 +124,39 @@ export const startRestAPI = () => {
activeRequestsGauge: metrics.rocketchatRestApiActiveRequests,
}),
)
+ .use(
+ metricsMiddleware({
+ basePathRegex: new RegExp(/^\/api\/experimental\//),
+ api: API.experimental,
+ settings,
+ endpointTimeSummary: metrics.rocketchatRestApi,
+ endpointTimeHistogram: metrics.rocketchatRestApiSeconds,
+ responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes,
+ activeRequestsGauge: metrics.rocketchatRestApiActiveRequests,
+ }),
+ )
+ .use(
+ // Catch-all sampler for the default router (`/api/info`, `/api/docs/json`) and for
+ // unmatched `/api/*` paths, which belong to none of the versioned prefixes above.
+ // Add any new versioned namespace to `excludePathRegex` as well, or it gets counted twice.
+ metricsMiddleware({
+ excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//),
+ // `API.default` has no `version`; label it explicitly so the series is not blank.
+ api: { version: 'default' },
+ settings,
+ endpointTimeSummary: metrics.rocketchatRestApi,
+ endpointTimeHistogram: metrics.rocketchatRestApiSeconds,
+ responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes,
+ activeRequestsGauge: metrics.rocketchatRestApiActiveRequests,
+ }),
+ )
.use(tracerSpanMiddleware)
.use(remoteAddressMiddleware)
+ .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) }))
.use(cors(settings))
.use(loggerMiddleware(logger))
.use(API.v1.router)
+ .use(API.experimental.router)
.use(API.default.router).router,
);
};
diff --git a/apps/meteor/server/api/v1/middlewares/experimental.spec.ts b/apps/meteor/server/api/v1/middlewares/experimental.spec.ts
new file mode 100644
index 0000000000000..4d44f66bec432
--- /dev/null
+++ b/apps/meteor/server/api/v1/middlewares/experimental.spec.ts
@@ -0,0 +1,86 @@
+import { Router } from '@rocket.chat/http-router';
+import Ajv from 'ajv';
+import express from 'express';
+import request from 'supertest';
+
+import { cors } from './cors';
+import { experimentalWarningMiddleware } from './experimental';
+import { CachedSettings } from '../../../settings/CachedSettings';
+
+const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
+
+const buildApp = ({ corsEnabled }: { corsEnabled: boolean }) => {
+ const ajv = new Ajv();
+ const settings = new CachedSettings();
+ settings.set({ _id: 'API_Enable_CORS', value: corsEnabled } as any);
+ settings.set({ _id: 'API_CORS_Origin', value: 'https://allowed.example' } as any);
+
+ const route = (router: Router) =>
+ router.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({
+ statusCode: 200 as const,
+ body: {},
+ }));
+
+ const api = new Router('/api')
+ .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) }))
+ .use(cors(settings))
+ .use(route(new Router('/v1')))
+ .use(route(new Router('/experimental')));
+
+ const app = express();
+ app.use(api.router);
+ return app;
+};
+
+const preflight = (app: express.Express, path: string, origin: string) =>
+ request(app).options(path).set('Origin', origin).set('Access-Control-Request-Method', 'GET');
+
+describe('Experimental middleware', () => {
+ it('should stamp the unstable signal headers on experimental responses', async () => {
+ const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/test');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['x-experimental']).toBe('true');
+ expect(res.headers.warning).toBe(WARNING_HEADER);
+ });
+
+ it('should not stamp responses from other versions', async () => {
+ const res = await request(buildApp({ corsEnabled: true })).get('/api/v1/test');
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers['x-experimental']).toBeUndefined();
+ expect(res.headers.warning).toBeUndefined();
+ });
+
+ it('should stamp 404s for unmatched experimental paths', async () => {
+ const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/nope');
+
+ expect(res.statusCode).toBe(404);
+ expect(res.headers['x-experimental']).toBe('true');
+ });
+
+ // cors answers rejected preflights without calling next(), so these only carry the headers
+ // while the middleware stays registered ahead of it
+ it('should stamp preflight rejections when CORS is disabled', async () => {
+ const res = await preflight(buildApp({ corsEnabled: false }), '/api/experimental/test', 'https://allowed.example');
+
+ expect(res.statusCode).toBe(405);
+ expect(res.headers['x-experimental']).toBe('true');
+ expect(res.headers.warning).toBe(WARNING_HEADER);
+ });
+
+ it('should stamp preflight rejections from disallowed origins', async () => {
+ const res = await preflight(buildApp({ corsEnabled: true }), '/api/experimental/test', 'https://evil.example');
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['x-experimental']).toBe('true');
+ expect(res.headers.warning).toBe(WARNING_HEADER);
+ });
+
+ it('should not stamp preflight rejections from other versions', async () => {
+ const res = await preflight(buildApp({ corsEnabled: true }), '/api/v1/test', 'https://evil.example');
+
+ expect(res.statusCode).toBe(403);
+ expect(res.headers['x-experimental']).toBeUndefined();
+ });
+});
diff --git a/apps/meteor/server/api/v1/middlewares/experimental.ts b/apps/meteor/server/api/v1/middlewares/experimental.ts
new file mode 100644
index 0000000000000..c8e80294c7431
--- /dev/null
+++ b/apps/meteor/server/api/v1/middlewares/experimental.ts
@@ -0,0 +1,29 @@
+import type { MiddlewareHandler } from 'hono';
+
+// `x-experimental` is the supported programmatic signal. `Warning: 299` is emitted for
+// legacy tooling only — warn code 299 came from RFC 7234, which RFC 9111 has obsoleted
+// along with the `Warning` header itself.
+const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
+
+/**
+ * Stamps every experimental response with the unstable signal headers.
+ *
+ * Registered on the shared `/api` mount ahead of `cors`, and scoped by path rather than by
+ * router: `cors` answers rejected preflights with 403/405 without calling `next()`, so a
+ * middleware living on `API.experimental.router` would never run for those responses.
+ *
+ * The headers are set on `c.res.headers` before the downstream handlers run; Hono merges them
+ * into whatever response is produced later, so 404s and CORS rejections are covered too.
+ */
+export const experimentalWarningMiddleware =
+ ({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler =>
+ async (c, next) => {
+ if (!basePathRegex.test(c.req.path)) {
+ return next();
+ }
+
+ c.res.headers.set('x-experimental', 'true');
+ c.res.headers.set('Warning', WARNING_HEADER);
+
+ await next();
+ };
diff --git a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts
index 87ed45e325128..76d33e0ce5560 100644
--- a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts
+++ b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts
@@ -199,4 +199,151 @@ describe('Metrics middleware', () => {
entrypoint: 'method.call/get:param',
});
});
+
+ it('should only record requests matching its own base path', async () => {
+ const ajv = new Ajv();
+ const app = express();
+ const settings = new CachedSettings();
+
+ const makeMetrics = () => {
+ const endTimer = jest.fn();
+ return {
+ endTimer,
+ summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
+ histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
+ responseSizeHistogram: { observe: jest.fn() },
+ activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
+ };
+ };
+
+ const v1Metrics = makeMetrics();
+ const experimentalMetrics = makeMetrics();
+
+ const route = (router: Router) =>
+ router.get(
+ '/test',
+ {
+ response: {
+ 200: ajv.compile({
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ },
+ }),
+ },
+ },
+ async () => ({
+ statusCode: 200,
+ body: { message: 'Metrics test successful' },
+ }),
+ );
+
+ const api = new Router('/api');
+
+ api
+ .use(
+ metricsMiddleware({
+ basePathRegex: new RegExp(/^\/api\/v1\//),
+ api: { version: 'v1' } as any,
+ settings,
+ endpointTimeSummary: v1Metrics.summary as any,
+ endpointTimeHistogram: v1Metrics.histogram as any,
+ responseSizeHistogram: v1Metrics.responseSizeHistogram as any,
+ activeRequestsGauge: v1Metrics.activeRequestsGauge as any,
+ }),
+ )
+ .use(
+ metricsMiddleware({
+ basePathRegex: new RegExp(/^\/api\/experimental\//),
+ api: { version: 'experimental' } as any,
+ settings,
+ endpointTimeSummary: experimentalMetrics.summary as any,
+ endpointTimeHistogram: experimentalMetrics.histogram as any,
+ responseSizeHistogram: experimentalMetrics.responseSizeHistogram as any,
+ activeRequestsGauge: experimentalMetrics.activeRequestsGauge as any,
+ }),
+ )
+ .use(route(new Router('/v1')))
+ .use(route(new Router('/experimental')));
+
+ app.use(api.router);
+
+ expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
+
+ expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
+ expect(v1Metrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'v1', entrypoint: 'test' });
+ expect(experimentalMetrics.summary.startTimer).not.toHaveBeenCalled();
+ expect(experimentalMetrics.activeRequestsGauge.inc).not.toHaveBeenCalled();
+
+ expect((await request(app).get('/api/experimental/test')).statusCode).toBe(200);
+
+ expect(experimentalMetrics.summary.startTimer).toHaveBeenCalledTimes(1);
+ expect(experimentalMetrics.endTimer).toHaveBeenCalledWith({
+ status: 200,
+ method: 'get',
+ version: 'experimental',
+ entrypoint: 'test',
+ });
+ expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
+ });
+
+ it('should sample the default router and unmatched paths exactly once', async () => {
+ const ajv = new Ajv();
+ const app = express();
+ const settings = new CachedSettings();
+
+ const makeMetrics = () => {
+ const endTimer = jest.fn();
+ return {
+ endTimer,
+ summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
+ histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
+ responseSizeHistogram: { observe: jest.fn() },
+ activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
+ };
+ };
+
+ const v1Metrics = makeMetrics();
+ const defaultMetrics = makeMetrics();
+
+ const wire = (metrics: ReturnType, extra: { basePathRegex?: RegExp; excludePathRegex?: RegExp }, version: string) =>
+ metricsMiddleware({
+ ...extra,
+ api: { version },
+ settings,
+ endpointTimeSummary: metrics.summary as any,
+ endpointTimeHistogram: metrics.histogram as any,
+ responseSizeHistogram: metrics.responseSizeHistogram as any,
+ activeRequestsGauge: metrics.activeRequestsGauge as any,
+ });
+
+ const api = new Router('/api')
+ .use(wire(v1Metrics, { basePathRegex: new RegExp(/^\/api\/v1\//) }, 'v1'))
+ .use(wire(defaultMetrics, { excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//) }, 'default'));
+
+ const route = (router: Router, subpath: string) =>
+ router.get(subpath, { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200 as const, body: {} }));
+
+ // the catch-all router is mounted first on purpose: the exclusion guard has to hold
+ // regardless of the order the versioned routers happen to be registered in
+ api.use(route(new Router(''), 'info'));
+ api.use(route(new Router('/v1'), '/test'));
+
+ expect((await request(app.use(api.router)).get('/api/info')).statusCode).toBe(200);
+
+ expect(v1Metrics.summary.startTimer).not.toHaveBeenCalled();
+ expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'default', entrypoint: '/api/info' });
+
+ defaultMetrics.endTimer.mockClear();
+
+ expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
+
+ expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
+ expect(defaultMetrics.summary.startTimer).toHaveBeenCalledTimes(1); // still just the /api/info call
+ expect(defaultMetrics.endTimer).not.toHaveBeenCalled();
+
+ expect((await request(app).get('/api/bogus')).statusCode).toBe(404);
+
+ expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 404, method: 'get', version: 'default', entrypoint: '/api/*' });
+ });
});
diff --git a/apps/meteor/server/api/v1/middlewares/metrics.ts b/apps/meteor/server/api/v1/middlewares/metrics.ts
index 22b5bb98aa76b..613ee2d90b335 100644
--- a/apps/meteor/server/api/v1/middlewares/metrics.ts
+++ b/apps/meteor/server/api/v1/middlewares/metrics.ts
@@ -2,11 +2,11 @@ import type { MiddlewareHandler } from 'hono';
import type { Gauge, Histogram, Summary } from 'prom-client';
import type { CachedSettings } from '../../../settings/CachedSettings';
-import type { APIClass } from '../../ApiClass';
export const metricsMiddleware =
({
basePathRegex,
+ excludePathRegex,
api,
settings,
endpointTimeSummary,
@@ -15,7 +15,8 @@ export const metricsMiddleware =
activeRequestsGauge,
}: {
basePathRegex?: RegExp;
- api: APIClass;
+ excludePathRegex?: RegExp;
+ api: { version?: string };
settings: CachedSettings;
endpointTimeSummary: Summary;
endpointTimeHistogram: Histogram;
@@ -23,6 +24,17 @@ export const metricsMiddleware =
activeRequestsGauge: Gauge;
}): MiddlewareHandler =>
async (c, next) => {
+ // Several metrics middlewares share the same `/api` mount (v1, experimental, apps, default), so
+ // each one has to ignore the paths that belong to the others or a request gets sampled more than
+ // once. The versioned ones opt in by prefix; the catch-all opts out of the prefixes it does not own.
+ if (basePathRegex && !basePathRegex.test(c.req.path)) {
+ return next();
+ }
+
+ if (excludePathRegex?.test(c.req.path)) {
+ return next();
+ }
+
const rocketchatRestApiEnd = endpointTimeSummary.startTimer();
const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer();
diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md
new file mode 100644
index 0000000000000..b3f7a86c38985
--- /dev/null
+++ b/docs/experimental-api-endpoints-plan.md
@@ -0,0 +1,215 @@
+# Plan: Experimental REST API endpoints
+
+## Goal
+
+Allow REST endpoints to ship to production but **change or be removed in any release
+without a major-version bump**. The mechanism must be general-purpose (any team can
+use it), not specific to one feature.
+
+## The contract
+
+> Endpoints under `/api/experimental/...` are unstable. They may change shape or be
+> removed in any release, without notice and without a deprecation cycle. No semver
+> promise attaches to this namespace.
+
+The namespace **is** the contract. A caller hitting `/api/experimental/*` has opted
+into instability by the URL alone. `/v1` keeps its implicit semver-stability promise,
+untouched.
+
+**Only the new typed API is allowed on experimental routes.** Endpoints must be
+registered with `.get()` / `.post()` / `.put()` / `.delete()` (with AJV `body` / `query`
+/ `response` validators). The deprecated `.addRoute()` must not be used — new surface
+area should not be born on the legacy registration path. This is a documented rule, not a
+compiler-enforced one: `API.experimental` is a plain `APIClass`, and `.addRoute()` already
+carries `@deprecated` everywhere it is reachable.
+
+## Why this design (key findings from the current code)
+
+- `createApi({ version })` turns the `version` string into the URL path segment, so a
+ new instance with `version: 'experimental'` mounts at `/api/experimental/`
+ without changing any router internals — the new router still has to be mounted in
+ `startRestAPI` (see Step 2). See `apps/meteor/server/api/api.ts` (the `API` object and
+ `createApi`) and `apps/meteor/server/api/ApiClass.ts` (`apiPath` composition in the
+ `APIClass` constructor).
+- The typed route methods `.get()/.post()/.put()/.delete()` are generic over
+ `TSubPathPattern extends string` (`ApiClass.ts`, `APIClass.method()` and its
+ per-verb wrappers) — they are **not** gated on `keyof Endpoints`. Routes accumulate
+ *outward* into the instance's `TOperations`, read back by `ExtractApiClassEndpoints`
+ (`apps/meteor/server/api/api.ts`). So a separate experimental instance works *with*
+ the type system.
+- `PathPattern`, `Method`, `Path`, and the typed client are all derived from the
+ `Endpoints` interface (`packages/rest-typings/src/index.ts`). Keeping
+ experimental paths **out** of that interface keeps the stable client surface clean
+ and forces explicit opt-in for experimental ones.
+- The deprecation framework already writes `x-deprecation-*` response headers
+ (`writeDeprecationHeader` in `apps/meteor/server/lib/deprecationWarningLogger.ts`). We
+ mirror that pattern for an `x-experimental` / `Warning` signal.
+- Auth, permissions, rate limiting, CORS, AJV validation, and metrics all come from
+ `createApi` + the middleware chain in `startRestAPI` — experimental endpoints get
+ them for free.
+
+---
+
+## Commit constraint
+
+**Each implementation step below ships as exactly one commit.** No step is split across
+multiple commits, and no commit spans more than one step. This keeps the history
+bisectable, makes each phase independently reviewable and revertable, and maps the PR
+review 1:1 onto the plan.
+
+- A step's commit must leave the tree in a compiling, lint-clean state (`yarn lint
+ --quiet` passes) — partial work is squashed before committing.
+- Commit message subject names the step, e.g. `feat(api): add experimental API instance
+ (step 1)`.
+- If a step turns out to require a prerequisite not in the plan, add it to that step's
+ single commit rather than introducing an out-of-band commit.
+
+## Implementation steps
+
+### Step 1 — Add the `experimental` API instance
+
+**File:** `apps/meteor/server/api/api.ts`
+
+1. In the `API` object literal, add:
+ ```ts
+ experimental: createApi({ version: 'experimental', useDefaultAuth: true }),
+ ```
+ Place it between `v1` and `default`.
+2. Add an `experimental` entry to the `API` type annotation so it is typed:
+ `APIClass<'/experimental'>`. Hiding `addRoute()` from that type was considered and
+ dropped: the typed methods return `this`, so a restricted surface only holds until the
+ first chained registration, and closing that hole means duplicating every typed
+ signature and widening the route-extraction types that pattern-match `APIClass`. Not
+ worth it for a rule `@deprecated` already signals.
+3. Refreshing experimental routes when settings change is a **required** parity
+ condition, not an optional extra — the contract above promises experimental
+ endpoints get rate limiting "for free", which only holds if the refresh callbacks
+ cover them. The `settings.watch(...)` callbacks in this file that must also update
+ `API.experimental`:
+ - `API_Enable_Rate_Limiter_Limit_Time_Default` → `reloadRoutesToRefreshRateLimiter()`
+ - `API_Enable_Rate_Limiter_Limit_Calls_Default` → `reloadRoutesToRefreshRateLimiter()`
+ - `Accounts_CustomFields` → `setLimitedCustomFields()`
+
+ **Known gap:** the rate-limiter watchers are at parity; the `Accounts_CustomFields`
+ watcher still updates `API.v1` only. That is currently harmless — no experimental
+ endpoint returns user objects — but it must be closed before one does.
+
+**Acceptance:** `API.experimental.get('ping', { ... }, handler)` compiles and serves at
+`GET /api/experimental/ping`.
+
+**Commit (1 of 5):** `feat(api): add experimental API instance`
+
+### Step 2 — Mount it in the request pipeline
+
+**File:** `apps/meteor/server/api/api.ts`, `startRestAPI`
+
+1. Insert `.use(API.experimental.router)` into the chain, **before**
+ `.use(API.default.router)`. Order matters: `default` is the catch-all.
+2. Add a second `metricsMiddleware` block pointed at `API.experimental` so experimental
+ traffic is measured. Metrics are the canary used later to decide whether an endpoint is
+ ready for promotion to `/v1`. Because every block shares the same `/api` mount, each one
+ needs a guard or a request is sampled more than once: the versioned blocks opt in via
+ `basePathRegex`, and a catch-all block for `API.default` (`/api/info`, `/api/docs/json`,
+ unmatched `/api/*`) opts out via `excludePathRegex`. Without that catch-all block the
+ guards silently drop default-router traffic that used to be sampled.
+
+**Acceptance:** experimental requests appear in the REST API Prometheus metrics labelled
+`version=experimental` — specifically that label, not merely a distinguishable one. A
+change that only adjusted the path regex while leaving experimental traffic under the
+`v1` version label does not satisfy this. `/api/v1/*` and default-router traffic each
+still record exactly one sample under their own label.
+
+**Commit (2 of 5):** `feat(api): mount experimental router and metrics`
+
+### Step 3 — Runtime "unstable" signal (mirror deprecation headers)
+
+**New file:** `apps/meteor/server/api/v1/middlewares/experimental.ts`, colocated with the
+existing middlewares.
+
+1. Write a middleware that sets, on every response from the experimental instance:
+ ```
+ Warning: 299 - "experimental: endpoint is unstable and may change without notice"
+ x-experimental: true
+ ```
+ `x-experimental: true` is the **supported programmatic signal** — clients should detect
+ experimental responses with it. `Warning: 299` is a legacy compatibility signal only:
+ warn code 299 came from RFC 7234, which RFC 9111 has since obsoleted along with the
+ `Warning` header itself, so modern clients are not expected to generate or interpret
+ it. It is emitted for the benefit of tooling that still surfaces it, and may be dropped
+ without it being a breaking change. Model the header-writing on `writeDeprecationHeader`
+ in `apps/meteor/server/lib/deprecationWarningLogger.ts`.
+2. Register the middleware on the shared `/api` mount in `startRestAPI`, **ahead of**
+ `cors`, scoped to `/api/experimental` by a `basePathRegex` (same shape as the metrics
+ middleware guard). It cannot live on `API.experimental.router`: `cors` answers rejected
+ preflights with 403/405 without calling `next()`, so a router-scoped middleware would
+ never run for those responses.
+
+**Acceptance:** every `/api/experimental/*` response carries both headers — including 404s
+and CORS preflight rejections; `/api/v1/*` responses do not.
+
+**Commit (3 of 5):** `feat(api): add experimental unstable-signal middleware`
+
+### Step 4 — Separate, opt-in SDK typings
+
+**File:** `packages/rest-typings/src/index.ts` (+ a new file for the declarations)
+
+1. Create `packages/rest-typings/src/experimental/index.ts` (new folder) and declare:
+ ```ts
+ export type ExperimentalEndpoints = {
+ '/experimental/': {
+ GET: (params: ...) => ...;
+ };
+ // ...
+ };
+ ```
+ Follow the existing per-resource endpoint style (e.g.
+ `packages/rest-typings/src/v1/channels/channels.ts`).
+2. Export `ExperimentalEndpoints` from the package root, but **do NOT** add it to the
+ `interface Endpoints extends ...` union. This keeps `PathPattern`,
+ `Method`, `Path`, and the stable typed client free of experimental paths.
+3. Consumers who want typed experimental calls import `ExperimentalEndpoints`
+ explicitly.
+
+**Acceptance:** `import type { Endpoints } from '@rocket.chat/rest-typings'` does NOT
+include experimental paths; `import type { ExperimentalEndpoints }` does.
+
+**Commit (4 of 5):** `feat(rest-typings): add opt-in ExperimentalEndpoints`
+
+### Step 5 — Guardrails (because it is a general mechanism)
+
+1. **No path in both unions.** A type-level CI guard for this was considered and dropped:
+ union keys are full paths, so `/experimental/x` and `/v1/x` never collide, and the
+ transition window described below does not produce a collision either. Keep the rule in
+ the docs instead — promotion means *moving* the declaration to a stable `*Endpoints`
+ type, not leaving a copy behind.
+2. **Promotion path:** document that stabilizing an endpoint means copying it to `/v1`
+ (optionally keeping the experimental path forwarding for a transition window).
+ Removal needs no deprecation cycle — but log removals for courtesy.
+3. **Docs/CONTRIBUTING note:** state the no-semver guarantee and how to add an
+ experimental endpoint, so the mechanism is discoverable.
+4. **OpenAPI/doc generation:** decide deliberately whether generated API docs scan only
+ `Endpoints` (experimental endpoints hidden — probably desirable) or also
+ `ExperimentalEndpoints`.
+
+**Commit (5 of 5):** `chore(api): add experimental guardrails and docs`
+
+---
+
+## Test checklist
+
+- [ ] `GET /api/experimental/` resolves and returns the `x-experimental` + `Warning` headers.
+- [ ] `/api/v1/*` responses are unchanged (no experimental headers).
+- [ ] Auth / permissions / rate limiting enforced on an experimental route exactly as on `/v1`.
+- [ ] `Endpoints` type does not include experimental paths; `ExperimentalEndpoints` does.
+- [ ] Experimental requests show up in REST API metrics.
+
+## Files touched (summary)
+
+| File | Change |
+| ---- | ------ |
+| `apps/meteor/server/api/api.ts` | Add `experimental` instance, type entry, mount in `startRestAPI`, metrics blocks |
+| `apps/meteor/server/api/v1/middlewares/experimental.ts` (new) | `x-experimental` / `Warning` header middleware |
+| `apps/meteor/server/api/v1/middlewares/metrics.ts` | `basePathRegex` / `excludePathRegex` sampling guards |
+| `packages/rest-typings/src/experimental/index.ts` (new) | `ExperimentalEndpoints` type, NOT merged into `Endpoints` |
+| `packages/rest-typings/src/index.ts` | Export `ExperimentalEndpoints` |
+| docs / CONTRIBUTING | Document the contract + promotion path |
diff --git a/docs/experimental-api-endpoints.md b/docs/experimental-api-endpoints.md
new file mode 100644
index 0000000000000..c049e34471edf
--- /dev/null
+++ b/docs/experimental-api-endpoints.md
@@ -0,0 +1,116 @@
+# Experimental REST API endpoints
+
+> Developer guide. For how the mechanism is built, see
+> [experimental-api-endpoints-plan.md](experimental-api-endpoints-plan.md).
+
+## What they are
+
+Experimental endpoints live under `/api/experimental/...` and carry an explicit
+stability contract:
+
+> Endpoints under `/api/experimental/...` are **unstable**. They may change shape or be
+> removed in **any** release — without notice and without a deprecation cycle. No semver
+> promise attaches to this namespace.
+
+Compare with `/api/v1/...`, which is the official, stable surface: its endpoints follow
+semver, breaking changes require a major-version bump, and removals go through a
+deprecation cycle.
+
+## Why they exist
+
+We sometimes need to ship something to production *before* its API shape has settled:
+
+- A new feature whose request/response contract is still being learned from real usage.
+- An endpoint built for a specific client (e.g. our own UI) where we are not yet ready
+ to commit to it as a public, supported interface.
+- Something we want behind a clear "use at your own risk" sign while it matures.
+
+Without an experimental lane, the only choices are bad ones: either freeze a design we
+are not confident in onto `/v1` (and then carry it forever, or break it with a major
+bump), or keep the feature out of production until the API is perfect. Experimental
+endpoints give a third path — ship now, iterate freely, commit later.
+
+## Should I use one? — decision guide
+
+**Use an experimental endpoint when:**
+
+- The request/response shape is likely to change as the feature matures.
+- You want production traffic / real feedback before committing to a contract.
+- The consumer is internal or opted-in, and can tolerate breaking changes between
+ releases.
+- You would otherwise be tempted to "just put it on `/v1` for now and fix it later."
+
+**Do NOT use an experimental endpoint when:**
+
+- The endpoint is meant for third-party integrators who expect stability. They should
+ not have to track breakage release-to-release.
+- The contract is already well understood and unlikely to change — put it on `/v1`.
+- You are tempted to use `experimental` as a permanent home to avoid the discipline of
+ a stable API. It is a staging area, not a dumping ground (see below).
+
+## Expectations if you publish one
+
+- **It is not forever.** Every experimental endpoint is expected to either be
+ **elevated to `/v1`** once its contract stabilizes, or be **removed** if it does not
+ pan out. An endpoint that sits in `experimental` indefinitely is a smell — it means a
+ decision is overdue.
+- **Callers are warned at runtime.** Every experimental response carries
+ `x-experimental: true` — that is the supported signal to detect and surface in client
+ code. Responses also carry a `Warning: 299 ...` header, kept only for legacy tooling
+ that still reads it: RFC 9111 obsoletes the `Warning` header and its warn codes, so do
+ not build new client logic on it.
+- **Typed clients must opt in.** Experimental endpoints are declared in a separate
+ `ExperimentalEndpoints` type, not in the main `Endpoints` union, so the stable SDK
+ surface stays honest. Consumers import them deliberately.
+- **Use the typed API only.** Register with `.get()` / `.post()` / `.put()` /
+ `.delete()` and AJV validators. Do not use `.addRoute()`: it is already deprecated
+ across the whole API, and a namespace created to iterate on new contracts is the last
+ place that should add to the legacy path.
+
+## Lifecycle: experimental → official
+
+```text
+ stabilizes
+ experimental ───────────────▶ v1 (official, semver-stable)
+ (/api/experimental/x) (/api/v1/x)
+ │
+ │ does not pan out
+ ▼
+ removed (no deprecation cycle needed)
+```
+
+**Elevating to `/v1`:**
+
+1. Confirm the contract is stable and you are ready to support it under semver.
+2. Add the endpoint under `/v1`: register it on `API.v1` and declare its types in the
+ appropriate `*Endpoints` type that *is* part of the `Endpoints` union.
+3. Optionally keep the experimental path forwarding to the new `/v1` path for a
+ transition window so existing callers are not broken on the day of promotion.
+4. Remove the experimental declaration once the transition window closes.
+
+**Removing an experimental endpoint** needs no deprecation cycle — that freedom is the
+whole point of the namespace. Still, log the removal and give a heads-up to any known
+consumers as a courtesy.
+
+## Guardrails & tooling
+
+- **No path lives in both unions.** A duplicate key would silently attach a semver
+ obligation to a path advertised as unstable, so promotion means *moving* the declaration
+ to a stable `*Endpoints` type, not leaving a copy behind. Nothing enforces this — the
+ `/experimental/` path prefix keeps the two unions from overlapping in practice.
+- **Generated API docs intentionally skip experimental endpoints.** OpenAPI /
+ doc generation scans the `Endpoints` union, which experimental paths are
+ deliberately kept out of, so they do not appear in public API docs. This is
+ by design: an unstable surface should not be advertised as part of the
+ documented contract. The runtime `x-experimental` / `Warning` headers and
+ this guide are how the namespace is surfaced instead.
+- **Metrics are the promotion signal.** Experimental traffic is recorded in the
+ REST API Prometheus metrics under `version=experimental`, so real usage can
+ inform whether an endpoint is ready to graduate to `/v1` or should be removed.
+
+## TL;DR
+
+Experimental endpoints let you ship an API to production while its shape is still in
+flux, without locking yourself into semver. They are a **staging area, not a permanent
+home**: every one is expected to graduate to `/v1` or be removed. If you need stability
+guarantees, use `/v1`. If a third party will depend on it, use `/v1`.
diff --git a/packages/rest-typings/src/experimental/index.ts b/packages/rest-typings/src/experimental/index.ts
new file mode 100644
index 0000000000000..6cca034a56677
--- /dev/null
+++ b/packages/rest-typings/src/experimental/index.ts
@@ -0,0 +1,27 @@
+/**
+ * Opt-in typings for experimental REST endpoints (`/api/experimental/...`).
+ *
+ * These are intentionally **not** merged into the `Endpoints` union exported
+ * from the package root, so the stable typed client surface (`PathPattern`,
+ * `Method`, `Path`, the SDK) stays free of unstable paths. Consumers who want
+ * typed experimental calls import `ExperimentalEndpoints` explicitly.
+ *
+ * Endpoints under this namespace carry **no semver promise**: they may change
+ * shape or be removed in any release without a deprecation cycle. See
+ * `docs/experimental-api-endpoints.md`.
+ *
+ * Declare new experimental endpoints here, following the per-resource style of
+ * the `/v1` endpoint types (e.g. `v1/calendar`). Every path key must begin with
+ * `/experimental/`. For example:
+ *
+ * ```ts
+ * export type ExperimentalEndpoints = {
+ * '/experimental/example.info': {
+ * GET: (params: { id: string }) => { id: string; value: number };
+ * };
+ * };
+ * ```
+ */
+export type ExperimentalEndpoints = {
+ // No experimental endpoints are currently declared.
+};
diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts
index 4694a86ade68a..419f8f82aa49f 100644
--- a/packages/rest-typings/src/index.ts
+++ b/packages/rest-typings/src/index.ts
@@ -272,5 +272,9 @@ export * from './v1/banners';
export * from './default';
export * from './v1/twoFactorChallenges';
+// Opt-in experimental endpoint typings. Deliberately NOT part of the `Endpoints`
+// union above — see ./experimental for the rationale.
+export type * from './experimental';
+
// Export the ajv instance for use in other packages
export * from './v1/Ajv';
From 3aea9739487ac87f39ec3592e65c0ec83452c7f5 Mon Sep 17 00:00:00 2001
From: Tasso Evangelista
Date: Thu, 20 Aug 2026 14:53:34 +0000
Subject: [PATCH 4/4] chore(media-signaling): Remove dangling `.eslintrc.json`
(#41884)
---
packages/media-signaling/.eslintrc.json | 4 ----
1 file changed, 4 deletions(-)
delete mode 100644 packages/media-signaling/.eslintrc.json
diff --git a/packages/media-signaling/.eslintrc.json b/packages/media-signaling/.eslintrc.json
deleted file mode 100644
index 9ec331f09b5e3..0000000000000
--- a/packages/media-signaling/.eslintrc.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "extends": ["@rocket.chat/eslint-config"],
- "ignorePatterns": ["dist"]
-}