Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions packages/federation-sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,10 @@ export class FederationSDK {
return this.stateService.getLatestRoomState(...args);
}

getRoomVersion(...args: Parameters<typeof this.stateService.getRoomVersion>) {
return this.stateService.getRoomVersion(...args);
}

handlePdu(...args: Parameters<typeof this.stateService.handlePdu>) {
return this.stateService.handlePdu(...args);
}
Expand Down
40 changes: 23 additions & 17 deletions packages/federation-sdk/src/services/room.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ export class RoomService {
async updateUserPowerLevel(roomId: RoomID, userId: UserID, powerLevel: number, senderId: UserID): Promise<string> {
logger.info(`Updating power level for user ${userId} in room ${roomId} to ${powerLevel} by ${senderId}`);

const roomVersion = await this.stateService.getRoomVersion(roomId);

const authEventIds = await this.eventService.getAuthEventIds('m.room.power_levels', { roomId, senderId });

const powerLevelsAuthResult = this.getEventByType(authEventIds, 'm.room.power_levels');
Expand Down Expand Up @@ -512,7 +514,7 @@ export class RoomService {
origin_server_ts: Date.now(),
sender: eventToSign.sender,
},
PersistentEventFactory.defaultRoomVersion,
roomVersion,
);

await this.stateService.handlePdu(event);
Expand Down Expand Up @@ -741,7 +743,7 @@ export class RoomService {
origin_server_ts: Date.now(),
sender: userId,
},
PersistentEventFactory.defaultRoomVersion,
createEvent.version,
);

await stateService.handlePdu(membershipEvent);
Expand All @@ -760,16 +762,23 @@ export class RoomService {
return membershipEvent.eventId;
}

// Resident server is remote, need to do join flow
const roomVersion = '10' as const;
// Resident server is remote, need to do join flow.
// If we already have local state for this room (re-join), hint our known
// version to the resident server instead of asking it to pick from the
// full supported list.
let knownRoomVersion: RoomVersion | undefined;
let isRejoin = false;
try {
knownRoomVersion = await stateService.getRoomVersion(roomId);
isRejoin = true;
} catch (error) {
if (!(error instanceof UnknownRoomError)) {
throw error;
}
}

// trying to join room from another server
const makeJoinResponse = await federationService.makeJoin(
residentServer,
roomId,
userId,
roomVersion, // NOTE: check the comment in the called method
);
const makeJoinResponse = await federationService.makeJoin(residentServer, roomId, userId, knownRoomVersion);
Comment on lines +765 to +781

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the makeJoin implementation and its tests.
rg -n -C 8 --type ts '\bmakeJoin\s*\(' packages/federation-sdk/src

# Inspect whether the implementation compares a requested known version with
# the returned room_version before returning the response to RoomService.
fd -t f --extension ts . packages/federation-sdk/src | while IFS= read -r file; do
  rg -n -C 12 'knownRoomVersion|room_version|makeJoin' "$file" || true
done

Repository: RocketChat/homeserver

Length of output: 46182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== FederationService.makeJoin implementation =="
sed -n '1,55p' packages/federation-sdk/src/services/federation.service.ts

echo
echo "== RoomService.joinRoom relevant section =="
sed -n '760,905p' packages/federation-sdk/src/services/room.service.ts

echo
echo "== sendJoin implementation =="
sed -n '44,70p' packages/federation-sdk/src/services/federation.service.ts

echo
echo "== Schemas =="
sed -n '87,102p' packages/federation-sdk/src/services/federation.service.ts 2>/dev/null || rg -n -C 8 'MakeJoinResponse|MakeJoinEvent' packages/federation-sdk/src | head -80

echo
echo "== Search for makeJoinResponse.room_version validation =="
rg -n -C 5 'makeJoinResponse\.room_version|if \(.*room_version|throw.*room_version|not equal|!==|!=|same' packages/federation-sdk/src/services/room.service.ts packages/federation-sdk/src/services/federation.service.ts packages/federation-sdk/src

Repository: RocketChat/homeserver

Length of output: 50378


Reject mismatched room versions after makeJoin.

When knownRoomVersion is set, compare makeJoinResponse.room_version to the local room version before calling sendJoin. makeJoin only passes ver to the request, so a different remote response can still be parsed with the remote version instead of the stored local room state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/federation-sdk/src/services/room.service.ts` around lines 765 - 781,
After makeJoinResponse is returned in the rejoin path, compare its room_version
with knownRoomVersion before calling sendJoin; reject or throw on a mismatch,
while preserving the existing flow for new joins where knownRoomVersion is
undefined. Locate the sendJoin invocation and enforce this validation before it.


// after receiving the join event we need to populate with local user profile
const profile = await this.profilesService.queryProfile(userId);
Expand Down Expand Up @@ -799,13 +808,9 @@ export class RoomService {
// 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);
if (isRejoin) {
this.logger.info({ roomId }, 'state already exists, updating with new state from send_join (re-join)');
} catch (error) {
if (!(error instanceof UnknownRoomError)) {
throw error;
}
} else {
this.logger.info({ roomId }, 'room not found, processing initial state');
}

Expand Down Expand Up @@ -1123,6 +1128,7 @@ export class RoomService {
if (!room) {
throw new HttpException('Room not found', HttpStatus.NOT_FOUND);
}
const roomVersion = await this.stateService.getRoomVersion(roomId);
const isTombstoned = await this.isRoomTombstoned(roomId);
if (isTombstoned) {
logger.warn(`Attempted to delete an already tombstoned room: ${roomId}`);
Expand Down Expand Up @@ -1173,7 +1179,7 @@ export class RoomService {
signatures: {},
type: 'm.room.tombstone',
},
PersistentEventFactory.defaultRoomVersion,
roomVersion,
);

const _stateId = await this.stateService.handlePdu(event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const internalRequestPlugin = (app: Elysia) => {
roomId: RoomID;
sender: UserID;
};
const version = (query.version as RoomVersion | undefined) || PersistentEventFactory.defaultRoomVersion;
const version = (query.version as RoomVersion | undefined) || (await federationSDK.getRoomVersion(roomId));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The /internal/event/template devtools endpoint used to fall back to PersistentEventFactory.defaultRoomVersion when the client didn't pass ?version=. Now getRoomVersion(roomId) is the fallback, but StateService.getRoomVersion throws UnknownRoomError when the room has no local m.room.create event. This endpoint generates a template event to fill in and send, frequently for rooms you haven't joined locally or are about to create, so the unhandled throw now turns the endpoint into a 500 instead of returning a template with a default version. Consider catching UnknownRoomError (or a .catch fallback) so the page keeps working for rooms without local state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/homeserver/src/controllers/internal/external-federation-request.controller.ts, line 64:

<comment>The `/internal/event/template` devtools endpoint used to fall back to `PersistentEventFactory.defaultRoomVersion` when the client didn't pass `?version=`. Now `getRoomVersion(roomId)` is the fallback, but `StateService.getRoomVersion` throws `UnknownRoomError` when the room has no local `m.room.create` event. This endpoint generates a template event to fill in and send, frequently for rooms you haven't joined locally or are about to create, so the unhandled throw now turns the endpoint into a 500 instead of returning a template with a default version. Consider catching `UnknownRoomError` (or a `.catch` fallback) so the page keeps working for rooms without local state.</comment>

<file context>
@@ -61,7 +61,7 @@ export const internalRequestPlugin = (app: Elysia) => {
 				sender: UserID;
 			};
-			const version = (query.version as RoomVersion | undefined) || PersistentEventFactory.defaultRoomVersion;
+			const version = (query.version as RoomVersion | undefined) || (await federationSDK.getRoomVersion(roomId));
 			switch (eventType) {
 				case 'm.room.member': {
</file context>

switch (eventType) {
case 'm.room.member': {
const event = await federationSDK.buildEvent<'m.room.member'>(
Expand Down Expand Up @@ -203,7 +203,7 @@ export const internalRequestPlugin = (app: Elysia) => {
'/internal/event/send',
async ({ body, query }) => {
const event = body as Pdu;
const version = (query.version as RoomVersion | undefined) || PersistentEventFactory.defaultRoomVersion;
const version = (query?.version as RoomVersion | undefined) || (await federationSDK.getRoomVersion(event.room_id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Same regression as the template endpoint: the /internal/event/send route used to default to PersistentEventFactory.defaultRoomVersion, but the new fallback calls federationSDK.getRoomVersion(event.room_id), which throws UnknownRoomError when the target room has no local m.room.create event. The query schema was also changed so version is now fully optional. This makes it impossible to send a room's first events (e.g. the initial m.room.create/m.room.member events for a brand-new room) through this devtool, because the version lookup itself raises before the event is built. Add a UnknownRoomError guard or a .catch fallback to defaultRoomVersion so version resolution failures degrade gracefully.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/homeserver/src/controllers/internal/external-federation-request.controller.ts, line 206:

<comment>Same regression as the template endpoint: the `/internal/event/send` route used to default to `PersistentEventFactory.defaultRoomVersion`, but the new fallback calls `federationSDK.getRoomVersion(event.room_id)`, which throws `UnknownRoomError` when the target room has no local `m.room.create` event. The query schema was also changed so `version` is now fully optional. This makes it impossible to send a room's first events (e.g. the initial `m.room.create`/`m.room.member` events for a brand-new room) through this devtool, because the version lookup itself raises before the event is built. Add a `UnknownRoomError` guard or a `.catch` fallback to `defaultRoomVersion` so version resolution failures degrade gracefully.</comment>

<file context>
@@ -203,7 +203,7 @@ export const internalRequestPlugin = (app: Elysia) => {
 		async ({ body, query }) => {
 			const event = body as Pdu;
-			const version = (query.version as RoomVersion | undefined) || PersistentEventFactory.defaultRoomVersion;
+			const version = (query?.version as RoomVersion | undefined) || (await federationSDK.getRoomVersion(event.room_id));
 			if (!PersistentEventFactory.isSupportedRoomVersion(version)) {
 				throw new Error(`Room version ${version} is not supported`);
</file context>

if (!PersistentEventFactory.isSupportedRoomVersion(version)) {
throw new Error(`Room version ${version} is not supported`);
}
Expand All @@ -219,7 +219,7 @@ export const internalRequestPlugin = (app: Elysia) => {
},
{
body: t.Any(),
query: t.Object({ version: t.String({ default: '10' }) }),
query: t.Optional(t.Object({ version: t.Optional(t.String()) })),
detail: {
tags: ['Devtools'],
summary: 'Send an event',
Expand Down
2 changes: 1 addition & 1 deletion packages/room/src/manager/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class PersistentEventFactory {
'11',
];

static defaultRoomVersion = '10' as const; // same as synapse
static defaultRoomVersion = '11' as const; // same as synapse

static isSupportedRoomVersion(roomVersion: string): roomVersion is RoomVersion {
return PersistentEventFactory.supportedRoomVersions.includes(roomVersion);
Expand Down
Loading