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: 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/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/core/src/events/m.reaction.spec.ts b/packages/core/src/events/m.reaction.spec.ts index dbb0aa782..a586ca22a 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,8 +53,11 @@ 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); + + // 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.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..982928b5c 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,12 +80,14 @@ 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'); 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/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 deleted file mode 100644 index 7ebfa1437..000000000 --- a/packages/core/src/events/utils/createSignedEvent.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 { createSignedEvent } from './createSignedEvent'; - -describe('makeSignedEvent', () => { - test('it should return the same payload, following create event > sign > generate id', async () => { - const signature = await generateKeyPairsFromString('ed25519 a_XRhW YjbSyfqQeGto+OFswt+XwtJUUooHXH5w+czSgawN63U'); - - const event = roomCreateEvent({ - roomId: '!uTqsSSWabZzthsSCNf:hs1', - sender: '@admin:hs1', - ts: 1733069433734, - }); - const signed = await signEvent(event, signature, 'hs1'); - const id = generateId(signed); - - const makeSignedEvent = createSignedEvent(signature, 'hs1'); - const result = await createRoomCreateEvent(makeSignedEvent)({ - roomId: '!uTqsSSWabZzthsSCNf:hs1', - sender: '@admin:hs1', - ts: 1733069433734, - }); - - expect({ - event: signed, - _id: id, - // @ts-expect-error --- IGNORE --- - }).toStrictEqual(result); - }); -}); diff --git a/packages/core/src/events/utils/createSignedEvent.ts b/packages/core/src/events/utils/createSignedEvent.ts deleted file mode 100644 index 710e5d7fb..000000000 --- a/packages/core/src/events/utils/createSignedEvent.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SigningKey, SignedEvent } from '../../types'; -import { generateId } from '../../utils/generateId'; -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>>; - }; - }; -}; - -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..2af0df73c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,8 +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 { authorizationHeaders, @@ -60,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/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/checkSignAndHashes.spec.ts b/packages/core/src/utils/checkSignAndHashes.spec.ts index b7121281c..00d7c861f 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,34 @@ 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].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].content).toEqual({}); + + 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 +110,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 +130,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 +150,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/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/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/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'; 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/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-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..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, 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, 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,8 +59,10 @@ export class EventFetcherService { ); const federationEvents = await this.fetchEventsFromFederation(missingEventIds, originServer); + const roomVersion = await this.stateService.getRoomVersion(roomId); + const federationEventsWithIds = federationEvents.map((e) => ({ - eventId: generateId(e), + eventId: PersistentEventFactory.createFromRawEvent(e, roomVersion).eventId, event: e, })); 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-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.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); + }); +}); diff --git a/packages/federation-sdk/src/services/event.service.ts b/packages/federation-sdk/src/services/event.service.ts index 513efeb3f..61d81a929 100644 --- a/packages/federation-sdk/src/services/event.service.ts +++ b/packages/federation-sdk/src/services/event.service.ts @@ -5,18 +5,9 @@ import type { RoomPowerLevelsEvent, ReceiptEDU, TypingEDU, - RedactionEvent, EventStore, } from '@rocket.chat/federation-core'; -import { - isPresenceEDU, - isReceiptEDU, - isTypingEDU, - generateId, - pruneEventDict, - checkSignAndHashes, - createLogger, -} from '@rocket.chat/federation-core'; +import { isPresenceEDU, isReceiptEDU, isTypingEDU, checkSignAndHashes, createLogger } from '@rocket.chat/federation-core'; import { type EventID, type Pdu, @@ -29,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'; @@ -39,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; @@ -72,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); @@ -152,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); @@ -215,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'); @@ -229,7 +230,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) { @@ -256,9 +257,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 { @@ -433,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}`); } } @@ -449,24 +452,6 @@ export class EventService { return parts.length > 1 ? parts[1] : ''; } - private async getRoomVersion(event: Pick) { - 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); } @@ -549,10 +534,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.stateService.getRoomVersion(redactionEvent.room_id); + + const eventIdToRedact = PersistentEventFactory.createFromRawEvent(redactionEvent, roomVersion).getRedacts(); if (!eventIdToRedact) { - this.logger.error(`[REDACTION] Event is missing 'redacts' field: ${generateId(redactionEvent)}`); + this.logger.error(`[REDACTION] Event is missing its redaction target in room ${redactionEvent.room_id}`); return; } @@ -562,42 +549,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..bcb1855a2 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'; @@ -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,15 +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', - content: { - reason: 'Unsetting reaction', - }, - redacts: eventIdReactedTo, + ...PersistentEventFactory.newRedactionEventFields(eventIdReactedTo, { reason: 'Unsetting reaction' }, roomVersion), room_id: roomId, auth_events: [], depth: 0, @@ -281,7 +273,7 @@ export class MessageService { origin_server_ts: Date.now(), sender: senderUserId, }, - roomInfo.room_version, + roomVersion, ); await this.stateService.handlePdu(redactionEvent); @@ -298,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); @@ -343,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) { @@ -353,10 +340,7 @@ 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}` }, roomVersion), room_id: roomId, auth_events: [], depth: 0, @@ -364,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.spec.ts b/packages/federation-sdk/src/services/profiles.service.spec.ts new file mode 100644 index 000000000..d43e6a05c --- /dev/null +++ b/packages/federation-sdk/src/services/profiles.service.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test'; + +import { IncompatibleRoomVersionError } from '@rocket.chat/federation-core'; +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 { 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'); + + // the asking server advertised only up to v10, the room is v11 + 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); + + // 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('builds a join template at the room version when the asking server supports it', async () => { + const { service, membershipEvent, built } = buildService('10'); + + await expect(service.makeJoin(roomId, userId, ['9', '10', '11'] as RoomVersion[])).resolves.toEqual({ + room_version: '10', + event: membershipEvent, + }); + + // the template has to be built at the room's version, not at the default + expect(built.roomVersion).toBe('10'); + }); +}); diff --git a/packages/federation-sdk/src/services/profiles.service.ts b/packages/federation-sdk/src/services/profiles.service.ts index b436a91bf..a56d85919 100644 --- a/packages/federation-sdk/src/services/profiles.service.ts +++ b/packages/federation-sdk/src/services/profiles.service.ts @@ -1,3 +1,4 @@ +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'; @@ -75,16 +76,18 @@ 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; - const roomInformation = await stateService.getRoomInformation(roomId); - - const roomVersion = roomInformation.room_version; + 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 Error(`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)) { @@ -105,15 +108,12 @@ export class ProfilesService { origin_server_ts: Date.now(), sender: userId, }, - roomInformation.room_version, + roomVersion, ); return { room_version: roomVersion, - event: { - ...membershipEvent.event, - origin: this.configService.serverName, - }, + event: membershipEvent.event, }; } 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..62bfa2d3a --- /dev/null +++ b/packages/federation-sdk/src/services/room-version-coexistence.spec.ts @@ -0,0 +1,266 @@ +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'; +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'; +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({})]); + }); + + // 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), + { 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 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 () => { + 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')); + + // 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 372ba85f7..d0c1433a3 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, @@ -38,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'; @@ -226,7 +225,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(creatorMembershipEvent); @@ -243,7 +242,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(roomNameEvent); @@ -275,7 +274,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(powerLevelEvent); @@ -292,7 +291,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(joinRuleEvent); @@ -312,7 +311,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: username, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(canonicalAliasEvent); @@ -474,45 +473,27 @@ 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 roomVersion = await this.stateService.getRoomVersion(roomId); 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, + roomVersion, ); await this.stateService.handlePdu(event); @@ -528,10 +509,10 @@ 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); + const roomVersion = await this.stateService.getRoomVersion(roomId); const leaveEvent = await this.stateService.buildEvent<'m.room.member'>( { type: 'm.room.member', @@ -544,23 +525,17 @@ export class RoomService { origin_server_ts: Date.now(), sender: senderId, }, - roomInfo.room_version, + roomVersion, ); return { - event: { - ...leaveEvent.event, - origin: this.configService.serverName, - }, - room_version: roomInfo.room_version, + event: leaveEvent.event, + 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) { @@ -616,8 +591,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' }); @@ -628,23 +601,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); @@ -660,8 +630,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' }); @@ -673,23 +641,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); @@ -741,7 +706,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: userId, }, - PersistentEventFactory.defaultRoomVersion, + createEvent.version, ); await stateService.handlePdu(membershipEvent); @@ -760,29 +725,27 @@ 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); - 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); @@ -929,7 +892,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); @@ -947,31 +914,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); @@ -1156,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, @@ -1173,7 +1137,7 @@ export class RoomService { signatures: {}, type: 'm.room.tombstone', }, - PersistentEventFactory.defaultRoomVersion, + roomVersion, ); const _stateId = await this.stateService.handlePdu(event); @@ -1480,7 +1444,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(creatorMembershipEvent); @@ -1510,7 +1474,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(powerLevelsEvent); @@ -1527,7 +1491,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(joinRulesEvent); @@ -1544,7 +1508,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(historyVisibilityEvent); @@ -1561,7 +1525,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(guestAccessEvent); @@ -1598,7 +1562,7 @@ export class RoomService { origin_server_ts: Date.now(), sender: creatorUserId, }, - PersistentEventFactory.defaultRoomVersion, + roomCreateEvent.version, ); await stateService.handlePdu(targetMembershipEvent); diff --git a/packages/federation-sdk/src/services/state.service.spec.ts b/packages/federation-sdk/src/services/state.service.spec.ts index c07accc97..908c535f2 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'; @@ -756,7 +757,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 +807,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', @@ -842,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); @@ -871,15 +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 () => { + await expect(stateService.getCreateEvent('abcd' as RoomID)).rejects.toThrowError(UnknownRoomError); const { roomCreateEvent } = await createRoom('public'); - expect(stateService.getRoomInformation(roomCreateEvent.roomId)).resolves.toHaveProperty( - 'creator', - roomCreateEvent.getContent().creator, - ); + 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 () => { @@ -889,7 +892,7 @@ describe('StateService', async () => { expect(roomVersion).toBe(roomCreateEvent.getContent().room_version as RoomVersion); - expect(stateService.getRoomVersion('roomId')).rejects.toThrowError(); + await expect(stateService.getRoomVersion('roomId' as RoomID)).rejects.toThrowError(UnknownRoomError); }); it('03 should find the correct state at an event', async () => { @@ -983,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); @@ -993,7 +996,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 +1014,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'); @@ -1027,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); }); @@ -1039,7 +1042,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'); @@ -1071,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); }); @@ -1213,7 +1216,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 +1257,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 +1304,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 +1386,7 @@ describe('StateService', async () => { // const newPowerLevelEvent = PersistentEventFactory.newPowerLevelEvent( // roomCreateEvent.roomId, - // roomCreateEvent.getContent().creator, + // roomCreateEvent.getCreator(), // powerLevelContent, // PersistentEventFactory.defaultRoomVersion, // ); @@ -1480,7 +1483,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 +1541,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 +1606,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); @@ -1661,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/services/state.service.ts b/packages/federation-sdk/src/services/state.service.ts index 6afde6a0a..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,29 +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'); + // 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); } - if (!stateId) { - throw new Error('Create event has no state id, something is very wrong'); + const { room_version: roomVersion } = createEvent.event.content; + if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { + throw new UnsupportedRoomVersionError(`Room ${roomId} is at unsupported version ${roomVersion}`); } - return event.content; + 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 @@ -240,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), @@ -269,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/federation-sdk/src/utils/event-schemas.spec.ts b/packages/federation-sdk/src/utils/event-schemas.spec.ts new file mode 100644 index 000000000..4e6878831 --- /dev/null +++ b/packages/federation-sdk/src/utils/event-schemas.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'bun:test'; + +import { PersistentEventFactory } from '@rocket.chat/federation-room'; + +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'; +const otherTarget = '$AAAnUd9WTPTQGbdPgfOPea8bOEQ21qPvbcGqeOApQxA'; + +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); + }); + + // 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 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); + } + }); + + 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 019bb257d..260334101 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,6 +18,8 @@ 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(''), @@ -26,11 +28,16 @@ const createEventSchema = baseEventSchema.extend({ room_version: z.string(), 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, @@ -97,14 +104,19 @@ const joinRulesEventSchema = baseEventSchema.extend({ 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())), + content: z.object({ reason: z.string().optional() }).and(anyContent), +}); + +// 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), }); -const roomV10Schemas = { +const roomV10Schemas: Record = { 'm.room.create': createEventSchema, 'm.room.member': memberEventSchema, 'm.room.message': messageEventSchema, @@ -115,21 +127,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; 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', ); diff --git a/packages/room/src/authorizartion-rules/rules.spec.ts b/packages/room/src/authorizartion-rules/rules.spec.ts index 119944476..072851186 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,49 @@ 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 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() + .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..141cfe45b 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[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 7328dd937..01c8cff31 100644 --- a/packages/room/src/manager/event-wrapper.ts +++ b/packages/room/src/manager/event-wrapper.ts @@ -6,8 +6,16 @@ import { PowerLevelEvent } from './power-level-event-wrapper'; 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 } from '../types/_common'; -import type { Pdu, PduContent, PduType, Signature, PduJoinRuleEventContent, PduMembershipEventContent } from '../types/v3-11'; +import type { EventID, PduForType, StateMapKey, UserID } from '../types/_common'; +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 { @@ -231,6 +245,28 @@ export abstract class 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; + newRedactionEventFields(redacts: EventID, content: PduRoomRedactionContent): RedactionEventFields; +}; + // The idea is to ALWAYS use this to create different events export class PersistentEventFactory { static supportedRoomVersions = [ @@ -38,17 +49,14 @@ export class PersistentEventFactory { '11', ]; - static defaultRoomVersion = '10' as const; // same as synapse + static defaultRoomVersion = '11' as const; - static isSupportedRoomVersion(roomVersion: string): roomVersion is RoomVersion { + static isSupportedRoomVersion(roomVersion: string): roomVersion is RoomVersion3To11 { 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 +65,41 @@ 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}`); } } - // create individual events - - // a m.room.create event, adds the roomId too - static newCreateEvent(creator: UserID, roomVersion: RoomVersion = PersistentEventFactory.defaultRoomVersion) { + static createFromRawEvent( + event: PduWithHashesAndSignaturesOptional, + roomVersion: string, + partial = false, + ): PersistentEventBase { if (!PersistentEventFactory.isSupportedRoomVersion(roomVersion)) { throw new Error(`Room version ${roomVersion} is not supported`); } - const createContent: PduCreateEventContent = { - room_version: roomVersion, - creator, - }; + const EventClass = PersistentEventFactory.getEventClass(roomVersion); + + return new EventClass(event, roomVersion, partial) as PersistentEventBase; + } + + // create individual events + + // a m.room.create event, adds the roomId too + static newCreateEvent(creator: UserID, roomVersion: RoomVersion = PersistentEventFactory.defaultRoomVersion) { + const createContent = PersistentEventFactory.getEventClass(roomVersion).newCreateEventContent(creator, roomVersion); const domain = creator.split(':').pop(); @@ -105,6 +120,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/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..99054f6e6 100644 --- a/packages/room/src/manager/v11.ts +++ b/packages/room/src/manager/v11.ts @@ -1,8 +1,29 @@ 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 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 + static newCreateEventContent(_creator: UserID, roomVersion: RoomVersion): PduCreateEventContent { + 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 00ddf0604..cc533f538 100644 --- a/packages/room/src/manager/v3.ts +++ b/packages/room/src/manager/v3.ts @@ -1,15 +1,23 @@ 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 { REDACT_ALLOW_ALL_KEYS, RedactionEventFields } from './event-wrapper'; +import type { RoomVersion, RoomVersion3To11 } from './type'; +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 { private _eventId?: EventID; + static newCreateEventContent(creator: UserID, roomVersion: RoomVersion): PduCreateEventContent { + return { room_version: roomVersion, creator }; + } + + static newRedactionEventFields(redacts: EventID, content: PduRoomRedactionContent): RedactionEventFields { + return { redacts, content }; + } + get eventId(): EventID { if (this._eventId) { return this._eventId; @@ -42,6 +50,14 @@ export class PersistentEventV3 extends Persisten ]; } + protected resolveCreator(): UserID | undefined { + 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 1cec0ff8c..f41c2a40a 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.') @@ -162,6 +163,8 @@ export type PduRoomTopicEventContent = z.infer; @@ -564,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({