From 007ff63a06a826d85066969485008c90e58c7b7b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 27 Mar 2026 16:02:36 -0300 Subject: [PATCH 1/4] fix: enhance invite handling for re-join scenarios and prevent duplicate events --- .../src/services/invite.service.spec.ts | 497 ++++++++++++++++++ .../src/services/invite.service.ts | 21 +- .../src/services/room.service.ts | 18 +- .../src/services/state.service.ts | 12 +- 4 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 packages/federation-sdk/src/services/invite.service.spec.ts diff --git a/packages/federation-sdk/src/services/invite.service.spec.ts b/packages/federation-sdk/src/services/invite.service.spec.ts new file mode 100644 index 00000000..2bad0c30 --- /dev/null +++ b/packages/federation-sdk/src/services/invite.service.spec.ts @@ -0,0 +1,497 @@ +import { beforeEach, describe, expect, it, spyOn } from 'bun:test'; + +import type { EventStore } from '@rocket.chat/federation-core'; +import type { EventID, PduCreateEventContent, RoomVersion } from '@rocket.chat/federation-room'; +import * as room from '@rocket.chat/federation-room'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; +import { type WithId } from 'mongodb'; + +import type { ConfigService } from './config.service'; +import { DatabaseConnectionService } from './database-connection.service'; +import type { EventAuthorizationService } from './event-authorization.service'; +import type { EventEmitterService } from './event-emitter.service'; +import type { EventService } from './event.service'; +import type { FederationValidationService } from './federation-validation.service'; +import type { FederationService } from './federation.service'; +import { InviteService } from './invite.service'; +import type { ProfilesService } from './profiles.service'; +import { StateService } from './state.service'; +import { EventRepository } from '../repositories/event.repository'; +import { StateGraphRepository } from '../repositories/state-graph.repository'; +import type { StateGraphStore } from '../repositories/state-graph.repository'; + +function getDefaultFields() { + return { + auth_events: [], + prev_events: [], + origin_server_ts: Date.now(), + depth: 0, + }; +} + +describe('InviteService', async () => { + if (!process.env.RUN_MONGO_TESTS) { + console.warn('Skipping tests that require a database'); + return; + } + + const localServerName = 'local.server.com'; + + const databaseConfig = { + uri: process.env.MONGO_URI || 'mongodb://localhost:27017?directConnection=true', + name: 'matrix_test', + poolSize: 100, + }; + + const configServiceInstance = { + getSigningKey: async () => { + /* noop */ + }, + serverName: localServerName, + getConfig: (key: string) => { + if (key === 'invite') { + return { allowedEncryptedRooms: true, allowedNonPrivateRooms: true }; + } + return {}; + }, + } as unknown as ConfigService; + + const database = new DatabaseConnectionService(databaseConfig); + + const eventCollection = (await database.getDb()).collection>('events_test'); + const stateGraphCollection = (await database.getDb()).collection('state_graph_test'); + + const eventRepository = new EventRepository(eventCollection); + const stateGraphRepository = new StateGraphRepository(stateGraphCollection); + + const stateService = new StateService(stateGraphRepository, eventRepository, configServiceInstance, { + notify: () => Promise.resolve(), + } as unknown as EventService); + + const emitterService = { + emit: () => Promise.resolve(), + } as unknown as EventEmitterService; + + const eventAuthorizationService = { + checkAclForInvite: () => Promise.resolve(), + } as unknown as EventAuthorizationService; + + const federationService = { + inviteUser: () => Promise.resolve(), + sendEventToAllServersInRoom: () => Promise.resolve(), + } as unknown as FederationService; + + const federationValidationService = { + validateOutboundInvite: () => Promise.resolve(), + } as unknown as FederationValidationService; + + const profilesService = { + queryProfile: () => Promise.resolve(undefined), + } as unknown as ProfilesService; + + const inviteService = new InviteService( + federationService, + stateService, + configServiceInstance, + eventAuthorizationService, + emitterService, + eventRepository, + federationValidationService, + profilesService, + ); + + beforeEach(async () => { + await Promise.all([eventCollection.deleteMany({}), stateGraphCollection.deleteMany({})]); + }); + + const createRoom = async (joinRule: 'public' | 'invite', creator: room.UserID = '@alice:remote.server.com' as room.UserID) => { + const roomCreateEvent = PersistentEventFactory.newCreateEvent(creator, PersistentEventFactory.defaultRoomVersion); + await stateService.handlePdu(roomCreateEvent); + + const roomVersion: RoomVersion = roomCreateEvent.getContent().room_version; + + const creatorMembershipEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: creator, + state_key: creator, + content: { membership: 'join' }, + ...getDefaultFields(), + }, + roomVersion, + ); + await stateService.handlePdu(creatorMembershipEvent); + + const powerLevelEvent = await stateService.buildEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomCreateEvent.roomId, + sender: creator, + state_key: '', + content: { + users: { [creator]: 100 }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, + }, + ...getDefaultFields(), + }, + roomVersion, + ); + await stateService.handlePdu(powerLevelEvent); + + const joinRuleEvent = await stateService.buildEvent<'m.room.join_rules'>( + { + room_id: roomCreateEvent.roomId, + sender: creator, + content: { join_rule: joinRule }, + type: 'm.room.join_rules', + state_key: '', + ...getDefaultFields(), + }, + roomVersion, + ); + await stateService.handlePdu(joinRuleEvent); + + return { roomCreateEvent, roomVersion, creator }; + }; + + const setUserMembership = async ( + roomId: string, + userId: string, + membership: room.PduMembershipEventContent['membership'], + sender?: string, + ) => { + const roomVersion = await stateService.getRoomVersion(roomId); + const membershipEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomId as room.RoomID, + sender: (sender || userId) as room.UserID, + state_key: userId as room.UserID, + content: { membership }, + ...getDefaultFields(), + }, + roomVersion, + ); + await stateService.handlePdu(membershipEvent); + return membershipEvent; + }; + + const joinUser = (roomId: string, userId: string) => setUserMembership(roomId, userId, 'join'); + const leaveUser = (roomId: string, userId: string) => setUserMembership(roomId, userId, 'leave'); + const inviteUser = (roomId: string, userId: string, sender: string) => setUserMembership(roomId, userId, 'invite', sender); + + describe('processInitialState - re-join after leave', () => { + it('should only notify for new events, not re-emit already known events', async () => { + const remoteCreator = '@alice:remote.server.com' as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Create room and have user join then leave + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + await inviteUser(roomId, localUser, remoteCreator); + await joinUser(roomId, localUser); + await leaveUser(roomId, localUser); + + // 2. Simulate re-invite from remote server (happens on the remote side before send_join) + const reInviteEvent = await inviteUser(roomId, localUser, remoteCreator); + + // 3. Track notify calls + const notifyCalls: Array<{ eventId: string; type: string }> = []; + const notifySpy = spyOn(stateService.eventService as any, 'notify').mockImplementation( + async (event: { eventId: string; event: { type: string } }) => { + notifyCalls.push({ eventId: event.eventId, type: event.event.type }); + }, + ); + + // 4. Build state from send_join with a new join event + const existingState = await stateService.getLatestRoomState(roomId); + const createPdu = existingState.get('m.room.create:')!; + const powerLevelsPdu = existingState.get('m.room.power_levels:')!; + const joinRulesPdu = existingState.get('m.room.join_rules:')!; + const creatorMemberPdu = existingState.get(`m.room.member:${remoteCreator}`)!; + + const rejoinEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomId, + sender: localUser, + state_key: localUser, + content: { membership: 'join' }, + ...getDefaultFields(), + }, + roomVersion, + ); + + const statePdus = [powerLevelsPdu.event, joinRulesPdu.event, creatorMemberPdu.event, rejoinEvent.event]; + + const authChain = [createPdu.event, creatorMemberPdu.event, powerLevelsPdu.event, joinRulesPdu.event, reInviteEvent.event]; + + // 5. Call processInitialState on the EXISTING room + await stateService.processInitialState(statePdus, authChain); + + // 6. Only the new join event should be notified — NOT the already-known events + // (create, power_levels, join_rules, creator membership, invite, leave, re-invite) + expect(notifyCalls.length).toBe(1); + expect(notifyCalls[0].eventId).toBe(rejoinEvent.eventId); + expect(notifyCalls[0].type).toBe('m.room.member'); + + notifySpy.mockRestore(); + }); + + it('should update room state correctly when processInitialState is called on re-join', async () => { + const remoteCreator = '@alice:remote.server.com' as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Create room, user joins and leaves + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + await inviteUser(roomId, localUser, remoteCreator); + await joinUser(roomId, localUser); + await leaveUser(roomId, localUser); + + // Verify user is not in room + const stateAfterLeave = await stateService.getLatestRoomState2(roomId); + expect(stateAfterLeave.isUserInRoom(localUser)).toBe(false); + + // 2. Simulate re-invite (happens on remote before send_join) + const reInviteEvent = await inviteUser(roomId, localUser, remoteCreator); + + // 3. Build state as if from send_join (simulating re-join) + const existingState = await stateService.getLatestRoomState(roomId); + const createPdu = existingState.get('m.room.create:')!; + const powerLevelsPdu = existingState.get('m.room.power_levels:')!; + const joinRulesPdu = existingState.get('m.room.join_rules:')!; + const creatorMemberPdu = existingState.get(`m.room.member:${remoteCreator}`)!; + + const rejoinEvent = await stateService.buildEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomId, + sender: localUser, + state_key: localUser, + content: { membership: 'join' }, + ...getDefaultFields(), + }, + roomVersion, + ); + + const statePdus = [powerLevelsPdu.event, joinRulesPdu.event, creatorMemberPdu.event, rejoinEvent.event]; + + const authChain = [createPdu.event, creatorMemberPdu.event, powerLevelsPdu.event, joinRulesPdu.event, reInviteEvent.event]; + + // 4. Call processInitialState on existing room + await stateService.processInitialState(statePdus, authChain); + + // 5. Verify the join event is now stored in the DB + const storedJoinEvent = await eventRepository.findById(rejoinEvent.eventId); + expect(storedJoinEvent).not.toBeNull(); + expect(storedJoinEvent!.event.type).toBe('m.room.member'); + expect(storedJoinEvent!.event.content.membership).toBe('join'); + }); + }); + + describe('processInvite - re-invite after leave', () => { + it('should store invite as outlier when prev_events reference unknown events (re-invite after leave)', async () => { + const remoteServer = 'remote.server.com'; + const remoteCreator = `@alice:${remoteServer}` as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Set up room on "remote" server (simulated locally for test) + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + + // 2. Invite local user, join, then leave + await inviteUser(roomId, localUser, remoteCreator); + await joinUser(roomId, localUser); + await leaveUser(roomId, localUser); + + // Verify user has left + const stateAfterLeave = await stateService.getLatestRoomState2(roomId); + expect(stateAfterLeave.isUserInRoom(localUser)).toBe(false); + + // 3. Simulate messages sent on the remote server that we never receive + // (these events exist on the remote server but not locally) + const unknownEventId1 = '$unknown-event-1:remote.server.com' as EventID; + const unknownEventId2 = '$unknown-event-2:remote.server.com' as EventID; + + // 4. Simulate a re-invite from the remote server + // The invite's prev_events reference events we never received + const latestEvents = await eventRepository.findLatestEvents(roomId); + const latestAuthEvents = latestEvents.map((e) => e._id); + + const reInviteEvent = { + type: 'm.room.member' as const, + content: { membership: 'invite' as const }, + room_id: roomId, + state_key: localUser, + sender: remoteCreator, + auth_events: latestAuthEvents, + prev_events: [unknownEventId1, unknownEventId2], + depth: 100, + origin_server_ts: Date.now(), + unsigned: {}, + } as room.Pdu; + + const reInviteEventId = PersistentEventFactory.createFromRawEvent(reInviteEvent, roomVersion).eventId; + + // 5. Process the re-invite - this should NOT throw + const result = await inviteService.processInvite(reInviteEvent as any, reInviteEventId, roomVersion, [ + { + content: { join_rule: 'invite' }, + sender: remoteCreator, + state_key: '', + type: 'm.room.join_rules', + }, + ] as any); + + expect(result).toBeDefined(); + expect(result.eventId).toBe(reInviteEventId); + + // 6. Verify the event was stored as an outlier + const storedEvent = await eventRepository.findById(reInviteEventId); + expect(storedEvent).not.toBeNull(); + expect(storedEvent!.outlier).toBe(true); + expect(storedEvent!.stateId).toBe(''); + }); + + it('should use handlePdu when prev_events are known locally (normal invite flow)', async () => { + const remoteServer = 'remote.server.com'; + const remoteCreator = `@alice:${remoteServer}` as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Set up room + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + + // 2. Use buildEvent + handlePdu via the existing helper (which properly sets auth_events and prev_events) + // This is the "normal" invite flow where the room host invites a local user + await inviteUser(roomId, localUser, remoteCreator); + + // 3. Verify the invite is reflected in room state (processed via handlePdu) + const state = await stateService.getLatestRoomState2(roomId); + expect(state.isUserInvited(localUser)).toBeTrue(); + }); + + it('should store as outlier when room has no create event', async () => { + const remoteServer = 'remote.server.com'; + const remoteCreator = `@alice:${remoteServer}` as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + const unknownRoomId = '!unknown-room:remote.server.com' as room.RoomID; + + const inviteEventRaw = { + type: 'm.room.member' as const, + content: { membership: 'invite' as const }, + room_id: unknownRoomId, + state_key: localUser, + sender: remoteCreator, + auth_events: [], + prev_events: ['$some-event:remote.server.com' as EventID], + depth: 5, + origin_server_ts: Date.now(), + unsigned: {}, + } as room.Pdu; + + const inviteEventInstance = PersistentEventFactory.createFromRawEvent(inviteEventRaw, '10'); + + const result = await inviteService.processInvite(inviteEventRaw as any, inviteEventInstance.eventId, '10', [ + { + content: { join_rule: 'invite' }, + sender: remoteCreator, + state_key: '', + type: 'm.room.join_rules', + }, + ] as any); + + expect(result).toBeDefined(); + + // Verify stored as outlier + const storedEvent = await eventRepository.findById(inviteEventInstance.eventId); + expect(storedEvent).not.toBeNull(); + expect(storedEvent!.outlier).toBe(true); + }); + + it('should handle a full invite-join-leave-reinvite cycle without errors', async () => { + const remoteServer = 'remote.server.com'; + const remoteCreator = `@alice:${remoteServer}` as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Set up room and do the full cycle + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + + // Step 1: Invite + const firstInvite = await inviteUser(roomId, localUser, remoteCreator); + const state1 = await stateService.getLatestRoomState2(roomId); + expect(state1.isUserInvited(localUser)).toBeTrue(); + + // Step 2: Join + await joinUser(roomId, localUser); + const state2 = await stateService.getLatestRoomState2(roomId); + expect(state2.isUserInRoom(localUser)).toBeTrue(); + + // Step 3: Send messages while user is in the room + const msgEvent = await stateService.buildEvent<'m.room.message'>( + { + type: 'm.room.message', + room_id: roomId, + sender: localUser, + content: { body: 'hello', msgtype: 'm.text' }, + ...getDefaultFields(), + }, + roomVersion, + ); + await stateService.handlePdu(msgEvent); + + // Step 4: User leaves + await leaveUser(roomId, localUser); + const state3 = await stateService.getLatestRoomState2(roomId); + expect(state3.isUserInRoom(localUser)).toBe(false); + + // Step 5: Simulate remote server activity we don't receive + // (after leave, the remote server stops sending us events) + // These events have prev_events pointing to events we don't have + const unknownPrevEvent = '$post-leave-msg:remote.server.com' as EventID; + + // Step 6: Re-invite from remote server with unknown prev_events + const reInviteEventRaw = { + type: 'm.room.member' as const, + content: { membership: 'invite' as const }, + room_id: roomId, + state_key: localUser, + sender: remoteCreator, + auth_events: [roomCreateEvent.eventId], + prev_events: [unknownPrevEvent], + depth: 200, + origin_server_ts: Date.now(), + unsigned: {}, + } as room.Pdu; + + const reInviteEventInstance = PersistentEventFactory.createFromRawEvent(reInviteEventRaw, roomVersion); + + // This should NOT throw "no previous state for event" + const result = await inviteService.processInvite(reInviteEventRaw as any, reInviteEventInstance.eventId, roomVersion, [ + { + content: { join_rule: 'invite' }, + sender: remoteCreator, + state_key: '', + type: 'm.room.join_rules', + }, + ] as any); + + expect(result).toBeDefined(); + + // Verify stored as outlier since prev_events are unknown + const storedEvent = await eventRepository.findById(reInviteEventInstance.eventId); + expect(storedEvent).not.toBeNull(); + expect(storedEvent!.outlier).toBe(true); + }); + }); +}); diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index 490571ad..94d18a48 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -216,10 +216,13 @@ export class InviteService { // check if we are already in the room, if so we can handlePdu because we have the state and should save // the invite in the state as well const createEvent = await this.eventRepository.findByRoomIdAndType(event.room_id, 'm.room.create'); - if (createEvent) { + if (createEvent && (await this.canResolveEventState(inviteEvent))) { await this.stateService.handlePdu(inviteEvent); } else { // otherwise we save as outlier only so we can deal with it later + // this also handles the case where we have the room create event but the invite's + // prev_events reference events we don't have (e.g. user was re-invited after leaving + // and missing events were sent while the user was not in the room) await this.eventRepository.insertOutlierEvent(inviteEvent.eventId, inviteEvent.event, residentServer); } @@ -232,4 +235,20 @@ export class InviteService { // so being the origin of the user, we sign the event and send it to the asking server, let them handle the transactions return inviteEvent; } + + /** + * Checks whether the invite event's prev_events exist locally so that + * handlePdu can resolve the state at the event. When the local server + * left a room and missed events, the invite's prev_events will reference + * events we never received, making state resolution impossible. + */ + private async canResolveEventState(event: PersistentEventBase): Promise { + const prevEventIds = event.getPreviousEventIds(); + if (prevEventIds.length === 0) { + return true; + } + + const found = await this.eventRepository.findByIds(prevEventIds).toArray(); + return found.length === prevEventIds.length; + } } diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index 7e37fd9b..1fd6eb80 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -731,18 +731,24 @@ export class RoomService { // run through state res // validate all auth chain events + // Always process initial state, even for existing rooms (re-join after leave). + // When a user leaves and re-joins, the room exists locally but we may have missed + // events sent while the user was away. processInitialState stores the new state/auth_chain + // from send_join and calls notify() for each event, which is how Rocket.Chat learns about + // the join. Without this, events go through the staging area where they get stuck on + // missing prev_events and are eventually silently dropped after MAX_EVENT_RETRY. try { await stateService.getRoomVersion(roomId); - - this.logger.info({ roomId }, 'state already exists'); + this.logger.info({ roomId }, 'state already exists, updating with new state from send_join (re-join)'); } catch (error) { - if (error instanceof UnknownRoomError) { - // if already in room, skip this, walk join event to fill the state - this.logger.info({ roomId }, 'room not found, processing initial state'); - await stateService.processInitialState(state, authChain); + if (!(error instanceof UnknownRoomError)) { + throw error; } + this.logger.info({ roomId }, 'room not found, processing initial state'); } + await stateService.processInitialState(state, authChain); + if (await stateService.isRoomStatePartial(roomId)) { this.logger.info({ roomId }, 'received incomplete graph of state from send_join, completing state before processing join'); diff --git a/packages/federation-sdk/src/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index 9eb6ba38..8837e9dc 100644 --- a/packages/federation-sdk/src/services/state.service.ts +++ b/packages/federation-sdk/src/services/state.service.ts @@ -416,6 +416,14 @@ export class StateService { return e1.eventId.localeCompare(e2.eventId); }); + // Collect IDs of events that already exist in the DB so we can skip re-emitting them. + // This is important for re-join scenarios where the room already exists and most events + // were already processed and emitted. Without this, we could send duplicated + // join/leave/membership events and mess up room history. + const allEventIds = sortedEvents.map((e) => e.eventId); + const existingEvents = await store.getEvents(allEventIds); + const knownEventIds = new Set(existingEvents.map((e) => e.eventId)); + let previousStateId = stateId; for await (const event of sortedEvents) { @@ -450,7 +458,9 @@ export class StateService { previousStateId = await this.stateRepository.createDelta(event, previousStateId); await this.addToRoomGraph(event, previousStateId); - await this.eventService.notify(event); + if (!knownEventIds.has(event.eventId)) { + await this.eventService.notify(event); + } } return previousStateId; From baef8b2eb8b9397ebdfa8d50e9287b429831565e Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 27 Mar 2026 16:15:27 -0300 Subject: [PATCH 2/4] fix create event not being notified on first join --- .../src/services/invite.service.spec.ts | 106 ++++++++++++++++++ .../src/services/state.service.ts | 22 ++-- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/packages/federation-sdk/src/services/invite.service.spec.ts b/packages/federation-sdk/src/services/invite.service.spec.ts index 2bad0c30..2c1b36cc 100644 --- a/packages/federation-sdk/src/services/invite.service.spec.ts +++ b/packages/federation-sdk/src/services/invite.service.spec.ts @@ -296,6 +296,112 @@ describe('InviteService', async () => { expect(storedJoinEvent!.event.type).toBe('m.room.member'); expect(storedJoinEvent!.event.content.membership).toBe('join'); }); + it('should notify all events including m.room.create on first-time join (fresh room)', async () => { + const remoteCreator = '@alice:remote.server.com' as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Build room state PDUs manually (simulating what send_join returns on first join) + const roomVersion = PersistentEventFactory.defaultRoomVersion; + const roomCreateEvent = PersistentEventFactory.newCreateEvent(remoteCreator, roomVersion); + + const creatorMemberEvent = PersistentEventFactory.createFromRawEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: remoteCreator, + state_key: remoteCreator, + content: { membership: 'join' }, + auth_events: [roomCreateEvent.eventId], + prev_events: [roomCreateEvent.eventId], + origin_server_ts: Date.now(), + depth: 1, + }, + roomVersion, + ); + + const powerLevelEvent = PersistentEventFactory.createFromRawEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomCreateEvent.roomId, + sender: remoteCreator, + state_key: '', + content: { + users: { [remoteCreator]: 100 }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, + }, + auth_events: [roomCreateEvent.eventId, creatorMemberEvent.eventId], + prev_events: [creatorMemberEvent.eventId], + origin_server_ts: Date.now(), + depth: 2, + }, + roomVersion, + ); + + const joinRuleEvent = PersistentEventFactory.createFromRawEvent<'m.room.join_rules'>( + { + type: 'm.room.join_rules', + room_id: roomCreateEvent.roomId, + sender: remoteCreator, + state_key: '', + content: { join_rule: 'invite' }, + auth_events: [roomCreateEvent.eventId, creatorMemberEvent.eventId, powerLevelEvent.eventId], + prev_events: [powerLevelEvent.eventId], + origin_server_ts: Date.now(), + depth: 3, + }, + roomVersion, + ); + + const inviteEvent = PersistentEventFactory.createFromRawEvent<'m.room.member'>( + { + type: 'm.room.member', + room_id: roomCreateEvent.roomId, + sender: remoteCreator, + state_key: localUser, + content: { membership: 'invite' }, + auth_events: [roomCreateEvent.eventId, creatorMemberEvent.eventId, powerLevelEvent.eventId, joinRuleEvent.eventId], + prev_events: [joinRuleEvent.eventId], + origin_server_ts: Date.now(), + depth: 4, + }, + roomVersion, + ); + + // 2. Track notify calls + const notifyCalls: Array<{ eventId: string; type: string }> = []; + const notifySpy = spyOn(stateService.eventService as any, 'notify').mockImplementation( + async (event: { eventId: string; event: { type: string } }) => { + notifyCalls.push({ eventId: event.eventId, type: event.event.type }); + }, + ); + + // 3. Call processInitialState on a FRESH room (no prior state) + const statePdus = [creatorMemberEvent.event, powerLevelEvent.event, joinRuleEvent.event, inviteEvent.event]; + const authChain = [roomCreateEvent.event, creatorMemberEvent.event, powerLevelEvent.event, joinRuleEvent.event]; + + await stateService.processInitialState(statePdus, authChain); + + // 4. ALL events should be notified, including m.room.create + const notifiedTypes = notifyCalls.map((c) => c.type); + expect(notifiedTypes).toContain('m.room.create'); + expect(notifiedTypes).toContain('m.room.member'); + expect(notifiedTypes).toContain('m.room.power_levels'); + expect(notifiedTypes).toContain('m.room.join_rules'); + + // The create event specifically must be notified + const createNotification = notifyCalls.find((c) => c.eventId === roomCreateEvent.eventId); + expect(createNotification).toBeDefined(); + expect(createNotification!.type).toBe('m.room.create'); + + notifySpy.mockRestore(); + }); }); describe('processInvite - re-invite after leave', () => { diff --git a/packages/federation-sdk/src/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index 8837e9dc..b7c14e7c 100644 --- a/packages/federation-sdk/src/services/state.service.ts +++ b/packages/federation-sdk/src/services/state.service.ts @@ -383,6 +383,18 @@ export class StateService { eventCache.set(event.eventId, event); } + const store = this._getStore(version); + + // Collect IDs of events that already exist in the DB so we can skip re-emitting them. + // This is important for re-join scenarios where the room already exists and most events + // were already processed and emitted. Without this, we could send duplicated + // join/leave/membership events and mess up room history. + // IMPORTANT: this query must run BEFORE the create event is saved below, otherwise the + // create event would be incorrectly treated as pre-existing and never notified. + const allEventIds = [...authChainCache.keys(), ...eventCache.keys()]; + const existingEvents = await store.getEvents(allEventIds); + const knownEventIds = new Set(existingEvents.map((e) => e.eventId)); + // handle create separately const createEvent = PersistentEventFactory.createFromRawEvent(create, version); const stateId = await this.stateRepository.createDelta(createEvent, '' as StateID); @@ -400,8 +412,6 @@ export class StateService { }, new Map()); }; - const store = this._getStore(version); - const sortedEvents = Array.from(eventCache.values()) .concat(Array.from(authChainCache.values())) .sort((e1, e2) => { @@ -416,14 +426,6 @@ export class StateService { return e1.eventId.localeCompare(e2.eventId); }); - // Collect IDs of events that already exist in the DB so we can skip re-emitting them. - // This is important for re-join scenarios where the room already exists and most events - // were already processed and emitted. Without this, we could send duplicated - // join/leave/membership events and mess up room history. - const allEventIds = sortedEvents.map((e) => e.eventId); - const existingEvents = await store.getEvents(allEventIds); - const knownEventIds = new Set(existingEvents.map((e) => e.eventId)); - let previousStateId = stateId; for await (const event of sortedEvents) { From bb5916c74ef891d768b3a9a402ef81d63b2eb958 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 30 Mar 2026 14:37:10 -0300 Subject: [PATCH 3/4] test: fix type errors --- .../src/services/invite.service.spec.ts | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/federation-sdk/src/services/invite.service.spec.ts b/packages/federation-sdk/src/services/invite.service.spec.ts index 2c1b36cc..2e66465d 100644 --- a/packages/federation-sdk/src/services/invite.service.spec.ts +++ b/packages/federation-sdk/src/services/invite.service.spec.ts @@ -168,7 +168,7 @@ describe('InviteService', async () => { membership: room.PduMembershipEventContent['membership'], sender?: string, ) => { - const roomVersion = await stateService.getRoomVersion(roomId); + const roomVersion = await stateService.getRoomVersion(roomId as room.RoomID); const membershipEvent = await stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', @@ -205,7 +205,7 @@ describe('InviteService', async () => { // 3. Track notify calls const notifyCalls: Array<{ eventId: string; type: string }> = []; - const notifySpy = spyOn(stateService.eventService as any, 'notify').mockImplementation( + const notifySpy = spyOn((stateService as any).eventService, 'notify').mockImplementation( async (event: { eventId: string; event: { type: string } }) => { notifyCalls.push({ eventId: event.eventId, type: event.event.type }); }, @@ -294,7 +294,7 @@ describe('InviteService', async () => { const storedJoinEvent = await eventRepository.findById(rejoinEvent.eventId); expect(storedJoinEvent).not.toBeNull(); expect(storedJoinEvent!.event.type).toBe('m.room.member'); - expect(storedJoinEvent!.event.content.membership).toBe('join'); + expect((storedJoinEvent!.event.content as any).membership).toBe('join'); }); it('should notify all events including m.room.create on first-time join (fresh room)', async () => { const remoteCreator = '@alice:remote.server.com' as room.UserID; @@ -376,7 +376,7 @@ describe('InviteService', async () => { // 2. Track notify calls const notifyCalls: Array<{ eventId: string; type: string }> = []; - const notifySpy = spyOn(stateService.eventService as any, 'notify').mockImplementation( + const notifySpy = spyOn((stateService as any).eventService, 'notify').mockImplementation( async (event: { eventId: string; event: { type: string } }) => { notifyCalls.push({ eventId: event.eventId, type: event.event.type }); }, @@ -465,7 +465,7 @@ describe('InviteService', async () => { const storedEvent = await eventRepository.findById(reInviteEventId); expect(storedEvent).not.toBeNull(); expect(storedEvent!.outlier).toBe(true); - expect(storedEvent!.stateId).toBe(''); + expect(storedEvent!.stateId).toBe('' as room.StateID); }); it('should use handlePdu when prev_events are known locally (normal invite flow)', async () => { @@ -492,22 +492,23 @@ describe('InviteService', async () => { const localUser = `@johnny:${localServerName}` as room.UserID; const unknownRoomId = '!unknown-room:remote.server.com' as room.RoomID; - const inviteEventRaw = { - type: 'm.room.member' as const, - content: { membership: 'invite' as const }, - room_id: unknownRoomId, - state_key: localUser, - sender: remoteCreator, - auth_events: [], - prev_events: ['$some-event:remote.server.com' as EventID], - depth: 5, - origin_server_ts: Date.now(), - unsigned: {}, - } as room.Pdu; - - const inviteEventInstance = PersistentEventFactory.createFromRawEvent(inviteEventRaw, '10'); + const inviteEventInstance = PersistentEventFactory.createFromRawEvent<'m.room.member'>( + { + type: 'm.room.member', + content: { membership: 'invite' as const }, + room_id: unknownRoomId, + state_key: localUser, + sender: remoteCreator, + auth_events: [], + prev_events: ['$some-event:remote.server.com' as EventID], + depth: 5, + origin_server_ts: Date.now(), + unsigned: {}, + }, + '10', + ); - const result = await inviteService.processInvite(inviteEventRaw as any, inviteEventInstance.eventId, '10', [ + const result = await inviteService.processInvite(inviteEventInstance.event, inviteEventInstance.eventId, '10', [ { content: { join_rule: 'invite' }, sender: remoteCreator, From 7558235f1405191336e1c270dadeb9cf0fc81d2a Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Mon, 30 Mar 2026 14:37:35 -0300 Subject: [PATCH 4/4] validate both auth_events and prev_events --- .../src/services/invite.service.spec.ts | 110 ++++++++++++++++++ .../src/services/invite.service.ts | 26 +++-- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/packages/federation-sdk/src/services/invite.service.spec.ts b/packages/federation-sdk/src/services/invite.service.spec.ts index 2e66465d..ba9e3ba3 100644 --- a/packages/federation-sdk/src/services/invite.service.spec.ts +++ b/packages/federation-sdk/src/services/invite.service.spec.ts @@ -525,6 +525,116 @@ describe('InviteService', async () => { expect(storedEvent!.outlier).toBe(true); }); + it('should store as outlier when prev_events exist but are themselves outliers (empty stateId)', async () => { + const remoteServer = 'remote.server.com'; + const remoteCreator = `@alice:${remoteServer}` as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Set up room + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + + // 2. Insert an outlier event that the invite's prev_events will reference + const outlierWrapped = PersistentEventFactory.createFromRawEvent<'m.room.message'>( + { + type: 'm.room.message', + content: { body: 'test', msgtype: 'm.text' }, + room_id: roomId, + sender: remoteCreator, + auth_events: [], + prev_events: [], + depth: 50, + origin_server_ts: Date.now(), + }, + roomVersion, + ); + const outlierEventId = outlierWrapped.eventId; + await eventRepository.insertOutlierEvent(outlierEventId, outlierWrapped.event, remoteServer); + + // 3. Build re-invite referencing the outlier as prev_event + const latestEvents = await eventRepository.findLatestEvents(roomId); + const latestAuthEvents = latestEvents.map((e) => e._id); + + const reInviteEventRaw = { + type: 'm.room.member' as const, + content: { membership: 'invite' as const }, + room_id: roomId, + state_key: localUser, + sender: remoteCreator, + auth_events: latestAuthEvents, + prev_events: [outlierEventId], + depth: 51, + origin_server_ts: Date.now(), + unsigned: {}, + } as room.Pdu; + + const reInviteEventInstance = PersistentEventFactory.createFromRawEvent(reInviteEventRaw, roomVersion); + + const result = await inviteService.processInvite(reInviteEventRaw as any, reInviteEventInstance.eventId, roomVersion, [ + { + content: { join_rule: 'invite' }, + sender: remoteCreator, + state_key: '', + type: 'm.room.join_rules', + }, + ] as any); + + expect(result).toBeDefined(); + + // Should be stored as outlier because the prev_event has stateId == '' + const storedEvent = await eventRepository.findById(reInviteEventInstance.eventId); + expect(storedEvent).not.toBeNull(); + expect(storedEvent!.outlier).toBe(true); + }); + + it('should store as outlier when auth_events are missing from the database', async () => { + const remoteServer = 'remote.server.com'; + const remoteCreator = `@alice:${remoteServer}` as room.UserID; + const localUser = `@johnny:${localServerName}` as room.UserID; + + // 1. Set up room + const { roomCreateEvent, roomVersion } = await createRoom('invite', remoteCreator); + const { roomId } = roomCreateEvent; + + // 2. Get the latest known events for prev_events (these are real, materialized) + const latestEvents = await eventRepository.findLatestEvents(roomId); + const latestPrevEvents = latestEvents.map((e) => e._id); + + // 3. Build re-invite with auth_events pointing to unknown events + const unknownAuthEvent = '$unknown-auth:remote.server.com' as EventID; + + const reInviteEventRaw = { + type: 'm.room.member' as const, + content: { membership: 'invite' as const }, + room_id: roomId, + state_key: localUser, + sender: remoteCreator, + auth_events: [unknownAuthEvent], + prev_events: latestPrevEvents, + depth: 100, + origin_server_ts: Date.now(), + unsigned: {}, + } as room.Pdu; + + const reInviteEventInstance = PersistentEventFactory.createFromRawEvent(reInviteEventRaw, roomVersion); + + const result = await inviteService.processInvite(reInviteEventRaw as any, reInviteEventInstance.eventId, roomVersion, [ + { + content: { join_rule: 'invite' }, + sender: remoteCreator, + state_key: '', + type: 'm.room.join_rules', + }, + ] as any); + + expect(result).toBeDefined(); + + // Should be stored as outlier because auth_events are missing + const storedEvent = await eventRepository.findById(reInviteEventInstance.eventId); + expect(storedEvent).not.toBeNull(); + expect(storedEvent!.outlier).toBe(true); + }); + it('should handle a full invite-join-leave-reinvite cycle without errors', async () => { const remoteServer = 'remote.server.com'; const remoteCreator = `@alice:${remoteServer}` as room.UserID; diff --git a/packages/federation-sdk/src/services/invite.service.ts b/packages/federation-sdk/src/services/invite.service.ts index 94d18a48..a1fd6300 100644 --- a/packages/federation-sdk/src/services/invite.service.ts +++ b/packages/federation-sdk/src/services/invite.service.ts @@ -237,18 +237,30 @@ export class InviteService { } /** - * Checks whether the invite event's prev_events exist locally so that - * handlePdu can resolve the state at the event. When the local server - * left a room and missed events, the invite's prev_events will reference - * events we never received, making state resolution impossible. + * Checks whether the invite event's prev_events and auth_events exist + * locally with fully materialized state so that handlePdu can resolve + * the state at the event. When the local server left a room and missed + * events, references may point to events we never received or to outlier + * events (stateId == ''), making state resolution impossible. */ private async canResolveEventState(event: PersistentEventBase): Promise { const prevEventIds = event.getPreviousEventIds(); - if (prevEventIds.length === 0) { + const authEventIds = event.getAuthEventIds(); + + if (prevEventIds.length === 0 && authEventIds.length === 0) { return true; } - const found = await this.eventRepository.findByIds(prevEventIds).toArray(); - return found.length === prevEventIds.length; + const [prevEvents, authEvents] = await Promise.all([ + prevEventIds.length > 0 ? this.eventRepository.findByIds(prevEventIds).toArray() : Promise.resolve([]), + authEventIds.length > 0 ? this.eventRepository.findByIds(authEventIds).toArray() : Promise.resolve([]), + ]); + + if (prevEvents.length !== prevEventIds.length || authEvents.length !== authEventIds.length) { + return false; + } + + const allMaterialized = [...prevEvents, ...authEvents].every((e) => !!e.stateId); + return allMaterialized; } }