diff --git a/.changeset/heavy-lions-attend.md b/.changeset/heavy-lions-attend.md new file mode 100644 index 0000000000000..169f319e5aa8b --- /dev/null +++ b/.changeset/heavy-lions-attend.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/federation-matrix': patch +'@rocket.chat/meteor': patch +--- + +Fixes federation endpoints rejecting requests that are valid per the Matrix specification: + +- `publicRooms` (GET and POST) required params/fields the spec marks optional +- `query/profile` rejected spec-valid profile fields such as `m.tz` +- `get_missing_events` required the optional `limit` field and bounded it +- `make_join` returned 500 instead of 400 `M_INCOMPATIBLE_ROOM_VERSION` for unsupported room versions +- `backfill` rejected spec-valid `limit` values +- `send` rejected an entire transaction when a single PDU didn't match a fixed event shape, instead of reporting failures per PDU + +Also links every federation endpoint to its definition in the Matrix specification. diff --git a/ee/packages/federation-matrix/src/api/.well-known/server.ts b/ee/packages/federation-matrix/src/api/.well-known/server.ts index 262afa5d3249e..d25599029879f 100644 --- a/ee/packages/federation-matrix/src/api/.well-known/server.ts +++ b/ee/packages/federation-matrix/src/api/.well-known/server.ts @@ -20,6 +20,7 @@ const isWellKnownServerResponseProps = ajv.compile(WellKnownServerResponseSchema // TODO: After changing the domain setting this route is still reporting the old domain until the server is restarted // TODO: this is wrong, is siteurl !== domain this path should return 404. this path is to discover the final address, domain being the "proxy" and siteurl the final destination, if domain is different, well-known should be served there, not here. export const getWellKnownRoutes = () => { + // https://spec.matrix.org/v1.19/server-server-api/#getwell-knownmatrixserver return new Router('/matrix').get( '/server', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index b63edfdeefc0a..6aa67c0ec1142 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -130,10 +130,25 @@ const ProcessInviteResponseSchema = { const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); export const getMatrixInviteRoutes = () => { + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2inviteroomideventid return new Router('/federation').put( '/v2/invite/:roomId/:eventId', { - body: ajv.compile({ type: 'object' }), // TODO: add schema from room package. + // TODO: add schema from room package. `event` is a PDU whose format varies by room + // version, so it stays unconstrained here; room_version and event are required per spec. + body: ajv.compile({ + type: 'object', + properties: { + room_version: { type: 'string' }, + event: { type: 'object' }, + invite_room_state: { + type: 'array', + items: { type: 'object' }, + nullable: true, + }, + }, + required: ['room_version', 'event'], + }), params: isProcessInviteParamsProps, response: { 200: isProcessInviteResponseProps, @@ -152,10 +167,11 @@ export const getMatrixInviteRoutes = () => { throw new Error('join event has missing state key, unable to determine user to join'); } + // spec: servers SHOULD return M_INVALID_PARAM if m.room.create is missing from invite_room_state if (!strippedStateEvents?.some((e: any) => e.type === 'm.room.create')) { return { body: { - errcode: 'M_MISSING_PARAM', + errcode: 'M_INVALID_PARAM', error: 'Missing invite_room_state: m.room.create event is required', }, statusCode: 400, diff --git a/ee/packages/federation-matrix/src/api/_matrix/key/server.ts b/ee/packages/federation-matrix/src/api/_matrix/key/server.ts index 89dc236f65606..74d5bfae732c8 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/key/server.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/key/server.ts @@ -33,6 +33,7 @@ const ServerKeyResponseSchema = { const isServerKeyResponseProps = ajv.compile(ServerKeyResponseSchema); export const getKeyServerRoutes = () => { + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixkeyv2server return new Router('/key').get( '/v2/server', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts index 911d2be81e78d..39b8c8a1d17fe 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts @@ -56,6 +56,7 @@ const isMakeLeaveErrorResponseProps = ajv.compile({ }); export const getMatrixMakeLeaveRoutes = () => { + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_leaveroomiduserid return new Router('/federation').get( '/v1/make_leave/:roomId/:userId', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index f9ed40a9ebc2a..5f09f60b4a55f 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -74,76 +74,80 @@ async function getMediaFile(mediaId: string, serverName: string): Promise<{ file } export const getMatrixMediaRoutes = () => { - return new Router('/federation') - .get( - '/v1/media/download/:mediaId', - { - params: isMediaDownloadParamsProps, - response: { - 200: isBufferResponseProps, - 401: isErrorResponseProps, - 403: isErrorResponseProps, - 404: isErrorResponseProps, - 429: isErrorResponseProps, - 500: isErrorResponseProps, + return ( + new Router('/federation') + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediadownloadmediaid + .get( + '/v1/media/download/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 200: isBufferResponseProps, + 401: isErrorResponseProps, + 403: isErrorResponseProps, + 404: isErrorResponseProps, + 429: isErrorResponseProps, + 500: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], }, - tags: ['Federation', 'Media'], - }, - canAccessResourceMiddleware('media'), - async (c) => { - try { - const { mediaId } = c.req.param(); - const serverName = federationSDK.getConfig('serverName'); - - // TODO: Add file streaming support - const result = await getMediaFile(mediaId, serverName); - if (!result) { + canAccessResourceMiddleware('media'), + async (c) => { + try { + const { mediaId } = c.req.param(); + const serverName = federationSDK.getConfig('serverName'); + + // TODO: Add file streaming support + const result = await getMediaFile(mediaId, serverName); + if (!result) { + return { + statusCode: 404, + body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + }; + } + + const { file, buffer } = result; + + const mimeType = file.type || 'application/octet-stream'; + const fileName = file.name || mediaId; + + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': multipartResponse.contentType, + 'content-length': String(multipartResponse.body.length), + }, + body: multipartResponse.body, + }; + } catch (error) { return { - statusCode: 404, - body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, }; } - - const { file, buffer } = result; - - const mimeType = file.type || 'application/octet-stream'; - const fileName = file.name || mediaId; - - const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); - - return { - statusCode: 200, - headers: { - ...SECURITY_HEADERS, - 'content-type': multipartResponse.contentType, - 'content-length': String(multipartResponse.body.length), - }, - body: multipartResponse.body, - }; - } catch (error) { - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; - } - }, - ) - .get( - '/v1/media/thumbnail/:mediaId', - { - params: isMediaDownloadParamsProps, - response: { - 404: isErrorResponseProps, }, - tags: ['Federation', 'Media'], - }, - canAccessResourceMiddleware('media'), - async (_c) => ({ - statusCode: 404, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on the homeserver side', + ) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediathumbnailmediaid + .get( + '/v1/media/thumbnail/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 404: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], }, - }), - ); + canAccessResourceMiddleware('media'), + async (_c) => ({ + statusCode: 404, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on the homeserver side', + }, + }), + ) + ); }; diff --git a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts index c9603c03125cc..705c23dab84b9 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts @@ -33,14 +33,14 @@ const QueryProfileQuerySchema = { properties: { user_id: UsernameSchema, field: { + // open string, not an enum: the spec defines displayname, avatar_url and m.tz, + // and servers MAY allow arbitrary additional profile fields type: 'string', - enum: ['displayname', 'avatar_url'], description: 'Profile field to query', nullable: true, }, }, required: ['user_id'], - additionalProperties: false, }; const isQueryProfileQueryProps = ajvQuery.compile(QueryProfileQuerySchema); @@ -157,20 +157,13 @@ const MakeJoinQuerySchema = { type: 'object', properties: { ver: { - anyOf: [ - { - type: 'string', - description: 'Supported room version', - }, - { - type: 'array', - items: { - type: 'string', - }, - minItems: 0, - description: 'Supported room versions', - }, - ], + // a string branch here would be redundant: ajvQuery coerces a single `?ver=` into a + // one-element array, and in a `oneOf` both branches would match and fail validation + type: 'array', + items: { + type: 'string', + }, + description: 'Room versions supported by the sending server', }, }, }; @@ -261,6 +254,26 @@ const MakeJoinResponseSchema = { const isMakeJoinResponseProps = ajv.compile(MakeJoinResponseSchema); +const MakeJoinIncompatibleVersionResponseSchema = { + type: 'object', + properties: { + errcode: { + type: 'string', + const: 'M_INCOMPATIBLE_ROOM_VERSION', + }, + error: { + type: 'string', + }, + room_version: { + type: 'string', + description: 'The version of the room', + }, + }, + required: ['errcode', 'error', 'room_version'], +}; + +const isMakeJoinIncompatibleVersionResponseProps = ajv.compile(MakeJoinIncompatibleVersionResponseSchema); + const GetMissingEventsParamsSchema = { type: 'object', properties: { @@ -289,13 +302,18 @@ const GetMissingEventsBodySchema = { description: 'Latest events', }, limit: { + // optional per spec (defaults to 10) and unbounded; the handler applies the default and a cap type: 'number', - minimum: 1, - maximum: 100, description: 'Maximum number of events to return', + nullable: true, + }, + min_depth: { + type: 'number', + description: 'Minimum depth of events to retrieve (ignored)', + nullable: true, }, }, - required: ['earliest_events', 'latest_events', 'limit'], + required: ['earliest_events', 'latest_events'], }; const isGetMissingEventsBodyProps = ajv.compile(GetMissingEventsBodySchema); @@ -347,174 +365,203 @@ const EventAuthResponseSchema = { const isEventAuthResponseProps = ajv.compile(EventAuthResponseSchema); export const getMatrixProfilesRoutes = () => { - return new Router('/federation') - .use(isAuthenticatedMiddleware()) - .get( - '/v1/query/profile', - { - query: isQueryProfileQueryProps, - response: { - 200: isQueryProfileResponseProps, + return ( + new Router('/federation') + .use(isAuthenticatedMiddleware()) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1queryprofile + .get( + '/v1/query/profile', + { + query: isQueryProfileQueryProps, + response: { + 200: isQueryProfileResponseProps, + }, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - const { user_id: userId, field } = c.req.query(); - - const response = await federationSDK.queryProfile(userId); + async (c) => { + const { user_id: userId, field } = c.req.query(); + + const response = await federationSDK.queryProfile(userId); + + if (!response) { + return { + body: { + errcode: 'M_NOT_FOUND', + error: `User ${userId} not found`, + }, + statusCode: 404, + }; + } + + if (field) { + return { + body: { + [field]: response[field as 'displayname' | 'avatar_url'] || null, + }, + statusCode: 200, + }; + } - if (!response) { return { body: { - errcode: 'M_NOT_FOUND', - error: `User ${userId} not found`, + displayname: response.displayname, + avatar_url: response.avatar_url, }, - statusCode: 404, + statusCode: 200, }; - } + }, + ) + // https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1userkeysquery + .post( + '/v1/user/keys/query', + { + body: isQueryKeysBodyProps, + response: { + 200: isQueryKeysResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const response = await federationSDK.queryKeys(body.device_keys); - if (field) { + return { + body: response, + statusCode: 200, + }; + }, + ) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1userdevicesuserid + .get( + '/v1/user/devices/:userId', + { + params: isGetDevicesParamsProps, + response: { + 200: isGetDevicesResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { return { body: { - [field]: response[field as 'displayname' | 'avatar_url'] || null, + devices: [], + stream_id: 0, + user_id: c.req.param('userId'), }, statusCode: 200, }; - } - - return { - body: { - displayname: response.displayname, - avatar_url: response.avatar_url, + }, + ) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_joinroomiduserid + .get( + '/v1/make_join/:roomId/:userId', + { + params: isMakeJoinParamsProps, + query: isMakeJoinQueryProps, + response: { + 200: isMakeJoinResponseProps, + 400: isMakeJoinIncompatibleVersionResponseProps, }, - statusCode: 200, - }; - }, - ) - .post( - '/v1/user/keys/query', - { - body: isQueryKeysBodyProps, - response: { - 200: isQueryKeysResponseProps, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - const body = await c.req.json(); - - const response = await federationSDK.queryKeys(body.device_keys); - - return { - body: response, - statusCode: 200, - }; - }, - ) - .get( - '/v1/user/devices/:userId', - { - params: isGetDevicesParamsProps, - response: { - 200: isGetDevicesResponseProps, + canAccessResourceMiddleware('room'), + async (c) => { + const { roomId, userId } = c.req.param(); + const url = new URL(c.req.url); + const verParams = url.searchParams.getAll('ver'); + + try { + const response = await federationSDK.makeJoin( + roomIdSchema.parse(roomId), + userIdSchema.parse(userId), + // spec: "The room versions the sending server has support for. Defaults to [1]." + verParams.length > 0 ? (verParams as RoomVersion[]) : ['1'], + ); + + return { + body: { + room_version: response.room_version, + event: response.event, + }, + statusCode: 200, + }; + } catch (error) { + // the SDK throws when the room's version is not in the requested `ver` list + const incompatibleVersion = error instanceof Error && error.message.match(/^Unsupported room version: (.+)$/); + if (incompatibleVersion) { + return { + body: { + errcode: 'M_INCOMPATIBLE_ROOM_VERSION', + error: 'Your homeserver does not support the features required to join this room', + room_version: incompatibleVersion[1], + }, + statusCode: 400, + }; + } + + throw error; + } }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - return { - body: { - devices: [], - stream_id: 0, - user_id: c.req.param('userId'), + ) + // https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1get_missing_eventsroomid + .post( + '/v1/get_missing_events/:roomId', + { + params: isGetMissingEventsParamsProps, + body: isGetMissingEventsBodyProps, + response: { + 200: isGetMissingEventsResponseProps, }, - statusCode: 200, - }; - }, - ) - .get( - '/v1/make_join/:roomId/:userId', - { - params: isMakeJoinParamsProps, - query: isMakeJoinQueryProps, - response: { - 200: isMakeJoinResponseProps, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - canAccessResourceMiddleware('room'), - async (c) => { - const { roomId, userId } = c.req.param(); - const url = new URL(c.req.url); - const verParams = url.searchParams.getAll('ver'); - - const response = await federationSDK.makeJoin( - roomIdSchema.parse(roomId), - userIdSchema.parse(userId), - verParams.length > 0 ? (verParams as RoomVersion[]) : ['1'], - ); - - return { - body: { - room_version: response.room_version, - event: response.event, - }, - statusCode: 200, - }; - }, - ) - .post( - '/v1/get_missing_events/:roomId', - { - params: isGetMissingEventsParamsProps, - body: isGetMissingEventsBodyProps, - response: { - 200: isGetMissingEventsResponseProps, + canAccessResourceMiddleware('room'), + async (c) => { + const { roomId } = c.req.param(); + const body = await c.req.json(); + + const limit = Math.min(body.limit ?? 10, 100); + + const response = await federationSDK.getMissingEvents( + roomIdSchema.parse(roomId), + body.earliest_events, + body.latest_events, + limit, + ); + + return { + body: response, + statusCode: 200, + }; }, - tags: ['Federation'], - license: ['federation'], - }, - canAccessResourceMiddleware('room'), - async (c) => { - const { roomId } = c.req.param(); - const body = await c.req.json(); - - const response = await federationSDK.getMissingEvents( - roomIdSchema.parse(roomId), - body.earliest_events, - body.latest_events, - body.limit, - ); - - return { - body: response, - statusCode: 200, - }; - }, - ) - .get( - '/v1/event_auth/:roomId/:eventId', - { - params: isEventAuthParamsProps, - response: { - 200: isEventAuthResponseProps, + ) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1event_authroomideventid + .get( + '/v1/event_auth/:roomId/:eventId', + { + params: isEventAuthParamsProps, + response: { + 200: isEventAuthResponseProps, + }, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - canAccessResourceMiddleware('room'), - async (c) => { - const { roomId, eventId } = c.req.param(); + canAccessResourceMiddleware('room'), + async (c) => { + const { roomId, eventId } = c.req.param(); - const response = await federationSDK.eventAuth(roomIdSchema.parse(roomId), eventIdSchema.parse(eventId)); + const response = await federationSDK.eventAuth(roomIdSchema.parse(roomId), eventIdSchema.parse(eventId)); - return { - body: response, - statusCode: 200, - }; - }, - ); + return { + body: response, + statusCode: 200, + }; + }, + ) + ); }; diff --git a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts index f494506b38009..d1bd4abf7f92d 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts @@ -4,19 +4,27 @@ import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; +// All query params are optional per spec. const PublicRoomsQuerySchema = { type: 'object', properties: { + limit: { + type: 'number', + description: 'Maximum number of rooms to return', + }, + since: { + type: 'string', + description: 'Pagination token from a previous call', + }, include_all_networks: { type: 'boolean', description: 'Include all networks (ignored)', }, - limit: { - type: 'number', - description: 'Maximum number of rooms to return', + third_party_instance_id: { + type: 'string', + description: 'Specific third-party network to request (ignored)', }, }, - required: ['include_all_networks', 'limit'], }; const isPublicRoomsQueryProps = ajvQuery.compile(PublicRoomsQuerySchema); @@ -86,17 +94,28 @@ const PublicRoomsResponseSchema = { const isPublicRoomsResponseProps = ajv.compile(PublicRoomsResponseSchema); +// All body fields are optional per spec: "Options for which rooms to return, or empty object to use defaults." const PublicRoomsPostBodySchema = { type: 'object', properties: { - include_all_networks: { + limit: { + type: 'number', + description: 'Maximum number of rooms to return', + nullable: true, + }, + since: { type: 'string', + description: 'Pagination token from a previous request', + nullable: true, + }, + include_all_networks: { + type: 'boolean', description: 'Include all networks (ignored)', nullable: true, }, - limit: { - type: 'number', - description: 'Maximum number of rooms to return', + third_party_instance_id: { + type: 'string', + description: 'Specific third-party network to request (ignored)', nullable: true, }, filter: { @@ -116,93 +135,97 @@ const PublicRoomsPostBodySchema = { nullable: true, }, }, + nullable: true, }, }, - required: ['filter'], }; const isPublicRoomsPostBodyProps = ajv.compile(PublicRoomsPostBodySchema); export const getMatrixRoomsRoutes = () => { - return new Router('/federation') - .use(isAuthenticatedMiddleware()) - .get( - '/v1/publicRooms', - { - query: isPublicRoomsQueryProps, - response: { - 200: isPublicRoomsResponseProps, - }, - tags: ['Federation'], - license: ['federation'], - }, - async () => { - const defaultObj = { - join_rule: 'public', - guest_can_join: false, // trying to reduce required endpoint hits - world_readable: false, // ^^^ - avatar_url: '', // ?? don't have any yet - }; - - const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); - - return { - body: { - chunk: publicRooms.map((room) => ({ - ...defaultObj, - ...room, - })), + return ( + new Router('/federation') + .use(isAuthenticatedMiddleware()) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1publicrooms + .get( + '/v1/publicRooms', + { + query: isPublicRoomsQueryProps, + response: { + 200: isPublicRoomsResponseProps, }, - statusCode: 200, - }; - }, - ) - .post( - '/v1/publicRooms', - { - body: isPublicRoomsPostBodyProps, - response: { - 200: isPublicRoomsResponseProps, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - const body = await c.req.json(); - - const defaultObj = { - join_rule: 'public', - guest_can_join: false, // trying to reduce required endpoint hits - world_readable: false, // ^^^ - avatar_url: '', // ?? don't have any yet - }; - - const { filter } = body; - - const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); - - return { - body: { - chunk: publicRooms - .filter((r) => { - if (filter.generic_search_term) { - return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase()); - } - - // Today only one room type is supported (https://spec.matrix.org/v1.15/client-server-api/#types) - // TODO: https://rocketchat.atlassian.net/browse/FDR-152 -> Implement logic to handle custom room types - // if (filter.room_types) { - // } - - return true; - }) - .map((room) => ({ + async () => { + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce required endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; + + const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); + + return { + body: { + chunk: publicRooms.map((room) => ({ ...defaultObj, ...room, })), + }, + statusCode: 200, + }; + }, + ) + // https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1publicrooms + .post( + '/v1/publicRooms', + { + body: isPublicRoomsPostBodyProps, + response: { + 200: isPublicRoomsResponseProps, }, - statusCode: 200, - }; - }, - ); + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce required endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; + + const { filter } = body; + + const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); + + return { + body: { + chunk: publicRooms + .filter((r) => { + if (filter?.generic_search_term) { + return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase()); + } + + // Today only one room type is supported (https://spec.matrix.org/v1.15/client-server-api/#types) + // TODO: https://rocketchat.atlassian.net/browse/FDR-152 -> Implement logic to handle custom room types + // if (filter.room_types) { + // } + + return true; + }) + .map((room) => ({ + ...defaultObj, + ...room, + })), + }, + statusCode: 200, + }; + }, + ) + ); }; diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-join.ts b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts index f127559ef3c4e..50d069b5409de 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/send-join.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts @@ -117,6 +117,7 @@ const SendJoinResponseSchema = { const isSendJoinResponseProps = ajv.compile(SendJoinResponseSchema); export const getMatrixSendJoinRoutes = () => { + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_joinroomideventid return new Router('/federation').put( '/v2/send_join/:roomId/:stateKey', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts index 72e2c76c55528..9111fb733614e 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts @@ -58,6 +58,7 @@ const isSendLeaveErrorResponseProps = ajv.compile({ }); export const getMatrixSendLeaveRoutes = () => { + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_leaveroomideventid return new Router('/federation').put( '/v2/send_leave/:roomId/:eventId', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts index 3881118cfd87b..6177a24903c71 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts @@ -58,88 +58,6 @@ const GetEventResponseSchema = { const isGetEventResponseProps = ajv.compile(GetEventResponseSchema); -const EventHashSchema = { - type: 'object', - properties: { - sha256: { - type: 'string', - description: 'SHA256 hash of the event', - }, - }, - required: ['sha256'], -}; - -const EventSignatureSchema = { - type: 'object', - description: 'Event signatures by server and key ID', -}; - -const EventBaseSchema = { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Event type', - }, - content: { - type: 'object', - description: 'Event content', - }, - sender: { - type: 'string', - pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', - description: 'Matrix user ID in format @user:server.com', - }, - room_id: { - type: 'string', - pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$', - description: 'Matrix room ID in format !room:server.com', - }, - origin_server_ts: { - type: 'number', - minimum: 0, - description: 'Unix timestamp in milliseconds', - }, - depth: { - type: 'number', - minimum: 0, - description: 'Event depth', - }, - prev_events: { - type: 'array', - items: { - type: 'string', - }, - description: 'Previous events in the room', - }, - auth_events: { - type: 'array', - items: { - type: 'string', - }, - description: 'Authorization events', - }, - origin: { - type: 'string', - description: 'Origin server', - }, - hashes: { - ...EventHashSchema, - nullable: true, - }, - signatures: { - ...EventSignatureSchema, - nullable: true, - }, - unsigned: { - type: 'object', - description: 'Unsigned data', - nullable: true, - }, - }, - required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events'], -}; - const SendTransactionBodySchema = { type: 'object', properties: { @@ -154,7 +72,12 @@ const SendTransactionBodySchema = { }, pdus: { type: 'array', - items: EventBaseSchema, + items: { + // deliberately unconstrained, matching the spec: the PDU format varies by room + // version, and a malformed PDU must be reported per-PDU in the 200 response's + // `pdus` map instead of failing the whole transaction with a 400 + type: 'object', + }, description: 'Persistent data units (PDUs) to process', default: [], }, @@ -194,14 +117,14 @@ const isSendTransactionResponseProps = ajv.compile(SendTransactionResponseSchema const ErrorResponseSchema = { type: 'object', properties: { - error: { + errcode: { type: 'string', }, - details: { - type: 'object', + error: { + type: 'string', }, }, - required: ['error', 'details'], + required: ['errcode', 'error'], }; const isErrorResponseProps = ajv.compile(ErrorResponseSchema); @@ -221,13 +144,22 @@ const isGetStateIdsParamsProps = ajv.compile(GetStateIdsParamsSchema); const GetStateIdsResponseSchema = { type: 'object', properties: { - stateIds: { + auth_chain_ids: { type: 'array', items: { type: 'string', }, + description: 'Auth chain event IDs, recursively', + }, + pdu_ids: { + type: 'array', + items: { + type: 'string', + }, + description: 'Event IDs of the fully resolved room state at the given event', }, }, + required: ['auth_chain_ids', 'pdu_ids'], }; const isGetStateIdsResponseProps = ajv.compile(GetStateIdsResponseSchema); @@ -246,10 +178,22 @@ const isGetStateParamsProps = ajv.compile<{ const GetStateResponseSchema = { type: 'object', properties: { - state: { - type: 'object', + auth_chain: { + type: 'array', + items: { + type: 'object', + }, + description: 'Auth chain events, recursively', + }, + pdus: { + type: 'array', + items: { + type: 'object', + }, + description: 'The fully resolved room state at the given event', }, }, + required: ['auth_chain', 'pdus'], }; const isGetStateResponseProps = ajv.compile(GetStateResponseSchema); @@ -273,9 +217,8 @@ const BackfillQuerySchema = { type: 'object', properties: { limit: { + // unbounded per spec; the handler caps it type: 'number', - minimum: 1, - maximum: 100, description: 'Maximum number of events to retrieve', }, v: { @@ -287,7 +230,6 @@ const BackfillQuerySchema = { }, }, required: ['limit', 'v'], - additionalProperties: false, }; const isBackfillQueryProps = ajvQuery.compile<{ @@ -309,7 +251,10 @@ const BackfillResponseSchema = { }, pdus: { type: 'array', - items: EventBaseSchema, + items: { + // spec: backfill responses "MUST NOT be validated" against PDU restrictions + type: 'object', + }, description: 'Events in reverse chronological order', }, }, @@ -319,10 +264,10 @@ const BackfillResponseSchema = { const isBackfillResponseProps = ajv.compile(BackfillResponseSchema); export const getMatrixTransactionsRoutes = () => { - // PUT /_matrix/federation/v1/send/{txnId} return ( new Router('/federation') .use(isAuthenticatedMiddleware()) + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv1sendtxnid .put( '/v1/send/:txnId', { @@ -331,6 +276,7 @@ export const getMatrixTransactionsRoutes = () => { response: { 200: isSendTransactionResponseProps, 400: isErrorResponseProps, + 429: isErrorResponseProps, }, tags: ['Federation'], license: ['federation'], @@ -346,7 +292,7 @@ export const getMatrixTransactionsRoutes = () => { return { statusCode: 429, body: { - errorcode: 'M_UNKNOWN', + errcode: 'M_UNKNOWN', error: 'Too many concurrent transactions', }, }; @@ -354,7 +300,10 @@ export const getMatrixTransactionsRoutes = () => { return { statusCode: 400, - body: {}, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to process transaction', + }, }; } @@ -368,7 +317,7 @@ export const getMatrixTransactionsRoutes = () => { }, ) - // GET /_matrix/federation/v1/state_ids/{roomId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1state_idsroomid .get( '/v1/state_ids/:roomId', { @@ -400,6 +349,7 @@ export const getMatrixTransactionsRoutes = () => { }; }, ) + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1stateroomid .get( '/v1/state/:roomId', { @@ -429,7 +379,7 @@ export const getMatrixTransactionsRoutes = () => { }; }, ) - // GET /_matrix/federation/v1/event/{eventId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1eventeventid .get( '/v1/event/:eventId', { @@ -463,7 +413,7 @@ export const getMatrixTransactionsRoutes = () => { }; }, ) - // GET /_matrix/federation/v1/backfill/{roomId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1backfillroomid .get( '/v1/backfill/:roomId', { @@ -478,7 +428,7 @@ export const getMatrixTransactionsRoutes = () => { canAccessResourceMiddleware('room'), async (c) => { const roomId = c.req.param('roomId'); - const limit = Number(c.req.query('limit') || 100); + const limit = Math.min(Number(c.req.query('limit') || 100), 100); const eventIds = c.req.queries('v'); if (!eventIds?.length) { return { diff --git a/ee/packages/federation-matrix/src/api/_matrix/versions.ts b/ee/packages/federation-matrix/src/api/_matrix/versions.ts index f24fd15c3847f..58dd6b9044cf0 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/versions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/versions.ts @@ -25,6 +25,7 @@ const GetVersionsResponseSchema = { const isGetVersionsResponseProps = ajv.compile(GetVersionsResponseSchema); export const getFederationVersionsRoutes = (version: string) => { + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1version return new Router('/federation').get( '/v1/version', {