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..ba9e3ba3 --- /dev/null +++ b/packages/federation-sdk/src/services/invite.service.spec.ts @@ -0,0 +1,714 @@ +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 as room.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 as any).eventService, '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 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; + 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 as any).eventService, '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', () => { + 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('' as room.StateID); + }); + + 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 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(inviteEventInstance.event, 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 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; + 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..a1fd6300 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,32 @@ 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 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(); + const authEventIds = event.getAuthEventIds(); + + if (prevEventIds.length === 0 && authEventIds.length === 0) { + return true; + } + + 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; + } } 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..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) => { @@ -450,7 +460,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;