From b722f55606bcafc121722584c58277552af1eba7 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Aug 2026 20:33:47 -0300 Subject: [PATCH 01/25] fix(room): accept v11 create events without content.creator Room version 11 removed content.creator from m.room.create: the event's sender is the creator. The auth rules and both zod schema sets still required it, so any spec-compliant v11 create event was rejected, and the factory always emitted it. Where the creator lives is now answered by the room version class (resolveCreator / newCreateEventContent) so callers stay version blind, and the factory has a single version-to-class mapping used by both createFromRawEvent and newCreateEvent. --- .../federation-sdk/src/utils/event-schemas.ts | 3 +- .../src/authorizartion-rules/rules.spec.ts | 27 ++++++++++- .../room/src/authorizartion-rules/rules.ts | 6 +-- packages/room/src/manager/event-wrapper.ts | 13 ++++- packages/room/src/manager/factory.ts | 48 +++++++++++-------- packages/room/src/manager/room-state.ts | 2 +- packages/room/src/manager/v11.ts | 13 ++++- packages/room/src/manager/v3.ts | 14 ++++-- packages/room/src/types/v3-11.ts | 3 +- 9 files changed, 97 insertions(+), 32 deletions(-) diff --git a/packages/federation-sdk/src/utils/event-schemas.ts b/packages/federation-sdk/src/utils/event-schemas.ts index 019bb257d..7122d8a16 100644 --- a/packages/federation-sdk/src/utils/event-schemas.ts +++ b/packages/federation-sdk/src/utils/event-schemas.ts @@ -24,7 +24,8 @@ const createEventSchema = baseEventSchema.extend({ content: z .object({ room_version: z.string(), - creator: userIdSchema, + // removed in room version 11, where the create event's sender is the creator + creator: userIdSchema.optional(), }) .and(z.record(z.string(), z.any())), prev_events: z.array(z.any()).max(0).optional(), diff --git a/packages/room/src/authorizartion-rules/rules.spec.ts b/packages/room/src/authorizartion-rules/rules.spec.ts index 119944476..16c24b9e9 100644 --- a/packages/room/src/authorizartion-rules/rules.spec.ts +++ b/packages/room/src/authorizartion-rules/rules.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { checkEventAuthWithState, checkEventAuthWithoutState } from './rules'; import type { PersistentEventBase } from '../manager/event-wrapper'; import { PersistentEventFactory } from '../manager/factory'; +import type { RoomVersion } from '../manager/type'; import { type EventStore, getStateMapKey } from '../state_resolution/definitions/definitions'; import { type StateMapKey } from '../types/_common'; import type { Pdu, PduContent, type PduType } from '../types/v3-11'; @@ -93,8 +94,8 @@ class FakeEventCreatorBase { return this.withType('test'); } - build() { - return PersistentEventFactory.createFromRawEvent(this._event, '10'); + build(roomVersion: RoomVersion = '10') { + return PersistentEventFactory.createFromRawEvent(this._event, roomVersion); } } @@ -1160,4 +1161,26 @@ describe('authorization rules', () => { // should still allow expect(() => checkEventAuthWithState(pl3, state2, store)).not.toThrow(); }); + + it('should accept a v11 create event without content.creator', async () => { + const create = new FakeStateEventCreator() + .asRoomCreate() + .withRoomId(roomId) + .withSender(creator) + .withContent({ room_version: '11' }) + .build('11'); + + expect(() => checkEventAuthWithoutState(create, [])).not.toThrow(); + }); + + it('should still require content.creator before v11', async () => { + const create = new FakeStateEventCreator() + .asRoomCreate() + .withRoomId(roomId) + .withSender(creator) + .withContent({ room_version: '10' }) + .build('10'); + + expect(() => checkEventAuthWithoutState(create, [])).toThrow(); + }); }); diff --git a/packages/room/src/authorizartion-rules/rules.ts b/packages/room/src/authorizartion-rules/rules.ts index e8a449d75..a76f98870 100644 --- a/packages/room/src/authorizartion-rules/rules.ts +++ b/packages/room/src/authorizartion-rules/rules.ts @@ -51,11 +51,11 @@ function isCreateAllowed(createEvent: PersistentEventBase = Omit; +// what the factory needs from a room version implementation: build events of that +// version, and know how that version shapes the content of a new create event +type PersistentEventClass = (new ( + event: PduWithHashesAndSignaturesOptional, + roomVersion: RoomVersion3To11, + partial?: boolean, +) => PersistentEventBase) & { + newCreateEventContent(creator: UserID, roomVersion: RoomVersion): PduCreateEventContent; +}; + // The idea is to ALWAYS use this to create different events export class PersistentEventFactory { static supportedRoomVersions = [ @@ -44,11 +54,8 @@ export class PersistentEventFactory { return PersistentEventFactory.supportedRoomVersions.includes(roomVersion); } - static createFromRawEvent( - event: PduWithHashesAndSignaturesOptional, - roomVersion: string, - partial = false, - ): PersistentEventBase { + // the single place mapping a room version to the class implementing its rules + private static getEventClass(roomVersion: string): PersistentEventClass { if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { throw new Error(`Room version ${roomVersion} is not supported`); } @@ -57,34 +64,37 @@ export class PersistentEventFactory { case '3': case '4': case '5': - return new PersistentEventV3(event, roomVersion, partial); + return PersistentEventV3; case '6': case '7': - return new PersistentEventV6(event, roomVersion, partial); + return PersistentEventV6; case '8': - return new PersistentEventV8(event, roomVersion, partial); + return PersistentEventV8; case '9': case '10': - return new PersistentEventV9(event, roomVersion, partial); + return PersistentEventV9; case '11': - return new PersistentEventV11(event, roomVersion, partial); + return PersistentEventV11; default: throw new Error(`Unknown room version: ${roomVersion}`); } } + static createFromRawEvent( + event: PduWithHashesAndSignaturesOptional, + roomVersion: string, + partial = false, + ): PersistentEventBase { + const EventClass = PersistentEventFactory.getEventClass(roomVersion); + + return new EventClass(event, roomVersion as RoomVersion3To11, partial) as PersistentEventBase; + } + // create individual events // a m.room.create event, adds the roomId too static newCreateEvent(creator: UserID, roomVersion: RoomVersion = PersistentEventFactory.defaultRoomVersion) { - if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { - throw new Error(`Room version ${roomVersion} is not supported`); - } - - const createContent: PduCreateEventContent = { - room_version: roomVersion, - creator, - }; + const createContent = PersistentEventFactory.getEventClass(roomVersion).newCreateEventContent(creator, roomVersion); const domain = creator.split(':').pop(); diff --git a/packages/room/src/manager/room-state.ts b/packages/room/src/manager/room-state.ts index a3c951a5d..4e67c690b 100644 --- a/packages/room/src/manager/room-state.ts +++ b/packages/room/src/manager/room-state.ts @@ -18,7 +18,7 @@ export class RoomState { throw new Error('Room create event not found'); } - return createEvent.getContent().creator; + return createEvent.getCreator(); } getUserMembership(userId: string): PduMembershipEventContent['membership'] | undefined { diff --git a/packages/room/src/manager/v11.ts b/packages/room/src/manager/v11.ts index ecb9c2f26..d44ba9fd8 100644 --- a/packages/room/src/manager/v11.ts +++ b/packages/room/src/manager/v11.ts @@ -1,8 +1,19 @@ import { REDACT_ALLOW_ALL_KEYS } from './event-wrapper'; +import type { RoomVersion } from './type'; import { PersistentEventV9 } from './v9'; -import { type PduType } from '../types/v3-11'; +import type { UserID } from '../types/_common'; +import { type PduCreateEventContent, type PduType } from '../types/v3-11'; export class PersistentEventV11 extends PersistentEventV9 { + // v11 removed m.room.create's content.creator, the sender is the creator instead + static newCreateEventContent(_creator: UserID, roomVersion: RoomVersion): PduCreateEventContent { + return { room_version: roomVersion }; + } + + protected resolveCreator(): UserID { + return this.sender as UserID; + } + getAllowedKeys(): string[] { return [ 'event_id', diff --git a/packages/room/src/manager/v3.ts b/packages/room/src/manager/v3.ts index 00ddf0604..86782de38 100644 --- a/packages/room/src/manager/v3.ts +++ b/packages/room/src/manager/v3.ts @@ -2,14 +2,18 @@ import { toUnpaddedBase64 } from '@rocket.chat/federation-crypto'; import { PersistentEventBase } from './event-wrapper'; import type { REDACT_ALLOW_ALL_KEYS } from './event-wrapper'; -import type { RoomVersion3To11 } from './type'; -import type { EventID } from '../types/_common'; -import type { PduType } from '../types/v3-11'; +import type { RoomVersion, RoomVersion3To11 } from './type'; +import type { EventID, UserID } from '../types/_common'; +import type { PduCreateEventContent, PduType } from '../types/v3-11'; // v3 is where it changes first export class PersistentEventV3 extends PersistentEventBase { private _eventId?: EventID; + static newCreateEventContent(creator: UserID, roomVersion: RoomVersion): PduCreateEventContent { + return { room_version: roomVersion, creator }; + } + get eventId(): EventID { if (this._eventId) { return this._eventId; @@ -42,6 +46,10 @@ export class PersistentEventV3 extends Persisten ]; } + protected resolveCreator(): UserID | undefined { + return (this.getContent() as PduCreateEventContent).creator as UserID | undefined; + } + getAllowedContentKeys(): Record { return { 'm.room.create': ['creator'], diff --git a/packages/room/src/types/v3-11.ts b/packages/room/src/types/v3-11.ts index 1cec0ff8c..99c2585a3 100644 --- a/packages/room/src/types/v3-11.ts +++ b/packages/room/src/types/v3-11.ts @@ -104,7 +104,8 @@ export const PduCreateEventContentSchema = z.object({ .string() .describe( ' The user_id of the room creator. Required for, and only present in, room versions 1 - 10. Starting with room version 11 the event sender should be used instead.', - ), + ) + .optional(), 'm.federate': z .boolean() .describe(' Whether users on other servers can join this room. Defaults to true if key does not exist.') From 89ace0ffc19612155ae3c35c5875eb66305c6e24 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Aug 2026 20:35:01 -0300 Subject: [PATCH 02/25] fix(room): allow the room creator's first join in v11 rooms The "only previous event is m.room.create and state_key is the creator" allowance compared against content.creator, which does not exist in v11, so the creator's own first join was rejected and no v11 room could be bootstrapped. Resolve the creator through the room version class instead. --- .../src/authorizartion-rules/rules.spec.ts | 23 +++++++++++++++++++ .../room/src/authorizartion-rules/rules.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/room/src/authorizartion-rules/rules.spec.ts b/packages/room/src/authorizartion-rules/rules.spec.ts index 16c24b9e9..072851186 100644 --- a/packages/room/src/authorizartion-rules/rules.spec.ts +++ b/packages/room/src/authorizartion-rules/rules.spec.ts @@ -1173,6 +1173,29 @@ describe('authorization rules', () => { expect(() => checkEventAuthWithoutState(create, [])).not.toThrow(); }); + it("should allow the room creator's first join in a v11 room", async () => { + const create = new FakeStateEventCreator() + .asRoomCreate() + .withRoomId(roomId) + .withSender(creator) + .withContent({ room_version: '11' }) + .build('11'); + + store.events.set(create.eventId, create); + + // no join_rules event yet, so only the creator allowance can let this through + const join = new FakeStateEventCreator() + .asRoomMember() + .withRoomId(roomId) + .withStateKey(creator) + .withSender(creator) + .withContent({ membership: 'join' }) + .withLastEvent(create) + .build('11'); + + await expect(checkEventAuthWithState(join, getStateMap([create]), store)).resolves.toBeUndefined(); + }); + it('should still require content.creator before v11', async () => { const create = new FakeStateEventCreator() .asRoomCreate() diff --git a/packages/room/src/authorizartion-rules/rules.ts b/packages/room/src/authorizartion-rules/rules.ts index a76f98870..141cfe45b 100644 --- a/packages/room/src/authorizartion-rules/rules.ts +++ b/packages/room/src/authorizartion-rules/rules.ts @@ -152,7 +152,7 @@ async function isMembershipChangeAllowed( if (previousEvents.length === 1) { const [event] = previousEvents; - if (event.isCreateEvent() && event.getContent().creator === membershipEventToCheck.stateKey) { + if (event.isCreateEvent() && event.getCreator() === membershipEventToCheck.stateKey) { return; } } From eb627be9d2908819629075d4cccb0bee87c1fb70 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 5 Aug 2026 20:38:42 -0300 Subject: [PATCH 03/25] fix(core): verify inbound signatures with room-version-aware redaction checkSignAndHashes reconstructed the signed bytes with pruneEventDict's pre-v6 defaults, so verification failed for every event whose redaction actually differs: for v11 the create content was collapsed to {creator}, invite was dropped from power_levels and origin/membership/prev_state were kept. Redact through the room version class instead, and require the room version at the call site. --- .../core/src/utils/checkSignAndHashes.spec.ts | 39 +++++++++++++++++-- packages/core/src/utils/checkSignAndHashes.ts | 8 +++- .../src/services/event.service.ts | 11 ++++-- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/core/src/utils/checkSignAndHashes.spec.ts b/packages/core/src/utils/checkSignAndHashes.spec.ts index b7121281c..36c78c848 100644 --- a/packages/core/src/utils/checkSignAndHashes.spec.ts +++ b/packages/core/src/utils/checkSignAndHashes.spec.ts @@ -14,6 +14,7 @@ describe('checkSignAndHashes', () => { const originalAtob = globalThis.atob; const mockOrigin = 'example.com'; + const roomVersion = '11'; const mockSignature = { algorithm: EncryptionValidAlgorithm.ed25519, version: 'key_version', @@ -61,7 +62,7 @@ describe('checkSignAndHashes', () => { const verifyJsonSpy = spyOn(signJson, 'verifyJsonSignature').mockReturnValue(true); const computeHashSpy = spyOn(authentication, 'computeHash').mockReturnValue(['sha256', mockHash]); - const result = await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock); + const result = await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock, roomVersion); expect(getSignaturesSpy).toHaveBeenCalledWith(mockPdu, mockOrigin); expect(verifyJsonSpy).toHaveBeenCalled(); @@ -74,6 +75,36 @@ describe('checkSignAndHashes', () => { computeHashSpy.mockRestore(); }); + it('should redact according to the room version before verifying', async () => { + const createPdu = { + type: 'm.room.create', + state_key: '', + room_id: '!room:example.com', + sender: '@creator:example.com', + content: { 'room_version': '11', 'm.federate': false }, + hashes: { sha256: mockHash }, + signatures: mockPdu.signatures, + } as unknown as HashedEvent>; + + const getSignaturesSpy = spyOn(signJson, 'getSignaturesFromRemote').mockResolvedValue([mockSignature]); + const verifyJsonSpy = spyOn(signJson, 'verifyJsonSignature').mockReturnValue(true); + const computeHashSpy = spyOn(authentication, 'computeHash').mockReturnValue(['sha256', mockHash]); + + // v11 keeps the whole m.room.create content + await checkSignAndHashes(createPdu, mockOrigin, getPublicKeyFromServerMock, '11'); + expect(verifyJsonSpy.mock.calls[0][0]).toMatchObject({ + content: { 'room_version': '11', 'm.federate': false }, + }); + + // before v11 only creator survives, so the same event redacts to an empty content + await checkSignAndHashes(createPdu, mockOrigin, getPublicKeyFromServerMock, '10'); + expect(verifyJsonSpy.mock.calls[1][0]).toMatchObject({ content: {} }); + + getSignaturesSpy.mockRestore(); + verifyJsonSpy.mockRestore(); + computeHashSpy.mockRestore(); + }); + it('should throw error for invalid signature', async () => { const getSignaturesSpy = spyOn(signJson, 'getSignaturesFromRemote').mockResolvedValue([mockSignature]); const verifyJsonSpy = spyOn(signJson, 'verifyJsonSignature').mockReturnValue(false); @@ -81,7 +112,7 @@ describe('checkSignAndHashes', () => { let error: Error | undefined; try { - await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock); + await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock, roomVersion); } catch (e) { error = e as Error; } @@ -101,7 +132,7 @@ describe('checkSignAndHashes', () => { let error: Error | undefined; try { - await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock); + await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock, roomVersion); } catch (e) { error = e as Error; } @@ -121,7 +152,7 @@ describe('checkSignAndHashes', () => { let error: Error | undefined; try { - await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock); + await checkSignAndHashes(mockPdu, mockOrigin, getPublicKeyFromServerMock, roomVersion); } catch (e) { error = e as Error; } diff --git a/packages/core/src/utils/checkSignAndHashes.ts b/packages/core/src/utils/checkSignAndHashes.ts index bb6acf4cc..3d22bd9dc 100644 --- a/packages/core/src/utils/checkSignAndHashes.ts +++ b/packages/core/src/utils/checkSignAndHashes.ts @@ -1,22 +1,26 @@ import type { Pdu } from '@rocket.chat/federation-room'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; import { type HashedEvent, computeHash } from './authentication'; import { MatrixError } from './errors'; import { logger } from './logger'; -import { pruneEventDict } from './pruneEventDict'; import { type SignedJson, getSignaturesFromRemote, verifyJsonSignature } from './signJson'; export async function checkSignAndHashes>( pdu: HashedEvent, origin: string, getPublicKeyFromServer: (origin: string, key: string) => Promise, + roomVersion: string, ) { const [signature] = await getSignaturesFromRemote(pdu, origin); const publicKey = await getPublicKeyFromServer(origin, `${signature.algorithm}:${signature.version}`); + // the signature covers the redacted event, and the redaction algorithm is room version specific + const { redactedEvent } = PersistentEventFactory.createFromRawEvent(pdu, roomVersion); + if ( !verifyJsonSignature( - pruneEventDict(pdu), + redactedEvent, origin, Uint8Array.from(atob(signature.signature), (c) => c.charCodeAt(0)), Uint8Array.from(atob(publicKey), (c) => c.charCodeAt(0)), diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index 513efeb3f..94342ec0e 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -256,9 +256,14 @@ export class EventService { throw new Error('M_MISSING_SIGNATURES_OR_HASHES'); } - await checkSignAndHashes(event, origin, (origin, key) => { - return this.serverService.getPublicKey(origin, key); - }); + await checkSignAndHashes( + event, + origin, + (origin, key) => { + return this.serverService.getPublicKey(origin, key); + }, + roomVersion, + ); } private async processIncomingEDUs(edus: BaseEDU[]): Promise { From 5a299c7795c5454e88f8c0012275f3c0fd49ee04 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 11:01:44 -0300 Subject: [PATCH 04/25] fix(sdk): remove the duplicate, version-blind event-ID implementation generateId pruned events with pre-v6 redaction rules, so it produced different IDs than the canonical PersistentEventFactory path for any event whose redaction is version dependent, causing cache misses, duplicate rows and failed lookups. - event-fetcher now matches federated events using the room version's ID - drop EventRepository.upsert, whose only job was minting an ID this way - log lines no longer mint an ID just to name an event - delete generateId along with createEventWithId and the create*Event builders that existed only to attach such an ID (no callers outside their own specs); the specs that assert an event ID now go through PersistentEventFactory and still match their recorded IDs --- packages/core/src/events/m.reaction.spec.ts | 5 +- packages/core/src/events/m.reaction.ts | 3 - .../core/src/events/m.room.create.spec.ts | 3 +- packages/core/src/events/m.room.create.ts | 3 - .../src/events/m.room.guest_access.spec.ts | 5 +- .../core/src/events/m.room.guest_access.ts | 3 - .../events/m.room.history_visibility.spec.ts | 5 +- .../src/events/m.room.history_visibility.ts | 3 - .../core/src/events/m.room.join_rules.spec.ts | 5 +- packages/core/src/events/m.room.join_rules.ts | 3 - .../src/events/m.room.member-invite.spec.ts | 5 +- .../core/src/events/m.room.member.spec.ts | 36 ++++--- packages/core/src/events/m.room.member.ts | 3 - packages/core/src/events/m.room.message.ts | 3 - packages/core/src/events/m.room.name.spec.ts | 12 ++- packages/core/src/events/m.room.name.ts | 3 - .../src/events/m.room.power_levels.spec.ts | 7 +- .../core/src/events/m.room.power_levels.ts | 3 - .../core/src/events/m.room.redaction.spec.ts | 5 +- packages/core/src/events/m.room.redaction.ts | 3 - .../core/src/events/m.room.tombstone.spec.ts | 37 +------ packages/core/src/events/m.room.tombstone.ts | 3 - .../events/utils/createEventWithId.spec.ts | 97 ------------------- .../events/utils/createSignedEvent.spec.ts | 25 ++--- .../src/events/utils/createSignedEvent.ts | 14 --- packages/core/src/index.ts | 1 - .../core/src/utils/authentication.spec.ts | 77 --------------- packages/core/src/utils/generateId.ts | 16 --- .../repositories/event-staging.repository.ts | 1 - .../src/repositories/event.repository.ts | 14 --- .../services/event-authorization.service.ts | 4 +- .../src/services/event-fetcher.service.ts | 14 ++- .../src/services/event.service.ts | 12 +-- 33 files changed, 78 insertions(+), 355 deletions(-) delete mode 100644 packages/core/src/events/utils/createEventWithId.spec.ts delete mode 100644 packages/core/src/utils/generateId.ts diff --git a/packages/core/src/events/m.reaction.spec.ts b/packages/core/src/events/m.reaction.spec.ts index dbb0aa782..036245f7d 100644 --- a/packages/core/src/events/m.reaction.spec.ts +++ b/packages/core/src/events/m.reaction.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { reactionEvent } from './m.reaction'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -52,7 +53,7 @@ test('reactionEvent', async () => { }); const signedReaction = await signEvent(reaction, signature, 'rc1'); - const reactionEventId = generateId(signedReaction); + const reactionEventId = PersistentEventFactory.createFromRawEvent(signedReaction as any, '10').eventId; expect(signedReaction).toMatchObject(finalEvent); expect(reactionEventId).toBe(reactionEventId); diff --git a/packages/core/src/events/m.reaction.ts b/packages/core/src/events/m.reaction.ts index 6585da38b..3d366063f 100644 --- a/packages/core/src/events/m.reaction.ts +++ b/packages/core/src/events/m.reaction.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -88,5 +87,3 @@ export const reactionEvent = ({ unsigned: { ...unsigned, age_ts: ts }, }); }; - -export const createReactionEvent = createEventWithId(reactionEvent); diff --git a/packages/core/src/events/m.room.create.spec.ts b/packages/core/src/events/m.room.create.spec.ts index 364d23265..8c3fa51b5 100644 --- a/packages/core/src/events/m.room.create.spec.ts +++ b/packages/core/src/events/m.room.create.spec.ts @@ -5,7 +5,6 @@ import { PersistentEventFactory } from '@rocket.chat/federation-room'; import type { SignedEvent } from '../types'; import { type RoomCreateEvent, isRoomCreateEvent, roomCreateEvent } from './m.room.create'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -52,7 +51,7 @@ test('roomCreateEvent', async () => { 'rmnvsWlTL+JP8Sk9767UR0svF4IrzC9zhUPbT+y4u31r/qtIaF9OtT1FP8tD/yFGD92qoTcRb4Oo8DRbLRXcAg', ); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(eventId).toBe(finalEventId); }); diff --git a/packages/core/src/events/m.room.create.ts b/packages/core/src/events/m.room.create.ts index f16f5884b..2a0c42c23 100644 --- a/packages/core/src/events/m.room.create.ts +++ b/packages/core/src/events/m.room.create.ts @@ -1,5 +1,4 @@ import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; export interface RoomCreateEvent extends EventBase { content: { @@ -38,6 +37,4 @@ export const roomCreateEvent = ({ roomId, sender, ts = Date.now() }: RoomCreateE unsigned: { age_ts: ts }, }); -export const createRoomCreateEvent = createEventWithId((...args: any[]) => roomCreateEvent(args[0])); - export const isRoomCreateEvent = (event: EventBase): event is RoomCreateEvent => event.type === 'm.room.create'; diff --git a/packages/core/src/events/m.room.guest_access.spec.ts b/packages/core/src/events/m.room.guest_access.spec.ts index 23c1d73bf..9dd9f7ad6 100644 --- a/packages/core/src/events/m.room.guest_access.spec.ts +++ b/packages/core/src/events/m.room.guest_access.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { roomGuestAccessEvent } from './m.room.guest_access'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -54,7 +55,7 @@ test('roomGuestAccessEvent', async () => { 'PLaE7un6a+pzrsU/0kiB/tvneZp5/dEda4+uE7UK411hNaM4W4ZUo52ua6AGO9q5gLBjSmnR90/tPf714HiTBw', ); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(eventId).toBe(finalEventId); }); diff --git a/packages/core/src/events/m.room.guest_access.ts b/packages/core/src/events/m.room.guest_access.ts index 5ee1d300c..1bab43460 100644 --- a/packages/core/src/events/m.room.guest_access.ts +++ b/packages/core/src/events/m.room.guest_access.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -44,5 +43,3 @@ export const roomGuestAccessEvent = ({ unsigned: { age_ts: ts }, }); }; - -export const createRoomGuestAccessEvent = createEventWithId(roomGuestAccessEvent); diff --git a/packages/core/src/events/m.room.history_visibility.spec.ts b/packages/core/src/events/m.room.history_visibility.spec.ts index 7cdbb4c19..e50408052 100644 --- a/packages/core/src/events/m.room.history_visibility.spec.ts +++ b/packages/core/src/events/m.room.history_visibility.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { roomHistoryVisibilityEvent } from './m.room.history_visibility'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -56,7 +57,7 @@ test('roomHistoryVisibilityEvent', async () => { 'ZHzOfPU2BYDilKSrt5zqMBC9ohZtHph4uLldOIzBY/oTO1pZCp3D9CRr04h5eJ7zkkuzkNv4y8+N0TDPNMHFBg', ); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(eventId).toBe(finalEventId); }); diff --git a/packages/core/src/events/m.room.history_visibility.ts b/packages/core/src/events/m.room.history_visibility.ts index 617a0d018..bd55c2d01 100644 --- a/packages/core/src/events/m.room.history_visibility.ts +++ b/packages/core/src/events/m.room.history_visibility.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -46,5 +45,3 @@ export const roomHistoryVisibilityEvent = ({ unsigned: { age_ts: ts }, }); }; - -export const createRoomHistoryVisibilityEvent = createEventWithId(roomHistoryVisibilityEvent); diff --git a/packages/core/src/events/m.room.join_rules.spec.ts b/packages/core/src/events/m.room.join_rules.spec.ts index c6a6b7a23..dccf4f283 100644 --- a/packages/core/src/events/m.room.join_rules.spec.ts +++ b/packages/core/src/events/m.room.join_rules.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { isRoomJoinRulesEvent, roomJoinRulesEvent } from './m.room.join_rules'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -54,7 +55,7 @@ test('roomJoinRulesEvent', async () => { 'egXzghr88RZMZYG4/DUrIf92NiUiC59GhgmvB1zV5oSuDuCGXgYnVBmXOfQ54ElXx1AFc8ajwPmfupXoYkHaAg', ); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(eventId).toBe(finalEventId); }); diff --git a/packages/core/src/events/m.room.join_rules.ts b/packages/core/src/events/m.room.join_rules.ts index fccc40b2e..07ac01615 100644 --- a/packages/core/src/events/m.room.join_rules.ts +++ b/packages/core/src/events/m.room.join_rules.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -49,8 +48,6 @@ export const roomJoinRulesEvent = ({ }); }; -export const createRoomJoinRulesEvent = createEventWithId(roomJoinRulesEvent); - export const isRoomJoinRulesEvent = (event: EventBase): event is RoomJoinRulesEvent => { return event.type === 'm.room.join_rules'; }; diff --git a/packages/core/src/events/m.room.member-invite.spec.ts b/packages/core/src/events/m.room.member-invite.spec.ts index a052bc989..ba0218bf7 100644 --- a/packages/core/src/events/m.room.member-invite.spec.ts +++ b/packages/core/src/events/m.room.member-invite.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { roomMemberEvent } from './m.room.member'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -117,7 +118,7 @@ test('roomMemberInviteEvent', async () => { '8/qPp2d0PTc4bVMNdbTl32OSFnNqXan9ACQr1QcDV3SgdsDnm+sZv2mXW8rdhIOLOohRG2cED0+1aNxV7VH2Cw', ); - const memberEventId = generateId(signed); + const memberEventId = PersistentEventFactory.createFromRawEvent(signed as any, '10').eventId; expect(memberEventId).toBe(finalEventId); }); diff --git a/packages/core/src/events/m.room.member.spec.ts b/packages/core/src/events/m.room.member.spec.ts index d5c6d4457..da1212f60 100644 --- a/packages/core/src/events/m.room.member.spec.ts +++ b/packages/core/src/events/m.room.member.spec.ts @@ -1,11 +1,15 @@ import { expect, test } from 'bun:test'; +import type { Pdu } from '@rocket.chat/federation-room'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { roomCreateEvent } from './m.room.create'; import { roomMemberEvent } from './m.room.member'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; +const eventIdOf = (event: unknown) => PersistentEventFactory.createFromRawEvent(event as Pdu, '10').eventId; + const finalEventId = '$tZRt2bwceX4sG913Ee67tJiwe-gk859kY2mCeYSncw8'; const finalEvent = { auth_events: ['$0AQU5dG_mtjH6qavAxYrQsDC0a_-6T3DHs1yoxf5fz4'], @@ -37,7 +41,7 @@ test('roomMemberEvent', async () => { }); const signedCreateEvent = await signEvent(createEvent, signature, 'hs1'); - const createEventId = generateId(signedCreateEvent); + const createEventId = eventIdOf(signedCreateEvent); const memberEvent = roomMemberEvent({ membership: 'join', roomId: '!uTqsSSWabZzthsSCNf:hs1', @@ -62,7 +66,7 @@ test('roomMemberEvent', async () => { 'y/qV5T9PeXvqgwRafZDSygtk4XRMstdt04qusZWJSu77Juxzzz4Ijyk+JsJ5NNV0/WWYMT9IhmVb7/EEBH4vDQ', ); - const memberEventId = generateId(signed); + const memberEventId = eventIdOf(signed); expect(memberEventId).toBe(finalEventId); }); @@ -80,7 +84,7 @@ test('roomMemberEvent - leave', async () => { ts: ts - 1000, }); const signedCreateEvent = await signEvent(createEventPayload, signature, serverName); - const createEventId = generateId(signedCreateEvent); + const createEventId = eventIdOf(signedCreateEvent); // A user usually joins before they can leave const joinMemberEventPayload = roomMemberEvent({ @@ -96,7 +100,7 @@ test('roomMemberEvent - leave', async () => { origin: serverName, }); const signedJoinEvent = await signEvent(joinMemberEventPayload, signature, serverName); - const joinEventId = generateId(signedJoinEvent); + const joinEventId = eventIdOf(signedJoinEvent); // Now, the leave event const leaveMemberEventPayload = roomMemberEvent({ @@ -118,7 +122,7 @@ test('roomMemberEvent - leave', async () => { }); const signedLeaveEvent = await signEvent(leaveMemberEventPayload, signature, serverName); - const leaveEventId = generateId(signedLeaveEvent); + const leaveEventId = eventIdOf(signedLeaveEvent); expect(signedLeaveEvent.type).toBe('m.room.member'); expect(signedLeaveEvent.room_id).toBe(roomId); @@ -152,7 +156,7 @@ test('roomMemberEvent - kick', async () => { ts: ts - 4000, }); const signedCreateEvent = await signEvent(createEventPayload, kickerSignature, serverName); - const createEventId = generateId(signedCreateEvent); + const createEventId = eventIdOf(signedCreateEvent); let lastEventId = createEventId; let currentDepth = 1; @@ -186,7 +190,7 @@ test('roomMemberEvent - kick', async () => { origin_server_ts: ts - 3000, }; const signedPowerLevelsEvent = await signEvent(powerLevelsEventPayload, kickerSignature, serverName); - const powerLevelsEventId = generateId(signedPowerLevelsEvent); + const powerLevelsEventId = eventIdOf(signedPowerLevelsEvent); lastEventId = powerLevelsEventId; // 3. Kicker Joins (sent by kicker) @@ -206,7 +210,7 @@ test('roomMemberEvent - kick', async () => { origin: serverName, }); const signedKickerJoinEvent = await signEvent(kickerJoinEventPayload, kickerSignature, serverName); - const kickerJoinEventId = generateId(signedKickerJoinEvent); + const kickerJoinEventId = eventIdOf(signedKickerJoinEvent); lastEventId = kickerJoinEventId; // 4. UserToKick Joins (sent by userToKick) @@ -226,7 +230,7 @@ test('roomMemberEvent - kick', async () => { origin: serverName, }); const signedUserToKickJoinEvent = await signEvent(userToKickJoinEventPayload, userToKickSignature, serverName); - const userToKickJoinEventId = generateId(signedUserToKickJoinEvent); + const userToKickJoinEventId = eventIdOf(signedUserToKickJoinEvent); lastEventId = userToKickJoinEventId; // 5. Kick Event (sent by kicker, targets userToKick) @@ -252,7 +256,7 @@ test('roomMemberEvent - kick', async () => { }); const signedKickEvent = await signEvent(kickMemberEventPayload, kickerSignature, serverName); - const kickEventId = generateId(signedKickEvent); + const kickEventId = eventIdOf(signedKickEvent); // Assertions expect(signedKickEvent.type).toBe('m.room.member'); @@ -292,7 +296,7 @@ test('roomMemberEvent - ban', async () => { ts: ts - 4000, }); const signedCreateEvent = await signEvent(createEventPayload, bannerSignature, serverName); - const createEventId = generateId(signedCreateEvent); + const createEventId = eventIdOf(signedCreateEvent); let lastEventId = createEventId; let currentDepth = 1; @@ -326,7 +330,7 @@ test('roomMemberEvent - ban', async () => { origin_server_ts: ts - 3000, }; const signedPowerLevelsEvent = await signEvent(powerLevelsEventPayload, bannerSignature, serverName); - const powerLevelsEventId = generateId(signedPowerLevelsEvent); + const powerLevelsEventId = eventIdOf(signedPowerLevelsEvent); lastEventId = powerLevelsEventId; // 3. Banner Joins (sent by banner) @@ -346,7 +350,7 @@ test('roomMemberEvent - ban', async () => { origin: serverName, }); const signedBannerJoinEvent = await signEvent(bannerJoinEventPayload, bannerSignature, serverName); - const bannerJoinEventId = generateId(signedBannerJoinEvent); + const bannerJoinEventId = eventIdOf(signedBannerJoinEvent); lastEventId = bannerJoinEventId; // 4. UserToBan Joins (sent by userToBan) @@ -366,7 +370,7 @@ test('roomMemberEvent - ban', async () => { origin: serverName, }); const signedUserToBanJoinEvent = await signEvent(userToBanJoinEventPayload, userToBanSignature, serverName); - const userToBanJoinEventId = generateId(signedUserToBanJoinEvent); + const userToBanJoinEventId = eventIdOf(signedUserToBanJoinEvent); lastEventId = userToBanJoinEventId; // 5. Ban Event (sent by banner, targets userToBan) @@ -392,7 +396,7 @@ test('roomMemberEvent - ban', async () => { }); const signedBanEvent = await signEvent(banMemberEventPayload, bannerSignature, serverName); - const banEventId = generateId(signedBanEvent); + const banEventId = eventIdOf(signedBanEvent); // Assertions expect(signedBanEvent.type).toBe('m.room.member'); diff --git a/packages/core/src/events/m.room.member.ts b/packages/core/src/events/m.room.member.ts index b84ee6032..b19110cfd 100644 --- a/packages/core/src/events/m.room.member.ts +++ b/packages/core/src/events/m.room.member.ts @@ -2,7 +2,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { createEventBase } from './eventBase'; import type { Membership, RoomMemberEvent } from './isRoomMemberEvent'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -80,5 +79,3 @@ export const roomMemberEvent = ({ unsigned: { age_ts: ts, ...unsigned }, }); }; - -export const createRoomMemberEvent = createEventWithId(roomMemberEvent); diff --git a/packages/core/src/events/m.room.message.ts b/packages/core/src/events/m.room.message.ts index 71813feb8..7577f2805 100644 --- a/packages/core/src/events/m.room.message.ts +++ b/packages/core/src/events/m.room.message.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; export type TextMessageType = 'm.text' | 'm.emote' | 'm.notice'; export type FileMessageType = 'm.image' | 'm.file' | 'm.audio' | 'm.video'; @@ -164,5 +163,3 @@ export const roomMessageEvent = ({ unsigned: { age_ts: ts, ...unsigned }, }); }; - -export const createRoomMessageEvent = createEventWithId(roomMessageEvent); diff --git a/packages/core/src/events/m.room.name.spec.ts b/packages/core/src/events/m.room.name.spec.ts index e3e3bd83c..d6c9e8031 100644 --- a/packages/core/src/events/m.room.name.spec.ts +++ b/packages/core/src/events/m.room.name.spec.ts @@ -1,13 +1,17 @@ import { describe, expect, test } from 'bun:test'; +import type { Pdu } from '@rocket.chat/federation-room'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import type { SignedEvent } from '../types'; import { roomCreateEvent } from './m.room.create'; import { roomMemberEvent } from './m.room.member'; import { type RoomNameEvent, roomNameEvent } from './m.room.name'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; +const eventIdOf = (event: unknown) => PersistentEventFactory.createFromRawEvent(event as Pdu, '10').eventId; + const finalEventId = '$JdX_s3d4CORV_BfkatJxF_lUfzJoKjzQXTP0NGtVj1E'; const finalEventPlaceholder: SignedEvent = { event_id: finalEventId, @@ -55,7 +59,7 @@ describe('roomNameEvent', () => { ts: exampleTimestamp - 2000, // No explicit content needed here }); const signedCreateEvent = await signEvent(createEventPayload, signingKey, serverName); - const createEventId = generateId(signedCreateEvent); + const createEventId = eventIdOf(signedCreateEvent); // 2. Mock Power Levels Event ID (as in m.room.message.spec.ts) const powerLevelsEventId = '$placeholder_power_levels_event_id'; @@ -73,7 +77,7 @@ describe('roomNameEvent', () => { prev_events: [createEventId], }); const signedMemberEvent = await signEvent(memberEventPayload, signingKey, serverName); - const memberEventId = generateId(signedMemberEvent); + const memberEventId = eventIdOf(signedMemberEvent); // 4. Room Name Event const roomNamePayload = roomNameEvent({ @@ -92,7 +96,7 @@ describe('roomNameEvent', () => { }); const signedRoomNameEvent = await signEvent(roomNamePayload, signingKey, serverName); - const generatedEventId = generateId(signedRoomNameEvent); + const generatedEventId = eventIdOf(signedRoomNameEvent); expect(finalEventId).toBe(generatedEventId); expect({ diff --git a/packages/core/src/events/m.room.name.ts b/packages/core/src/events/m.room.name.ts index 264c4456b..1654cb155 100644 --- a/packages/core/src/events/m.room.name.ts +++ b/packages/core/src/events/m.room.name.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -73,5 +72,3 @@ export const roomNameEvent = ({ origin, }) as RoomNameEvent; }; - -export const createRoomNameEvent = createEventWithId(roomNameEvent); diff --git a/packages/core/src/events/m.room.power_levels.spec.ts b/packages/core/src/events/m.room.power_levels.spec.ts index 60e477bd2..45faeb772 100644 --- a/packages/core/src/events/m.room.power_levels.spec.ts +++ b/packages/core/src/events/m.room.power_levels.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import type { SignedEvent } from '../types'; import { type RoomPowerLevelsEvent, roomPowerLevelsEvent } from './m.room.power_levels'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -114,7 +115,7 @@ test('roomPowerLevelsEvent', async () => { 'UBNpsQBCDX7t6cPHSj+g4bfAf/9Gb1TxYnme2MCXF4JgN7P3X0OUq0leFjrI5p/+sTR60/nuaZCX7OUYWTTLDA', ); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(eventId).toBe(finalEventId); }); @@ -168,7 +169,7 @@ test('roomPowerLevelsEvent with custom content', async () => { }); const signed: Omit, 'event_id'> = await signEvent(event, signature, 'hs1'); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(signed).toStrictEqual(finalCustomEvent); expect(eventId).toBe(finalCustomEventId); diff --git a/packages/core/src/events/m.room.power_levels.ts b/packages/core/src/events/m.room.power_levels.ts index 634e6cd38..815e9f259 100644 --- a/packages/core/src/events/m.room.power_levels.ts +++ b/packages/core/src/events/m.room.power_levels.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -99,8 +98,6 @@ export const roomPowerLevelsEvent = ({ }); }; -export const createRoomPowerLevelsEvent = createEventWithId(roomPowerLevelsEvent); - export const isRoomPowerLevelsEvent = (event: EventBase): event is RoomPowerLevelsEvent => { return event.type === 'm.room.power_levels'; }; diff --git a/packages/core/src/events/m.room.redaction.spec.ts b/packages/core/src/events/m.room.redaction.spec.ts index 834b1962d..224343edb 100644 --- a/packages/core/src/events/m.room.redaction.spec.ts +++ b/packages/core/src/events/m.room.redaction.spec.ts @@ -1,8 +1,9 @@ import { expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import type { EventBase } from './eventBase'; import { isRedactionEvent, redactionEvent } from './m.room.redaction'; -import { generateId } from '../utils/generateId'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -79,7 +80,7 @@ test('redactionEvent', async () => { expect(redaction.redacts).toBe('$8ftnUd9WTPTQGbdPgfOPea8bOEQ21qPvbcGqeOApQxA'); const signedRedaction = await signEvent(redaction, signature, 'rc1'); - const redactionEventId = generateId(signedRedaction); + const redactionEventId = PersistentEventFactory.createFromRawEvent(signedRedaction as any, '10').eventId; // Verify basic event structure after signing expect(signedRedaction.type).toBe('m.room.redaction'); diff --git a/packages/core/src/events/m.room.redaction.ts b/packages/core/src/events/m.room.redaction.ts index 2a09ba680..ef7eeeb83 100644 --- a/packages/core/src/events/m.room.redaction.ts +++ b/packages/core/src/events/m.room.redaction.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; declare module './eventBase' { interface Events { @@ -81,5 +80,3 @@ export const redactionEvent = ({ redacts, }; }; - -export const createRedactionEvent = createEventWithId(redactionEvent); diff --git a/packages/core/src/events/m.room.tombstone.spec.ts b/packages/core/src/events/m.room.tombstone.spec.ts index 70173cbf6..b4a563e41 100644 --- a/packages/core/src/events/m.room.tombstone.spec.ts +++ b/packages/core/src/events/m.room.tombstone.spec.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import type { EventBase } from './eventBase'; -import { createRoomTombstoneEvent, isRoomTombstoneEvent, roomTombstoneEvent } from './m.room.tombstone'; -import { generateId } from '../utils/generateId'; +import { isRoomTombstoneEvent, roomTombstoneEvent } from './m.room.tombstone'; import { generateKeyPairsFromString } from '../utils/keys'; import { signEvent } from '../utils/signEvent'; @@ -123,37 +124,7 @@ describe('m.room.tombstone', () => { expect(signed).toHaveProperty('signatures.hs1.ed25519:a_HDhg'); expect(signed.signatures.hs1['ed25519:a_HDhg']).toBe(expectedSignature); - const eventId = generateId(signed); + const { eventId } = PersistentEventFactory.createFromRawEvent(signed as any, '10'); expect(eventId).toBe(expectedEventId); }); - - test('should create a room tombstone event with ID', async () => { - const signature = await generateKeyPairsFromString('ed25519 a_HDhg WntaJ4JP5WbZZjDShjeuwqCybQ5huaZAiowji7tnIEw'); - - const eventProps = { - roomId, - sender, - body, - replacementRoom, - depth, - auth_events, - prev_events, - ts: 1733107418713, - origin: 'hs1', - }; - - const { createSignedEvent } = await import('./utils/createSignedEvent'); - const signEventFn = createSignedEvent(signature, 'hs1'); - const createEventFn = createRoomTombstoneEvent(signEventFn); - - const { event, _id: event_id } = await createEventFn(eventProps); - - expect(event.type).toBe('m.room.tombstone'); - expect(event.room_id).toBe(roomId); - expect(event.sender).toBe(sender); - expect(event.content.body).toBe(body); - expect(event.content.replacement_room).toBe(replacementRoom); - expect(event).toHaveProperty('signatures.hs1.ed25519:a_HDhg'); - expect(event_id).toBe('$3Sw4iBWv9pil8BRt3ojPIWGLNQGpsKJoImxNoCmz7ME'); - }); }); diff --git a/packages/core/src/events/m.room.tombstone.ts b/packages/core/src/events/m.room.tombstone.ts index 12aaf9a28..0c3c49444 100644 --- a/packages/core/src/events/m.room.tombstone.ts +++ b/packages/core/src/events/m.room.tombstone.ts @@ -1,7 +1,6 @@ import type { EventID } from '@rocket.chat/federation-room'; import { type EventBase, createEventBase } from './eventBase'; -import { createEventWithId } from './utils/createSignedEvent'; export interface RoomTombstoneEvent extends EventBase { type: 'm.room.tombstone'; @@ -77,7 +76,5 @@ export const roomTombstoneEvent = ({ }); }; -export const createRoomTombstoneEvent = createEventWithId(roomTombstoneEvent); - export const isRoomTombstoneEvent = (event: EventBase | null | undefined): event is RoomTombstoneEvent => Boolean(event && typeof event === 'object' && event.type === 'm.room.tombstone'); diff --git a/packages/core/src/events/utils/createEventWithId.spec.ts b/packages/core/src/events/utils/createEventWithId.spec.ts deleted file mode 100644 index 29f818adf..000000000 --- a/packages/core/src/events/utils/createEventWithId.spec.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { createEventWithId, createSignedEvent } from './createSignedEvent'; -import { generateId } from '../../utils/generateId'; -import { generateKeyPairsFromString } from '../../utils/keys'; - -describe('createEventWithId', () => { - test('it should add an ID to a signed event', async () => { - const signature = await generateKeyPairsFromString('ed25519 a_XRhW YjbSyfqQeGto+OFswt+XwtJUUooHXH5w+czSgawN63U'); - - const testEvent = () => ({ - type: 'm.test.event', - room_id: '!roomid:test.server', - sender: '@user:test.server', - origin_server_ts: 1733069433734, - content: { test: 'data' }, - }); - - const makeSignedEvent = createSignedEvent(signature, 'test.server'); - const withId = createEventWithId(testEvent)(makeSignedEvent); - const result = await withId(); - - expect(result).toHaveProperty('event'); - expect(result).toHaveProperty('_id'); - expect(result.event.type).toBe('m.test.event'); - expect(result.event.content.test).toBe('data'); - expect(result._id).toBe(generateId(result.event)); - }); - - test('it should generate unique IDs for different events', async () => { - const signature = await generateKeyPairsFromString('ed25519 a_XRhW YjbSyfqQeGto+OFswt+XwtJUUooHXH5w+czSgawN63U'); - - const testEvent1 = () => ({ - type: 'm.test.event', - room_id: '!room1:test.server', - sender: '@user:test.server', - origin_server_ts: 1733069433734, - content: { id: 'event1' }, - }); - - const testEvent2 = () => ({ - type: 'm.test.event', - room_id: '!room2:test.server', - sender: '@different:test.server', - origin_server_ts: 1733069433735, - content: { id: 'event2' }, - }); - - const makeSignedEvent = createSignedEvent(signature, 'test.server'); - const withId1 = createEventWithId(testEvent1)(makeSignedEvent); - const withId2 = createEventWithId(testEvent2)(makeSignedEvent); - const result1 = await withId1(); - const result2 = await withId2(); - - expect(result1._id).not.toBe(result2._id); - expect(result1._id).toBe(generateId(result1.event)); - expect(result2._id).toBe(generateId(result2.event)); - expect(result1.event.content.id).toBe('event1'); - expect(result2.event.content.id).toBe('event2'); - }); - - test('it should work with async event generators', async () => { - const signature = await generateKeyPairsFromString('ed25519 a_XRhW YjbSyfqQeGto+OFswt+XwtJUUooHXH5w+czSgawN63U'); - - const asyncTestEvent = async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - return { - type: 'm.test.async.event', - room_id: '!asyncroom:test.server', - sender: '@user:test.server', - origin_server_ts: 1733069433734, - content: { async: true }, - }; - }; - - const makeSignedEvent = createSignedEvent(signature, 'test.server'); - const withId = createEventWithId(asyncTestEvent)(makeSignedEvent); - const result = (await withId()) as unknown as { - _id: string; - event: { - type: string; - room_id: string; - sender: string; - origin_server_ts: number; - content: { - async: boolean; - }; - }; - }; - - expect(result).toHaveProperty('event'); - expect(result).toHaveProperty('_id'); - expect(result.event.type).toBe('m.test.async.event'); - expect(result.event.content.async).toBe(true); - expect(result._id).toBe(generateId(result.event)); - }); -}); diff --git a/packages/core/src/events/utils/createSignedEvent.spec.ts b/packages/core/src/events/utils/createSignedEvent.spec.ts index 7ebfa1437..bffc41fca 100644 --- a/packages/core/src/events/utils/createSignedEvent.spec.ts +++ b/packages/core/src/events/utils/createSignedEvent.spec.ts @@ -1,34 +1,25 @@ import { describe, expect, test } from 'bun:test'; -import { generateId } from '../../utils/generateId'; import { generateKeyPairsFromString } from '../../utils/keys'; import { signEvent } from '../../utils/signEvent'; -import { createRoomCreateEvent, roomCreateEvent } from '../m.room.create'; +import { roomCreateEvent } from '../m.room.create'; import { createSignedEvent } from './createSignedEvent'; describe('makeSignedEvent', () => { - test('it should return the same payload, following create event > sign > generate id', async () => { + test('it should return the same payload, following create event > sign', async () => { const signature = await generateKeyPairsFromString('ed25519 a_XRhW YjbSyfqQeGto+OFswt+XwtJUUooHXH5w+czSgawN63U'); - const event = roomCreateEvent({ + const args = { roomId: '!uTqsSSWabZzthsSCNf:hs1', sender: '@admin:hs1', ts: 1733069433734, - }); - const signed = await signEvent(event, signature, 'hs1'); - const id = generateId(signed); + }; + + const signed = await signEvent(roomCreateEvent(args), signature, 'hs1'); const makeSignedEvent = createSignedEvent(signature, 'hs1'); - const result = await createRoomCreateEvent(makeSignedEvent)({ - roomId: '!uTqsSSWabZzthsSCNf:hs1', - sender: '@admin:hs1', - ts: 1733069433734, - }); + const result = await makeSignedEvent(roomCreateEvent)(args); - expect({ - event: signed, - _id: id, - // @ts-expect-error --- IGNORE --- - }).toStrictEqual(result); + expect(result).toStrictEqual(signed); }); }); diff --git a/packages/core/src/events/utils/createSignedEvent.ts b/packages/core/src/events/utils/createSignedEvent.ts index 710e5d7fb..7156b5a03 100644 --- a/packages/core/src/events/utils/createSignedEvent.ts +++ b/packages/core/src/events/utils/createSignedEvent.ts @@ -1,5 +1,4 @@ import type { SigningKey, SignedEvent } from '../../types'; -import { generateId } from '../../utils/generateId'; import { signEvent } from '../../utils/signEvent'; export const createSignedEvent = (signature: SigningKey, signingName: string) => { @@ -10,16 +9,3 @@ export const createSignedEvent = (signature: SigningKey, signingName: string) => }; }; }; - -export const createEventWithId = any>(fn: F) => { - return >(sign: S) => { - return async (...args: Parameters): Promise<{ event: SignedEvent>; _id: string }> => { - const event = await sign(fn)(...args); - const id = generateId(event); - return { - event, - _id: id, - }; - }; - }; -}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 13aa7c151..440fa165a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,6 @@ export type { SignedEvent, SigningKey } from './types'; export { signEvent } from './utils/signEvent'; // Authentication utilities -export { generateId } from './utils/generateId'; export { pruneEventDict } from './utils/pruneEventDict'; export { checkSignAndHashes } from './utils/checkSignAndHashes'; export { diff --git a/packages/core/src/utils/authentication.spec.ts b/packages/core/src/utils/authentication.spec.ts index dee7292a7..c96b9eb65 100644 --- a/packages/core/src/utils/authentication.spec.ts +++ b/packages/core/src/utils/authentication.spec.ts @@ -8,7 +8,6 @@ import { signRequest, validateAuthorizationHeader, } from './authentication'; -import { generateId } from './generateId'; import { generateKeyPairsFromString } from './keys'; import { signJson } from './signJson'; @@ -135,80 +134,4 @@ test('signRequest', async () => { expect(signedRequest.signatures.hs2['ed25519:a_XRhW']).toBe( 'KDhgfpGp+34ElXpvFIBjsGO2kldNZKj1CWFEbSjyQR142ZYx+kIg+N3muLlMXEK0Fw76T/2vjihEWhwffsbcAg', ); - - const id = generateId(event); - - expect(id).toBe('$P4qGIj3TWoJBnr1IGzXEvgRd1IljQYqlFZkMI8_GmwY'); -}); - -describe('generateId', () => { - test('should generate a consistent ID for the same event content', () => { - const event = { - type: 'm.room.message', - sender: '@alice:example.com', - room_id: '!someroom:example.com', - content: { - body: 'Hello world!', - msgtype: 'm.text', - }, - origin_server_ts: 1234567890, - }; - const id1 = generateId(event); - const id2 = generateId(event); - - expect(id1).toBe(id2); - }); - - test('should generate different IDs for different event content', () => { - const event1 = { - type: 'm.room.message', - sender: '@alice:example.com', - room_id: '!someroom:example.com', - content: { - body: 'Hello world!', - msgtype: 'm.text', - }, - origin_server_ts: 1234567890, - }; - const event2 = { - type: 'm.room.message', - sender: '@bob:example.com', // Different sender - room_id: '!someroom:example.com', - content: { - body: 'Hello world!', - msgtype: 'm.text', - }, - origin_server_ts: 1234567890, - }; - - const id1 = generateId(event1); - const id2 = generateId(event2); - - expect(id1).not.toBe(id2); - }); - - test('should ignore fields like age_ts, unsigned, and signatures when generating ID', () => { - const eventBase = { - type: 'm.room.message', - sender: '@alice:example.com', - room_id: '!someroom:example.com', - content: { - body: 'Hello world!', - msgtype: 'm.text', - }, - origin_server_ts: 1234567890, - }; - - const eventWithExtraFields = { - ...eventBase, - age_ts: 1234567890, - unsigned: { age: 100 }, - signatures: { 'example.com': { 'ed25519:key': 'signature' } }, - }; - - const id1 = generateId(eventBase); - const id2 = generateId(eventWithExtraFields); - - expect(id1).toBe(id2); - }); }); diff --git a/packages/core/src/utils/generateId.ts b/packages/core/src/utils/generateId.ts deleted file mode 100644 index 0d19960a4..000000000 --- a/packages/core/src/utils/generateId.ts +++ /dev/null @@ -1,16 +0,0 @@ -import crypto from 'node:crypto'; - -import { encodeCanonicalJson } from '@rocket.chat/federation-crypto'; -import type { EventID, Pdu } from '@rocket.chat/federation-room'; - -import type { EventBase } from '@rocket.chat/federation-core'; - -import { toUnpaddedBase64 } from './binaryData'; -import { pruneEventDict } from './pruneEventDict'; - -export function generateId(content: T): EventID { - // remove the fields that are not part of the hash - const { unsigned, signatures, ...toHash } = pruneEventDict(content); - - return `\$${toUnpaddedBase64(crypto.createHash('sha256').update(encodeCanonicalJson(toHash)).digest(), { urlSafe: true })}` as EventID; -} diff --git a/packages/federation-sdk/src/repositories/event-staging.repository.ts b/packages/federation-sdk/src/repositories/event-staging.repository.ts index 4ccaa9731..a7b415047 100644 --- a/packages/federation-sdk/src/repositories/event-staging.repository.ts +++ b/packages/federation-sdk/src/repositories/event-staging.repository.ts @@ -1,4 +1,3 @@ -import { generateId } from '@rocket.chat/federation-core'; import type { EventStagingStore } from '@rocket.chat/federation-core'; import type { Pdu, RoomID, EventID } from '@rocket.chat/federation-room'; import type { Collection, DeleteResult, UpdateResult } from 'mongodb'; diff --git a/packages/federation-sdk/src/repositories/event.repository.ts b/packages/federation-sdk/src/repositories/event.repository.ts index f8f65959d..387c3f352 100644 --- a/packages/federation-sdk/src/repositories/event.repository.ts +++ b/packages/federation-sdk/src/repositories/event.repository.ts @@ -1,4 +1,3 @@ -import { generateId } from '@rocket.chat/federation-core'; import type { EventStore } from '@rocket.chat/federation-core'; import type { Pdu, PduForType, PduType, RejectCode, RoomID, StateID, EventID } from '@rocket.chat/federation-room'; import type { Collection, FindCursor, FindOptions, InsertOneResult, UpdateResult, WithId } from 'mongodb'; @@ -85,19 +84,6 @@ export class EventRepository { ); } - async upsert(event: Pdu): Promise { - const id = generateId(event); - - await this.collection.updateOne( - { _id: id }, - // TODO: _id is really required here? - { $set: { _id: id, event } }, - { upsert: true }, - ); - - return id; - } - public async findPowerLevelsEventByRoomId(roomId: string): Promise> | null> { return this.collection.findOne({ 'event.room_id': roomId, diff --git a/packages/federation-sdk/src/services/event-authorization.service.ts b/packages/federation-sdk/src/services/event-authorization.service.ts index a6b1e6f64..f11608919 100644 --- a/packages/federation-sdk/src/services/event-authorization.service.ts +++ b/packages/federation-sdk/src/services/event-authorization.service.ts @@ -1,4 +1,4 @@ -import { createLogger, extractSignaturesFromHeader, generateId, validateAuthorizationHeader } from '@rocket.chat/federation-core'; +import { createLogger, extractSignaturesFromHeader, validateAuthorizationHeader } from '@rocket.chat/federation-core'; import type { EventID, Pdu, PersistentEventBase, RoomID } from '@rocket.chat/federation-room'; import { delay, inject, singleton } from 'tsyringe'; @@ -32,7 +32,7 @@ export class EventAuthorizationService { ) {} async authorizeEvent(event: Pdu, authEvents: Pdu[]): Promise { - this.logger.debug(`Authorizing event ${generateId(event)} of type ${event.type}`); + this.logger.debug(`Authorizing event of type ${event.type} in room ${event.room_id}`); // Simple implementation - would need proper auth rules based on Matrix spec // https://spec.matrix.org/v1.7/server-server-api/#checks-performed-on-receipt-of-a-pdu diff --git a/packages/federation-sdk/src/services/event-fetcher.service.ts b/packages/federation-sdk/src/services/event-fetcher.service.ts index a31c07e93..77f51cdeb 100644 --- a/packages/federation-sdk/src/services/event-fetcher.service.ts +++ b/packages/federation-sdk/src/services/event-fetcher.service.ts @@ -1,5 +1,5 @@ -import { isFederationEventWithPDUs, createLogger, generateId } from '@rocket.chat/federation-core'; -import { EventID, Pdu } from '@rocket.chat/federation-room'; +import { isFederationEventWithPDUs, createLogger } from '@rocket.chat/federation-core'; +import { EventID, Pdu, PersistentEventFactory, RoomVersion } from '@rocket.chat/federation-room'; import { delay, inject, singleton } from 'tsyringe'; import { ConfigService } from './config.service'; @@ -57,8 +57,10 @@ export class EventFetcherService { ); const federationEvents = await this.fetchEventsFromFederation(missingEventIds, originServer); + const roomVersion = await this.getRoomVersion(roomId); + const federationEventsWithIds = federationEvents.map((e) => ({ - eventId: generateId(e), + eventId: PersistentEventFactory.createFromRawEvent(e, roomVersion).eventId, event: e, })); @@ -74,6 +76,12 @@ export class EventFetcherService { }; } + private async getRoomVersion(roomId: string): Promise { + const createEvent = await this.eventRepository.findByRoomIdAndType(roomId, 'm.room.create'); + + return createEvent?.event.content.room_version ?? PersistentEventFactory.defaultRoomVersion; + } + async fetchEventsFromFederation(eventIds: string[], targetServerName: string): Promise { const eventsToReturn: Pdu[] = []; diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index 94342ec0e..32f2678a6 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -8,15 +8,7 @@ import type { RedactionEvent, EventStore, } from '@rocket.chat/federation-core'; -import { - isPresenceEDU, - isReceiptEDU, - isTypingEDU, - generateId, - pruneEventDict, - checkSignAndHashes, - createLogger, -} from '@rocket.chat/federation-core'; +import { isPresenceEDU, isReceiptEDU, isTypingEDU, pruneEventDict, checkSignAndHashes, createLogger } from '@rocket.chat/federation-core'; import { type EventID, type Pdu, @@ -557,7 +549,7 @@ export class EventService { async processRedaction(redactionEvent: RedactionEvent): Promise { const eventIdToRedact = redactionEvent.redacts; if (!eventIdToRedact) { - this.logger.error(`[REDACTION] Event is missing 'redacts' field: ${generateId(redactionEvent)}`); + this.logger.error(`[REDACTION] Event is missing 'redacts' field in room ${redactionEvent.room_id}`); return; } From 8f8ee7a855e9b5d254486b7bb69db69d3e976c62 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 11:21:56 -0300 Subject: [PATCH 05/25] fix(room): put m.room.redaction's redacts where the room version expects it v11 moved redacts from the top level into content. Outbound v11 redactions were built with a top level redacts, which v11's allowed keys omit, so the target was lost under redaction and signing; inbound Synapse v11 redactions (content.redacts only) looked like they had no target. The version class now owns both directions: newRedactionEventFields places the target when building, getRedacts reads it when handling one. Applying a redaction also prunes with the room's own rules instead of hardcoded v6+ flags. --- packages/core/src/events/eventBase.ts | 3 +- .../src/services/event.service.ts | 48 ++++--------------- .../src/services/message.service.ts | 16 +++---- .../federation-sdk/src/utils/event-schemas.ts | 24 ++++++---- .../room/src/manager/event-wrapper.spec.ts | 37 ++++++++++++++ packages/room/src/manager/event-wrapper.ts | 27 ++++++++++- packages/room/src/manager/factory.ts | 12 +++-- packages/room/src/manager/v11.ts | 14 +++++- packages/room/src/manager/v3.ts | 14 ++++-- packages/room/src/types/v3-11.ts | 5 +- 10 files changed, 133 insertions(+), 67 deletions(-) diff --git a/packages/core/src/events/eventBase.ts b/packages/core/src/events/eventBase.ts index a4e474759..df1b95217 100644 --- a/packages/core/src/events/eventBase.ts +++ b/packages/core/src/events/eventBase.ts @@ -37,7 +37,8 @@ export type RedactedEvent = EventBase & { }; export const isRedactedEvent = (event: Pdu): event is PduForType<'m.room.redaction'> => { - return event.type === 'm.room.redaction' && 'redacts' in event; + // the target is a top level field before room version 11 and part of content from v11 on + return event.type === 'm.room.redaction'; }; // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index 32f2678a6..5b61ca507 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -5,10 +5,9 @@ import type { RoomPowerLevelsEvent, ReceiptEDU, TypingEDU, - RedactionEvent, EventStore, } from '@rocket.chat/federation-core'; -import { isPresenceEDU, isReceiptEDU, isTypingEDU, pruneEventDict, checkSignAndHashes, createLogger } from '@rocket.chat/federation-core'; +import { isPresenceEDU, isReceiptEDU, isTypingEDU, checkSignAndHashes, createLogger } from '@rocket.chat/federation-core'; import { type EventID, type Pdu, @@ -546,10 +545,12 @@ export class EventService { return authEvents; } - async processRedaction(redactionEvent: RedactionEvent): Promise { - const eventIdToRedact = redactionEvent.redacts; + async processRedaction(redactionEvent: PduForType<'m.room.redaction'>): Promise { + const roomVersion = await this.getRoomVersion(redactionEvent); + + const eventIdToRedact = PersistentEventFactory.createFromRawEvent(redactionEvent, roomVersion).getRedacts(); if (!eventIdToRedact) { - this.logger.error(`[REDACTION] Event is missing 'redacts' field in room ${redactionEvent.room_id}`); + this.logger.error(`[REDACTION] Event is missing its redaction target in room ${redactionEvent.room_id}`); return; } @@ -559,42 +560,13 @@ export class EventService { return; } - // Apply redaction rules according to Matrix spec for room versions 6 and above - // These parameters correspond to the features in newer room versions (v6+): - // - updated_redaction_rules: Uses stricter redaction rules from v6+ - // - restricted_join_rule_fix: Preserves "authorising_user" field in membership events (v8+) - // - restricted_join_rule: Preserves "allow" field in join rules (v7+) - // - special_case_aliases_auth: Special handling for aliases events (v6+) - // - msc3389_relation_redactions: Preserves certain relation data per MSC3389 (v9+) - const redactedEventContent = pruneEventDict(eventToRedact.event, { - updated_redaction_rules: true, - restricted_join_rule_fix: true, - implicit_room_creator: false, - restricted_join_rule: true, - special_case_aliases_auth: true, - msc3389_relation_redactions: true, - }); + const { redactedEvent } = PersistentEventFactory.createFromRawEvent(eventToRedact.event, roomVersion); // According to Matrix spec, redacted events must contain a reference to what redacted them // in the unsigned section of the event - if (!redactedEventContent.unsigned) { - redactedEventContent.unsigned = {}; - } - - // Store the redaction event in the redacted_because field as specified in the Matrix spec - redactedEventContent.unsigned.redacted_because = redactionEvent; - - await this.eventRepository.redactEvent(eventIdToRedact, { - ...redactedEventContent, - room_id: eventToRedact.event.room_id, - sender: eventToRedact.event.sender, - // TODO: check what to do with origin - // origin: eventToRedact.event.sender.split(':')[1], - origin_server_ts: eventToRedact.event.origin_server_ts, - depth: eventToRedact.event.depth, - prev_events: eventToRedact.event.prev_events, - auth_events: eventToRedact.event.auth_events, - } as typeof eventToRedact.event); + redactedEvent.unsigned = { ...redactedEvent.unsigned, redacted_because: redactionEvent }; + + await this.eventRepository.redactEvent(eventIdToRedact, redactedEvent as typeof eventToRedact.event); this.logger.info(`Successfully redacted event ${eventIdToRedact}`); } diff --git a/packages/federation-sdk/src/services/message.service.ts b/packages/federation-sdk/src/services/message.service.ts index ccaefec3c..b8043ad93 100644 --- a/packages/federation-sdk/src/services/message.service.ts +++ b/packages/federation-sdk/src/services/message.service.ts @@ -1,5 +1,5 @@ import { ForbiddenError, createLogger } from '@rocket.chat/federation-core'; -import { type EventID, type PersistentEventBase, RoomID, UserID } from '@rocket.chat/federation-room'; +import { type EventID, type PersistentEventBase, PersistentEventFactory, RoomID, UserID } from '@rocket.chat/federation-room'; import { singleton } from 'tsyringe'; import { EventSenderService } from './event-sender.service'; @@ -270,10 +270,7 @@ export class MessageService { const redactionEvent = await this.stateService.buildEvent<'m.room.redaction'>( { type: 'm.room.redaction', - content: { - reason: 'Unsetting reaction', - }, - redacts: eventIdReactedTo, + ...PersistentEventFactory.newRedactionEventFields(eventIdReactedTo, { reason: 'Unsetting reaction' }, roomInfo.room_version), room_id: roomId, auth_events: [], depth: 0, @@ -353,10 +350,11 @@ export class MessageService { const redactionEvent = await this.stateService.buildEvent<'m.room.redaction'>( { type: 'm.room.redaction', - content: { - reason: `Deleting message: ${eventIdToRedact}`, - }, - redacts: eventIdToRedact, + ...PersistentEventFactory.newRedactionEventFields( + eventIdToRedact, + { reason: `Deleting message: ${eventIdToRedact}` }, + roomInfo.room_version, + ), room_id: roomId, auth_events: [], depth: 0, diff --git a/packages/federation-sdk/src/utils/event-schemas.ts b/packages/federation-sdk/src/utils/event-schemas.ts index 7122d8a16..59263c745 100644 --- a/packages/federation-sdk/src/utils/event-schemas.ts +++ b/packages/federation-sdk/src/utils/event-schemas.ts @@ -95,15 +95,21 @@ const joinRulesEventSchema = baseEventSchema.extend({ .and(z.record(z.string(), z.any())), }); -const redactionEventSchema = baseEventSchema.extend({ - type: z.literal('m.room.redaction'), - redacts: eventIdSchema, - content: z - .object({ - reason: z.string().optional(), - }) - .and(z.record(z.string(), z.any())), -}); +const redactionEventSchema = baseEventSchema + .extend({ + type: z.literal('m.room.redaction'), + redacts: eventIdSchema.optional(), + content: z + .object({ + reason: z.string().optional(), + // where the target lives from room version 11 on + redacts: eventIdSchema.optional(), + }) + .and(z.record(z.string(), z.any())), + }) + .refine((event) => Boolean(event.redacts ?? event.content.redacts), { + message: 'm.room.redaction must name the event it redacts', + }); const roomV10Schemas = { 'm.room.create': createEventSchema, diff --git a/packages/room/src/manager/event-wrapper.spec.ts b/packages/room/src/manager/event-wrapper.spec.ts index ee03a0c24..de5bb0d08 100644 --- a/packages/room/src/manager/event-wrapper.spec.ts +++ b/packages/room/src/manager/event-wrapper.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test'; import { PersistentEventFactory } from './factory'; import type { RoomVersion } from './type'; +import type { EventID } from '../types/_common'; import type { Pdu } from '../types/v3-11'; function runTest(event: Parameters[0], expected: any, roomVersion: RoomVersion = '10') { @@ -376,3 +377,39 @@ describe('[EventWrapper] Redaction', () => { expect(e2.depth).toBe(8); }); }); + +describe('[EventWrapper] Redaction target', () => { + const target = '$target:domain' as EventID; + + it('is a top level field before v11', () => { + const fields = PersistentEventFactory.newRedactionEventFields(target, { reason: 'spam' }, '10'); + + expect(fields).toEqual({ redacts: target, content: { reason: 'spam' } }); + + const event = PersistentEventFactory.createFromRawEvent({ type: 'm.room.redaction', ...fields } as Pdu, '10'); + expect(event.getRedacts()).toBe(target); + }); + + it('is part of content from v11 on', () => { + const fields = PersistentEventFactory.newRedactionEventFields(target, { reason: 'spam' }, '11'); + + expect(fields).toEqual({ content: { reason: 'spam', redacts: target } }); + + const event = PersistentEventFactory.createFromRawEvent({ type: 'm.room.redaction', ...fields } as Pdu, '11'); + expect(event.getRedacts()).toBe(target); + }); + + it('ignores the location the room version does not use', () => { + const v11 = PersistentEventFactory.createFromRawEvent({ type: 'm.room.redaction', redacts: target, content: {} } as Pdu, '11'); + expect(v11.getRedacts()).toBeUndefined(); + + const v10 = PersistentEventFactory.createFromRawEvent({ type: 'm.room.redaction', content: { redacts: target } } as Pdu, '10'); + expect(v10.getRedacts()).toBeUndefined(); + }); + + it('throws for events that are not redactions', () => { + const event = PersistentEventFactory.createFromRawEvent({ type: 'm.room.message', content: {} } as Pdu, '11'); + + expect(() => event.getRedacts()).toThrow('Event is not a redaction event'); + }); +}); diff --git a/packages/room/src/manager/event-wrapper.ts b/packages/room/src/manager/event-wrapper.ts index 0b757b436..01c8cff31 100644 --- a/packages/room/src/manager/event-wrapper.ts +++ b/packages/room/src/manager/event-wrapper.ts @@ -7,7 +7,15 @@ import { type RoomVersion } from './type'; import { type RejectCode, RejectCodes } from '../authorizartion-rules/errors'; import { type EventStore, getStateMapKey } from '../state_resolution/definitions/definitions'; import type { EventID, PduForType, StateMapKey, UserID } from '../types/_common'; -import type { Pdu, PduContent, PduType, Signature, PduJoinRuleEventContent, PduMembershipEventContent } from '../types/v3-11'; +import type { + Pdu, + PduContent, + PduType, + Signature, + PduJoinRuleEventContent, + PduMembershipEventContent, + PduRoomRedactionContent, +} from '../types/v3-11'; export function extractDomainFromId(identifier: string) { const idx = identifier.indexOf(':'); @@ -29,6 +37,12 @@ export type Prettify = { export type PduWithHashesAndSignaturesOptional = Prettify>; +// the parts of a new m.room.redaction that carry its target, which moved into content in v11 +export type RedactionEventFields = { + redacts?: EventID; + content: PduRoomRedactionContent; +}; + export const REDACT_ALLOW_ALL_KEYS: unique symbol = Symbol.for('all'); export interface State extends Map { @@ -242,6 +256,17 @@ export abstract class PersistentEventBase PersistentEventBase) & { newCreateEventContent(creator: UserID, roomVersion: RoomVersion): PduCreateEventContent; + newRedactionEventFields(redacts: EventID, content: PduRoomRedactionContent): RedactionEventFields; }; // The idea is to ALWAYS use this to create different events @@ -115,6 +116,11 @@ export class PersistentEventFactory { return PersistentEventFactory.createFromRawEvent<'m.room.create'>(eventPartial, roomVersion); } + // the target of a redaction is a top level field before v11 and part of content from v11 on + static newRedactionEventFields(redacts: EventID, content: PduRoomRedactionContent, roomVersion: RoomVersion): RedactionEventFields { + return PersistentEventFactory.getEventClass(roomVersion).newRedactionEventFields(redacts, content); + } + static newEvent( event: PduWithHashesAndSignaturesOptional>, roomVersion: RoomVersion, diff --git a/packages/room/src/manager/v11.ts b/packages/room/src/manager/v11.ts index d44ba9fd8..99054f6e6 100644 --- a/packages/room/src/manager/v11.ts +++ b/packages/room/src/manager/v11.ts @@ -1,8 +1,9 @@ import { REDACT_ALLOW_ALL_KEYS } from './event-wrapper'; +import type { RedactionEventFields } from './event-wrapper'; import type { RoomVersion } from './type'; import { PersistentEventV9 } from './v9'; -import type { UserID } from '../types/_common'; -import { type PduCreateEventContent, type PduType } from '../types/v3-11'; +import type { EventID, UserID } from '../types/_common'; +import { type PduCreateEventContent, type PduRoomRedactionContent, type PduType } from '../types/v3-11'; export class PersistentEventV11 extends PersistentEventV9 { // v11 removed m.room.create's content.creator, the sender is the creator instead @@ -10,10 +11,19 @@ export class PersistentEventV11 extends Persiste return { room_version: roomVersion }; } + // v11 moved m.room.redaction's redacts from the top level into content + static newRedactionEventFields(redacts: EventID, content: PduRoomRedactionContent): RedactionEventFields { + return { content: { ...content, redacts } }; + } + protected resolveCreator(): UserID { return this.sender as UserID; } + protected resolveRedacts(): EventID | undefined { + return (this.getContent() as PduRoomRedactionContent).redacts; + } + getAllowedKeys(): string[] { return [ 'event_id', diff --git a/packages/room/src/manager/v3.ts b/packages/room/src/manager/v3.ts index 86782de38..cc533f538 100644 --- a/packages/room/src/manager/v3.ts +++ b/packages/room/src/manager/v3.ts @@ -1,10 +1,10 @@ import { toUnpaddedBase64 } from '@rocket.chat/federation-crypto'; import { PersistentEventBase } from './event-wrapper'; -import type { REDACT_ALLOW_ALL_KEYS } from './event-wrapper'; +import type { REDACT_ALLOW_ALL_KEYS, RedactionEventFields } from './event-wrapper'; import type { RoomVersion, RoomVersion3To11 } from './type'; -import type { EventID, UserID } from '../types/_common'; -import type { PduCreateEventContent, PduType } from '../types/v3-11'; +import type { EventID, PduForType, UserID } from '../types/_common'; +import type { PduCreateEventContent, PduRoomRedactionContent, PduType } from '../types/v3-11'; // v3 is where it changes first export class PersistentEventV3 extends PersistentEventBase { @@ -14,6 +14,10 @@ export class PersistentEventV3 extends Persisten return { room_version: roomVersion, creator }; } + static newRedactionEventFields(redacts: EventID, content: PduRoomRedactionContent): RedactionEventFields { + return { redacts, content }; + } + get eventId(): EventID { if (this._eventId) { return this._eventId; @@ -50,6 +54,10 @@ export class PersistentEventV3 extends Persisten return (this.getContent() as PduCreateEventContent).creator as UserID | undefined; } + protected resolveRedacts(): EventID | undefined { + return (this.rawEvent as PduForType<'m.room.redaction'>).redacts; + } + getAllowedContentKeys(): Record { return { 'm.room.create': ['creator'], diff --git a/packages/room/src/types/v3-11.ts b/packages/room/src/types/v3-11.ts index 99c2585a3..f41c2a40a 100644 --- a/packages/room/src/types/v3-11.ts +++ b/packages/room/src/types/v3-11.ts @@ -163,6 +163,8 @@ export type PduRoomTopicEventContent = z.infer; @@ -565,7 +567,8 @@ const EventPduTypeRoomRedaction = z.object({ ...PduNoContentTimelineEventSchema, type: z.literal('m.room.redaction'), content: PduRoomRedactionContentSchema, - redacts: eventIdSchema.describe('event id'), + // dropped as a top level field in room version 11, where it lives in content + redacts: eventIdSchema.describe('event id').optional(), }); export const EventPduTypeRoomAvatar = z.object({ From 2de5e0369cff0917b84d66ca99b94596c7df3ffd Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 11:25:39 -0300 Subject: [PATCH 06/25] fix(sdk): do not reject events from rooms without a dedicated schema set validateEvent resolved schemas through a private lookup that threw for any room version other than '10', so every inbound v11 event failed validation with "Unsupported room version: 11" regardless of the rest of the v11 work. Use the shared resolver, which falls back to the canonical schema set as documented. --- .../src/services/event.service.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index 5b61ca507..ff5956715 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -20,7 +20,6 @@ import { getAuthChain, } from '@rocket.chat/federation-room'; import { delay, inject, singleton } from 'tsyringe'; -import type { z } from 'zod'; import { ConfigService } from './config.service'; import { EventEmitterService } from './event-emitter.service'; @@ -30,7 +29,7 @@ import { StateService } from './state.service'; import { StagingAreaQueue } from '../queues/staging-area.queue'; import { EventStagingRepository } from '../repositories/event-staging.repository'; import { EventRepository } from '../repositories/event.repository'; -import { eventSchemas } from '../utils/event-schemas'; +import { getEventSchemaForType } from '../utils/event-schemas'; export interface AuthEventParams { roomId: string; @@ -220,7 +219,7 @@ export class EventService { throw new Error('Event sender is missing domain'); } - const eventSchema = this.getEventSchema(roomVersion, event.type); + const eventSchema = getEventSchemaForType(event.type, roomVersion); const validationResult = eventSchema.safeParse(event); if (!validationResult.success) { @@ -449,20 +448,6 @@ export class EventService { return this.stateService.getRoomVersion(event.room_id) || PersistentEventFactory.defaultRoomVersion; } - private getEventSchema(roomVersion: string, eventType: string): z.ZodSchema { - const versionSchemas = eventSchemas[roomVersion]; - if (!versionSchemas) { - throw new Error(`Unsupported room version: ${roomVersion}`); - } - - const schema = versionSchemas[eventType] || versionSchemas.default; - if (!schema) { - throw new Error(`No schema available for event type ${eventType} in room version ${roomVersion}`); - } - - return schema; - } - async getLastEventForRoom(roomId: string): Promise { return this.eventRepository.findLatestFromRoomId(roomId); } From 9a6ebaacf7daaf403dd1e45c7dc033b43be1e30a Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 11:55:46 -0300 Subject: [PATCH 07/25] feat(sdk): add a v11 event schema set Only a v10 set existed and every other room version silently resolved to it, so v11 events were validated against v10's shape. Split the sets that differ and register one per supported version: - create requires content.creator below v11 and not from v11 on, which also restores the pre-v11 strictness loosened when v11 creates were first accepted - redaction takes its target from the top level below v11 and from content from v11 on, replacing the "either location" check An unrecognised version resolves to the v11 set, since later versions inherit its event format. --- .../src/utils/event-schemas.spec.ts | 51 ++++++++++++++ .../federation-sdk/src/utils/event-schemas.ts | 67 +++++++++++-------- 2 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 packages/federation-sdk/src/utils/event-schemas.spec.ts diff --git a/packages/federation-sdk/src/utils/event-schemas.spec.ts b/packages/federation-sdk/src/utils/event-schemas.spec.ts new file mode 100644 index 000000000..24b48272f --- /dev/null +++ b/packages/federation-sdk/src/utils/event-schemas.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; + +import { getEventSchemaForType } from './event-schemas'; + +const base = { + room_id: '!room:hs1', + sender: '@admin:hs1', + origin_server_ts: 1733107418719, + depth: 1, + prev_events: [], + auth_events: [], +}; + +const createEvent = { ...base, type: 'm.room.create', state_key: '' }; +const redaction = { ...base, type: 'm.room.redaction' }; +const target = '$8ftnUd9WTPTQGbdPgfOPea8bOEQ21qPvbcGqeOApQxA'; + +function validate(event: object, roomVersion: string) { + return getEventSchemaForType((event as { type: string }).type, roomVersion).safeParse(event).success; +} + +describe('event schemas', () => { + it('requires m.room.create content.creator before v11', () => { + expect(validate({ ...createEvent, content: { room_version: '10', creator: '@admin:hs1' } }, '10')).toBe(true); + expect(validate({ ...createEvent, content: { room_version: '10' } }, '10')).toBe(false); + }); + + it('does not require m.room.create content.creator from v11 on', () => { + expect(validate({ ...createEvent, content: { room_version: '11' } }, '11')).toBe(true); + }); + + it('takes the redaction target from the top level before v11', () => { + expect(validate({ ...redaction, redacts: target, content: {} }, '10')).toBe(true); + expect(validate({ ...redaction, content: { redacts: target } }, '10')).toBe(false); + }); + + it('takes the redaction target from content from v11 on', () => { + expect(validate({ ...redaction, content: { redacts: target } }, '11')).toBe(true); + expect(validate({ ...redaction, redacts: target, content: {} }, '11')).toBe(false); + }); + + it('resolves a schema for every supported room version', () => { + for (const roomVersion of ['3', '4', '5', '6', '7', '8', '9', '10']) { + expect(validate({ ...createEvent, content: { room_version: roomVersion, creator: '@admin:hs1' } }, roomVersion)).toBe(true); + } + }); + + it('falls back to the permissive base schema for unknown event types', () => { + expect(validate({ ...base, type: 'com.example.custom', content: { anything: true } }, '11')).toBe(true); + }); +}); diff --git a/packages/federation-sdk/src/utils/event-schemas.ts b/packages/federation-sdk/src/utils/event-schemas.ts index 59263c745..071425f35 100644 --- a/packages/federation-sdk/src/utils/event-schemas.ts +++ b/packages/federation-sdk/src/utils/event-schemas.ts @@ -1,4 +1,4 @@ -import { eventIdSchema, roomIdSchema, userIdSchema } from '@rocket.chat/federation-room'; +import { PersistentEventFactory, eventIdSchema, roomIdSchema, userIdSchema } from '@rocket.chat/federation-room'; import * as z from 'zod'; const baseEventSchema = z.object({ @@ -18,20 +18,26 @@ const baseEventSchema = z.object({ unsigned: z.any().optional(), }); +const anyContent = z.record(z.string(), z.any()); + const createEventSchema = baseEventSchema.extend({ type: z.literal('m.room.create'), state_key: z.literal(''), content: z .object({ room_version: z.string(), - // removed in room version 11, where the create event's sender is the creator - creator: userIdSchema.optional(), + creator: userIdSchema, }) - .and(z.record(z.string(), z.any())), + .and(anyContent), prev_events: z.array(z.any()).max(0).optional(), auth_events: z.array(z.any()).max(0).optional(), }); +// v11 removed content.creator, the create event's sender is the creator instead +const createEventSchemaV11 = createEventSchema.extend({ + content: z.object({ room_version: z.string() }).and(anyContent), +}); + const memberEventSchema = baseEventSchema.extend({ type: z.literal('m.room.member'), state_key: userIdSchema, @@ -95,23 +101,19 @@ const joinRulesEventSchema = baseEventSchema.extend({ .and(z.record(z.string(), z.any())), }); -const redactionEventSchema = baseEventSchema - .extend({ - type: z.literal('m.room.redaction'), - redacts: eventIdSchema.optional(), - content: z - .object({ - reason: z.string().optional(), - // where the target lives from room version 11 on - redacts: eventIdSchema.optional(), - }) - .and(z.record(z.string(), z.any())), - }) - .refine((event) => Boolean(event.redacts ?? event.content.redacts), { - message: 'm.room.redaction must name the event it redacts', - }); - -const roomV10Schemas = { +const redactionEventSchema = baseEventSchema.extend({ + type: z.literal('m.room.redaction'), + redacts: eventIdSchema, + content: z.object({ reason: z.string().optional() }).and(anyContent), +}); + +// v11 moved the redaction target from the top level into content +const redactionEventSchemaV11 = baseEventSchema.extend({ + type: z.literal('m.room.redaction'), + content: z.object({ redacts: eventIdSchema, reason: z.string().optional() }).and(anyContent), +}); + +const roomV10Schemas: Record = { 'm.room.create': createEventSchema, 'm.room.member': memberEventSchema, 'm.room.message': messageEventSchema, @@ -122,21 +124,32 @@ const roomV10Schemas = { 'default': baseEventSchema, }; -export const eventSchemas: Record> = { - '10': roomV10Schemas, +const roomV11Schemas: Record = { + ...roomV10Schemas, + 'm.room.create': createEventSchemaV11, + 'm.room.redaction': redactionEventSchemaV11, }; +// v11 is the only version that changed the shape of an event we validate, and later +// versions inherit its format, so an unrecognised version resolves to the v11 set +function schemasForRoomVersion(roomVersion?: string): Record { + return Number(roomVersion) < 11 ? roomV10Schemas : roomV11Schemas; +} + +export const eventSchemas: Record> = Object.fromEntries( + PersistentEventFactory.supportedRoomVersions.map((roomVersion) => [roomVersion, schemasForRoomVersion(roomVersion)]), +); + /** * Resolve the validation schema for an event type. Known types resolve to their * specific schema; unknown ("custom") types fall back to the permissive base - * schema, which the Matrix spec allows. Room versions without a dedicated schema - * set fall back to the canonical v10 schemas (currently the only set defined). + * schema, which the Matrix spec allows. */ export function getEventSchemaForType(eventType: string, roomVersion?: string): z.ZodSchema { - const versionSchemas = (roomVersion && eventSchemas[roomVersion]) || eventSchemas['10']; + const versionSchemas = schemasForRoomVersion(roomVersion); return versionSchemas[eventType] ?? versionSchemas.default; } -export { roomV10Schemas }; +export { roomV10Schemas, roomV11Schemas }; export type BaseEventType = z.infer; From b5cd5b5048547ad4c55d6a72d3f96b2c1ee96d16 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 11:59:45 -0300 Subject: [PATCH 08/25] fix(sdk): stop putting a legacy top level origin on events origin is not a PDU field. The make_join and make_leave templates we serve added it, and a join template received from the resident server was signed back with whatever origin it carried, so v11 events went out with a field the version does not define. updateUserPowerLevel also ran its content through the legacy core event builder, which injects origin, only to keep the content and sender it was already given. Build the content directly so no live path reaches that builder. --- .../src/services/profiles.service.ts | 7 +-- .../src/services/room.service.ts | 54 ++++++------------- 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/packages/federation-sdk/src/services/profiles.service.ts b/packages/federation-sdk/src/services/profiles.service.ts index b436a91bf..c9feb1096 100644 --- a/packages/federation-sdk/src/services/profiles.service.ts +++ b/packages/federation-sdk/src/services/profiles.service.ts @@ -75,7 +75,7 @@ export class ProfilesService { userId: UserID, versions: RoomVersion[], // asking server supports these ): Promise<{ - event: PduForType<'m.room.member'> & { origin: string }; + event: PduForType<'m.room.member'>; room_version: RoomVersion; }> { const { stateService } = this; @@ -110,10 +110,7 @@ export class ProfilesService { return { room_version: roomVersion, - event: { - ...membershipEvent.event, - origin: this.configService.serverName, - }, + event: membershipEvent.event, }; } diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index 372ba85f7..e8ac33718 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -5,7 +5,6 @@ import { SignedEvent, TombstoneAuthEvents, createLogger, - roomPowerLevelsEvent, ForbiddenError, HttpException, HttpStatus, @@ -474,43 +473,23 @@ export class RoomService { throw new HttpException('Room has no history, cannot update power levels', HttpStatus.BAD_REQUEST); } - const { serverName } = this.configService; - if (!serverName) { - logger.error('Server name is not configured. Cannot set event origin.'); - throw new HttpException('Server configuration error for event origin.', HttpStatus.INTERNAL_SERVER_ERROR); - } - - const eventToSign = roomPowerLevelsEvent({ - roomId, - members: [senderId, userId], - auth_events: { - 'm.room.create': createAuthResult._id, - 'm.room.power_levels': powerLevelsAuthResult._id, - 'm.room.member': memberAuthResult._id, - }, - prev_events: lastEventStore._id ? [lastEventStore._id] : [], - depth: lastEventStore.event.depth + 1, - content: { - ...currentPowerLevelsEvent.event.content, - users: { - ...(currentPowerLevelsEvent.event.content.users || {}), - [userId]: powerLevel, - }, - }, - ts: Date.now(), - }) as PduForType<'m.room.power_levels'>; - const event = await this.stateService.buildEvent<'m.room.power_levels'>( { type: 'm.room.power_levels', - content: eventToSign.content, + content: { + ...currentPowerLevelsEvent.event.content, + users: { + ...(currentPowerLevelsEvent.event.content.users || {}), + [userId]: powerLevel, + }, + }, room_id: roomId, state_key: '', auth_events: [], depth: 0, prev_events: [], origin_server_ts: Date.now(), - sender: eventToSign.sender, + sender: senderId, }, PersistentEventFactory.defaultRoomVersion, ); @@ -528,7 +507,7 @@ export class RoomService { roomId: RoomID, senderId: UserID, ): Promise<{ - event: PduForType<'m.room.member'> & { origin: string }; + event: PduForType<'m.room.member'>; room_version: RoomVersion; }> { const roomInfo = await this.stateService.getRoomInformation(roomId); @@ -548,10 +527,7 @@ export class RoomService { ); return { - event: { - ...leaveEvent.event, - origin: this.configService.serverName, - }, + event: leaveEvent.event, room_version: roomInfo.room_version, }; } @@ -774,15 +750,19 @@ export class RoomService { // after receiving the join event we need to populate with local user profile const profile = await this.profilesService.queryProfile(userId); - makeJoinResponse.event.content = { - ...makeJoinResponse.event.content, + // origin is not a PDU field, drop whatever the resident server put in the template + // instead of signing it back + const { origin: _origin, ...template } = makeJoinResponse.event as PduForType<'m.room.member'> & { origin?: string }; + + template.content = { + ...template.content, ...(profile?.avatar_url && { avatar_url: profile.avatar_url }), ...(profile?.displayname && { displayname: profile.displayname }), }; // ^ have the template for the join event now - const joinEvent = PersistentEventFactory.createFromRawEvent(makeJoinResponse.event, makeJoinResponse.room_version); + const joinEvent = PersistentEventFactory.createFromRawEvent(template, makeJoinResponse.room_version); await stateService.signEvent(joinEvent); From 60fe057082dbf199197cd74b01ab0f2412c60b42 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 12:02:04 -0300 Subject: [PATCH 09/25] fix(sdk): build events with the room's version, not the default Several RoomService paths passed PersistentEventFactory.defaultRoomVersion to buildEvent for rooms that already exist, so an event for a room on any other version was built with the wrong redaction rules, giving it the wrong event ID and a signature over the wrong bytes. Room creation keeps using the default, and everything after it uses the version of the create event or the version looked up for the room. This is also a prerequisite for changing the default at all. --- .../src/services/room.service.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index e8ac33718..47fab750b 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -225,7 +225,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(creatorMembershipEvent); @@ -242,7 +242,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(roomNameEvent); @@ -274,7 +274,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(powerLevelEvent); @@ -291,7 +291,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(joinRuleEvent); @@ -311,7 +311,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(canonicalAliasEvent); @@ -491,7 +491,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: senderId, }, - PersistentEventFactory.defaultRoomVersion, + await this.stateService.getRoomVersion(roomId), ); await this.stateService.handlePdu(event); @@ -717,7 +717,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: userId, }, - PersistentEventFactory.defaultRoomVersion, + createEvent.version, ); await stateService.handlePdu(membershipEvent); @@ -1153,7 +1153,7 @@ export class RoomService { signatures: {}, type: 'm.room.tombstone', }, - PersistentEventFactory.defaultRoomVersion, + await this.stateService.getRoomVersion(roomId), ); const _stateId = await this.stateService.handlePdu(event); @@ -1460,7 +1460,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(creatorMembershipEvent); @@ -1490,7 +1490,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(powerLevelsEvent); @@ -1507,7 +1507,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(joinRulesEvent); @@ -1524,7 +1524,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(historyVisibilityEvent); @@ -1541,7 +1541,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(guestAccessEvent); @@ -1578,7 +1578,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(targetMembershipEvent); From 39dbc2bb4417dca79e49b363bbd4c06184afccb6 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 12:03:11 -0300 Subject: [PATCH 10/25] feat(room): create rooms at version 11 by default The v11 blockers are fixed and the SDK now builds every event with the room's own version, so new rooms default to 11. Existing rooms keep the version recorded in their create event. --- packages/room/src/manager/factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/room/src/manager/factory.ts b/packages/room/src/manager/factory.ts index 64c71a541..3bc01e012 100644 --- a/packages/room/src/manager/factory.ts +++ b/packages/room/src/manager/factory.ts @@ -49,7 +49,7 @@ export class PersistentEventFactory { '11', ]; - static defaultRoomVersion = '10' as const; // same as synapse + static defaultRoomVersion = '11' as const; static isSupportedRoomVersion(roomVersion: string): roomVersion is RoomVersion { return PersistentEventFactory.supportedRoomVersions.includes(roomVersion); From 312ec9649d6bbc93fa944a7d2eea38ebc9867b9b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 12:04:09 -0300 Subject: [PATCH 11/25] fix(sdk): advertise every supported version when joining a remote room joinUser asked for a make_join template with ver=10 only, so a resident server holding a room on any other version answers with M_INCOMPATIBLE_ROOM_VERSION. Omit the argument so the request lists every supported version and take the room's version from the response, which is what the code already builds the join event with. --- .../federation-sdk/src/services/room.service.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index 47fab750b..ae829947b 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -736,16 +736,10 @@ export class RoomService { return membershipEvent.eventId; } - // Resident server is remote, need to do join flow - const roomVersion = '10' as const; - - // trying to join room from another server - const makeJoinResponse = await federationService.makeJoin( - residentServer, - roomId, - userId, - roomVersion, // NOTE: check the comment in the called method - ); + // Resident server is remote, need to do join flow. + // no version argument, so the request advertises every version we support and the + // resident server answers with the room's actual version + const makeJoinResponse = await federationService.makeJoin(residentServer, roomId, userId); // after receiving the join event we need to populate with local user profile const profile = await this.profilesService.queryProfile(userId); From bbf7a0089e39bde45da2510e2a30d371c01e2067 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 12:04:54 -0300 Subject: [PATCH 12/25] chore(sdk): export the v11 schema set alongside the v10 one --- packages/federation-sdk/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index f263f5845..fc15b691d 100644 --- a/packages/federation-sdk/src/index.ts +++ b/packages/federation-sdk/src/index.ts @@ -70,7 +70,7 @@ export type { // Utility exports export { getErrorMessage } from './utils/get-error-message'; export { USERNAME_REGEX, ROOM_ID_REGEX } from './utils/validation-regex'; -export { eventSchemas, roomV10Schemas, type BaseEventType } from './utils/event-schemas'; +export { eventSchemas, roomV10Schemas, roomV11Schemas, type BaseEventType } from './utils/event-schemas'; export { errCodes } from './utils/response-codes'; export { NotAllowedError } from './services/invite.service'; export { FederationValidationService, FederationValidationError } from './services/federation-validation.service'; From 5fa8122168afe390ed4249e755b7fb876b81f970 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 14:14:56 -0300 Subject: [PATCH 13/25] fix(sdk): report a room creator for v11 rooms too getRoomInformation returned the create event content verbatim, so callers reading creator got undefined for a v11 room, where the field no longer exists. Report the creator the room version resolves instead. The state service specs read content.creator to find the sender for follow-up events, which made every such test fail against a v11 room. They now use the version blind accessor. These specs only run with RUN_MONGO_TESTS=1, which is why the earlier v11 work did not surface it. Adds a coexistence spec that drives a v10 and a v11 room through create, join, message, redact and ban in the same database, asserting each room keeps its own version, creator resolution and redaction target placement. --- .../services/room-version-coexistence.spec.ts | 226 ++++++++++++++++++ .../src/services/state.service.spec.ts | 29 +-- .../src/services/state.service.ts | 12 +- 3 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 packages/federation-sdk/src/services/room-version-coexistence.spec.ts diff --git a/packages/federation-sdk/src/services/room-version-coexistence.spec.ts b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts new file mode 100644 index 000000000..be0d6a5fc --- /dev/null +++ b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it } from 'bun:test'; + +import { type EventStore } from '@rocket.chat/federation-core'; +import type { EventID, PduCreateEventContent, RoomVersion, UserID } 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 { EventService } from './event.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'; + +// A v10 room created before v11 became the default has to keep working alongside new +// v11 rooms, in the same database and process. Every assertion here is run against both +// versions so a version specific regression cannot pass unnoticed. +describe('room version coexistence', async () => { + if (!process.env.RUN_MONGO_TESTS) { + console.warn('Skipping tests that require a database'); + return; + } + + const alice = '@alice:example.com' as UserID; + const bob = '@bob:example.com' as UserID; + + const database = new DatabaseConnectionService({ + uri: process.env.MONGO_URI || 'mongodb://localhost:27017?directConnection=true', + name: 'matrix_test', + poolSize: 100, + }); + + const eventCollection = (await database.getDb()).collection>('events_coexistence_test'); + const stateGraphCollection = (await database.getDb()).collection('state_graph_coexistence_test'); + + beforeEach(async () => { + await Promise.all([eventCollection.deleteMany({}), stateGraphCollection.deleteMany({})]); + }); + + const stateService = new StateService( + new StateGraphRepository(stateGraphCollection), + new EventRepository(eventCollection), + { getSigningKey: async () => undefined, serverName: 'example.com' } as unknown as ConfigService, + { notify: () => Promise.resolve() } as unknown as EventService, + ); + + const defaults = () => ({ auth_events: [], prev_events: [], origin_server_ts: Date.now(), depth: 0 }); + + async function createRoom(roomVersion: RoomVersion) { + const createEvent = PersistentEventFactory.newCreateEvent(alice, roomVersion); + await stateService.handlePdu(createEvent); + + const { roomId } = createEvent; + + const join = await stateService.buildEvent<'m.room.member'>( + { type: 'm.room.member', room_id: roomId, sender: alice, state_key: alice, content: { membership: 'join' }, ...defaults() }, + roomVersion, + ); + await stateService.handlePdu(join); + + const powerLevels = await stateService.buildEvent<'m.room.power_levels'>( + { + type: 'm.room.power_levels', + room_id: roomId, + sender: alice, + state_key: '', + content: { + users: { [alice]: 100 }, + users_default: 0, + events: {}, + events_default: 0, + state_default: 50, + ban: 50, + kick: 50, + redact: 50, + invite: 50, + }, + ...defaults(), + }, + roomVersion, + ); + await stateService.handlePdu(powerLevels); + + const joinRules = await stateService.buildEvent<'m.room.join_rules'>( + { type: 'm.room.join_rules', room_id: roomId, sender: alice, state_key: '', content: { join_rule: 'public' }, ...defaults() }, + roomVersion, + ); + await stateService.handlePdu(joinRules); + + return { createEvent, roomId, join, powerLevels, joinRules }; + } + + async function sendMessage(roomId: ReturnType & string, roomVersion: RoomVersion, body: string) { + const message = await stateService.buildEvent<'m.room.message'>( + // @ts-expect-error room id is branded, the harness passes it through + { type: 'm.room.message', room_id: roomId, sender: alice, content: { msgtype: 'm.text', body }, ...defaults() }, + roomVersion, + ); + await stateService.handlePdu(message); + return message; + } + + it('creates each room at its own version and resolves the creator on both', async () => { + const v10 = await createRoom('10'); + const v11 = await createRoom('11'); + + expect(await stateService.getRoomVersion(v10.roomId)).toBe('10'); + expect(await stateService.getRoomVersion(v11.roomId)).toBe('11'); + + // the wire format differs, the accessor does not + expect(v10.createEvent.getContent().creator).toBe(alice); + expect(v11.createEvent.getContent().creator).toBeUndefined(); + expect(v10.createEvent.getCreator()).toBe(alice); + expect(v11.createEvent.getCreator()).toBe(alice); + + // and getRoomInformation reports a creator either way, so consumers keep working + expect(await stateService.getRoomInformation(v10.roomId)).toHaveProperty('creator', alice); + expect(await stateService.getRoomInformation(v11.roomId)).toHaveProperty('creator', alice); + }); + + it('keeps v10 and v11 rooms independent while both are used', async () => { + const v10 = await createRoom('10'); + const v11 = await createRoom('11'); + + // interleave the two rooms rather than finishing one first + const joinV10 = await stateService.buildEvent<'m.room.member'>( + { type: 'm.room.member', room_id: v10.roomId, sender: bob, state_key: bob, content: { membership: 'join' }, ...defaults() }, + '10', + ); + await stateService.handlePdu(joinV10); + + const joinV11 = await stateService.buildEvent<'m.room.member'>( + { type: 'm.room.member', room_id: v11.roomId, sender: bob, state_key: bob, content: { membership: 'join' }, ...defaults() }, + '11', + ); + await stateService.handlePdu(joinV11); + + const messageV10 = await sendMessage(v10.roomId, '10', 'hello from the v10 room'); + const messageV11 = await sendMessage(v11.roomId, '11', 'hello from the v11 room'); + + expect(joinV10.rejected).toBeFalse(); + expect(joinV11.rejected).toBeFalse(); + expect(messageV10.rejected).toBeFalse(); + expect(messageV11.rejected).toBeFalse(); + + const stateV10 = await stateService.getLatestRoomState2(v10.roomId); + const stateV11 = await stateService.getLatestRoomState2(v11.roomId); + + expect(stateV10.isUserInRoom(bob)).toBeTrue(); + expect(stateV11.isUserInRoom(bob)).toBeTrue(); + expect(stateV10.creator).toBe(alice); + expect(stateV11.creator).toBe(alice); + + // no cross talk: each room only knows its own events + const v10Ids = new Set([...(await stateService.getLatestRoomState(v10.roomId)).values()].map((e) => e.eventId)); + const v11Ids = new Set([...(await stateService.getLatestRoomState(v11.roomId)).values()].map((e) => e.eventId)); + expect([...v10Ids].some((id) => v11Ids.has(id))).toBeFalse(); + }); + + async function expectRedactionRoundTrip(roomVersion: RoomVersion) { + const { roomId } = await createRoom(roomVersion); + const message = await sendMessage(roomId, roomVersion, 'redact me'); + + const redaction = await stateService.buildEvent<'m.room.redaction'>( + { + type: 'm.room.redaction', + // @ts-expect-error room id is branded, the harness passes it through + room_id: roomId, + sender: alice, + ...PersistentEventFactory.newRedactionEventFields(message.eventId, { reason: 'spam' }, roomVersion), + ...defaults(), + }, + roomVersion, + ); + await stateService.handlePdu(redaction); + + expect(redaction.rejected).toBeFalse(); + + // the target is readable back, and survives redaction, which is what B4 was about + expect(redaction.getRedacts()).toBe(message.eventId); + expect(PersistentEventFactory.createFromRawEvent(redaction.event, roomVersion).getRedacts()).toBe(message.eventId); + + return redaction.event as typeof redaction.event & { redacts?: EventID }; + } + + it('redacts in a v10 room with the target at the top level', async () => { + const wire = await expectRedactionRoundTrip('10'); + + expect(wire.redacts).toBeDefined(); + expect(wire.content).not.toHaveProperty('redacts'); + }); + + it('redacts in a v11 room with the target in content', async () => { + const wire = await expectRedactionRoundTrip('11'); + + expect(wire.redacts).toBeUndefined(); + expect(wire.content).toHaveProperty('redacts'); + }); + + async function expectBanWorks(roomVersion: RoomVersion) { + const { roomId } = await createRoom(roomVersion); + + const join = await stateService.buildEvent<'m.room.member'>( + // @ts-expect-error room id is branded, the harness passes it through + { type: 'm.room.member', room_id: roomId, sender: bob, state_key: bob, content: { membership: 'join' }, ...defaults() }, + roomVersion, + ); + await stateService.handlePdu(join); + + const ban = await stateService.buildEvent<'m.room.member'>( + // @ts-expect-error room id is branded, the harness passes it through + { type: 'm.room.member', room_id: roomId, sender: alice, state_key: bob, content: { membership: 'ban' }, ...defaults() }, + roomVersion, + ); + await stateService.handlePdu(ban); + + expect(ban.rejected).toBeFalse(); + // @ts-expect-error room id is branded, the harness passes it through + expect((await stateService.getLatestRoomState2(roomId)).getUserMembership(bob)).toBe('ban'); + } + + it('bans in a v10 room', async () => expectBanWorks('10')); + + it('bans in a v11 room', async () => expectBanWorks('11')); +}); diff --git a/packages/federation-sdk/src/services/state.service.spec.ts b/packages/federation-sdk/src/services/state.service.spec.ts index c07accc97..f65261d9b 100644 --- a/packages/federation-sdk/src/services/state.service.spec.ts +++ b/packages/federation-sdk/src/services/state.service.spec.ts @@ -756,7 +756,7 @@ describe('StateService', async () => { const stateAtEvent = new Map(); const { roomId } = roomCreateEvent; - const creator = roomCreateEvent.getContent().creator as room.UserID; + const creator = roomCreateEvent.getCreator() as room.UserID; const state = await stateService.getLatestRoomState(roomId); @@ -806,7 +806,7 @@ describe('StateService', async () => { const roomNameEvent2 = await stateService.buildEvent<'m.room.name'>( { room_id: roomId, - sender: roomCreateEvent.getContent().creator as room.UserID, + sender: roomCreateEvent.getCreator() as room.UserID, content: { name: newRoomName }, state_key: '', type: 'm.room.name', @@ -876,10 +876,7 @@ describe('StateService', async () => { const { roomCreateEvent } = await createRoom('public'); - expect(stateService.getRoomInformation(roomCreateEvent.roomId)).resolves.toHaveProperty( - 'creator', - roomCreateEvent.getContent().creator, - ); + expect(stateService.getRoomInformation(roomCreateEvent.roomId)).resolves.toHaveProperty('creator', roomCreateEvent.getCreator()); }); it('02 should get the correct room version', async () => { @@ -993,7 +990,7 @@ describe('StateService', async () => { const { roomCreateEvent } = await createRoom('invite'); const newUser = '@bob:example.com' as room.UserID; - await inviteUser(roomCreateEvent.roomId, newUser, roomCreateEvent.getContent().creator); + await inviteUser(roomCreateEvent.roomId, newUser, roomCreateEvent.getCreator()); expect((await stateService.getLatestRoomState2(roomCreateEvent.roomId)).isUserInvited(newUser)).toBeTrue(); @@ -1011,7 +1008,7 @@ describe('StateService', async () => { expect((await stateService.getLatestRoomState2(roomCreateEvent.roomId)).isUserInRoom(newUser)).toBeTrue(); - await banUser(roomCreateEvent.roomId, newUser, roomCreateEvent.getContent().creator); + await banUser(roomCreateEvent.roomId, newUser, roomCreateEvent.getCreator()); expect((await stateService.getLatestRoomState2(roomCreateEvent.roomId)).getUserMembership(newUser)).toBe('ban'); @@ -1039,7 +1036,7 @@ describe('StateService', async () => { const bob = '@bob:example.com' as room.UserID; await joinUser(roomCreateEvent.roomId, bob); // ban bob now - const banBobEvent = await banUser(roomCreateEvent.roomId, bob, roomCreateEvent.getContent().creator); + const banBobEvent = await banUser(roomCreateEvent.roomId, bob, roomCreateEvent.getCreator()); const state1 = await stateService.getLatestRoomState2(roomCreateEvent.roomId); expect(state1.getUserMembership(bob)).toBe('ban'); @@ -1213,7 +1210,7 @@ describe('StateService', async () => { { type: 'm.room.power_levels', room_id: roomCreateEvent.roomId, - sender: roomCreateEvent.getContent().creator as room.UserID, + sender: roomCreateEvent.getCreator() as room.UserID, state_key: '', content: powerLevelContent, ...getDefaultFields(), @@ -1254,7 +1251,7 @@ describe('StateService', async () => { { type: 'm.room.member', room_id: roomCreateEvent.roomId, - sender: roomCreateEvent.getContent().creator as room.UserID, + sender: roomCreateEvent.getCreator() as room.UserID, state_key: bob, content: { membership: 'ban' }, ...getDefaultFields(), @@ -1301,7 +1298,7 @@ describe('StateService', async () => { await stateService.buildEvent<'m.room.join_rules'>( { room_id: roomCreateEvent.roomId, - sender: roomCreateEvent.getContent().creator as room.UserID, + sender: roomCreateEvent.getCreator() as room.UserID, content: { join_rule: 'invite' }, type: 'm.room.join_rules', state_key: '', @@ -1383,7 +1380,7 @@ describe('StateService', async () => { // const newPowerLevelEvent = PersistentEventFactory.newPowerLevelEvent( // roomCreateEvent.roomId, - // roomCreateEvent.getContent().creator, + // roomCreateEvent.getCreator(), // powerLevelContent, // PersistentEventFactory.defaultRoomVersion, // ); @@ -1480,7 +1477,7 @@ describe('StateService', async () => { const { roomCreateEvent } = await createRoom('public'); const { roomId } = roomCreateEvent; const roomVersion = roomCreateEvent.getContent().room_version; - const creator = roomCreateEvent.getContent().creator as room.UserID; + const creator = roomCreateEvent.getCreator() as room.UserID; const referenceDepthEvent = await joinUser(roomId, '@dummy:example.com'); @@ -1538,7 +1535,7 @@ describe('StateService', async () => { it('should consider previously rejected event as part of state if new out of order event allows it', async () => { const { roomCreateEvent } = await createRoom('public'); const { roomId } = roomCreateEvent; - const creator = roomCreateEvent.getContent().creator as room.UserID; + const creator = roomCreateEvent.getCreator() as room.UserID; const roomVersion = roomCreateEvent.version; // make bob join @@ -1603,7 +1600,7 @@ describe('StateService', async () => { const { roomId } = roomCreateEvent; const roomVersion = roomCreateEvent.version; - const creator = roomCreateEvent.getContent().creator as room.UserID; + const creator = roomCreateEvent.getCreator() as room.UserID; await joinUser(roomId, don); diff --git a/packages/federation-sdk/src/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index 6afde6a0a..7668e4842 100644 --- a/packages/federation-sdk/src/services/state.service.ts +++ b/packages/federation-sdk/src/services/state.service.ts @@ -94,7 +94,17 @@ export class StateService { throw new Error('Create event has no state id, something is very wrong'); } - return event.content; + // v11 rooms have no content.creator, so report the creator the room version resolves + // rather than handing callers an undefined field + const { room_version: roomVersion } = event.content; + if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { + return event.content; + } + + return { + ...event.content, + creator: PersistentEventFactory.createFromRawEvent<'m.room.create'>(event, roomVersion).getCreator(), + }; } async getRoomVersion(roomId: RoomID): Promise { From d80ec5ee96d73ade5851ab479a99559dca6fa536 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 14:41:10 -0300 Subject: [PATCH 14/25] expore way to get redact event --- .../src/events/homeserver-event-signatures.ts | 2 ++ packages/federation-sdk/src/sdk.ts | 4 +++ .../src/services/event-notifier.service.ts | 5 +++- .../src/services/event.service.ts | 27 ++++++++++++------- 4 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/core/src/events/homeserver-event-signatures.ts b/packages/core/src/events/homeserver-event-signatures.ts index e9b71fad2..aea8a8724 100644 --- a/packages/core/src/events/homeserver-event-signatures.ts +++ b/packages/core/src/events/homeserver-event-signatures.ts @@ -46,6 +46,8 @@ export type HomeserverEventSignatures = { 'homeserver.matrix.redaction': { event_id: EventID; event: PduForType<'m.room.redaction'>; + // resolved for the room's version, since v11 moved the target from the top level into content + redacts: EventID | undefined; }; 'homeserver.matrix.membership': { event_id: EventID; diff --git a/packages/federation-sdk/src/sdk.ts b/packages/federation-sdk/src/sdk.ts index f374f9a4f..071c5828a 100644 --- a/packages/federation-sdk/src/sdk.ts +++ b/packages/federation-sdk/src/sdk.ts @@ -131,6 +131,10 @@ export class FederationSDK { return this.eventService.getEventById(...args); } + getRedactionTarget(...args: Parameters) { + return this.eventService.getRedactionTarget(...args); + } + makeLeave(...args: Parameters) { return this.roomService.makeLeave(...args); } diff --git a/packages/federation-sdk/src/services/event-notifier.service.ts b/packages/federation-sdk/src/services/event-notifier.service.ts index 593739300..a57b55dcd 100644 --- a/packages/federation-sdk/src/services/event-notifier.service.ts +++ b/packages/federation-sdk/src/services/event-notifier.service.ts @@ -1,6 +1,6 @@ import { createLogger } from '@rocket.chat/federation-core'; import type { EventID, Pdu } from '@rocket.chat/federation-room'; -import { RoomState } from '@rocket.chat/federation-room'; +import { PersistentEventFactory, RoomState } from '@rocket.chat/federation-room'; import { singleton } from 'tsyringe'; import { EventEmitterService } from './event-emitter.service'; @@ -53,9 +53,12 @@ export class EventNotifierService { break; } case event.event.type === 'm.room.redaction': { + const roomVersion = await this.stateService.getRoomVersion(event.event.room_id); + await this.eventEmitterService.emit('homeserver.matrix.redaction', { event_id: eventId, event: event.event, + redacts: PersistentEventFactory.createFromRawEvent(event.event, roomVersion).getRedacts(), }); break; } diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index ff5956715..d9abdf1c4 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -62,6 +62,20 @@ export class EventService { return (this.eventRepository.findById(eventId) ?? null) as Promise

; } + // which event a redaction targets. consumers must not read the field themselves: it is a top + // level field before room v11 and lives in content from v11 on, and only the room knows which + async getRedactionTarget(eventId: EventID): Promise { + const redaction = await this.getEventById(eventId, 'm.room.redaction'); + if (!redaction) { + this.logger.warn(`No redaction event found with id ${eventId}`); + return undefined; + } + + const roomVersion = await this.stateService.getRoomVersion(redaction.event.room_id); + + return PersistentEventFactory.createFromRawEvent(redaction.event, roomVersion).getRedacts(); + } + async checkIfEventsExists(eventIds: EventID[]): Promise<{ missing: EventID[]; found: EventID[] }> { // TODO, return only the IDs, not the full events const eventsCursor = this.eventRepository.findByIds(eventIds); @@ -142,7 +156,7 @@ export class EventService { return roomIdToRoomVersionmap.get(roomId) as RoomVersion; } - const roomVersion = await this.getRoomVersion({ room_id: roomId }); + const roomVersion = await this.stateService.getRoomVersion(roomId); roomIdToRoomVersionmap.set(roomId, roomVersion); @@ -205,10 +219,7 @@ export class EventService { } private async validateEvent(event: Pdu): Promise { - const roomVersion = await this.getRoomVersion(event); - if (!roomVersion) { - throw new Error('M_UNKNOWN_ROOM_VERSION'); - } + const roomVersion = await this.stateService.getRoomVersion(event.room_id); if (event.type === 'm.room.member' && event.content.membership === 'invite' && 'third_party_invite' in event.content) { throw new Error('Third party invites are not supported'); @@ -444,10 +455,6 @@ export class EventService { return parts.length > 1 ? parts[1] : ''; } - private async getRoomVersion(event: Pick) { - return this.stateService.getRoomVersion(event.room_id) || PersistentEventFactory.defaultRoomVersion; - } - async getLastEventForRoom(roomId: string): Promise { return this.eventRepository.findLatestFromRoomId(roomId); } @@ -531,7 +538,7 @@ export class EventService { } async processRedaction(redactionEvent: PduForType<'m.room.redaction'>): Promise { - const roomVersion = await this.getRoomVersion(redactionEvent); + const roomVersion = await this.stateService.getRoomVersion(redactionEvent.room_id); const eventIdToRedact = PersistentEventFactory.createFromRawEvent(redactionEvent, roomVersion).getRedacts(); if (!eventIdToRedact) { From 36d0a80a9f6cf8f6a38e73e83da65784b52c16fe Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 6 Aug 2026 15:37:40 -0300 Subject: [PATCH 15/25] refactor: remove getRoomInformation in favor of getRoomVersion --- .../src/services/event-fetcher.service.ts | 14 +-- .../src/services/event-sender.service.spec.ts | 10 +- .../src/services/event-sender.service.ts | 3 - .../src/services/event.service.ts | 7 +- .../src/services/message.service.ts | 100 ++++++++--------- .../src/services/missing-event.service.ts | 4 +- .../src/services/profiles.service.ts | 9 +- .../services/room-version-coexistence.spec.ts | 43 +++++++- .../src/services/room.service.ts | 104 ++++++++---------- .../src/services/state.service.spec.ts | 16 ++- .../src/services/state.service.ts | 67 +++++------ packages/room/src/manager/factory.ts | 8 +- 12 files changed, 190 insertions(+), 195 deletions(-) diff --git a/packages/federation-sdk/src/services/event-fetcher.service.ts b/packages/federation-sdk/src/services/event-fetcher.service.ts index 77f51cdeb..8be2508c4 100644 --- a/packages/federation-sdk/src/services/event-fetcher.service.ts +++ b/packages/federation-sdk/src/services/event-fetcher.service.ts @@ -1,9 +1,10 @@ import { isFederationEventWithPDUs, createLogger } from '@rocket.chat/federation-core'; -import { EventID, Pdu, PersistentEventFactory, RoomVersion } from '@rocket.chat/federation-room'; +import { EventID, Pdu, PersistentEventFactory, RoomID } from '@rocket.chat/federation-room'; import { delay, inject, singleton } from 'tsyringe'; import { ConfigService } from './config.service'; import { FederationService } from './federation.service'; +import { StateService } from './state.service'; import { EventRepository } from '../repositories/event.repository'; export interface FetchedEvents { @@ -20,9 +21,10 @@ export class EventFetcherService { private readonly eventRepository: EventRepository, private readonly federationService: FederationService, private readonly configService: ConfigService, + private readonly stateService: StateService, ) {} - public async fetchEventsByIds(eventIds: EventID[], roomId: string, originServer: string): Promise { + public async fetchEventsByIds(eventIds: EventID[], roomId: RoomID, originServer: string): Promise { this.logger.debug(`Fetching ${eventIds.length} events for room ${roomId}`); if (!eventIds || eventIds.length === 0) { @@ -57,7 +59,7 @@ export class EventFetcherService { ); const federationEvents = await this.fetchEventsFromFederation(missingEventIds, originServer); - const roomVersion = await this.getRoomVersion(roomId); + const roomVersion = await this.stateService.getRoomVersion(roomId); const federationEventsWithIds = federationEvents.map((e) => ({ eventId: PersistentEventFactory.createFromRawEvent(e, roomVersion).eventId, @@ -76,12 +78,6 @@ export class EventFetcherService { }; } - private async getRoomVersion(roomId: string): Promise { - const createEvent = await this.eventRepository.findByRoomIdAndType(roomId, 'm.room.create'); - - return createEvent?.event.content.room_version ?? PersistentEventFactory.defaultRoomVersion; - } - async fetchEventsFromFederation(eventIds: string[], targetServerName: string): Promise { const eventsToReturn: Pdu[] = []; diff --git a/packages/federation-sdk/src/services/event-sender.service.spec.ts b/packages/federation-sdk/src/services/event-sender.service.spec.ts index dab67467a..f19c591e2 100644 --- a/packages/federation-sdk/src/services/event-sender.service.spec.ts +++ b/packages/federation-sdk/src/services/event-sender.service.spec.ts @@ -7,7 +7,7 @@ import type { PersistentEventBase, RoomID, UserID } from '@rocket.chat/federatio import { EventSenderService } from './event-sender.service'; import type { FederationService } from './federation.service'; -import type { StateService } from './state.service'; +import { type StateService, UnknownRoomError } from './state.service'; const ROOM_ID = '!room:example.com' as RoomID; const SENDER = '@bridge:example.com' as UserID; @@ -79,12 +79,14 @@ describe('EventSenderService.sendCustomEvent', () => { expect(routePersistent).not.toHaveBeenCalled(); }); - test('throws when the room version cannot be resolved', async () => { - getRoomVersion.mockResolvedValueOnce(undefined); + test('propagates the error when the room version cannot be resolved', async () => { + // getRoomVersion throws on an unknown room rather than resolving undefined + getRoomVersion.mockRejectedValueOnce(new UnknownRoomError(ROOM_ID)); - await expect(service.sendCustomEvent(ROOM_ID, 'org.matrix.bridge.ping', {}, SENDER)).rejects.toThrow(/Room version not found/); + await expect(service.sendCustomEvent(ROOM_ID, 'org.matrix.bridge.ping', {}, SENDER)).rejects.toThrow(UnknownRoomError); expect(buildEvent).not.toHaveBeenCalled(); + expect(handlePdu).not.toHaveBeenCalled(); }); test('throws and does not federate when the built event is rejected', async () => { diff --git a/packages/federation-sdk/src/services/event-sender.service.ts b/packages/federation-sdk/src/services/event-sender.service.ts index 00eed2b44..7f0dbe89e 100644 --- a/packages/federation-sdk/src/services/event-sender.service.ts +++ b/packages/federation-sdk/src/services/event-sender.service.ts @@ -41,9 +41,6 @@ export class EventSenderService { senderUserId: UserID, ): Promise { const roomVersion = await this.stateService.getRoomVersion(roomId); - if (!roomVersion) { - throw new Error(`Room version not found for room ${roomId} while trying to send custom event`); - } const rawEvent = { type: eventType, diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index d9abdf1c4..61d81a929 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -439,11 +439,8 @@ export class EventService { if (!event.content || !event.content.room_version) { errors.push('Create event must specify a room_version'); - } else { - const validRoomVersions = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; - if (typeof event.content.room_version !== 'string' || !validRoomVersions.includes(event.content.room_version)) { - errors.push(`Unsupported room version: ${event.content.room_version}`); - } + } else if (!PersistentEventFactory.isSupportedRoomVersion(event.content.room_version)) { + errors.push(`Unsupported room version: ${event.content.room_version}`); } } diff --git a/packages/federation-sdk/src/services/message.service.ts b/packages/federation-sdk/src/services/message.service.ts index b8043ad93..bcb1855a2 100644 --- a/packages/federation-sdk/src/services/message.service.ts +++ b/packages/federation-sdk/src/services/message.service.ts @@ -235,27 +235,22 @@ export class MessageService { throw new ForbiddenError('Cannot react to a message in a tombstoned room'); } - const roomInfo = await this.stateService.getRoomInformation(roomId); - - const reactionEvent = await this.stateService.buildEvent<'m.reaction'>( - { - type: 'm.reaction', - content: { - 'm.relates_to': { - rel_type: 'm.annotation', - event_id: eventId, - key: emoji, - }, + const reactionEvent = await this.stateService.buildEvent<'m.reaction'>({ + type: 'm.reaction', + content: { + 'm.relates_to': { + rel_type: 'm.annotation', + event_id: eventId, + key: emoji, }, - room_id: roomId, - auth_events: [], - depth: 0, - prev_events: [], - origin_server_ts: Date.now(), - sender: senderUserId, }, - roomInfo.room_version, - ); + room_id: roomId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: senderUserId, + }); await this.stateService.handlePdu(reactionEvent); @@ -265,12 +260,12 @@ export class MessageService { } async unsetReaction(roomId: RoomID, eventIdReactedTo: EventID, _emoji: string, senderUserId: UserID): Promise { - const roomInfo = await this.stateService.getRoomInformation(roomId); + const roomVersion = await this.stateService.getRoomVersion(roomId); const redactionEvent = await this.stateService.buildEvent<'m.room.redaction'>( { type: 'm.room.redaction', - ...PersistentEventFactory.newRedactionEventFields(eventIdReactedTo, { reason: 'Unsetting reaction' }, roomInfo.room_version), + ...PersistentEventFactory.newRedactionEventFields(eventIdReactedTo, { reason: 'Unsetting reaction' }, roomVersion), room_id: roomId, auth_events: [], depth: 0, @@ -278,7 +273,7 @@ export class MessageService { origin_server_ts: Date.now(), sender: senderUserId, }, - roomInfo.room_version, + roomVersion, ); await this.stateService.handlePdu(redactionEvent); @@ -295,36 +290,31 @@ export class MessageService { senderUserId: UserID, eventIdToReplace: EventID, ): Promise { - const roomInfo = await this.stateService.getRoomInformation(roomId); - - const redactionEvent = await this.stateService.buildEvent<'m.room.message'>( - { - type: 'm.room.message', - content: { - 'msgtype': 'm.text', - 'body': rawMessage, - 'format': 'org.matrix.custom.html', - 'formatted_body': formattedMessage, - 'm.new_content': { - msgtype: 'm.text', - body: rawMessage, - format: 'org.matrix.custom.html', - formatted_body: formattedMessage, - }, - 'm.relates_to': { - rel_type: 'm.replace', - event_id: eventIdToReplace, - }, + const redactionEvent = await this.stateService.buildEvent<'m.room.message'>({ + type: 'm.room.message', + content: { + 'msgtype': 'm.text', + 'body': rawMessage, + 'format': 'org.matrix.custom.html', + 'formatted_body': formattedMessage, + 'm.new_content': { + msgtype: 'm.text', + body: rawMessage, + format: 'org.matrix.custom.html', + formatted_body: formattedMessage, + }, + 'm.relates_to': { + rel_type: 'm.replace', + event_id: eventIdToReplace, }, - room_id: roomId, - auth_events: [], - depth: 0, - prev_events: [], - origin_server_ts: Date.now(), - sender: senderUserId, }, - roomInfo.room_version, - ); + room_id: roomId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: senderUserId, + }); await this.stateService.handlePdu(redactionEvent); @@ -340,7 +330,7 @@ export class MessageService { throw new ForbiddenError('Cannot delete a message in a tombstoned room'); } - const roomInfo = await this.stateService.getRoomInformation(roomId); + const roomVersion = await this.stateService.getRoomVersion(roomId); const senderUserId = await this.eventService.getEventById(eventIdToRedact); if (!senderUserId?.event.sender) { @@ -350,11 +340,7 @@ export class MessageService { const redactionEvent = await this.stateService.buildEvent<'m.room.redaction'>( { type: 'm.room.redaction', - ...PersistentEventFactory.newRedactionEventFields( - eventIdToRedact, - { reason: `Deleting message: ${eventIdToRedact}` }, - roomInfo.room_version, - ), + ...PersistentEventFactory.newRedactionEventFields(eventIdToRedact, { reason: `Deleting message: ${eventIdToRedact}` }, roomVersion), room_id: roomId, auth_events: [], depth: 0, @@ -362,7 +348,7 @@ export class MessageService { origin_server_ts: Date.now(), sender: senderUserId.event.sender, }, - roomInfo.room_version, + roomVersion, ); await this.stateService.handlePdu(redactionEvent); diff --git a/packages/federation-sdk/src/services/missing-event.service.ts b/packages/federation-sdk/src/services/missing-event.service.ts index 79fbd1634..2b4c2d552 100644 --- a/packages/federation-sdk/src/services/missing-event.service.ts +++ b/packages/federation-sdk/src/services/missing-event.service.ts @@ -1,5 +1,5 @@ import { createLogger } from '@rocket.chat/federation-core'; -import { EventID } from '@rocket.chat/federation-room'; +import { EventID, RoomID } from '@rocket.chat/federation-room'; import { singleton } from 'tsyringe'; import { EventFetcherService } from './event-fetcher.service'; @@ -8,7 +8,7 @@ import { StateService } from './state.service'; type MissingEventType = { eventId: EventID; - roomId: string; + roomId: RoomID; origin: string; }; diff --git a/packages/federation-sdk/src/services/profiles.service.ts b/packages/federation-sdk/src/services/profiles.service.ts index c9feb1096..79d3422b3 100644 --- a/packages/federation-sdk/src/services/profiles.service.ts +++ b/packages/federation-sdk/src/services/profiles.service.ts @@ -1,3 +1,4 @@ +import { UnsupportedRoomVersionError } from '@rocket.chat/federation-core'; import { EventID, extractDomainFromId, Pdu, PduForType, RoomID, RoomVersion, UserID } from '@rocket.chat/federation-room'; import { delay, inject, singleton } from 'tsyringe'; @@ -79,12 +80,10 @@ export class ProfilesService { room_version: RoomVersion; }> { const { stateService } = this; - const roomInformation = await stateService.getRoomInformation(roomId); - - const roomVersion = roomInformation.room_version; + const roomVersion = await stateService.getRoomVersion(roomId); if (!versions.includes(roomVersion)) { - throw new Error(`Unsupported room version: ${roomVersion}`); + throw new UnsupportedRoomVersionError(`Unsupported room version: ${roomVersion}`); } if (!(await this.stateService.getLatestRoomState2(roomId)).isUserInvited(userId)) { @@ -105,7 +104,7 @@ export class ProfilesService { origin_server_ts: Date.now(), sender: userId, }, - roomInformation.room_version, + roomVersion, ); return { diff --git a/packages/federation-sdk/src/services/room-version-coexistence.spec.ts b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts index be0d6a5fc..8dd930239 100644 --- a/packages/federation-sdk/src/services/room-version-coexistence.spec.ts +++ b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts @@ -1,13 +1,15 @@ import { beforeEach, describe, expect, it } from 'bun:test'; import { type EventStore } from '@rocket.chat/federation-core'; -import type { EventID, PduCreateEventContent, RoomVersion, UserID } from '@rocket.chat/federation-room'; +import type { EventID, Pdu, PduCreateEventContent, RoomVersion, UserID } 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 { EventFetcherService } from './event-fetcher.service'; import type { EventService } from './event.service'; +import type { FederationService } from './federation.service'; import { StateService } from './state.service'; import { EventRepository } from '../repositories/event.repository'; import { StateGraphRepository } from '../repositories/state-graph.repository'; @@ -114,9 +116,9 @@ describe('room version coexistence', async () => { expect(v10.createEvent.getCreator()).toBe(alice); expect(v11.createEvent.getCreator()).toBe(alice); - // and getRoomInformation reports a creator either way, so consumers keep working - expect(await stateService.getRoomInformation(v10.roomId)).toHaveProperty('creator', alice); - expect(await stateService.getRoomInformation(v11.roomId)).toHaveProperty('creator', alice); + // and the same holds through the service seam, so consumers never touch content.creator + expect((await stateService.getCreateEvent(v10.roomId)).getCreator()).toBe(alice); + expect((await stateService.getCreateEvent(v11.roomId)).getCreator()).toBe(alice); }); it('keeps v10 and v11 rooms independent while both are used', async () => { @@ -223,4 +225,37 @@ describe('room version coexistence', async () => { it('bans in a v10 room', async () => expectBanWorks('10')); it('bans in a v11 room', async () => expectBanWorks('11')); + + // the fetcher used to resolve the version with its own read that fell back to + // defaultRoomVersion, so a v10 room's federated events got v10-invalid ids + it('identifies federation-fetched events at the room version, not the default', async () => { + const { roomId } = await createRoom('10'); + + // origin is a top level field v10 keeps under redaction and v11 drops, so the + // two versions disagree on this event's id + const fetched = { + type: 'm.room.message', + room_id: roomId, + sender: bob, + content: { msgtype: 'm.text', body: 'from federation' }, + origin: 'example.com', + ...defaults(), + } as unknown as Pdu; + + const atRoomVersion = PersistentEventFactory.createFromRawEvent(fetched, '10').eventId; + const atDefaultVersion = PersistentEventFactory.createFromRawEvent(fetched, PersistentEventFactory.defaultRoomVersion).eventId; + expect(atRoomVersion).not.toBe(atDefaultVersion); + + const fetcher = new EventFetcherService( + new EventRepository(eventCollection), + { getEvent: async () => ({ pdus: [fetched] }) } as unknown as FederationService, + { serverName: 'example.com' } as unknown as ConfigService, + stateService, + ); + + const { events } = await fetcher.fetchEventsByIds([atRoomVersion], roomId, 'remote.example.com'); + + expect(events).toHaveLength(1); + expect(events[0].eventId).toBe(atRoomVersion); + }); }); diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index ae829947b..5f1e65f7b 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -37,7 +37,7 @@ import { FederationValidationService } from './federation-validation.service'; import { FederationService } from './federation.service'; import { InviteService } from './invite.service'; import { ProfilesService } from './profiles.service'; -import { RoomInfoNotReadyError, StateService, UnknownRoomError } from './state.service'; +import { StateService, UnknownRoomError } from './state.service'; import { EventStagingRepository } from '../repositories/event-staging.repository'; import { EventRepository } from '../repositories/event.repository'; import { RoomRepository } from '../repositories/room.repository'; @@ -510,7 +510,7 @@ export class RoomService { event: PduForType<'m.room.member'>; room_version: RoomVersion; }> { - const roomInfo = await this.stateService.getRoomInformation(roomId); + const roomVersion = await this.stateService.getRoomVersion(roomId); const leaveEvent = await this.stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', @@ -523,20 +523,17 @@ export class RoomService { origin_server_ts: Date.now(), sender: senderId, }, - roomInfo.room_version, + roomVersion, ); return { event: leaveEvent.event, - room_version: roomInfo.room_version, + room_version: roomVersion, }; } async sendLeave(roomId: RoomID, eventId: EventID, event: PduForType<'m.room.member'>) { const roomVersion = await this.stateService.getRoomVersion(roomId); - if (!roomVersion) { - throw new Error('Room version not found while sending leave'); - } const leaveEvent = PersistentEventFactory.createFromRawEvent<'m.room.member'>(event, roomVersion); if (leaveEvent.eventId !== eventId) { @@ -592,8 +589,6 @@ export class RoomService { async kickUser(roomId: RoomID, kickedUserId: UserID, senderId: UserID, reason?: string): Promise { logger.info(`User ${senderId} kicking user ${kickedUserId} from room ${roomId}. Reason: ${reason || 'No reason specified'}`); - const roomInfo = await this.stateService.getRoomInformation(roomId); - // Use resolved room state for power level (same state used when building the kick event's auth_events). const state = await this.stateService.getLatestRoomState(roomId); const powerLevelsEvent = getStateByMapKey(state, { type: 'm.room.power_levels' }); @@ -604,23 +599,20 @@ export class RoomService { this.validateKickPermission(powerLevelsEvent.getContent(), senderId, kickedUserId); - const kickEvent = await this.stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - content: { - membership: 'leave', - reason, - }, - room_id: roomId, - state_key: kickedUserId, - auth_events: [], - depth: 0, - prev_events: [], - origin_server_ts: Date.now(), - sender: senderId, + const kickEvent = await this.stateService.buildEvent<'m.room.member'>({ + type: 'm.room.member', + content: { + membership: 'leave', + reason, }, - roomInfo.room_version, - ); + room_id: roomId, + state_key: kickedUserId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: senderId, + }); await this.stateService.handlePdu(kickEvent); @@ -636,8 +628,6 @@ export class RoomService { async banUser(roomId: RoomID, bannedUserId: UserID, senderId: UserID, reason?: string): Promise { logger.info(`User ${senderId} banning user ${bannedUserId} from room ${roomId}. Reason: ${reason || 'No reason specified'}`); - const roomInfo = await this.stateService.getRoomInformation(roomId); - // Use resolved room state for power level (same state used when building the ban event's auth_events). const state = await this.stateService.getLatestRoomState(roomId); const powerLevelsEvent = getStateByMapKey(state, { type: 'm.room.power_levels' }); @@ -649,23 +639,20 @@ export class RoomService { this.validateBanPermission(powerLevelsEvent.getContent(), senderId, bannedUserId); - const banEvent = await this.stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - content: { - membership: 'ban', - reason, - }, - room_id: roomId, - state_key: bannedUserId, - auth_events: [], - depth: 0, - prev_events: [], - origin_server_ts: Date.now(), - sender: senderId, + const banEvent = await this.stateService.buildEvent<'m.room.member'>({ + type: 'm.room.member', + content: { + membership: 'ban', + reason, }, - roomInfo.room_version, - ); + room_id: roomId, + state_key: bannedUserId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: senderId, + }); await this.stateService.handlePdu(banEvent); @@ -921,31 +908,26 @@ export class RoomService { * Update user profile (displayname/avatar) in a room by sending a membership event */ async updateUserProfile(roomId: RoomID, userId: UserID, profile: { displayname?: string; avatar_url?: string }) { - const roomInfo = await this.stateService.getRoomInformation(roomId); - const state = await this.stateService.getLatestRoomState(roomId); const membershipEvent = state.get(`m.room.member:${userId}`); if (!membershipEvent || membershipEvent.getMembership() !== 'join') { throw new Error(`User ${userId} is not a member of room ${roomId}`); } - const newMembershipEvent = await this.stateService.buildEvent<'m.room.member'>( - { - type: 'm.room.member', - content: { - ...membershipEvent.event.content, - ...profile, - }, - room_id: roomId, - state_key: userId, - auth_events: [], - depth: 0, - prev_events: [], - origin_server_ts: Date.now(), - sender: userId, + const newMembershipEvent = await this.stateService.buildEvent<'m.room.member'>({ + type: 'm.room.member', + content: { + ...membershipEvent.event.content, + ...profile, }, - roomInfo.room_version, - ); + room_id: roomId, + state_key: userId, + auth_events: [], + depth: 0, + prev_events: [], + origin_server_ts: Date.now(), + sender: userId, + }); await this.stateService.handlePdu(newMembershipEvent); diff --git a/packages/federation-sdk/src/services/state.service.spec.ts b/packages/federation-sdk/src/services/state.service.spec.ts index f65261d9b..ee9f719ce 100644 --- a/packages/federation-sdk/src/services/state.service.spec.ts +++ b/packages/federation-sdk/src/services/state.service.spec.ts @@ -9,6 +9,7 @@ import type { PduPowerLevelsEventContent, PduRoomNameEventContent, PersistentEventBase, + RoomID, RoomVersion, } from '@rocket.chat/federation-room'; import * as room from '@rocket.chat/federation-room'; @@ -18,7 +19,7 @@ import { type WithId } from 'mongodb'; import { type ConfigService } from './config.service'; import { DatabaseConnectionService } from './database-connection.service'; import type { EventNotifierService } from './event-notifier.service'; -import { StateService } from './state.service'; +import { StateService, UnknownRoomError } from './state.service'; import { EventRepository } from '../repositories/event.repository'; import { StateGraphRepository } from '../repositories/state-graph.repository'; import type { StateGraphStore } from '../repositories/state-graph.repository'; @@ -871,12 +872,17 @@ describe('StateService', async () => { compareStates(stateAtMessage, state9); }); - it('01 should return the correct room information for room id', async () => { - expect(stateService.getRoomInformation('abcd')).rejects.toThrowError(/Create event mapping not found/); + it('01 should return the create event wrapper for room id', async () => { + expect(stateService.getCreateEvent('abcd' as RoomID)).rejects.toThrowError(UnknownRoomError); const { roomCreateEvent } = await createRoom('public'); - expect(stateService.getRoomInformation(roomCreateEvent.roomId)).resolves.toHaveProperty('creator', roomCreateEvent.getCreator()); + const createEvent = await stateService.getCreateEvent(roomCreateEvent.roomId); + + expect(createEvent.eventId).toBe(roomCreateEvent.eventId); + expect(createEvent.version).toBe(roomCreateEvent.version); + // the accessor resolves the creator per version, callers never read content.creator + expect(createEvent.getCreator()).toBe(roomCreateEvent.getCreator()); }); it('02 should get the correct room version', async () => { @@ -886,7 +892,7 @@ describe('StateService', async () => { expect(roomVersion).toBe(roomCreateEvent.getContent().room_version as RoomVersion); - expect(stateService.getRoomVersion('roomId')).rejects.toThrowError(); + expect(stateService.getRoomVersion('roomId' as RoomID)).rejects.toThrowError(UnknownRoomError); }); it('03 should find the correct state at an event', async () => { diff --git a/packages/federation-sdk/src/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index 7668e4842..364b3f0ea 100644 --- a/packages/federation-sdk/src/services/state.service.ts +++ b/packages/federation-sdk/src/services/state.service.ts @@ -1,10 +1,9 @@ -import { createLogger, signEvent } from '@rocket.chat/federation-core'; +import { UnsupportedRoomVersionError, createLogger, signEvent } from '@rocket.chat/federation-core'; import { type EventID, type EventStore, Pdu, type PduContent, - PduCreateEventContent, PduForType, type PduType, PduWithHashesAndSignaturesOptional, @@ -14,6 +13,7 @@ import { RoomID, RoomState, RoomVersion, + type RoomVersion3To11, State, type StateID, type StateMapKey, @@ -62,12 +62,6 @@ export class UnknownRoomError extends Error { this.name = 'UnknownRoomError'; } } -export class RoomInfoNotReadyError extends Error { - constructor(message: string) { - super(message); - this.name = 'RoomInfoNotReadyError'; - } -} @singleton() export class StateService { @@ -81,39 +75,36 @@ export class StateService { private readonly configService: ConfigService, ) {} - // TODO: this is a very vague method, better would be to use exactly what needed, - // or getCreateEvent. - // currently AFAIK mostly is used for just room version - async getRoomInformation(roomId: string): Promise { - const { event, stateId } = (await this.eventRepository.findByRoomIdAndType(roomId, 'm.room.create')) ?? {}; - if (event?.type !== 'm.room.create') { - throw new RoomInfoNotReadyError('Create event mapping not found for room information'); - } - - if (!stateId) { - throw new Error('Create event has no state id, something is very wrong'); + // reading room_version off raw content is unavoidable here: you need the version before + // you can build the version aware wrapper that would otherwise hand it to you. this is + // the only place that does it, so it is also where an unsupported version is caught + private async findCreateEvent(roomId: RoomID) { + const createEvent = await this.eventRepository.findByRoomIdAndType(roomId, 'm.room.create'); + if (!createEvent) { + throw new UnknownRoomError(roomId); } - // v11 rooms have no content.creator, so report the creator the room version resolves - // rather than handing callers an undefined field - const { room_version: roomVersion } = event.content; + const { room_version: roomVersion } = createEvent.event.content; if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { - return event.content; + throw new UnsupportedRoomVersionError(`Room ${roomId} is at unsupported version ${roomVersion}`); } - return { - ...event.content, - creator: PersistentEventFactory.createFromRawEvent<'m.room.create'>(event, roomVersion).getCreator(), - }; + return { event: createEvent.event, roomVersion }; } - async getRoomVersion(roomId: RoomID): Promise { - const createEvent = await this.eventRepository.findByRoomIdAndType(roomId, 'm.room.create'); - if (!createEvent) { - throw new UnknownRoomError(roomId); - } + async getRoomVersion(roomId: RoomID): Promise { + const { roomVersion } = await this.findCreateEvent(roomId); - return createEvent.event.content.room_version; + return roomVersion; + } + + // the seam for anything derived from a room's create event. returns the version aware + // wrapper rather than raw content so format changes (v11 moved the creator from + // content to sender) stay inside the version classes instead of leaking to callers + async getCreateEvent(roomId: RoomID): Promise> { + const { event, roomVersion } = await this.findCreateEvent(roomId); + + return PersistentEventFactory.createFromRawEvent<'m.room.create'>(event, roomVersion); } // helps with logging state @@ -250,11 +241,14 @@ export class StateService { }; } + // roomVersion is optional: callers that already hold it save a lookup, everyone else + // should not have to know rooms have versions at all async buildEvent( event: PduWithHashesAndSignaturesOptional>, - roomVersion: RoomVersion, + roomVersion?: RoomVersion, ): Promise> { - const instance = PersistentEventFactory.createFromRawEvent(event, roomVersion); + const version = roomVersion ?? (await this.getRoomVersion(event.room_id)); + const instance = PersistentEventFactory.createFromRawEvent(event, version); await Promise.all([ instance.event.auth_events.length === 0 && this.addAuthEvents(instance), instance.event.prev_events.length === 0 && this.addPrevEvents(instance), @@ -279,9 +273,6 @@ export class StateService { async addPrevEvents(event: PersistentEventBase) { const roomVersion = await this.getRoomVersion(event.roomId); - if (!roomVersion) { - throw new Error('Room version not found while filling prev events'); - } const prevEvents = await this.eventRepository.findLatestEvents(event.roomId); diff --git a/packages/room/src/manager/factory.ts b/packages/room/src/manager/factory.ts index 3bc01e012..a25542c1b 100644 --- a/packages/room/src/manager/factory.ts +++ b/packages/room/src/manager/factory.ts @@ -51,7 +51,7 @@ export class PersistentEventFactory { static defaultRoomVersion = '11' as const; - static isSupportedRoomVersion(roomVersion: string): roomVersion is RoomVersion { + static isSupportedRoomVersion(roomVersion: string): roomVersion is RoomVersion3To11 { return PersistentEventFactory.supportedRoomVersions.includes(roomVersion); } @@ -86,9 +86,13 @@ export class PersistentEventFactory { roomVersion: string, partial = false, ): PersistentEventBase { + if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { + throw new Error(`Room version ${roomVersion} is not supported`); + } + const EventClass = PersistentEventFactory.getEventClass(roomVersion); - return new EventClass(event, roomVersion as RoomVersion3To11, partial) as PersistentEventBase; + return new EventClass(event, roomVersion, partial) as PersistentEventBase; } // create individual events From fa5a5965a121565f51b2338569813183353f8926 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 16:03:25 -0300 Subject: [PATCH 16/25] remove createSignedEvent and state pruneEventDict is v10 only --- .../events/utils/createSignedEvent.spec.ts | 25 ------- .../src/events/utils/createSignedEvent.ts | 11 --- packages/core/src/index.ts | 4 -- .../core/src/utils/pruneEventDict.spec.ts | 6 ++ packages/core/src/utils/pruneEventDict.ts | 4 ++ .../federation-sdk/src/utils/signJson.spec.ts | 68 +++++++++---------- 6 files changed, 44 insertions(+), 74 deletions(-) delete mode 100644 packages/core/src/events/utils/createSignedEvent.spec.ts delete mode 100644 packages/core/src/events/utils/createSignedEvent.ts diff --git a/packages/core/src/events/utils/createSignedEvent.spec.ts b/packages/core/src/events/utils/createSignedEvent.spec.ts deleted file mode 100644 index bffc41fca..000000000 --- a/packages/core/src/events/utils/createSignedEvent.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { generateKeyPairsFromString } from '../../utils/keys'; -import { signEvent } from '../../utils/signEvent'; -import { roomCreateEvent } from '../m.room.create'; -import { createSignedEvent } from './createSignedEvent'; - -describe('makeSignedEvent', () => { - test('it should return the same payload, following create event > sign', async () => { - const signature = await generateKeyPairsFromString('ed25519 a_XRhW YjbSyfqQeGto+OFswt+XwtJUUooHXH5w+czSgawN63U'); - - const args = { - roomId: '!uTqsSSWabZzthsSCNf:hs1', - sender: '@admin:hs1', - ts: 1733069433734, - }; - - const signed = await signEvent(roomCreateEvent(args), signature, 'hs1'); - - const makeSignedEvent = createSignedEvent(signature, 'hs1'); - const result = await makeSignedEvent(roomCreateEvent)(args); - - expect(result).toStrictEqual(signed); - }); -}); diff --git a/packages/core/src/events/utils/createSignedEvent.ts b/packages/core/src/events/utils/createSignedEvent.ts deleted file mode 100644 index 7156b5a03..000000000 --- a/packages/core/src/events/utils/createSignedEvent.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { SigningKey, SignedEvent } from '../../types'; -import { signEvent } from '../../utils/signEvent'; - -export const createSignedEvent = (signature: SigningKey, signingName: string) => { - return any>(fn: F) => { - return async (...args: Parameters): Promise>> => { - const event = await fn(...args); - return signEvent(event, signature, signingName) as Promise>>; - }; - }; -}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 440fa165a..2af0df73c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,6 @@ export type { SignedEvent, SigningKey } from './types'; export { signEvent } from './utils/signEvent'; // Authentication utilities -export { pruneEventDict } from './utils/pruneEventDict'; export { checkSignAndHashes } from './utils/checkSignAndHashes'; export { authorizationHeaders, @@ -59,9 +58,6 @@ export type { RoomMemberEvent, Membership } from './events/isRoomMemberEvent'; export { isRoomMemberEvent } from './events/isRoomMemberEvent'; -// Event utilities -export * from './events/utils/createSignedEvent'; - // Event models export * from './models/event.model'; diff --git a/packages/core/src/utils/pruneEventDict.spec.ts b/packages/core/src/utils/pruneEventDict.spec.ts index ab5c804bf..06d7689a6 100644 --- a/packages/core/src/utils/pruneEventDict.spec.ts +++ b/packages/core/src/utils/pruneEventDict.spec.ts @@ -38,6 +38,12 @@ const event = { origin: 'synapse2', }; +// TODO: pruneEventDict and signEvent's `prune` flag should go away entirely, replaced by +// PersistentEventFactory.createFromRawEvent(event, roomVersion).redactedEvent. Blocked on migrating +// the 28 remaining signEvent(..., prune=true) call sites in packages/core/src/events/*.spec.ts. +// m.room.create.spec.ts already signs the same fixture both ways and gets an identical signature, so +// the migration should preserve every hardcoded signature fixture rather than require regenerating +// them — a failure there would be a real divergence between the two redaction implementations. describe('pruneEventDict', () => { test('m.room.member', () => { const result = pruneEventDict(event.content); diff --git a/packages/core/src/utils/pruneEventDict.ts b/packages/core/src/utils/pruneEventDict.ts index 85dfb93a6..6815adba0 100644 --- a/packages/core/src/utils/pruneEventDict.ts +++ b/packages/core/src/utils/pruneEventDict.ts @@ -42,6 +42,10 @@ export type Prettify = { [K in keyof T]: T[K]; }; +// Not exported from the package: this defaults to pre-v11 redaction rules and has no notion of the +// room version, so signing a v11 event through it produces a signature the spec-correct redaction +// will reject. Production signing goes through PersistentEventBase.redactedEvent instead; this only +// survives as scaffolding for the core event specs. export function pruneEventDict( eventDict: T, roomVersion: RoomVersion = { diff --git a/packages/federation-sdk/src/utils/signJson.spec.ts b/packages/federation-sdk/src/utils/signJson.spec.ts index 17d1d5c5c..2650212f5 100644 --- a/packages/federation-sdk/src/utils/signJson.spec.ts +++ b/packages/federation-sdk/src/utils/signJson.spec.ts @@ -1,12 +1,7 @@ import { describe, expect, it, test } from 'bun:test'; -import { - EncryptionValidAlgorithm, - generateKeyPairsFromString, - pruneEventDict, - signJson, - verifySignaturesFromRemote, -} from '@rocket.chat/federation-core'; +import { EncryptionValidAlgorithm, generateKeyPairsFromString, signJson, verifySignaturesFromRemote } from '@rocket.chat/federation-core'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; describe('verifySignaturesFromRemote', async () => { test('it should verify a valid signature', async () => { @@ -119,37 +114,42 @@ describe('verifySignaturesFromRemote', async () => { test('signJson send_join', async () => { const signature = await generateKeyPairsFromString('ed25519 a_yNbw tBD7FfjyBHgT4TwhwzvyS9Dq2Z9ck38RRQKaZ6Sz2z8'); + // the expected signature below comes from real Synapse output, so redacting through the + // versioned factory here doubles as a cross-check of our v10 redaction against Synapse const signed = await signJson( - pruneEventDict({ - auth_events: [ - '$aokhD3KlL_EHZ67626nn_aHMPW9K3T7rvT7IkrZaMbI', - '$-aRadmHs-xyc4xVWx38FmlIaM6xafoJsqCj3fVbkO-Q', - '$NAL56UfuEcLlL2kjmOYZvd5dQJY59Sxxp3l42iBNenw', - '$smcGuuNx478aANd8STTp0bDI94ER93vldR-_mO_KLyU', - ], - prev_events: ['$UqTWV2zA0fLTB2gj9iemXVyjamrt5X6GsSTnCQAtmik'], - type: 'm.room.member', - room_id: '!JVkUxGlBLsuOwTBUpN:synapse1', - sender: '@rodrigo2:synapse2', - depth: 10, - - content: { - membership: 'join', - avatar_url: null, - displayname: 'rodrigo2', - }, + PersistentEventFactory.createFromRawEvent( + { + auth_events: [ + '$aokhD3KlL_EHZ67626nn_aHMPW9K3T7rvT7IkrZaMbI', + '$-aRadmHs-xyc4xVWx38FmlIaM6xafoJsqCj3fVbkO-Q', + '$NAL56UfuEcLlL2kjmOYZvd5dQJY59Sxxp3l42iBNenw', + '$smcGuuNx478aANd8STTp0bDI94ER93vldR-_mO_KLyU', + ], + prev_events: ['$UqTWV2zA0fLTB2gj9iemXVyjamrt5X6GsSTnCQAtmik'], + type: 'm.room.member', + room_id: '!JVkUxGlBLsuOwTBUpN:synapse1', + sender: '@rodrigo2:synapse2', + depth: 10, + + content: { + membership: 'join', + avatar_url: null, + displayname: 'rodrigo2', + }, - hashes: { - sha256: 'YBZHC60WOdOVDB2ISkVTnbg/L7J9qYBKWY+lUSZYIUk', - }, - origin: 'synapse2', - origin_server_ts: 1732999153019, + hashes: { + sha256: 'YBZHC60WOdOVDB2ISkVTnbg/L7J9qYBKWY+lUSZYIUk', + }, + origin: 'synapse2', + origin_server_ts: 1732999153019, - state_key: '@rodrigo2:synapse2', - unsigned: { - age: 2, + state_key: '@rodrigo2:synapse2', + unsigned: { + age: 2, + }, }, - }), + '10', + ).redactedEvent, signature, 'synapse2', ); From 2f5eb11e53c5af48ce9ce5b01f4aed5db0d82873 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 16:15:10 -0300 Subject: [PATCH 17/25] remove origin field on make_leave event --- packages/federation-sdk/src/services/room.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index 5f1e65f7b..b68e6eab7 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -890,7 +890,11 @@ export class RoomService { // that's why we handle it manually instead of calling this.leaveRoom const { event: leaveTemplate, room_version } = await this.federationService.makeLeave(invitingServer, roomId, userId); - const leaveEvent = PersistentEventFactory.createFromRawEvent<'m.room.member'>(leaveTemplate, room_version); + // same as the make_join template: origin is not a PDU field, drop whatever the remote put + // there instead of signing their hostname back as the origin of our own event + const { origin: _origin, ...template } = leaveTemplate as PduForType<'m.room.member'> & { origin?: string }; + + const leaveEvent = PersistentEventFactory.createFromRawEvent<'m.room.member'>(template, room_version); await this.stateService.signEvent(leaveEvent); From 6358e9ea4bd183f791ae8819a8159ea96ee91985 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 16:16:53 -0300 Subject: [PATCH 18/25] test: add redact tests --- .../src/services/event.service.spec.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/federation-sdk/src/services/event.service.spec.ts diff --git a/packages/federation-sdk/src/services/event.service.spec.ts b/packages/federation-sdk/src/services/event.service.spec.ts new file mode 100644 index 000000000..bf58c41f2 --- /dev/null +++ b/packages/federation-sdk/src/services/event.service.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'bun:test'; + +import type { EventID, Pdu, PduForType, RoomVersion } from '@rocket.chat/federation-room'; + +import { EventService } from './event.service'; +import type { StateService } from './state.service'; +import type { EventRepository } from '../repositories/event.repository'; + +// a v10-shaped member event: pre-v11 redaction keeps origin/prev_state/membership at the top level, +// and only `membership` out of content +const memberEvent = { + type: 'm.room.member', + room_id: '!room:hs1', + sender: '@alice:hs1', + state_key: '@alice:hs1', + depth: 5, + auth_events: ['$auth1'], + prev_events: ['$prev1'], + origin_server_ts: 1732999153019, + origin: 'hs1', + prev_state: [], + membership: 'join', + content: { + membership: 'join', + displayname: 'alice', + avatar_url: 'mxc://hs1/abc', + }, + hashes: { sha256: 'irrelevant-to-redaction' }, + signatures: { hs1: { 'ed25519:key': 'sig' } }, + unsigned: { age: 2 }, +} as unknown as Pdu; + +function buildService(roomVersion: RoomVersion, storedEvent: Pdu) { + const captured: { eventId?: EventID; event?: Pdu } = {}; + + const eventRepository = { + async findById() { + return { event: storedEvent }; + }, + async redactEvent(eventId: EventID, redactedEvent: Pdu) { + captured.eventId = eventId; + captured.event = redactedEvent; + }, + } as unknown as EventRepository; + + const stateService = { + async getRoomVersion() { + return roomVersion; + }, + } as unknown as StateService; + + const service = new EventService( + null as never, + null as never, + stateService, + null as never, + null as never, + eventRepository, + null as never, + null as never, + ); + + return { service, captured }; +} + +describe('processRedaction', () => { + it('keeps the pre-v11 top level fields when redacting in a v10 room', async () => { + const { service, captured } = buildService('10', memberEvent); + + // before v11 the redaction target is a top level field + const redaction = { + type: 'm.room.redaction', + room_id: '!room:hs1', + sender: '@alice:hs1', + depth: 6, + auth_events: [], + prev_events: [], + origin_server_ts: 1732999153020, + redacts: '$target' as EventID, + content: {}, + } as unknown as PduForType<'m.room.redaction'>; + + await service.processRedaction(redaction); + + expect(captured.eventId).toBe('$target' as EventID); + + // stripping these would invalidate the signature, which covers the redacted form + expect(captured.event).toHaveProperty('origin', 'hs1'); + expect(captured.event).toHaveProperty('membership', 'join'); + expect(captured.event).toHaveProperty('prev_state'); + + expect(captured.event?.content).toEqual({ membership: 'join' }); + }); + + it('drops the pre-v11 top level fields when redacting in a v11 room', async () => { + const { service, captured } = buildService('11', memberEvent); + + // v11 moved the redaction target into content + const redaction = { + type: 'm.room.redaction', + room_id: '!room:hs1', + sender: '@alice:hs1', + depth: 6, + auth_events: [], + prev_events: [], + origin_server_ts: 1732999153020, + content: { redacts: '$target' as EventID }, + } as unknown as PduForType<'m.room.redaction'>; + + await service.processRedaction(redaction); + + expect(captured.eventId).toBe('$target' as EventID); + + expect(captured.event).not.toHaveProperty('origin'); + expect(captured.event).not.toHaveProperty('membership'); + expect(captured.event).not.toHaveProperty('prev_state'); + + expect(captured.event?.content).toEqual({ membership: 'join' }); + }); + + it('records what redacted the event', async () => { + const { service, captured } = buildService('11', memberEvent); + + const redaction = { + type: 'm.room.redaction', + room_id: '!room:hs1', + sender: '@alice:hs1', + depth: 6, + auth_events: [], + prev_events: [], + origin_server_ts: 1732999153020, + content: { redacts: '$target' as EventID }, + } as unknown as PduForType<'m.room.redaction'>; + + await service.processRedaction(redaction); + + expect(captured.event?.unsigned).toHaveProperty('redacted_because', redaction); + }); +}); From 380bbdacf74d323da29690eadff4ba810edb6506 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 16:23:50 -0300 Subject: [PATCH 19/25] test: document and test top-level redacts field --- .../federation-sdk/src/utils/event-schemas.spec.ts | 13 +++++++++++++ packages/federation-sdk/src/utils/event-schemas.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/federation-sdk/src/utils/event-schemas.spec.ts b/packages/federation-sdk/src/utils/event-schemas.spec.ts index 24b48272f..39c6869eb 100644 --- a/packages/federation-sdk/src/utils/event-schemas.spec.ts +++ b/packages/federation-sdk/src/utils/event-schemas.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'bun:test'; +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + import { getEventSchemaForType } from './event-schemas'; const base = { @@ -14,6 +16,7 @@ const base = { const createEvent = { ...base, type: 'm.room.create', state_key: '' }; const redaction = { ...base, type: 'm.room.redaction' }; const target = '$8ftnUd9WTPTQGbdPgfOPea8bOEQ21qPvbcGqeOApQxA'; +const otherTarget = '$AAAnUd9WTPTQGbdPgfOPea8bOEQ21qPvbcGqeOApQxA'; function validate(event: object, roomVersion: string) { return getEventSchemaForType((event as { type: string }).type, roomVersion).safeParse(event).success; @@ -39,6 +42,16 @@ describe('event schemas', () => { expect(validate({ ...redaction, redacts: target, content: {} }, '11')).toBe(false); }); + // rejecting this would mean dropping an event the rest of the federation accepted, so a v11 + // redaction carrying the legacy top level field is accepted and the field ignored + it('tolerates a legacy top level redacts in v11 and resolves the target from content', () => { + const withBoth = { ...redaction, redacts: otherTarget, content: { redacts: target } }; + + expect(validate(withBoth, '11')).toBe(true); + + expect(PersistentEventFactory.createFromRawEvent(withBoth as never, '11').getRedacts()).toBe(target); + }); + it('resolves a schema for every supported room version', () => { for (const roomVersion of ['3', '4', '5', '6', '7', '8', '9', '10']) { expect(validate({ ...createEvent, content: { room_version: roomVersion, creator: '@admin:hs1' } }, roomVersion)).toBe(true); diff --git a/packages/federation-sdk/src/utils/event-schemas.ts b/packages/federation-sdk/src/utils/event-schemas.ts index 071425f35..260334101 100644 --- a/packages/federation-sdk/src/utils/event-schemas.ts +++ b/packages/federation-sdk/src/utils/event-schemas.ts @@ -107,7 +107,10 @@ const redactionEventSchema = baseEventSchema.extend({ content: z.object({ reason: z.string().optional() }).and(anyContent), }); -// v11 moved the redaction target from the top level into content +// v11 moved the redaction target from the top level into content. A stray top-level `redacts` is +// deliberately tolerated rather than rejected: this validates inbound federation traffic, and +// refusing an event the rest of the federation accepted would drop it from our state and diverge. +// The field is inert, since only resolveRedacts() reads the target and v11 reads it from content. const redactionEventSchemaV11 = baseEventSchema.extend({ type: z.literal('m.room.redaction'), content: z.object({ redacts: eventIdSchema, reason: z.string().optional() }).and(anyContent), From e7347972f87547c94b1d0ab853899eea09f84f2c Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 16:29:03 -0300 Subject: [PATCH 20/25] fix make_join room version error --- .../src/services/profiles.service.spec.ts | 49 +++++++++++++++++++ .../src/services/profiles.service.ts | 8 ++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/federation-sdk/src/services/profiles.service.spec.ts diff --git a/packages/federation-sdk/src/services/profiles.service.spec.ts b/packages/federation-sdk/src/services/profiles.service.spec.ts new file mode 100644 index 000000000..1aec85609 --- /dev/null +++ b/packages/federation-sdk/src/services/profiles.service.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; + +import { IncompatibleRoomVersionError } from '@rocket.chat/federation-core'; +import type { RoomID, RoomVersion, UserID } from '@rocket.chat/federation-room'; + +import { ProfilesService } from './profiles.service'; +import type { StateService } from './state.service'; + +function buildService(roomVersion: RoomVersion) { + const stateService = { + async getRoomVersion() { + return roomVersion; + }, + } as unknown as StateService; + + return new ProfilesService(null as never, stateService, null as never); +} + +describe('makeJoin', () => { + it('reports the room version the asking server is missing support for', async () => { + const service = buildService('11'); + + // the asking server advertised only up to v10, the room is v11 + const makeJoin = service.makeJoin('!room:hs1' as RoomID, '@alice:hs2' as UserID, ['10'] as RoomVersion[]); + + await expect(makeJoin).rejects.toBeInstanceOf(IncompatibleRoomVersionError); + + const error = await makeJoin.catch((e: IncompatibleRoomVersionError) => e); + + expect(error.errcode).toBe('M_INCOMPATIBLE_ROOM_VERSION'); + expect(error.status).toBe(400); + + // room_version is required on this errcode, it is how the remote learns what it needs + expect(error.toJSON()).toEqual({ + errcode: 'M_INCOMPATIBLE_ROOM_VERSION', + error: 'Your homeserver does not support the features required to join this room', + room_version: '11', + }); + }); + + it('does not reject a version the asking server advertised', async () => { + const service = buildService('10'); + + // gets past the version check and fails later, on state we did not stub + const makeJoin = service.makeJoin('!room:hs1' as RoomID, '@alice:hs2' as UserID, ['9', '10', '11'] as RoomVersion[]); + + await expect(makeJoin).rejects.not.toBeInstanceOf(IncompatibleRoomVersionError); + }); +}); diff --git a/packages/federation-sdk/src/services/profiles.service.ts b/packages/federation-sdk/src/services/profiles.service.ts index 79d3422b3..a56d85919 100644 --- a/packages/federation-sdk/src/services/profiles.service.ts +++ b/packages/federation-sdk/src/services/profiles.service.ts @@ -1,4 +1,4 @@ -import { UnsupportedRoomVersionError } from '@rocket.chat/federation-core'; +import { IncompatibleRoomVersionError } from '@rocket.chat/federation-core'; import { EventID, extractDomainFromId, Pdu, PduForType, RoomID, RoomVersion, UserID } from '@rocket.chat/federation-room'; import { delay, inject, singleton } from 'tsyringe'; @@ -82,8 +82,12 @@ export class ProfilesService { const { stateService } = this; const roomVersion = await stateService.getRoomVersion(roomId); + // SPEC: make_join answers a version the asking server cannot handle with + // M_INCOMPATIBLE_ROOM_VERSION and the room's actual version, so it knows what it would need if (!versions.includes(roomVersion)) { - throw new UnsupportedRoomVersionError(`Unsupported room version: ${roomVersion}`); + throw new IncompatibleRoomVersionError('Your homeserver does not support the features required to join this room', { + roomVersion, + }); } if (!(await this.stateService.getLatestRoomState2(roomId)).isUserInvited(userId)) { From c83f37c1038686976f9dde72598a27c97a012e41 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 16:38:28 -0300 Subject: [PATCH 21/25] test: overall test improvements --- packages/core/src/events/m.reaction.spec.ts | 5 ++++- packages/core/src/events/m.room.redaction.spec.ts | 4 +++- packages/core/src/utils/checkSignAndHashes.spec.ts | 6 ++---- .../src/services/room-version-coexistence.spec.ts | 7 ++++++- .../src/services/state.service.spec.ts | 14 +++++++------- .../federation-sdk/src/utils/event-schemas.spec.ts | 7 +++++-- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/core/src/events/m.reaction.spec.ts b/packages/core/src/events/m.reaction.spec.ts index 036245f7d..a586ca22a 100644 --- a/packages/core/src/events/m.reaction.spec.ts +++ b/packages/core/src/events/m.reaction.spec.ts @@ -56,5 +56,8 @@ test('reactionEvent', async () => { const reactionEventId = PersistentEventFactory.createFromRawEvent(signedReaction as any, '10').eventId; expect(signedReaction).toMatchObject(finalEvent); - expect(reactionEventId).toBe(reactionEventId); + + // pinned from this implementation rather than captured from Synapse: it guards against + // unintended changes to the id computation, it does not prove the id is spec correct + expect(reactionEventId).toBe('$M01iR1pDCIvC7MqCClelXqsEvFK6iO_pA2d1aJc6Oh4'); }); diff --git a/packages/core/src/events/m.room.redaction.spec.ts b/packages/core/src/events/m.room.redaction.spec.ts index 224343edb..982928b5c 100644 --- a/packages/core/src/events/m.room.redaction.spec.ts +++ b/packages/core/src/events/m.room.redaction.spec.ts @@ -87,5 +87,7 @@ test('redactionEvent', async () => { expect(signedRedaction.content.reason).toBe('Inappropriate content'); expect(signedRedaction.room_id).toBe('!MZyyuzkUwHEaBBOXai:hs1'); expect(signedRedaction.sender).toBe('@user:rc1'); - expect(redactionEventId).toBeDefined(); + // pinned from this implementation rather than captured from Synapse: it guards against + // unintended changes to the id computation, it does not prove the id is spec correct + expect(redactionEventId).toBe('$eXeXxVHTKQn0Q52ro1_abGTKsRtLD2h1b-ZtyzeVNXA'); }); diff --git a/packages/core/src/utils/checkSignAndHashes.spec.ts b/packages/core/src/utils/checkSignAndHashes.spec.ts index 36c78c848..00d7c861f 100644 --- a/packages/core/src/utils/checkSignAndHashes.spec.ts +++ b/packages/core/src/utils/checkSignAndHashes.spec.ts @@ -92,13 +92,11 @@ describe('checkSignAndHashes', () => { // v11 keeps the whole m.room.create content await checkSignAndHashes(createPdu, mockOrigin, getPublicKeyFromServerMock, '11'); - expect(verifyJsonSpy.mock.calls[0][0]).toMatchObject({ - content: { 'room_version': '11', 'm.federate': false }, - }); + expect(verifyJsonSpy.mock.calls[0][0].content).toEqual({ 'room_version': '11', 'm.federate': false }); // before v11 only creator survives, so the same event redacts to an empty content await checkSignAndHashes(createPdu, mockOrigin, getPublicKeyFromServerMock, '10'); - expect(verifyJsonSpy.mock.calls[1][0]).toMatchObject({ content: {} }); + expect(verifyJsonSpy.mock.calls[1][0].content).toEqual({}); getSignaturesSpy.mockRestore(); verifyJsonSpy.mockRestore(); diff --git a/packages/federation-sdk/src/services/room-version-coexistence.spec.ts b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts index 8dd930239..62bfa2d3a 100644 --- a/packages/federation-sdk/src/services/room-version-coexistence.spec.ts +++ b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'bun:test'; +import { afterAll, beforeEach, describe, expect, it } from 'bun:test'; import { type EventStore } from '@rocket.chat/federation-core'; import type { EventID, Pdu, PduCreateEventContent, RoomVersion, UserID } from '@rocket.chat/federation-room'; @@ -40,6 +40,11 @@ describe('room version coexistence', async () => { await Promise.all([eventCollection.deleteMany({}), stateGraphCollection.deleteMany({})]); }); + // this suite owns its connection instead of the container's, so it has to close it + afterAll(async () => { + await database.disconnect(); + }); + const stateService = new StateService( new StateGraphRepository(stateGraphCollection), new EventRepository(eventCollection), diff --git a/packages/federation-sdk/src/services/state.service.spec.ts b/packages/federation-sdk/src/services/state.service.spec.ts index ee9f719ce..908c535f2 100644 --- a/packages/federation-sdk/src/services/state.service.spec.ts +++ b/packages/federation-sdk/src/services/state.service.spec.ts @@ -843,7 +843,7 @@ describe('StateService', async () => { }, roomCreateEvent.getContent().room_version, ); - expect(stateService.handlePdu(roomNameEvent3)).rejects.toThrow(); + await expect(stateService.handlePdu(roomNameEvent3)).rejects.toThrow(); const state8 = await stateService.getLatestRoomState(roomId); expect(state8.size).toBe(9); // same as before, bob was banned can't change name compareStates(state7, state8); @@ -873,7 +873,7 @@ describe('StateService', async () => { }); it('01 should return the create event wrapper for room id', async () => { - expect(stateService.getCreateEvent('abcd' as RoomID)).rejects.toThrowError(UnknownRoomError); + await expect(stateService.getCreateEvent('abcd' as RoomID)).rejects.toThrowError(UnknownRoomError); const { roomCreateEvent } = await createRoom('public'); @@ -892,7 +892,7 @@ describe('StateService', async () => { expect(roomVersion).toBe(roomCreateEvent.getContent().room_version as RoomVersion); - expect(stateService.getRoomVersion('roomId' as RoomID)).rejects.toThrowError(UnknownRoomError); + await expect(stateService.getRoomVersion('roomId' as RoomID)).rejects.toThrowError(UnknownRoomError); }); it('03 should find the correct state at an event', async () => { @@ -986,7 +986,7 @@ describe('StateService', async () => { roomCreateEvent.getContent().room_version, ); - expect(stateService.handlePdu(membershipEvent)).rejects.toThrow(); + await expect(stateService.handlePdu(membershipEvent)).rejects.toThrow(); expect(membershipEvent.rejected).toBeTrue(); expect(membershipEvent.rejectCode).toBe(RejectCodes.AuthError); @@ -1030,7 +1030,7 @@ describe('StateService', async () => { roomCreateEvent.getContent().room_version, ); - expect(stateService.handlePdu(membershipEventJoin2)).rejects.toThrow(); + await expect(stateService.handlePdu(membershipEventJoin2)).rejects.toThrow(); expect(membershipEventJoin2.rejected).toBeTrue(); expect(membershipEventJoin2.rejectCode).toBe(RejectCodes.AuthError); }); @@ -1074,7 +1074,7 @@ describe('StateService', async () => { // biome-ignore lint/complexity/noForEach: authEventsForBobBan.forEach((e) => bobLeaveEvent.authedBy(e)); - expect(stateService.handlePdu(bobLeaveEvent)).rejects.toThrow(); + await expect(stateService.handlePdu(bobLeaveEvent)).rejects.toThrow(); expect(bobLeaveEvent.rejected).toBeTrue(); expect(bobLeaveEvent.rejectCode).toBe(RejectCodes.AuthError); }); @@ -1664,7 +1664,7 @@ describe('StateService', async () => { // a new event that roomName.addPrevEvents([bobJoin, donBan]); - expect(stateService.handlePdu(roomName)).rejects.toThrowError(); + await expect(stateService.handlePdu(roomName)).rejects.toThrowError(); const state2 = await stateService.getStateAtEvent(roomName); // must not be new name diff --git a/packages/federation-sdk/src/utils/event-schemas.spec.ts b/packages/federation-sdk/src/utils/event-schemas.spec.ts index 39c6869eb..4e6878831 100644 --- a/packages/federation-sdk/src/utils/event-schemas.spec.ts +++ b/packages/federation-sdk/src/utils/event-schemas.spec.ts @@ -53,8 +53,11 @@ describe('event schemas', () => { }); it('resolves a schema for every supported room version', () => { - for (const roomVersion of ['3', '4', '5', '6', '7', '8', '9', '10']) { - expect(validate({ ...createEvent, content: { room_version: roomVersion, creator: '@admin:hs1' } }, roomVersion)).toBe(true); + for (const roomVersion of PersistentEventFactory.supportedRoomVersions) { + // creator is required before v11 and dropped from v11 on + const content = Number(roomVersion) < 11 ? { room_version: roomVersion, creator: '@admin:hs1' } : { room_version: roomVersion }; + + expect(validate({ ...createEvent, content }, roomVersion)).toBe(true); } }); From 63749de27ba6b4ac04fffc0e4258adcd3101b8b4 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 11 Aug 2026 18:37:55 -0300 Subject: [PATCH 22/25] test: improve tests --- .../src/services/profiles.service.spec.ts | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/federation-sdk/src/services/profiles.service.spec.ts b/packages/federation-sdk/src/services/profiles.service.spec.ts index 1aec85609..d43e6a05c 100644 --- a/packages/federation-sdk/src/services/profiles.service.spec.ts +++ b/packages/federation-sdk/src/services/profiles.service.spec.ts @@ -1,32 +1,50 @@ import { describe, expect, it } from 'bun:test'; import { IncompatibleRoomVersionError } from '@rocket.chat/federation-core'; -import type { RoomID, RoomVersion, UserID } from '@rocket.chat/federation-room'; +import type { PduForType, RoomID, RoomVersion, UserID } from '@rocket.chat/federation-room'; import { ProfilesService } from './profiles.service'; import type { StateService } from './state.service'; +const roomId = '!room:hs1' as RoomID; +const userId = '@alice:hs2' as UserID; + function buildService(roomVersion: RoomVersion) { + const membershipEvent = { + type: 'm.room.member', + room_id: roomId, + sender: userId, + state_key: userId, + content: { membership: 'join' }, + } as unknown as PduForType<'m.room.member'>; + + // captured so the template's room version can be asserted, not just its shape + const built: { roomVersion?: RoomVersion } = {}; + const stateService = { async getRoomVersion() { return roomVersion; }, + async getLatestRoomState2() { + return { isUserInvited: () => true }; + }, + async buildEvent(_event: unknown, version: RoomVersion) { + built.roomVersion = version; + return { event: membershipEvent }; + }, } as unknown as StateService; - return new ProfilesService(null as never, stateService, null as never); + return { service: new ProfilesService(null as never, stateService, null as never), membershipEvent, built }; } describe('makeJoin', () => { it('reports the room version the asking server is missing support for', async () => { - const service = buildService('11'); + const { service } = buildService('11'); // the asking server advertised only up to v10, the room is v11 - const makeJoin = service.makeJoin('!room:hs1' as RoomID, '@alice:hs2' as UserID, ['10'] as RoomVersion[]); - - await expect(makeJoin).rejects.toBeInstanceOf(IncompatibleRoomVersionError); - - const error = await makeJoin.catch((e: IncompatibleRoomVersionError) => e); + const error = await service.makeJoin(roomId, userId, ['10'] as RoomVersion[]).catch((e: IncompatibleRoomVersionError) => e); + expect(error).toBeInstanceOf(IncompatibleRoomVersionError); expect(error.errcode).toBe('M_INCOMPATIBLE_ROOM_VERSION'); expect(error.status).toBe(400); @@ -38,12 +56,15 @@ describe('makeJoin', () => { }); }); - it('does not reject a version the asking server advertised', async () => { - const service = buildService('10'); + it('builds a join template at the room version when the asking server supports it', async () => { + const { service, membershipEvent, built } = buildService('10'); - // gets past the version check and fails later, on state we did not stub - const makeJoin = service.makeJoin('!room:hs1' as RoomID, '@alice:hs2' as UserID, ['9', '10', '11'] as RoomVersion[]); + await expect(service.makeJoin(roomId, userId, ['9', '10', '11'] as RoomVersion[])).resolves.toEqual({ + room_version: '10', + event: membershipEvent, + }); - await expect(makeJoin).rejects.not.toBeInstanceOf(IncompatibleRoomVersionError); + // the template has to be built at the room's version, not at the default + expect(built.roomVersion).toBe('10'); }); }); From b34a00b2bfed54719f61294a8731f2839cccf706 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 13 Aug 2026 11:36:57 -0300 Subject: [PATCH 23/25] assign room version to variable --- packages/federation-sdk/src/services/room.service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index b68e6eab7..d0c1433a3 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -473,6 +473,8 @@ export class RoomService { throw new HttpException('Room has no history, cannot update power levels', HttpStatus.BAD_REQUEST); } + const roomVersion = await this.stateService.getRoomVersion(roomId); + const event = await this.stateService.buildEvent<'m.room.power_levels'>( { type: 'm.room.power_levels', @@ -491,7 +493,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: senderId, }, - await this.stateService.getRoomVersion(roomId), + roomVersion, ); await this.stateService.handlePdu(event); @@ -1116,6 +1118,8 @@ export class RoomService { const authEventsArray = Object.values(authEventsMap).filter((event) => event !== undefined) as EventID[]; + const roomVersion = await this.stateService.getRoomVersion(roomId); + const event = await this.stateService.buildEvent<'m.room.tombstone'>( { room_id: roomId, @@ -1133,7 +1137,7 @@ export class RoomService { signatures: {}, type: 'm.room.tombstone', }, - await this.stateService.getRoomVersion(roomId), + roomVersion, ); const _stateId = await this.stateService.handlePdu(event); From e84544081d34b13f696af305c47cad51c63d7a55 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Thu, 13 Aug 2026 11:41:36 -0300 Subject: [PATCH 24/25] ci: add missing test files to ci --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baf892f55..a2ccc341a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,12 @@ jobs: - run: bun lint - run: bun tsc --noEmit - uses: supercharge/mongodb-github-action@1.12.1 - - run: RUN_MONGO_TESTS=1 bun test packages/federation-sdk/src/services/state.service.spec.ts packages/federation-sdk/src/services/room.service.spec.ts + - run: | + RUN_MONGO_TESTS=1 bun test \ + packages/federation-sdk/src/services/invite.service.spec.ts \ + packages/federation-sdk/src/services/room-version-coexistence.spec.ts \ + packages/federation-sdk/src/services/room.service.spec.ts \ + packages/federation-sdk/src/services/state.service.spec.ts - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: From e9fd2f3367bd1b205a4366eefb9fc9819325c0bb Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 21 Aug 2026 15:16:13 -0300 Subject: [PATCH 25/25] dont drop any field from make_join response --- packages/federation-sdk/src/services/room.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index d0c1433a3..27bb36eae 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -733,9 +733,7 @@ export class RoomService { // after receiving the join event we need to populate with local user profile const profile = await this.profilesService.queryProfile(userId); - // origin is not a PDU field, drop whatever the resident server put in the template - // instead of signing it back - const { origin: _origin, ...template } = makeJoinResponse.event as PduForType<'m.room.member'> & { origin?: string }; + const { event: template } = makeJoinResponse; template.content = { ...template.content,