From a209c6757f9af0ecbd87f185cbe4fc35bdf9b8e0 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 10:37:52 +0100 Subject: [PATCH] Return an out-of-band membership event from /context instead of a 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An out-of-band membership event (e.g. an invite or knock received over federation) is an outlier with no stream position or state group, so computing surrounding events or state for it fails with 'No state group for unknown or outlier event'. Visibility already permits the user to see their own out-of-band membership events, and clients legitimately fetch them — e.g. to render a push notification for an invite after the server has already joined the user (auto-accepted knock). Return the event with an empty context instead of erroring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG --- synapse/handlers/room.py | 19 +++++++++++ tests/handlers/test_federation.py | 53 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index c75fff9170..d3fad7611d 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -1975,6 +1975,25 @@ class RoomContextHandler: if not filtered: raise AuthError(403, "You don't have permission to access that event.") + if event.internal_metadata.outlier: + # The only outliers that pass the visibility filter are the user's own + # out-of-band membership events (e.g. an invite or knock received over + # federation). They have no stream position or state group, so we cannot + # compute surrounding events or state — return the event with an empty + # context rather than failing. Clients legitimately fetch such events, + # e.g. to render a push notification for an invite after the room has + # already been joined. + token = await StreamToken.START.to_string(self.store) + return EventContext( + events_before=[], + event=filtered[0], + events_after=[], + state=[], + aggregations={}, + start=token, + end=token, + ) + results = await self.store.get_events_around( room_id, event_id, before_limit, after_limit, event_filter ) diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py index 0c7edbaa2d..cca341873e 100644 --- a/tests/handlers/test_federation.py +++ b/tests/handlers/test_federation.py @@ -359,6 +359,59 @@ class FederationTestCase(unittest.FederatingHomeserverTestCase): exc=LimitExceededError, ) + def test_context_on_out_of_band_invite(self) -> None: + """/context on the user's own out-of-band invite returns the event with an + empty context instead of a 500: the outlier has no stream position or state + group, so surrounding events and state cannot be computed. Clients fetch such + events e.g. to render a push notification for an invite after the server has + already joined the user (auto-accepted knock).""" + other_server = "otherserver" + other_user = "@otheruser:" + other_server + + user_id = self.register_user("kermit", "test") + tok = self.login("kermit", "test") + room_id = self.helper.create_room_as(room_creator=user_id, tok=tok) + room_version = self.get_success(self.store.get_room_version(room_id)) + + invited_user = self.register_user("invitee", "test") + invited_tok = self.login("invitee", "test") + + invite = make_test_pdu_event( + { + "type": EventTypes.Member, + "content": {"membership": "invite"}, + "room_id": room_id, + "sender": other_user, + "state_key": invited_user, + "depth": 32, + "prev_events": [], + "auth_events": [], + "origin_server_ts": self.clock.time_msec(), + }, + room_version, + ) + self.get_success( + self.handler.on_invite_request(other_server, invite, invite.room_version) + ) + + channel = self.make_request( + "GET", + f"/rooms/{room_id}/context/{invite.event_id}", + access_token=invited_tok, + ) + self.assertEqual(channel.code, 200, channel.result) + self.assertEqual(channel.json_body["event"]["event_id"], invite.event_id) + self.assertEqual(channel.json_body["events_before"], []) + self.assertEqual(channel.json_body["events_after"], []) + + # Another user must NOT be able to fetch it. + channel = self.make_request( + "GET", + f"/rooms/{room_id}/context/{invite.event_id}", + access_token=tok, + ) + self.assertEqual(channel.code, 403, channel.result) + def _build_and_send_join_event( self, other_server: str, other_user: str, room_id: str ) -> EventBase: