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 <quenting@element.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and Quentin Gliech committed 2026-10-30 10:20:37 +01:00
1 parent ca28da9663
commit e4c60a80e3
4 files changed
+44 -8

No files matched your search

+1
View File
@@ -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.
+26 -2
View File
@@ -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
)
+8 -3
View File
@@ -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
+9 -3
View File
@@ -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