From f86f726214e516ad9c187e8e35e50c8c7e4a3b84 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 25 May 2026 12:26:53 -0300 Subject: [PATCH 01/16] feat: migrate sendMessage + getReadReceipts callers from DDP to REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two methods that resisted the URL-swap pass in the wider DDP -> REST migration. This PR contains the deeper refactor each needed. sendMessage Replace sdk.call('sendMessage', message, previewUrls) with sdk.rest.post('/v1/chat.sendMessage', { message, previewUrls }). In the primary send flow (apps/meteor/client/lib/chats/flows/sendMessage.ts) the response's server-rendered { message } is fed back into Messages.state via mapMessageFromApi, replacing the optimistic temp record in the same tick the REST call resolves. That reproduces the Minimongo replication the DDP method triggered — composer quote previews unmount, attachment renderers see attachments[] and urls[], message _updatedAt advances — all without waiting for the room-messages stream event to arrive separately. The seven fire-and-forget callsites do not run the optimistic reconcile and are straight URL swaps: - apps/meteor/client/hooks/notification/useNotification.ts - apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx - apps/meteor/app/slashcommand-asciiarts/client/{lenny,tableflip,unflip,gimme,shrug}.ts getReadReceipts Replace useMethod('getReadReceipts') with useEndpoint('GET', '/v1/chat.getMessageReadReceipts') in ReadReceiptsModal. Add rid prop and subscribe to notify-room//messagesRead; on event, invalidate the ['read-receipts', messageId] query so the dialog re-fetches when new receipts land. mapReadReceiptFromApi revives the Date fields the REST endpoint serializes as strings. Server-side: ReadReceipt.markMessagesAsRead / ReadReceipt.markMessageAsReadBySender / ReadReceipt.storeThreadMessagesReadReceipts no longer fire-and-forget the storeReadReceipts insertion — they await it. Closes the read-after-write race the omnichannel-livechat-read-receipts e2e test exposed: a fast REST GET could observe a partial set of receipts because the visitor's self-receipt insert was still in flight when the test opened the Read-Receipts dialog. --- .../slashcommand-asciiarts/client/gimme.ts | 2 +- .../slashcommand-asciiarts/client/lenny.ts | 2 +- .../slashcommand-asciiarts/client/shrug.ts | 2 +- .../client/tableflip.ts | 2 +- .../slashcommand-asciiarts/client/unflip.ts | 2 +- .../GameCenterInvitePlayersModal.tsx | 11 ++++--- .../toolbar/useReadReceiptsDetailsAction.tsx | 1 + .../hooks/notification/useNotification.ts | 10 +++--- .../client/lib/chats/flows/sendMessage.ts | 14 +++++--- .../ReadReceiptsModal/ReadReceiptsModal.tsx | 32 ++++++++++++++----- .../lib/message-read-receipt/ReadReceipt.ts | 6 ++-- 11 files changed, 55 insertions(+), 29 deletions(-) diff --git a/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts b/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts index 7cd5edb6bb879..e91ce830cfb88 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/gimme.ts @@ -8,7 +8,7 @@ import { slashCommands } from '../../utils/client/slashCommand'; */ async function Gimme({ message, params }: SlashCommandCallbackParams<'gimme'>): Promise { const msg = message; - await sdk.call('sendMessage', { ...msg, msg: `༼ つ ◕_◕ ༽つ ${params}` }); + await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `༼ つ ◕_◕ ༽つ ${params}` } }); } slashCommands.add({ diff --git a/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts b/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts index 0e3cc55f6b867..4fb47b6941e78 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/lenny.ts @@ -9,7 +9,7 @@ import { slashCommands } from '../../utils/client/slashCommand'; async function LennyFace({ message, params }: SlashCommandCallbackParams<'lenny'>): Promise { const msg = message; - await sdk.call('sendMessage', { ...msg, msg: `${params} ( ͡° ͜ʖ ͡°)` }); + await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} ( ͡° ͜ʖ ͡°)` } }); } slashCommands.add({ diff --git a/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts b/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts index c4bdec8f1a8cf..8ad22a0fd380f 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/shrug.ts @@ -11,7 +11,7 @@ slashCommands.add({ command: 'shrug', callback: async ({ message, params }: SlashCommandCallbackParams<'shrug'>): Promise => { const msg = message; - await sdk.call('sendMessage', { ...msg, msg: `${params} ¯\\\\_(ツ)_/¯` }); + await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} ¯\\\\_(ツ)_/¯` } }); }, options: { description: 'Slash_Shrug_Description', diff --git a/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts b/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts index 8820b81f7c4ae..f5383214c4bd2 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/tableflip.ts @@ -11,7 +11,7 @@ slashCommands.add({ command: 'tableflip', callback: async ({ message, params }: SlashCommandCallbackParams<'tableflip'>): Promise => { const msg = message; - await sdk.call('sendMessage', { ...msg, msg: `${params} (╯°□°)╯︵ ┻━┻` }); + await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} (╯°□°)╯︵ ┻━┻` } }); }, options: { description: 'Slash_Tableflip_Description', diff --git a/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts b/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts index 6c02fa196052c..eaf478ac79b39 100644 --- a/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts +++ b/apps/meteor/app/slashcommand-asciiarts/client/unflip.ts @@ -11,7 +11,7 @@ slashCommands.add({ command: 'unflip', callback: async ({ message, params }: SlashCommandCallbackParams<'unflip'>): Promise => { const msg = message; - await sdk.call('sendMessage', { ...msg, msg: `${params} ┬─┬ ノ( ゜-゜ノ)` }); + await sdk.rest.post('/v1/chat.sendMessage', { message: { ...msg, msg: `${params} ┬─┬ ノ( ゜-゜ノ)` } }); }, options: { description: 'Slash_TableUnflip_Description', diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index 13da02330d6a3..1ea3e5f8dcca8 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -10,7 +10,6 @@ import { sdk } from '../../../app/utils/client/lib/SDKClient'; import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple'; import { useOpenedRoom } from '../../lib/RoomManager'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -import { callWithErrorHandling } from '../../lib/utils/callWithErrorHandling'; type Username = Exclude; @@ -35,10 +34,12 @@ const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayer roomCoordinator.openRouteLink(group.t, { rid: group._id, name: group.name }); if (openedRoom === group._id) { - await callWithErrorHandling('sendMessage', { - _id: Random.id(), - rid: group._id, - msg: t('Apps_Game_Center_Play_Game_Together', { name }), + void sdk.rest.post('/v1/chat.sendMessage', { + message: { + _id: Random.id(), + rid: group._id, + msg: t('Apps_Game_Center_Play_Game_Together', { name }), + }, }); } onClose(); diff --git a/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx b/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx index faaf1f3a9efed..d8230dd300a17 100644 --- a/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx +++ b/apps/meteor/client/components/message/toolbar/useReadReceiptsDetailsAction.tsx @@ -24,6 +24,7 @@ export const useReadReceiptsDetailsAction = (message: IMessage): MessageActionCo setModal( { setModal(null); }} diff --git a/apps/meteor/client/hooks/notification/useNotification.ts b/apps/meteor/client/hooks/notification/useNotification.ts index 0ef377703fcd6..88296e3334323 100644 --- a/apps/meteor/client/hooks/notification/useNotification.ts +++ b/apps/meteor/client/hooks/notification/useNotification.ts @@ -51,10 +51,12 @@ export const useNotification = () => { n.addEventListener( 'reply', ({ response }) => - void sdk.call('sendMessage', { - _id: Random.id(), - rid, - msg: response, + void sdk.rest.post('/v1/chat.sendMessage', { + message: { + _id: Random.id(), + rid, + msg: response, + }, }), ); } diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 69d2a079d16c9..73bb7b8224611 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -7,6 +7,7 @@ import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeB import { Messages } from '../../../stores'; import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; import { dispatchToastMessage } from '../../toast'; +import { mapMessageFromApi } from '../../utils/mapMessageFromApi'; import type { ChatAPI } from '../ChatAPI'; import { afterSendMessageCallback } from './afterSendMessageCallback'; import { processMessageEditing } from './processMessageEditing'; @@ -47,12 +48,17 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], chat.composer?.clear(); await runOptimisticSendMessage(message); - await sdk.call('sendMessage', message, previewUrls); - // after the request is complete we can go ahead and mark as sent + const { message: saved } = await sdk.rest.post('/v1/chat.sendMessage', { message, previewUrls }); + + // Replace the optimistic temp record with the server-rendered message so + // downstream consumers (composer quote preview, message list, threads) + // see the final shape — `attachments[]`, `urls[]`, `mentions[]`, etc. — + // in the same tick the REST call resolves. Mirrors the Minimongo replication + // that the DDP `sendMessage` method used to trigger. Messages.state.update( - (record) => record._id === message._id && record.temp === true, - ({ temp: _, ...record }) => record, + (record) => record._id === message._id, + () => mapMessageFromApi(saved), ); }; diff --git a/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx b/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx index 725bb44a1c4ae..da242168435fc 100644 --- a/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx +++ b/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx @@ -1,28 +1,44 @@ -import type { IMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; import { GenericModal, GenericModalSkeleton } from '@rocket.chat/ui-client'; -import { useMethod, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; +import { useEndpoint, useStream, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import ReadReceiptRow from './ReadReceiptRow'; +import { mapReadReceiptFromApi } from '../../../../lib/utils/mapReadReceiptFromApi'; -export type ReadReceiptsModalProps = { +type ReadReceiptsModalProps = { messageId: IMessage['_id']; + rid?: IRoom['_id']; onClose: () => void; }; -const ReadReceiptsModal = ({ messageId, onClose }: ReadReceiptsModalProps) => { +const ReadReceiptsModal = ({ messageId, rid, onClose }: ReadReceiptsModalProps): ReactElement => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + const subscribeToNotifyRoom = useStream('notify-room'); - const getReadReceipts = useMethod('getReadReceipts'); + const getReadReceipts = useEndpoint('GET', '/v1/chat.getMessageReadReceipts'); + + const queryKey = ['read-receipts', messageId]; const readReceiptsResult = useQuery({ - queryKey: ['read-receipts', messageId], - queryFn: () => getReadReceipts({ messageId }), + queryKey, + queryFn: async () => (await getReadReceipts({ messageId })).receipts.map(mapReadReceiptFromApi), }); + useEffect(() => { + if (!rid) { + return; + } + return subscribeToNotifyRoom(`${rid}/messagesRead`, () => { + queryClient.invalidateQueries({ queryKey }); + }); + }, [rid, queryClient, queryKey, subscribeToNotifyRoom]); + useEffect(() => { if (readReceiptsResult.isError) { dispatchToastMessage({ type: 'error', message: readReceiptsResult.error }); diff --git a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts index 23745f8773f0f..165acb5e6c12e 100644 --- a/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts +++ b/apps/meteor/ee/server/lib/message-read-receipt/ReadReceipt.ts @@ -49,7 +49,7 @@ class ReadReceiptClass { return; } - void this.storeReadReceipts( + await this.storeReadReceipts( () => { return Messages.findVisibleUnreadMessagesByRoomAndDate(roomId, userLastSeen).toArray(); }, @@ -80,7 +80,7 @@ class ReadReceiptClass { } } - void this.storeReadReceipts( + await this.storeReadReceipts( () => { return Promise.resolve([message]); }, @@ -101,7 +101,7 @@ class ReadReceiptClass { return; } - void this.storeReadReceipts( + await this.storeReadReceipts( () => { return Messages.findUnreadThreadMessagesByDate(message.rid, tmid, userId, userLastSeen).toArray(); }, From c2301b118fba70c708a1b4cd73a7bbe5fdee0b8c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 16:41:56 -0300 Subject: [PATCH 02/16] fix: decrypt server response in E2EE rooms after REST sendMessage The optimistic record runs through onClientMessageReceived (which the E2EE hook subscribes to) so the ciphertext is rendered as plaintext. The post-REST replacement was bypassing that hook and storing the server-returned ciphertext directly, leaving encrypted rooms showing the raw cipher (or the lastUserMessageBody locator timing out in the e2ee-encrypted-channels e2e suite). Pipe the server response through the same hook before the state replacement. For non-encrypted rooms the hook is a no-op (shouldConvertReceivedMessages returns false), so the change only affects E2EE flows. Co-Authored-By: Claude Opus 4.7 --- apps/meteor/client/lib/chats/flows/sendMessage.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 73bb7b8224611..41daa1f9a2cbd 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -6,6 +6,7 @@ import { t } from '../../../../app/utils/lib/i18n'; import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock'; import { Messages } from '../../../stores'; import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; +import { onClientMessageReceived } from '../../onClientMessageReceived'; import { dispatchToastMessage } from '../../toast'; import { mapMessageFromApi } from '../../utils/mapMessageFromApi'; import type { ChatAPI } from '../ChatAPI'; @@ -56,9 +57,15 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], // see the final shape — `attachments[]`, `urls[]`, `mentions[]`, etc. — // in the same tick the REST call resolves. Mirrors the Minimongo replication // that the DDP `sendMessage` method used to trigger. + // + // Run the server response through onClientMessageReceived so E2EE rooms + // re-decrypt the ciphertext into the rendered plaintext (matching what + // runOptimisticSendMessage already does for the optimistic record); for + // non-encrypted rooms the hook is a no-op. + const processed = await onClientMessageReceived(mapMessageFromApi(saved)); Messages.state.update( (record) => record._id === message._id, - () => mapMessageFromApi(saved), + () => processed, ); }; From 3b927980db163756b13c3551ea818acbb06adf7f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 17:48:27 -0300 Subject: [PATCH 03/16] fix: stop clobbering messages stream updates after REST sendMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-REST state.update was replacing the optimistic record with the server's response unconditionally. When the messages stream delivered an update first (read-receipt-driven `unread: false`, async URL/quote attachments arriving via AfterSave hooks, E2EE decrypt results) the REST resolution would overwrite it with the stale snapshot the server captured before those side effects landed — breaking quote rendering in non-encrypted rooms and the 'Message viewed' status icon used by read-receipts e2e tests. Restore the original predicate from the DDP-era code: drop the optimistic `temp` flag only if no other update has touched the record. The stream (Minimongo replication) keeps doing the heavy lifting for async-derived fields. The composer's `dismissAllQuotedMessages()` call already runs from the outer `sendMessage` and doesn't depend on the state.update body, so quote preview unmount keeps working. Co-Authored-By: Claude Opus 4.7 --- .../client/lib/chats/flows/sendMessage.ts | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 41daa1f9a2cbd..19f9b8a35a708 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -6,9 +6,7 @@ import { t } from '../../../../app/utils/lib/i18n'; import { closeUnclosedCodeBlock } from '../../../../lib/utils/closeUnclosedCodeBlock'; import { Messages } from '../../../stores'; import { onClientBeforeSendMessage } from '../../onClientBeforeSendMessage'; -import { onClientMessageReceived } from '../../onClientMessageReceived'; import { dispatchToastMessage } from '../../toast'; -import { mapMessageFromApi } from '../../utils/mapMessageFromApi'; import type { ChatAPI } from '../ChatAPI'; import { afterSendMessageCallback } from './afterSendMessageCallback'; import { processMessageEditing } from './processMessageEditing'; @@ -50,22 +48,15 @@ const process = async (chat: ChatAPI, message: IMessage, previewUrls?: string[], chat.composer?.clear(); await runOptimisticSendMessage(message); - const { message: saved } = await sdk.rest.post('/v1/chat.sendMessage', { message, previewUrls }); - - // Replace the optimistic temp record with the server-rendered message so - // downstream consumers (composer quote preview, message list, threads) - // see the final shape — `attachments[]`, `urls[]`, `mentions[]`, etc. — - // in the same tick the REST call resolves. Mirrors the Minimongo replication - // that the DDP `sendMessage` method used to trigger. - // - // Run the server response through onClientMessageReceived so E2EE rooms - // re-decrypt the ciphertext into the rendered plaintext (matching what - // runOptimisticSendMessage already does for the optimistic record); for - // non-encrypted rooms the hook is a no-op. - const processed = await onClientMessageReceived(mapMessageFromApi(saved)); + await sdk.rest.post('/v1/chat.sendMessage', { message, previewUrls }); + + // Clear the optimistic `temp` flag only if the messages stream hasn't already + // replaced the record. Overwriting with the server response can clobber stream + // updates that arrive first — e.g. read-receipt-driven `unread: false`, async + // URL/quote attachments, or E2EE decrypt — leading to stale UI state. Messages.state.update( - (record) => record._id === message._id, - () => processed, + (record) => record._id === message._id && record.temp === true, + ({ temp: _, ...record }) => record, ); }; From 18c81171938df553b52ae459c530579d051e9bb6 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 29 Jun 2026 11:51:54 -0300 Subject: [PATCH 04/16] chore: deprecate sendMessage and getReadReceipts DDP methods --- apps/meteor/ee/server/meteor-methods/getReadReceipts.ts | 3 +++ apps/meteor/server/meteor-methods/messages/sendMessage.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts index e5054c92e0a63..6ce07b96e0090 100644 --- a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts +++ b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts @@ -6,6 +6,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../../server/lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; import { ReadReceipt } from '../lib/message-read-receipt/ReadReceipt'; declare module '@rocket.chat/ddp-client' { @@ -37,6 +38,8 @@ export const getReadReceiptsFunction = async function (messageId: IMessage['_id' Meteor.methods({ async getReadReceipts({ messageId }) { + methodDeprecationLogger.method('getReadReceipts', '9.0.0', '/v1/chat.getMessageReadReceipts'); + check(messageId, String); const uid = Meteor.userId(); diff --git a/apps/meteor/server/meteor-methods/messages/sendMessage.ts b/apps/meteor/server/meteor-methods/messages/sendMessage.ts index 0fe3a238e4f5f..fc1143123575e 100644 --- a/apps/meteor/server/meteor-methods/messages/sendMessage.ts +++ b/apps/meteor/server/meteor-methods/messages/sendMessage.ts @@ -16,6 +16,7 @@ import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/ai import { i18n } from '../../lib/i18n'; import { SystemLogger } from '../../lib/logger/system'; import { sendMessage } from '../../lib/messages/sendMessage'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; import { metrics } from '../../lib/metrics'; import { settings } from '../../settings'; /** @@ -136,6 +137,8 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async sendMessage(message, previewUrls) { + methodDeprecationLogger.method('sendMessage', '9.0.0', '/v1/chat.sendMessage'); + check(message, { _id: Match.Maybe(String), rid: Match.Maybe(String), From 6a5922ae4e52b5997d3b79fb86517d4f470fc0ce Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 30 Jun 2026 09:28:38 -0300 Subject: [PATCH 05/16] fix(client): dismiss quoted messages optimistically on send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer quote preview stayed mounted when /v1/chat.sendMessage rejected (e.g. response validation under TEST_MODE) even though the message was already broadcast over the stream, leaving a duplicate blockquote. Dismiss the quoted messages at the optimistic phase — they are already baked into the outgoing message — so composer state is decoupled from the request outcome, matching the optimistic composer.clear() already done for text. --- apps/meteor/client/lib/chats/flows/sendMessage.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 19f9b8a35a708..7e02b49ede3e9 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -111,8 +111,13 @@ export const sendMessage = async ( } try { - await process(chat, message, previewUrls, isSlashCommandAllowed); + // Dismiss quoted messages optimistically — they are already baked into + // `message` by composeMessage above, so the composer preview must unmount + // regardless of whether the send request resolves. Keeping it coupled to a + // resolved request leaves the quote stuck in the composer when the REST call + // rejects even though the message was already broadcast over the stream. chat.composer?.dismissAllQuotedMessages(); + await process(chat, message, previewUrls, isSlashCommandAllowed); await afterSendMessageCallback(message, message.rid); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); From 6ea32641a91f8c3c4f77cd921e4cbbd516504712 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 30 Jun 2026 09:51:09 -0300 Subject: [PATCH 06/16] fix(api): disambiguate file attachment branch in MessageAttachment schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typia-generated FileAttachmentProps union includes a catch-all base branch that only requires `type: 'file'`, so it also matched image/video/audio payloads. A single media file therefore satisfied both its specific oneOf branch and the base branch, violating oneOf's "exactly one" rule and failing response validation (TEST_MODE) for any message carrying a file or quoted-file attachment — e.g. /v1/chat.sendMessage when quoting a message with an attachment. Lock the base file branch with additionalProperties:false (same approach already used for MessageAttachmentDefault) so only genuine plain-file attachments match it. Verified against the failing quote-attachment payload: invalid -> valid. --- apps/meteor/server/api/validation/ajv.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/meteor/server/api/validation/ajv.ts b/apps/meteor/server/api/validation/ajv.ts index 10556de26582f..cd3678516abaa 100644 --- a/apps/meteor/server/api/validation/ajv.ts +++ b/apps/meteor/server/api/validation/ajv.ts @@ -10,6 +10,28 @@ if (components) { (mad as Record).additionalProperties = false; } + // Lock down the catch-all "plain file" attachment branch. typia emits the + // FileAttachmentProps union as `(type: 'file') & (video | image | audio | base)`; + // the base branch only requires `type: 'file'`, so it also matches image/video/audio + // payloads. That makes the MessageAttachment oneOf ambiguous — a single image + // attachment satisfies both its specific branch and the base branch, violating + // oneOf's "exactly one" rule and failing response validation for any message with a + // file or quoted-file attachment. Reject unknown props on the base branch so only + // genuine plain-file attachments match it. + for (const key in components) { + if (!Object.prototype.hasOwnProperty.call(components, key)) { + continue; + } + const schema = components[key] as { properties?: Record }; + const props = schema?.properties; + const typeEnum = (props?.type as { enum?: unknown[] } | undefined)?.enum; + const isFileBranch = Array.isArray(typeEnum) && typeEnum.length === 1 && typeEnum[0] === 'file'; + const hasMediaUrl = !!props && ('image_url' in props || 'video_url' in props || 'audio_url' in props); + if (isFileBranch && !hasMediaUrl) { + (schema as Record).additionalProperties = false; + } + } + for (const key in components) { if (Object.prototype.hasOwnProperty.call(components, key)) { const uri = `#/components/schemas/${key}`; From ce8287314a04071914715e132e130676c7716781 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 7 Jul 2026 18:55:51 -0300 Subject: [PATCH 07/16] fix(lint): order deprecationWarningLogger import before canAccessRoom in getReadReceipts --- apps/meteor/ee/server/meteor-methods/getReadReceipts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts index 6ce07b96e0090..a1acb42695812 100644 --- a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts +++ b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts @@ -5,8 +5,8 @@ import { Messages } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { canAccessRoomIdAsync } from '../../../server/lib/authorization/canAccessRoom'; import { methodDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; +import { canAccessRoomIdAsync } from '../../../server/lib/authorization/canAccessRoom'; import { ReadReceipt } from '../lib/message-read-receipt/ReadReceipt'; declare module '@rocket.chat/ddp-client' { From 4251a871cfbf481f77130f8677bc80412db8b915 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 14 Jul 2026 11:10:34 -0300 Subject: [PATCH 08/16] fix(client): clear credentials on REST 401 so expired sessions redirect to login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DDP-routed calls cleared stored credentials on auth failure via ddpOverREST; direct sdk.rest calls (e.g. the migrated chat.sendMessage flow) bypassed it, leaving an expired session wedged instead of redirecting to login. Clear credentials globally in the REST client middleware on 401 (unauthenticated) only — never 403 (permission). --- apps/meteor/app/utils/client/lib/RestApiClient.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index 7cdb81beb7835..aa5fbb65013bb 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -3,6 +3,7 @@ import { RestClient } from '@rocket.chat/api-client'; import { invokeTwoFactorModal } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; +import { clearStoredCredentials } from '../../../../client/lib/sdk/ddpSdk'; import { STORAGE_KEYS, getStoredItem } from '../../../../client/lib/sdk/storage'; class RestApiClient extends RestClient { @@ -42,6 +43,14 @@ APIClient.use(async (request, next) => { return await next(...request); } catch (error) { if (error instanceof Response) { + // A 401 means the stored session token is no longer valid (expired or revoked + // server-side). DDP-routed calls cleared credentials via ddpOverREST; direct REST + // calls must do the same so the router falls through to the login page instead of + // leaving the user wedged. Only 401 (unauthenticated) — never 403 (authenticated but + // lacking permission), which must not log the user out. + if (error.status === 401) { + clearStoredCredentials(); + } const e = await error.json(); throw e; } From bb4fe002688a9bb696bee927d1f4dc284cec7742 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 16 Jul 2026 10:07:12 -0300 Subject: [PATCH 09/16] fix(api): correct getReadReceipts deprecationLogger path after develop reorg --- apps/meteor/ee/server/meteor-methods/getReadReceipts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts index a1acb42695812..8641800646c09 100644 --- a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts +++ b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts @@ -5,7 +5,7 @@ import { Messages } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { methodDeprecationLogger } from '../../../app/lib/server/lib/deprecationWarningLogger'; +import { methodDeprecationLogger } from '../../../server/lib/deprecationWarningLogger'; import { canAccessRoomIdAsync } from '../../../server/lib/authorization/canAccessRoom'; import { ReadReceipt } from '../lib/message-read-receipt/ReadReceipt'; From a66a571fad2f96381c54fe13cd61a7c753cb0089 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 17 Jul 2026 00:34:01 -0300 Subject: [PATCH 10/16] chore: fix import order and prettier formatting --- apps/meteor/ee/server/meteor-methods/getReadReceipts.ts | 2 +- apps/meteor/server/meteor-methods/messages/sendMessage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts index 8641800646c09..e54d18f356937 100644 --- a/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts +++ b/apps/meteor/ee/server/meteor-methods/getReadReceipts.ts @@ -5,8 +5,8 @@ import { Messages } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { methodDeprecationLogger } from '../../../server/lib/deprecationWarningLogger'; import { canAccessRoomIdAsync } from '../../../server/lib/authorization/canAccessRoom'; +import { methodDeprecationLogger } from '../../../server/lib/deprecationWarningLogger'; import { ReadReceipt } from '../lib/message-read-receipt/ReadReceipt'; declare module '@rocket.chat/ddp-client' { diff --git a/apps/meteor/server/meteor-methods/messages/sendMessage.ts b/apps/meteor/server/meteor-methods/messages/sendMessage.ts index fc1143123575e..a408e92db0121 100644 --- a/apps/meteor/server/meteor-methods/messages/sendMessage.ts +++ b/apps/meteor/server/meteor-methods/messages/sendMessage.ts @@ -13,10 +13,10 @@ import { RateLimiterClass as RateLimiter } from '../../lib/RateLimiter'; import { canSendMessageAsync } from '../../lib/authorization/canSendMessage'; import { hasPermissionAsync } from '../../lib/authorization/hasPermission'; import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; import { i18n } from '../../lib/i18n'; import { SystemLogger } from '../../lib/logger/system'; import { sendMessage } from '../../lib/messages/sendMessage'; -import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; import { metrics } from '../../lib/metrics'; import { settings } from '../../settings'; /** From febd98d0552b47b7e2bdba7f3c0ea588f4d160e0 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 27 Jul 2026 11:30:30 -0300 Subject: [PATCH 11/16] chore: deprecate getThreadMessages DDP method Its only client caller moved to GET /v1/chat.getThreadMessages in #40998, but the method was left without a deprecation log, so external consumers get no warning before 9.0.0 removes the registration. --- .../meteor/server/meteor-methods/messages/getThreadMessages.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts b/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts index e1b99ca9e940d..cce60acaf044b 100644 --- a/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts +++ b/apps/meteor/server/meteor-methods/messages/getThreadMessages.ts @@ -5,6 +5,7 @@ import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../lib/authorization'; import { callbacks } from '../../lib/callbacks'; +import { methodDeprecationLogger } from '../../lib/deprecationWarningLogger'; import { readThread } from '../../lib/messaging/threads/functions'; import { settings } from '../../settings'; @@ -19,6 +20,8 @@ const MAX_LIMIT = 100; Meteor.methods({ async getThreadMessages({ tmid, limit, skip }) { + methodDeprecationLogger.method('getThreadMessages', '9.0.0', '/v1/chat.getThreadMessages'); + if ((limit ?? 0) > MAX_LIMIT) { throw new Meteor.Error('error-not-allowed', `max limit: ${MAX_LIMIT}`, { method: 'getThreadMessages', From da463369a834c1d8818a0c00c7469b00cc817fc1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 27 Jul 2026 11:30:30 -0300 Subject: [PATCH 12/16] fix(client): only clear credentials when the 401 came from the current token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 401 handler cleared the stored credentials unconditionally, so a transient unauthenticated 401 during app boot logged the user straight back out. OmnichannelProvider's initializeLivechatInquiryStream calls GET /v1/livechat/config/routing (authRequired, no permission gate) while the session is still being established, and custom-sounds.list does the same; both 401 with no token attached. The wipe then dropped the session that had just been created and the router fell through to the login page, so #main-content never rendered — every omnichannel e2e spec in the shard timed out in beforeEach and took the worker down with it. Capture the stored token before the request and clear only when the token is unchanged and was present at send time. An unauthenticated boot call (no token) and a request still in flight across a re-login (different token) say nothing about the credentials currently stored; an actually expired or revoked token still matches and still logs out, which is what the original fix was for. --- .../app/utils/client/lib/RestApiClient.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index aa5fbb65013bb..92625428a058d 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -39,16 +39,24 @@ APIClient.handleTwoFactorChallenge(invokeTwoFactorModal); * */ APIClient.use(async (request, next) => { + const tokenAtSend = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + try { return await next(...request); } catch (error) { if (error instanceof Response) { - // A 401 means the stored session token is no longer valid (expired or revoked - // server-side). DDP-routed calls cleared credentials via ddpOverREST; direct REST - // calls must do the same so the router falls through to the login page instead of - // leaving the user wedged. Only 401 (unauthenticated) — never 403 (authenticated but - // lacking permission), which must not log the user out. - if (error.status === 401) { + // A 401 on a request that carried the *current* token means that token is no longer + // valid (expired or revoked server-side). DDP-routed calls cleared credentials via + // ddpOverREST; direct REST calls must do the same so the router falls through to the + // login page instead of leaving the user wedged. Only 401 (unauthenticated) — never + // 403 (authenticated but lacking permission), which must not log the user out. + // + // The token comparison is what keeps this from logging out a healthy session: an + // authRequired call fired before login completes (OmnichannelProvider's + // livechat/config/routing, custom-sounds.list) 401s with no token at send time, and a + // request still in flight across a re-login 401s carrying the previous session's token. + // Neither says anything about the credentials currently stored. + if (error.status === 401 && tokenAtSend && tokenAtSend === getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)) { clearStoredCredentials(); } const e = await error.json(); From 6e1b3a9a7de94428c0d4d21e7a3d99cdb75b4cb7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 27 Jul 2026 12:14:30 -0300 Subject: [PATCH 13/16] fix(client): confirm a dead session with /v1/me before clearing credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 401 from an arbitrary endpoint does not prove the stored token is dead. The trace from the failing omnichannel e2e shard shows the same token answering 200 on 108 requests and 401 on four bursts of GET /v1/livechat/config/routing, whose body is the auth middleware's "You must be logged in to do this." — the per-route rate limiter throttles the auth check and reports it as unauthenticated, exactly the failure mode already documented in client/startup/startup.ts. Clearing on that 401 dropped a live session, the router fell through to the login page, #main-content never rendered, and every omnichannel spec in the shard timed out in beforeEach. Route the decision through /v1/me instead: a 401 elsewhere triggers one single-flight re-check, and only a 401 from /v1/me itself clears the credentials. The expired-token redirect this was added for still works — that is the same endpoint synchronizeUserData already relies on in #40870 — while a throttled or boot-time 401 no longer costs the user their session. The wipe is irreversible: the login token lives only in localStorage, so there is nothing to resume from once it is gone. --- .../app/utils/client/lib/RestApiClient.ts | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index 92625428a058d..ef6f4eb99c4cb 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -38,26 +38,47 @@ APIClient.handleTwoFactorChallenge(invokeTwoFactorModal); * This middleware will throw the error object instead. * */ +/** + * Wiping the stored credentials is irreversible — the login token only lives in localStorage, so + * once it is gone there is nothing left to resume the session with, and the user is logged out for + * real. A 401 from an arbitrary endpoint is not enough evidence to spend that: an auth check can be + * throttled by the per-route rate limiter and answer 401 while the token is perfectly valid (see + * the note in client/startup/startup.ts), and boot-time calls such as OmnichannelProvider's + * livechat/config/routing fire before the session is established. + * + * `/v1/me` is the authority on whether the session is still alive. Re-check it once per burst; if it + * also 401s, this same middleware clears the credentials on that response — that is the only place + * the wipe happens. + */ +let sessionRecheck: Promise | undefined; + +const recheckSession = (): Promise => { + sessionRecheck ??= APIClient.get('/v1/me') + .catch(() => undefined) + .finally(() => { + sessionRecheck = undefined; + }); + + return sessionRecheck; +}; + APIClient.use(async (request, next) => { + const [endpoint] = request; const tokenAtSend = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); try { return await next(...request); } catch (error) { if (error instanceof Response) { - // A 401 on a request that carried the *current* token means that token is no longer - // valid (expired or revoked server-side). DDP-routed calls cleared credentials via - // ddpOverREST; direct REST calls must do the same so the router falls through to the - // login page instead of leaving the user wedged. Only 401 (unauthenticated) — never - // 403 (authenticated but lacking permission), which must not log the user out. - // - // The token comparison is what keeps this from logging out a healthy session: an - // authRequired call fired before login completes (OmnichannelProvider's - // livechat/config/routing, custom-sounds.list) 401s with no token at send time, and a - // request still in flight across a re-login 401s carrying the previous session's token. - // Neither says anything about the credentials currently stored. + // Only 401 (unauthenticated) — never 403 (authenticated but lacking permission), which + // must not log the user out. The token comparison keeps a request that was in flight + // across a re-login from evicting the session it does not belong to. if (error.status === 401 && tokenAtSend && tokenAtSend === getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)) { - clearStoredCredentials(); + if (endpoint === '/v1/me') { + clearStoredCredentials(); + } else { + void recheckSession(); + } } const e = await error.json(); throw e; From 6a26bc5a9bd473d1fa3b278fd263330fabce5cae Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 27 Jul 2026 13:28:08 -0300 Subject: [PATCH 14/16] revert: drop the REST 401 credential wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the RestApiClient middleware back to develop. Clearing the stored credentials on any REST 401 logs out live sessions: the auth middleware answers 401 whenever (X-User-Id, hashed X-Auth-Token) has no match, which also covers a call issued before the session is established and a token that vanished server-side, and from 9.0.0 ApiClass maps a thrown `error-unauthorized` — the code this codebase uses for permission denials — to 401 as well. None of those mean the stored token is dead, and the wipe is irreversible because the token only lives in localStorage. The omnichannel e2e shards that fail on this branch and pass on develop are the symptom: an auxiliary context boots, takes a 401, loses its credentials, lands on the login page, and #main-content never renders. Gating the wipe behind a /v1/me re-check narrowed it but did not close it, because /v1/me can take the same 401. Expired sessions already redirect to login through synchronizeUserData in client/startup/startup.ts (#40870), which asks /v1/me and keeps the token-stable guard. If a gap remains outside that path it needs its own change, after the 9.0.0 error-unauthorized mapping is fixed so that a 401 means one thing again. --- .../app/utils/client/lib/RestApiClient.ts | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index ef6f4eb99c4cb..7cdb81beb7835 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -3,7 +3,6 @@ import { RestClient } from '@rocket.chat/api-client'; import { invokeTwoFactorModal } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; -import { clearStoredCredentials } from '../../../../client/lib/sdk/ddpSdk'; import { STORAGE_KEYS, getStoredItem } from '../../../../client/lib/sdk/storage'; class RestApiClient extends RestClient { @@ -38,48 +37,11 @@ APIClient.handleTwoFactorChallenge(invokeTwoFactorModal); * This middleware will throw the error object instead. * */ -/** - * Wiping the stored credentials is irreversible — the login token only lives in localStorage, so - * once it is gone there is nothing left to resume the session with, and the user is logged out for - * real. A 401 from an arbitrary endpoint is not enough evidence to spend that: an auth check can be - * throttled by the per-route rate limiter and answer 401 while the token is perfectly valid (see - * the note in client/startup/startup.ts), and boot-time calls such as OmnichannelProvider's - * livechat/config/routing fire before the session is established. - * - * `/v1/me` is the authority on whether the session is still alive. Re-check it once per burst; if it - * also 401s, this same middleware clears the credentials on that response — that is the only place - * the wipe happens. - */ -let sessionRecheck: Promise | undefined; - -const recheckSession = (): Promise => { - sessionRecheck ??= APIClient.get('/v1/me') - .catch(() => undefined) - .finally(() => { - sessionRecheck = undefined; - }); - - return sessionRecheck; -}; - APIClient.use(async (request, next) => { - const [endpoint] = request; - const tokenAtSend = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); - try { return await next(...request); } catch (error) { if (error instanceof Response) { - // Only 401 (unauthenticated) — never 403 (authenticated but lacking permission), which - // must not log the user out. The token comparison keeps a request that was in flight - // across a re-login from evicting the session it does not belong to. - if (error.status === 401 && tokenAtSend && tokenAtSend === getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)) { - if (endpoint === '/v1/me') { - clearStoredCredentials(); - } else { - void recheckSession(); - } - } const e = await error.json(); throw e; } From e0d5425223d0e051a9ccc6108db810bbd1fe4564 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 27 Jul 2026 14:21:23 -0300 Subject: [PATCH 15/16] fix(client): clear credentials on a 401 from a mutation, not from boot reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstates the expired-session redirect for the migrated send path, narrowed to the case that actually carries the signal. session-expiration-redirect.spec.ts:87 deletes the login tokens server-side and clicks send: with sendMessage now on POST /v1/chat.sendMessage, nothing cleared the stored credentials, so the router never fell through to LoginPage. The sibling test at :49 kept passing because message search is still a DDP method and ddpOverREST clears there — the gap was only on the path this PR migrated. Clearing on every REST 401 is what broke the omnichannel e2e shard: an auxiliary context boots, OmnichannelProvider's GET livechat/config/routing answers 401 with the same "You must be logged in to do this." before the session is up, the credentials are wiped, and #main-content never renders. The message body does not separate the two cases, but the request does: ddpOverREST only ever cleared for method calls, never for background fetches. Restricting the wipe to POST/PUT/ DELETE reproduces that, so a session that dies while idle is noticed on the user's next write — the pre-migration behaviour. Keeps the token-stable guard so a request in flight across a re-login cannot evict the session it does not belong to, and leaves 403 alone. --- .../app/utils/client/lib/RestApiClient.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/meteor/app/utils/client/lib/RestApiClient.ts b/apps/meteor/app/utils/client/lib/RestApiClient.ts index 7cdb81beb7835..2498ff07f4688 100644 --- a/apps/meteor/app/utils/client/lib/RestApiClient.ts +++ b/apps/meteor/app/utils/client/lib/RestApiClient.ts @@ -3,6 +3,7 @@ import { RestClient } from '@rocket.chat/api-client'; import { invokeTwoFactorModal } from '../../../../client/lib/2fa/process2faReturn'; import { baseURI } from '../../../../client/lib/baseURI'; +import { clearStoredCredentials } from '../../../../client/lib/sdk/ddpSdk'; import { STORAGE_KEYS, getStoredItem } from '../../../../client/lib/sdk/storage'; class RestApiClient extends RestClient { @@ -37,11 +38,31 @@ APIClient.handleTwoFactorChallenge(invokeTwoFactorModal); * This middleware will throw the error object instead. * */ +/** + * A 401 means the request reached an authRequired route without a session the server recognizes. + * That is only evidence of an expired session when the request was an action the user just took: + * boot-time reads (OmnichannelProvider's livechat/config/routing, custom-sounds.list) fire before + * the session is established and answer 401 with the same message, and clearing on those logs out + * a session that is coming up. Restricting the wipe to mutations mirrors the DDP path this replaced + * — ddpOverREST only clears credentials for method calls, never for background fetches — so a + * session that dies while idle is noticed on the user's next write, exactly as before. + */ +const isMutation = (method: string): boolean => method === 'POST' || method === 'PUT' || method === 'DELETE'; + APIClient.use(async (request, next) => { + const [, method] = request; + const tokenAtSend = getStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + try { return await next(...request); } catch (error) { if (error instanceof Response) { + // Never 403 (authenticated but lacking permission) — that must not log the user out. + // The token comparison keeps a request that was in flight across a re-login from + // evicting the session it does not belong to. + if (error.status === 401 && isMutation(method) && tokenAtSend && tokenAtSend === getStoredItem(STORAGE_KEYS.LOGIN_TOKEN)) { + clearStoredCredentials(); + } const e = await error.json(); throw e; } From 8b884b32f39a67d9380baaf35e3b906d43b80ff7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 27 Jul 2026 19:07:55 -0300 Subject: [PATCH 16/16] fix(client): await the game-center invite message and stabilize the read-receipts subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects both review bots flagged. The play-together message was detached with `void`, so a rejection escaped the surrounding try/catch as an unhandled rejection and the freshly created group was left without its message. It was awaited before the REST migration; await it again. ReadReceiptsModal built its query key as a fresh array literal on every render and passed it as an effect dependency, so the notify-room subscription tore down and re-registered on every render — messagesRead events landing in that window were lost. Build the key from a helper at each use and depend on messageId, matching what useThreadMessagesQuery already does. --- .../apps/gameCenter/GameCenterInvitePlayersModal.tsx | 2 +- .../modals/ReadReceiptsModal/ReadReceiptsModal.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx index 1ea3e5f8dcca8..01f5b36373c5c 100644 --- a/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx +++ b/apps/meteor/client/apps/gameCenter/GameCenterInvitePlayersModal.tsx @@ -34,7 +34,7 @@ const GameCenterInvitePlayersModal = ({ game, onClose }: IGameCenterInvitePlayer roomCoordinator.openRouteLink(group.t, { rid: group._id, name: group.name }); if (openedRoom === group._id) { - void sdk.rest.post('/v1/chat.sendMessage', { + await sdk.rest.post('/v1/chat.sendMessage', { message: { _id: Random.id(), rid: group._id, diff --git a/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx b/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx index da242168435fc..9ef49032f045b 100644 --- a/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx +++ b/apps/meteor/client/views/room/modals/ReadReceiptsModal/ReadReceiptsModal.tsx @@ -15,6 +15,8 @@ type ReadReceiptsModalProps = { onClose: () => void; }; +const readReceiptsQueryKey = (messageId: IMessage['_id']) => ['read-receipts', messageId] as const; + const ReadReceiptsModal = ({ messageId, rid, onClose }: ReadReceiptsModalProps): ReactElement => { const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); @@ -23,10 +25,8 @@ const ReadReceiptsModal = ({ messageId, rid, onClose }: ReadReceiptsModalProps): const getReadReceipts = useEndpoint('GET', '/v1/chat.getMessageReadReceipts'); - const queryKey = ['read-receipts', messageId]; - const readReceiptsResult = useQuery({ - queryKey, + queryKey: readReceiptsQueryKey(messageId), queryFn: async () => (await getReadReceipts({ messageId })).receipts.map(mapReadReceiptFromApi), }); @@ -35,9 +35,9 @@ const ReadReceiptsModal = ({ messageId, rid, onClose }: ReadReceiptsModalProps): return; } return subscribeToNotifyRoom(`${rid}/messagesRead`, () => { - queryClient.invalidateQueries({ queryKey }); + queryClient.invalidateQueries({ queryKey: readReceiptsQueryKey(messageId) }); }); - }, [rid, queryClient, queryKey, subscribeToNotifyRoom]); + }, [rid, messageId, queryClient, subscribeToNotifyRoom]); useEffect(() => { if (readReceiptsResult.isError) {