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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/federation-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export {
type BaseEventType,
} from './utils/event-schemas';
export { errCodes } from './utils/response-codes';
export { NotAllowedError } from './services/invite.service';

export { EventRepository } from './repositories/event.repository';
export { RoomRepository } from './repositories/room.repository';
Expand Down Expand Up @@ -163,6 +164,10 @@ export type HomeserverEventSignatures = {
last_active_ago?: number;
origin?: string;
};
'homeserver.matrix.encryption': {
event_id: EventID;
event: PduForType<'m.room.encryption'>;
};
'homeserver.matrix.encrypted': {
event_id: EventID;
event: PduForType<'m.room.encrypted'>;
Expand Down
12 changes: 12 additions & 0 deletions packages/federation-sdk/src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export interface AppConfig {
downloadPerMinute: number;
};
};
invite: {
allowedEncryptedRooms: boolean;
allowedNonPrivateRooms: boolean;
};
Comment on lines +34 to +37
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify test coverage for the new invite configuration.

The implementation follows existing patterns and is correct. However, the codecov report indicates that this file has 50% patch coverage with 4 missing lines.

Please ensure that the new invite configuration block and the getInviteConfig() method have adequate test coverage. You can run the following script to check which lines are missing coverage:

Also applies to: 76-79, 124-126


🏁 Script executed:

#!/bin/bash
# Description: Check test coverage for config.service.ts

# Find and display test files for config.service
fd -e spec.ts -e test.ts config.service

# Check if the invite config is tested
rg -nP "getInviteConfig|invite.*config" --type=ts -g '*test*' -g '*spec*'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for config.service usages and config getters in test files
rg -n "config.service" -g "*spec.ts" -g "*test.ts"
rg -n "getDatabaseConfig" -g "*spec.ts" -g "*test.ts"
rg -n "getMediaConfig" -g "*spec.ts" -g "*test.ts"
rg -n "getInviteConfig" -g "*spec.ts" -g "*test.ts"

Length of output: 347


Add unit tests for the new invite configuration

No tests currently cover getInviteConfig() or the invite block in config.service.ts; please add spec tests to exercise those code paths and restore full coverage.

🤖 Prompt for AI Agents
In packages/federation-sdk/src/services/config.service.ts around lines 34 to 37,
there are no unit tests covering getInviteConfig() or the invite configuration
block; add Jest spec tests that import ConfigService (or the module exposing
getInviteConfig), mock the underlying configuration/source to supply variations,
and assert the returned invite object shape and values. Create tests for:
default behavior when invite is missing, explicit true/false values for
allowedEncryptedRooms and allowedNonPrivateRooms, and any edge/invalid inputs
the service tolerates; use beforeEach to set/restore config mocks and expect
exact booleans to restore coverage for these code paths.

}

export const AppConfigSchema = z.object({
Expand Down Expand Up @@ -69,6 +73,10 @@ export const AppConfigSchema = z.object({
.min(1, 'Download rate limit must be at least 1'),
}),
}),
invite: z.object({
allowedEncryptedRooms: z.boolean(),
allowedNonPrivateRooms: z.boolean(),
}),
});

export class ConfigService {
Expand Down Expand Up @@ -113,6 +121,10 @@ export class ConfigService {
return this.config.media;
}

getInviteConfig(): AppConfig['invite'] {
return this.config.invite;
}

async getSigningKey() {
// If config contains a signing key, use it
if (!this.config.signingKey) {
Expand Down
44 changes: 44 additions & 0 deletions packages/federation-sdk/src/services/invite.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export type ProcessInviteEvent = {
room_version: string;
};

export class NotAllowedError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotAllowedError';
}
}

@singleton()
export class InviteService {
private readonly logger = createLogger('InviteService');
Expand Down Expand Up @@ -142,6 +149,42 @@ export class InviteService {
};
}

private async shouldProcessInvite(
event: PduForType<'m.room.member'>,
): Promise<void> {
const isRoomNonPrivate = event.unsigned.invite_room_state.some(
(
stateEvent: PersistentEventBase<
RoomVersion,
'm.room.join_rules'
>['event'],
) =>
stateEvent.type === 'm.room.join_rules' &&
stateEvent.content.join_rule === 'public',
);

const isRoomEncrypted = event.unsigned.invite_room_state.some(
(
stateEvent: PersistentEventBase<
RoomVersion,
'm.room.encryption'
>['event'],
) => stateEvent.type === 'm.room.encryption',
);

const { allowedEncryptedRooms, allowedNonPrivateRooms } =
this.configService.getInviteConfig();

const shouldRejectInvite =
(!allowedEncryptedRooms && isRoomEncrypted) ||
(!allowedNonPrivateRooms && isRoomNonPrivate);
if (shouldRejectInvite) {
throw new NotAllowedError(
`Could not process invite due to room being ${isRoomEncrypted ? 'encrypted' : 'public'}`,
);
}
}

async processInvite(
event: PduForType<'m.room.member'>,
roomId: RoomID,
Expand All @@ -150,6 +193,7 @@ export class InviteService {
authenticatedServer: string,
) {
// SPEC: when a user invites another user on a different homeserver, a request to that homeserver to have the event signed and verified must be made
await this.shouldProcessInvite(event);

const residentServer = roomId.split(':').pop();
if (!residentServer) {
Expand Down
2 changes: 1 addition & 1 deletion packages/federation-sdk/src/services/room.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,7 @@ export class RoomService {

// trying to join room from another server
const makeJoinResponse = await federationService.makeJoin(
residentServer as string,
residentServer,
roomId,
userId,
roomVersion, // NOTE: check the comment in the called method
Expand Down
6 changes: 6 additions & 0 deletions packages/federation-sdk/src/services/staging-area.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ export class StagingAreaService {
},
});
break;
case event.event.type === 'm.room.encryption':
this.eventEmitterService.emit('homeserver.matrix.encryption', {
event_id: eventId,
event: event.event,
});
break;
case event.event.type === 'm.room.encrypted':
this.eventEmitterService.emit('homeserver.matrix.encrypted', {
event_id: eventId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,56 @@ import { EventID, RoomID } from '@rocket.chat/federation-room';
import {
EventAuthorizationService,
InviteService,
NotAllowedError,
} from '@rocket.chat/federation-sdk';
import { isAuthenticatedMiddleware } from '@rocket.chat/homeserver/middlewares/isAuthenticated';
import { Elysia, t } from 'elysia';
import { container } from 'tsyringe';
import { ProcessInviteParamsDto, RoomVersionDto } from '../../dtos';
import {
FederationErrorResponseDto,
ProcessInviteParamsDto,
ProcessInviteResponseDto,
RoomVersionDto,
} from '../../dtos';

export const invitePlugin = (app: Elysia) => {
const inviteService = container.resolve(InviteService);
const eventAuthService = container.resolve(EventAuthorizationService);

return app.use(isAuthenticatedMiddleware(eventAuthService)).put(
'/_matrix/federation/v2/invite/:roomId/:eventId',
async ({ body, params: { roomId, eventId }, authenticatedServer }) => {
async ({ body, set, params: { roomId, eventId }, authenticatedServer }) => {
if (!authenticatedServer) {
throw new Error('Missing authenticated server from request');
}

return inviteService.processInvite(
body.event,
roomId as RoomID,
eventId as EventID,
body.room_version,
authenticatedServer,
);
try {
return await inviteService.processInvite(
body.event,
roomId as RoomID,
eventId as EventID,
body.room_version,
authenticatedServer,
);
} catch (error) {
if (error instanceof NotAllowedError) {
set.status = 403;
return {
errcode: 'M_FORBIDDEN',
error:
'This server does not allow joining this type of room based on federation settings.',
};
}

set.status = 500;
return {
errcode: 'M_UNKNOWN',
error:
error instanceof Error
? error.message
: 'Internal server error while processing request',
};
}
},
{
params: ProcessInviteParamsDto,
Expand All @@ -34,6 +60,11 @@ export const invitePlugin = (app: Elysia) => {
room_version: RoomVersionDto,
invite_room_state: t.Any(),
}),
response: {
200: ProcessInviteResponseDto,
403: FederationErrorResponseDto,
500: FederationErrorResponseDto,
},
detail: {
tags: ['Federation'],
summary: 'Process room invite',
Expand Down
15 changes: 15 additions & 0 deletions packages/homeserver/src/dtos/federation/error.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type Static, t } from 'elysia';

export const FederationErrorResponseDto = t.Object({
errcode: t.Enum({
M_UNRECOGNIZED: 'M_UNRECOGNIZED',
M_UNAUTHORIZED: 'M_UNAUTHORIZED',
M_FORBIDDEN: 'M_FORBIDDEN',
M_UNKNOWN: 'M_UNKNOWN',
}),
error: t.String(),
});

export type FederationErrorResponseDto = Static<
typeof FederationErrorResponseDto
>;
1 change: 1 addition & 0 deletions packages/homeserver/src/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export * from './federation/state-ids.dto';
export * from './federation/state.dto';
export * from './federation/transactions.dto';
export * from './federation/versions.dto';
export * from './federation/error.dto';

// Internal DTOs
export * from './internal/invite.dto';
Expand Down
6 changes: 6 additions & 0 deletions packages/homeserver/src/homeserver.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ export async function setup(options?: HomeserverSetupOptions) {
),
},
},
invite: {
allowedEncryptedRooms:
process.env.INVITE_ALLOWED_ENCRYPTED_ROOMS === 'true',
allowedNonPrivateRooms:
process.env.INVITE_ALLOWED_NON_PRIVATE_ROOMS === 'true',
},
});

const containerOptions: FederationContainerOptions = {
Expand Down
15 changes: 15 additions & 0 deletions packages/room/src/types/v3-11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,13 @@ const EncryptedContentSchema = BaseTimelineContentSchema.extend({
.optional(),
});

export const PduEncryptionEventContentSchema = z.object({
algorithm: z
.enum(['m.megolm.v1.aes-sha2'])
.describe('The algorithm used to encrypt the content.'),
ciphertext: z.string().describe('The encrypted content.'),
});

export type PduMessageEventContent = z.infer<
typeof PduMessageEventContentSchema
>;
Expand Down Expand Up @@ -706,6 +713,12 @@ const EventPduTypeRoomEncrypted = z.object({
content: EncryptedContentSchema,
});

const EventPduTypeRoomEncryption = z.object({
...PduNoContentEmptyStateKeyStateEventSchema,
type: z.literal('m.room.encryption'),
content: PduEncryptionEventContentSchema,
});

const EventPduTypeRoomMessage = z.object({
...PduNoContentTimelineEventSchema,
type: z.literal('m.room.message'),
Expand Down Expand Up @@ -749,6 +762,8 @@ export const PduStateEventSchema = z.discriminatedUnion('type', [
EventPduTypeRoomServerAcl,

EventPduTypeRoomTombstone,

EventPduTypeRoomEncryption,
]);

export const PduTimelineSchema = z.discriminatedUnion('type', [
Expand Down