diff --git a/packages/federation-sdk/src/index.ts b/packages/federation-sdk/src/index.ts index b5607b0ad..a2c7c4084 100644 --- a/packages/federation-sdk/src/index.ts +++ b/packages/federation-sdk/src/index.ts @@ -155,6 +155,20 @@ export type HomeserverEventSignatures = { reason?: string; }; }; + 'homeserver.matrix.leave': { + event_id: string; + room_id: string; + user_id: string; + origin_server_ts: number; + }; + 'homeserver.matrix.kick': { + event_id: string; + room_id: string; + kicked_user_id: string; + kicked_by: string; + reason?: string; + origin_server_ts: number; + }; }; export function getAllServices(): HomeserverServices { diff --git a/packages/federation-sdk/src/services/room.service.ts b/packages/federation-sdk/src/services/room.service.ts index 853284a41..ef21c371b 100644 --- a/packages/federation-sdk/src/services/room.service.ts +++ b/packages/federation-sdk/src/services/room.service.ts @@ -1,42 +1,38 @@ import { EventBase, + RoomNameAuthEvents, + RoomPowerLevelsEvent, + RoomTombstoneEvent, + SignedEvent, + TombstoneAuthEvents, generateId, isRoomPowerLevelsEvent, roomMemberEvent, - RoomNameAuthEvents, roomNameEvent, roomPowerLevelsEvent, - RoomPowerLevelsEvent, roomTombstoneEvent, - RoomTombstoneEvent, - SignedEvent, signEvent, - TombstoneAuthEvents, } from '@hs/core'; -import { FederationService } from './federation.service'; import { inject, singleton } from 'tsyringe'; +import { FederationService } from './federation.service'; import { ForbiddenError, HttpException, HttpStatus } from '@hs/core'; import { type SigningKey } from '@hs/core'; -import type { - EventStore, - EventBaseWithOptionalId as ModelEventBase, -} from '@hs/core'; +import type { EventStore } from '@hs/core'; import { logger } from '@hs/core'; -import { ConfigService } from './config.service'; -import { EventService } from './event.service'; -import { EventType } from './event.service'; -import type { RoomRepository } from '../repositories/room.repository'; -import { StateService } from './state.service'; -import { EventRepository } from '../repositories/event.repository'; import { PduCreateEventContent, PduJoinRuleEventContent, PersistentEventBase, PersistentEventFactory, - RoomVersion, } from '@hs/room'; +import { EventRepository } from '../repositories/event.repository'; +import type { RoomRepository } from '../repositories/room.repository'; +import { ConfigService } from './config.service'; +import { EventService } from './event.service'; +import { EventType } from './event.service'; +import { StateService } from './state.service'; @singleton() export class RoomService { @@ -579,28 +575,18 @@ export class RoomService { return eventId; } - async leaveRoom( - roomId: string, - senderId: string, - targetServers: string[] = [], - ): Promise { + async leaveRoom(roomId: string, senderId: string): Promise { logger.info(`User ${senderId} leaving room ${roomId}`); - const lastEvent = await this.eventService.getLastEventForRoom(roomId); - if (!lastEvent) { - throw new HttpException( - 'Room has no history, cannot leave', - HttpStatus.BAD_REQUEST, - ); - } + // Get room information needed for the membership event + const roomInformation = await this.stateService.getRoomInformation(roomId); + // Check if user has permission to leave (send m.room.member events) const authEventIds = await this.eventService.getAuthEventIds( EventType.MEMBER, { roomId, senderId }, ); - // For a leave event, the user must have permission to send m.room.member events. - // This is typically covered by them being a member, but power levels might restrict it. const powerLevelsEventId = authEventIds.find( (e) => e.type === EventType.POWER_LEVELS, )?._id; @@ -630,79 +616,39 @@ export class RoomService { ); } - const createEventId = authEventIds.find( - (e) => e.type === EventType.CREATE, - )?._id; - const memberEventId = authEventIds.find( - (e) => e.type === EventType.MEMBER && e.state_key === senderId, - )?._id; - - if (!createEventId || !memberEventId) { - logger.error( - `Critical auth events missing for leave. Create: ${createEventId}, Member: ${memberEventId}`, - ); - throw new HttpException( - 'Critical auth events missing, cannot leave room', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - const authEvents = { - 'm.room.create': createEventId, - 'm.room.power_levels': powerLevelsEventId, - [`m.room.member:${senderId}`]: memberEventId, - }; - - const serverName = this.configService.getServerConfig().name; - const signingKeyConfig = await this.configService.getSigningKey(); - const signingKey: SigningKey = Array.isArray(signingKeyConfig) - ? signingKeyConfig[0] - : signingKeyConfig; - - const unsignedEvent = roomMemberEvent({ + // Create the leave event using PersistentEventFactory + const leaveEvent = PersistentEventFactory.newMembershipEvent( roomId, - sender: senderId, - state_key: senderId, - auth_events: authEvents, - prev_events: [lastEvent._id], - depth: lastEvent.event.depth + 1, - membership: 'leave', - origin: serverName, - content: { - membership: 'leave', - }, - }); - - const signedEvent = await signEvent(unsignedEvent, signingKey, serverName); - const eventId = generateId(signedEvent); + senderId, + senderId, // state_key is the same as sender for leave + 'leave', + roomInformation, + ); - // After leaving, update local room membership state if necessary (e.g., remove from active members list) - // This might be handled by whatever consumes these events, or could be an explicit step here. - // For now, we assume event persistence is the primary concern of this service method. + // Add auth and prev events + await this.stateService.addAuthEvents(leaveEvent); + await this.stateService.addPrevEvents(leaveEvent); - for (const server of targetServers) { - if (server === serverName) { - continue; - } + // Sign the event + await this.stateService.signEvent(leaveEvent); - try { - await this.federationService.sendEvent(server, signedEvent); - logger.info( - `Successfully sent m.room.member (leave) event ${eventId} over federation to ${server} for room ${roomId}`, - ); - } catch (error) { - logger.error( - `Failed to send m.room.member (leave) event ${eventId} over federation to ${server}: ${error instanceof Error ? error.message : String(error)}`, - ); - } + // Persist as state event (membership events are state events) + await this.stateService.persistStateEvent(leaveEvent); + if (leaveEvent.rejected) { + throw new HttpException( + leaveEvent.rejectedReason || 'Leave event was rejected', + HttpStatus.BAD_REQUEST, + ); } - await this.eventService.insertEvent(signedEvent, eventId); + // Send to other servers + await this.federationService.sendEventToAllServersInRoom(leaveEvent); + logger.info( - `Successfully created and stored m.room.member (leave) event ${eventId} for user ${senderId} in room ${roomId}`, + `Successfully created and stored m.room.member (leave) event ${leaveEvent.eventId} for user ${senderId} in room ${roomId}`, ); - return eventId; + return leaveEvent.eventId; } async kickUser( @@ -710,23 +656,15 @@ export class RoomService { kickedUserId: string, senderId: string, reason?: string, - targetServers: string[] = [], ): Promise { logger.info( `User ${senderId} kicking user ${kickedUserId} from room ${roomId}. Reason: ${reason || 'No reason specified'}`, ); - // TODO: Check if both sender and kicked user are members of the room - // This will be easier when we have a room state cache - - const lastEvent = await this.eventService.getLastEventForRoom(roomId); - if (!lastEvent) { - throw new HttpException( - 'Room has no history, cannot kick user', - HttpStatus.BAD_REQUEST, - ); - } + // Get room information needed for the membership event + const roomInformation = await this.stateService.getRoomInformation(roomId); + // Check kick permissions const authEventIdsForPowerLevels = await this.eventService.getAuthEventIds( EventType.POWER_LEVELS, { roomId, senderId }, @@ -764,78 +702,44 @@ export class RoomService { kickedUserId, ); - const authEventIdsForMemberEvent = await this.eventService.getAuthEventIds( - EventType.MEMBER, - { roomId, senderId }, + // Create the kick event using PersistentEventFactory + const kickEvent = PersistentEventFactory.newMembershipEvent( + roomId, + senderId, + kickedUserId, // state_key is the kicked user + 'leave', + roomInformation, ); - const createEventId = authEventIdsForMemberEvent.find( - (e) => e.type === EventType.CREATE, - )?._id; - const senderMemberEventId = authEventIdsForMemberEvent.find( - (e) => e.type === EventType.MEMBER && e.state_key === senderId, - )?._id; - if (!createEventId || !senderMemberEventId || !powerLevelsEventId) { - logger.error( - `Critical auth events missing for kick. Create: ${createEventId}, Sender's Member: ${senderMemberEventId}, PowerLevels: ${powerLevelsEventId}`, - ); - throw new HttpException( - 'Critical auth events missing, cannot kick user', - HttpStatus.INTERNAL_SERVER_ERROR, - ); + // Add reason to the event content if provided + if (reason) { + (kickEvent.event.content as any).reason = reason; } - const authEvents = { - 'm.room.create': createEventId, - 'm.room.power_levels': powerLevelsEventId, - [`m.room.member:${kickedUserId}`]: senderMemberEventId, - }; + // Add auth and prev events + await this.stateService.addAuthEvents(kickEvent); + await this.stateService.addPrevEvents(kickEvent); - const serverName = this.configService.getServerConfig().name; - const signingKeyConfig = await this.configService.getSigningKey(); - const signingKey: SigningKey = Array.isArray(signingKeyConfig) - ? signingKeyConfig[0] - : signingKeyConfig; + // Sign the event + await this.stateService.signEvent(kickEvent); - const unsignedEvent = roomMemberEvent({ - roomId, - sender: senderId, - state_key: kickedUserId, - auth_events: authEvents, - prev_events: [lastEvent._id], - depth: lastEvent.event.depth + 1, - membership: 'leave', - origin: serverName, - content: { - membership: 'leave', - ...(reason ? { reason } : {}), - }, - }); + // Persist as state event (membership events are state events) + await this.stateService.persistStateEvent(kickEvent); + if (kickEvent.rejected) { + throw new HttpException( + kickEvent.rejectedReason || 'Kick event was rejected', + HttpStatus.BAD_REQUEST, + ); + } - const signedEvent = await signEvent(unsignedEvent, signingKey, serverName); - const eventId = generateId(signedEvent); + // Send to other servers + await this.federationService.sendEventToAllServersInRoom(kickEvent); - await this.eventService.insertEvent(signedEvent, eventId); logger.info( - `Successfully created and stored m.room.member (kick) event ${eventId} for user ${kickedUserId} in room ${roomId}`, + `Successfully created and stored m.room.member (kick) event ${kickEvent.eventId} for user ${kickedUserId} in room ${roomId}`, ); - for (const server of targetServers) { - if (server === serverName) { - continue; - } - try { - await this.federationService.sendEvent(server, signedEvent); - logger.info( - `Successfully sent m.room.member (kick) event ${eventId} over federation to ${server} for room ${roomId}`, - ); - } catch (error) { - logger.error( - `Failed to send m.room.member (kick) event ${eventId} over federation to ${server}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - return eventId; + return kickEvent.eventId; } async banUser( diff --git a/packages/federation-sdk/src/services/staging-area.service.ts b/packages/federation-sdk/src/services/staging-area.service.ts index 83c5898ec..86dd50586 100644 --- a/packages/federation-sdk/src/services/staging-area.service.ts +++ b/packages/federation-sdk/src/services/staging-area.service.ts @@ -396,6 +396,35 @@ export class StagingAreaService { }); break; } + case EventType.MEMBER: { + const membership = event.event.content?.membership as string; + const stateKey = (event.event as any).state_key; + const sender = event.event.sender; + + if (membership === 'leave') { + if (sender === stateKey) { + // User left voluntarily + this.eventEmitterService.emit('homeserver.matrix.leave', { + event_id: event.eventId, + room_id: event.roomId, + user_id: stateKey, + origin_server_ts: event.event.origin_server_ts, + }); + } else { + // User was kicked + this.eventEmitterService.emit('homeserver.matrix.kick', { + event_id: event.eventId, + room_id: event.roomId, + kicked_user_id: stateKey, + kicked_by: sender, + reason: event.event.content?.reason as string | undefined, + origin_server_ts: event.event.origin_server_ts, + }); + } + } + // Note: We can handle 'join', 'invite', 'ban' etc. here in the future + break; + } default: this.logger.warn( `Unknown event type: ${event.event.type} for emitterService for now`, diff --git a/packages/homeserver/src/controllers/internal/room.controller.ts b/packages/homeserver/src/controllers/internal/room.controller.ts index f6d7c5586..68705d43c 100644 --- a/packages/homeserver/src/controllers/internal/room.controller.ts +++ b/packages/homeserver/src/controllers/internal/room.controller.ts @@ -1,40 +1,40 @@ +import { + type ErrorResponse, + ErrorResponseDto, + RoomIdDto, + UsernameDto, +} from '@hs/federation-sdk'; +import { RoomService } from '@hs/federation-sdk'; +import { StateService } from '@hs/federation-sdk'; +import { InviteService } from '@hs/federation-sdk'; +import { type PduCreateEventContent, PersistentEventFactory } from '@hs/room'; import { Elysia, t } from 'elysia'; import { container } from 'tsyringe'; import { - type InternalBanUserResponse, - type InternalCreateRoomResponse, - type InternalKickUserResponse, - type InternalLeaveRoomResponse, - type InternalTombstoneRoomResponse, - type InternalUpdateRoomNameResponse, - type InternalUpdateUserPowerLevelResponse, InternalBanUserBodyDto, InternalBanUserParamsDto, + type InternalBanUserResponse, InternalCreateRoomBodyDto, + type InternalCreateRoomResponse, InternalCreateRoomResponseDto, InternalKickUserBodyDto, InternalKickUserParamsDto, + type InternalKickUserResponse, InternalLeaveRoomBodyDto, InternalLeaveRoomParamsDto, + type InternalLeaveRoomResponse, InternalRoomEventResponseDto, InternalTombstoneRoomBodyDto, InternalTombstoneRoomParamsDto, + type InternalTombstoneRoomResponse, InternalTombstoneRoomResponseDto, InternalUpdateRoomNameBodyDto, InternalUpdateRoomNameParamsDto, + type InternalUpdateRoomNameResponse, InternalUpdateUserPowerLevelBodyDto, InternalUpdateUserPowerLevelParamsDto, + type InternalUpdateUserPowerLevelResponse, } from '../../dtos'; -import { - type ErrorResponse, - ErrorResponseDto, - RoomIdDto, - UsernameDto, -} from '@hs/federation-sdk'; -import { RoomService } from '@hs/federation-sdk'; -import { type PduCreateEventContent, PersistentEventFactory } from '@hs/room'; -import { StateService } from '@hs/federation-sdk'; -import { InviteService } from '@hs/federation-sdk'; export const internalRoomPlugin = (app: Elysia) => { const roomService = container.resolve(RoomService); @@ -236,12 +236,11 @@ export const internalRoomPlugin = (app: Elysia) => { }, }; } - const { senderUserId, targetServers } = bodyParse.data; + const { senderUserId } = bodyParse.data; try { const eventId = await roomService.leaveRoom( roomIdParse.data, senderUserId, - targetServers, ); return { eventId }; } catch (error) { @@ -291,15 +290,13 @@ export const internalRoomPlugin = (app: Elysia) => { }, }; } - const { /*userIdToKick, */ senderUserId, reason, targetServers } = - bodyParse.data; + const { /*userIdToKick, */ senderUserId, reason } = bodyParse.data; try { const eventId = await roomService.kickUser( params.roomId, params.memberId, senderUserId, reason, - targetServers, ); return { eventId }; } catch (error) { diff --git a/packages/homeserver/src/dtos/internal/room.dto.ts b/packages/homeserver/src/dtos/internal/room.dto.ts index 94670eb9b..2894e8e30 100644 --- a/packages/homeserver/src/dtos/internal/room.dto.ts +++ b/packages/homeserver/src/dtos/internal/room.dto.ts @@ -1,5 +1,5 @@ -import { type Static, t } from 'elysia'; import { RoomIdDto, ServerNameDto, UsernameDto } from '@hs/federation-sdk'; +import { type Static, t } from 'elysia'; export const InternalCreateRoomBodyDto = t.Object({ username: t.String({ @@ -63,7 +63,6 @@ export const InternalLeaveRoomParamsDto = t.Object({ export const InternalLeaveRoomBodyDto = t.Object({ senderUserId: UsernameDto, - targetServers: t.Optional(t.Array(ServerNameDto)), }); export const InternalKickUserParamsDto = t.Object({ @@ -75,7 +74,6 @@ export const InternalKickUserBodyDto = t.Object({ userIdToKick: UsernameDto, senderUserId: UsernameDto, reason: t.Optional(t.String({ description: 'Reason for kicking' })), - targetServers: t.Optional(t.Array(ServerNameDto)), }); export const InternalBanUserParamsDto = t.Object({