From 690fc8f58187b98683dca31cbe874182c547204c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 16 Nov 2025 22:46:41 -0300 Subject: [PATCH 01/72] refactor: remove auto-join invite capability --- .../src/api/_matrix/invite.ts | 154 +----------------- 1 file changed, 3 insertions(+), 151 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index 9f4f1c918a784..cf1c6899a60dc 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -1,17 +1,15 @@ -import { Authorization, Room } from '@rocket.chat/core-services'; +import { Authorization } from '@rocket.chat/core-services'; import { isUserNativeFederated, type IUser } from '@rocket.chat/core-typings'; -import type { PduMembershipEventContent, PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; +import type { PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; import { eventIdSchema, roomIdSchema, NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; -import { createOrUpdateFederatedUser, getUsernameServername } from '../../FederationMatrix'; +import { getUsernameServername } from '../../FederationMatrix'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; -const logger = new Logger('federation-matrix:invite'); - const EventBaseSchema = { type: 'object', properties: { @@ -134,142 +132,6 @@ const ProcessInviteResponseSchema = { const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); -// 5 seconds -// 25 seconds -// 625 seconds = 10 minutes 25 seconds // max -async function runWithBackoff(fn: () => Promise, delaySec = 5) { - try { - await fn(); - } catch (e) { - // don't retry on authorization/validation errors - they won't succeed on retry - if (e instanceof NotAllowedError) { - logger.error(e, 'Authorization error, not retrying'); - return; - } - - const delay = Math.min(625, delaySec ** 2); - logger.error(e, `error occurred, retrying in ${delay}s`); - setTimeout(() => { - runWithBackoff(fn, delay); - }, delay * 1000); - } -} - -async function joinRoom({ - inviteEvent, - user, // ours trying to join the room -}: { - inviteEvent: PersistentEventBase; - user: IUser; -}) { - // from the response we get the event - if (!inviteEvent.stateKey) { - throw new Error('join event has missing state key, unable to determine user to join'); - } - - // backoff needed for this call, can fail - await federationSDK.joinUser(inviteEvent, inviteEvent.event.state_key); - - // now we create the room we saved post joining - const matrixRoom = await federationSDK.getLatestRoomState2(inviteEvent.roomId); - if (!matrixRoom) { - throw new Error('room not found not processing invite'); - } - - // we only understand these two types of rooms, plus direct messages - const isDM = inviteEvent.getContent().is_direct; - - if (!isDM && !matrixRoom.isPublic() && !matrixRoom.isInviteOnly()) { - throw new Error('room is neither direct message - rocketchat is unable to join for now'); - } - - // need both the sender and the participating user to exist in the room - // TODO implement on model - const senderUser = await Users.findOneByUsername(inviteEvent.sender, { projection: { _id: 1 } }); - - const senderUserId = - senderUser?._id || - (await createOrUpdateFederatedUser({ - username: inviteEvent.sender, - origin: matrixRoom.origin, - })); - - if (!senderUserId) { - throw new Error('Sender user ID not found'); - } - - let internalRoomId: string; - - const internalMappedRoom = await Rooms.findOne({ 'federation.mrid': inviteEvent.roomId }); - - if (!internalMappedRoom) { - let roomType: 'c' | 'p' | 'd'; - - if (isDM) { - roomType = 'd'; - } else if (matrixRoom.isPublic()) { - roomType = 'c'; - } else if (matrixRoom.isInviteOnly()) { - roomType = 'p'; - } else { - throw new Error('room is neither public, private, nor direct message - rocketchat is unable to join for now'); - } - - let ourRoom: { _id: string }; - - if (isDM) { - const senderUser = await Users.findOneById(senderUserId, { projection: { _id: 1, username: 1 } }); - const inviteeUser = user; - - if (!senderUser?.username) { - throw new Error('Sender user not found'); - } - if (!inviteeUser?.username) { - throw new Error('Invitee user not found'); - } - - ourRoom = await Room.create(senderUserId, { - type: roomType, - name: inviteEvent.sender, - members: [senderUser.username, inviteeUser.username], - options: { - federatedRoomId: inviteEvent.roomId, - creator: senderUserId, - }, - extraData: { - federated: true, - }, - }); - } else { - const roomFname = `${matrixRoom.name}:${matrixRoom.origin}`; - const roomName = inviteEvent.roomId.replace('!', '').replace(':', '_'); - - ourRoom = await Room.create(senderUserId, { - type: roomType, - name: roomName, - options: { - federatedRoomId: inviteEvent.roomId, - creator: senderUserId, - }, - extraData: { - federated: true, - fname: roomFname, - }, - }); - } - - internalRoomId = ourRoom._id; - } else { - internalRoomId = internalMappedRoom._id; - } - - await Room.addUserToRoom(internalRoomId, { _id: user._id }, { _id: senderUserId, username: inviteEvent.sender }); -} - -async function startJoiningRoom(...opts: Parameters) { - void runWithBackoff(() => joinRoom(...opts)); -} - // This is a special case where inside rocket chat we invite users inside rockechat, so if the sender or the invitee are external iw should throw an error export const acceptInvite = async (inviteEvent: PersistentEventBase, username: string) => { if (!inviteEvent.stateKey) { @@ -377,16 +239,6 @@ export const getMatrixInviteRoutes = () => { strippedStateEvents, ); - setTimeout( - () => { - void startJoiningRoom({ - inviteEvent, - user: ourUser, - }); - }, - inviteEvent.event.content.is_direct ? 2000 : 0, - ); - return { body: { event: inviteEvent.event, From 5ce51ebd84fbdb56609841fb09921df6b1c6d9d5 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 16 Nov 2025 22:49:16 -0300 Subject: [PATCH 02/72] feat: add invited and federation fields to subscription model --- apps/meteor/app/lib/server/functions/addUserToRoom.ts | 9 +++++++++ apps/meteor/lib/publishFields.ts | 2 ++ apps/meteor/server/modules/watchers/watchers.module.ts | 2 ++ packages/core-services/src/types/IRoomService.ts | 5 +++++ packages/core-typings/src/ISubscription.ts | 6 ++++++ 5 files changed, 24 insertions(+) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 881afb11812c7..8c93571a58c48 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -27,10 +27,17 @@ export const addUserToRoom = async ( skipSystemMessage, skipAlertSound, createAsHidden = false, + invited, + federation, }: { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; + invited?: boolean; + federation?: { + inviteEventId?: string; + inviterUsername?: string; + }; } = {}, ): Promise => { const now = new Date(); @@ -99,6 +106,8 @@ export const addUserToRoom = async ( unread: 1, userMentions: 1, groupMentions: 0, + ...(invited && { invited: true }), + ...(federation && { federation }), ...autoTranslateConfig, ...getDefaultSubscriptionPref(userToBeAdded as IUser), }); diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 1caa84d6358a3..1703fee15feea 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -42,6 +42,8 @@ export const subscriptionFields = { tunread: 1, tunreadGroup: 1, tunreadUser: 1, + invited: 1, + federation: 1, // Omnichannel fields department: 1, diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 67ece854efa3b..db2ea4e2e9244 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -137,6 +137,8 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'tunread' | 'tunreadGroup' | 'tunreadUser' + | 'invited' + | 'federation' // Omnichannel fields | 'department' diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 58d1e09d081ab..1115acfbbe2e7 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -39,6 +39,11 @@ export interface IRoomService { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; + invited?: boolean; + federation?: { + inviteEventId?: string; + inviterUsername?: string; + }; }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 742f63dac9c39..62defea00040c 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -72,6 +72,12 @@ export interface ISubscription extends IRocketChatRecord { customFields?: Record; oldRoomKeys?: OldKey[]; suggestedOldRoomKeys?: OldKey[]; + + invited?: true; + federation?: { + inviteEventId?: string; + inviterUsername?: string; + }; } export interface IOmnichannelSubscription extends ISubscription { From cad34e4c18767eed063a13b8b31cc468f4ae6826 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 00:51:07 -0300 Subject: [PATCH 03/72] feat: expose invited on user listing for channel member list --- apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts index 6d37513db3694..1a59708783487 100644 --- a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -102,13 +102,14 @@ export async function findUsersOfRoomOrderedByRole({ }, }, }, - { $project: { roles: 1 } }, + { $project: { roles: 1, invited: 1 } }, ], }, }, { $addFields: { roles: { $arrayElemAt: ['$subscription.roles', 0] }, + invited: { $arrayElemAt: ['$subscription.invited', 0] }, }, }, { From b5c0b10ba2d17930248b59c412dbe740b464908a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 01:16:22 -0300 Subject: [PATCH 04/72] refactor: remove beforeAddUserToRoom hook --- .../ee/server/hooks/federation/index.ts | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index da15ffc50ad01..7a5cc7637df72 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,11 +1,11 @@ -import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; +import { FederationMatrix } from '@rocket.chat/core-services'; import { isEditedMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { afterRemoveFromRoomCallback } from '../../../../lib/callbacks/afterRemoveFromRoomCallback'; -import { beforeAddUsersToRoom, beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; +import { beforeAddUsersToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; import { beforeChangeRoomRole } from '../../../../lib/callbacks/beforeChangeRoomRole'; import { FederationActions } from '../../../../server/services/room/hooks/BeforeFederationActions'; @@ -76,23 +76,6 @@ beforeAddUsersToRoom.add(async ({ usernames }, room) => { } }); -beforeAddUserToRoom.add( - async ({ user, inviter }, room) => { - if (!user.username || !inviter) { - return; - } - - if (FederationActions.shouldPerformFederationAction(room)) { - if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { - throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); - } - await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); - } - }, - callbacks.priority.MEDIUM, - 'native-federation-on-before-add-users-to-room', -); - callbacks.add( 'afterSetReaction', async (message: IMessage, params): Promise => { From 23308f89417aa7ff15f94d4f25c09c4d31138590 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 00:53:24 -0300 Subject: [PATCH 05/72] fix: align homeserver event signatures with tidying (event + event_id) --- ee/packages/federation-matrix/src/events/room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/events/room.ts b/ee/packages/federation-matrix/src/events/room.ts index 45668831e52f8..7eb0a0ffdc46f 100644 --- a/ee/packages/federation-matrix/src/events/room.ts +++ b/ee/packages/federation-matrix/src/events/room.ts @@ -10,7 +10,7 @@ export function room(emitter: Emitter) { const { room_id: roomId, content: { name }, - sender: userId, + state_key: userId, } = event; const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } }); From 38895072fcf11018091e7facb36f37bc4ec67c00 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 00:56:46 -0300 Subject: [PATCH 06/72] refactor: add findOneFederatedByMrid and replace direct model calls --- packages/model-typings/src/models/IRoomsModel.ts | 2 ++ packages/models/src/models/Rooms.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 5da85091ea7b0..5408cdbf94031 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -165,6 +165,8 @@ export interface IRoomsModel extends IBaseModel { findFederatedRooms(options?: FindOptions): FindCursor; + findOneFederatedByMrid(mrid: string, options?: FindOptions): Promise; + findCountOfRoomsWithActiveCalls(): Promise; findBiggestFederatedRoomInNumberOfUsers(options?: FindOptions): Promise; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 59f5151646bab..949014bc360f0 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -866,6 +866,15 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find(query, options); } + findOneFederatedByMrid(mrid: string, options: FindOptions = {}): Promise { + const query: Filter = { + 'federated': true, + 'federation.mrid': mrid, + }; + + return this.findOne(query, options); + } + findCountOfRoomsWithActiveCalls(): Promise { const query: Filter = { // No matter the actual "status" of the call, if the room has a callStatus, it means there is/was a call From 32b014772bae6b14dd461baad384dbad2b34fc0b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 22:18:20 -0300 Subject: [PATCH 07/72] fix: ensure canAccessRoom denies access for invited users --- packages/models/src/models/Subscriptions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index acc83474e1b33..d9103f293a7ee 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -161,6 +161,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri const query = { rid, 'u._id': uid, + 'invited': { $exists: false }, }; return this.countDocuments(query); From c0c04d778c4f5138432bb507a2222f136b7619cc Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 13:16:58 -0300 Subject: [PATCH 08/72] chore: improve addUsersToRoom readability --- .../app/lib/server/methods/addUsersToRoom.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index c5fd718d6911f..c48947a78371a 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,4 +1,4 @@ -import { api } from '@rocket.chat/core-services'; +import { api, FederationMatrix } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; @@ -107,19 +107,33 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - await addUserToRoom(data.rid, newUser, user); - } else { - if (!newUser.username) { - return; + let inviteOptions: { invited?: boolean; federation?: { inviteEventId?: string; inviterUsername?: string } } = {}; + + if (isRoomNativeFederated(room) && user && newUser.username) { + const inviteResult = await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); + inviteOptions = { + invited: true, + federation: { + inviteEventId: inviteResult.eventId, + inviterUsername: user.username, + }, + }; } - void api.broadcast('notify.ephemeralMessage', userId, data.rid, { - msg: i18n.t('Username_is_already_in_here', { - postProcess: 'sprintf', - sprintf: [newUser.username], - lng: user?.language, - }), - }); + + return addUserToRoom(data.rid, newUser, user, inviteOptions); + } + + if (!newUser.username) { + return; } + + void api.broadcast('notify.ephemeralMessage', userId, data.rid, { + msg: i18n.t('Username_is_already_in_here', { + postProcess: 'sprintf', + sprintf: [newUser.username], + lng: user?.language, + }), + }); }), ); From 24b76f8ddaa3ac17a90a2b6019534c4a040434f5 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 13:55:10 -0300 Subject: [PATCH 09/72] refactor: clarify inviteUsersToRoom and eliminate redundant loop checks --- .../app/lib/server/methods/addUsersToRoom.ts | 2 +- .../federation-matrix/src/FederationMatrix.ts | 41 +++++++++++++------ .../src/types/IFederationMatrixService.ts | 10 +++-- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index c48947a78371a..e5c5f9c86f2f4 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -114,7 +114,7 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; inviteOptions = { invited: true, federation: { - inviteEventId: inviteResult.eventId, + inviteEventId: inviteResult[0].event_id, inviterUsername: user.username, }, }; diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 7a60af564e0b9..55689691a522d 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -9,7 +9,15 @@ import { } from '@rocket.chat/core-typings'; import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK } from '@rocket.chat/federation-sdk'; -import type { EventID, UserID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk'; +import type { + EventID, + UserID, + FileMessageType, + PresenceState, + PersistentEventBase, + RoomVersion, + RoomID, +} from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; import emojione from 'emojione'; @@ -541,13 +549,27 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - async inviteUsersToRoom(room: IRoomNativeFederated, matrixUsersUsername: string[], inviter: IUser): Promise { + async inviteUsersToRoom( + room: IRoomNativeFederated, + matrixUsersUsername: string[], + inviter: IUser, + ): Promise<{ event_id: EventID; event: PersistentEventBase; room_id: RoomID }[]> { try { const inviterUserId = `@${inviter.username}:${this.serverName}`; + const isInviterNativeFederated = isUserNativeFederated(inviter); - await Promise.all( - matrixUsersUsername.map(async (username) => { - if (validateFederatedUsername(username)) { + // if inviter is an external user it means we receive the invite from the endpoint + // since we accept from there we can skip accepting here - only process external users + const usersToInvite = isInviterNativeFederated ? matrixUsersUsername.filter(validateFederatedUsername) : matrixUsersUsername; + + if (usersToInvite.length === 0) { + return []; + } + + return Promise.all( + usersToInvite.map(async (username) => { + const isExternalUser = validateFederatedUsername(username); + if (isExternalUser) { return federationSDK.inviteUserToRoom( userIdSchema.parse(username), roomIdSchema.parse(room.federation.mrid), @@ -555,14 +577,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ); } - // if inviter is an external user it means we receive the invite from the endpoint - // since we accept from there we can skip accepting here - if (isUserNativeFederated(inviter)) { - this.logger.debug('Inviter is native federated, skip accept invite'); - return; - } - - const result = await federationSDK.inviteUserToRoom( + return federationSDK.inviteUserToRoom( userIdSchema.parse(`@${username}:${this.serverName}`), roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(inviterUserId), diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 1d036069a8d86..b37802b3827d5 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,5 +1,5 @@ -import type { IMessage, IRoomFederated, IRoomNativeFederated, IUser } from '@rocket.chat/core-typings'; -import type { EventStore } from '@rocket.chat/federation-sdk'; +import type { IMessage, IRoomFederated, IRoomNativeFederated, ISubscription, IUser, RoomID } from '@rocket.chat/core-typings'; +import type { EventID, EventStore, PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; export interface IFederationMatrixService { createRoom(room: IRoomFederated, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }>; @@ -25,7 +25,11 @@ export interface IFederationMatrixService { userId: string, role: 'moderator' | 'owner' | 'leader' | 'user', ): Promise; - inviteUsersToRoom(room: IRoomFederated, usersUserName: string[], inviter: IUser): Promise; + inviteUsersToRoom( + room: IRoomFederated, + usersUserName: string[], + inviter: IUser, + ): Promise<{ event_id: EventID; event: PersistentEventBase; room_id: RoomID }[]>; notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise; verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; } From d037ff9e24163138834ea991edc1bec0d83a6370 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 21:33:54 -0300 Subject: [PATCH 10/72] =?UTF-8?q?feat:=20add=20=E2=80=98user=20invited?= =?UTF-8?q?=E2=80=99=20and=20=E2=80=98invite=20rejected=E2=80=99=20system?= =?UTF-8?q?=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/lib/server/functions/addUserToRoom.ts | 4 ++ .../dataExport/exportRoomMessagesToFile.ts | 8 ++++ .../federation-matrix/src/FederationMatrix.ts | 39 ++++++++++++++++++- .../src/definition/messages/MessageType.ts | 4 ++ .../core-typings/src/IMessage/IMessage.ts | 2 + packages/i18n/src/locales/en.i18n.json | 4 ++ .../message-types/src/registrations/common.ts | 12 ++++++ 7 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 8c93571a58c48..2125f7b42479b 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -131,6 +131,10 @@ export const addUserToRoom = async ( }; if (room.teamMain) { await Message.saveSystemMessage('added-user-to-team', rid, userToBeAdded.username, userToBeAdded, extraData); + } else if (invited) { + await Message.saveSystemMessage('ui', rid, userToBeAdded.username, userToBeAdded, { + u: { _id: inviter._id, username: inviter.username }, + }); } else { await Message.saveSystemMessage('au', rid, userToBeAdded.username, userToBeAdded, extraData); } diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index 5b46c07d2e179..c4791b7e80aca 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -79,6 +79,14 @@ export const getMessageData = ( case 'ul': messageObject.msg = i18n.t('User_left_this_channel'); break; + case 'ui': + messageObject.msg = i18n.t('User_invited_to_room', { + user_invited: hideUserName(msg.msg, userData, usersMap), + }); + break; + case 'uir': + messageObject.msg = i18n.t('User_rejected_invitation_to_room'); + break; case 'ult': messageObject.msg = i18n.t('User_left_this_team'); break; diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 55689691a522d..e6306955edf94 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1,4 +1,4 @@ -import { type IFederationMatrixService, ServiceClass } from '@rocket.chat/core-services'; +import { type IFederationMatrixService, Message, ServiceClass } from '@rocket.chat/core-services'; import { isDeletedMessage, isMessageFromMatrixFederation, @@ -914,4 +914,41 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return results; } + + async handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { + const subscription = await Subscriptions.findOne( + { + '_id': subscriptionId, + 'u._id': userId, + 'invited': true, + }, + { projection: { _id: 1, rid: 1, federation: 1 } }, + ); + if (!subscription) { + throw new Error('Subscription not found'); + } + if (!subscription.federation?.inviteEventId) { + throw new Error('Invite event ID not found'); + } + + const user = await Users.findOneById(userId); + if (!user) { + throw new Error('User not found'); + } + + // TODO: should use common function to get matrix user ID + const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + if (!user.username) { + throw new Error('User username not found'); + } + + if (action === 'accept') { + await federationSDK.acceptInvite(subscription.federation?.inviteEventId, matrixUserId); + await Message.saveSystemMessage('uj', subscription.rid, user.username, user, { u: { _id: user._id, username: user.username } }); + } + if (action === 'reject') { + await federationSDK.rejectInvite(subscription.federation?.inviteEventId, matrixUserId); + await Message.saveSystemMessage('uir', subscription.rid, user.username, user, { u: { _id: user._id, username: user.username } }); + } + } } diff --git a/packages/apps-engine/src/definition/messages/MessageType.ts b/packages/apps-engine/src/definition/messages/MessageType.ts index 1894a92438f24..4b2734c1d3657 100644 --- a/packages/apps-engine/src/definition/messages/MessageType.ts +++ b/packages/apps-engine/src/definition/messages/MessageType.ts @@ -75,6 +75,10 @@ export type MessageType = | 'ru' /** Sent when a user was added */ | 'au' + /** Sent when a user was invited to a room */ + | 'ui' + /** Sent when a user was invited to a room and rejected */ + | 'uir' /** Sent when system messages were muted */ | 'mute_unmute' /** Sent when a room name was changed */ diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index d0442adc148d4..381ab53b03d4d 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -68,6 +68,8 @@ export type OtrSystemMessages = (typeof OtrSystemMessagesValues)[number]; const MessageTypes = [ 'e2e', 'uj', + 'ui', + 'uir', 'ul', 'ru', 'au', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9c98444aeba80..c010eb8b4f2bb 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3368,6 +3368,8 @@ "Message_GroupingPeriodDescription": "Messages will be grouped with previous message if both are from the same user and the elapsed time was less than the informed time in seconds.", "Message_HideType_added_user_to_team": "User added to team", "Message_HideType_au": "User added", + "Message_HideType_ui": "User invited to room", + "Message_HideType_uir": "User rejected invitation to room", "Message_HideType_changed_announcement": "Room announcement changed", "Message_HideType_changed_description": "Room description changed", "Message_HideType_livechat_closed": "Hide \"Conversation finished\" messages", @@ -5565,6 +5567,8 @@ "User_left": "Has left the channel.", "User_left_team": "left this Team", "User_left_this_channel": "left the channel", + "User_invited_to_room": "invited {{user_invited}} to the room", + "User_rejected_invitation_to_room": "rejected invitation to room", "User_left_this_team": "left this team", "User_logged_out": "User is logged out", "User_management": "User Management", diff --git a/packages/message-types/src/registrations/common.ts b/packages/message-types/src/registrations/common.ts index 684ea00633096..88566517c818a 100644 --- a/packages/message-types/src/registrations/common.ts +++ b/packages/message-types/src/registrations/common.ts @@ -25,6 +25,18 @@ export default (instance: MessageTypes) => { text: (t, message) => t('User_added_to', { user_added: message.msg }), }); + instance.registerType({ + id: 'ui', + system: true, + text: (t, message) => t('User_invited_to_room', { user_invited: message.msg }), + }); + + instance.registerType({ + id: 'uir', + system: true, + text: (t) => t('User_rejected_invitation_to_room'), + }); + instance.registerType({ id: 'added-user-to-team', system: true, From af6847296d302ce43258fd0a97d5716e8a04f81d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 22:24:14 -0300 Subject: [PATCH 11/72] fix: remove mandatory origin field from invite route to match protocol --- ee/packages/federation-matrix/src/api/_matrix/invite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index cf1c6899a60dc..1dcace7345963 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -65,7 +65,7 @@ const EventBaseSchema = { nullable: true, }, }, - required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events', 'origin'], + required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events'], }; const MembershipEventContentSchema = { From 11ca46a7876e788c0610940042d9a934821754ce Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 22:24:46 -0300 Subject: [PATCH 12/72] chore: apply eslint fixes and CodeRabbit minor adjustments --- .../federation-matrix/src/FederationMatrix.ts | 4 + .../federation-matrix/src/events/member.ts | 147 ++++++++++++------ 2 files changed, 103 insertions(+), 48 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index e6306955edf94..dbdd15570683c 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -936,6 +936,10 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error('User not found'); } + if (!user.username) { + throw new Error('User username not found'); + } + // TODO: should use common function to get matrix user ID const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; if (!user.username) { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 790f2dbb723ab..e5f947b431002 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -8,81 +8,132 @@ import { createOrUpdateFederatedUser, getUsernameServername } from '../Federatio const logger = new Logger('federation-matrix:member'); -async function membershipLeaveAction(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']) { - const room = await Rooms.findOne({ 'federation.mrid': event.room_id }, { projection: { _id: 1 } }); - if (!room) { - logger.warn(`No bridged room found for Matrix room_id: ${event.room_id}`); +export async function handleInvite( + event: HomeserverEventSignatures['homeserver.matrix.membership']['event'], + eventId: EventID, +): Promise { + const { room_id: roomId, sender: senderId, state_key: userId, content } = event; + + const inviterUser = await getOrCreateFederatedUser(senderId as UserID); + if (!inviterUser) { + logger.error(`Failed to get or create inviter user: ${senderId}`); return; } - const serverName = federationSDK.getConfig('serverName'); - - const [affectedUsername] = getUsernameServername(event.state_key, serverName); - // state_key is the user affected by the membership change - const affectedUser = await Users.findOneByUsername(affectedUsername); - if (!affectedUser) { - logger.error(`No Rocket.Chat user found for bridged user: ${event.state_key}`); + const inviteeUser = await getOrCreateFederatedUser(userId as UserID); + if (!inviteeUser) { + logger.error(`Failed to get or create invitee user: ${userId}`); return; } - // Check if this is a kick (sender != state_key) or voluntary leave (sender == state_key) - if (event.sender === event.state_key) { - // Voluntary leave - await Room.removeUserFromRoom(room._id, affectedUser); - logger.info(`User ${affectedUser.username} left room ${room._id} via Matrix federation`); - } else { - // Kick - find who kicked + const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'c'; + const strippedState = event.unsigned.stripped_state; - const [kickerUsername] = getUsernameServername(event.sender, serverName); - const kickerUser = await Users.findOneByUsername(kickerUsername); + const createState = strippedState?.find((state: PduForType<'m.room.create'>) => state.type === 'm.room.create'); + const roomOriginDomain = createState?.sender?.split(':')?.pop(); + if (!roomOriginDomain) { + throw new Error(`Room origin domain not found: ${roomId}`); + } - await Room.removeUserFromRoom(room._id, affectedUser, { - byUser: kickerUser || { _id: 'matrix.federation', username: 'Matrix User' }, - }); + const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); + const matrixRoomName = roomNameState?.content?.name; - const reasonText = event.content.reason ? ` Reason: ${event.content.reason}` : ''; - logger.info(`User ${affectedUser.username} was kicked from room ${room._id} by ${event.sender} via Matrix federation.${reasonText}`); + // if is a DM, use the sender username as the room name + // otherwise, use the matrix room name and the room origin domain + let roomName: string; + if (content?.is_direct) { + roomName = senderId; + } else if (matrixRoomName && roomOriginDomain) { + roomName = `${matrixRoomName}:${roomOriginDomain}`; + } else { + roomName = `${roomId}:${roomOriginDomain}`; } -} -async function membershipJoinAction(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']) { - const room = await Rooms.findOne({ 'federation.mrid': event.room_id }); + // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. + const roomFName = roomName; + + const room = await getOrCreateFederatedRoom( + roomId as RoomID, + roomFName, + roomType, + inviterUser._id as UserID, + inviterUser.username as UserID, + ); if (!room) { - logger.warn(`No bridged room found for room_id: ${event.room_id}`); + logger.error(`Room not found or could not be created: ${roomId}`); return; } - const [username, serverName, isLocal] = getUsernameServername(event.sender, federationSDK.getConfig('serverName')); + await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { + invited: true, + federation: { inviteEventId: eventId, inviterUsername: inviterUser.username }, + }); +} - // for local users we must to remove the @ and the server domain - const localUser = isLocal && (await Users.findOneByUsername(username)); +async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const { room_id: roomId, state_key: userId } = event; - if (localUser) { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, localUser._id); - if (subscription) { - return; - } - await Room.addUserToRoom(room._id, localUser); + const joiningUser = await getOrCreateFederatedUser(userId as UserID); + if (!joiningUser) { + logger.error(`Failed to get or create joining user: ${userId}`); return; } - if (!serverName) { - throw new Error('Invalid sender format, missing server name'); + // TODO: move DB calls to models package + const room = await Rooms.findOne({ 'federation.mrid': roomId }); + if (!room) { + logger.warn(`Join event for unknown room: ${roomId} - user may be joining before room creation event received`); + return membershipJoinAction(event); } - const insertedId = await createOrUpdateFederatedUser({ - username: event.state_key, - origin: serverName, - name: event.content.displayname || event.state_key, + // TODO: move DB calls to models package + const subscription = await Subscriptions.findOne({ + 'rid': room._id, + 'u._id': joiningUser._id, }); - const user = await Users.findOneById(insertedId); + if (!subscription) { + logger.info(`User ${userId} joining room ${roomId} directly (no prior invite)`); + return membershipJoinAction(event); + } + + logger.info(`User ${userId} accepting invite to room ${roomId}`); + + // TODO: move DB calls to models package + await Subscriptions.updateOne( + { _id: subscription._id }, + { + $unset: { + 'invited': 1, + 'federation.inviteEventId': 1, + 'federation.inviterUsername': 1, + }, + $set: { + open: true, + alert: false, + _updatedAt: new Date(), + }, + }, + ); +} + +async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const { room_id: roomId, state_key: userId } = event; - if (!user) { - console.warn(`User with ID ${insertedId} not found after insertion`); + const leavingUser = await getOrCreateFederatedUser(userId as UserID); + if (!leavingUser) { + logger.error(`Failed to get or create leaving user: ${userId}`); return; } - await Room.addUserToRoom(room._id, user); + + // TODO: move DB calls to models package + const room = await Rooms.findOne({ 'federation.mrid': roomId }); + if (!room) { + logger.warn(`Leave event for unknown room: ${roomId}`); + return; + } + + await Room.removeUserFromRoom(room._id, leavingUser); } export function member(emitter: Emitter) { From 022b9454fe9574cbc4259dd72bd8176555bc7b24 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 22:19:23 -0300 Subject: [PATCH 13/72] refactor: add markInviteAsAccepted and replace direct model calls --- .../lib/server/functions/acceptRoomInvite.ts | 31 +++++++++++++++++++ apps/meteor/server/services/room/service.ts | 8 ++++- .../core-services/src/types/IRoomService.ts | 13 +++++++- .../src/models/ISubscriptionsModel.ts | 1 + packages/models/src/models/Subscriptions.ts | 16 ++++++++++ 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 apps/meteor/app/lib/server/functions/acceptRoomInvite.ts diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts new file mode 100644 index 0000000000000..76949c60e0d6d --- /dev/null +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -0,0 +1,31 @@ +import { Message } from '@rocket.chat/core-services'; +import type { IUser, IRoom, ISubscription } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; + +import { callbacks } from '../../../../lib/callbacks'; +import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; + +export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: Pick): Promise => { + if (!user.username) { + throw new Meteor.Error('error-user-username-not-found', 'User username not found', { + method: 'acceptRoomInvite', + }); + } + + if (!subscription.invited) { + throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { + method: 'acceptRoomInvite', + }); + } + + await callbacks.run('beforeJoinRoom', user, room); + + await Subscriptions.markInviteAsAccepted(subscription._id); + + void notifyOnSubscriptionChangedById(subscription._id, 'updated'); + + await Message.saveSystemMessage('uj', room._id, user.username, user); + + await callbacks.run('afterJoinRoom', user, room); +}; diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 1e9492d12792c..68cd9ec3ebb9a 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -1,11 +1,13 @@ import { ServiceClassInternal, Authorization, MeteorError } from '@rocket.chat/core-services'; import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services'; -import { type AtLeast, type IRoom, type IUser, isOmnichannelRoom, isRoomWithJoinCode } from '@rocket.chat/core-typings'; +import { isOmnichannelRoom, isRoomWithJoinCode } from '@rocket.chat/core-typings'; +import type { ISubscription, AtLeast, IRoom, IUser } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { FederationActions } from './hooks/BeforeFederationActions'; import { saveRoomName } from '../../../app/channel-settings/server'; import { saveRoomTopic } from '../../../app/channel-settings/server/functions/saveRoomTopic'; +import { acceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import import { removeUserFromRoom } from '../../../app/lib/server/functions/removeUserFromRoom'; @@ -81,6 +83,10 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return removeUserFromRoom(roomId, user, options); } + async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: Pick): Promise { + return acceptRoomInvite(room, subscription, user); + } + async getValidRoomName(displayName: string, roomId = '', options: { allowDuplicates?: boolean } = {}): Promise { return getValidRoomName(displayName, roomId, options); } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 1115acfbbe2e7..493419d49b1c5 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -1,4 +1,4 @@ -import type { AtLeast, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; export interface ISubscriptionExtraData { open: boolean; @@ -47,6 +47,17 @@ export interface IRoomService { }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; + acceptRoomInvite( + room: IRoom, + subscription: ISubscription, + user: Pick, + options?: { + skipSystemMessage?: boolean; + federation?: { + inviteEventId?: string; + }; + }, + ): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( roomId: string, diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 121da7e8ad1c3..2282c042a2d75 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -336,4 +336,5 @@ export interface ISubscriptionsModel extends IBaseModel { setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; findUserFederatedRoomIds(userId: IUser['_id']): AggregationCursor<{ _id: IRoom['_id']; externalRoomId: string }>; + markInviteAsAccepted(subscriptionId: string): Promise; } diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index d9103f293a7ee..e190971b1d21f 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2086,4 +2086,20 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri }, ]); } + + async markInviteAsAccepted(subscriptionId: string): Promise { + return this.updateOne( + { _id: subscriptionId }, + { + $unset: { + invited: 1, + federation: 1, + }, + $set: { + open: true, + alert: false, + }, + }, + ); + } } From 2bca9c5da6b2de627730a226c097445159ea4dad Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 16 Nov 2025 22:54:43 -0300 Subject: [PATCH 14/72] feat: add capabilities for accepting and rejecting invites --- apps/meteor/app/api/server/v1/rooms.ts | 37 ++++- apps/meteor/app/lib/lib/MessageTypes.ts | 8 + .../server/functions/removeUserFromRoom.ts | 4 +- .../federation-matrix/src/FederationMatrix.ts | 7 +- .../federation-matrix/src/events/helpers.ts | 84 ++++++++++ .../federation-matrix/src/events/member.ts | 149 ++++++++++++++++-- .../src/types/IFederationMatrixService.ts | 1 + packages/rest-typings/src/v1/rooms.ts | 27 ++++ 8 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 ee/packages/federation-matrix/src/events/helpers.ts diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index 88558dc9a62df..c6251e7787168 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,4 +1,4 @@ -import { Media, MeteorError, Team } from '@rocket.chat/core-services'; +import { FederationMatrix, Media, MeteorError, Team } from '@rocket.chat/core-services'; import type { IRoom, IUpload } from '@rocket.chat/core-typings'; import { isPrivateRoom, isPublicRoom } from '@rocket.chat/core-typings'; import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/models'; @@ -15,6 +15,9 @@ import { isRoomsMembersOrderedByRoleProps, isRoomsChangeArchivationStateProps, isRoomsHideProps, + isRoomsInviteProps, + validateBadRequestErrorResponse, + validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; @@ -1073,7 +1076,37 @@ export const roomEndpoints = API.v1.get( }, ); -type RoomEndpoints = ExtractRoutesFromAPI; +const roomInviteEndpoints = API.v1.post( + 'rooms.invite', + { + authRequired: true, + body: isRoomsInviteProps, + response: { + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 200: ajv.compile({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + required: ['success'], + additionalProperties: false, + }), + }, + }, + async function action() { + const { subscriptionId, action } = this.bodyParams; + + try { + await FederationMatrix.handleInvite(subscriptionId, this.userId, action); + return API.v1.success(); + } catch (error) { + return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); + } + }, +); + +type RoomEndpoints = ExtractRoutesFromAPI & ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface diff --git a/apps/meteor/app/lib/lib/MessageTypes.ts b/apps/meteor/app/lib/lib/MessageTypes.ts index 2d83584da5c08..8205200615ff7 100644 --- a/apps/meteor/app/lib/lib/MessageTypes.ts +++ b/apps/meteor/app/lib/lib/MessageTypes.ts @@ -29,6 +29,14 @@ export const MessageTypesValues: Array<{ key: MessageTypesValuesType; i18nLabel: key: 'au', // added user i18nLabel: 'Message_HideType_au', }, + { + key: 'ui', // user invited to room + i18nLabel: 'Message_HideType_ui', + }, + { + key: 'uir', // user rejected invitation to room + i18nLabel: 'Message_HideType_uir', + }, { key: 'added-user-to-team', i18nLabel: 'Message_HideType_added_user_to_team', diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index 23e82389cb271..cc4d3e56c0804 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -33,7 +33,7 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await beforeLeaveRoomCallback.run(user, room); const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { - projection: { _id: 1 }, + projection: { _id: 1, invited: 1 }, }); if (subscription) { @@ -48,6 +48,8 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti } else { await Message.saveSystemMessage('ru', rid, user.username || '', user, extraData); } + } else if (subscription.invited) { + await Message.saveSystemMessage('uir', rid, removedUser.username || '', removedUser); } else if (room.teamMain) { await Message.saveSystemMessage('ult', rid, removedUser.username || '', removedUser); } else { diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index dbdd15570683c..e50cb603f5c2b 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -7,7 +7,7 @@ import { isUserNativeFederated, UserStatus, } from '@rocket.chat/core-typings'; -import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings'; +import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated, ISubscription } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK } from '@rocket.chat/federation-sdk'; import type { EventID, @@ -942,17 +942,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS // TODO: should use common function to get matrix user ID const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; - if (!user.username) { - throw new Error('User username not found'); - } if (action === 'accept') { await federationSDK.acceptInvite(subscription.federation?.inviteEventId, matrixUserId); - await Message.saveSystemMessage('uj', subscription.rid, user.username, user, { u: { _id: user._id, username: user.username } }); } if (action === 'reject') { await federationSDK.rejectInvite(subscription.federation?.inviteEventId, matrixUserId); - await Message.saveSystemMessage('uir', subscription.rid, user.username, user, { u: { _id: user._id, username: user.username } }); } } } diff --git a/ee/packages/federation-matrix/src/events/helpers.ts b/ee/packages/federation-matrix/src/events/helpers.ts new file mode 100644 index 0000000000000..405384214f1b2 --- /dev/null +++ b/ee/packages/federation-matrix/src/events/helpers.ts @@ -0,0 +1,84 @@ +import { Room } from '@rocket.chat/core-services'; +import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import type { UserID, RoomID } from '@rocket.chat/federation-sdk'; +import { Logger } from '@rocket.chat/logger'; +import { Rooms, Users } from '@rocket.chat/models'; + +import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix'; + +const logger = new Logger('federation-matrix:helpers'); + +export async function getOrCreateFederatedUser(matrixId: UserID): Promise { + try { + const serverName = federationSDK.getConfig('serverName'); + const [username, userServerName, isLocal] = getUsernameServername(matrixId, serverName); + + let user = await Users.findOneByUsername(username); + + if (user) { + return user; + } + + if (isLocal) { + logger.warn(`Local user ${username} not found for Matrix ID: ${matrixId}`); + return null; + } + + logger.info(`Creating federated user for Matrix ID: ${matrixId}`); + + const userId = await createOrUpdateFederatedUser({ + username: matrixId, + name: matrixId, + origin: userServerName, + }); + + user = await Users.findOneById(userId); + + if (!user) { + logger.error(`Failed to retrieve user after creation: ${matrixId}`); + return null; + } + + return user; + } catch (error) { + logger.error(`Error getting or creating federated user ${matrixId}:`, error); + return null; + } +} + +export async function getOrCreateFederatedRoom( + roomName: RoomID, // matrix room ID + roomFName: string, + roomType: RoomType, + inviterUserId: UserID, + _inviterMatrixId: UserID, +): Promise { + try { + const room = await Rooms.findOne({ 'federation.mrid': roomName }); + if (room) { + return room; + } + + logger.info(`Creating federated room for Matrix room ID: ${roomName} with name: ${roomFName}`); + + const createdRoom = await Room.create(inviterUserId, { + type: roomType, + name: roomName, + options: { + federatedRoomId: roomName, + creator: inviterUserId, + }, + extraData: { + federated: true, + fname: roomFName, + }, + }); + + logger.info(`Successfully created federated room ${createdRoom._id} for Matrix room ${roomName}`); + return createdRoom; + } catch (error) { + logger.error(`Error getting or creating federated room ${roomName}:`, error); + return null; + } +} diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index e5f947b431002..fefa4a0ea905b 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,10 +1,12 @@ import { Room } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; -import { federationSDK, type HomeserverEventSignatures } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, UserID, RoomID, PduForType, EventID } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix'; +import { getOrCreateFederatedRoom, getOrCreateFederatedUser } from './helpers'; const logger = new Logger('federation-matrix:member'); @@ -136,18 +138,147 @@ async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.m await Room.removeUserFromRoom(room._id, leavingUser); } +async function handleInvite(event: HomeserverEventSignatures['homeserver.matrix.membership']['event'], eventId: EventID): Promise { + const { room_id: roomId, sender: senderId, state_key: userId, content } = event; + + const inviterUser = await getOrCreateFederatedUser(senderId as UserID); + if (!inviterUser) { + logger.error(`Failed to get or create inviter user: ${senderId}`); + return; + } + + const inviteeUser = await getOrCreateFederatedUser(userId as UserID); + if (!inviteeUser) { + logger.error(`Failed to get or create invitee user: ${userId}`); + return; + } + + const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'c'; + const strippedState = event.unsigned.stripped_state; + + const createState = strippedState?.find((state: PduForType<'m.room.create'>) => state.type === 'm.room.create'); + const roomOriginDomain = createState?.sender?.split(':')?.pop(); + + const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); + const matrixRoomName = roomNameState?.content?.name; + + // if is a DM, use the sender username as the room name + // otherwise, use the matrix room name and the room origin domain + let roomName: string; + if (content?.is_direct) { + roomName = senderId; + } else if (matrixRoomName && roomOriginDomain) { + roomName = `${matrixRoomName}:${roomOriginDomain}`; + } else { + roomName = `${roomId}:${roomOriginDomain}`; + } + + // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. + const roomFName = roomName; + + const room = await getOrCreateFederatedRoom( + roomId as RoomID, + roomFName, + roomType, + inviterUser._id as UserID, + inviterUser.username as UserID, + ); + if (!room) { + logger.error(`Room not found or could not be created: ${roomId}`); + return; + } + + await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { + invited: true, + federation: { inviteEventId: eventId, inviterUsername: inviterUser.username }, + }); +} + +async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const { room_id: roomId, state_key: userId } = event; + + const joiningUser = await getOrCreateFederatedUser(userId as UserID); + if (!joiningUser) { + logger.error(`Failed to get or create joining user: ${userId}`); + return; + } + + // TODO: move DB calls to models package + const room = await Rooms.findOne({ 'federation.mrid': roomId }); + if (!room) { + logger.warn(`Join event for unknown room: ${roomId} - user may be joining before room creation event received`); + return membershipJoinAction(event); + } + + // TODO: move DB calls to models package + const subscription = await Subscriptions.findOne({ + 'rid': room._id, + 'u._id': joiningUser._id, + }); + + if (!subscription) { + logger.info(`User ${userId} joining room ${roomId} directly (no prior invite)`); + return membershipJoinAction(event); + } + + logger.info(`User ${userId} accepting invite to room ${roomId}`); + + // TODO: move DB calls to models package + await Subscriptions.updateOne( + { _id: subscription._id }, + { + $unset: { + 'invited': 1, + 'federation.inviteEventId': 1, + 'federation.inviterUsername': 1, + }, + $set: { + open: true, + alert: false, + _updatedAt: new Date(), + }, + }, + ); +} + +async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { + const { room_id: roomId, state_key: userId } = event; + + const leavingUser = await getOrCreateFederatedUser(userId as UserID); + if (!leavingUser) { + logger.error(`Failed to get or create leaving user: ${userId}`); + return; + } + + // TODO: move DB calls to models package + const room = await Rooms.findOne({ 'federation.mrid': roomId }); + if (!room) { + logger.warn(`Leave event for unknown room: ${roomId}`); + return; + } + + await Room.removeUserFromRoom(room._id, leavingUser); +} + export function member(emitter: Emitter) { - emitter.on('homeserver.matrix.membership', async ({ event }) => { + emitter.on('homeserver.matrix.membership', async ({ event, event_id: eventId }) => { try { - if (event.content.membership === 'leave') { - return membershipLeaveAction(event); - } + switch (event.content.membership) { + case 'invite': + await handleInvite(event, eventId); + break; - if (event.content.membership === 'join') { - return membershipJoinAction(event); - } + case 'join': + await handleJoin(event); + break; + + case 'leave': + await handleLeave(event); + break; - logger.debug(`Ignoring membership event with membership: ${event.content.membership}`); + default: + logger.warn(`Unknown membership type: ${event.content.membership}`); + } } catch (error) { logger.error(error, 'Failed to process Matrix membership event'); } diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index b37802b3827d5..b40d825113b72 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -32,4 +32,5 @@ export interface IFederationMatrixService { ): Promise<{ event_id: EventID; event: PersistentEventBase; room_id: RoomID }[]>; notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise; verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; + handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise; } diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 68f83cbe56b38..f5afdedcb936c 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -688,6 +688,29 @@ const roomsHideSchema = { export const isRoomsHideProps = ajv.compile(roomsHideSchema); +type RoomsInviteProps = { + subscriptionId: string; + action: 'accept' | 'reject'; +}; + +const roomsInvitePropsSchema = { + type: 'object', + properties: { + subscriptionId: { + type: 'string', + minLength: 1, + }, + action: { + type: 'string', + enum: ['accept', 'reject'], + }, + }, + required: ['subscriptionId', 'action'], + additionalProperties: false, +}; + +export const isRoomsInviteProps = ajv.compile(roomsInvitePropsSchema); + export type RoomsEndpoints = { '/v1/rooms.autocomplete.channelAndPrivate': { GET: (params: RoomsAutoCompleteChannelAndPrivateProps) => { @@ -868,4 +891,8 @@ export type RoomsEndpoints = { '/v1/rooms.hide': { POST: (params: RoomsHideProps) => void; }; + + '/v1/rooms.invite': { + POST: (params: RoomsInviteProps) => void; + }; }; From d422053714d59c085985f5a1eef39ed4e4666503 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 18 Nov 2025 22:53:38 -0300 Subject: [PATCH 15/72] chore: adjust processInvite input signature* --- .../federation-matrix/src/FederationMatrix.ts | 12 +- .../src/api/_matrix/invite.ts | 11 +- .../federation-matrix/src/events/member.ts | 173 ++---------------- 3 files changed, 23 insertions(+), 173 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index e50cb603f5c2b..3d457251e76c5 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1,4 +1,4 @@ -import { type IFederationMatrixService, Message, ServiceClass } from '@rocket.chat/core-services'; +import { type IFederationMatrixService, ServiceClass } from '@rocket.chat/core-services'; import { isDeletedMessage, isMessageFromMatrixFederation, @@ -22,7 +22,6 @@ import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; import emojione from 'emojione'; -import { acceptInvite } from './api/_matrix/invite'; import { toExternalMessageFormat, toExternalQuoteMessageFormat } from './helpers/message.parsers'; import { MatrixMediaService } from './services/MatrixMediaService'; @@ -582,8 +581,6 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(inviterUserId), ); - - return acceptInvite(result.event, username); }), ); } catch (error) { @@ -851,7 +848,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (!rid || !user) { return; } - const room = await Rooms.findOneById(rid); + const room = await Rooms.findOneById(rid, { projection: { _id: 1, federation: 1, federated: 1 } }); if (!room || !isRoomNativeFederated(room)) { return; } @@ -863,6 +860,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } + const hasUserJoinedRoom = await Subscriptions.findOneByRoomIdAndUserId(room._id, localUser?._id, { projection: { _id: 1 } }); + if (!hasUserJoinedRoom) { + return; + } + const userMui = isUserNativeFederated(localUser) ? localUser.federation.mui : `@${localUser.username}:${this.serverName}`; void federationSDK.sendTypingNotification(room.federation.mrid, userMui, isTyping); diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index 1dcace7345963..3e0a8efda8ee5 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -1,7 +1,7 @@ import { Authorization } from '@rocket.chat/core-services'; import { isUserNativeFederated, type IUser } from '@rocket.chat/core-typings'; import type { PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; -import { eventIdSchema, roomIdSchema, NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; +import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Users } from '@rocket.chat/models'; @@ -230,14 +230,7 @@ export const getMatrixInviteRoutes = () => { } try { - const inviteEvent = await federationSDK.processInvite( - event, - roomIdSchema.parse(roomId), - eventIdSchema.parse(eventId), - roomVersion, - c.get('authenticatedServer'), - strippedStateEvents, - ); + const inviteEvent = await federationSDK.processInvite(event, eventId, roomVersion); return { body: { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index fefa4a0ea905b..3e8c7980d8f5a 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,11 +1,9 @@ import { Room } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; import type { HomeserverEventSignatures, UserID, RoomID, PduForType, EventID } from '@rocket.chat/federation-sdk'; -import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; -import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix'; import { getOrCreateFederatedRoom, getOrCreateFederatedUser } from './helpers'; const logger = new Logger('federation-matrix:member'); @@ -28,8 +26,10 @@ export async function handleInvite( return; } - const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'c'; - const strippedState = event.unsigned.stripped_state; + // we are not handling public rooms yet - in the future we should use 'c' for public rooms + // as well as should rethink the canAccessRoom authorization logic + const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'p'; + const strippedState = event.unsigned.invite_room_state; const createState = strippedState?.find((state: PduForType<'m.room.create'>) => state.type === 'm.room.create'); const roomOriginDomain = createState?.sender?.split(':')?.pop(); @@ -75,170 +75,26 @@ export async function handleInvite( async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const { room_id: roomId, state_key: userId } = event; - const joiningUser = await getOrCreateFederatedUser(userId as UserID); + const joiningUser = await getOrCreateFederatedUser(userId); if (!joiningUser) { logger.error(`Failed to get or create joining user: ${userId}`); return; } - // TODO: move DB calls to models package - const room = await Rooms.findOne({ 'federation.mrid': roomId }); + const room = await Rooms.findOneFederatedByMrid(roomId); if (!room) { - logger.warn(`Join event for unknown room: ${roomId} - user may be joining before room creation event received`); - return membershipJoinAction(event); + throw new Error(`Room not found while joining user ${userId} to room ${roomId}`); } - // TODO: move DB calls to models package - const subscription = await Subscriptions.findOne({ - 'rid': room._id, - 'u._id': joiningUser._id, + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id, { + projection: { _id: 1, invited: 1, federation: 1 }, }); - if (!subscription) { - logger.info(`User ${userId} joining room ${roomId} directly (no prior invite)`); - return membershipJoinAction(event); - } - - logger.info(`User ${userId} accepting invite to room ${roomId}`); - - // TODO: move DB calls to models package - await Subscriptions.updateOne( - { _id: subscription._id }, - { - $unset: { - 'invited': 1, - 'federation.inviteEventId': 1, - 'federation.inviterUsername': 1, - }, - $set: { - open: true, - alert: false, - _updatedAt: new Date(), - }, - }, - ); -} - -async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { - const { room_id: roomId, state_key: userId } = event; - - const leavingUser = await getOrCreateFederatedUser(userId as UserID); - if (!leavingUser) { - logger.error(`Failed to get or create leaving user: ${userId}`); - return; - } - - // TODO: move DB calls to models package - const room = await Rooms.findOne({ 'federation.mrid': roomId }); - if (!room) { - logger.warn(`Leave event for unknown room: ${roomId}`); - return; - } - - await Room.removeUserFromRoom(room._id, leavingUser); -} - -async function handleInvite(event: HomeserverEventSignatures['homeserver.matrix.membership']['event'], eventId: EventID): Promise { - const { room_id: roomId, sender: senderId, state_key: userId, content } = event; - - const inviterUser = await getOrCreateFederatedUser(senderId as UserID); - if (!inviterUser) { - logger.error(`Failed to get or create inviter user: ${senderId}`); - return; - } - - const inviteeUser = await getOrCreateFederatedUser(userId as UserID); - if (!inviteeUser) { - logger.error(`Failed to get or create invitee user: ${userId}`); + logger.error(`Subscription not found while joining user ${userId} to room ${roomId}`); return; } - const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'c'; - const strippedState = event.unsigned.stripped_state; - - const createState = strippedState?.find((state: PduForType<'m.room.create'>) => state.type === 'm.room.create'); - const roomOriginDomain = createState?.sender?.split(':')?.pop(); - - const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); - const matrixRoomName = roomNameState?.content?.name; - - // if is a DM, use the sender username as the room name - // otherwise, use the matrix room name and the room origin domain - let roomName: string; - if (content?.is_direct) { - roomName = senderId; - } else if (matrixRoomName && roomOriginDomain) { - roomName = `${matrixRoomName}:${roomOriginDomain}`; - } else { - roomName = `${roomId}:${roomOriginDomain}`; - } - - // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. - const roomFName = roomName; - - const room = await getOrCreateFederatedRoom( - roomId as RoomID, - roomFName, - roomType, - inviterUser._id as UserID, - inviterUser.username as UserID, - ); - if (!room) { - logger.error(`Room not found or could not be created: ${roomId}`); - return; - } - - await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { - invited: true, - federation: { inviteEventId: eventId, inviterUsername: inviterUser.username }, - }); -} - -async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { - const { room_id: roomId, state_key: userId } = event; - - const joiningUser = await getOrCreateFederatedUser(userId as UserID); - if (!joiningUser) { - logger.error(`Failed to get or create joining user: ${userId}`); - return; - } - - // TODO: move DB calls to models package - const room = await Rooms.findOne({ 'federation.mrid': roomId }); - if (!room) { - logger.warn(`Join event for unknown room: ${roomId} - user may be joining before room creation event received`); - return membershipJoinAction(event); - } - - // TODO: move DB calls to models package - const subscription = await Subscriptions.findOne({ - 'rid': room._id, - 'u._id': joiningUser._id, - }); - - if (!subscription) { - logger.info(`User ${userId} joining room ${roomId} directly (no prior invite)`); - return membershipJoinAction(event); - } - - logger.info(`User ${userId} accepting invite to room ${roomId}`); - - // TODO: move DB calls to models package - await Subscriptions.updateOne( - { _id: subscription._id }, - { - $unset: { - 'invited': 1, - 'federation.inviteEventId': 1, - 'federation.inviterUsername': 1, - }, - $set: { - open: true, - alert: false, - _updatedAt: new Date(), - }, - }, - ); + await Room.acceptRoomInvite(room, subscription, joiningUser); } async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { @@ -250,10 +106,9 @@ async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.m return; } - // TODO: move DB calls to models package - const room = await Rooms.findOne({ 'federation.mrid': roomId }); + const room = await Rooms.findOneFederatedByMrid(roomId); if (!room) { - logger.warn(`Leave event for unknown room: ${roomId}`); + logger.error(`Room not found while leaving user ${userId} from room ${roomId}`); return; } From 5b1ba2dc02889db62bf6ff083683756b6302bc68 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 24 Nov 2025 15:18:54 -0300 Subject: [PATCH 16/72] refactor: change invited prop name to status --- .../app/lib/server/functions/acceptRoomInvite.ts | 2 +- apps/meteor/app/lib/server/functions/addUserToRoom.ts | 10 +++++----- .../app/lib/server/functions/removeUserFromRoom.ts | 4 ++-- apps/meteor/app/lib/server/methods/addUsersToRoom.ts | 6 +++--- apps/meteor/lib/publishFields.ts | 2 +- apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts | 4 ++-- apps/meteor/server/modules/watchers/watchers.module.ts | 2 +- ee/packages/federation-matrix/src/FederationMatrix.ts | 2 +- ee/packages/federation-matrix/src/events/member.ts | 4 ++-- packages/core-services/src/types/IRoomService.ts | 4 ++-- packages/core-typings/src/ISubscription.ts | 3 ++- packages/models/src/models/Subscriptions.ts | 4 ++-- 12 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index 76949c60e0d6d..a512925c27613 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -13,7 +13,7 @@ export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, }); } - if (!subscription.invited) { + if (subscription.status !== 'INVITED') { throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { method: 'acceptRoomInvite', }); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 2125f7b42479b..d7ba645d01e41 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -1,7 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team } from '@rocket.chat/core-services'; -import { type IUser } from '@rocket.chat/core-typings'; +import { type IUser, type SubscriptionStatus } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -27,13 +27,13 @@ export const addUserToRoom = async ( skipSystemMessage, skipAlertSound, createAsHidden = false, - invited, + status, federation, }: { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; - invited?: boolean; + status?: SubscriptionStatus; federation?: { inviteEventId?: string; inviterUsername?: string; @@ -106,7 +106,7 @@ export const addUserToRoom = async ( unread: 1, userMentions: 1, groupMentions: 0, - ...(invited && { invited: true }), + ...(status && { status }), ...(federation && { federation }), ...autoTranslateConfig, ...getDefaultSubscriptionPref(userToBeAdded as IUser), @@ -131,7 +131,7 @@ export const addUserToRoom = async ( }; if (room.teamMain) { await Message.saveSystemMessage('added-user-to-team', rid, userToBeAdded.username, userToBeAdded, extraData); - } else if (invited) { + } else if (status === 'INVITED') { await Message.saveSystemMessage('ui', rid, userToBeAdded.username, userToBeAdded, { u: { _id: inviter._id, username: inviter.username }, }); diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index cc4d3e56c0804..e3c4499eaab5e 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -33,7 +33,7 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await beforeLeaveRoomCallback.run(user, room); const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { - projection: { _id: 1, invited: 1 }, + projection: { _id: 1, status: 1 }, }); if (subscription) { @@ -48,7 +48,7 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti } else { await Message.saveSystemMessage('ru', rid, user.username || '', user, extraData); } - } else if (subscription.invited) { + } else if (subscription.status === 'INVITED') { await Message.saveSystemMessage('uir', rid, removedUser.username || '', removedUser); } else if (room.teamMain) { await Message.saveSystemMessage('ult', rid, removedUser.username || '', removedUser); diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index e5c5f9c86f2f4..0494863a767ae 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,5 +1,5 @@ import { api, FederationMatrix } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import type { IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; @@ -107,12 +107,12 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - let inviteOptions: { invited?: boolean; federation?: { inviteEventId?: string; inviterUsername?: string } } = {}; + let inviteOptions: { status?: SubscriptionStatus; federation?: { inviteEventId?: string; inviterUsername?: string } } = {}; if (isRoomNativeFederated(room) && user && newUser.username) { const inviteResult = await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); inviteOptions = { - invited: true, + status: 'INVITED', federation: { inviteEventId: inviteResult[0].event_id, inviterUsername: user.username, diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 1703fee15feea..433ba41436052 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -42,7 +42,7 @@ export const subscriptionFields = { tunread: 1, tunreadGroup: 1, tunreadUser: 1, - invited: 1, + status: 1, federation: 1, // Omnichannel fields diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts index 1a59708783487..e5e4f53231c67 100644 --- a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -102,14 +102,14 @@ export async function findUsersOfRoomOrderedByRole({ }, }, }, - { $project: { roles: 1, invited: 1 } }, + { $project: { roles: 1, status: 1 } }, ], }, }, { $addFields: { roles: { $arrayElemAt: ['$subscription.roles', 0] }, - invited: { $arrayElemAt: ['$subscription.invited', 0] }, + status: { $arrayElemAt: ['$subscription.status', 0] }, }, }, { diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index db2ea4e2e9244..99a37f90665d2 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -137,7 +137,7 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'tunread' | 'tunreadGroup' | 'tunreadUser' - | 'invited' + | 'status' | 'federation' // Omnichannel fields diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 3d457251e76c5..957a05410c72f 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -922,7 +922,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS { '_id': subscriptionId, 'u._id': userId, - 'invited': true, + 'status': 'INVITED', }, { projection: { _id: 1, rid: 1, federation: 1 } }, ); diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 3e8c7980d8f5a..d83ffa276c1d4 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -67,7 +67,7 @@ export async function handleInvite( } await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { - invited: true, + status: 'INVITED', federation: { inviteEventId: eventId, inviterUsername: inviterUser.username }, }); } @@ -87,7 +87,7 @@ async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.me } const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id, { - projection: { _id: 1, invited: 1, federation: 1 }, + projection: { _id: 1, status: 1, federation: 1 }, }); if (!subscription) { logger.error(`Subscription not found while joining user ${userId} to room ${roomId}`); diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 493419d49b1c5..9d69db2cc9f0f 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -1,4 +1,4 @@ -import type { AtLeast, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, IRoom, ISubscription, IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; export interface ISubscriptionExtraData { open: boolean; @@ -39,7 +39,7 @@ export interface IRoomService { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; - invited?: boolean; + status?: SubscriptionStatus; federation?: { inviteEventId?: string; inviterUsername?: string; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 62defea00040c..0ca25aac0732a 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -7,6 +7,7 @@ type RoomID = string; export type OldKey = { e2eKeyId: string; ts: Date; E2EKey: string }; +export type SubscriptionStatus = 'INVITED'; export interface ISubscription extends IRocketChatRecord { u: Pick; v?: Pick & { token?: string }; @@ -73,7 +74,7 @@ export interface ISubscription extends IRocketChatRecord { oldRoomKeys?: OldKey[]; suggestedOldRoomKeys?: OldKey[]; - invited?: true; + status?: SubscriptionStatus; federation?: { inviteEventId?: string; inviterUsername?: string; diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index e190971b1d21f..8b4fa4cf8c5ff 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -161,7 +161,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri const query = { rid, 'u._id': uid, - 'invited': { $exists: false }, + 'status': { $exists: false }, }; return this.countDocuments(query); @@ -2092,7 +2092,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { _id: subscriptionId }, { $unset: { - invited: 1, + status: 1, federation: 1, }, $set: { From e9ffadc07a80b9044c3f3b0ba1715cd2a0ab8ae1 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 24 Nov 2025 17:46:25 -0300 Subject: [PATCH 17/72] refactor: change inviter username to subscription root level --- apps/meteor/app/lib/server/functions/addUserToRoom.ts | 4 +++- apps/meteor/app/lib/server/methods/addUsersToRoom.ts | 4 ++-- apps/meteor/lib/publishFields.ts | 1 + apps/meteor/server/modules/watchers/watchers.module.ts | 1 + ee/packages/federation-matrix/src/events/member.ts | 3 ++- packages/core-services/src/types/IRoomService.ts | 2 +- packages/core-typings/src/ISubscription.ts | 2 +- 7 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index d7ba645d01e41..5e1e5839cf6ab 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -28,15 +28,16 @@ export const addUserToRoom = async ( skipAlertSound, createAsHidden = false, status, + inviterUsername, federation, }: { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; status?: SubscriptionStatus; + inviterUsername?: string; federation?: { inviteEventId?: string; - inviterUsername?: string; }; } = {}, ): Promise => { @@ -107,6 +108,7 @@ export const addUserToRoom = async ( userMentions: 1, groupMentions: 0, ...(status && { status }), + ...(inviterUsername && { inviterUsername }), ...(federation && { federation }), ...autoTranslateConfig, ...getDefaultSubscriptionPref(userToBeAdded as IUser), diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 0494863a767ae..6705bb01803f4 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -107,15 +107,15 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - let inviteOptions: { status?: SubscriptionStatus; federation?: { inviteEventId?: string; inviterUsername?: string } } = {}; + let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string; federation?: { inviteEventId?: string } } = {}; if (isRoomNativeFederated(room) && user && newUser.username) { const inviteResult = await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); inviteOptions = { status: 'INVITED', + inviterUsername: user.username, federation: { inviteEventId: inviteResult[0].event_id, - inviterUsername: user.username, }, }; } diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 433ba41436052..1a27a7c4fce24 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -43,6 +43,7 @@ export const subscriptionFields = { tunreadGroup: 1, tunreadUser: 1, status: 1, + inviterUsername: 1, federation: 1, // Omnichannel fields diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 99a37f90665d2..7e95f290f1c5b 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -138,6 +138,7 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'tunreadGroup' | 'tunreadUser' | 'status' + | 'inviterUsername' | 'federation' // Omnichannel fields diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index d83ffa276c1d4..750b03dbb7b81 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -68,7 +68,8 @@ export async function handleInvite( await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { status: 'INVITED', - federation: { inviteEventId: eventId, inviterUsername: inviterUser.username }, + inviterUsername: inviterUser.username, + federation: { inviteEventId: eventId }, }); } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 9d69db2cc9f0f..adf406a3a9847 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -40,9 +40,9 @@ export interface IRoomService { skipAlertSound?: boolean; createAsHidden?: boolean; status?: SubscriptionStatus; + inviterUsername?: string; federation?: { inviteEventId?: string; - inviterUsername?: string; }; }, ): Promise; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 0ca25aac0732a..ec3e68ad3abb7 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -75,9 +75,9 @@ export interface ISubscription extends IRocketChatRecord { suggestedOldRoomKeys?: OldKey[]; status?: SubscriptionStatus; + inviterUsername?: string; federation?: { inviteEventId?: string; - inviterUsername?: string; }; } From 9981a0a46fc39ac0ebc9bd85d91390c325ae5da2 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 24 Nov 2025 18:35:52 -0300 Subject: [PATCH 18/72] refactor: remove federation invite specifics from subscription --- .../meteor/app/lib/server/functions/addUserToRoom.ts | 5 ----- apps/meteor/app/lib/server/methods/addUsersToRoom.ts | 7 ++----- apps/meteor/lib/publishFields.ts | 1 - .../server/modules/watchers/watchers.module.ts | 1 - .../federation-matrix/src/FederationMatrix.ts | 12 +++++++----- ee/packages/federation-matrix/src/events/member.ts | 12 ++++-------- packages/core-services/src/types/IRoomService.ts | 6 ------ packages/core-typings/src/ISubscription.ts | 3 --- 8 files changed, 13 insertions(+), 34 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 5e1e5839cf6ab..087889f97ca85 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -29,16 +29,12 @@ export const addUserToRoom = async ( createAsHidden = false, status, inviterUsername, - federation, }: { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; status?: SubscriptionStatus; inviterUsername?: string; - federation?: { - inviteEventId?: string; - }; } = {}, ): Promise => { const now = new Date(); @@ -109,7 +105,6 @@ export const addUserToRoom = async ( groupMentions: 0, ...(status && { status }), ...(inviterUsername && { inviterUsername }), - ...(federation && { federation }), ...autoTranslateConfig, ...getDefaultSubscriptionPref(userToBeAdded as IUser), }); diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 6705bb01803f4..14175185906e6 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -107,16 +107,13 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string; federation?: { inviteEventId?: string } } = {}; + let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string } = {}; if (isRoomNativeFederated(room) && user && newUser.username) { - const inviteResult = await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); + await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); inviteOptions = { status: 'INVITED', inviterUsername: user.username, - federation: { - inviteEventId: inviteResult[0].event_id, - }, }; } diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 1a27a7c4fce24..6de591f0b13b7 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -44,7 +44,6 @@ export const subscriptionFields = { tunreadUser: 1, status: 1, inviterUsername: 1, - federation: 1, // Omnichannel fields department: 1, diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 7e95f290f1c5b..91fc051addd81 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -139,7 +139,6 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'tunreadUser' | 'status' | 'inviterUsername' - | 'federation' // Omnichannel fields | 'department' diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 957a05410c72f..74dbfb6fed519 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -924,13 +924,15 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS 'u._id': userId, 'status': 'INVITED', }, - { projection: { _id: 1, rid: 1, federation: 1 } }, + { projection: { _id: 1, rid: 1 } }, ); if (!subscription) { throw new Error('Subscription not found'); } - if (!subscription.federation?.inviteEventId) { - throw new Error('Invite event ID not found'); + + const room = await Rooms.findOneById(subscription.rid); + if (!room || !isRoomNativeFederated(room)) { + throw new Error('Room not found or not federated'); } const user = await Users.findOneById(userId); @@ -946,10 +948,10 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; if (action === 'accept') { - await federationSDK.acceptInvite(subscription.federation?.inviteEventId, matrixUserId); + await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); } if (action === 'reject') { - await federationSDK.rejectInvite(subscription.federation?.inviteEventId, matrixUserId); + await federationSDK.rejectInvite(room.federation.mrid, matrixUserId); } } } diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 750b03dbb7b81..83cf001cec8ab 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,6 +1,6 @@ import { Room } from '@rocket.chat/core-services'; import type { Emitter } from '@rocket.chat/emitter'; -import type { HomeserverEventSignatures, UserID, RoomID, PduForType, EventID } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, UserID, RoomID, PduForType } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Subscriptions } from '@rocket.chat/models'; @@ -8,10 +8,7 @@ import { getOrCreateFederatedRoom, getOrCreateFederatedUser } from './helpers'; const logger = new Logger('federation-matrix:member'); -export async function handleInvite( - event: HomeserverEventSignatures['homeserver.matrix.membership']['event'], - eventId: EventID, -): Promise { +export async function handleInvite(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const { room_id: roomId, sender: senderId, state_key: userId, content } = event; const inviterUser = await getOrCreateFederatedUser(senderId as UserID); @@ -69,7 +66,6 @@ export async function handleInvite( await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { status: 'INVITED', inviterUsername: inviterUser.username, - federation: { inviteEventId: eventId }, }); } @@ -117,11 +113,11 @@ async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.m } export function member(emitter: Emitter) { - emitter.on('homeserver.matrix.membership', async ({ event, event_id: eventId }) => { + emitter.on('homeserver.matrix.membership', async ({ event }) => { try { switch (event.content.membership) { case 'invite': - await handleInvite(event, eventId); + await handleInvite(event); break; case 'join': diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index adf406a3a9847..5f8d7b956eaea 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -41,9 +41,6 @@ export interface IRoomService { createAsHidden?: boolean; status?: SubscriptionStatus; inviterUsername?: string; - federation?: { - inviteEventId?: string; - }; }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; @@ -53,9 +50,6 @@ export interface IRoomService { user: Pick, options?: { skipSystemMessage?: boolean; - federation?: { - inviteEventId?: string; - }; }, ): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index ec3e68ad3abb7..6108d70a0593d 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -76,9 +76,6 @@ export interface ISubscription extends IRocketChatRecord { status?: SubscriptionStatus; inviterUsername?: string; - federation?: { - inviteEventId?: string; - }; } export interface IOmnichannelSubscription extends ISubscription { From 7f32615bf49d4726995062a2ea837ea96a2d5df8 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 07:48:34 -0300 Subject: [PATCH 19/72] refactor: make room invite route to receive roomId instead of subscriptionId --- apps/meteor/app/api/server/v1/rooms.ts | 4 ++-- .../federation-matrix/src/FederationMatrix.ts | 12 ++++++------ packages/rest-typings/src/v1/rooms.ts | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index c6251e7787168..eb9b261d634f3 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1095,10 +1095,10 @@ const roomInviteEndpoints = API.v1.post( }, }, async function action() { - const { subscriptionId, action } = this.bodyParams; + const { roomId, action } = this.bodyParams; try { - await FederationMatrix.handleInvite(subscriptionId, this.userId, action); + await FederationMatrix.handleInvite(roomId, this.userId, action); return API.v1.success(); } catch (error) { return API.v1.failure({ error: `Failed to handle invite: ${error instanceof Error ? error.message : String(error)}` }); diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 74dbfb6fed519..7e9134c972e8e 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -7,7 +7,7 @@ import { isUserNativeFederated, UserStatus, } from '@rocket.chat/core-typings'; -import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated, ISubscription } from '@rocket.chat/core-typings'; +import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK } from '@rocket.chat/federation-sdk'; import type { EventID, @@ -917,20 +917,20 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return results; } - async handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { + async handleInvite(roomId: IRoom['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { const subscription = await Subscriptions.findOne( { - '_id': subscriptionId, + 'rid': roomId, 'u._id': userId, 'status': 'INVITED', }, - { projection: { _id: 1, rid: 1 } }, + { projection: { _id: 1 } }, ); if (!subscription) { - throw new Error('Subscription not found'); + throw new Error('User does not have a pending invite for this room'); } - const room = await Rooms.findOneById(subscription.rid); + const room = await Rooms.findOneById(roomId); if (!room || !isRoomNativeFederated(room)) { throw new Error('Room not found or not federated'); } diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index f5afdedcb936c..904d9c2b66800 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -689,14 +689,14 @@ const roomsHideSchema = { export const isRoomsHideProps = ajv.compile(roomsHideSchema); type RoomsInviteProps = { - subscriptionId: string; + roomId: string; action: 'accept' | 'reject'; }; const roomsInvitePropsSchema = { type: 'object', properties: { - subscriptionId: { + roomId: { type: 'string', minLength: 1, }, @@ -705,7 +705,7 @@ const roomsInvitePropsSchema = { enum: ['accept', 'reject'], }, }, - required: ['subscriptionId', 'action'], + required: ['roomId', 'action'], additionalProperties: false, }; From a3e10a5bb970900064e3a46cc94054397e45ad84 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 08:05:35 -0300 Subject: [PATCH 20/72] chore: move helpers to member specific file --- .../federation-matrix/src/events/helpers.ts | 84 ------------------ .../federation-matrix/src/events/member.ts | 87 +++++++++++++++++-- 2 files changed, 78 insertions(+), 93 deletions(-) delete mode 100644 ee/packages/federation-matrix/src/events/helpers.ts diff --git a/ee/packages/federation-matrix/src/events/helpers.ts b/ee/packages/federation-matrix/src/events/helpers.ts deleted file mode 100644 index 405384214f1b2..0000000000000 --- a/ee/packages/federation-matrix/src/events/helpers.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Room } from '@rocket.chat/core-services'; -import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; -import { federationSDK } from '@rocket.chat/federation-sdk'; -import type { UserID, RoomID } from '@rocket.chat/federation-sdk'; -import { Logger } from '@rocket.chat/logger'; -import { Rooms, Users } from '@rocket.chat/models'; - -import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix'; - -const logger = new Logger('federation-matrix:helpers'); - -export async function getOrCreateFederatedUser(matrixId: UserID): Promise { - try { - const serverName = federationSDK.getConfig('serverName'); - const [username, userServerName, isLocal] = getUsernameServername(matrixId, serverName); - - let user = await Users.findOneByUsername(username); - - if (user) { - return user; - } - - if (isLocal) { - logger.warn(`Local user ${username} not found for Matrix ID: ${matrixId}`); - return null; - } - - logger.info(`Creating federated user for Matrix ID: ${matrixId}`); - - const userId = await createOrUpdateFederatedUser({ - username: matrixId, - name: matrixId, - origin: userServerName, - }); - - user = await Users.findOneById(userId); - - if (!user) { - logger.error(`Failed to retrieve user after creation: ${matrixId}`); - return null; - } - - return user; - } catch (error) { - logger.error(`Error getting or creating federated user ${matrixId}:`, error); - return null; - } -} - -export async function getOrCreateFederatedRoom( - roomName: RoomID, // matrix room ID - roomFName: string, - roomType: RoomType, - inviterUserId: UserID, - _inviterMatrixId: UserID, -): Promise { - try { - const room = await Rooms.findOne({ 'federation.mrid': roomName }); - if (room) { - return room; - } - - logger.info(`Creating federated room for Matrix room ID: ${roomName} with name: ${roomFName}`); - - const createdRoom = await Room.create(inviterUserId, { - type: roomType, - name: roomName, - options: { - federatedRoomId: roomName, - creator: inviterUserId, - }, - extraData: { - federated: true, - fname: roomFName, - }, - }); - - logger.info(`Successfully created federated room ${createdRoom._id} for Matrix room ${roomName}`); - return createdRoom; - } catch (error) { - logger.error(`Error getting or creating federated room ${roomName}:`, error); - return null; - } -} diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 83cf001cec8ab..446667af9bccc 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,13 +1,88 @@ import { Room } from '@rocket.chat/core-services'; +import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { HomeserverEventSignatures, UserID, RoomID, PduForType } from '@rocket.chat/federation-sdk'; +import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; -import { getOrCreateFederatedRoom, getOrCreateFederatedUser } from './helpers'; +import { createOrUpdateFederatedUser, getUsernameServername } from '../FederationMatrix'; const logger = new Logger('federation-matrix:member'); +export async function getOrCreateFederatedUser(matrixId: UserID): Promise { + try { + const serverName = federationSDK.getConfig('serverName'); + const [username, userServerName, isLocal] = getUsernameServername(matrixId, serverName); + + let user = await Users.findOneByUsername(username); + + if (user) { + return user; + } + + if (isLocal) { + logger.warn(`Local user ${username} not found for Matrix ID: ${matrixId}`); + return null; + } + + logger.info(`Creating federated user for Matrix ID: ${matrixId}`); + + const userId = await createOrUpdateFederatedUser({ + username: matrixId, + name: matrixId, + origin: userServerName, + }); + + user = await Users.findOneById(userId); + + if (!user) { + logger.error(`Failed to retrieve user after creation: ${matrixId}`); + return null; + } + + return user; + } catch (error) { + logger.error(`Error getting or creating federated user ${matrixId}:`, error); + return null; + } +} + +export async function getOrCreateFederatedRoom( + roomName: RoomID, // matrix room ID + roomFName: string, + roomType: RoomType, + inviterUserId: UserID, +): Promise { + try { + const room = await Rooms.findOne({ 'federation.mrid': roomName }); + if (room) { + return room; + } + + logger.info(`Creating federated room for Matrix room ID: ${roomName} with name: ${roomFName}`); + + const createdRoom = await Room.create(inviterUserId, { + type: roomType, + name: roomName, + options: { + federatedRoomId: roomName, + creator: inviterUserId, + }, + extraData: { + federated: true, + fname: roomFName, + }, + }); + + logger.info(`Successfully created federated room ${createdRoom._id} for Matrix room ${roomName}`); + return createdRoom; + } catch (error) { + logger.error(`Error getting or creating federated room ${roomName}:`, error); + return null; + } +} + export async function handleInvite(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const { room_id: roomId, sender: senderId, state_key: userId, content } = event; @@ -51,13 +126,7 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. const roomFName = roomName; - const room = await getOrCreateFederatedRoom( - roomId as RoomID, - roomFName, - roomType, - inviterUser._id as UserID, - inviterUser.username as UserID, - ); + const room = await getOrCreateFederatedRoom(roomId, roomFName, roomType, inviterUser._id as UserID); if (!room) { logger.error(`Room not found or could not be created: ${roomId}`); return; From 87852cace0cfd8a11bfc40a779328933c64b42f8 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 14:14:34 -0300 Subject: [PATCH 21/72] chore: adjust acceptRoomInvite signature* --- .../lib/server/functions/acceptRoomInvite.ts | 8 +-- apps/meteor/server/services/room/service.ts | 2 +- .../federation-matrix/src/FederationMatrix.ts | 16 ++--- .../federation-matrix/src/events/member.ts | 66 ++++++------------- .../core-services/src/types/IRoomService.ts | 2 +- 5 files changed, 32 insertions(+), 62 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index a512925c27613..0b7273026c3cb 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -6,13 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; -export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: Pick): Promise => { - if (!user.username) { - throw new Meteor.Error('error-user-username-not-found', 'User username not found', { - method: 'acceptRoomInvite', - }); - } - +export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: IUser): Promise => { if (subscription.status !== 'INVITED') { throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { method: 'acceptRoomInvite', diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 68cd9ec3ebb9a..8dc7a68c296d3 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -83,7 +83,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return removeUserFromRoom(roomId, user, options); } - async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: Pick): Promise { + async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise { return acceptRoomInvite(room, subscription, user); } diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 7e9134c972e8e..c2a0af5579e4a 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -96,10 +96,11 @@ export const getUsernameServername = (mxid: string, serverName: string): [mxid: * Because of historical reasons, we can have users only with federated flag but no federation object * So we need to upsert the user with the federation object */ -export async function createOrUpdateFederatedUser(options: { username: UserID; name?: string; origin: string }): Promise { +export async function createOrUpdateFederatedUser(options: { username: string; name?: string; origin: string }): Promise { const { username, name = username, origin } = options; - const result = await Users.updateOne( + // TODO: Have a specific method to handle this upsert + const user = await Users.findOneAndUpdate( { username, }, @@ -126,17 +127,16 @@ export async function createOrUpdateFederatedUser(options: { username: UserID; n }, { upsert: true, + projection: { _id: 1, username: 1 }, + returnDocument: 'after', }, ); - const userId = result.upsertedId || (await Users.findOneByUsername(username, { projection: { _id: 1 } }))?._id; - if (!userId) { + if (!user) { throw new Error(`Failed to create or update federated user: ${username}`); } - if (typeof userId !== 'string') { - return userId.toString(); - } - return userId; + + return user; } export { generateEd25519RandomSecretKey } from '@rocket.chat/federation-sdk'; diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 446667af9bccc..b36a6bcb97e9d 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,7 +1,7 @@ import { Room } from '@rocket.chat/core-services'; import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; -import type { HomeserverEventSignatures, UserID, RoomID, PduForType } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, UserID, PduForType } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; @@ -10,59 +10,43 @@ import { createOrUpdateFederatedUser, getUsernameServername } from '../Federatio const logger = new Logger('federation-matrix:member'); -export async function getOrCreateFederatedUser(matrixId: UserID): Promise { +export async function getOrCreateFederatedUser(userId: string): Promise { try { const serverName = federationSDK.getConfig('serverName'); - const [username, userServerName, isLocal] = getUsernameServername(matrixId, serverName); - - let user = await Users.findOneByUsername(username); + const [username, userServerName, isLocal] = getUsernameServername(userId, serverName); + const user = await Users.findOneByUsername(username); if (user) { return user; } if (isLocal) { - logger.warn(`Local user ${username} not found for Matrix ID: ${matrixId}`); - return null; + throw new Error(`Local user ${username} not found for Matrix ID: ${userId}`); } - logger.info(`Creating federated user for Matrix ID: ${matrixId}`); - - const userId = await createOrUpdateFederatedUser({ - username: matrixId, - name: matrixId, + return createOrUpdateFederatedUser({ + username: userId, + name: userId, origin: userServerName, }); - - user = await Users.findOneById(userId); - - if (!user) { - logger.error(`Failed to retrieve user after creation: ${matrixId}`); - return null; - } - - return user; } catch (error) { - logger.error(`Error getting or creating federated user ${matrixId}:`, error); - return null; + throw new Error(`Error getting or creating federated user ${userId}: ${error}`); } } export async function getOrCreateFederatedRoom( - roomName: RoomID, // matrix room ID + roomName: string, // matrix room ID roomFName: string, roomType: RoomType, - inviterUserId: UserID, -): Promise { + inviterUserId: string, +): Promise { try { const room = await Rooms.findOne({ 'federation.mrid': roomName }); if (room) { return room; } - logger.info(`Creating federated room for Matrix room ID: ${roomName} with name: ${roomFName}`); - - const createdRoom = await Room.create(inviterUserId, { + return Room.create(inviterUserId, { type: roomType, name: roomName, options: { @@ -74,25 +58,21 @@ export async function getOrCreateFederatedRoom( fname: roomFName, }, }); - - logger.info(`Successfully created federated room ${createdRoom._id} for Matrix room ${roomName}`); - return createdRoom; } catch (error) { - logger.error(`Error getting or creating federated room ${roomName}:`, error); - return null; + throw new Error(`Error getting or creating federated room ${roomName}: ${error}`); } } export async function handleInvite(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const { room_id: roomId, sender: senderId, state_key: userId, content } = event; - const inviterUser = await getOrCreateFederatedUser(senderId as UserID); + const inviterUser = await getOrCreateFederatedUser(senderId); if (!inviterUser) { logger.error(`Failed to get or create inviter user: ${senderId}`); return; } - const inviteeUser = await getOrCreateFederatedUser(userId as UserID); + const inviteeUser = await getOrCreateFederatedUser(userId); if (!inviteeUser) { logger.error(`Failed to get or create invitee user: ${userId}`); return; @@ -142,9 +122,8 @@ async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.me const { room_id: roomId, state_key: userId } = event; const joiningUser = await getOrCreateFederatedUser(userId); - if (!joiningUser) { - logger.error(`Failed to get or create joining user: ${userId}`); - return; + if (!joiningUser || !joiningUser.username) { + throw new Error(`Failed to get or create joining user: ${userId}`); } const room = await Rooms.findOneFederatedByMrid(roomId); @@ -152,12 +131,9 @@ async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.me throw new Error(`Room not found while joining user ${userId} to room ${roomId}`); } - const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id, { - projection: { _id: 1, status: 1, federation: 1 }, - }); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, joiningUser._id); if (!subscription) { - logger.error(`Subscription not found while joining user ${userId} to room ${roomId}`); - return; + throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } await Room.acceptRoomInvite(room, subscription, joiningUser); @@ -166,7 +142,7 @@ async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.me async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const { room_id: roomId, state_key: userId } = event; - const leavingUser = await getOrCreateFederatedUser(userId as UserID); + const leavingUser = await getOrCreateFederatedUser(userId); if (!leavingUser) { logger.error(`Failed to get or create leaving user: ${userId}`); return; diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 5f8d7b956eaea..1908924cf50e4 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -47,7 +47,7 @@ export interface IRoomService { acceptRoomInvite( room: IRoom, subscription: ISubscription, - user: Pick, + user: IUser, options?: { skipSystemMessage?: boolean; }, From bee3f14123a8d63eda80ff8c247d825e0c8b4164 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 14:27:18 -0300 Subject: [PATCH 22/72] refactor: adjust markInviteAsAccepted to unset inviterUsername --- packages/models/src/models/Subscriptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 8b4fa4cf8c5ff..f070975706ddf 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2093,7 +2093,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { $unset: { status: 1, - federation: 1, + inviterUsername: 1, }, $set: { open: true, From a359a2da599d3ed938bd103b7b1f0baf473a8c48 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 14:31:56 -0300 Subject: [PATCH 23/72] refactor: make errors throw instead of soft failing --- .../federation-matrix/src/events/member.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index b36a6bcb97e9d..7c503f1543d12 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -68,14 +68,12 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. const inviterUser = await getOrCreateFederatedUser(senderId); if (!inviterUser) { - logger.error(`Failed to get or create inviter user: ${senderId}`); - return; + throw new Error(`Failed to get or create inviter user: ${senderId}`); } const inviteeUser = await getOrCreateFederatedUser(userId); if (!inviteeUser) { - logger.error(`Failed to get or create invitee user: ${userId}`); - return; + throw new Error(`Failed to get or create invitee user: ${userId}`); } // we are not handling public rooms yet - in the future we should use 'c' for public rooms @@ -108,8 +106,7 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. const room = await getOrCreateFederatedRoom(roomId, roomFName, roomType, inviterUser._id as UserID); if (!room) { - logger.error(`Room not found or could not be created: ${roomId}`); - return; + throw new Error(`Room not found or could not be created: ${roomId}`); } await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { @@ -144,14 +141,12 @@ async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.m const leavingUser = await getOrCreateFederatedUser(userId); if (!leavingUser) { - logger.error(`Failed to get or create leaving user: ${userId}`); - return; + throw new Error(`Failed to get or create leaving user: ${userId}`); } const room = await Rooms.findOneFederatedByMrid(roomId); if (!room) { - logger.error(`Room not found while leaving user ${userId} from room ${roomId}`); - return; + throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`); } await Room.removeUserFromRoom(room._id, leavingUser); From 25c197f9f24fd52acb0c51bcfa78c6998a13305c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 17:17:02 -0300 Subject: [PATCH 24/72] chore: tidying on createRoom and handleInvite* --- .../federation-matrix/src/FederationMatrix.ts | 15 ++++++++------- .../federation-matrix/src/events/member.ts | 3 +-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index c2a0af5579e4a..83434e85b6039 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -231,15 +231,16 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await Rooms.setAsFederated(room._id, { mrid: matrixRoomResult.room_id, origin: this.serverName }); const federatedRoom = await Rooms.findOneById(room._id); - - if (federatedRoom && isRoomNativeFederated(federatedRoom)) { - await this.inviteUsersToRoom( - federatedRoom, - members.filter((m) => m !== owner.username), - owner, - ); + if (!federatedRoom || !isRoomNativeFederated(federatedRoom)) { + throw new Error(`Federated room not found: ${room._id}`); } + await this.inviteUsersToRoom( + federatedRoom, + members.filter((m) => m !== owner.username), + owner, + ); + this.logger.debug('Room creation completed successfully', room._id); return matrixRoomResult; diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 7c503f1543d12..b2fac9f7c8488 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -81,8 +81,7 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'p'; const strippedState = event.unsigned.invite_room_state; - const createState = strippedState?.find((state: PduForType<'m.room.create'>) => state.type === 'm.room.create'); - const roomOriginDomain = createState?.sender?.split(':')?.pop(); + const roomOriginDomain = senderId.split(':')?.pop(); if (!roomOriginDomain) { throw new Error(`Room origin domain not found: ${roomId}`); } From ec4d9499018a1d037ad87b6394d61f9743088adb Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 25 Nov 2025 18:38:10 -0300 Subject: [PATCH 25/72] fix: revert addUsersToRoom --- .../app/lib/server/methods/addUsersToRoom.ts | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 14175185906e6..c5fd718d6911f 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,5 +1,5 @@ -import { api, FederationMatrix } from '@rocket.chat/core-services'; -import type { IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; +import { api } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; @@ -107,30 +107,19 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string } = {}; - - if (isRoomNativeFederated(room) && user && newUser.username) { - await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); - inviteOptions = { - status: 'INVITED', - inviterUsername: user.username, - }; + await addUserToRoom(data.rid, newUser, user); + } else { + if (!newUser.username) { + return; } - - return addUserToRoom(data.rid, newUser, user, inviteOptions); - } - - if (!newUser.username) { - return; + void api.broadcast('notify.ephemeralMessage', userId, data.rid, { + msg: i18n.t('Username_is_already_in_here', { + postProcess: 'sprintf', + sprintf: [newUser.username], + lng: user?.language, + }), + }); } - - void api.broadcast('notify.ephemeralMessage', userId, data.rid, { - msg: i18n.t('Username_is_already_in_here', { - postProcess: 'sprintf', - sprintf: [newUser.username], - lng: user?.language, - }), - }); }), ); From 734cb26c12c9148f4d3c9f28f081d20cee88fbb8 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 01:53:23 -0300 Subject: [PATCH 26/72] chore: remove beforeAddUserToRoom EE hook --- .../app/lib/server/methods/addUsersToRoom.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index c5fd718d6911f..05e3d590810f3 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,5 +1,5 @@ -import { api } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import { api, FederationMatrix } from '@rocket.chat/core-services'; +import type { IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; @@ -107,7 +107,18 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - await addUserToRoom(data.rid, newUser, user); + // no clear and easy way to avoid federation logic here, since we must trigger an invite + // and set the status to INVITED when dealing with federated users + let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string } = {}; + if (isRoomNativeFederated(room) && user && newUser.username) { + await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); + inviteOptions = { + status: 'INVITED', + inviterUsername: user.username, + }; + } + + return addUserToRoom(data.rid, newUser, user, inviteOptions); } else { if (!newUser.username) { return; From c6c716befff61f21861115c630cb543255c9b2e0 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 26 Nov 2025 23:55:01 -0300 Subject: [PATCH 27/72] chore: create room using roomName instead of roomId --- ee/packages/federation-matrix/src/events/member.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index b2fac9f7c8488..e6e5f980099ee 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,7 +1,7 @@ import { Room } from '@rocket.chat/core-services'; import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; -import type { HomeserverEventSignatures, UserID, PduForType } from '@rocket.chat/federation-sdk'; +import type { HomeserverEventSignatures, PduForType } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; @@ -35,13 +35,14 @@ export async function getOrCreateFederatedUser(userId: string): Promise { } export async function getOrCreateFederatedRoom( - roomName: string, // matrix room ID + matrixRoomId: string, + roomName: string, roomFName: string, roomType: RoomType, inviterUserId: string, ): Promise { try { - const room = await Rooms.findOne({ 'federation.mrid': roomName }); + const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId }); if (room) { return room; } @@ -50,7 +51,7 @@ export async function getOrCreateFederatedRoom( type: roomType, name: roomName, options: { - federatedRoomId: roomName, + federatedRoomId: matrixRoomId, creator: inviterUserId, }, extraData: { @@ -103,7 +104,7 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. const roomFName = roomName; - const room = await getOrCreateFederatedRoom(roomId, roomFName, roomType, inviterUser._id as UserID); + const room = await getOrCreateFederatedRoom(roomId, roomName, roomFName, roomType, inviterUser._id); if (!room) { throw new Error(`Room not found or could not be created: ${roomId}`); } From 045a8c2cf023506273a3f5b2d122a17060c3404e Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 17:43:48 -0300 Subject: [PATCH 28/72] chore: ensure correct props are set on subscription during dm creation --- .../app/lib/server/functions/createDirectRoom.ts | 12 +++++++++++- apps/meteor/app/lib/server/functions/createRoom.ts | 3 ++- ee/packages/federation-matrix/src/events/member.ts | 13 ++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 7f26a78d85089..1728c81c91f87 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -44,7 +44,7 @@ export async function createDirectRoom( members: IUser[] | string[], roomExtraData: Partial = {}, options: { - creator?: string; + creator?: IUser['_id']; subscriptionExtra?: ISubscriptionExtraData; federatedRoomId?: string; }, @@ -157,6 +157,15 @@ export async function createDirectRoom( for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); + + const subscriptionStatus: Partial = + roomExtraData.federated && options?.creator !== member._id + ? { + status: 'INVITED', + inviterUsername: options?.creator, // TODO: Should use inviterId instead of inviterUsername + } + : {}; + const { modifiedCount, upsertedCount } = await Subscriptions.updateOne( { rid, 'u._id': member._id }, { @@ -164,6 +173,7 @@ export async function createDirectRoom( $setOnInsert: generateSubscription(getFname(otherMembers), getName(otherMembers), member, { ...options?.subscriptionExtra, ...(options?.creator !== member._id && { open: members.length > 2 }), + ...subscriptionStatus, }), }, { upsert: true }, diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 3d9e3b1810570..9ad500dcb8fcf 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -158,6 +158,7 @@ export const createRoom = async ( federated: true, federation: { version: 1, + ...(options?.federatedRoomId && { mrid: options.federatedRoomId }), // TODO we should be able to provide all values from here, currently we update on callback afterCreateRoom }, }), @@ -181,7 +182,7 @@ export const createRoom = async ( } if (type === 'd') { - return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?.username }); + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); } if (!onlyUsernames(members)) { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index e6e5f980099ee..2ecc645795895 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -40,6 +40,8 @@ export async function getOrCreateFederatedRoom( roomFName: string, roomType: RoomType, inviterUserId: string, + inviterUserName: string, + inviteeUserName?: string, ): Promise { try { const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId }); @@ -50,6 +52,7 @@ export async function getOrCreateFederatedRoom( return Room.create(inviterUserId, { type: roomType, name: roomName, + members: inviteeUserName ? [inviteeUserName, inviterUserName] : [inviterUserName], options: { federatedRoomId: matrixRoomId, creator: inviterUserId, @@ -104,7 +107,15 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. const roomFName = roomName; - const room = await getOrCreateFederatedRoom(roomId, roomName, roomFName, roomType, inviterUser._id); + const room = await getOrCreateFederatedRoom( + roomId, + roomName, + roomFName, + roomType, + inviterUser._id, + inviterUser?.username as string, + content?.is_direct ? (inviteeUser?.username as string) : undefined, + ); if (!room) { throw new Error(`Room not found or could not be created: ${roomId}`); } From 47c27b2e5b57affaba159f1f0a50f62e5820561c Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 18:03:45 -0300 Subject: [PATCH 29/72] chore: add event.unsigned undefined guard --- ee/packages/federation-matrix/src/events/member.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 2ecc645795895..3dc966d0d6bc3 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -83,7 +83,7 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. // we are not handling public rooms yet - in the future we should use 'c' for public rooms // as well as should rethink the canAccessRoom authorization logic const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'p'; - const strippedState = event.unsigned.invite_room_state; + const strippedState = event.unsigned?.invite_room_state; const roomOriginDomain = senderId.split(':')?.pop(); if (!roomOriginDomain) { From b0d5af4937250c704b3df53117490f9f2cb14add Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 18:54:06 -0300 Subject: [PATCH 30/72] chore: ensure user.username exists on acceptRoomInvite signature --- apps/meteor/app/lib/server/functions/acceptRoomInvite.ts | 2 +- apps/meteor/server/services/room/service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index 0b7273026c3cb..46b2cb3444957 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -6,7 +6,7 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; -export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: IUser): Promise => { +export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise => { if (subscription.status !== 'INVITED') { throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { method: 'acceptRoomInvite', diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 8dc7a68c296d3..b498c9ca054d2 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -83,7 +83,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return removeUserFromRoom(roomId, user, options); } - async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise { + async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { return acceptRoomInvite(room, subscription, user); } From de4d8afb881112d32bbc96b485f7fc031cea52e1 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 18:55:31 -0300 Subject: [PATCH 31/72] chore: remove spreaded vars and use object params in membership handling functions --- .../federation-matrix/src/events/member.ts | 69 +++++++++++-------- .../core-services/src/types/IRoomService.ts | 9 +-- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 3dc966d0d6bc3..94301953ae9a6 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -10,7 +10,7 @@ import { createOrUpdateFederatedUser, getUsernameServername } from '../Federatio const logger = new Logger('federation-matrix:member'); -export async function getOrCreateFederatedUser(userId: string): Promise { +async function getOrCreateFederatedUser(userId: string): Promise { try { const serverName = federationSDK.getConfig('serverName'); const [username, userServerName, isLocal] = getUsernameServername(userId, serverName); @@ -34,15 +34,23 @@ export async function getOrCreateFederatedUser(userId: string): Promise { } } -export async function getOrCreateFederatedRoom( - matrixRoomId: string, - roomName: string, - roomFName: string, - roomType: RoomType, - inviterUserId: string, - inviterUserName: string, - inviteeUserName?: string, -): Promise { +async function getOrCreateFederatedRoom({ + matrixRoomId, + roomName, + roomFName, + roomType, + inviterUserId, + inviterUserName, + inviteeUserName, +}: { + matrixRoomId: string; + roomName: string; + roomFName: string; + roomType: RoomType; + inviterUserId: string; + inviterUserName: string; + inviteeUserName?: string; +}): Promise { try { const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId }); if (room) { @@ -67,9 +75,13 @@ export async function getOrCreateFederatedRoom( } } -export async function handleInvite(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { - const { room_id: roomId, sender: senderId, state_key: userId, content } = event; - +async function handleInvite({ + sender: senderId, + state_key: userId, + room_id: roomId, + content, + unsigned, +}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const inviterUser = await getOrCreateFederatedUser(senderId); if (!inviterUser) { throw new Error(`Failed to get or create inviter user: ${senderId}`); @@ -83,7 +95,7 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. // we are not handling public rooms yet - in the future we should use 'c' for public rooms // as well as should rethink the canAccessRoom authorization logic const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'p'; - const strippedState = event.unsigned?.invite_room_state; + const strippedState = unsigned?.invite_room_state; const roomOriginDomain = senderId.split(':')?.pop(); if (!roomOriginDomain) { @@ -95,6 +107,8 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. // if is a DM, use the sender username as the room name // otherwise, use the matrix room name and the room origin domain + // TODO: consider refactoring to create federated rooms using the Matrix room_id + // as the Rocket.Chat room name and set the display (visual) name as the fName property. let roomName: string; if (content?.is_direct) { roomName = senderId; @@ -104,18 +118,17 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. roomName = `${roomId}:${roomOriginDomain}`; } - // TODO: Consider refactoring to create federated rooms using the Matrix roomId as the Rocket.Chat room name and set the display (visual) name as the fName property. const roomFName = roomName; - const room = await getOrCreateFederatedRoom( - roomId, + const room = await getOrCreateFederatedRoom({ + matrixRoomId: roomId, roomName, roomFName, roomType, - inviterUser._id, - inviterUser?.username as string, - content?.is_direct ? (inviteeUser?.username as string) : undefined, - ); + inviterUserId: inviterUser._id, + inviterUserName: inviterUser.username as string, // TODO: Remove force cast + inviteeUserName: content?.is_direct ? inviteeUser.username : undefined, + }); if (!room) { throw new Error(`Room not found or could not be created: ${roomId}`); } @@ -126,9 +139,10 @@ export async function handleInvite(event: HomeserverEventSignatures['homeserver. }); } -async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { - const { room_id: roomId, state_key: userId } = event; - +async function handleJoin({ + room_id: roomId, + state_key: userId, +}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const joiningUser = await getOrCreateFederatedUser(userId); if (!joiningUser || !joiningUser.username) { throw new Error(`Failed to get or create joining user: ${userId}`); @@ -147,9 +161,10 @@ async function handleJoin(event: HomeserverEventSignatures['homeserver.matrix.me await Room.acceptRoomInvite(room, subscription, joiningUser); } -async function handleLeave(event: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { - const { room_id: roomId, state_key: userId } = event; - +async function handleLeave({ + room_id: roomId, + state_key: userId, +}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const leavingUser = await getOrCreateFederatedUser(userId); if (!leavingUser) { throw new Error(`Failed to get or create leaving user: ${userId}`); diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 1908924cf50e4..a4bbb5befd8ed 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -44,14 +44,7 @@ export interface IRoomService { }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; - acceptRoomInvite( - room: IRoom, - subscription: ISubscription, - user: IUser, - options?: { - skipSystemMessage?: boolean; - }, - ): Promise; + acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( roomId: string, From 3b744e0f3808946a625746e9b042b73219ab0ae9 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 19:10:36 -0300 Subject: [PATCH 32/72] chore: add findInvitedSubscription to subscription model --- .../federation-matrix/src/FederationMatrix.ts | 9 +-------- .../src/models/ISubscriptionsModel.ts | 3 ++- packages/models/src/models/Subscriptions.ts | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 83434e85b6039..9fd81f86eeac1 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -919,14 +919,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } async handleInvite(roomId: IRoom['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { - const subscription = await Subscriptions.findOne( - { - 'rid': roomId, - 'u._id': userId, - 'status': 'INVITED', - }, - { projection: { _id: 1 } }, - ); + const subscription = await Subscriptions.findInvitedSubscription(roomId, userId); if (!subscription) { throw new Error('User does not have a pending invite for this room'); } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 2282c042a2d75..f59c5eef67720 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -336,5 +336,6 @@ export interface ISubscriptionsModel extends IBaseModel { setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; findUserFederatedRoomIds(userId: IUser['_id']): AggregationCursor<{ _id: IRoom['_id']; externalRoomId: string }>; - markInviteAsAccepted(subscriptionId: string): Promise; + findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise | null>; + markInviteAsAccepted(subscriptionId: ISubscription['_id']): Promise; } diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index f070975706ddf..2ec250ea6a044 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2087,6 +2087,20 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri ]); } + async findInvitedSubscription( + roomId: ISubscription['rid'], + userId: ISubscription['u']['_id'], + ): Promise | null> { + return this.findOne( + { + 'rid': roomId, + 'u._id': userId, + 'status': 'INVITED', + }, + { projection: { _id: 1 } }, + ); + } + async markInviteAsAccepted(subscriptionId: string): Promise { return this.updateOne( { _id: subscriptionId }, From 48bba11c2fc30a68a6133ae1aa0e7118b07dad8f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 27 Nov 2025 22:10:03 -0300 Subject: [PATCH 33/72] chore: revert inviteUsersToRoom signature --- .../federation-matrix/src/FederationMatrix.ts | 41 ++++++------------- .../src/types/IFederationMatrixService.ts | 10 ++--- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 9fd81f86eeac1..e7143e8be15c5 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -9,15 +9,7 @@ import { } from '@rocket.chat/core-typings'; import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings'; import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK } from '@rocket.chat/federation-sdk'; -import type { - EventID, - UserID, - FileMessageType, - PresenceState, - PersistentEventBase, - RoomVersion, - RoomID, -} from '@rocket.chat/federation-sdk'; +import type { EventID, UserID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; import emojione from 'emojione'; @@ -549,27 +541,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } - async inviteUsersToRoom( - room: IRoomNativeFederated, - matrixUsersUsername: string[], - inviter: IUser, - ): Promise<{ event_id: EventID; event: PersistentEventBase; room_id: RoomID }[]> { + async inviteUsersToRoom(room: IRoomNativeFederated, matrixUsersUsername: string[], inviter: IUser): Promise { try { const inviterUserId = `@${inviter.username}:${this.serverName}`; - const isInviterNativeFederated = isUserNativeFederated(inviter); - - // if inviter is an external user it means we receive the invite from the endpoint - // since we accept from there we can skip accepting here - only process external users - const usersToInvite = isInviterNativeFederated ? matrixUsersUsername.filter(validateFederatedUsername) : matrixUsersUsername; - - if (usersToInvite.length === 0) { - return []; - } - return Promise.all( - usersToInvite.map(async (username) => { - const isExternalUser = validateFederatedUsername(username); - if (isExternalUser) { + await Promise.all( + matrixUsersUsername.map(async (username) => { + if (validateFederatedUsername(username)) { return federationSDK.inviteUserToRoom( userIdSchema.parse(username), roomIdSchema.parse(room.federation.mrid), @@ -577,7 +555,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ); } - return federationSDK.inviteUserToRoom( + // if inviter is an external user it means we receive the invite from the endpoint + // since we accept from there we can skip accepting here + if (isUserNativeFederated(inviter)) { + this.logger.debug('Inviter is native federated, skip accept invite'); + return; + } + + await federationSDK.inviteUserToRoom( userIdSchema.parse(`@${username}:${this.serverName}`), roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(inviterUserId), diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index b40d825113b72..7535dbf77e7aa 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -1,5 +1,5 @@ -import type { IMessage, IRoomFederated, IRoomNativeFederated, ISubscription, IUser, RoomID } from '@rocket.chat/core-typings'; -import type { EventID, EventStore, PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; +import type { IMessage, IRoomFederated, IRoomNativeFederated, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { EventStore } from '@rocket.chat/federation-sdk'; export interface IFederationMatrixService { createRoom(room: IRoomFederated, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }>; @@ -25,11 +25,7 @@ export interface IFederationMatrixService { userId: string, role: 'moderator' | 'owner' | 'leader' | 'user', ): Promise; - inviteUsersToRoom( - room: IRoomFederated, - usersUserName: string[], - inviter: IUser, - ): Promise<{ event_id: EventID; event: PersistentEventBase; room_id: RoomID }[]>; + inviteUsersToRoom(room: IRoomFederated, usersUserName: string[], inviter: IUser): Promise; notifyUserTyping(rid: string, user: string, isTyping: boolean): Promise; verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }>; handleInvite(subscriptionId: ISubscription['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise; From 612c1c8a9887342873aa314d69a8f3b0daaf6f40 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 28 Nov 2025 00:04:49 -0300 Subject: [PATCH 34/72] chore: remove federationMatrix calls from CE code --- .../app/lib/server/methods/addUsersToRoom.ts | 26 ++++++++----------- .../ee/server/hooks/federation/index.ts | 21 +++++++++++++-- .../federation-matrix/src/events/member.ts | 5 ++++ 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 05e3d590810f3..22359e4abc58d 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,4 +1,4 @@ -import { api, FederationMatrix } from '@rocket.chat/core-services'; +import { api } from '@rocket.chat/core-services'; import type { IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; @@ -107,11 +107,8 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - // no clear and easy way to avoid federation logic here, since we must trigger an invite - // and set the status to INVITED when dealing with federated users let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string } = {}; if (isRoomNativeFederated(room) && user && newUser.username) { - await FederationMatrix.inviteUsersToRoom(room, [newUser.username], user); inviteOptions = { status: 'INVITED', inviterUsername: user.username, @@ -119,18 +116,17 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; } return addUserToRoom(data.rid, newUser, user, inviteOptions); - } else { - if (!newUser.username) { - return; - } - void api.broadcast('notify.ephemeralMessage', userId, data.rid, { - msg: i18n.t('Username_is_already_in_here', { - postProcess: 'sprintf', - sprintf: [newUser.username], - lng: user?.language, - }), - }); } + if (!newUser.username) { + return; + } + void api.broadcast('notify.ephemeralMessage', userId, data.rid, { + msg: i18n.t('Username_is_already_in_here', { + postProcess: 'sprintf', + sprintf: [newUser.username], + lng: user?.language, + }), + }); }), ); diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 7a5cc7637df72..da15ffc50ad01 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,11 +1,11 @@ -import { FederationMatrix } from '@rocket.chat/core-services'; +import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; import { isEditedMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { afterRemoveFromRoomCallback } from '../../../../lib/callbacks/afterRemoveFromRoomCallback'; -import { beforeAddUsersToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; +import { beforeAddUsersToRoom, beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; import { beforeChangeRoomRole } from '../../../../lib/callbacks/beforeChangeRoomRole'; import { FederationActions } from '../../../../server/services/room/hooks/BeforeFederationActions'; @@ -76,6 +76,23 @@ beforeAddUsersToRoom.add(async ({ usernames }, room) => { } }); +beforeAddUserToRoom.add( + async ({ user, inviter }, room) => { + if (!user.username || !inviter) { + return; + } + + if (FederationActions.shouldPerformFederationAction(room)) { + if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { + throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); + } + await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); + } + }, + callbacks.priority.MEDIUM, + 'native-federation-on-before-add-users-to-room', +); + callbacks.add( 'afterSetReaction', async (message: IMessage, params): Promise => { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 94301953ae9a6..516b875a83eaa 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -133,6 +133,11 @@ async function handleInvite({ throw new Error(`Room not found or could not be created: ${roomId}`); } + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, inviteeUser._id); + if (subscription) { + return; + } + await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { status: 'INVITED', inviterUsername: inviterUser.username, From 39b5c3dd97d3de429791625cfc56290aeeb36944 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 28 Nov 2025 00:15:29 -0300 Subject: [PATCH 35/72] chore: revert createDirectRoom creator props --- apps/meteor/app/lib/server/functions/createRoom.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 9ad500dcb8fcf..3d9e3b1810570 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -158,7 +158,6 @@ export const createRoom = async ( federated: true, federation: { version: 1, - ...(options?.federatedRoomId && { mrid: options.federatedRoomId }), // TODO we should be able to provide all values from here, currently we update on callback afterCreateRoom }, }), @@ -182,7 +181,7 @@ export const createRoom = async ( } if (type === 'd') { - return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?.username }); } if (!onlyUsernames(members)) { From 5dd716b8ae83363fd113f2c22fe733e225059357 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 28 Nov 2025 00:21:09 -0300 Subject: [PATCH 36/72] fix: inviterUsername prop typo --- .../app/lib/server/functions/createDirectRoom.ts | 2 +- ee/packages/federation-matrix/src/events/member.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 1728c81c91f87..3ddc873281c36 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -162,7 +162,7 @@ export async function createDirectRoom( roomExtraData.federated && options?.creator !== member._id ? { status: 'INVITED', - inviterUsername: options?.creator, // TODO: Should use inviterId instead of inviterUsername + inviterUsername: options?.creator, } : {}; diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 516b875a83eaa..7eacd43d4fa69 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -40,16 +40,16 @@ async function getOrCreateFederatedRoom({ roomFName, roomType, inviterUserId, - inviterUserName, - inviteeUserName, + inviterUsername, + inviteeUsername, }: { matrixRoomId: string; roomName: string; roomFName: string; roomType: RoomType; inviterUserId: string; - inviterUserName: string; - inviteeUserName?: string; + inviterUsername: string; + inviteeUsername?: string; }): Promise { try { const room = await Rooms.findOne({ 'federation.mrid': matrixRoomId }); @@ -60,7 +60,7 @@ async function getOrCreateFederatedRoom({ return Room.create(inviterUserId, { type: roomType, name: roomName, - members: inviteeUserName ? [inviteeUserName, inviterUserName] : [inviterUserName], + members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername], options: { federatedRoomId: matrixRoomId, creator: inviterUserId, @@ -126,8 +126,8 @@ async function handleInvite({ roomFName, roomType, inviterUserId: inviterUser._id, - inviterUserName: inviterUser.username as string, // TODO: Remove force cast - inviteeUserName: content?.is_direct ? inviteeUser.username : undefined, + inviterUsername: inviterUser.username as string, // TODO: Remove force cast + inviteeUsername: content?.is_direct ? inviteeUser.username : undefined, }); if (!room) { throw new Error(`Room not found or could not be created: ${roomId}`); From 364eaf85ee512951df6cb69daf5bb0c9c82e70e5 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 28 Nov 2025 10:59:42 -0300 Subject: [PATCH 37/72] chore: pass route invite_room_state to processInvite --- ee/packages/federation-matrix/src/api/_matrix/invite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index 3e0a8efda8ee5..d23434ac20589 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -230,7 +230,7 @@ export const getMatrixInviteRoutes = () => { } try { - const inviteEvent = await federationSDK.processInvite(event, eventId, roomVersion); + const inviteEvent = await federationSDK.processInvite(event, eventId, roomVersion, strippedStateEvents); return { body: { From cf783b49f449ceeeaece78d44550ab2720817c0b Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Fri, 28 Nov 2025 11:52:33 -0300 Subject: [PATCH 38/72] fix: use previous room naming convention --- ee/packages/federation-matrix/src/events/member.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 7eacd43d4fa69..f9f1993057b3f 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -105,21 +105,16 @@ async function handleInvite({ const roomNameState = strippedState?.find((state: PduForType<'m.room.name'>) => state.type === 'm.room.name'); const matrixRoomName = roomNameState?.content?.name; - // if is a DM, use the sender username as the room name - // otherwise, use the matrix room name and the room origin domain - // TODO: consider refactoring to create federated rooms using the Matrix room_id - // as the Rocket.Chat room name and set the display (visual) name as the fName property. let roomName: string; + let roomFName: string; if (content?.is_direct) { roomName = senderId; - } else if (matrixRoomName && roomOriginDomain) { - roomName = `${matrixRoomName}:${roomOriginDomain}`; + roomFName = senderId; } else { - roomName = `${roomId}:${roomOriginDomain}`; + roomName = roomId.replace('!', '').replace(':', '_'); + roomFName = `${matrixRoomName}:${roomOriginDomain}`; } - const roomFName = roomName; - const room = await getOrCreateFederatedRoom({ matrixRoomId: roomId, roomName, From 4f99db30d67060940d8dfa42ba0d29d968629ae7 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 30 Nov 2025 21:20:35 -0300 Subject: [PATCH 39/72] feat: add make leave route support --- .../src/api/_matrix/make-leave.ts | 106 ++++++++++++++++++ .../federation-matrix/src/api/routes.ts | 4 +- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 ee/packages/federation-matrix/src/api/_matrix/make-leave.ts diff --git a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts new file mode 100644 index 0000000000000..72e741f8df8b7 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts @@ -0,0 +1,106 @@ +import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; +import { Router } from '@rocket.chat/http-router'; +import { Logger } from '@rocket.chat/logger'; +import { ajv } from '@rocket.chat/rest-typings'; + +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; + +const isMakeLeaveParamsProps = ajv.compile({ + type: 'object', + properties: { roomId: { type: 'string' }, userId: { type: 'string' } }, + required: ['roomId', 'userId'], +}); +const isMakeLeaveSuccessResponseProps = ajv.compile({ + type: 'object', + properties: { + event: { + type: 'object', + properties: { + content: { + type: 'object', + properties: { + membership: { + type: 'string', + const: 'leave', + }, + }, + }, + origin: { + type: 'string', + }, + origin_server_ts: { + type: 'number', + }, + sender: { + type: 'string', + }, + state_key: { + type: 'string', + }, + type: { + type: 'string', + const: 'm.room.member', + }, + }, + }, + room_version: { type: 'string' }, + }, +}); +const isMakeLeaveForbiddenResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } }, +}); +const isMakeLeaveErrorResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } }, +}); + +export const getMatrixMakeLeaveRoutes = () => { + const logger = new Logger('matrix-make-leave'); + + return new Router('/federation').get( + '/v1/make_leave/:roomId/:userId', + { + params: isMakeLeaveParamsProps, + response: { + 200: isMakeLeaveSuccessResponseProps, + 403: isMakeLeaveForbiddenResponseProps, + 500: isMakeLeaveErrorResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + isAuthenticatedMiddleware(), + async (c) => { + const { roomId, userId } = c.req.param(); + try { + // TODO: Remove out of spec attributes being returned + const makeLeaveResponse = await federationSDK.makeLeave(roomId, userId); + return { + body: makeLeaveResponse, + statusCode: 200, + }; + } catch (error) { + if (error instanceof NotAllowedError) { + return { + body: { + errcode: 'M_FORBIDDEN', + error: 'This server does not allow leaving this room based on federation settings.', + }, + statusCode: 403, + }; + } + + logger.error({ msg: 'Error making leave', err: error }); + + return { + body: { + errcode: 'M_UNKNOWN', + error: error instanceof Error ? error.message : 'Internal server error while processing request', + }, + statusCode: 500, + }; + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/routes.ts b/ee/packages/federation-matrix/src/api/routes.ts index 10a03684bcd73..9235c135996fe 100644 --- a/ee/packages/federation-matrix/src/api/routes.ts +++ b/ee/packages/federation-matrix/src/api/routes.ts @@ -3,6 +3,7 @@ import { Router } from '@rocket.chat/http-router'; import { getWellKnownRoutes } from './.well-known/server'; import { getMatrixInviteRoutes } from './_matrix/invite'; import { getKeyServerRoutes } from './_matrix/key/server'; +import { getMatrixMakeLeaveRoutes } from './_matrix/make-leave'; import { getMatrixMediaRoutes } from './_matrix/media'; import { getMatrixProfilesRoutes } from './_matrix/profiles'; import { getMatrixRoomsRoutes } from './_matrix/rooms'; @@ -28,7 +29,8 @@ export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix .use(getMatrixRoomsRoutes()) .use(getMatrixSendJoinRoutes()) .use(getMatrixTransactionsRoutes()) - .use(getMatrixMediaRoutes()); + .use(getMatrixMediaRoutes()) + .use(getMatrixMakeLeaveRoutes()); wellKnown.use(isFederationEnabledMiddleware).use(isLicenseEnabledMiddleware).use(getWellKnownRoutes()); From b1c1859a6ac02c109af0f2b4d25d5ce9c48fa63d Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 30 Nov 2025 21:21:22 -0300 Subject: [PATCH 40/72] feat: add send leave route support --- .../src/api/_matrix/send-leave.ts | 109 ++++++++++++++++++ .../federation-matrix/src/api/routes.ts | 2 + 2 files changed, 111 insertions(+) create mode 100644 ee/packages/federation-matrix/src/api/_matrix/send-leave.ts diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts new file mode 100644 index 0000000000000..7d12b743ed139 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts @@ -0,0 +1,109 @@ +import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; +import { Router } from '@rocket.chat/http-router'; +import { Logger } from '@rocket.chat/logger'; +import { ajv } from '@rocket.chat/rest-typings'; + +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; + +const isSendLeaveParamsProps = ajv.compile({ + type: 'object', + properties: { roomId: { type: 'string' }, eventId: { type: 'string' } }, + required: ['roomId', 'eventId'], +}); +const isSendLeaveBodyProps = ajv.compile({ + type: 'object', + properties: { + content: { + type: 'object', + properties: { + membership: { + type: 'string', + const: 'leave', + }, + }, + }, + depth: { + type: 'number', + }, + origin: { + type: 'string', + }, + origin_server_ts: { + type: 'number', + }, + sender: { + type: 'string', + }, + state_key: { + type: 'string', + }, + type: { + type: 'string', + const: 'm.room.member', + }, + }, + required: ['content', 'depth', 'origin', 'origin_server_ts', 'sender', 'state_key', 'type'], +}); +const isSendLeaveSuccessResponseProps = ajv.compile({ + type: 'object', + properties: {}, +}); +const isSendLeaveForbiddenResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_FORBIDDEN' }, error: { type: 'string' } }, +}); +const isSendLeaveErrorResponseProps = ajv.compile({ + type: 'object', + properties: { errcode: { type: 'string', const: 'M_UNKNOWN' }, error: { type: 'string' } }, +}); + +export const getMatrixSendLeaveRoutes = () => { + const logger = new Logger('matrix-send-leave'); + + return new Router('/federation').put( + '/v2/send_leave/:roomId/:eventId', + { + params: isSendLeaveParamsProps, + body: isSendLeaveBodyProps, + response: { + 200: isSendLeaveSuccessResponseProps, + 403: isSendLeaveForbiddenResponseProps, + 500: isSendLeaveErrorResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + isAuthenticatedMiddleware(), + async (c) => { + const { roomId, eventId } = c.req.param(); + const body = await c.req.json(); + try { + await federationSDK.sendLeave(roomId, eventId, body); + return { + body: {}, + statusCode: 200, + }; + } catch (error) { + if (error instanceof NotAllowedError) { + return { + body: { + errcode: 'M_FORBIDDEN', + error: 'This server does not allow leaving this room based on federation settings.', + }, + statusCode: 403, + }; + } + + logger.error({ msg: 'Error making leave', err: error }); + + return { + body: { + errcode: 'M_UNKNOWN', + error: error instanceof Error ? error.message : 'Internal server error while processing request', + }, + statusCode: 500, + }; + } + }, + ); +}; diff --git a/ee/packages/federation-matrix/src/api/routes.ts b/ee/packages/federation-matrix/src/api/routes.ts index 9235c135996fe..986bc4db81b83 100644 --- a/ee/packages/federation-matrix/src/api/routes.ts +++ b/ee/packages/federation-matrix/src/api/routes.ts @@ -8,6 +8,7 @@ import { getMatrixMediaRoutes } from './_matrix/media'; import { getMatrixProfilesRoutes } from './_matrix/profiles'; import { getMatrixRoomsRoutes } from './_matrix/rooms'; import { getMatrixSendJoinRoutes } from './_matrix/send-join'; +import { getMatrixSendLeaveRoutes } from './_matrix/send-leave'; import { getMatrixTransactionsRoutes } from './_matrix/transactions'; import { getFederationVersionsRoutes } from './_matrix/versions'; import { isFederationDomainAllowedMiddleware } from './middlewares/isFederationDomainAllowed'; @@ -30,6 +31,7 @@ export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix .use(getMatrixSendJoinRoutes()) .use(getMatrixTransactionsRoutes()) .use(getMatrixMediaRoutes()) + .use(getMatrixSendLeaveRoutes()) .use(getMatrixMakeLeaveRoutes()); wellKnown.use(isFederationEnabledMiddleware).use(isLicenseEnabledMiddleware).use(getWellKnownRoutes()); From 9ad15e5b4283ed5685aeb3a89496564e4de0f438 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Sun, 30 Nov 2025 21:31:00 -0300 Subject: [PATCH 41/72] refactor: isolate homeserver-triggered actions from RC-originated flows to avoid propagation loops --- .../lib/server/functions/acceptRoomInvite.ts | 27 ++++++- .../app/lib/server/functions/addUserToRoom.ts | 81 ++++++++++++++----- .../app/lib/server/functions/createRoom.ts | 28 ++++++- .../server/functions/removeUserFromRoom.ts | 61 +++++++++----- .../ee/server/hooks/federation/index.ts | 21 +++-- apps/meteor/server/services/room/service.ts | 29 ++++++- .../federation-matrix/src/FederationMatrix.ts | 13 +-- .../federation-matrix/src/events/member.ts | 6 +- .../src/types/IFederationMatrixService.ts | 2 +- .../core-services/src/types/IRoomService.ts | 14 ++++ 10 files changed, 210 insertions(+), 72 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index 46b2cb3444957..a8d2ad03441e8 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -6,7 +6,17 @@ import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; -export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise => { +/** + * Accepts a room invite when triggered by internal events such as federation + * or third-party callbacks. Performs the necessary database updates and triggers + * safe callbacks, ensuring no propagation loops are created during external event + * processing. + */ +export const performAcceptRoomInvite = async ( + room: IRoom, + subscription: ISubscription, + user: IUser & { username: string }, +): Promise => { if (subscription.status !== 'INVITED') { throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { method: 'acceptRoomInvite', @@ -20,6 +30,21 @@ export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, void notifyOnSubscriptionChangedById(subscription._id, 'updated'); await Message.saveSystemMessage('uj', room._id, user.username, user); +}; + +/** + * Accepts a room invite initiated locally - via UI or API calls - performing full + * database updates and triggering all standard callbacks. These callbacks are + * expected to propagate normally to other parts of the system. + */ +export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise => { + if (subscription.status !== 'INVITED') { + throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { + method: 'acceptRoomInvite', + }); + } + + await performAcceptRoomInvite(room, subscription, user); await callbacks.run('afterJoinRoom', user, room); }; diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 087889f97ca85..e40d4e441f1fa 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -15,11 +15,11 @@ import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscri import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; /** - * This function adds user to the given room. - * Caution - It does not validates if the user has permission to join room + * Adds a user to a room when triggered by internal events such as federation + * or third-party callbacks. Performs the required database operations and fires + * only safe callbacks to avoid propagation loops during external event handling. */ - -export const addUserToRoom = async ( +export const performAddUserToRoom = async ( rid: string, user: Pick, inviter?: Pick, @@ -42,7 +42,7 @@ export const addUserToRoom = async ( if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'addUserToRoom', + method: 'performAddUserToRoom', }); } @@ -53,6 +53,11 @@ export const addUserToRoom = async ( throw new Meteor.Error('user-not-found'); } + const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); + if (existingSubscription || !userToBeAdded) { + return; + } + if ( !(await roomDirectives.allowMemberAction(room, RoomMemberActions.JOIN, userToBeAdded._id)) && !(await roomDirectives.allowMemberAction(room, RoomMemberActions.INVITE, userToBeAdded._id)) @@ -70,12 +75,6 @@ export const addUserToRoom = async ( await callbacks.run('beforeAddedToRoom', { user: userToBeAdded, inviter }); - // Check if user is already in room - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); - if (subscription || !userToBeAdded) { - return; - } - try { await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, userToBeAdded, inviter); } catch (error: any) { @@ -144,6 +143,54 @@ export const addUserToRoom = async ( } } + + if (room.teamMain && room.teamId) { + await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId); + } + + if (room.encrypted && settings.get('E2E_Enable') && userToBeAdded.e2e?.public_key) { + await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id); + } + + void notifyOnRoomChangedById(rid); + + return true; +}; + +/** + * Adds a user to the specified room by performing database updates and triggering + * all standard callbacks. Note: This function does not validate whether the user + * has permission to join the room. + */ +export const addUserToRoom = async ( + rid: string, + user: Pick, + inviter?: Pick, + options: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + createAsHidden?: boolean; + status?: SubscriptionStatus; + inviterUsername?: string; + } = {}, +): Promise => { + const room = await Rooms.findOneById(rid); + if (!room) { + throw new Meteor.Error('error-invalid-room', 'Invalid room', { + method: 'addUserToRoom', + }); + } + + const userToBeAdded = await Users.findOneById(user._id); + if (!userToBeAdded) { + throw new Meteor.Error('user-not-found'); + } + + const result = await performAddUserToRoom(rid, user, inviter, options); + if (!result) { + return; + } + if (room.t === 'c' || room.t === 'p') { process.nextTick(async () => { // Add a new event, with an optional inviter @@ -156,15 +203,5 @@ export const addUserToRoom = async ( }); } - if (room.teamMain && room.teamId) { - // if user is joining to main team channel, create a membership - await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId); - } - - if (room.encrypted && settings.get('E2E_Enable') && userToBeAdded.e2e?.public_key) { - await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id); - } - - void notifyOnRoomChangedById(rid); - return true; + return result; }; diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 3d9e3b1810570..62c8dd9c9cb50 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -7,6 +7,7 @@ import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { performAddUserToRoom } from './addUserToRoom'; import { createDirectRoom } from './createDirectRoom'; import { callbacks } from '../../../../lib/callbacks'; import { beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; @@ -59,6 +60,21 @@ async function createUsersSubscriptions({ await notifyOnRoomChanged(room, 'inserted'); } + // Invite federated members to the room SYNCRONOUSLY, + // since we do not use to invite lots of users at once, this is acceptable. + const membersToInvite = members.filter((m) => m !== owner.username); + for await (const memberUsername of membersToInvite) { + const member = await Users.findOneByUsername(memberUsername); + if (!member) { + continue; + } + + await performAddUserToRoom(room._id, member, owner, { + status: 'INVITED', + inviterUsername: owner.username, + }); + } + return; } @@ -238,6 +254,7 @@ export const createRoom = async ( }, ts: now, ro: readOnly === true, + ...(options?.federatedRoomId && { federation: { mrid: options.federatedRoomId, origin: options.federatedRoomId.split(':').pop() } }), }; if (teamId) { @@ -286,6 +303,13 @@ export const createRoom = async ( void notifyOnRoomChanged(room, 'inserted'); + // If federated, we must create Matrix room BEFORE subscriptions so invites can be sent. + if (shouldBeHandledByFederation) { + // Reusing unused callback to create Matrix room. + // We should discuss the opportunity to rename it to something with "before" prefix. + await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); + } + await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { @@ -301,10 +325,6 @@ export const createRoom = async ( } callbacks.runAsync('afterCreateRoom', owner, room); - if (shouldBeHandledByFederation) { - callbacks.runAsync('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); - } - void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, room); return { rid: room._id, // backwards compatible diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index e3c4499eaab5e..f9addbcedf05e 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -10,32 +10,28 @@ import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRo import { settings } from '../../../settings/server'; import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/notifyListener'; -export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { - const room = await Rooms.findOneById(rid); - - if (!room) { +/** + * Removes a user from a room when triggered by federation or other external events. + * Executes only the necessary database operations, with no callbacks, to prevent + * propagation loops during external event processing. + */ +export const performUserRemoval = async function (rid: string, user: IUser, options?: { byUser?: IUser }): Promise { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { + projection: { _id: 1, status: 1 }, + }); + if (!subscription) { return; } - try { - await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); - } catch (error: any) { - if (error.name === AppsEngineException.name) { - throw new Meteor.Error('error-app-prevented', error.message); - } + const room = await Rooms.findOneById(rid); - throw error; + if (!room) { + return; } - await Room.beforeLeave(room); - // TODO: move before callbacks to service await beforeLeaveRoomCallback.run(user, room); - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { - projection: { _id: 1, status: 1 }, - }); - if (subscription) { const removedUser = user; if (options?.byUser) { @@ -74,10 +70,35 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]); } - // TODO: CACHE: maybe a queue? - await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); - void notifyOnRoomChangedById(rid); +}; + +/** + * Removes a user from the given room by performing the required database updates + * and triggering all standard callbacks. Used for local actions (UI or API) + * that should propagate normally to federation and other subscribers. + */ +export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { + const room = await Rooms.findOneById(rid); + if (!room) { + return; + } + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + + await Room.beforeLeave(room); + + await performUserRemoval(rid, user, options); + + await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser); }; diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index da15ffc50ad01..79206ea222fb6 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,5 +1,5 @@ import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; -import { isEditedMessage, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { isEditedMessage, isUserNativeFederated, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; @@ -12,14 +12,14 @@ import { FederationActions } from '../../../../server/services/room/hooks/Before // callbacks.add('federation-event-example', async () => FederationMatrix.handleExample(), callbacks.priority.MEDIUM, 'federation-event-example-handler'); // TODO: move this to the hooks folder -callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner, originalMemberList: members, options }) => { - if (FederationActions.shouldPerformFederationAction(room)) { - const federatedRoomId = options?.federatedRoomId; +// Called BEFORE subscriptions are created - creates Matrix room so invites can be sent. +// The invites are sent by beforeAddUserToRoom callback. +callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner }) => { + if (FederationActions.shouldPerformFederationAction(room)) { + const federatedRoomId = room?.federation?.mrid; if (!federatedRoomId) { - // if room exists, we don't want to create it again - // adds bridge record - await FederationMatrix.createRoom(room, owner, members); + await FederationMatrix.createRoom(room, owner); } else { // matrix room was already created and passed const fromServer = federatedRoomId.split(':')[1]; @@ -86,6 +86,13 @@ beforeAddUserToRoom.add( if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); } + + // If inviter is federated, the invite came from an external transaction. + // Don't propagate back to Matrix (it was already processed at origin server). + if (isUserNativeFederated(inviter)) { + return; + } + await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); } }, diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index b498c9ca054d2..128cb0fb22161 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -7,10 +7,10 @@ import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { FederationActions } from './hooks/BeforeFederationActions'; import { saveRoomName } from '../../../app/channel-settings/server'; import { saveRoomTopic } from '../../../app/channel-settings/server/functions/saveRoomTopic'; -import { acceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; -import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; +import { acceptRoomInvite, performAcceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; +import { addUserToRoom, performAddUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import -import { removeUserFromRoom } from '../../../app/lib/server/functions/removeUserFromRoom'; +import { removeUserFromRoom, performUserRemoval } from '../../../app/lib/server/functions/removeUserFromRoom'; import { getValidRoomName } from '../../../app/utils/server/lib/getValidRoomName'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; @@ -79,14 +79,37 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return addUserToRoom(roomId, user, inviter, options); } + async performAddUserToRoom( + roomId: string, + user: Pick, + inviter?: Pick, + options?: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + createAsHidden?: boolean; + status?: ISubscription['status']; + inviterUsername?: string; + }, + ): Promise { + return performAddUserToRoom(roomId, user, inviter, options); + } + async removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: IUser }): Promise { return removeUserFromRoom(roomId, user, options); } + async performUserRemoval(roomId: string, user: IUser, options?: { byUser?: IUser }): Promise { + return performUserRemoval(roomId, user, options); + } + async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { return acceptRoomInvite(room, subscription, user); } + async performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { + return performAcceptRoomInvite(room, subscription, user); + } + async getValidRoomName(displayName: string, roomId = '', options: { allowDuplicates?: boolean } = {}): Promise { return getValidRoomName(displayName, roomId, options); } diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index e7143e8be15c5..db92eaec2dc58 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -206,7 +206,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS this.processEDUPresence = (await Settings.getValueById('Federation_Service_EDU_Process_Presence')) || false; } - async createRoom(room: IRoom, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }> { + async createRoom(room: IRoom, owner: IUser): Promise<{ room_id: string; event_id: string }> { if (room.t !== 'c' && room.t !== 'p') { throw new Error('Room is not a public or private room'); } @@ -222,16 +222,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await Rooms.setAsFederated(room._id, { mrid: matrixRoomResult.room_id, origin: this.serverName }); - const federatedRoom = await Rooms.findOneById(room._id); - if (!federatedRoom || !isRoomNativeFederated(federatedRoom)) { - throw new Error(`Federated room not found: ${room._id}`); - } - - await this.inviteUsersToRoom( - federatedRoom, - members.filter((m) => m !== owner.username), - owner, - ); + // Members are NOT invited here - invites are sent via beforeAddUserToRoom callback. this.logger.debug('Room creation completed successfully', room._id); diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index f9f1993057b3f..0820b402b284c 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -133,7 +133,7 @@ async function handleInvite({ return; } - await Room.addUserToRoom(room._id, inviteeUser, inviterUser, { + await Room.performAddUserToRoom(room._id, inviteeUser, inviterUser, { status: 'INVITED', inviterUsername: inviterUser.username, }); @@ -158,7 +158,7 @@ async function handleJoin({ throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } - await Room.acceptRoomInvite(room, subscription, joiningUser); + await Room.performAcceptRoomInvite(room, subscription, joiningUser); } async function handleLeave({ @@ -175,7 +175,7 @@ async function handleLeave({ throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`); } - await Room.removeUserFromRoom(room._id, leavingUser); + await Room.performUserRemoval(room._id, leavingUser); } export function member(emitter: Emitter) { diff --git a/packages/core-services/src/types/IFederationMatrixService.ts b/packages/core-services/src/types/IFederationMatrixService.ts index 7535dbf77e7aa..ee721b18d5c61 100644 --- a/packages/core-services/src/types/IFederationMatrixService.ts +++ b/packages/core-services/src/types/IFederationMatrixService.ts @@ -2,7 +2,7 @@ import type { IMessage, IRoomFederated, IRoomNativeFederated, ISubscription, IUs import type { EventStore } from '@rocket.chat/federation-sdk'; export interface IFederationMatrixService { - createRoom(room: IRoomFederated, owner: IUser, members: string[]): Promise<{ room_id: string; event_id: string }>; + createRoom(room: IRoomFederated, owner: IUser): Promise<{ room_id: string; event_id: string }>; ensureFederatedUsersExistLocally(members: string[]): Promise; createDirectMessageRoom(room: IRoomFederated, members: IUser[], creatorId: IUser['_id']): Promise; sendMessage(message: IMessage, room: IRoomFederated, user: IUser): Promise; diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index a4bbb5befd8ed..a2cb9d463e2e5 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -43,8 +43,22 @@ export interface IRoomService { inviterUsername?: string; }, ): Promise; + performAddUserToRoom( + roomId: string, + user: Pick, + inviter?: Pick, + options?: { + skipSystemMessage?: boolean; + skipAlertSound?: boolean; + createAsHidden?: boolean; + status?: SubscriptionStatus; + inviterUsername?: string; + }, + ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; + performUserRemoval(roomId: string, user: IUser, options?: { byUser?: IUser }): Promise; acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; + performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( roomId: string, From 73f8469a04cf9f040d8d82d0e8ca806d5098163f Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 1 Dec 2025 13:12:30 -0300 Subject: [PATCH 42/72] refactor: add subscription data on findUsersOfRoomOrderedByRole response when user is INVITED --- .../lib/findUsersOfRoomOrderedByRole.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts index e5e4f53231c67..24788d0bb9721 100644 --- a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -16,8 +16,9 @@ type FindUsersParam = { extraQuery?: Document[]; }; -type UserWithRoleData = IUser & { +type UserWithRoleAndSubscriptionData = IUser & { roles: IRole['_id'][]; + subscription?: { status: string; createdAt: string }; }; export async function findUsersOfRoomOrderedByRole({ @@ -29,7 +30,7 @@ export async function findUsersOfRoomOrderedByRole({ sort = {}, exceptions = [], extraQuery = [], -}: FindUsersParam): Promise<{ members: UserWithRoleData[]; total: number }> { +}: FindUsersParam): Promise<{ members: UserWithRoleAndSubscriptionData[]; total: number }> { const searchFields = settings.get('Accounts_SearchFields').trim().split(','); const termRegex = new RegExp(escapeRegExp(filter), 'i'); const orStmt = filter && searchFields.length ? searchFields.map((field) => ({ [field.trim()]: termRegex })) : []; @@ -62,7 +63,7 @@ export async function findUsersOfRoomOrderedByRole({ ], }; - const membersResult = Users.col.aggregate( + const membersResult = Users.col.aggregate( [ { $match: matchUserFilter, @@ -102,19 +103,34 @@ export async function findUsersOfRoomOrderedByRole({ }, }, }, - { $project: { roles: 1, status: 1 } }, + { $project: { roles: 1, status: 1, ts: 1 } }, ], }, }, { $addFields: { roles: { $arrayElemAt: ['$subscription.roles', 0] }, - status: { $arrayElemAt: ['$subscription.status', 0] }, + subscription: { + $let: { + vars: { + sub: { $arrayElemAt: ['$subscription', 0] }, + }, + in: { + $cond: { + if: { $ifNull: ['$$sub.status', false] }, + then: { + status: '$$sub.status', + createdAt: '$$sub.ts', + }, + else: '$$REMOVE', + }, + }, + }, + }, }, }, { $project: { - subscription: 0, statusSortKey: 0, }, }, From 855618dc8710e406de738eb481750b1294a6afe3 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Mon, 1 Dec 2025 17:41:44 -0300 Subject: [PATCH 43/72] chore: remove acceptInvite from invite controler to use joinUser --- .../src/api/_matrix/invite.ts | 44 +------------------ 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index d23434ac20589..271d33ffaaba5 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -1,13 +1,10 @@ import { Authorization } from '@rocket.chat/core-services'; -import { isUserNativeFederated, type IUser } from '@rocket.chat/core-typings'; -import type { PersistentEventBase, RoomVersion } from '@rocket.chat/federation-sdk'; import { NotAllowedError, federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; -import { getUsernameServername } from '../../FederationMatrix'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; const EventBaseSchema = { @@ -132,45 +129,6 @@ const ProcessInviteResponseSchema = { const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); -// This is a special case where inside rocket chat we invite users inside rockechat, so if the sender or the invitee are external iw should throw an error -export const acceptInvite = async (inviteEvent: PersistentEventBase, username: string) => { - if (!inviteEvent.stateKey) { - throw new Error('join event has missing state key, unable to determine user to join'); - } - - const internalMappedRoom = await Rooms.findOne({ 'federation.mrid': inviteEvent.roomId }); - if (!internalMappedRoom) { - throw new Error('room not found not processing invite'); - } - - const serverName = federationSDK.getConfig('serverName'); - - const inviter = await Users.findOneByUsername>(getUsernameServername(inviteEvent.sender, serverName)[0], { - projection: { _id: 1, username: 1 }, - }); - - if (!inviter) { - throw new Error('Sender user ID not found'); - } - if (isUserNativeFederated(inviter)) { - throw new Error('Sender user is native federated'); - } - - const user = await Users.findOneByUsername>(username, { - projection: { username: 1, federation: 1, federated: 1 }, - }); - - // we cannot accept invites from users that are external - if (!user) { - throw new Error('User not found'); - } - if (isUserNativeFederated(user)) { - throw new Error('User is native federated'); - } - - await federationSDK.joinUser(inviteEvent, inviteEvent.event.state_key); -}; - export const getMatrixInviteRoutes = () => { const logger = new Logger('matrix-invite'); From c4fc5d22e57fe2b19d42919a793e7159f5153ac6 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Dec 2025 08:34:06 -0300 Subject: [PATCH 44/72] chore: extract room type from stripped events before RC room creation --- .../federation-matrix/src/events/member.ts | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 0820b402b284c..c349d4cada9ae 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -75,6 +75,36 @@ async function getOrCreateFederatedRoom({ } } +// get the join rule type from the stripped state stored in the unsigned section of the event +// as per the spec, we must support several types but we only support invite and public for now. +// in the future, we must start looking into 'knock', 'knock_restricted', 'restricted' and 'private'. +function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' | 'c' | 'd' { + const joinRulesState = strippedState?.find((state: PduForType<'m.room.join_rules'>) => state.type === 'm.room.join_rules'); + + // as per the spec, users need to be invited to join a room, unless the room’s join rules state otherwise. + if (!joinRulesState) { + return 'p'; + } + + const joinRule = joinRulesState?.content?.join_rule; + switch (joinRule) { + case 'invite': + return 'p'; + case 'public': + return 'c'; + case 'knock': + throw new Error(`Knock join rule is not supported`); + case 'knock_restricted': + throw new Error(`Knock restricted join rule is not supported`); + case 'restricted': + throw new Error(`Restricted join rule is not supported`); + case 'private': + throw new Error(`Private join rule is not supported`); + default: + throw new Error(`Unknown join rule type: ${joinRule}`); + } +} + async function handleInvite({ sender: senderId, state_key: userId, @@ -92,10 +122,13 @@ async function handleInvite({ throw new Error(`Failed to get or create invitee user: ${userId}`); } - // we are not handling public rooms yet - in the future we should use 'c' for public rooms - // as well as should rethink the canAccessRoom authorization logic - const roomType = content.membership === 'invite' && content?.is_direct ? 'd' : 'p'; - const strippedState = unsigned?.invite_room_state; + const strippedState = unsigned.invite_room_state; + + const joinRuleType = getJoinRuleType(strippedState); + + // DMs do not have a join rule type (they are treated as invite only rooms), + // so we use 'd' for direct messages translation to RC. + const roomType = content?.is_direct ? 'd' : joinRuleType; const roomOriginDomain = senderId.split(':')?.pop(); if (!roomOriginDomain) { From 3e5728151e807119821b5e1e9ed5de60c405e313 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Dec 2025 09:22:21 -0300 Subject: [PATCH 45/72] refactor: add subscription to rooms.membersOrderedByRole route signature --- packages/rest-typings/src/v1/rooms.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index 904d9c2b66800..a444bda32254e 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -884,7 +884,7 @@ export type RoomsEndpoints = { '/v1/rooms.membersOrderedByRole': { GET: (params: RoomsMembersOrderedByRoleProps) => PaginatedResult<{ - members: (IUser & { roles?: IRole['_id'][] })[]; + members: (IUser & { roles?: IRole['_id'][]; subscription?: { status: string; createdAt: string } })[]; }>; }; From 9ab9491637451175e69430b0e5918e66a08cbbed Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Tue, 2 Dec 2025 13:17:33 -0300 Subject: [PATCH 46/72] test: fix broken tests due to invite feature --- apps/meteor/tests/data/rooms.helper.ts | 29 ++ .../tests/end-to-end/room.spec.ts | 281 ++++++++++-------- 2 files changed, 188 insertions(+), 122 deletions(-) diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts index 958318bc3c55a..0017a0a128340 100644 --- a/apps/meteor/tests/data/rooms.helper.ts +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -424,3 +424,32 @@ export const loadHistory = async ( unreadNotLoaded?: number; }; }; + +/** + * Accepts a room invite for the authenticated user. + * + * Processes a room invitation by accepting it, which grants the user + * access to the room. This is essential for federated room workflows + * where users receive invitations rather than auto-joining. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the acceptance response + */ +export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise<{ success: boolean }>((resolve) => { + void requestInstance + .post(api('rooms.invite')) + .set(credentialsInstance) + .send({ + roomId, + action: 'accept', + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; \ No newline at end of file diff --git a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts index 97c953009152a..48d85344749a2 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts @@ -7,6 +7,7 @@ import { findRoomMember, addUserToRoom, addUserToRoomSlashCommand, + acceptRoomInvite, } from '../../../../../apps/meteor/tests/data/rooms.helper'; import { type IRequestConfig, getRequestConfig, createUser, deleteUser } from '../../../../../apps/meteor/tests/data/users.helper'; import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; @@ -380,21 +381,21 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the system message that the user added', async () => { + it('It should show the system message that the user was invited', async () => { // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol // Get the room history to find the system message const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for a system message about the user joining - // System messages typically have t: 'uj' (user joined) and the msg contains the username - const joinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + // Look for a system message about the user being invited + // Members added during room creation are invited (status: 'INVITED'), not auto-joined + const inviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - expect(joinMessage).toBeDefined(); - expect(joinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - expect(joinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + expect(inviteMessage).toBeDefined(); + expect(inviteMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(inviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); }); }); @@ -497,29 +498,55 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1User1InSynapseUser1).not.toBeNull(); }); - it('It should show the system messages that the user added on all RCs involved', async () => { + it('It should show the system messages that the users were invited', async () => { // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users joining - // System messages typically have t: 'uj' (user joined) and the msg contains the username - const adminJoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + // Look for system messages about both users being invited + // Members added during room creation are invited (status: 'INVITED'), not auto-joined + const adminInviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - const hs1User1JoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.additionalUser1.matrixUserId, + const hs1User1InviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.additionalUser1.matrixUserId, ); - expect(adminJoinMessage).toBeDefined(); - expect(adminJoinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - expect(adminJoinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + expect(adminInviteMessage).toBeDefined(); + expect(adminInviteMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminInviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); - expect(hs1User1JoinMessage).toBeDefined(); - expect(hs1User1JoinMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); - expect(hs1User1JoinMessage?.u?.username).toBe(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1InviteMessage).toBeDefined(); + expect(hs1User1InviteMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1InviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); + }); + + it('It should show the system messages that the users joined when they accept the invites', async () => { + // RC view: Check in RC. We don't check in Synapse because this is not part of the protocol + // Get the room history to find the system messages + const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); + expect(Array.isArray(historyResponse.messages)).toBe(true); + + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const adminJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + ); + + const hs1User1JoinedMessage = historyResponse.messages.find( + (message: IMessage) => + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + ); + + expect(adminJoinedMessage).toBeDefined(); + expect(adminJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinedMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + + expect(hs1User1JoinedMessage).toBeDefined(); + expect(hs1User1JoinedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinedMessage?.u?.username).toBe(federationConfig.hs1.additionalUser1.matrixUserId); }); }); @@ -554,9 +581,12 @@ import { SynapseClient } from '../helper/synapse-client'; expect(federatedChannel).toHaveProperty('federation'); expect((federatedChannel as any).federation).toHaveProperty('version', 1); - // Accept invitation for the federated user (local user is already added automatically) + // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); + + // Accept invitation for the local user (rc1User1) + await acceptRoomInvite(federatedChannel._id, rc1User1RequestConfig); }, 15000); it('It should show the room on the remote Element or RC and local for the second user', async () => { @@ -638,42 +668,43 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the 2 system messages that the user added', async () => { - // RC view: Check in RC (admin view) for system messages about both users joining + it('It should show the 2 system messages that the users were invited', async () => { + // RC view: Check in RC (admin view) for system messages about both users being invited const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users joining - const localUserJoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, + // Look for system messages about both users being invited + // Members added during room creation are invited (status: 'INVITED'), not auto-joined + const localUserInviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, ); - const federatedUserJoinMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + const federatedUserInviteMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - expect(localUserJoinMessage).toBeDefined(); - expect(localUserJoinMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); - expect(localUserJoinMessage?.u?.username).toBe(federationConfig.rc1.additionalUser1.username); + expect(localUserInviteMessage).toBeDefined(); + expect(localUserInviteMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserInviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); - expect(federatedUserJoinMessage).toBeDefined(); - expect(federatedUserJoinMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - expect(federatedUserJoinMessage?.u?.username).toBe(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserInviteMessage).toBeDefined(); + expect(federatedUserInviteMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserInviteMessage?.u?.username).toBe(federationConfig.rc1.adminUser); - // RC view: Check in RC (user1 view) for system messages about both users joining + // RC view: Check in RC (user1 view) for system messages about both users being invited const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); expect(Array.isArray(historyResponseUser1.messages)).toBe(true); - const localUserJoinMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, + const localUserInviteMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.rc1.additionalUser1.username, ); - const federatedUserJoinMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'uj' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, + const federatedUserInviteMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'ui' && message.msg && message.msg === federationConfig.hs1.adminMatrixUserId, ); - expect(localUserJoinMessageUser1).toBeDefined(); - expect(federatedUserJoinMessageUser1).toBeDefined(); + expect(localUserInviteMessageUser1).toBeDefined(); + expect(federatedUserInviteMessageUser1).toBeDefined(); }); }); }); @@ -766,20 +797,20 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the system message that the user added', async () => { + it('It should show the system message that the user joined', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about the user being added - // look for 'au' (added user) message types - const addedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about the user joining after accepting invite + // look for 'uj' (user joined) message types + const joinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(addedMessage).toBeDefined(); - expect(addedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(joinedMessage).toBeDefined(); + expect(joinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); @@ -893,29 +924,29 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1User1InSynapseUser1).not.toBeNull(); }); - it('It should show the system messages that the user added on all RCs involved', async () => { + it('It should show the system messages that the users joined', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users being added - // 'au' (added user) message types - const adminAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const adminJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(adminAddedMessage).toBeDefined(); - expect(adminAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinedMessage).toBeDefined(); + expect(adminJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - // Look for 'au' (added user) message types - const hs1User1AddedMessage = historyResponse.messages.find( + // Look for 'uj' (user joined) message types + const hs1User1JoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), ); - expect(hs1User1AddedMessage).toBeDefined(); - expect(hs1User1AddedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinedMessage).toBeDefined(); + expect(hs1User1JoinedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); }); }); @@ -959,9 +990,12 @@ import { SynapseClient } from '../helper/synapse-client'; expect(addUserResponse.body).toHaveProperty('success', true); - // Accept invitation for the federated user (local user is added automatically) + // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); + + // Accept invitation for the local user (rc1User1) + await acceptRoomInvite(federatedChannel._id, rc1User1RequestConfig); }, 15000); it('It should show the room on the remote Element or RC and local for the second user', async () => { @@ -1043,46 +1077,46 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the 2 system messages that the user added', async () => { + it('It should show the 2 system messages that the user joined', async () => { // RC view: Check in RC (admin view) for system messages about both users joining const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // 'au' (added user) message types - const localUserAddedMessage = historyResponse.messages.find( + // 'uj' (user joined) message types + const localUserJoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - expect(localUserAddedMessage).toBeDefined(); - expect(localUserAddedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessage).toBeDefined(); + expect(localUserJoinedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); - const federatedUserAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(federatedUserAddedMessage).toBeDefined(); - expect(federatedUserAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessage).toBeDefined(); + expect(federatedUserJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - // RC view: Check in RC (user1 view) for system messages about both users being added + // RC view: Check in RC (user1 view) for system messages about both users joining const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); expect(Array.isArray(historyResponseUser1.messages)).toBe(true); - // Look for 'au' (added user) message types - const localUserAddedMessageUser1 = historyResponseUser1.messages.find( + // Look for 'uj' (user joined) message types + const localUserJoinedMessageUser1 = historyResponseUser1.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - expect(localUserAddedMessageUser1).toBeDefined(); - expect(localUserAddedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessageUser1).toBeDefined(); + expect(localUserJoinedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); - const federatedUserAddedMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(federatedUserAddedMessageUser1).toBeDefined(); - expect(federatedUserAddedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessageUser1).toBeDefined(); + expect(federatedUserJoinedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); }); @@ -1174,20 +1208,20 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the system message that the user added', async () => { + it('It should show the system message that the user joined', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about the user being added - // 'au' (added user) message types - const addedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about the user joining after accepting invite + // 'uj' (user joined) message types + const joinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(addedMessage).toBeDefined(); - expect(addedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(joinedMessage).toBeDefined(); + expect(joinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); @@ -1301,28 +1335,28 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1User1InSynapseUser1).not.toBeNull(); }); - it('It should show the system messages that the user added on all RCs involved', async () => { + it('It should show the system messages that the user joined on all RCs involved', async () => { // RC view: Check in RC // Get the room history to find the system messages const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users being added - // 'au' (added user) message types - const adminAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const adminJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(adminAddedMessage).toBeDefined(); - expect(adminAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(adminJoinedMessage).toBeDefined(); + expect(adminJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - const hs1User1AddedMessage = historyResponse.messages.find( + const hs1User1JoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.additionalUser1.matrixUserId), ); - expect(hs1User1AddedMessage).toBeDefined(); - expect(hs1User1AddedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); + expect(hs1User1JoinedMessage).toBeDefined(); + expect(hs1User1JoinedMessage?.msg).toContain(federationConfig.hs1.additionalUser1.matrixUserId); }); }); @@ -1366,9 +1400,12 @@ import { SynapseClient } from '../helper/synapse-client'; expect(addUserResponse.body).toHaveProperty('success', true); - // Accept invitation for the federated user (local user is added automatically) + // Accept invitation for the federated user const acceptedRoomId = await hs1AdminApp.acceptInvitationForRoomName(channelName); expect(acceptedRoomId).not.toBe(''); + + // Accept invitation for the local user (rc1User1) + await acceptRoomInvite(federatedChannel._id, rc1User1RequestConfig); }, 15000); it('It should show the room on the remote Element or RC and local for the second user', async () => { @@ -1450,47 +1487,47 @@ import { SynapseClient } from '../helper/synapse-client'; expect(hs1AdminUserInSynapse).not.toBeNull(); }); - it('It should show the 2 system messages that the user added', async () => { + it('It should show the 2 system messages that the user joined', async () => { // RC view: Check in RC (admin view) for system messages about both users joining const historyResponse = await getGroupHistory(federatedChannel._id, rc1AdminRequestConfig); expect(Array.isArray(historyResponse.messages)).toBe(true); - // Look for system messages about both users joining - // 'au' (added user) message types - const localUserAddedMessage = historyResponse.messages.find( + // Look for system messages about both users joining after accepting invites + // 'uj' (user joined) message types + const localUserJoinedMessage = historyResponse.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - expect(localUserAddedMessage).toBeDefined(); - expect(localUserAddedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessage).toBeDefined(); + expect(localUserJoinedMessage?.msg).toContain(federationConfig.rc1.additionalUser1.username); - const federatedUserAddedMessage = historyResponse.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessage = historyResponse.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(federatedUserAddedMessage).toBeDefined(); - expect(federatedUserAddedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessage).toBeDefined(); + expect(federatedUserJoinedMessage?.msg).toContain(federationConfig.hs1.adminMatrixUserId); - // RC view: Check in RC (user1 view) for system messages about both users being added + // RC view: Check in RC (user1 view) for system messages about both users joining const historyResponseUser1 = await getGroupHistory(federatedChannel._id, rc1User1RequestConfig); expect(Array.isArray(historyResponseUser1.messages)).toBe(true); - // Look for 'au' (added user) message types - const localUserAddedMessageUser1 = historyResponseUser1.messages.find( + // Look for 'uj' (user joined) message types + const localUserJoinedMessageUser1 = historyResponseUser1.messages.find( (message: IMessage) => - message.t === 'au' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), + message.t === 'uj' && message.msg && message.msg.includes(federationConfig.rc1.additionalUser1.username), ); - const federatedUserAddedMessageUser1 = historyResponseUser1.messages.find( - (message: IMessage) => message.t === 'au' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), + const federatedUserJoinedMessageUser1 = historyResponseUser1.messages.find( + (message: IMessage) => message.t === 'uj' && message.msg && message.msg.includes(federationConfig.hs1.adminMatrixUserId), ); - expect(localUserAddedMessageUser1).toBeDefined(); - expect(localUserAddedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); + expect(localUserJoinedMessageUser1).toBeDefined(); + expect(localUserJoinedMessageUser1?.msg).toContain(federationConfig.rc1.additionalUser1.username); - expect(federatedUserAddedMessageUser1).toBeDefined(); - expect(federatedUserAddedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); + expect(federatedUserJoinedMessageUser1).toBeDefined(); + expect(federatedUserJoinedMessageUser1?.msg).toContain(federationConfig.hs1.adminMatrixUserId); }); }); }); From 7522d3e9c3972ea7cba6dd2efae8b92f1a9228fe Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 2 Dec 2025 17:57:35 -0300 Subject: [PATCH 47/72] bump @rocket.chat/federation-sdk --- apps/meteor/package.json | 2 +- ee/packages/federation-matrix/package.json | 2 +- packages/core-services/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 983981fd0a7b3..8fc0c859861e6 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -98,7 +98,7 @@ "@rocket.chat/emitter": "~0.31.25", "@rocket.chat/favicon": "workspace:^", "@rocket.chat/federation-matrix": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.2", + "@rocket.chat/federation-sdk": "0.3.3", "@rocket.chat/freeswitch": "workspace:^", "@rocket.chat/fuselage": "^0.69.0", "@rocket.chat/fuselage-forms": "~0.1.1", diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index f28cf3f57451a..8b7b9c68adc2a 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -22,7 +22,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", - "@rocket.chat/federation-sdk": "0.3.2", + "@rocket.chat/federation-sdk": "0.3.3", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/packages/core-services/package.json b/packages/core-services/package.json index dab350f0cde5d..70ddd59351cd2 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.3.2", + "@rocket.chat/federation-sdk": "0.3.3", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "~0.45.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 809a8eb8120c3..09e32ee7ae991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8284,7 +8284,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.2" + "@rocket.chat/federation-sdk": "npm:0.3.3" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:~0.45.0" "@rocket.chat/jest-presets": "workspace:~" @@ -8495,7 +8495,7 @@ __metadata: "@rocket.chat/ddp-client": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.2" + "@rocket.chat/federation-sdk": "npm:0.3.3" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -8521,9 +8521,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.3.2": - version: 0.3.2 - resolution: "@rocket.chat/federation-sdk@npm:0.3.2" +"@rocket.chat/federation-sdk@npm:0.3.3": + version: 0.3.3 + resolution: "@rocket.chat/federation-sdk@npm:0.3.3" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.5" "@noble/ed25519": "npm:^3.0.0" @@ -8536,7 +8536,7 @@ __metadata: zod: "npm:^3.24.1" peerDependencies: typescript: ~5.9.2 - checksum: 10/53c0179437425b731a5f77792ee8bf271526499474db4f9e1fdb946ef3b2697a4fd6feceec8d9576f50619ffade51d2ef1bd0cf3f8200024ceb9dcc944a4c561 + checksum: 10/e93f0d59da8508ee0ecfb53f6d599f93130ab4b9f651d87da77615355261e4852d6f4659aae9f4c8881cf4b26a628512e6363ecc407d6e01a31a27d20b9d7684 languageName: node linkType: hard @@ -9187,7 +9187,7 @@ __metadata: "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/favicon": "workspace:^" "@rocket.chat/federation-matrix": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.3.2" + "@rocket.chat/federation-sdk": "npm:0.3.3" "@rocket.chat/freeswitch": "workspace:^" "@rocket.chat/fuselage": "npm:^0.69.0" "@rocket.chat/fuselage-forms": "npm:~0.1.1" From ab21ff74bfde240a8e140d97713d43d2fa1c28f4 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 2 Dec 2025 18:24:40 -0300 Subject: [PATCH 48/72] remove acceptRoomInvite from Room service --- apps/meteor/app/lib/server/functions/acceptRoomInvite.ts | 4 +--- apps/meteor/app/lib/server/functions/addUserToRoom.ts | 1 - apps/meteor/server/services/room/service.ts | 6 +----- apps/meteor/tests/data/rooms.helper.ts | 2 +- packages/core-services/src/types/IRoomService.ts | 1 - 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index a8d2ad03441e8..7a9cedfb92561 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -18,9 +18,7 @@ export const performAcceptRoomInvite = async ( user: IUser & { username: string }, ): Promise => { if (subscription.status !== 'INVITED') { - throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { - method: 'acceptRoomInvite', - }); + throw new Meteor.Error('error-not-invited', 'User was not invited to this room'); } await callbacks.run('beforeJoinRoom', user, room); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index e40d4e441f1fa..0ec42e0de248d 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -143,7 +143,6 @@ export const performAddUserToRoom = async ( } } - if (room.teamMain && room.teamId) { await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId); } diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 128cb0fb22161..25b5a7a9c79e4 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -7,7 +7,7 @@ import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { FederationActions } from './hooks/BeforeFederationActions'; import { saveRoomName } from '../../../app/channel-settings/server'; import { saveRoomTopic } from '../../../app/channel-settings/server/functions/saveRoomTopic'; -import { acceptRoomInvite, performAcceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; +import { performAcceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; import { addUserToRoom, performAddUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import import { removeUserFromRoom, performUserRemoval } from '../../../app/lib/server/functions/removeUserFromRoom'; @@ -102,10 +102,6 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return performUserRemoval(roomId, user, options); } - async acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { - return acceptRoomInvite(room, subscription, user); - } - async performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { return performAcceptRoomInvite(room, subscription, user); } diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts index 0017a0a128340..eb4a722c64f12 100644 --- a/apps/meteor/tests/data/rooms.helper.ts +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -452,4 +452,4 @@ export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) resolve(req.body); }); }); -}; \ No newline at end of file +}; diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index a2cb9d463e2e5..ffadd4f65e3cf 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -57,7 +57,6 @@ export interface IRoomService { ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; performUserRemoval(roomId: string, user: IUser, options?: { byUser?: IUser }): Promise; - acceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( From 2433bf19e62e0b78c9090ab5848cbe1e6422a52a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Wed, 3 Dec 2025 17:06:02 -0300 Subject: [PATCH 49/72] test: ensure accept/reject actions can only be performed by the invited user --- apps/meteor/tests/data/rooms.helper.ts | 31 +++++++++++++- .../federation-matrix/src/FederationMatrix.ts | 2 +- .../tests/end-to-end/room.spec.ts | 42 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/meteor/tests/data/rooms.helper.ts b/apps/meteor/tests/data/rooms.helper.ts index eb4a722c64f12..32c033050d80f 100644 --- a/apps/meteor/tests/data/rooms.helper.ts +++ b/apps/meteor/tests/data/rooms.helper.ts @@ -440,7 +440,7 @@ export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) const requestInstance = config?.request || request; const credentialsInstance = config?.credentials || credentials; - return new Promise<{ success: boolean }>((resolve) => { + return new Promise<{ success: boolean; error?: string }>((resolve) => { void requestInstance .post(api('rooms.invite')) .set(credentialsInstance) @@ -453,3 +453,32 @@ export const acceptRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) }); }); }; + +/** + * Rejects a room invite for the authenticated user. + * + * Processes a room invitation by rejecting it, which prevents the user + * from joining the room and removes them from the invited members list. + * This is essential for federated room workflows where users can decline invitations. + * + * @param roomId - The unique identifier of the room + * @param config - Optional request configuration for custom domains + * @returns Promise resolving to the rejection response + */ +export const rejectRoomInvite = (roomId: IRoom['_id'], config?: IRequestConfig) => { + const requestInstance = config?.request || request; + const credentialsInstance = config?.credentials || credentials; + + return new Promise<{ success: boolean; error?: string }>((resolve) => { + void requestInstance + .post(api('rooms.invite')) + .set(credentialsInstance) + .send({ + roomId, + action: 'reject', + }) + .end((_err: any, req: any) => { + resolve(req.body); + }); + }); +}; diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index db92eaec2dc58..e67d376a9c1f9 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -897,7 +897,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS async handleInvite(roomId: IRoom['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { const subscription = await Subscriptions.findInvitedSubscription(roomId, userId); if (!subscription) { - throw new Error('User does not have a pending invite for this room'); + throw new Error('No subscription found or user does not have permission to accept or reject this invite'); } const room = await Rooms.findOneById(roomId); diff --git a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts index 48d85344749a2..f29586ebd5608 100644 --- a/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts +++ b/ee/packages/federation-matrix/tests/end-to-end/room.spec.ts @@ -8,6 +8,7 @@ import { addUserToRoom, addUserToRoomSlashCommand, acceptRoomInvite, + rejectRoomInvite, } from '../../../../../apps/meteor/tests/data/rooms.helper'; import { type IRequestConfig, getRequestConfig, createUser, deleteUser } from '../../../../../apps/meteor/tests/data/users.helper'; import { IS_EE } from '../../../../../apps/meteor/tests/e2e/config/constants'; @@ -1532,5 +1533,46 @@ import { SynapseClient } from '../helper/synapse-client'; }); }); }); + + describe('Accept/Reject invitation permissions', () => { + describe('User tries to accept another user invitation', () => { + let channelName: string; + let federatedChannel: any; + + beforeAll(async () => { + channelName = `federated-channel-accept-permission-${Date.now()}`; + const createResponse = await createRoom({ + type: 'p', + name: channelName, + members: [federationConfig.rc1.additionalUser1.username], + extraData: { + federated: true, + }, + config: rc1AdminRequestConfig, + }); + + federatedChannel = createResponse.body.group; + + expect(federatedChannel).toHaveProperty('_id'); + expect(federatedChannel).toHaveProperty('name', channelName); + expect(federatedChannel).toHaveProperty('t', 'p'); + expect(federatedChannel).toHaveProperty('federated', true); + }, 10000); + + it('It should not allow admin to accept invitation on behalf of another user', async () => { + // RC view: Admin tries to accept rc1User1's invitation + const response = await acceptRoomInvite(federatedChannel._id, rc1AdminRequestConfig); + expect(response.success).toBe(false); + expect(response.error).toBe('Failed to handle invite: No subscription found or user does not have permission to accept or reject this invite'); + }); + + it('It should not allow admin to reject invitation on behalf of another user', async () => { + // RC view: Admin tries to reject rc1User1's invitation + const response = await rejectRoomInvite(federatedChannel._id, rc1AdminRequestConfig); + expect(response.success).toBe(false); + expect(response.error).toBe('Failed to handle invite: No subscription found or user does not have permission to accept or reject this invite'); + }); + }); + }); }); }); From b215fbeeb2e91bb889b51847cccdc6a99c4583f0 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 4 Dec 2025 17:12:57 -0300 Subject: [PATCH 50/72] fix: room creation with new federated user --- .../app/lib/server/functions/createRoom.ts | 7 ++- .../ee/server/hooks/federation/index.ts | 50 +++++++++++++------ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 62c8dd9c9cb50..bf3daf50277ef 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,6 +1,6 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; +import { FederationMatrix, Message, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; @@ -63,10 +63,13 @@ async function createUsersSubscriptions({ // Invite federated members to the room SYNCRONOUSLY, // since we do not use to invite lots of users at once, this is acceptable. const membersToInvite = members.filter((m) => m !== owner.username); + + await FederationMatrix.ensureFederatedUsersExistLocally(membersToInvite); + for await (const memberUsername of membersToInvite) { const member = await Users.findOneByUsername(memberUsername); if (!member) { - continue; + throw new Error('Federated user not found locally'); } await performAddUserToRoom(room._id, member, owner, { diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 79206ea222fb6..76fcc3cec7d00 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,5 +1,12 @@ import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; -import { isEditedMessage, isUserNativeFederated, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings'; +import { + isEditedMessage, + isRoomNativeFederated, + isUserNativeFederated, + type IMessage, + type IRoom, + type IUser, +} from '@rocket.chat/core-typings'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; @@ -15,21 +22,36 @@ import { FederationActions } from '../../../../server/services/room/hooks/Before // Called BEFORE subscriptions are created - creates Matrix room so invites can be sent. // The invites are sent by beforeAddUserToRoom callback. -callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner }) => { - if (FederationActions.shouldPerformFederationAction(room)) { - const federatedRoomId = room?.federation?.mrid; - if (!federatedRoomId) { - await FederationMatrix.createRoom(room, owner); - } else { - // matrix room was already created and passed - const fromServer = federatedRoomId.split(':')[1]; +callbacks.add('federation.afterCreateFederatedRoom', async (room, { owner, originalMemberList: members }) => { + if (!FederationActions.shouldPerformFederationAction(room)) { + return; + } - await Rooms.setAsFederated(room._id, { - mrid: federatedRoomId, - origin: fromServer, - }); - } + const federatedRoomId = room?.federation?.mrid; + if (!federatedRoomId) { + await FederationMatrix.createRoom(room, owner); + } else { + // TODO unify how to get server + // matrix room was already created and passed + const fromServer = federatedRoomId.split(':')[1]; + + await Rooms.setAsFederated(room._id, { + mrid: federatedRoomId, + origin: fromServer, + }); + } + + const federationRoom = await Rooms.findOneById(room._id); + if (!federationRoom || !isRoomNativeFederated(federationRoom)) { + throw new MeteorError('error-invalid-room', 'Invalid room'); } + + // TODO this won't be neeeded once we receive all state events at ee/packages/federation-matrix/src/events/member.ts + await FederationMatrix.inviteUsersToRoom( + federationRoom, + members.filter((member) => member !== owner.username), + owner, + ); }); callbacks.add( From fc4849d5118cb168327474d9e8500a02c2d843f6 Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 4 Dec 2025 17:26:25 -0300 Subject: [PATCH 51/72] fix: get right userId from homeserver.matrix.room.name processing --- ee/packages/federation-matrix/src/events/room.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/federation-matrix/src/events/room.ts b/ee/packages/federation-matrix/src/events/room.ts index 7eb0a0ffdc46f..45668831e52f8 100644 --- a/ee/packages/federation-matrix/src/events/room.ts +++ b/ee/packages/federation-matrix/src/events/room.ts @@ -10,7 +10,7 @@ export function room(emitter: Emitter) { const { room_id: roomId, content: { name }, - state_key: userId, + sender: userId, } = event; const localRoomId = await Rooms.findOne({ 'federation.mrid': roomId }, { projection: { _id: 1 } }); From 6570a8301ec77aa15eff56da2304db26b2dfb26a Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 4 Dec 2025 19:46:04 -0300 Subject: [PATCH 52/72] use creator user id --- apps/meteor/app/lib/server/functions/createDirectRoom.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 3ddc873281c36..dd634fdf0890d 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -155,6 +155,8 @@ export async function createDirectRoom( { projection: { 'username': 1, 'settings.preferences': 1 } }, ).toArray(); + const creatorUser = options?.creator ? roomMembers.find((member) => member._id === options?.creator) : undefined; + for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); @@ -162,7 +164,7 @@ export async function createDirectRoom( roomExtraData.federated && options?.creator !== member._id ? { status: 'INVITED', - inviterUsername: options?.creator, + inviterUsername: creatorUser?.username, } : {}; From 0e6deb691100ad898c7171bd4d0be49a5e7c30bb Mon Sep 17 00:00:00 2001 From: Ricardo Garim Date: Thu, 4 Dec 2025 20:40:40 -0300 Subject: [PATCH 53/72] set open, unread and userMentions to newly created DMs --- apps/meteor/app/lib/server/functions/createDirectRoom.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index dd634fdf0890d..6a40a97d90a4e 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -165,6 +165,9 @@ export async function createDirectRoom( ? { status: 'INVITED', inviterUsername: creatorUser?.username, + open: true, + unread: 1, + userMentions: 1, } : {}; From 6ad78040e350a4492aaf1af361b747d7099ee5ed Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 5 Dec 2025 20:07:06 -0300 Subject: [PATCH 54/72] move logic to create subscription --- .../lib/server/functions/acceptRoomInvite.ts | 44 +++--- .../app/lib/server/functions/addUserToRoom.ts | 135 ++++-------------- .../app/lib/server/functions/createRoom.ts | 9 +- .../app/lib/server/methods/addUsersToRoom.ts | 20 +-- apps/meteor/server/services/room/service.ts | 90 +++++++++--- .../federation-matrix/src/events/member.ts | 16 ++- .../core-services/src/types/IRoomService.ts | 22 ++- 7 files changed, 167 insertions(+), 169 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index 7a9cedfb92561..f86f0943b7dbd 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -1,6 +1,8 @@ +import { Apps, AppEvents } from '@rocket.chat/apps'; +import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message } from '@rocket.chat/core-services'; import type { IUser, IRoom, ISubscription } from '@rocket.chat/core-typings'; -import { Subscriptions } from '@rocket.chat/models'; +import { Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../../lib/callbacks'; @@ -17,32 +19,40 @@ export const performAcceptRoomInvite = async ( subscription: ISubscription, user: IUser & { username: string }, ): Promise => { - if (subscription.status !== 'INVITED') { - throw new Meteor.Error('error-not-invited', 'User was not invited to this room'); + if (subscription.status !== 'INVITED' || !subscription.inviterUsername) { + throw new Meteor.Error('error-not-invited', `User was not invited to this room ${subscription.status}`); } + const inviter = await Users.findOneByUsername(subscription.inviterUsername); await callbacks.run('beforeJoinRoom', user, room); + await callbacks.run('beforeAddedToRoom', { user, inviter }, room); + + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserJoined, room, user, inviter); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } + + throw error; + } + await Subscriptions.markInviteAsAccepted(subscription._id); void notifyOnSubscriptionChangedById(subscription._id, 'updated'); await Message.saveSystemMessage('uj', room._id, user.username, user); -}; -/** - * Accepts a room invite initiated locally - via UI or API calls - performing full - * database updates and triggering all standard callbacks. These callbacks are - * expected to propagate normally to other parts of the system. - */ -export const acceptRoomInvite = async (room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise => { - if (subscription.status !== 'INVITED') { - throw new Meteor.Error('error-not-invited', 'User was not invited to this room', { - method: 'acceptRoomInvite', - }); - } + if (room.t === 'c' || room.t === 'p') { + process.nextTick(async () => { + // Add a new event, with an optional inviter + await callbacks.run('afterAddedToRoom', { user, inviter }, room); - await performAcceptRoomInvite(room, subscription, user); + // Keep the current event + await callbacks.run('afterJoinRoom', user, room); - await callbacks.run('afterJoinRoom', user, room); + void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, user, inviter); + }); + } }; diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 0ec42e0de248d..af4f39ec86b78 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -1,25 +1,23 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { Message, Team } from '@rocket.chat/core-services'; -import { type IUser, type SubscriptionStatus } from '@rocket.chat/core-typings'; +import { Team, Room } from '@rocket.chat/core-services'; +import { type IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { callbacks } from '../../../../lib/callbacks'; import { beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; -import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { settings } from '../../../settings/server'; -import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; -import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener'; +import { notifyOnRoomChangedById } from '../lib/notifyListener'; /** - * Adds a user to a room when triggered by internal events such as federation - * or third-party callbacks. Performs the required database operations and fires - * only safe callbacks to avoid propagation loops during external event handling. + * This function adds user to the given room. + * Caution - It does not validates if the user has permission to join room */ -export const performAddUserToRoom = async ( + +export const addUserToRoom = async ( rid: string, user: Pick, inviter?: Pick, @@ -27,14 +25,10 @@ export const performAddUserToRoom = async ( skipSystemMessage, skipAlertSound, createAsHidden = false, - status, - inviterUsername, }: { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; - status?: SubscriptionStatus; - inviterUsername?: string; } = {}, ): Promise => { const now = new Date(); @@ -42,7 +36,7 @@ export const performAddUserToRoom = async ( if (!room) { throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'performAddUserToRoom', + method: 'addUserToRoom', }); } @@ -53,8 +47,9 @@ export const performAddUserToRoom = async ( throw new Meteor.Error('user-not-found'); } - const existingSubscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); - if (existingSubscription || !userToBeAdded) { + // Check if user is already in room + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); + if (subscription || !userToBeAdded) { return; } @@ -93,57 +88,29 @@ export const performAddUserToRoom = async ( await callbacks.run('beforeJoinRoom', userToBeAdded, room); } - const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); - - const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded as IUser, { + await Room.createUserSubscription({ + room, ts: now, - open: !createAsHidden, - alert: createAsHidden ? false : !skipAlertSound, - unread: 1, - userMentions: 1, - groupMentions: 0, - ...(status && { status }), - ...(inviterUsername && { inviterUsername }), - ...autoTranslateConfig, - ...getDefaultSubscriptionPref(userToBeAdded as IUser), + userToBeAdded, + createAsHidden, + skipAlertSound, + skipSystemMessage, }); - if (insertedId) { - void notifyOnSubscriptionChangedById(insertedId, 'inserted'); - } + if (room.t === 'c' || room.t === 'p') { + process.nextTick(async () => { + // Add a new event, with an optional inviter + await callbacks.run('afterAddedToRoom', { user: userToBeAdded, inviter }, room); - if (!userToBeAdded.username) { - throw new Meteor.Error('error-invalid-user', 'Cannot add an user to a room without a username'); - } + // Keep the current event + await callbacks.run('afterJoinRoom', userToBeAdded, room); - if (!skipSystemMessage) { - if (inviter) { - const extraData = { - ts: now, - u: { - _id: inviter._id, - username: inviter.username, - }, - }; - if (room.teamMain) { - await Message.saveSystemMessage('added-user-to-team', rid, userToBeAdded.username, userToBeAdded, extraData); - } else if (status === 'INVITED') { - await Message.saveSystemMessage('ui', rid, userToBeAdded.username, userToBeAdded, { - u: { _id: inviter._id, username: inviter.username }, - }); - } else { - await Message.saveSystemMessage('au', rid, userToBeAdded.username, userToBeAdded, extraData); - } - } else if (room.prid) { - await Message.saveSystemMessage('ut', rid, userToBeAdded.username, userToBeAdded, { ts: now }); - } else if (room.teamMain) { - await Message.saveSystemMessage('ujt', rid, userToBeAdded.username, userToBeAdded, { ts: now }); - } else { - await Message.saveSystemMessage('uj', rid, userToBeAdded.username, userToBeAdded, { ts: now }); - } + void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); + }); } if (room.teamMain && room.teamId) { + // if user is joining to main team channel, create a membership await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId); } @@ -152,55 +119,5 @@ export const performAddUserToRoom = async ( } void notifyOnRoomChangedById(rid); - return true; }; - -/** - * Adds a user to the specified room by performing database updates and triggering - * all standard callbacks. Note: This function does not validate whether the user - * has permission to join the room. - */ -export const addUserToRoom = async ( - rid: string, - user: Pick, - inviter?: Pick, - options: { - skipSystemMessage?: boolean; - skipAlertSound?: boolean; - createAsHidden?: boolean; - status?: SubscriptionStatus; - inviterUsername?: string; - } = {}, -): Promise => { - const room = await Rooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { - method: 'addUserToRoom', - }); - } - - const userToBeAdded = await Users.findOneById(user._id); - if (!userToBeAdded) { - throw new Meteor.Error('user-not-found'); - } - - const result = await performAddUserToRoom(rid, user, inviter, options); - if (!result) { - return; - } - - if (room.t === 'c' || room.t === 'p') { - process.nextTick(async () => { - // Add a new event, with an optional inviter - await callbacks.run('afterAddedToRoom', { user: userToBeAdded, inviter }, room); - - // Keep the current event - await callbacks.run('afterJoinRoom', userToBeAdded, room); - - void Apps.self?.triggerEvent(AppEvents.IPostRoomUserJoined, room, userToBeAdded, inviter); - }); - } - - return result; -}; diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index bf3daf50277ef..bb087abd4fbd5 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -1,6 +1,6 @@ import { AppEvents, Apps } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; -import { FederationMatrix, Message, Team } from '@rocket.chat/core-services'; +import { FederationMatrix, Message, Room, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; @@ -72,9 +72,12 @@ async function createUsersSubscriptions({ throw new Error('Federated user not found locally'); } - await performAddUserToRoom(room._id, member, owner, { + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: member, + inviter: owner, status: 'INVITED', - inviterUsername: owner.username, }); } diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 22359e4abc58d..1ae756410cdc4 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,5 +1,5 @@ -import { api } from '@rocket.chat/core-services'; -import type { IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; +import { api, Room } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; @@ -107,15 +107,19 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - let inviteOptions: { status?: SubscriptionStatus; inviterUsername?: string } = {}; - if (isRoomNativeFederated(room) && user && newUser.username) { - inviteOptions = { + // for federation rooms we just invite + if (isRoomNativeFederated(room)) { + await Room.createUserSubscription({ + room, + inviter: user, + ts: new Date(), + userToBeAdded: newUser, status: 'INVITED', - inviterUsername: user.username, - }; + }); + return; } - return addUserToRoom(data.rid, newUser, user, inviteOptions); + return addUserToRoom(data.rid, newUser, user); } if (!newUser.username) { return; diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 25b5a7a9c79e4..fe516fec24067 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -1,4 +1,4 @@ -import { ServiceClassInternal, Authorization, MeteorError } from '@rocket.chat/core-services'; +import { ServiceClassInternal, Authorization, Message, MeteorError } from '@rocket.chat/core-services'; import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services'; import { isOmnichannelRoom, isRoomWithJoinCode } from '@rocket.chat/core-typings'; import type { ISubscription, AtLeast, IRoom, IUser } from '@rocket.chat/core-typings'; @@ -8,11 +8,14 @@ import { FederationActions } from './hooks/BeforeFederationActions'; import { saveRoomName } from '../../../app/channel-settings/server'; import { saveRoomTopic } from '../../../app/channel-settings/server/functions/saveRoomTopic'; import { performAcceptRoomInvite } from '../../../app/lib/server/functions/acceptRoomInvite'; -import { addUserToRoom, performAddUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; +import { addUserToRoom } from '../../../app/lib/server/functions/addUserToRoom'; import { createRoom } from '../../../app/lib/server/functions/createRoom'; // TODO remove this import import { removeUserFromRoom, performUserRemoval } from '../../../app/lib/server/functions/removeUserFromRoom'; +import { notifyOnSubscriptionChangedById } from '../../../app/lib/server/lib/notifyListener'; +import { getDefaultSubscriptionPref } from '../../../app/utils/lib/getDefaultSubscriptionPref'; import { getValidRoomName } from '../../../app/utils/server/lib/getValidRoomName'; import { RoomMemberActions } from '../../../definition/IRoomTypeConfig'; +import { getSubscriptionAutotranslateDefaultConfig } from '../../lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { addRoomLeader } from '../../methods/addRoomLeader'; import { addRoomModerator } from '../../methods/addRoomModerator'; @@ -79,21 +82,6 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return addUserToRoom(roomId, user, inviter, options); } - async performAddUserToRoom( - roomId: string, - user: Pick, - inviter?: Pick, - options?: { - skipSystemMessage?: boolean; - skipAlertSound?: boolean; - createAsHidden?: boolean; - status?: ISubscription['status']; - inviterUsername?: string; - }, - ): Promise { - return performAddUserToRoom(roomId, user, inviter, options); - } - async removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: IUser }): Promise { return removeUserFromRoom(roomId, user, options); } @@ -231,4 +219,72 @@ export class RoomService extends ServiceClassInternal implements IRoomService { } } } + + async createUserSubscription({ + room, + ts, + userToBeAdded, + inviter, + createAsHidden = false, + skipAlertSound = false, + skipSystemMessage = false, + status, + }: { + room: IRoom; + ts: Date; + userToBeAdded: IUser; + inviter?: Pick; + createAsHidden?: boolean; + skipAlertSound?: boolean; + skipSystemMessage?: boolean; + status?: 'INVITED'; + }): Promise { + const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(userToBeAdded); + + const { insertedId } = await Subscriptions.createWithRoomAndUser(room, userToBeAdded, { + ts, + open: !createAsHidden, + alert: createAsHidden ? false : !skipAlertSound, + unread: 1, + userMentions: 1, + groupMentions: 0, + ...(status && { status }), + ...(inviter && { inviterUsername: inviter?.username }), + ...autoTranslateConfig, + ...getDefaultSubscriptionPref(userToBeAdded), + }); + + if (insertedId) { + void notifyOnSubscriptionChangedById(insertedId, 'inserted'); + } + + if (!skipSystemMessage && userToBeAdded.username) { + if (inviter) { + const extraData = { + ts, + u: { + _id: inviter._id, + username: inviter.username, + }, + }; + if (room.teamMain) { + await Message.saveSystemMessage('added-user-to-team', room._id, userToBeAdded.username, userToBeAdded, extraData); + } else if (status === 'INVITED') { + await Message.saveSystemMessage('ui', room._id, userToBeAdded.username, userToBeAdded, { + u: { _id: inviter._id, username: inviter.username }, + }); + } else { + await Message.saveSystemMessage('au', room._id, userToBeAdded.username, userToBeAdded, extraData); + } + } else if (room.prid) { + await Message.saveSystemMessage('ut', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } else if (room.teamMain) { + await Message.saveSystemMessage('ujt', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } else { + await Message.saveSystemMessage('uj', room._id, userToBeAdded.username, userToBeAdded, { ts }); + } + } + + return insertedId; + } } diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index c349d4cada9ae..2e731b0002ed3 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -105,6 +105,8 @@ function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' } } +// TODO on invite we may only want to create the subscription with INVITED status +// everything else should be created on join async function handleInvite({ sender: senderId, state_key: userId, @@ -166,9 +168,12 @@ async function handleInvite({ return; } - await Room.performAddUserToRoom(room._id, inviteeUser, inviterUser, { + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: inviteeUser, + inviter: inviterUser, status: 'INVITED', - inviterUsername: inviterUser.username, }); } @@ -177,7 +182,7 @@ async function handleJoin({ state_key: userId, }: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { const joiningUser = await getOrCreateFederatedUser(userId); - if (!joiningUser || !joiningUser.username) { + if (!joiningUser?.username) { throw new Error(`Failed to get or create joining user: ${userId}`); } @@ -191,6 +196,11 @@ async function handleJoin({ throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`); } + if (!subscription.status) { + logger.info('User is already joined to the room, skipping...'); + return; + } + await Room.performAcceptRoomInvite(room, subscription, joiningUser); } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index ffadd4f65e3cf..b2a881bea2625 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -43,18 +43,6 @@ export interface IRoomService { inviterUsername?: string; }, ): Promise; - performAddUserToRoom( - roomId: string, - user: Pick, - inviter?: Pick, - options?: { - skipSystemMessage?: boolean; - skipAlertSound?: boolean; - createAsHidden?: boolean; - status?: SubscriptionStatus; - inviterUsername?: string; - }, - ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; performUserRemoval(roomId: string, user: IUser, options?: { byUser?: IUser }): Promise; performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; @@ -73,4 +61,14 @@ export interface IRoomService { beforeTopicChange(room: IRoom): Promise; saveRoomName(roomId: string, userId: string, name: string): Promise; addUserRoleRoomScoped(fromUserId: string, userId: string, roomId: string, role: 'moderator' | 'owner' | 'leader' | 'user'): Promise; + createUserSubscription(params: { + room: IRoom; + ts: Date; + userToBeAdded: IUser; + inviter?: Pick; + createAsHidden?: boolean; + skipAlertSound?: boolean; + skipSystemMessage?: boolean; + status?: 'INVITED'; + }): Promise; } From de1665001e3e420451f5caca8ae8be0839a1b14d Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 09:55:18 -0300 Subject: [PATCH 55/72] rename accept subscription model --- apps/meteor/app/lib/server/functions/acceptRoomInvite.ts | 5 ++++- packages/model-typings/src/models/ISubscriptionsModel.ts | 2 +- packages/models/src/models/Subscriptions.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index f86f0943b7dbd..eee3bd0515355 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -14,6 +14,9 @@ import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; * safe callbacks, ensuring no propagation loops are created during external event * processing. */ + +// TODO this funcion is pretty much the same as the one in addUserToRoom.ts, we should probably +// unify them at some point export const performAcceptRoomInvite = async ( room: IRoom, subscription: ISubscription, @@ -38,7 +41,7 @@ export const performAcceptRoomInvite = async ( throw error; } - await Subscriptions.markInviteAsAccepted(subscription._id); + await Subscriptions.acceptInvitationById(subscription._id); void notifyOnSubscriptionChangedById(subscription._id, 'updated'); diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index f59c5eef67720..5196e3dd3457e 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -337,5 +337,5 @@ export interface ISubscriptionsModel extends IBaseModel { countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; findUserFederatedRoomIds(userId: IUser['_id']): AggregationCursor<{ _id: IRoom['_id']; externalRoomId: string }>; findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise | null>; - markInviteAsAccepted(subscriptionId: ISubscription['_id']): Promise; + acceptInvitationById(subscriptionId: ISubscription['_id']): Promise; } diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 2ec250ea6a044..634de619c09fc 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2101,7 +2101,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri ); } - async markInviteAsAccepted(subscriptionId: string): Promise { + async acceptInvitationById(subscriptionId: string): Promise { return this.updateOne( { _id: subscriptionId }, { From 31021a5ed4f9aa199d80799ec18fda293b41ef4d Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 13:38:56 -0300 Subject: [PATCH 56/72] add TODOs --- ee/packages/federation-matrix/src/FederationMatrix.ts | 2 +- ee/packages/federation-matrix/src/events/member.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index e67d376a9c1f9..24d7265f6bcc2 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -553,7 +553,7 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return; } - await federationSDK.inviteUserToRoom( + return federationSDK.inviteUserToRoom( userIdSchema.parse(`@${username}:${this.serverName}`), roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(inviterUserId), diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 2e731b0002ed3..2d94ed266b85e 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -30,7 +30,8 @@ async function getOrCreateFederatedUser(userId: string): Promise { origin: userServerName, }); } catch (error) { - throw new Error(`Error getting or creating federated user ${userId}: ${error}`); + logger.error(error, `Error getting or creating federated user ${userId}`); + throw new Error(`Error getting or creating federated user ${userId}`); } } @@ -57,6 +58,8 @@ async function getOrCreateFederatedRoom({ return room; } + // TODO room creator is not always the inviter + return Room.create(inviterUserId, { type: roomType, name: roomName, @@ -71,7 +74,8 @@ async function getOrCreateFederatedRoom({ }, }); } catch (error) { - throw new Error(`Error getting or creating federated room ${roomName}: ${error}`); + logger.error(error, `Error getting or creating federated room ${roomName}`); + throw new Error(`Error getting or creating federated room ${roomName}`); } } @@ -219,6 +223,8 @@ async function handleLeave({ } await Room.performUserRemoval(room._id, leavingUser); + + // TODO check if there are no pending invites to the room, and if so, delete the room } export function member(emitter: Emitter) { From b3672f6e5cac03a388cb8b926add14240637bfd7 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 13:39:58 -0300 Subject: [PATCH 57/72] send whole room object to user removal --- .../server/functions/removeUserFromRoom.ts | 30 ++++++++----------- apps/meteor/server/services/room/service.ts | 4 +-- .../federation-matrix/src/events/member.ts | 9 ++++-- .../core-services/src/types/IRoomService.ts | 2 +- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index f9addbcedf05e..ee01d451c07ad 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -1,7 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team, Room } from '@rocket.chat/core-services'; -import type { IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -15,20 +15,14 @@ import { notifyOnRoomChangedById, notifyOnSubscriptionChanged } from '../lib/not * Executes only the necessary database operations, with no callbacks, to prevent * propagation loops during external event processing. */ -export const performUserRemoval = async function (rid: string, user: IUser, options?: { byUser?: IUser }): Promise { - const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { +export const performUserRemoval = async function (room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise { + const subscription = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1, status: 1 }, }); if (!subscription) { return; } - const room = await Rooms.findOneById(rid); - - if (!room) { - return; - } - // TODO: move before callbacks to service await beforeLeaveRoomCallback.run(user, room); @@ -40,24 +34,24 @@ export const performUserRemoval = async function (rid: string, user: IUser, opti }; if (room.teamMain) { - await Message.saveSystemMessage('removed-user-from-team', rid, user.username || '', user, extraData); + await Message.saveSystemMessage('removed-user-from-team', room._id, user.username || '', user, extraData); } else { - await Message.saveSystemMessage('ru', rid, user.username || '', user, extraData); + await Message.saveSystemMessage('ru', room._id, user.username || '', user, extraData); } } else if (subscription.status === 'INVITED') { - await Message.saveSystemMessage('uir', rid, removedUser.username || '', removedUser); + await Message.saveSystemMessage('uir', room._id, removedUser.username || '', removedUser); } else if (room.teamMain) { - await Message.saveSystemMessage('ult', rid, removedUser.username || '', removedUser); + await Message.saveSystemMessage('ult', room._id, removedUser.username || '', removedUser); } else { - await Message.saveSystemMessage('ul', rid, removedUser.username || '', removedUser); + await Message.saveSystemMessage('ul', room._id, removedUser.username || '', removedUser); } } if (room.t === 'l') { - await Message.saveSystemMessage('command', rid, 'survey', user); + await Message.saveSystemMessage('command', room._id, 'survey', user); } - const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(room._id, user._id); if (deletedSubscription) { void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); } @@ -70,7 +64,7 @@ export const performUserRemoval = async function (rid: string, user: IUser, opti await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]); } - void notifyOnRoomChangedById(rid); + void notifyOnRoomChangedById(room._id); }; /** @@ -96,7 +90,7 @@ export const removeUserFromRoom = async function (rid: string, user: IUser, opti await Room.beforeLeave(room); - await performUserRemoval(rid, user, options); + await performUserRemoval(room, user, options); await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index fe516fec24067..70d59161323e6 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -86,8 +86,8 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return removeUserFromRoom(roomId, user, options); } - async performUserRemoval(roomId: string, user: IUser, options?: { byUser?: IUser }): Promise { - return performUserRemoval(roomId, user, options); + async performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise { + return performUserRemoval(room, user, options); } async performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser & { username: string }): Promise { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index 2d94ed266b85e..ab426254f012a 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -212,9 +212,12 @@ async function handleLeave({ room_id: roomId, state_key: userId, }: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise { - const leavingUser = await getOrCreateFederatedUser(userId); + const serverName = federationSDK.getConfig('serverName'); + const [username] = getUsernameServername(userId, serverName); + + const leavingUser = await Users.findOneByUsername(username); if (!leavingUser) { - throw new Error(`Failed to get or create leaving user: ${userId}`); + return; } const room = await Rooms.findOneFederatedByMrid(roomId); @@ -222,7 +225,7 @@ async function handleLeave({ throw new Error(`Room not found while leaving user ${userId} from room ${roomId}`); } - await Room.performUserRemoval(room._id, leavingUser); + await Room.performUserRemoval(room, leavingUser); // TODO check if there are no pending invites to the room, and if so, delete the room } diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index b2a881bea2625..6937b1d818118 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -44,7 +44,7 @@ export interface IRoomService { }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; - performUserRemoval(roomId: string, user: IUser, options?: { byUser?: IUser }): Promise; + performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise; performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( From 5b2c5e53cd1c0d739048223b61319254c3611d4f Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 14:41:00 -0300 Subject: [PATCH 58/72] change subscription invite field to `inviter` --- apps/meteor/app/lib/server/functions/acceptRoomInvite.ts | 4 ++-- apps/meteor/app/lib/server/functions/createDirectRoom.ts | 6 +++++- apps/meteor/lib/publishFields.ts | 2 +- apps/meteor/server/modules/watchers/watchers.module.ts | 2 +- apps/meteor/server/services/room/service.ts | 4 ++-- packages/core-typings/src/ISubscription.ts | 2 +- packages/models/src/models/Subscriptions.ts | 2 +- 7 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts index eee3bd0515355..32370ab8da01f 100644 --- a/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts +++ b/apps/meteor/app/lib/server/functions/acceptRoomInvite.ts @@ -22,10 +22,10 @@ export const performAcceptRoomInvite = async ( subscription: ISubscription, user: IUser & { username: string }, ): Promise => { - if (subscription.status !== 'INVITED' || !subscription.inviterUsername) { + if (subscription.status !== 'INVITED' || !subscription.inviter) { throw new Meteor.Error('error-not-invited', `User was not invited to this room ${subscription.status}`); } - const inviter = await Users.findOneByUsername(subscription.inviterUsername); + const inviter = await Users.findOneById(subscription.inviter._id); await callbacks.run('beforeJoinRoom', user, room); diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 6a40a97d90a4e..dd0fc42405dcc 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -157,6 +157,8 @@ export async function createDirectRoom( const creatorUser = options?.creator ? roomMembers.find((member) => member._id === options?.creator) : undefined; + // TODO wtf creatorUser can be undefined here? + for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); @@ -164,7 +166,9 @@ export async function createDirectRoom( roomExtraData.federated && options?.creator !== member._id ? { status: 'INVITED', - inviterUsername: creatorUser?.username, + inviter: { + _id: creatorUser?._id, + }, open: true, unread: 1, userMentions: 1, diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 6de591f0b13b7..999c4b7f0b13f 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -43,7 +43,7 @@ export const subscriptionFields = { tunreadGroup: 1, tunreadUser: 1, status: 1, - inviterUsername: 1, + inviter: 1, // Omnichannel fields department: 1, diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index 91fc051addd81..33befeaf9fd6d 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -138,7 +138,7 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'tunreadGroup' | 'tunreadUser' | 'status' - | 'inviterUsername' + | 'inviter' // Omnichannel fields | 'department' diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index 70d59161323e6..968b380b0a984 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -233,7 +233,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { room: IRoom; ts: Date; userToBeAdded: IUser; - inviter?: Pick; + inviter?: Pick; createAsHidden?: boolean; skipAlertSound?: boolean; skipSystemMessage?: boolean; @@ -249,7 +249,7 @@ export class RoomService extends ServiceClassInternal implements IRoomService { userMentions: 1, groupMentions: 0, ...(status && { status }), - ...(inviter && { inviterUsername: inviter?.username }), + ...(inviter && { inviter: { _id: inviter._id, username: inviter.username, name: inviter.name } }), ...autoTranslateConfig, ...getDefaultSubscriptionPref(userToBeAdded), }); diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 6108d70a0593d..b984d2e06d6a6 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -75,7 +75,7 @@ export interface ISubscription extends IRocketChatRecord { suggestedOldRoomKeys?: OldKey[]; status?: SubscriptionStatus; - inviterUsername?: string; + inviter?: Pick; } export interface IOmnichannelSubscription extends ISubscription { diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index 634de619c09fc..a8cb7ce49b27b 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2107,7 +2107,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { $unset: { status: 1, - inviterUsername: 1, + inviter: 1, }, $set: { open: true, From 3b03b141717aa304db93a233b37447c7a1106f31 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 14:43:01 -0300 Subject: [PATCH 59/72] keep invite code contained --- .../app/lib/server/functions/addUserToRoom.ts | 8 +++- .../app/lib/server/methods/addUsersToRoom.ts | 23 +--------- .../ee/server/hooks/federation/index.ts | 44 +++++++++++++------ 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index af4f39ec86b78..c239a4e4db025 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -1,7 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Team, Room } from '@rocket.chat/core-services'; -import { type IUser } from '@rocket.chat/core-typings'; +import { isRoomNativeFederated, type IUser } from '@rocket.chat/core-typings'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -79,6 +79,12 @@ export const addUserToRoom = async ( throw error; } + + // for federation rooms we stop here since everything else will be handled by the federation invite flow + if (isRoomNativeFederated(room)) { + return; + } + // TODO: are we calling this twice? if (room.t === 'c' || room.t === 'p' || room.t === 'l') { // Add a new event, with an optional inviter diff --git a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts index 1ae756410cdc4..35c337305e1c5 100644 --- a/apps/meteor/app/lib/server/methods/addUsersToRoom.ts +++ b/apps/meteor/app/lib/server/methods/addUsersToRoom.ts @@ -1,8 +1,6 @@ -import { api, Room } from '@rocket.chat/core-services'; +import { api } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; -import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -91,13 +89,6 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; data.users.map(async (username) => { const sanitizedUsername = sanitizeUsername(username); - // If it's a federated username format and the room is not federated, throw error immediately - if (validateFederatedUsername(sanitizedUsername) && !isRoomNativeFederated(room)) { - throw new Meteor.Error('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms', { - method: 'addUsersToRoom', - }); - } - const newUser = await Users.findOneByUsernameIgnoringCase(sanitizedUsername); if (!newUser) { throw new Meteor.Error('error-user-not-found', 'User not found', { @@ -107,18 +98,6 @@ export const addUsersToRoomMethod = async (userId: string, data: { rid: string; const subscription = await Subscriptions.findOneByRoomIdAndUserId(data.rid, newUser._id); if (!subscription) { - // for federation rooms we just invite - if (isRoomNativeFederated(room)) { - await Room.createUserSubscription({ - room, - inviter: user, - ts: new Date(), - userToBeAdded: newUser, - status: 'INVITED', - }); - return; - } - return addUserToRoom(data.rid, newUser, user); } if (!newUser.username) { diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 76fcc3cec7d00..0350a6abf38f8 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,4 +1,4 @@ -import { FederationMatrix, Authorization, MeteorError } from '@rocket.chat/core-services'; +import { FederationMatrix, Authorization, MeteorError, Room } from '@rocket.chat/core-services'; import { isEditedMessage, isRoomNativeFederated, @@ -92,10 +92,13 @@ callbacks.add( 'native-federation-after-delete-message', ); -beforeAddUsersToRoom.add(async ({ usernames }, room) => { - if (FederationActions.shouldPerformFederationAction(room)) { - await FederationMatrix.ensureFederatedUsersExistLocally(usernames); +beforeAddUsersToRoom.add(async ({ usernames, inviter }, room) => { + if (!FederationActions.shouldPerformFederationAction(room) && inviter) { + return; } + + // we create local users before adding them to the room + await FederationMatrix.ensureFederatedUsersExistLocally(usernames); }); beforeAddUserToRoom.add( @@ -104,19 +107,32 @@ beforeAddUserToRoom.add( return; } - if (FederationActions.shouldPerformFederationAction(room)) { - if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { - throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); - } + if (!FederationActions.shouldPerformFederationAction(room)) { + return; + } - // If inviter is federated, the invite came from an external transaction. - // Don't propagate back to Matrix (it was already processed at origin server). - if (isUserNativeFederated(inviter)) { - return; - } + // TODO should we really check for "user" here? it is potentially an external user + if (!(await Authorization.hasPermission(user._id, 'access-federation'))) { + throw new MeteorError('error-not-authorized-federation', 'Not authorized to access federation'); + } - await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); + // If inviter is federated, the invite came from an external transaction. + // Don't propagate back to Matrix (it was already processed at origin server). + if (isUserNativeFederated(inviter)) { + return; } + + await FederationMatrix.inviteUsersToRoom(room, [user.username], inviter); + + // after invite is sent we create the invite subscriptions + // TODO this may be not needed if we receive the emit for the invite event from matrix + await Room.createUserSubscription({ + ts: new Date(), + room, + userToBeAdded: user, + inviter, + status: 'INVITED', + }); }, callbacks.priority.MEDIUM, 'native-federation-on-before-add-users-to-room', From fadf98cb41d62ea07b14059080c177dc7c8b39bf Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 16:10:24 -0300 Subject: [PATCH 60/72] validate creator for federated DMs --- .../app/lib/server/functions/createDirectRoom.ts | 13 ++++++++----- apps/meteor/app/lib/server/functions/createRoom.ts | 3 +-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index dd0fc42405dcc..6d902a182fcbe 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -155,19 +155,22 @@ export async function createDirectRoom( { projection: { 'username': 1, 'settings.preferences': 1 } }, ).toArray(); - const creatorUser = options?.creator ? roomMembers.find((member) => member._id === options?.creator) : undefined; - - // TODO wtf creatorUser can be undefined here? + const creatorUser = roomMembers.find((member) => member._id === options?.creator); + if (roomExtraData.federated && !creatorUser) { + throw new Meteor.Error('error-creator-not-in-room', 'The creator user must be part of the direct room'); + } for await (const member of membersWithPreferences) { const otherMembers = sortedMembers.filter(({ _id }) => _id !== member._id); const subscriptionStatus: Partial = - roomExtraData.federated && options?.creator !== member._id + roomExtraData.federated && options.creator !== member._id && creatorUser ? { status: 'INVITED', inviter: { - _id: creatorUser?._id, + _id: creatorUser._id, + username: creatorUser.username, + name: creatorUser.name, }, open: true, unread: 1, diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index bb087abd4fbd5..90c3c46fca94a 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -7,7 +7,6 @@ import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import { performAddUserToRoom } from './addUserToRoom'; import { createDirectRoom } from './createDirectRoom'; import { callbacks } from '../../../../lib/callbacks'; import { beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; @@ -203,7 +202,7 @@ export const createRoom = async ( } if (type === 'd') { - return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?.username }); + return createDirectRoom(members as IUser[], extraData, { ...options, creator: options?.creator || owner?._id }); } if (!onlyUsernames(members)) { From 26ea248869db406034d6c8e5c40c662e76597e62 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 18:30:12 -0300 Subject: [PATCH 61/72] perform accept invite on accept --- .../federation-matrix/src/FederationMatrix.ts | 4 +++- .../src/models/ISubscriptionsModel.ts | 2 +- packages/models/src/models/Subscriptions.ts | 18 ++++++------------ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 24d7265f6bcc2..32e8a7e98b7c3 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -1,4 +1,4 @@ -import { type IFederationMatrixService, ServiceClass } from '@rocket.chat/core-services'; +import { type IFederationMatrixService, Room, ServiceClass } from '@rocket.chat/core-services'; import { isDeletedMessage, isMessageFromMatrixFederation, @@ -919,6 +919,8 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS if (action === 'accept') { await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); + + await Room.performAcceptRoomInvite(room, subscription, user); } if (action === 'reject') { await federationSDK.rejectInvite(room.federation.mrid, matrixUserId); diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 5196e3dd3457e..c8e1e9ed6c5ff 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -336,6 +336,6 @@ export interface ISubscriptionsModel extends IBaseModel { setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; findUserFederatedRoomIds(userId: IUser['_id']): AggregationCursor<{ _id: IRoom['_id']; externalRoomId: string }>; - findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise | null>; + findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise; acceptInvitationById(subscriptionId: ISubscription['_id']): Promise; } diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index a8cb7ce49b27b..99b50ef84de7a 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2087,18 +2087,12 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri ]); } - async findInvitedSubscription( - roomId: ISubscription['rid'], - userId: ISubscription['u']['_id'], - ): Promise | null> { - return this.findOne( - { - 'rid': roomId, - 'u._id': userId, - 'status': 'INVITED', - }, - { projection: { _id: 1 } }, - ); + async findInvitedSubscription(roomId: ISubscription['rid'], userId: ISubscription['u']['_id']): Promise { + return this.findOne({ + 'rid': roomId, + 'u._id': userId, + 'status': 'INVITED', + }); } async acceptInvitationById(subscriptionId: string): Promise { From 72fb063744a67ebf6186e0695e29e47bf46fed0b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 21:24:54 -0300 Subject: [PATCH 62/72] code cleanup --- ee/packages/federation-matrix/src/events/member.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index ab426254f012a..ee48484718a84 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -109,8 +109,6 @@ function getJoinRuleType(strippedState: PduForType<'m.room.join_rules'>[]): 'p' } } -// TODO on invite we may only want to create the subscription with INVITED status -// everything else should be created on join async function handleInvite({ sender: senderId, state_key: userId, From f74bbb4ea2583502775cbda116f835664a2678db Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 21:25:03 -0300 Subject: [PATCH 63/72] regression: add inviter param --- apps/meteor/app/lib/server/functions/addUserToRoom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index c239a4e4db025..7403c15511aaf 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -97,6 +97,7 @@ export const addUserToRoom = async ( await Room.createUserSubscription({ room, ts: now, + inviter, userToBeAdded, createAsHidden, skipAlertSound, From 53bf6d88aa3b3cf953fdf18d7d1164ed7836d7ff Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 8 Dec 2025 23:32:49 -0300 Subject: [PATCH 64/72] regression: validate adding federated users to non-federated rooms --- apps/meteor/ee/server/hooks/federation/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 0350a6abf38f8..8af43a349b736 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -7,6 +7,7 @@ import { type IRoom, type IUser, } from '@rocket.chat/core-typings'; +import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Rooms } from '@rocket.chat/models'; import { callbacks } from '../../../../lib/callbacks'; @@ -94,6 +95,11 @@ callbacks.add( beforeAddUsersToRoom.add(async ({ usernames, inviter }, room) => { if (!FederationActions.shouldPerformFederationAction(room) && inviter) { + // check if trying to invite a federated user to a non-federated room + const federatedUsernames = usernames.filter((u) => validateFederatedUsername(u)); + if (federatedUsernames.length > 0) { + throw new MeteorError('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms'); + } return; } From 48771b441912387ad60063ecb4328cf37c273d67 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 11:32:04 -0300 Subject: [PATCH 65/72] use $unwind to return subscription --- .../app/lib/server/functions/addUserToRoom.ts | 2 +- .../lib/findUsersOfRoomOrderedByRole.ts | 28 ++++++------------- packages/rest-typings/src/v1/rooms.ts | 4 +-- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 7403c15511aaf..75667c70f4df9 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -49,7 +49,7 @@ export const addUserToRoom = async ( // Check if user is already in room const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, userToBeAdded._id); - if (subscription || !userToBeAdded) { + if (subscription) { return; } diff --git a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts index 24788d0bb9721..e03544d3a5a03 100644 --- a/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts +++ b/apps/meteor/server/lib/findUsersOfRoomOrderedByRole.ts @@ -1,4 +1,4 @@ -import { type IUser, type IRole, ROOM_ROLE_PRIORITY_MAP } from '@rocket.chat/core-typings'; +import { type IUser, ROOM_ROLE_PRIORITY_MAP, type ISubscription } from '@rocket.chat/core-typings'; import { Subscriptions, Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Document, FilterOperators } from 'mongodb'; @@ -17,8 +17,7 @@ type FindUsersParam = { }; type UserWithRoleAndSubscriptionData = IUser & { - roles: IRole['_id'][]; - subscription?: { status: string; createdAt: string }; + subscription: Pick; }; export async function findUsersOfRoomOrderedByRole({ @@ -110,23 +109,12 @@ export async function findUsersOfRoomOrderedByRole({ { $addFields: { roles: { $arrayElemAt: ['$subscription.roles', 0] }, - subscription: { - $let: { - vars: { - sub: { $arrayElemAt: ['$subscription', 0] }, - }, - in: { - $cond: { - if: { $ifNull: ['$$sub.status', false] }, - then: { - status: '$$sub.status', - createdAt: '$$sub.ts', - }, - else: '$$REMOVE', - }, - }, - }, - }, + }, + }, + { + $unwind: { + path: '$subscription', + preserveNullAndEmptyArrays: true, }, }, { diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index a444bda32254e..35400e04052b0 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -1,4 +1,4 @@ -import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload, IE2EEMessage, ITeam, IRole } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload, IE2EEMessage, ITeam, ISubscription } from '@rocket.chat/core-typings'; import { ajv } from './Ajv'; import type { PaginatedRequest } from '../helpers/PaginatedRequest'; @@ -884,7 +884,7 @@ export type RoomsEndpoints = { '/v1/rooms.membersOrderedByRole': { GET: (params: RoomsMembersOrderedByRoleProps) => PaginatedResult<{ - members: (IUser & { roles?: IRole['_id'][]; subscription?: { status: string; createdAt: string } })[]; + members: (IUser & { subscription: Pick })[]; }>; }; From 03677ca270a868a21f4b83027079f3d42673d3ba Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 13:21:22 -0300 Subject: [PATCH 66/72] remove unneeded prop federatedRoomId --- .../app/lib/server/functions/createDirectRoom.ts | 2 -- .../meteor/app/lib/server/functions/createRoom.ts | 1 - .../federation-matrix/src/events/member.ts | 15 ++++++++++++--- packages/core-services/src/types/IRoomService.ts | 12 +++--------- packages/core-typings/src/IRoom.ts | 1 + 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createDirectRoom.ts b/apps/meteor/app/lib/server/functions/createDirectRoom.ts index 6d902a182fcbe..b6ff87a87b2b1 100644 --- a/apps/meteor/app/lib/server/functions/createDirectRoom.ts +++ b/apps/meteor/app/lib/server/functions/createDirectRoom.ts @@ -46,7 +46,6 @@ export async function createDirectRoom( options: { creator?: IUser['_id']; subscriptionExtra?: ISubscriptionExtraData; - federatedRoomId?: string; }, ): Promise { const maxUsers = settings.get('DirectMesssage_maxUsers') || 1; @@ -203,7 +202,6 @@ export async function createDirectRoom( await callbacks.run('afterCreateDirectRoom', insertedRoom, { members: roomMembers, creatorId: options?.creator, - mrid: options?.federatedRoomId, }); void Apps.self?.triggerEvent(AppEvents.IPostRoomCreate, insertedRoom); diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 90c3c46fca94a..d33e7a7b8454e 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -259,7 +259,6 @@ export const createRoom = async ( }, ts: now, ro: readOnly === true, - ...(options?.federatedRoomId && { federation: { mrid: options.federatedRoomId, origin: options.federatedRoomId.split(':').pop() } }), }; if (teamId) { diff --git a/ee/packages/federation-matrix/src/events/member.ts b/ee/packages/federation-matrix/src/events/member.ts index ee48484718a84..56b5030b5e4fc 100644 --- a/ee/packages/federation-matrix/src/events/member.ts +++ b/ee/packages/federation-matrix/src/events/member.ts @@ -1,5 +1,5 @@ import { Room } from '@rocket.chat/core-services'; -import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; +import type { IRoomNativeFederated, IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; import type { Emitter } from '@rocket.chat/emitter'; import type { HomeserverEventSignatures, PduForType } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; @@ -58,18 +58,27 @@ async function getOrCreateFederatedRoom({ return room; } + const origin = matrixRoomId.split(':').pop(); + if (!origin) { + throw new Error(`Room origin not found for Matrix ID: ${matrixRoomId}`); + } + // TODO room creator is not always the inviter - return Room.create(inviterUserId, { + return Room.create(inviterUserId, { type: roomType, name: roomName, members: inviteeUsername ? [inviteeUsername, inviterUsername] : [inviterUsername], options: { - federatedRoomId: matrixRoomId, creator: inviterUserId, }, extraData: { federated: true, + federation: { + version: 1, + mrid: matrixRoomId, + origin, + }, fname: roomFName, }, }); diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 6937b1d818118..33055a5db4162 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -10,25 +10,19 @@ export interface ISubscriptionExtraData { export interface ICreateRoomOptions extends Partial> { creator: string; subscriptionExtra?: ISubscriptionExtraData; - federatedRoomId?: string; } -export interface ICreateRoomExtraData extends Record { - teamId: string; - teamMain: boolean; -} - -export interface ICreateRoomParams { +export interface ICreateRoomParams { type: IRoom['t']; name: IRoom['name']; members?: Array; // member's usernames readOnly?: boolean; - extraData?: Partial; + extraData?: Partial; options?: ICreateRoomOptions; } export interface IRoomService { addMember(uid: string, rid: string): Promise; - create(uid: string, params: ICreateRoomParams): Promise; + create(uid: string, params: ICreateRoomParams): Promise; createDirectMessage(data: { to: string; from: string }): Promise<{ rid: string }>; createDirectMessageWithMultipleUsers(members: string[], creatorId: string): Promise<{ rid: string }>; addUserToRoom( diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 629ac5ee1e124..c2975b49b07e7 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -116,6 +116,7 @@ export interface IRoomNativeFederated extends IRoomFederated { version: number; // Matrix's room ID. Example: !XqJXqZxXqJXq:matrix.org mrid: string; + origin: string; }; } From 7e00ab8c313cba7f801d2ea9d8e0a83a84651f86 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 16:07:30 -0300 Subject: [PATCH 67/72] remove unused params --- packages/core-services/src/types/IRoomService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index 33055a5db4162..a3a06f16040ba 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -33,8 +33,6 @@ export interface IRoomService { skipSystemMessage?: boolean; skipAlertSound?: boolean; createAsHidden?: boolean; - status?: SubscriptionStatus; - inviterUsername?: string; }, ): Promise; removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; From 08e13514f6fe7f922adc590c92bc79907e17a41b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 16:12:16 -0300 Subject: [PATCH 68/72] remove some federation code spread all around --- .../app/lib/server/functions/createRoom.ts | 15 ++-------- .../ee/server/hooks/federation/index.ts | 29 ++++++++++++++----- .../server/methods/createDirectMessage.ts | 23 ++++----------- .../core-services/src/types/IRoomService.ts | 2 +- 4 files changed, 30 insertions(+), 39 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index d33e7a7b8454e..3786a9fe7cbec 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -156,7 +156,7 @@ export const createRoom = async ( rid: string; } > => { - const { teamId, ...optionalExtraData } = roomExtraData || ({} as IRoom); + const { teamId, ...extraData } = roomExtraData || ({} as IRoom); // TODO: use a shared helper to check whether a user is federated const hasFederatedMembers = members.some((member) => { @@ -167,23 +167,12 @@ export const createRoom = async ( }); // Prevent adding federated users to rooms that are not marked as federated explicitly - if (hasFederatedMembers && optionalExtraData.federated !== true) { + if (hasFederatedMembers && extraData.federated !== true) { throw new Meteor.Error('error-federated-users-in-non-federated-rooms', 'Cannot add federated users to non-federated rooms', { method: 'createRoom', }); } - const extraData = { - ...optionalExtraData, - ...((hasFederatedMembers || optionalExtraData.federated) && { - federated: true, - federation: { - version: 1, - // TODO we should be able to provide all values from here, currently we update on callback afterCreateRoom - }, - }), - }; - await prepareCreateRoomCallback.run({ type, // name, diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 8af43a349b736..0c68b609fdca9 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -252,17 +252,32 @@ callbacks.add( 'federation-matrix-before-create-direct-room', ); +callbacks.add('federation.beforeCreateDirectMessage', async (roomUsers) => { + // TODO: use a shared helper to check whether a user is federated + // since the DM creation API doesn't tell us if the room is federated (unlike normal channels), + // we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated + const hasFederatedMembers = roomUsers.some((user: unknown) => typeof user === 'string' && user.includes(':') && user.includes('@')); + + if (hasFederatedMembers) { + return { + federated: true, + federation: { + version: 1, + }, + }; + } +}); + callbacks.add( 'afterCreateDirectRoom', - async (room: IRoom, params: { members: IUser[]; creatorId: IUser['_id']; mrid?: string }): Promise => { - if (params.mrid) { - await Rooms.setAsFederated(room._id, { - mrid: params.mrid, - origin: params.mrid.split(':').pop()!, - }); + async (room: IRoom, params: { members: IUser[]; creatorId: IUser['_id'] }): Promise => { + if (!FederationActions.shouldPerformFederationAction(room)) { return; } - if (FederationActions.shouldPerformFederationAction(room)) { + + // as per federation.beforeCreateDirectMessage we create a DM without federation data because we still don't have it. + if (!room.federation.mrid) { + // so after the DM is created we call the federation to create the DM on Matrix side and then updated the reference here await FederationMatrix.createDirectMessageRoom(room, params.members, params.creatorId); } }, diff --git a/apps/meteor/server/methods/createDirectMessage.ts b/apps/meteor/server/methods/createDirectMessage.ts index ffe355f85ed94..4774282850f26 100644 --- a/apps/meteor/server/methods/createDirectMessage.ts +++ b/apps/meteor/server/methods/createDirectMessage.ts @@ -43,11 +43,6 @@ export async function createDirectMessage( const options: Exclude = { creator: me._id }; const roomUsers = excludeSelf ? users : [me, ...users]; - // TODO: use a shared helper to check whether a user is federated - // since the DM creation API doesn't tell us if the room is federated (unlike normal channels), - // we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated - const hasFederatedMembers = roomUsers.some((user) => typeof user === 'string' && user.includes(':') && user.includes('@')); - // allow self-DMs if (roomUsers.length === 1 && roomUsers[0] !== undefined && typeof roomUsers[0] !== 'string' && roomUsers[0]._id !== me._id) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { @@ -78,8 +73,11 @@ export async function createDirectMessage( if (excludeSelf && (await hasPermissionAsync(userId, 'view-room-administration'))) { options.subscriptionExtra = { open: true }; } + + let extraData = {}; + try { - await callbacks.run('federation.beforeCreateDirectMessage', roomUsers); + extraData = await callbacks.run('federation.beforeCreateDirectMessage', roomUsers); } catch (error) { throw new Meteor.Error((error as any)?.message); } @@ -87,18 +85,7 @@ export async function createDirectMessage( _id: rid, inserted, ...room - } = await createRoom<'d'>( - 'd', - undefined, - undefined, - roomUsers as IUser[], - false, - undefined, - { - ...(hasFederatedMembers && { federated: true }), - }, - options, - ); + } = await createRoom<'d'>('d', undefined, undefined, roomUsers as IUser[], false, undefined, extraData, options); return { // @ts-expect-error - room type is already defined in the `createRoom` return type diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index a3a06f16040ba..ecb850d45ef2b 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -1,4 +1,4 @@ -import type { AtLeast, IRoom, ISubscription, IUser, SubscriptionStatus } from '@rocket.chat/core-typings'; +import type { AtLeast, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; export interface ISubscriptionExtraData { open: boolean; From cedc8bfe9c66074382f383a72cda257e81286824 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 17:02:52 -0300 Subject: [PATCH 69/72] use additional callback param --- apps/meteor/ee/server/hooks/federation/index.ts | 10 ++++------ apps/meteor/lib/callbacks.ts | 2 +- apps/meteor/server/methods/createDirectMessage.ts | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index 0c68b609fdca9..bac1ae455d878 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -252,18 +252,16 @@ callbacks.add( 'federation-matrix-before-create-direct-room', ); -callbacks.add('federation.beforeCreateDirectMessage', async (roomUsers) => { +callbacks.add('federation.beforeCreateDirectMessage', async (roomUsers, extraData) => { // TODO: use a shared helper to check whether a user is federated // since the DM creation API doesn't tell us if the room is federated (unlike normal channels), // we're currently inferring it: if any participant has a Matrix-style ID (@user:server), we treat the DM as federated const hasFederatedMembers = roomUsers.some((user: unknown) => typeof user === 'string' && user.includes(':') && user.includes('@')); if (hasFederatedMembers) { - return { - federated: true, - federation: { - version: 1, - }, + extraData.federated = true; + extraData.federation = { + version: 1, }; } }); diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 0c2aec4cf78e2..d55a6c64ae96f 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -78,7 +78,7 @@ interface EventLikeCallbackSignatures { }, ) => void; 'beforeCreateDirectRoom': (members: string[], room: IRoom) => void; - 'federation.beforeCreateDirectMessage': (members: IUser[]) => void; + 'federation.beforeCreateDirectMessage': (members: IUser[], extraData: Record) => void; 'afterSetReaction': (message: IMessage, params: { user: IUser; reaction: string; shouldReact: boolean; room: IRoom }) => void; 'afterUnsetReaction': ( message: IMessage, diff --git a/apps/meteor/server/methods/createDirectMessage.ts b/apps/meteor/server/methods/createDirectMessage.ts index 4774282850f26..f3dfc6cd14e0c 100644 --- a/apps/meteor/server/methods/createDirectMessage.ts +++ b/apps/meteor/server/methods/createDirectMessage.ts @@ -74,10 +74,10 @@ export async function createDirectMessage( options.subscriptionExtra = { open: true }; } - let extraData = {}; + const extraData = {}; try { - extraData = await callbacks.run('federation.beforeCreateDirectMessage', roomUsers); + await callbacks.run('federation.beforeCreateDirectMessage', roomUsers, extraData); } catch (error) { throw new Meteor.Error((error as any)?.message); } From 78f37b5cfaf08156f05722eed3d11f9f930d5c58 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 18:17:42 -0300 Subject: [PATCH 70/72] fix room being created as IRoomNativeFederated --- .../app/lib/server/functions/createRoom.ts | 8 +++---- .../ee/server/hooks/federation/index.ts | 22 ++++++++++++------- .../lib/callbacks/beforeCreateRoomCallback.ts | 2 +- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 3786a9fe7cbec..56f62ca5052f5 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -3,7 +3,6 @@ import { AppsEngineException } from '@rocket.chat/apps-engine/definition/excepti import { FederationMatrix, Message, Room, Team } from '@rocket.chat/core-services'; import type { ICreateRoomParams, ISubscriptionExtraData } from '@rocket.chat/core-services'; import type { ICreatedRoom, IUser, IRoom, RoomType } from '@rocket.chat/core-typings'; -import { isRoomNativeFederated } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -183,8 +182,7 @@ export const createRoom = async ( // options, }); - const shouldBeHandledByFederation = isRoomNativeFederated(extraData); - if (shouldBeHandledByFederation && owner && !(await hasPermissionAsync(owner._id, 'access-federation'))) { + if (hasFederatedMembers && owner && !(await hasPermissionAsync(owner._id, 'access-federation'))) { throw new Meteor.Error('error-not-authorized-federation', 'Not authorized to access federation', { method: 'createRoom', }); @@ -297,13 +295,13 @@ export const createRoom = async ( void notifyOnRoomChanged(room, 'inserted'); // If federated, we must create Matrix room BEFORE subscriptions so invites can be sent. - if (shouldBeHandledByFederation) { + if (hasFederatedMembers) { // Reusing unused callback to create Matrix room. // We should discuss the opportunity to rename it to something with "before" prefix. await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); } - await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); + await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation: hasFederatedMembers }); if (type === 'c') { if (room.teamId) { diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index bac1ae455d878..dfe26b4688104 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -1,12 +1,6 @@ import { FederationMatrix, Authorization, MeteorError, Room } from '@rocket.chat/core-services'; -import { - isEditedMessage, - isRoomNativeFederated, - isUserNativeFederated, - type IMessage, - type IRoom, - type IUser, -} from '@rocket.chat/core-typings'; +import { isEditedMessage, isRoomNativeFederated, isUserNativeFederated } from '@rocket.chat/core-typings'; +import type { IRoomNativeFederated, IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Rooms } from '@rocket.chat/models'; @@ -15,6 +9,7 @@ import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoom import { afterRemoveFromRoomCallback } from '../../../../lib/callbacks/afterRemoveFromRoomCallback'; import { beforeAddUsersToRoom, beforeAddUserToRoom } from '../../../../lib/callbacks/beforeAddUserToRoom'; import { beforeChangeRoomRole } from '../../../../lib/callbacks/beforeChangeRoomRole'; +import { prepareCreateRoomCallback } from '../../../../lib/callbacks/beforeCreateRoomCallback'; import { FederationActions } from '../../../../server/services/room/hooks/BeforeFederationActions'; // callbacks.add('federation-event-example', async () => FederationMatrix.handleExample(), callbacks.priority.MEDIUM, 'federation-event-example-handler'); @@ -282,3 +277,14 @@ callbacks.add( callbacks.priority.HIGH, 'federation-matrix-after-create-direct-room', ); + +prepareCreateRoomCallback.add(async ({ extraData }) => { + if (!extraData.federated) { + return; + } + + // when we receive extraData.federated, we need to prepare the room to be considered IRoomNativeFederated. + // according to isRoomNativeFederated for a room to be considered IRoomNativeFederated it is enough to have + // only an empty "federation" object + (extraData as IRoomNativeFederated).federation = { version: 1 } as any; +}); diff --git a/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts b/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts index 625ea21984bc8..fe41628219240 100644 --- a/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts +++ b/apps/meteor/lib/callbacks/beforeCreateRoomCallback.ts @@ -6,4 +6,4 @@ export const beforeCreateRoomCallback = Callbacks.create<(data: { owner: IUser; room: Omit }) => void>('beforeCreateRoom'); export const prepareCreateRoomCallback = - Callbacks.create<(data: { type: IRoom['t']; extraData: { encrypted?: boolean } }) => void>('prepareCreateRoom'); + Callbacks.create<(data: { type: IRoom['t']; extraData: Partial }) => void>('prepareCreateRoom'); From f09e1f244c8d563e36d279f947b1459f3cfa897b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 21:14:23 -0300 Subject: [PATCH 71/72] fix federation condition --- apps/meteor/app/lib/server/functions/createRoom.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 56f62ca5052f5..c6a5a9b88742a 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -182,7 +182,9 @@ export const createRoom = async ( // options, }); - if (hasFederatedMembers && owner && !(await hasPermissionAsync(owner._id, 'access-federation'))) { + const shouldBeHandledByFederation = extraData.federated === true; + + if (shouldBeHandledByFederation && owner && !(await hasPermissionAsync(owner._id, 'access-federation'))) { throw new Meteor.Error('error-not-authorized-federation', 'Not authorized to access federation', { method: 'createRoom', }); @@ -295,13 +297,13 @@ export const createRoom = async ( void notifyOnRoomChanged(room, 'inserted'); // If federated, we must create Matrix room BEFORE subscriptions so invites can be sent. - if (hasFederatedMembers) { + if (shouldBeHandledByFederation) { // Reusing unused callback to create Matrix room. // We should discuss the opportunity to rename it to something with "before" prefix. await callbacks.run('federation.afterCreateFederatedRoom', room, { owner, originalMemberList: members, options }); } - await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation: hasFederatedMembers }); + await createUsersSubscriptions({ room, members, now, owner, options, shouldBeHandledByFederation }); if (type === 'c') { if (room.teamId) { From ff339d3a25cd109ee55939262664ec34ca95b322 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 9 Dec 2025 21:46:06 -0300 Subject: [PATCH 72/72] dont do anything if room is already native federated --- apps/meteor/ee/server/hooks/federation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/hooks/federation/index.ts b/apps/meteor/ee/server/hooks/federation/index.ts index dfe26b4688104..01f94f31e5024 100644 --- a/apps/meteor/ee/server/hooks/federation/index.ts +++ b/apps/meteor/ee/server/hooks/federation/index.ts @@ -279,7 +279,7 @@ callbacks.add( ); prepareCreateRoomCallback.add(async ({ extraData }) => { - if (!extraData.federated) { + if (!extraData.federated || isRoomNativeFederated(extraData)) { return; }