From e4c60a80e31421268e64db40baa743ea53ed3a67 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Fri, 30 Oct 2026 10:15:22 +0100 Subject: [PATCH] Return an out-of-band membership from `/context` instead of a 500 A remote invite, a knock, or a leave that rejects either reaches the server as an out-of-band membership event. Synapse stores it as an outlier with no state group, so `get_state_for_events` raises "No state group for unknown or outlier event" and `/context` returns a 500. The visibility rules already let the user see their own membership events, and `/rooms/{roomId}/event/{eventId}` already returns them. `get_event_context` now returns early for an outlier that is the requesting user's own `m.room.member` event: the event itself, empty `events_before`, `events_after` and `state`, and no pagination tokens. `EventContext.start` and `end` become optional, and the client and admin servlets leave the keys out when they are None. The event has no position in the room's stream that this server can stand behind, and a fabricated token would point a client paginating from `end` at the start of this server's stream. The branch checks the membership type and state key itself. Under `use_admin_priviledge` the admin API skips the visibility filter, so an unconditional outlier check would also catch rejected events and auth events. The admin API keeps its current behaviour, a 500, for those. The spec does not define this case yet. An MSC and a spec clarification for the `/context` response schema are in preparation. Co-authored-by: Quentin Gliech Co-Authored-By: Claude Fable 5.1 --- changelog.d/20440.bugfix | 1 + synapse/handlers/room.py | 28 ++++++++++++++++++++++++++-- synapse/rest/admin/rooms.py | 11 ++++++++--- synapse/rest/client/room.py | 12 +++++++++--- 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 changelog.d/20440.bugfix diff --git a/changelog.d/20440.bugfix b/changelog.d/20440.bugfix new file mode 100644 index 0000000000..77f6ad3e63 --- /dev/null +++ b/changelog.d/20440.bugfix @@ -0,0 +1 @@ +Return the event with an empty context, rather than a 500, when `GET /_matrix/client/v3/rooms/{roomId}/context/{eventId}` is called on an out-of-band membership event such as a remote invite or a knock. diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index e5de95a972..b2664a4f62 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -120,8 +120,8 @@ class EventContext: events_after: list[FilteredEvent] state: list[EventBase] aggregations: dict[str, BundledAggregations] - start: str - end: str + start: str | None + end: str | None class RoomCreationHandler: @@ -2069,6 +2069,30 @@ class RoomContextHandler: if not filtered: raise AuthError(403, "You don't have permission to access that event.") + if ( + event.internal_metadata.outlier + and event.type == EventTypes.Member + and event.state_key == user.to_string() + ): + # An out-of-band membership (a remote invite, a knock, or a leave that + # rejects either) is an outlier: it has no state group, so + # get_state_for_events below raises for it. Its stream position points into + # a room whose history we do not hold, so the surrounding events and + # pagination tokens derived from it would describe nothing. Return the + # event alone. + # + # Any other outlier still raises. Only the admin API can reach one, since + # the visibility filter drops them for everyone else. + return EventContext( + events_before=[], + event=filtered[0], + events_after=[], + state=[], + aggregations={}, + start=None, + end=None, + ) + results = await self.store.get_events_around( room_id, event_id, before_limit, after_limit, event_filter ) diff --git a/synapse/rest/admin/rooms.py b/synapse/rest/admin/rooms.py index e47b6e9efe..da36c94a51 100644 --- a/synapse/rest/admin/rooms.py +++ b/synapse/rest/admin/rooms.py @@ -901,7 +901,7 @@ class RoomEventContextServlet(RestServlet): ) time_now = self.clock.time_msec() - results = { + results: JsonDict = { "events_before": await self._event_serializer.serialize_events( event_context.events_before, time_now, @@ -921,10 +921,15 @@ class RoomEventContextServlet(RestServlet): [FilteredEvent.state(e) for e in event_context.state], time_now, ), - "start": event_context.start, - "end": event_context.end, } + # Omitted when the handler has no token to give, as in + # RoomEventContextServlet. + if event_context.start is not None: + results["start"] = event_context.start + if event_context.end is not None: + results["end"] = event_context.end + return HTTPStatus.OK, results diff --git a/synapse/rest/client/room.py b/synapse/rest/client/room.py index b5dcc8902e..5af2a37257 100644 --- a/synapse/rest/client/room.py +++ b/synapse/rest/client/room.py @@ -1280,7 +1280,7 @@ class RoomEventContextServlet(RestServlet): serializer_options = await self._event_serializer.create_config( requester=requester ) - results = { + results: JsonDict = { "events_before": await self._event_serializer.serialize_events( event_context.events_before, time_now, @@ -1304,10 +1304,16 @@ class RoomEventContextServlet(RestServlet): time_now, config=serializer_options, ), - "start": event_context.start, - "end": event_context.end, } + # An out-of-band membership event cannot be placed in the room's stream, + # so the handler has no tokens to give for it. Every other response + # carries both. + if event_context.start is not None: + results["start"] = event_context.start + if event_context.end is not None: + results["end"] = event_context.end + return 200, results