Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions packages/federation-sdk/src/services/edu.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export class EduService {

this.logger.debug(`Sending typing notification for room ${roomId}: ${userId} (typing: ${typing}) to all servers in room`);

const servers = await this.stateService.getServersInRoom(roomId);
const uniqueServers = servers.filter((server) => server !== origin);
const servers = await this.stateService.getServerSetInRoom(roomId);
const uniqueServers = Array.from(servers).filter((server) => server !== origin);

await this.federationService.sendEDUToServers([typingEDU], uniqueServers);

Expand All @@ -47,14 +47,16 @@ export class EduService {
this.logger.debug(`Sending presence updates for ${presenceUpdates.length} users to all servers in rooms: ${roomIds.join(', ')}`);
const uniqueServers = new Set<string>();

for await (const roomId of roomIds) {
const servers = await this.stateService.getServersInRoom(roomId);
for (const server of servers) {
if (server !== origin) {
uniqueServers.add(server);
await Promise.all(
roomIds.map(async (roomId) => {
const servers = await this.stateService.getServerSetInRoom(roomId);
for (const server of servers) {
if (server !== origin) {
uniqueServers.add(server);
}
}
}
}
}),
);
Comment on lines +50 to +59
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.all(roomIds.map(...)) fans out one getServerSetInRoom call per room with no concurrency limit. If roomIds can be large, this can overwhelm the DB/state store and increase tail latency. Consider batching / limiting concurrency (e.g., chunking roomIds, a simple semaphore, or a library like p-limit).

Copilot uses AI. Check for mistakes.

await this.federationService.sendEDUToServers([presenceEDU], Array.from(uniqueServers));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ export class EventAuthorizationService {
return false;
}

const serversInRoom = await this.stateService.getServersInRoom(roomId);
if (serversInRoom.includes(serverName)) {
const serversInRoom = await this.stateService.getServerSetInRoom(roomId, state);
if (serversInRoom.has(serverName)) {
this.logger.debug(`Server ${serverName} is in room, allowing access`);
return true;
}
Expand Down
159 changes: 145 additions & 14 deletions packages/federation-sdk/src/services/state.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1446,32 +1446,32 @@ describe('StateService', async () => {
const diego = '@diego:example.com';
await joinUser(roomCreateEvent.roomId, diego);

const servers = await stateService.getServersInRoom(roomCreateEvent.roomId);
expect(servers).toContain('example.com');
expect(servers.length).toBe(1);
const servers = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers.has('example.com')).toBe(true);
expect(servers.size).toBe(1);

const remoteUser = '@alice:remote.com';
await joinUser(roomCreateEvent.roomId, remoteUser);

const servers2 = await stateService.getServersInRoom(roomCreateEvent.roomId);
expect(servers2).toContain('example.com');
expect(servers2).toContain('remote.com');
expect(servers2.length).toBe(2);
const servers2 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers2.has('example.com')).toBe(true);
expect(servers2.has('remote.com')).toBe(true);
expect(servers2.size).toBe(2);

// now leave the remote user
await leaveUser(roomCreateEvent.roomId, remoteUser);

const servers3 = await stateService.getServersInRoom(roomCreateEvent.roomId);
expect(servers3).toContain('example.com');
expect(servers3.length).toBe(1);
const servers3 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers3.has('example.com')).toBe(true);
expect(servers3.size).toBe(1);

// now add her again
await joinUser(roomCreateEvent.roomId, remoteUser);

const servers4 = await stateService.getServersInRoom(roomCreateEvent.roomId);
expect(servers4).toContain('example.com');
expect(servers4).toContain('remote.com');
expect(servers4.length).toBe(2);
const servers4 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers4.has('example.com')).toBe(true);
expect(servers4.has('remote.com')).toBe(true);
expect(servers4.size).toBe(2);
});

it('should allow previously rejected events through multiple state resolutions', async () => {
Expand Down Expand Up @@ -2224,4 +2224,135 @@ describe('StateService', async () => {
});
});
}

describe('getServerSetInRoom', () => {
it('should return servers with joined members', async () => {
const { roomCreateEvent } = await createRoom('public');

const alice = '@alice:example.com';
const bob = '@bob:remote.com';
const charlie = '@charlie:another.com';

await joinUser(roomCreateEvent.roomId, alice);
await joinUser(roomCreateEvent.roomId, bob);
await joinUser(roomCreateEvent.roomId, charlie);

const servers = await stateService.getServerSetInRoom(roomCreateEvent.roomId);

expect(servers.has('example.com')).toBe(true);
expect(servers.has('remote.com')).toBe(true);
expect(servers.has('another.com')).toBe(true);
expect(servers.size).toBe(3);
});

it('should deduplicate servers with multiple users from same domain', async () => {
const { roomCreateEvent } = await createRoom('public');

const alice = '@alice:example.com';
const bob = '@bob:example.com';
const charlie = '@charlie:example.com';

await joinUser(roomCreateEvent.roomId, alice);
await joinUser(roomCreateEvent.roomId, bob);
await joinUser(roomCreateEvent.roomId, charlie);

const servers = await stateService.getServerSetInRoom(roomCreateEvent.roomId);

expect(servers.has('example.com')).toBe(true);
expect(servers.size).toBe(1);
});

it('should exclude servers with non-joined members', async () => {
const { roomCreateEvent } = await createRoom('public');

const creator = '@alice:example.com'; // Room creator with admin permissions
const joined = '@joined:joined.com';
const left = '@left:left.com';
const banned = '@banned:banned.com';
const invited = '@invited:invited.com';

await joinUser(roomCreateEvent.roomId, joined);
await joinUser(roomCreateEvent.roomId, left);
await joinUser(roomCreateEvent.roomId, banned);

// Change memberships - use creator for moderation actions
await leaveUser(roomCreateEvent.roomId, left);
await banUser(roomCreateEvent.roomId, banned, creator);
await inviteUser(roomCreateEvent.roomId, invited, creator);

const servers = await stateService.getServerSetInRoom(roomCreateEvent.roomId);

expect(servers.has('joined.com')).toBe(true);
expect(servers.has('left.com')).toBe(false);
expect(servers.has('banned.com')).toBe(false);
expect(servers.has('invited.com')).toBe(false);
expect(servers.size).toBe(2); // example.com (creator) + joined.com
});

it('should return creator server for room with only creator', async () => {
const { roomCreateEvent } = await createRoom('public');

const servers = await stateService.getServerSetInRoom(roomCreateEvent.roomId);

// Only the room creator from createRoom should be present
expect(servers.has('example.com')).toBe(true);
expect(servers.size).toBe(1);
});

it('should update server list when users join and leave', async () => {
const { roomCreateEvent } = await createRoom('public');

const remoteUser = '@alice:remote.com';

// Initially only creator's server
const servers1 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers1.has('example.com')).toBe(true);
expect(servers1.size).toBe(1);

// Add remote user
await joinUser(roomCreateEvent.roomId, remoteUser);
const servers2 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers2.has('example.com')).toBe(true);
expect(servers2.has('remote.com')).toBe(true);
expect(servers2.size).toBe(2);

// Remove remote user
await leaveUser(roomCreateEvent.roomId, remoteUser);
const servers3 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers3.has('example.com')).toBe(true);
expect(servers3.has('remote.com')).toBe(false);
expect(servers3.size).toBe(1);

// Add remote user back
await joinUser(roomCreateEvent.roomId, remoteUser);
const servers4 = await stateService.getServerSetInRoom(roomCreateEvent.roomId);
expect(servers4.has('example.com')).toBe(true);
expect(servers4.has('remote.com')).toBe(true);
expect(servers4.size).toBe(2);
});

it('should handle multiple servers with different user counts', async () => {
const { roomCreateEvent } = await createRoom('public');

// Server 1: 3 users
await joinUser(roomCreateEvent.roomId, '@user1:server1.com');
await joinUser(roomCreateEvent.roomId, '@user2:server1.com');
await joinUser(roomCreateEvent.roomId, '@user3:server1.com');

// Server 2: 2 users
await joinUser(roomCreateEvent.roomId, '@alice:server2.com');
await joinUser(roomCreateEvent.roomId, '@bob:server2.com');

// Server 3: 1 user
await joinUser(roomCreateEvent.roomId, '@charlie:server3.com');

const servers = await stateService.getServerSetInRoom(roomCreateEvent.roomId);

expect(servers.has('example.com')).toBe(true);
expect(servers.has('server1.com')).toBe(true);
expect(servers.has('server2.com')).toBe(true);
expect(servers.has('server3.com')).toBe(true);
expect(servers.size).toBe(4);
});
});
Comment on lines +2228 to +2325
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add regression coverage for the changed callers, not just StateService.

This suite validates getServerSetInRoom itself, but the PR also changes edu.service.ts and event-authorization.service.ts, and those files are still showing 0% patch coverage in the PR report. A bug in the new Array.from(...)/origin filtering or the Set.has(...) authorization path would still slip through with this test set alone.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/federation-sdk/src/services/state.service.spec.ts` around lines 2228
- 2357, Tests only exercise StateService.getServerSetInRoom but the PR also
changed edu.service.ts and event-authorization.service.ts so add regression
tests that exercise the new callers: write unit/integration tests that trigger
edu.service.ts flows (e.g., call the function that processes EDUs and ensure it
uses Array.from(...).origin filtering) and tests for
event-authorization.service.ts that hit the authorization path that uses
Set.has(...) (simulate events from origins present/absent in the set and assert
allow/deny behavior); locate the entrypoints in edu.service (the EDU processing
method) and in event-authorization.service (the method that checks origins with
Set.has) and add test cases that validate the new filtering/authorization logic
across cross-server scenarios to ensure coverage.

});
9 changes: 2 additions & 7 deletions packages/federation-sdk/src/services/state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,8 +692,8 @@ export class StateService {
// const stateId = await this.getStateIdBeforeEvent(event);
// return this.getStateAtStateId(stateId, event.version);
// }
async getServerSetInRoom(roomId: RoomID) {
const state = await this.getLatestRoomState(roomId);
async getServerSetInRoom(roomId: RoomID, roomState?: State) {
const state = roomState ?? (await this.getLatestRoomState(roomId));

const servers = new Set<string>();

Expand All @@ -719,11 +719,6 @@ export class StateService {
return servers;
}

// @deprecated use getServerSetInRoom
async getServersInRoom(roomId: RoomID) {
return Array.from(await this.getServerSetInRoom(roomId));
}

private async _isSameChain(stateIds: StateID[]) {
const stateDocs = await this.stateRepository.findByStateIds(stateIds).toArray();

Expand Down