Skip to content
Draft
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
1 change: 1 addition & 0 deletions changelog.d/19314.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimental support for the [MSC4370](https://github.com/matrix-org/matrix-spec-proposals/pull/4370) Federation API `GET /extremities` endpoint.
4 changes: 4 additions & 0 deletions synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#
# Copyright 2021 The Matrix.org Foundation C.I.C.
# Copyright (C) 2023 New Vector, Ltd
# Copyright (C) 2025 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -533,6 +534,9 @@ def read_config(
"msc4108_delegation_endpoint", None
)

# MSC4370: Get extremities federation endpoint
self.msc4370_enabled = experimental.get("msc4370_enabled", False)

auth_delegated = self.msc3861.enabled or (
config.get("matrix_authentication_service") or {}
).get("enabled", False)
Expand Down
11 changes: 11 additions & 0 deletions synapse/federation/federation_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Copyright 2019-2021 Matrix.org Federation C.I.C
# Copyright 2015, 2016 OpenMarket Ltd
# Copyright (C) 2023 New Vector, Ltd
# Copyright (C) 2025 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -683,6 +684,16 @@ async def on_query_request(
resp = await self.registry.on_query(query_type, args)
return 200, resp

async def on_get_extremities_request(self, origin: str, room_id: str) -> JsonDict:
origin_host, _ = parse_server_name(origin)
await self.check_server_matches_acl(origin_host, room_id)

await self._event_auth_handler.assert_host_in_room(room_id, origin)

extremities = await self.store.get_forward_extremities_for_room(room_id)
prev_event_ids = [e[0] for e in extremities]
return {"prev_events": prev_event_ids}

async def on_make_join_request(
self, origin: str, room_id: str, user_id: str, supported_versions: list[str]
) -> dict[str, Any]:
Expand Down
8 changes: 8 additions & 0 deletions synapse/federation/transport/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Copyright 2020 Sorunome
# Copyright 2014-2021 The Matrix.org Foundation C.I.C.
# Copyright (C) 2023 New Vector, Ltd
# Copyright (C) 2025 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -33,6 +34,7 @@
FederationMediaDownloadServlet,
FederationMediaThumbnailServlet,
FederationUnstableClientKeysClaimServlet,
FederationUnstableGetExtremitiesServlet,
)
from synapse.http.server import HttpServer, JsonResource
from synapse.http.servlet import (
Expand Down Expand Up @@ -326,6 +328,12 @@ def register_servlets(
if not hs.config.media.can_load_media_repo:
continue

if (
servletclass == FederationUnstableGetExtremitiesServlet
and not hs.config.experimental.msc4370_enabled
):
continue

servletclass(
hs=hs,
authenticator=authenticator,
Expand Down
20 changes: 19 additions & 1 deletion synapse/federation/transport/server/federation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright 2021 The Matrix.org Foundation C.I.C.
# Copyright 2021 The Matrix.org Foundation C.I.C.
# Copyright (C) 2023 New Vector, Ltd
# Copyright (C) 2025 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
Expand Down Expand Up @@ -273,6 +274,22 @@ async def on_GET(
return await self.handler.on_query_request(query_type, args)


class FederationUnstableGetExtremitiesServlet(BaseFederationServerServlet):
PREFIX = FEDERATION_UNSTABLE_PREFIX + "/org.matrix.msc4370"
PATH = "/extremities/(?P<room_id>[^/]*)"
CATEGORY = "Federation requests"

async def on_GET(
self,
origin: str,
content: Literal[None],
query: dict[bytes, list[bytes]],
room_id: str,
) -> tuple[int, JsonDict]:
result = await self.handler.on_get_extremities_request(origin, room_id)
return 200, result


class FederationMakeJoinServlet(BaseFederationServerServlet):
PATH = "/make_join/(?P<room_id>[^/]*)/(?P<user_id>[^/]*)"
CATEGORY = "Federation requests"
Expand Down Expand Up @@ -884,6 +901,7 @@ async def on_GET(
FederationBackfillServlet,
FederationTimestampLookupServlet,
FederationQueryServlet,
FederationUnstableGetExtremitiesServlet,
FederationMakeJoinServlet,
FederationMakeLeaveServlet,
FederationEventServlet,
Expand Down
142 changes: 142 additions & 0 deletions tests/federation/test_federation_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,148 @@ def test_needs_to_be_in_room(self) -> None:
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")


class UnstableGetExtremitiesTests(unittest.FederatingHomeserverTestCase):
servlets = [
admin.register_servlets,
room.register_servlets,
login.register_servlets,
]

def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
super().prepare(reactor, clock, hs)
self._storage_controllers = hs.get_storage_controllers()

def _make_endpoint_path(self, room_id: str) -> str:
return f"/_matrix/federation/unstable/org.matrix.msc4370/extremities/{room_id}"

def _remote_join(self, room_id: str, room_version: str) -> None:
# Note: other tests ensure the called endpoints in this function return useful
# and proper data.

# make_join first
joining_user = "@misspiggy:" + self.OTHER_SERVER_NAME
channel = self.make_signed_federation_request(
"GET",
f"/_matrix/federation/v1/make_join/{room_id}/{joining_user}?ver={room_version}",
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
join_result = channel.json_body

# Sign/populate the join
join_event_dict = join_result["event"]
self.add_hashes_and_signatures_from_other_server(
join_event_dict,
KNOWN_ROOM_VERSIONS[room_version],
)
if room_version in ["1", "2"]:
add_hashes_and_signatures(
KNOWN_ROOM_VERSIONS[room_version],
join_event_dict,
signature_name=self.hs.hostname,
signing_key=self.hs.signing_key,
)

# Send the join
channel = self.make_signed_federation_request(
"PUT",
f"/_matrix/federation/v2/send_join/{room_id}/x",
content=join_event_dict,
)

# Check that things went okay so the test doesn't become a total train wreck
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
r = self.get_success(self._storage_controllers.state.get_current_state(room_id))
self.assertEqual(r[("m.room.member", joining_user)].membership, "join")

def _test_get_extremities_common(self, room_version: str) -> None:
# Create a room to test with
creator_user_id = self.register_user("kermit", "test")
tok = self.login("kermit", "test")
room_id = self.helper.create_room_as(
room_creator=creator_user_id,
tok=tok,
room_version=room_version,
extra_content={
# Public preset uses `shared` history visibility, but makes joins
# easier in our tests.
# https://spec.matrix.org/v1.16/client-server-api/#post_matrixclientv3createroom
"preset": "public_chat"
},
)

# At this stage we should fail to get the extremities because we're not joined
# and therefore can't see the events (`shared` history visibility).
channel = self.make_signed_federation_request(
"GET", self._make_endpoint_path(room_id)
)
self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body)
self.assertEqual(channel.json_body["error"], "Host not in room.")
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")

# Now join the room and try again
# Note: we're expecting a linear room DAG, so there should be just one extremity
self._remote_join(room_id, room_version)
channel = self.make_signed_federation_request(
"GET", self._make_endpoint_path(room_id)
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
self.assertEqual(
channel.json_body["prev_events"],
[
self.get_success(
self._storage_controllers.main.get_forward_extremities_for_room(
room_id
)
)[0][0]
],
)

# ACL the calling server and try again. This should cause an error getting extremities.
self.helper.send_state(
room_id,
"m.room.server_acl",
{
"allow": ["*"],
"allow_ip_literals": False,
"deny": [self.OTHER_SERVER_NAME],
},
tok=tok,
expect_code=HTTPStatus.OK,
)
channel = self.make_signed_federation_request(
"GET", self._make_endpoint_path(room_id)
)
self.assertEqual(channel.code, HTTPStatus.FORBIDDEN, channel.json_body)
self.assertEqual(channel.json_body["error"], "Server is banned from room")
self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")

@parameterized.expand([(k,) for k in KNOWN_ROOM_VERSIONS.keys()])
@override_config(
{"use_frozen_dicts": True, "experimental_features": {"msc4370_enabled": True}}
)
def test_get_extremities_with_frozen_dicts(self, room_version: str) -> None:
"""Test GET /extremities with USE_FROZEN_DICTS=True"""
self._test_get_extremities_common(room_version)

@parameterized.expand([(k,) for k in KNOWN_ROOM_VERSIONS.keys()])
@override_config(
{"use_frozen_dicts": False, "experimental_features": {"msc4370_enabled": True}}
)
def test_get_extremities_without_frozen_dicts(self, room_version: str) -> None:
"""Test GET /extremities with USE_FROZEN_DICTS=True"""
self._test_get_extremities_common(room_version)

# note the lack of config-setting stuff on this test.
def test_get_extremities_unstable_not_enabled(self) -> None:
"""Test that GET /extremities returns M_UNRECOGNIZED when MSC4370 is not enabled"""
# We shouldn't even have to create a room - the endpoint should just fail.
channel = self.make_signed_federation_request(
"GET", self._make_endpoint_path("!room:example.org")
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND, channel.json_body)
self.assertEqual(channel.json_body["errcode"], "M_UNRECOGNIZED")


class SendJoinFederationTests(unittest.FederatingHomeserverTestCase):
servlets = [
admin.register_servlets,
Expand Down
Loading