From 4d5449ac187320942395d56a119d0d7bbb3ca9e4 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Thu, 28 May 2026 19:10:41 +0200 Subject: [PATCH 1/5] fix: narrow outdated notification check to duplicate-risk events The global lastEventDate outdated filter was applied to every notification stream event. When two unrelated conversations share the same millisecond timestamp, a later OTR message can be silently dropped after an earlier one advances lastEventDate. Scope the check to event types that can be handled locally and replayed on the stream (member-join, member-leave, create, rename, protocol-update, message-timer-update, receipt-mode-update, add-permission-update). OTR and MLS content events are no longer filtered this way. Introduce isOutdatedNotificationStreamEvent in core and reuse it from NotificationService and EventValidator. --- .../repositories/event/EventValidator.test.ts | 39 +++++++++++- .../repositories/event/EventValidator.ts | 17 ++---- libraries/core/src/notification/index.ts | 1 + .../src/notification/notificationService.ts | 13 +--- ...tdatedNotificationStreamEventTypes.test.ts | 58 ++++++++++++++++++ .../outdatedNotificationStreamEventTypes.ts | 60 +++++++++++++++++++ 6 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 libraries/core/src/notification/outdatedNotificationStreamEventTypes.test.ts create mode 100644 libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts diff --git a/apps/webapp/src/script/repositories/event/EventValidator.test.ts b/apps/webapp/src/script/repositories/event/EventValidator.test.ts index 06b1f03d797..52855318d4b 100644 --- a/apps/webapp/src/script/repositories/event/EventValidator.test.ts +++ b/apps/webapp/src/script/repositories/event/EventValidator.test.ts @@ -18,7 +18,12 @@ */ import {CONVERSATION_TYPING} from '@wireapp/api-client/lib/conversation/data/'; -import {ConversationTypingEvent, CONVERSATION_EVENT} from '@wireapp/api-client/lib/event/'; +import { + ConversationMemberJoinEvent, + ConversationOtrMessageAddEvent, + ConversationTypingEvent, + CONVERSATION_EVENT, +} from '@wireapp/api-client/lib/event/'; import {EventSource} from './EventSource'; import {EventValidation} from './EventValidation'; @@ -40,5 +45,37 @@ describe('EventValidator', () => { expect(result).toBe(EventValidation.VALID); }); + + it('ignores duplicate-risk events replayed on the notification stream', () => { + const eventTime = '2026-05-28T06:37:31.673Z'; + + const event: ConversationMemberJoinEvent = { + conversation: '939d0410-a17e-499e-804b-e7a8503415ae', + data: {user_ids: ['30c6d863-6d9d-41ba-882d-fbcdc32b75a8']}, + from: '30c6d863-6d9d-41ba-882d-fbcdc32b75a8', + time: eventTime, + type: CONVERSATION_EVENT.MEMBER_JOIN, + }; + + const result = validateEvent(event, EventSource.NOTIFICATION_STREAM, eventTime); + + expect(result).toBe(EventValidation.OUTDATED_TIMESTAMP); + }); + + it('allows OTR messages with the same timestamp as lastEventDate on the notification stream', () => { + const eventTime = '2026-05-28T06:37:31.673Z'; + + const event: ConversationOtrMessageAddEvent = { + conversation: '939d0410-a17e-499e-804b-e7a8503415ae', + data: {recipient: 'a66c7dc1e8ffa326', sender: 'c9878bbba7a1f9ab', text: 'encrypted'}, + from: '30c6d863-6d9d-41ba-882d-fbcdc32b75a8', + time: eventTime, + type: CONVERSATION_EVENT.OTR_MESSAGE_ADD, + }; + + const result = validateEvent(event, EventSource.NOTIFICATION_STREAM, eventTime); + + expect(result).toBe(EventValidation.VALID); + }); }); }); diff --git a/apps/webapp/src/script/repositories/event/EventValidator.ts b/apps/webapp/src/script/repositories/event/EventValidator.ts index b32188ae175..811eafc8338 100644 --- a/apps/webapp/src/script/repositories/event/EventValidator.ts +++ b/apps/webapp/src/script/repositories/event/EventValidator.ts @@ -18,25 +18,20 @@ */ import {CONVERSATION_EVENT, USER_EVENT} from '@wireapp/api-client/lib/event/'; +import {isOutdatedNotificationStreamEvent} from '@wireapp/core/lib/notification'; import {EventSource} from './EventSource'; import {EventValidation} from './EventValidation'; export function validateEvent( - event: {time: string; type: CONVERSATION_EVENT | USER_EVENT}, + event: {time?: string; type: CONVERSATION_EVENT | USER_EVENT}, source: EventSource, lastEventDate?: string, ): EventValidation { - const eventTime = event.time; - const isFromNotificationStream = source === EventSource.NOTIFICATION_STREAM; - const shouldCheckEventDate = !!eventTime && isFromNotificationStream && lastEventDate; - - if (shouldCheckEventDate) { - /** This check prevents duplicated "You joined" system messages. */ - const isOutdated = new Date(lastEventDate).getTime() >= new Date(eventTime).getTime(); - if (isOutdated) { - return EventValidation.OUTDATED_TIMESTAMP; - } + if ( + isOutdatedNotificationStreamEvent(event, source, lastEventDate !== undefined ? new Date(lastEventDate) : undefined) + ) { + return EventValidation.OUTDATED_TIMESTAMP; } return EventValidation.VALID; diff --git a/libraries/core/src/notification/index.ts b/libraries/core/src/notification/index.ts index d83a8bef610..5eaf8d07cb1 100644 --- a/libraries/core/src/notification/index.ts +++ b/libraries/core/src/notification/index.ts @@ -19,3 +19,4 @@ export * from './notificationService'; export * from './notificationSource.types'; +export * from './outdatedNotificationStreamEventTypes'; diff --git a/libraries/core/src/notification/notificationService.ts b/libraries/core/src/notification/notificationService.ts index db38a2f05be..6667a942cd4 100644 --- a/libraries/core/src/notification/notificationService.ts +++ b/libraries/core/src/notification/notificationService.ts @@ -28,6 +28,7 @@ import {CRUDEngine, error as StoreEngineError} from '@wireapp/store-engine'; import {NotificationBackendRepository} from './notificationBackendRepository'; import {NotificationDatabaseRepository} from './notificationDatabaseRepository'; import {NotificationSource} from './notificationSource.types'; +import {isOutdatedNotificationStreamEvent} from './outdatedNotificationStreamEventTypes'; import {ConversationService} from '../conversation'; import {CoreError, NotificationError} from '../coreError'; @@ -195,16 +196,8 @@ export class NotificationService extends TypedEventEmitter { * @param source * @param lastEventDate? */ - private isOutdatedEvent(event: {time: string}, source: NotificationSource, lastEventDate?: Date) { - const isFromNotificationStream = source === NotificationSource.NOTIFICATION_STREAM; - const shouldCheckEventDate = event.time.length > 0 && isFromNotificationStream && lastEventDate !== undefined; - - if (shouldCheckEventDate) { - /** This check prevents duplicated "You joined" system messages. */ - const isOutdated = lastEventDate.getTime() >= new Date(event.time).getTime(); - return isOutdated; - } - return false; + private isOutdatedEvent(event: {time: string; type: string}, source: NotificationSource, lastEventDate?: Date) { + return isOutdatedNotificationStreamEvent(event, source, lastEventDate); } public async *handleNotification( diff --git a/libraries/core/src/notification/outdatedNotificationStreamEventTypes.test.ts b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.test.ts new file mode 100644 index 00000000000..151f70b5346 --- /dev/null +++ b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.test.ts @@ -0,0 +1,58 @@ +/* + * Wire + * Copyright (C) 2018 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CONVERSATION_EVENT} from '@wireapp/api-client/lib/event'; + +import {NotificationSource} from './notificationSource.types'; +import {isOutdatedNotificationStreamEvent} from './outdatedNotificationStreamEventTypes'; + +describe('isOutdatedNotificationStreamEvent', () => { + const eventTime = '2026-05-28T06:37:31.673Z'; + const lastEventDate = new Date(eventTime); + + it('marks duplicate-risk events with an equal timestamp as outdated on the notification stream', () => { + expect( + isOutdatedNotificationStreamEvent( + {time: eventTime, type: CONVERSATION_EVENT.MEMBER_JOIN}, + NotificationSource.NOTIFICATION_STREAM, + lastEventDate, + ), + ).toBe(true); + }); + + it('does not mark OTR message events with an equal timestamp as outdated', () => { + expect( + isOutdatedNotificationStreamEvent( + {time: eventTime, type: CONVERSATION_EVENT.OTR_MESSAGE_ADD}, + NotificationSource.NOTIFICATION_STREAM, + lastEventDate, + ), + ).toBe(false); + }); + + it('does not apply the outdated check outside the notification stream', () => { + expect( + isOutdatedNotificationStreamEvent( + {time: eventTime, type: CONVERSATION_EVENT.MEMBER_JOIN}, + NotificationSource.WEBSOCKET, + lastEventDate, + ), + ).toBe(false); + }); +}); diff --git a/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts new file mode 100644 index 00000000000..06983c25c3c --- /dev/null +++ b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2018 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CONVERSATION_EVENT} from '@wireapp/api-client/lib/event'; + +import {NotificationSource} from './notificationSource.types'; + +/** + * Event types that can be handled locally (API response or pre-app init) and then + * replayed on the notification stream with the same timestamp. + * + * @see ConversationJoin.tsx (member-join via guest link) + * @see ConversationRepository injectEvent(..., BACKEND_RESPONSE) + */ +export const NOTIFICATION_STREAM_DUPLICATE_RISK_EVENT_TYPES: ReadonlySet = new Set([ + CONVERSATION_EVENT.MEMBER_JOIN, + CONVERSATION_EVENT.MEMBER_LEAVE, + CONVERSATION_EVENT.CREATE, + CONVERSATION_EVENT.RENAME, + CONVERSATION_EVENT.PROTOCOL_UPDATE, + CONVERSATION_EVENT.MESSAGE_TIMER_UPDATE, + CONVERSATION_EVENT.RECEIPT_MODE_UPDATE, + CONVERSATION_EVENT.ADD_PERMISSION_UPDATE, +]); + +export function isOutdatedNotificationStreamEvent( + event: {time?: string; type: string}, + source: string, + lastEventDate?: Date, +): boolean { + if (source !== NotificationSource.NOTIFICATION_STREAM) { + return false; + } + + if (event.time === undefined || event.time.length === 0 || lastEventDate === undefined) { + return false; + } + + if (!NOTIFICATION_STREAM_DUPLICATE_RISK_EVENT_TYPES.has(event.type)) { + return false; + } + + return lastEventDate.getTime() >= new Date(event.time).getTime(); +} From f8fd896d2b33f16ba47eb258e07d7e32ed6da289 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Fri, 29 May 2026 14:27:37 +0200 Subject: [PATCH 2/5] rename variable to notificationSource --- .../src/notification/outdatedNotificationStreamEventTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts index 06983c25c3c..2c4dac4c228 100644 --- a/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts +++ b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts @@ -41,10 +41,10 @@ export const NOTIFICATION_STREAM_DUPLICATE_RISK_EVENT_TYPES: ReadonlySet export function isOutdatedNotificationStreamEvent( event: {time?: string; type: string}, - source: string, + notificationSource: string, lastEventDate?: Date, ): boolean { - if (source !== NotificationSource.NOTIFICATION_STREAM) { + if (notificationSource !== NotificationSource.NOTIFICATION_STREAM) { return false; } From ed3d61bd1c7b481df406dd70f374afcbb223b192 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Fri, 29 May 2026 14:30:52 +0200 Subject: [PATCH 3/5] use is in logic --- .../src/notification/outdatedNotificationStreamEventTypes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts index 2c4dac4c228..0da35936daf 100644 --- a/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts +++ b/libraries/core/src/notification/outdatedNotificationStreamEventTypes.ts @@ -17,6 +17,7 @@ * */ +import is from '@sindresorhus/is'; import {CONVERSATION_EVENT} from '@wireapp/api-client/lib/event'; import {NotificationSource} from './notificationSource.types'; @@ -48,7 +49,7 @@ export function isOutdatedNotificationStreamEvent( return false; } - if (event.time === undefined || event.time.length === 0 || lastEventDate === undefined) { + if (!is.nonEmptyString(event.time) || is.nullOrUndefined(lastEventDate)) { return false; } From f6e2a7e30d0080a284a7b59b29f72dd947f599c2 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Fri, 29 May 2026 15:04:47 +0200 Subject: [PATCH 4/5] remove optional type --- apps/webapp/src/script/repositories/event/EventValidator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/src/script/repositories/event/EventValidator.ts b/apps/webapp/src/script/repositories/event/EventValidator.ts index 811eafc8338..25315975765 100644 --- a/apps/webapp/src/script/repositories/event/EventValidator.ts +++ b/apps/webapp/src/script/repositories/event/EventValidator.ts @@ -24,7 +24,7 @@ import {EventSource} from './EventSource'; import {EventValidation} from './EventValidation'; export function validateEvent( - event: {time?: string; type: CONVERSATION_EVENT | USER_EVENT}, + event: {time: string; type: CONVERSATION_EVENT | USER_EVENT}, source: EventSource, lastEventDate?: string, ): EventValidation { From 24c3189dcdd8b5773d96f9b1b74e0280f44d544d Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Fri, 29 May 2026 15:06:51 +0200 Subject: [PATCH 5/5] refactor lastEventDate --- .../src/script/repositories/event/EventValidator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/src/script/repositories/event/EventValidator.ts b/apps/webapp/src/script/repositories/event/EventValidator.ts index 25315975765..7b29fa3b5ba 100644 --- a/apps/webapp/src/script/repositories/event/EventValidator.ts +++ b/apps/webapp/src/script/repositories/event/EventValidator.ts @@ -26,11 +26,11 @@ import {EventValidation} from './EventValidation'; export function validateEvent( event: {time: string; type: CONVERSATION_EVENT | USER_EVENT}, source: EventSource, - lastEventDate?: string, + lastEventDateString?: string, ): EventValidation { - if ( - isOutdatedNotificationStreamEvent(event, source, lastEventDate !== undefined ? new Date(lastEventDate) : undefined) - ) { + const lastEventDate = lastEventDateString ? new Date(lastEventDateString) : undefined; + + if (isOutdatedNotificationStreamEvent(event, source, lastEventDate)) { return EventValidation.OUTDATED_TIMESTAMP; }