From 11e390a7124709f8e37b229a93d85ce16f873caf Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 15:52:41 -0300 Subject: [PATCH 1/8] chore: add POST /v1/users.block and /v1/users.unblock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract blockUserMethod/unblockUserMethod into app/lib/server/functions/ and reuse them from REST + DDP. Body is { rid, userId }, mirroring users.resetE2EKey naming. Permission is per-room via RoomMemberActions.BLOCK — the same check the DDP method already enforces; unblock has no permission check today and the REST endpoint keeps parity to avoid a silent regression. Co-Authored-By: Claude Opus 4.7 --- .changeset/rest-users-block-unblock.md | 6 +++ apps/meteor/app/api/server/v1/users.ts | 42 +++++++++++++++++++ .../app/lib/server/functions/blockUser.ts | 35 ++++++++++++++++ .../app/lib/server/functions/unblockUser.ts | 23 ++++++++++ .../app/lib/server/methods/blockUser.ts | 35 +++------------- .../app/lib/server/methods/unblockUser.ts | 25 +++-------- packages/rest-typings/src/v1/users.ts | 12 ++++++ .../src/v1/users/UsersBlockParamsPOST.ts | 24 +++++++++++ .../src/v1/users/UsersUnblockParamsPOST.ts | 24 +++++++++++ 9 files changed, 177 insertions(+), 49 deletions(-) create mode 100644 .changeset/rest-users-block-unblock.md create mode 100644 apps/meteor/app/lib/server/functions/blockUser.ts create mode 100644 apps/meteor/app/lib/server/functions/unblockUser.ts create mode 100644 packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts diff --git a/.changeset/rest-users-block-unblock.md b/.changeset/rest-users-block-unblock.md new file mode 100644 index 0000000000000..40e9110557fed --- /dev/null +++ b/.changeset/rest-users-block-unblock.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/users.block` and `POST /v1/users.unblock` (replace the deprecated `blockUser` / `unblockUser` DDP methods). Both accept `{ rid, userId }`, are auth-gated, and reuse the per-room `RoomMemberActions.BLOCK` directive that the DDP method already enforced. The legacy DDP methods remain registered until 9.0.0 with a deprecation log pointing at the new routes. diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index fbc46b9579a67..9deef02045128 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -23,6 +23,8 @@ import { isUsersRequestDataDownloadParamsGET, isUsersGetPresenceParamsGET, isUsersGetStatusParamsGET, + isUsersBlockParamsPOST, + isUsersUnblockParamsPOST, ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, @@ -53,6 +55,7 @@ import { executeSetUserActiveStatus } from '../../../../server/methods/setUserAc import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { blockUserMethod } from '../../../lib/server/functions/blockUser'; import { checkEmailAvailability } from '../../../lib/server/functions/checkEmailAvailability'; import { checkUsernameAvailability, @@ -70,6 +73,7 @@ import { canEditExtension } from '../../../lib/server/functions/saveUser/validat import { setStatusText } from '../../../lib/server/functions/setStatusText'; import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername'; +import { unblockUserMethod } from '../../../lib/server/functions/unblockUser'; import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields'; import { validateNameChars } from '../../../lib/server/functions/validateNameChars'; import { validateUsername } from '../../../lib/server/functions/validateUsername'; @@ -1805,6 +1809,44 @@ API.v1 return API.v1.success(); } await resetTOTP(this.userId, false); + return API.v1.success(); + }, + ) + .post( + 'users.block', + { + authRequired: true, + body: isUsersBlockParamsPOST, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, userId } = this.bodyParams; + + await blockUserMethod(this.userId, { rid, blocked: userId }); + + return API.v1.success(); + }, + ) + .post( + 'users.unblock', + { + authRequired: true, + body: isUsersUnblockParamsPOST, + response: { + 200: voidSuccessResponse, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { rid, userId } = this.bodyParams; + + await unblockUserMethod(this.userId, { rid, blocked: userId }); + return API.v1.success(); }, ); diff --git a/apps/meteor/app/lib/server/functions/blockUser.ts b/apps/meteor/app/lib/server/functions/blockUser.ts new file mode 100644 index 0000000000000..c748ade354a34 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/blockUser.ts @@ -0,0 +1,35 @@ +import { Subscriptions, Rooms } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; + +export const blockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise => { + const room = await Rooms.findOne({ _id: rid }); + + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, userId))) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); + + if (!blockedUser || !blockerUser) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + } + + const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } +}; diff --git a/apps/meteor/app/lib/server/functions/unblockUser.ts b/apps/meteor/app/lib/server/functions/unblockUser.ts new file mode 100644 index 0000000000000..03ee770f69d6b --- /dev/null +++ b/apps/meteor/app/lib/server/functions/unblockUser.ts @@ -0,0 +1,23 @@ +import { Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; + +export const unblockUserMethod = async (userId: string, { rid, blocked }: { rid: string; blocked: string }): Promise => { + const [blockedUser, blockerUser] = await Promise.all([ + Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), + Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), + ]); + + if (!blockedUser || !blockerUser) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'unblockUser' }); + } + + const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); + + const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; + + if (listenerUsers.length) { + void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); + } +}; diff --git a/apps/meteor/app/lib/server/methods/blockUser.ts b/apps/meteor/app/lib/server/methods/blockUser.ts index 7fe6ec803dd1d..6ece1af88ca16 100644 --- a/apps/meteor/app/lib/server/methods/blockUser.ts +++ b/apps/meteor/app/lib/server/methods/blockUser.ts @@ -1,11 +1,9 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Subscriptions, Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; -import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; +import { blockUserMethod } from '../functions/blockUser'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,40 +14,17 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async blockUser({ rid, blocked }) { + methodDeprecationLogger.method('blockUser', '9.0.0', '/v1/users.block'); check(rid, String); check(blocked, String); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); } - const room = await Rooms.findOne({ _id: rid }); - - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); - } - - if (!(await roomCoordinator.getRoomDirectives(room.t).allowMemberAction(room, RoomMemberActions.BLOCK, userId))) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); - } - - const [blockedUser, blockerUser] = await Promise.all([ - Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), - Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), - ]); - - if (!blockedUser || !blockerUser) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); - } - - const [blockedResponse, blockerResponse] = await Subscriptions.setBlockedByRoomId(rid, blocked, userId); - - const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; - - if (listenerUsers.length) { - void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); - } + await blockUserMethod(userId, { rid, blocked }); return true; }, diff --git a/apps/meteor/app/lib/server/methods/unblockUser.ts b/apps/meteor/app/lib/server/methods/unblockUser.ts index 7b4bc56600103..75d556950c193 100644 --- a/apps/meteor/app/lib/server/methods/unblockUser.ts +++ b/apps/meteor/app/lib/server/methods/unblockUser.ts @@ -1,9 +1,9 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Subscriptions } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { notifyOnSubscriptionChangedByRoomIdAndUserIds } from '../lib/notifyListener'; +import { unblockUserMethod } from '../functions/unblockUser'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -14,30 +14,17 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async unblockUser({ rid, blocked }) { + methodDeprecationLogger.method('unblockUser', '9.0.0', '/v1/users.unblock'); check(rid, String); check(blocked, String); + const userId = Meteor.userId(); if (!userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'blockUser' }); - } - - const [blockedUser, blockerUser] = await Promise.all([ - Subscriptions.findOneByRoomIdAndUserId(rid, blocked, { projection: { _id: 1 } }), - Subscriptions.findOneByRoomIdAndUserId(rid, userId, { projection: { _id: 1 } }), - ]); - - if (!blockedUser || !blockerUser) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'blockUser' }); + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'unblockUser' }); } - const [blockedResponse, blockerResponse] = await Subscriptions.unsetBlockedByRoomId(rid, blocked, userId); - - const listenerUsers = [...(blockedResponse?.modifiedCount ? [blocked] : []), ...(blockerResponse?.modifiedCount ? [userId] : [])]; - - if (listenerUsers.length) { - void notifyOnSubscriptionChangedByRoomIdAndUserIds(rid, listenerUsers); - } + await unblockUserMethod(userId, { rid, blocked }); return true; }, diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index dda7d95c75330..42252735405b9 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -8,6 +8,7 @@ import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; +import type { UsersBlockParamsPOST } from './users/UsersBlockParamsPOST'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; import type { UsersListParamsGET } from './users/UsersListParamsGET'; import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET'; @@ -15,6 +16,7 @@ import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { UsersSendConfirmationEmailParamsPOST } from './users/UsersSendConfirmationEmailParamsPOST'; import type { UsersSendWelcomeEmailParamsPOST } from './users/UsersSendWelcomeEmailParamsPOST'; import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferenceParamsPOST'; +import type { UsersUnblockParamsPOST } from './users/UsersUnblockParamsPOST'; import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST'; import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST'; @@ -371,6 +373,14 @@ export type UsersEndpoints = { '/v1/users.deleteOwnAccount': { POST: (params: { password: string; confirmRelinquish?: boolean }) => void; }; + + '/v1/users.block': { + POST: (params: UsersBlockParamsPOST) => void; + }; + + '/v1/users.unblock': { + POST: (params: UsersUnblockParamsPOST) => void; + }; }; export * from './users/UserCreateParamsPOST'; @@ -384,3 +394,5 @@ export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; export * from './users/UsersAutocompleteParamsGET'; +export * from './users/UsersBlockParamsPOST'; +export * from './users/UsersUnblockParamsPOST'; diff --git a/packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts new file mode 100644 index 0000000000000..6b29bb9e83284 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts @@ -0,0 +1,24 @@ +import { ajv } from '../Ajv'; + +export type UsersBlockParamsPOST = { + rid: string; + userId: string; +}; + +const UsersBlockParamsPostSchema = { + type: 'object', + properties: { + rid: { + type: 'string', + minLength: 1, + }, + userId: { + type: 'string', + minLength: 1, + }, + }, + required: ['rid', 'userId'], + additionalProperties: false, +}; + +export const isUsersBlockParamsPOST = ajv.compile(UsersBlockParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts new file mode 100644 index 0000000000000..88def5aaa1db5 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts @@ -0,0 +1,24 @@ +import { ajv } from '../Ajv'; + +export type UsersUnblockParamsPOST = { + rid: string; + userId: string; +}; + +const UsersUnblockParamsPostSchema = { + type: 'object', + properties: { + rid: { + type: 'string', + minLength: 1, + }, + userId: { + type: 'string', + minLength: 1, + }, + }, + required: ['rid', 'userId'], + additionalProperties: false, +}; + +export const isUsersUnblockParamsPOST = ajv.compile(UsersUnblockParamsPostSchema); From 78d9a222327c3974fa221fa44787482874545d4b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 15:52:47 -0300 Subject: [PATCH 2/8] chore: add POST /v1/settings.bulk Extract saveSettingsBulk into app/lib/server/functions/ and call it from both REST and DDP. The endpoint is auth-gated, enforces 2FA (disableRememberMe), and reuses the per-setting permission chain (edit-privileged-setting OR manage-selected-settings + per -id permission) the DDP method already enforced. Co-Authored-By: Claude Opus 4.7 --- .changeset/rest-settings-bulk.md | 6 + apps/meteor/app/api/server/v1/settings.ts | 28 ++++ .../lib/server/functions/saveSettingsBulk.ts | 129 ++++++++++++++++++ .../app/lib/server/methods/saveSettings.ts | 112 +-------------- packages/rest-typings/src/v1/settings.ts | 33 ++++- 5 files changed, 200 insertions(+), 108 deletions(-) create mode 100644 .changeset/rest-settings-bulk.md create mode 100644 apps/meteor/app/lib/server/functions/saveSettingsBulk.ts diff --git a/.changeset/rest-settings-bulk.md b/.changeset/rest-settings-bulk.md new file mode 100644 index 0000000000000..c24214ef3c788 --- /dev/null +++ b/.changeset/rest-settings-bulk.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/settings.bulk` for batched admin setting updates (replaces the deprecated `saveSettings` DDP method). Body is `{ settings: { _id, value }[] }`. The endpoint requires authentication, enforces 2FA (`twoFactorRequired: true`), and runs the same per-setting permission chain (`edit-privileged-setting` OR `manage-selected-settings` + per-id permission) and audit/notify side effects the DDP method already performed. The legacy DDP method remains registered until 9.0.0 with a deprecation log pointing at the new route. diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 97e4a51d3133c..1f52ed4bd1623 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -15,8 +15,10 @@ import { isSettingsUpdatePropsColor, isSettingsPublicWithPaginationProps, isSettingsGetParams, + isSettingsBulkProps, validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, + validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import type { FindOptions } from 'mongodb'; @@ -25,6 +27,7 @@ import _ from 'underscore'; import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { disableCustomScripts } from '../../../lib/server/functions/disableCustomScripts'; +import { saveSettingsBulk } from '../../../lib/server/functions/saveSettingsBulk'; import { checkSettingValueBounds } from '../../../lib/server/lib/checkSettingValueBonds'; import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { addOAuthServiceMethod } from '../../../lib/server/methods/addOAuthService'; @@ -404,6 +407,31 @@ API.v1.post( }, ); +API.v1.post( + 'settings.bulk', + { + authRequired: true, + twoFactorRequired: true, + twoFactorOptions: { disableRememberMe: true }, + body: isSettingsBulkProps, + response: { + 200: settingByIdPostResponseSchema, + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + await saveSettingsBulk(this.userId, this.bodyParams.settings, { + username: this.user.username ?? '', + ip: this.requestIp ?? '', + useragent: this.request.headers.get('user-agent') ?? '', + }); + + return API.v1.success(); + }, +); + API.v1.get( 'service.configurations', { diff --git a/apps/meteor/app/lib/server/functions/saveSettingsBulk.ts b/apps/meteor/app/lib/server/functions/saveSettingsBulk.ts new file mode 100644 index 0000000000000..9f4f5a1a83f38 --- /dev/null +++ b/apps/meteor/app/lib/server/functions/saveSettingsBulk.ts @@ -0,0 +1,129 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { isSettingCode } from '@rocket.chat/core-typings'; +import { Settings } from '@rocket.chat/models'; +import { Match, check } from 'meteor/check'; +import { Meteor } from 'meteor/meteor'; + +import { disableCustomScripts } from './disableCustomScripts'; +import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; +import { getSettingPermissionId } from '../../../authorization/lib'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { settings } from '../../../settings/server'; +import { checkSettingValueBounds } from '../lib/checkSettingValueBonds'; +import { notifyOnSettingChangedById } from '../lib/notifyListener'; + +const validJSON = Match.Where((value: string) => { + try { + value === '' || JSON.parse(value); + return true; + } catch (_) { + throw new Meteor.Error('Invalid JSON provided'); + } +}); + +const checkInteger = (value: ISetting['value']) => { + if (!Number.isInteger(value)) { + throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, { + method: 'saveSettings', + }); + } +}; + +export type SaveSettingsAudit = { + username: string; + ip: string; + useragent: string; +}; + +export const saveSettingsBulk = async ( + uid: string, + params: { _id: ISetting['_id']; value: ISetting['value'] }[], + audit: SaveSettingsAudit, +): Promise => { + const settingsNotAllowed: ISetting['_id'][] = []; + + const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); + const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); + + // if the id contains Organization_Name then change the Site_Name + const orgName = params.find(({ _id }) => _id === 'Organization_Name'); + + if (orgName) { + // check if the site name is still the default value or ifs the same as organization name + const siteName = await Settings.findOneById('Site_Name'); + + if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { + params.push({ + _id: 'Site_Name', + value: orgName.value, + }); + } + } + + await Promise.all( + params.map(async ({ _id, value }) => { + // Verify the _id passed in is a string. + check(_id, String); + if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { + return settingsNotAllowed.push(_id); + } + + // Disable custom scripts in cloud trials to prevent phishing campaigns + if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { + return settingsNotAllowed.push(_id); + } + + const setting = await Settings.findOneById(_id); + // Verify the value is what it should be + switch (setting?.type) { + case 'roomPick': + check(value, Match.OneOf([Object], '')); + break; + case 'boolean': + check(value, Boolean); + break; + case 'timespan': + case 'int': + case 'range': + check(value, Number); + checkInteger(value); + checkSettingValueBounds(setting, value); + break; + case 'multiSelect': + check(value, Array); + break; + case 'code': + check(value, String); + if (isSettingCode(setting) && setting.code === 'application/json') { + check(value, validJSON); + } + break; + default: + check(value, String); + break; + } + }), + ); + + if (settingsNotAllowed.length) { + throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { + method: 'saveSettings', + settingIds: settingsNotAllowed, + }); + } + + const auditSettingOperation = updateAuditedByUser({ + _id: uid, + username: audit.username, + ip: audit.ip, + useragent: audit.useragent, + }); + + const promises = params.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); + + (await Promise.all(promises)).forEach((value, index) => { + if (value?.modifiedCount) { + void notifyOnSettingChangedById(params[index]._id); + } + }); +}; diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index e8efa3be6ebd1..8c3208384a88d 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -1,18 +1,10 @@ import type { ISetting } from '@rocket.chat/core-typings'; -import { isSettingCode } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Settings } from '@rocket.chat/models'; -import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; -import { getSettingPermissionId } from '../../../authorization/lib'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { settings } from '../../../settings/server'; -import { disableCustomScripts } from '../functions/disableCustomScripts'; -import { checkSettingValueBounds } from '../lib/checkSettingValueBonds'; -import { notifyOnSettingChangedById } from '../lib/notifyListener'; +import { saveSettingsBulk } from '../functions/saveSettingsBulk'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -26,23 +18,6 @@ declare module '@rocket.chat/ddp-client' { } } -const validJSON = Match.Where((value: string) => { - try { - value === '' || JSON.parse(value); - return true; - } catch (_) { - throw new Meteor.Error('Invalid JSON provided'); - } -}); - -const checkInteger = (value: ISetting['value']) => { - if (!Number.isInteger(value)) { - throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, { - method: 'saveSettings', - }); - } -}; - Meteor.methods({ saveSettings: twoFactorRequired(async function ( params: { @@ -50,98 +25,21 @@ Meteor.methods({ value: ISetting['value']; }[] = [], ) { + methodDeprecationLogger.method('saveSettings', '9.0.0', '/v1/settings.bulk'); + const uid = Meteor.userId(); - const settingsNotAllowed: ISetting['_id'][] = []; if (uid === null) { throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { method: 'saveSetting', }); } - const editPrivilegedSetting = await hasPermissionAsync(uid, 'edit-privileged-setting'); - const manageSelectedSettings = await hasPermissionAsync(uid, 'manage-selected-settings'); - - // if the id contains Organization_Name then change the Site_Name - const orgName = params.find(({ _id }) => _id === 'Organization_Name'); - - if (orgName) { - // check if the site name is still the default value or ifs the same as organization name - const siteName = await Settings.findOneById('Site_Name'); - - if (siteName?.value === siteName?.packageValue || siteName?.value === settings.get('Organization_Name')) { - params.push({ - _id: 'Site_Name', - value: orgName.value, - }); - } - } - - await Promise.all( - params.map(async ({ _id, value }) => { - // Verify the _id passed in is a string. - check(_id, String); - if (!editPrivilegedSetting && !(manageSelectedSettings && (await hasPermissionAsync(uid, getSettingPermissionId(_id))))) { - return settingsNotAllowed.push(_id); - } - - // Disable custom scripts in cloud trials to prevent phishing campaigns - if (disableCustomScripts() && /^Custom_Script_/.test(_id)) { - return settingsNotAllowed.push(_id); - } - - const setting = await Settings.findOneById(_id); - // Verify the value is what it should be - switch (setting?.type) { - case 'roomPick': - check(value, Match.OneOf([Object], '')); - break; - case 'boolean': - check(value, Boolean); - break; - case 'timespan': - case 'int': - case 'range': - check(value, Number); - checkInteger(value); - checkSettingValueBounds(setting, value); - break; - case 'multiSelect': - check(value, Array); - break; - case 'code': - check(value, String); - if (isSettingCode(setting) && setting.code === 'application/json') { - check(value, validJSON); - } - break; - default: - check(value, String); - break; - } - }), - ); - - if (settingsNotAllowed.length) { - throw new Meteor.Error('error-action-not-allowed', 'Editing settings is not allowed', { - method: 'saveSettings', - settingIds: settingsNotAllowed, - }); - } - const auditSettingOperation = updateAuditedByUser({ - _id: uid, + await saveSettingsBulk(uid, params, { username: (await Meteor.userAsync())!.username!, ip: this.connection.clientAddress || '', useragent: this.connection.httpHeaders['user-agent'] || '', }); - const promises = params.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); - - (await Promise.all(promises)).forEach((value, index) => { - if (value?.modifiedCount) { - void notifyOnSettingChangedById(params[index]._id); - } - }); - return true; }, {}), }); diff --git a/packages/rest-typings/src/v1/settings.ts b/packages/rest-typings/src/v1/settings.ts index bfd4571cb1cac..7bbacc161505c 100644 --- a/packages/rest-typings/src/v1/settings.ts +++ b/packages/rest-typings/src/v1/settings.ts @@ -1,6 +1,6 @@ import type { ISetting, ISettingColor, LoginServiceConfiguration } from '@rocket.chat/core-typings'; -import { ajvQuery } from './Ajv'; +import { ajv, ajvQuery } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; import type { PaginatedResult } from '../helpers/PaginatedResult'; @@ -87,6 +87,33 @@ const SettingsGetSchema = { export const isSettingsGetParams = ajvQuery.compile(SettingsGetSchema); +export type SettingsBulkProps = { + settings: { _id: ISetting['_id']; value: ISetting['value'] }[]; +}; + +const SettingsBulkSchema = { + type: 'object', + properties: { + settings: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string', minLength: 1 }, + value: {}, + }, + required: ['_id'], + additionalProperties: false, + }, + minItems: 1, + }, + }, + required: ['settings'], + additionalProperties: false, +}; + +export const isSettingsBulkProps = ajv.compile(SettingsBulkSchema); + export type SettingsEndpoints = { '/v1/settings.public': { GET: (params: SettingsPublicWithPaginationProps) => PaginatedResult & { @@ -115,6 +142,10 @@ export type SettingsEndpoints = { POST: (params: SettingsUpdateProps) => void; }; + '/v1/settings.bulk': { + POST: (params: SettingsBulkProps) => void; + }; + '/v1/service.configurations': { GET: () => { configurations: Array; From cdf3c363f643c2f1b98dbadf0e124aff06812c61 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 15:52:53 -0300 Subject: [PATCH 3/8] chore: add POST /v1/e2e.requestSubscriptionKeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export requestSubscriptionKeysMethod and reuse it from REST + DDP. No body — the server reads this.userId and fans out notify.e2e.keyRequest broadcasts for the caller's encrypted subscriptions, matching the DDP method's behavior. Co-Authored-By: Claude Opus 4.7 --- .../rest-e2e-request-subscription-keys.md | 6 +++ apps/meteor/app/api/server/v1/e2e.ts | 19 ++++++++ .../server/methods/requestSubscriptionKeys.ts | 44 +++++++++++-------- 3 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 .changeset/rest-e2e-request-subscription-keys.md diff --git a/.changeset/rest-e2e-request-subscription-keys.md b/.changeset/rest-e2e-request-subscription-keys.md new file mode 100644 index 0000000000000..3b6ee9bd087ef --- /dev/null +++ b/.changeset/rest-e2e-request-subscription-keys.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/e2e.requestSubscriptionKeys` (replaces the deprecated `e2e.requestSubscriptionKeys` DDP method). Auth-gated, no body. Broadcasts `notify.e2e.keyRequest` for every encrypted room the caller is subscribed to without an E2E key, matching the DDP method's behavior. The legacy DDP method remains registered until 9.0.0 with a deprecation log pointing at the new route. diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index 489a9e27a5953..a864d2cfcaba1 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -16,6 +16,7 @@ import { handleSuggestedGroupKey } from '../../../e2e/server/functions/handleSug import { provideUsersSuggestedGroupKeys } from '../../../e2e/server/functions/provideUsersSuggestedGroupKeys'; import { resetRoomKey } from '../../../e2e/server/functions/resetRoomKey'; import { getUsersOfRoomWithoutKeyMethod } from '../../../e2e/server/methods/getUsersOfRoomWithoutKey'; +import { requestSubscriptionKeysMethod } from '../../../e2e/server/methods/requestSubscriptionKeys'; import { setRoomKeyIDMethod } from '../../../e2e/server/methods/setRoomKeyID'; import { setUserPublicAndPrivateKeysMethod } from '../../../e2e/server/methods/setUserPublicAndPrivateKeys'; import { updateGroupKey } from '../../../e2e/server/methods/updateGroupKey'; @@ -196,6 +197,24 @@ const e2eEndpoints = API.v1 return API.v1.success(); }, ) + .post( + 'e2e.requestSubscriptionKeys', + { + authRequired: true, + response: { + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + }), + }, + }, + + async function action() { + await requestSubscriptionKeysMethod(this.userId); + + return API.v1.success(); + }, + ) .get( 'e2e.fetchMyKeys', { diff --git a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts index cf899a5d64ad7..2beea4346dcaa 100644 --- a/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts +++ b/apps/meteor/app/e2e/server/methods/requestSubscriptionKeys.ts @@ -3,6 +3,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -10,8 +12,31 @@ declare module '@rocket.chat/ddp-client' { } } +export const requestSubscriptionKeysMethod = async (userId: string): Promise => { + // Get all encrypted rooms that the user is subscribed to and has no E2E key yet + const subscriptions = await Subscriptions.findByUserIdWithoutE2E(userId).toArray(); + const roomIds = subscriptions.map((subscription) => subscription.rid); + + // For all subscriptions without E2E key, get the rooms that have encryption enabled + const query = { + e2eKeyId: { + $exists: true, + }, + _id: { + $in: roomIds, + }, + }; + + const rooms = Rooms.find(query); + await rooms.forEach((room) => { + void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); + }); +}; + Meteor.methods({ async 'e2e.requestSubscriptionKeys'() { + methodDeprecationLogger.method('e2e.requestSubscriptionKeys', '9.0.0', '/v1/e2e.requestSubscriptionKeys'); + const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -19,24 +44,7 @@ Meteor.methods({ }); } - // Get all encrypted rooms that the user is subscribed to and has no E2E key yet - const subscriptions = await Subscriptions.findByUserIdWithoutE2E(userId).toArray(); - const roomIds = subscriptions.map((subscription) => subscription.rid); - - // For all subscriptions without E2E key, get the rooms that have encryption enabled - const query = { - e2eKeyId: { - $exists: true, - }, - _id: { - $in: roomIds, - }, - }; - - const rooms = Rooms.find(query); - await rooms.forEach((room) => { - void api.broadcast('notify.e2e.keyRequest', room._id, room.e2eKeyId); - }); + await requestSubscriptionKeysMethod(userId); return true; }, From f9a53d71c4c2970cb62cf3f8c35aa1cdc1f33415 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 15:53:01 -0300 Subject: [PATCH 4/8] chore: migrate batch3 client DDP callers to REST - deleteCustomSound -> POST /v1/custom-sounds.delete - blockUser/unblockUser -> POST /v1/users.block / /v1/users.unblock - saveSettings -> POST /v1/settings.bulk - e2e.requestSubscriptionKeys -> POST /v1/e2e.requestSubscriptionKeys The DDP methods stay registered on the server for external SDK/ mobile clients with a deprecation log pointing at the REST route. Flag the two e2e specs that still drive these methods through /v1/method.call (custom-sounds + methods) with TODOs so they can be migrated when the DDP methods are removed in 9.0.0. Co-Authored-By: Claude Opus 4.7 --- .changeset/ddp-migrate-batch3-callers.md | 10 ++++++++++ apps/meteor/client/lib/e2ee/rocketchat.e2e.ts | 2 +- apps/meteor/client/providers/SettingsProvider.tsx | 6 +++--- .../actions/useBlockUserAction.ts | 15 ++++++++++++--- apps/meteor/tests/end-to-end/api/custom-sounds.ts | 1 + apps/meteor/tests/end-to-end/api/methods.ts | 1 + 6 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 .changeset/ddp-migrate-batch3-callers.md diff --git a/.changeset/ddp-migrate-batch3-callers.md b/.changeset/ddp-migrate-batch3-callers.md new file mode 100644 index 0000000000000..735d2bc76d9c9 --- /dev/null +++ b/.changeset/ddp-migrate-batch3-callers.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate four client DDP callers to their REST equivalents (the DDP methods stay registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes them): + +- `deleteCustomSound` → `POST /v1/custom-sounds.delete` +- `blockUser` / `unblockUser` → `POST /v1/users.block` / `POST /v1/users.unblock` +- `saveSettings` → `POST /v1/settings.bulk` +- `e2e.requestSubscriptionKeys` → `POST /v1/e2e.requestSubscriptionKeys` diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index 6562dc384e36f..5e04134ed5065 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -513,7 +513,7 @@ class E2E extends Emitter { } async requestSubscriptionKeys(): Promise { - await sdk.call('e2e.requestSubscriptionKeys'); + await sdk.rest.post('/v1/e2e.requestSubscriptionKeys'); } async createRandomPassword(): Promise { diff --git a/apps/meteor/client/providers/SettingsProvider.tsx b/apps/meteor/client/providers/SettingsProvider.tsx index d90f167e6feaf..c79c044413849 100644 --- a/apps/meteor/client/providers/SettingsProvider.tsx +++ b/apps/meteor/client/providers/SettingsProvider.tsx @@ -1,7 +1,7 @@ import type { ISetting } from '@rocket.chat/core-typings'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; import type { SettingsContextQuery, SettingsContextValue } from '@rocket.chat/ui-contexts'; -import { SettingsContext, useAtLeastOnePermission, useMethod } from '@rocket.chat/ui-contexts'; +import { SettingsContext, useAtLeastOnePermission, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import type { ReactNode } from 'react'; import { useCallback, useMemo } from 'react'; @@ -96,7 +96,7 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { const queryClient = useQueryClient(); - const saveSettings = useMethod('saveSettings'); + const saveSettings = useEndpoint('POST', '/v1/settings.bulk'); const dispatch = useCallback( async (changes: Partial[]) => { // FIXME: This is a temporary solution to invalidate queries when settings change @@ -106,7 +106,7 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { } }); - await saveSettings(changes as Pick[]); + await saveSettings({ settings: changes as Pick[] }); }, [queryClient, saveSettings], ); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts index d2c4f1e065e5a..faa4c0c20f03d 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts @@ -1,6 +1,13 @@ import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, useMethod, useToastMessageDispatch, useUserId, useUserSubscription, useUserRoom } from '@rocket.chat/ui-contexts'; +import { + useTranslation, + useEndpoint, + useToastMessageDispatch, + useUserId, + useUserSubscription, + useUserRoom, +} from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { getRoomDirectives } from '../../../lib/getRoomDirectives'; @@ -21,11 +28,13 @@ export const useBlockUserAction = (user: Pick, rid: I const { roomCanBlock } = getRoomDirectives({ room, showingUserId: uid, userSubscription: currentSubscription }); const isUserBlocked = currentSubscription?.blocker; - const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); + const blockUser = useEndpoint('POST', '/v1/users.block'); + const unblockUser = useEndpoint('POST', '/v1/users.unblock'); + const toggleBlock = isUserBlocked ? unblockUser : blockUser; const toggleBlockUserAction = useStableCallback(async () => { try { - await toggleBlock({ rid, blocked: uid }); + await toggleBlock({ rid, userId: uid }); dispatchToastMessage({ type: 'success', message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), diff --git a/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts index bd0e03e0eb671..65aed4fda0af6 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -30,6 +30,7 @@ async function createCustomSound(fileName: string, filePath: string): Promise { }); }); + // TODO migrate these three cases to POST /v1/settings.bulk once the deprecated DDP method is removed. describe('[@saveSettings]', () => { it('should return an error when trying to save a "NaN" value', () => { void request From 7748b60b347ed0f022ca8bb42b7eed734a1b2df5 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 16:19:53 -0300 Subject: [PATCH 5/8] fix: address review on batch3 endpoints - Move bulk settings save from POST /v1/settings.bulk to POST /v1/settings to align with REST convention (sibling of the existing GET). - Require 'value' in each settings.bulk item schema so runtime validation matches the SettingsBulkProps contract. - Drop the 404 response declaration from custom-sounds.delete (invalid sound id is currently returned as a 400 by the shared error wrapper). - Replace POST /v1/users.block and POST /v1/users.unblock with a single POST /v1/im.blockUser toggle (body { roomId, block: boolean }) under the im.* namespace, since the BLOCK directive is DM-only. The other participant is derived from room.uids server-side. DDP methods (blockUser, unblockUser, saveSettings) keep their deprecation logs pointing at the renamed routes. Co-Authored-By: Claude Opus 4.7 --- .changeset/ddp-migrate-batch3-callers.md | 4 +- .changeset/rest-im-block-user.md | 6 +++ .changeset/rest-settings-bulk.md | 6 --- .changeset/rest-settings-post.md | 6 +++ .changeset/rest-users-block-unblock.md | 6 --- apps/meteor/app/api/server/v1/im.ts | 42 ++++++++++++++++++- apps/meteor/app/api/server/v1/settings.ts | 2 +- apps/meteor/app/api/server/v1/users.ts | 42 ------------------- .../app/lib/server/methods/blockUser.ts | 2 +- .../app/lib/server/methods/saveSettings.ts | 2 +- .../app/lib/server/methods/unblockUser.ts | 2 +- .../client/providers/SettingsProvider.tsx | 2 +- .../actions/useBlockUserAction.ts | 8 ++-- .../src/v1/dm/DmBlockUserProps.ts | 23 ++++++++++ packages/rest-typings/src/v1/dm/im.ts | 4 ++ packages/rest-typings/src/v1/dm/index.ts | 1 + packages/rest-typings/src/v1/settings.ts | 7 +--- packages/rest-typings/src/v1/users.ts | 12 ------ .../src/v1/users/UsersBlockParamsPOST.ts | 24 ----------- .../src/v1/users/UsersUnblockParamsPOST.ts | 24 ----------- 20 files changed, 93 insertions(+), 132 deletions(-) create mode 100644 .changeset/rest-im-block-user.md delete mode 100644 .changeset/rest-settings-bulk.md create mode 100644 .changeset/rest-settings-post.md delete mode 100644 .changeset/rest-users-block-unblock.md create mode 100644 packages/rest-typings/src/v1/dm/DmBlockUserProps.ts delete mode 100644 packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts delete mode 100644 packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts diff --git a/.changeset/ddp-migrate-batch3-callers.md b/.changeset/ddp-migrate-batch3-callers.md index 735d2bc76d9c9..4606c3a4adcc1 100644 --- a/.changeset/ddp-migrate-batch3-callers.md +++ b/.changeset/ddp-migrate-batch3-callers.md @@ -5,6 +5,6 @@ Migrate four client DDP callers to their REST equivalents (the DDP methods stay registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes them): - `deleteCustomSound` → `POST /v1/custom-sounds.delete` -- `blockUser` / `unblockUser` → `POST /v1/users.block` / `POST /v1/users.unblock` -- `saveSettings` → `POST /v1/settings.bulk` +- `blockUser` / `unblockUser` → `POST /v1/im.blockUser` (single toggle with `{ roomId, block: boolean }`) +- `saveSettings` → `POST /v1/settings` - `e2e.requestSubscriptionKeys` → `POST /v1/e2e.requestSubscriptionKeys` diff --git a/.changeset/rest-im-block-user.md b/.changeset/rest-im-block-user.md new file mode 100644 index 0000000000000..d4f840ef42da8 --- /dev/null +++ b/.changeset/rest-im-block-user.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/im.blockUser` (replaces the deprecated `blockUser` / `unblockUser` DDP methods). Body is `{ roomId, block: boolean }` — `block: true` blocks the other DM participant, `block: false` unblocks. Auth-gated and per-room via the `RoomMemberActions.BLOCK` directive (DM-only). Both legacy DDP methods remain registered until 9.0.0 with deprecation logs pointing at the new route. diff --git a/.changeset/rest-settings-bulk.md b/.changeset/rest-settings-bulk.md deleted file mode 100644 index c24214ef3c788..0000000000000 --- a/.changeset/rest-settings-bulk.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -Added `POST /v1/settings.bulk` for batched admin setting updates (replaces the deprecated `saveSettings` DDP method). Body is `{ settings: { _id, value }[] }`. The endpoint requires authentication, enforces 2FA (`twoFactorRequired: true`), and runs the same per-setting permission chain (`edit-privileged-setting` OR `manage-selected-settings` + per-id permission) and audit/notify side effects the DDP method already performed. The legacy DDP method remains registered until 9.0.0 with a deprecation log pointing at the new route. diff --git a/.changeset/rest-settings-post.md b/.changeset/rest-settings-post.md new file mode 100644 index 0000000000000..b1b7f3c4f8686 --- /dev/null +++ b/.changeset/rest-settings-post.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Added `POST /v1/settings` for batched admin setting updates (replaces the deprecated `saveSettings` DDP method). Body is `{ settings: { _id, value }[] }`. The endpoint requires authentication, enforces 2FA (`twoFactorRequired: true`), and runs the same per-setting permission chain (`edit-privileged-setting` OR `manage-selected-settings` + per-id permission) and audit/notify side effects the DDP method already performed. The legacy DDP method remains registered until 9.0.0 with a deprecation log pointing at the new route. diff --git a/.changeset/rest-users-block-unblock.md b/.changeset/rest-users-block-unblock.md deleted file mode 100644 index 40e9110557fed..0000000000000 --- a/.changeset/rest-users-block-unblock.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -Added `POST /v1/users.block` and `POST /v1/users.unblock` (replace the deprecated `blockUser` / `unblockUser` DDP methods). Both accept `{ rid, userId }`, are auth-gated, and reuse the per-room `RoomMemberActions.BLOCK` directive that the DDP method already enforced. The legacy DDP methods remain registered until 9.0.0 with a deprecation log pointing at the new routes. diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index 1cbcf3237e3ac..826f181d4419a 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -9,6 +9,7 @@ import { validateUnauthorizedErrorResponse, validateForbiddenErrorResponse, validateBadRequestErrorResponse, + isDmBlockUserProps, isDmFileProps, isDmMemberProps, isDmMessagesProps, @@ -26,7 +27,9 @@ import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; +import { blockUserMethod } from '../../../lib/server/functions/blockUser'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; +import { unblockUserMethod } from '../../../lib/server/functions/unblockUser'; import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { settings } from '../../../settings/server'; import { normalizeMessagesForUser } from '../../../utils/server/lib/normalizeMessagesForUser'; @@ -924,6 +927,42 @@ const dmCreateAction = (_path: Path): TypedAction({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, +} as const; + +const dmBlockUserAction = (_path: Path): TypedAction => + async function action() { + const { roomId, block } = this.bodyParams; + const { room } = await findDirectMessageRoom({ roomId }, this.userId); + + const blocked = room.uids?.find((uid) => uid !== this.userId); + if (!blocked) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'im.blockUser' }); + } + + if (block) { + await blockUserMethod(this.userId, { rid: room._id, blocked }); + } else { + await unblockUserMethod(this.userId, { rid: room._id, blocked }); + } + + return API.v1.success(); + }; + const dmEndpoints = API.v1 .post('im.delete', dmDeleteEndpointsProps, dmDeleteAction('im.delete')) .post('dm.delete', dmDeleteEndpointsProps, dmDeleteAction('dm.delete')) @@ -950,7 +989,8 @@ const dmEndpoints = API.v1 .get('dm.list', dmListEndpointsProps, dmListAction('dm.list')) .get('im.list', dmListEndpointsProps, dmListAction('im.list')) .get('dm.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('dm.list.everyone')) - .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')); + .get('im.list.everyone', dmListEveryoneEndpointsProps, dmListEveryoneAction('im.list.everyone')) + .post('im.blockUser', dmBlockUserEndpointsProps, dmBlockUserAction('im.blockUser')); export type DmEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 1f52ed4bd1623..6c764f4105bef 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -408,7 +408,7 @@ API.v1.post( ); API.v1.post( - 'settings.bulk', + 'settings', { authRequired: true, twoFactorRequired: true, diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 9deef02045128..fbc46b9579a67 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -23,8 +23,6 @@ import { isUsersRequestDataDownloadParamsGET, isUsersGetPresenceParamsGET, isUsersGetStatusParamsGET, - isUsersBlockParamsPOST, - isUsersUnblockParamsPOST, ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, @@ -55,7 +53,6 @@ import { executeSetUserActiveStatus } from '../../../../server/methods/setUserAc import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { blockUserMethod } from '../../../lib/server/functions/blockUser'; import { checkEmailAvailability } from '../../../lib/server/functions/checkEmailAvailability'; import { checkUsernameAvailability, @@ -73,7 +70,6 @@ import { canEditExtension } from '../../../lib/server/functions/saveUser/validat import { setStatusText } from '../../../lib/server/functions/setStatusText'; import { setUserAvatar } from '../../../lib/server/functions/setUserAvatar'; import { setUsernameWithValidation } from '../../../lib/server/functions/setUsername'; -import { unblockUserMethod } from '../../../lib/server/functions/unblockUser'; import { validateCustomFields } from '../../../lib/server/functions/validateCustomFields'; import { validateNameChars } from '../../../lib/server/functions/validateNameChars'; import { validateUsername } from '../../../lib/server/functions/validateUsername'; @@ -1809,44 +1805,6 @@ API.v1 return API.v1.success(); } await resetTOTP(this.userId, false); - return API.v1.success(); - }, - ) - .post( - 'users.block', - { - authRequired: true, - body: isUsersBlockParamsPOST, - response: { - 200: voidSuccessResponse, - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - }, - }, - async function action() { - const { rid, userId } = this.bodyParams; - - await blockUserMethod(this.userId, { rid, blocked: userId }); - - return API.v1.success(); - }, - ) - .post( - 'users.unblock', - { - authRequired: true, - body: isUsersUnblockParamsPOST, - response: { - 200: voidSuccessResponse, - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - }, - }, - async function action() { - const { rid, userId } = this.bodyParams; - - await unblockUserMethod(this.userId, { rid, blocked: userId }); - return API.v1.success(); }, ); diff --git a/apps/meteor/app/lib/server/methods/blockUser.ts b/apps/meteor/app/lib/server/methods/blockUser.ts index 6ece1af88ca16..cc873f62e8e24 100644 --- a/apps/meteor/app/lib/server/methods/blockUser.ts +++ b/apps/meteor/app/lib/server/methods/blockUser.ts @@ -14,7 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async blockUser({ rid, blocked }) { - methodDeprecationLogger.method('blockUser', '9.0.0', '/v1/users.block'); + methodDeprecationLogger.method('blockUser', '9.0.0', '/v1/im.blockUser'); check(rid, String); check(blocked, String); diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index 8c3208384a88d..c01f40481b106 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -25,7 +25,7 @@ Meteor.methods({ value: ISetting['value']; }[] = [], ) { - methodDeprecationLogger.method('saveSettings', '9.0.0', '/v1/settings.bulk'); + methodDeprecationLogger.method('saveSettings', '9.0.0', '/v1/settings'); const uid = Meteor.userId(); if (uid === null) { diff --git a/apps/meteor/app/lib/server/methods/unblockUser.ts b/apps/meteor/app/lib/server/methods/unblockUser.ts index 75d556950c193..ef940bf40b4eb 100644 --- a/apps/meteor/app/lib/server/methods/unblockUser.ts +++ b/apps/meteor/app/lib/server/methods/unblockUser.ts @@ -14,7 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async unblockUser({ rid, blocked }) { - methodDeprecationLogger.method('unblockUser', '9.0.0', '/v1/users.unblock'); + methodDeprecationLogger.method('unblockUser', '9.0.0', '/v1/im.blockUser'); check(rid, String); check(blocked, String); diff --git a/apps/meteor/client/providers/SettingsProvider.tsx b/apps/meteor/client/providers/SettingsProvider.tsx index c79c044413849..9412bd1989c2d 100644 --- a/apps/meteor/client/providers/SettingsProvider.tsx +++ b/apps/meteor/client/providers/SettingsProvider.tsx @@ -96,7 +96,7 @@ const SettingsProvider = ({ children }: SettingsProviderProps) => { const queryClient = useQueryClient(); - const saveSettings = useEndpoint('POST', '/v1/settings.bulk'); + const saveSettings = useEndpoint('POST', '/v1/settings'); const dispatch = useCallback( async (changes: Partial[]) => { // FIXME: This is a temporary solution to invalidate queries when settings change diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts index faa4c0c20f03d..97c431c76f7d7 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts @@ -27,14 +27,12 @@ export const useBlockUserAction = (user: Pick, rid: I const { roomCanBlock } = getRoomDirectives({ room, showingUserId: uid, userSubscription: currentSubscription }); - const isUserBlocked = currentSubscription?.blocker; - const blockUser = useEndpoint('POST', '/v1/users.block'); - const unblockUser = useEndpoint('POST', '/v1/users.unblock'); - const toggleBlock = isUserBlocked ? unblockUser : blockUser; + const isUserBlocked = Boolean(currentSubscription?.blocker); + const toggleBlockUser = useEndpoint('POST', '/v1/im.blockUser'); const toggleBlockUserAction = useStableCallback(async () => { try { - await toggleBlock({ rid, userId: uid }); + await toggleBlockUser({ roomId: rid, block: !isUserBlocked }); dispatchToastMessage({ type: 'success', message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), diff --git a/packages/rest-typings/src/v1/dm/DmBlockUserProps.ts b/packages/rest-typings/src/v1/dm/DmBlockUserProps.ts new file mode 100644 index 0000000000000..7b2ea135714d0 --- /dev/null +++ b/packages/rest-typings/src/v1/dm/DmBlockUserProps.ts @@ -0,0 +1,23 @@ +import { ajv } from '../Ajv'; + +export type DmBlockUserProps = { + roomId: string; + block: boolean; +}; + +const DmBlockUserPropsSchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + block: { + type: 'boolean', + }, + }, + required: ['roomId', 'block'], + additionalProperties: false, +}; + +export const isDmBlockUserProps = ajv.compile(DmBlockUserPropsSchema); diff --git a/packages/rest-typings/src/v1/dm/im.ts b/packages/rest-typings/src/v1/dm/im.ts index 82b9e5b909f3a..38574570426db 100644 --- a/packages/rest-typings/src/v1/dm/im.ts +++ b/packages/rest-typings/src/v1/dm/im.ts @@ -1,5 +1,6 @@ import type { IMessage, IRoom, IUser, IUploadWithUser, ISubscription } from '@rocket.chat/core-typings'; +import type { DmBlockUserProps } from './DmBlockUserProps'; import type { DmCreateProps } from './DmCreateProps'; import type { DmFileProps } from './DmFileProps'; import type { DmHistoryProps } from './DmHistoryProps'; @@ -76,4 +77,7 @@ export type ImEndpoints = { topic?: string; }; }; + '/v1/im.blockUser': { + POST: (params: DmBlockUserProps) => void; + }; }; diff --git a/packages/rest-typings/src/v1/dm/index.ts b/packages/rest-typings/src/v1/dm/index.ts index f68824c6e4212..2935d40218a3f 100644 --- a/packages/rest-typings/src/v1/dm/index.ts +++ b/packages/rest-typings/src/v1/dm/index.ts @@ -1,5 +1,6 @@ export type * from './dm'; export type * from './im'; +export * from './DmBlockUserProps'; export * from './DmCreateProps'; export * from './DmFileProps'; export * from './DmMembersProps'; diff --git a/packages/rest-typings/src/v1/settings.ts b/packages/rest-typings/src/v1/settings.ts index 7bbacc161505c..39faf84de2511 100644 --- a/packages/rest-typings/src/v1/settings.ts +++ b/packages/rest-typings/src/v1/settings.ts @@ -102,7 +102,7 @@ const SettingsBulkSchema = { _id: { type: 'string', minLength: 1 }, value: {}, }, - required: ['_id'], + required: ['_id', 'value'], additionalProperties: false, }, minItems: 1, @@ -135,6 +135,7 @@ export type SettingsEndpoints = { GET: (params: SettingsGetParams) => { settings: ISetting[]; }; + POST: (params: SettingsBulkProps) => void; }; '/v1/settings/:_id': { @@ -142,10 +143,6 @@ export type SettingsEndpoints = { POST: (params: SettingsUpdateProps) => void; }; - '/v1/settings.bulk': { - POST: (params: SettingsBulkProps) => void; - }; - '/v1/service.configurations': { GET: () => { configurations: Array; diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 42252735405b9..dda7d95c75330 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -8,7 +8,6 @@ import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; -import type { UsersBlockParamsPOST } from './users/UsersBlockParamsPOST'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; import type { UsersListParamsGET } from './users/UsersListParamsGET'; import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET'; @@ -16,7 +15,6 @@ import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; import type { UsersSendConfirmationEmailParamsPOST } from './users/UsersSendConfirmationEmailParamsPOST'; import type { UsersSendWelcomeEmailParamsPOST } from './users/UsersSendWelcomeEmailParamsPOST'; import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferenceParamsPOST'; -import type { UsersUnblockParamsPOST } from './users/UsersUnblockParamsPOST'; import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST'; import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST'; @@ -373,14 +371,6 @@ export type UsersEndpoints = { '/v1/users.deleteOwnAccount': { POST: (params: { password: string; confirmRelinquish?: boolean }) => void; }; - - '/v1/users.block': { - POST: (params: UsersBlockParamsPOST) => void; - }; - - '/v1/users.unblock': { - POST: (params: UsersUnblockParamsPOST) => void; - }; }; export * from './users/UserCreateParamsPOST'; @@ -394,5 +384,3 @@ export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; export * from './users/UsersAutocompleteParamsGET'; -export * from './users/UsersBlockParamsPOST'; -export * from './users/UsersUnblockParamsPOST'; diff --git a/packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts deleted file mode 100644 index 6b29bb9e83284..0000000000000 --- a/packages/rest-typings/src/v1/users/UsersBlockParamsPOST.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ajv } from '../Ajv'; - -export type UsersBlockParamsPOST = { - rid: string; - userId: string; -}; - -const UsersBlockParamsPostSchema = { - type: 'object', - properties: { - rid: { - type: 'string', - minLength: 1, - }, - userId: { - type: 'string', - minLength: 1, - }, - }, - required: ['rid', 'userId'], - additionalProperties: false, -}; - -export const isUsersBlockParamsPOST = ajv.compile(UsersBlockParamsPostSchema); diff --git a/packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts deleted file mode 100644 index 88def5aaa1db5..0000000000000 --- a/packages/rest-typings/src/v1/users/UsersUnblockParamsPOST.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ajv } from '../Ajv'; - -export type UsersUnblockParamsPOST = { - rid: string; - userId: string; -}; - -const UsersUnblockParamsPostSchema = { - type: 'object', - properties: { - rid: { - type: 'string', - minLength: 1, - }, - userId: { - type: 'string', - minLength: 1, - }, - }, - required: ['rid', 'userId'], - additionalProperties: false, -}; - -export const isUsersUnblockParamsPOST = ajv.compile(UsersUnblockParamsPostSchema); From c08edfbb661e34650f275ee16896a8e89f5ad549 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 16:20:03 -0300 Subject: [PATCH 6/8] test: add e2e tests for batch3 endpoints - /custom-sounds.delete: 401, 400 (missing/empty _id), 403 (no manage-sounds), happy path + GET returns 404, error for invalid id. - /settings (POST bulk): 401, 400 (missing/empty/invalid item), happy path verifying values land via subsequent GET, fail without edit-privileged-setting permission. - /im.blockUser: 401, 400 (missing roomId/block), happy path verifies subscription.blocker flag flips, fail on non-DM room. - /e2e.requestSubscriptionKeys: 401 + success path. Switch the custom-sounds test helper to call the new REST endpoint directly (drops the method.call/deleteCustomSound TODO) and drop the corresponding saveSettings TODO from methods.ts. Co-Authored-By: Claude Opus 4.7 --- .../tests/end-to-end/api/custom-sounds.ts | 64 ++++++++++++++- .../tests/end-to-end/api/direct-message.ts | 57 ++++++++++++++ apps/meteor/tests/end-to-end/api/methods.ts | 1 - apps/meteor/tests/end-to-end/api/settings.ts | 78 +++++++++++++++++++ apps/meteor/tests/end-to-end/api/users.ts | 16 ++++ 5 files changed, 213 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/custom-sounds.ts b/apps/meteor/tests/end-to-end/api/custom-sounds.ts index 65aed4fda0af6..8cfe7d23d1a1e 100644 --- a/apps/meteor/tests/end-to-end/api/custom-sounds.ts +++ b/apps/meteor/tests/end-to-end/api/custom-sounds.ts @@ -30,9 +30,15 @@ async function createCustomSound(fileName: string, filePath: string): Promise { + expect(res.body).to.have.property('success', true); + }); } describe('[CustomSounds]', () => { @@ -589,6 +595,60 @@ describe('[CustomSounds]', () => { }); }); + describe('[/custom-sounds.delete]', () => { + let toDeleteFileId: string; + + before(async () => { + toDeleteFileId = await createCustomSound(`${fileName}-delete-${randomUUID()}`, mockWavAudioPath); + }); + + it('should return unauthorized if not authenticated', async () => { + await request.post(api('custom-sounds.delete')).send({ _id: toDeleteFileId }).expect(401); + }); + + it('should return bad request when _id is missing', async () => { + await request.post(api('custom-sounds.delete')).set(credentials).send({}).expect(400); + }); + + it('should return bad request when _id is an empty string', async () => { + await request.post(api('custom-sounds.delete')).set(credentials).send({ _id: '' }).expect(400); + }); + + it('should fail when the user does not have manage-sounds permission', async () => { + const testUser = await createUser(); + const testUserCredentials = await login(testUser.username, password); + try { + await request.post(api('custom-sounds.delete')).set(testUserCredentials).send({ _id: toDeleteFileId }).expect(403); + } finally { + await deleteUser(testUser); + } + }); + + it('should delete the sound successfully and remove it from the list', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: toDeleteFileId }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request.get(api('custom-sounds.getOne')).set(credentials).query({ _id: toDeleteFileId }).expect(404); + }); + + it('should fail when the sound id does not exist', async () => { + await request + .post(api('custom-sounds.delete')) + .set(credentials) + .send({ _id: 'non-existent-sound-id' }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + describe('[/custom-sounds.getOne]', () => { it('should return unauthorized if not authenticated', async () => { await request.get(api('custom-sounds.getOne')).query({ _id: fileId }).expect(401); diff --git a/apps/meteor/tests/end-to-end/api/direct-message.ts b/apps/meteor/tests/end-to-end/api/direct-message.ts index a5774325b20a2..da32487c7b687 100644 --- a/apps/meteor/tests/end-to-end/api/direct-message.ts +++ b/apps/meteor/tests/end-to-end/api/direct-message.ts @@ -58,6 +58,63 @@ describe('[Direct Messages]', () => { after(() => deleteUser(user)); + describe('/im.blockUser', () => { + const fetchOwnSubscription = async (roomId: IRoom['_id']) => { + const res = await request.get(api('subscriptions.getOne')).set(credentials).query({ roomId }).expect(200); + return res.body.subscription; + }; + + it('should return unauthorized when not authenticated', async () => { + await request.post(api('im.blockUser')).send({ roomId: directMessage._id, block: true }).expect(401); + }); + + it('should return bad request when roomId is missing', async () => { + await request.post(api('im.blockUser')).set(credentials).send({ block: true }).expect(400); + }); + + it('should return bad request when block flag is missing', async () => { + await request.post(api('im.blockUser')).set(credentials).send({ roomId: directMessage._id }).expect(400); + }); + + it('should block the other DM participant and flip the subscription blocker flag', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: directMessage._id, block: true }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await fetchOwnSubscription(directMessage._id); + expect(subscription).to.have.property('blocker', true); + }); + + it('should unblock the other DM participant and clear the subscription blocker flag', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: directMessage._id, block: false }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const subscription = await fetchOwnSubscription(directMessage._id); + expect(Boolean(subscription.blocker)).to.equal(false); + }); + + it('should fail when called on a non-DM room', async () => { + await request + .post(api('im.blockUser')) + .set(credentials) + .send({ roomId: 'GENERAL', block: true }) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + }); + describe('/im.setTopic', () => { it('should set the topic of the DM with a string', (done) => { void request diff --git a/apps/meteor/tests/end-to-end/api/methods.ts b/apps/meteor/tests/end-to-end/api/methods.ts index 999971333c0c7..9f4b166666608 100644 --- a/apps/meteor/tests/end-to-end/api/methods.ts +++ b/apps/meteor/tests/end-to-end/api/methods.ts @@ -3251,7 +3251,6 @@ describe('Meteor.methods', () => { }); }); - // TODO migrate these three cases to POST /v1/settings.bulk once the deprecated DDP method is removed. describe('[@saveSettings]', () => { it('should return an error when trying to save a "NaN" value', () => { void request diff --git a/apps/meteor/tests/end-to-end/api/settings.ts b/apps/meteor/tests/end-to-end/api/settings.ts index a8905e0533dbb..d49e0d77c3ad4 100644 --- a/apps/meteor/tests/end-to-end/api/settings.ts +++ b/apps/meteor/tests/end-to-end/api/settings.ts @@ -115,6 +115,84 @@ describe('[Settings]', () => { expect(res.body.settings[0]).to.have.property('packageValue'); }); }); + + describe('POST (bulk)', () => { + before(async () => { + await updatePermission('edit-privileged-setting', ['admin']); + }); + + after(async () => { + await updatePermission('edit-privileged-setting', ['admin']); + }); + + it('should return unauthorized when not authenticated', async () => { + await request + .post(api('settings')) + .send({ settings: [{ _id: 'LDAP_Enable', value: false }] }) + .expect(401); + }); + + it('should return bad request when settings is missing', async () => { + await request.post(api('settings')).set(credentials).send({}).expect(400); + }); + + it('should return bad request when settings is empty', async () => { + await request.post(api('settings')).set(credentials).send({ settings: [] }).expect(400); + }); + + it('should return bad request when an item is missing value', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'LDAP_Enable' }] }) + .expect(400); + }); + + it('should successfully update multiple settings in a single request', async () => { + await request + .post(api('settings')) + .set(credentials) + .send({ + settings: [ + { _id: 'LDAP_Enable', value: false }, + { _id: 'Accounts_Default_User_Preferences_masterVolume', value: 50 }, + ], + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('settings/LDAP_Enable')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', false); + }); + + await request + .get(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('value', 50); + }); + }); + + it('should fail when the user does not have edit-privileged-setting permission', async () => { + await updatePermission('edit-privileged-setting', []); + await request + .post(api('settings')) + .set(credentials) + .send({ settings: [{ _id: 'LDAP_Enable', value: false }] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.match(/error-action-not-allowed/); + }); + }); + }); }); describe('[/settings/:_id]', () => { diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 62550d952de6b..453107581e13f 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -247,6 +247,22 @@ describe('[Users]', () => { }); }); + describe('[/e2e.requestSubscriptionKeys]', () => { + it('should return unauthorized when not authenticated', async () => { + await request.post(api('e2e.requestSubscriptionKeys')).expect(401); + }); + + it('should accept the request and return success for an authenticated user', async () => { + await request + .post(api('e2e.requestSubscriptionKeys')) + .set(userCredentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); + describe('[/users.create]', () => { before(async () => clearCustomFields()); after(async () => clearCustomFields()); From f4d49d89f2be692cca9363f2eb714350c6bcc635 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 16:34:45 -0300 Subject: [PATCH 7/8] fix: declare /v1/e2e.requestSubscriptionKeys in rest-typings The route was registered via apps/meteor's declare-module augmentation of Endpoints, which sdk.rest.post's type doesn't pick up because @rocket.chat/api-client compiles against rest-typings only. Move the declaration into packages/rest-typings/src/v1/e2e.ts so sdk.rest.post sees it. Co-Authored-By: Claude Opus 4.7 --- packages/rest-typings/src/v1/e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rest-typings/src/v1/e2e.ts b/packages/rest-typings/src/v1/e2e.ts index 5ab6ac9024501..f54988313c7b8 100644 --- a/packages/rest-typings/src/v1/e2e.ts +++ b/packages/rest-typings/src/v1/e2e.ts @@ -26,4 +26,8 @@ export type E2eEndpoints = { '/v1/e2e.setUserPublicAndPrivateKeys': { POST: (params: E2eSetUserPublicAndPrivateKeysProps) => void; }; + + '/v1/e2e.requestSubscriptionKeys': { + POST: () => void; + }; }; From 8ec18f6b6ecbe56d9b1e8ef9394ec5bb5a0ca263 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 17:23:08 -0300 Subject: [PATCH 8/8] fix(test): migrate e2e saveSettings helper to POST /v1/settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper was driving the DDP saveSettings method through /api/v1/method.call/saveSettings. With the deprecation log now attached, TEST_MODE=true makes the method throw — so SAML beforeAll's resetTestData silently fails to apply the SAML configuration and the Login button never renders, failing the SAML e2e suite. Switch the helper to the new POST /v1/settings bulk endpoint introduced in this PR. Co-Authored-By: Claude Opus 4.7 --- apps/meteor/tests/e2e/utils/saveSettings.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/meteor/tests/e2e/utils/saveSettings.ts b/apps/meteor/tests/e2e/utils/saveSettings.ts index db44ea6ff8915..e78f826e86936 100644 --- a/apps/meteor/tests/e2e/utils/saveSettings.ts +++ b/apps/meteor/tests/e2e/utils/saveSettings.ts @@ -9,12 +9,4 @@ export const saveSettings = ( _id: ISetting['_id']; value: ISetting['value']; }[], -): Promise => - api.post('/method.call/saveSettings', { - message: JSON.stringify({ - msg: 'method', - id: '1', - method: 'saveSettings', - params: [changes], - }), - }); +): Promise => api.post('/settings', { settings: changes });