Stamp replaced knock into unsigned on an out-of-band denial

Outliers never gain replaces_state, so clients could not tell a denied
knock (prev_content.membership == knock) from a plain kick. Reflect the
replaced knock event in the denial's unsigned ourselves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG
This commit is contained in:
Matthew Hodgson
2026-07-14 16:14:21 +01:00
co-authored by Claude Fable 5
parent c464be40ac
commit 962148d404
2 changed files with 27 additions and 1 deletions
+12
View File
@@ -323,6 +323,18 @@ class FederationEventHandler:
pdu.state_key, pdu.room_id
)
if origin == via_server or origin == get_domain_from_id(pdu.sender):
# As an outlier the event will never gain a
# `replaces_state`, so reflect the replaced knock in
# `unsigned` ourselves: clients use `prev_content` to
# distinguish a denied knock from a plain kick.
knock_event = await self._store.get_event(
membership_event_id, allow_none=True
)
if knock_event is not None:
pdu.unsigned["replaces_state"] = knock_event.event_id
pdu.unsigned["prev_content"] = knock_event.content
pdu.unsigned["prev_sender"] = knock_event.sender
# Handle the denial event
pdu.internal_metadata.outlier = True
pdu.internal_metadata.out_of_band_membership = True
@@ -405,9 +405,10 @@ class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
# The knock should (eventually - the PDU is processed in the
# background) be retracted.
leave_event_id = None
with test_timeout(3, "Denial of the knock was not processed"):
while True:
membership, _ = self.get_success(
membership, leave_event_id = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
knock_result.local_user1_id, knock_result.remote_room_id
)
@@ -416,6 +417,19 @@ class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
break
time.sleep(0.1)
# The stored leave should carry the replaced knock in `unsigned`, so
# that clients can tell a denied knock from a plain kick.
assert leave_event_id is not None
leave_event = self.get_success(self.store.get_event(leave_event_id))
self.assertEqual(
leave_event.unsigned.get("prev_content", {}).get("membership"),
Membership.KNOCK,
)
self.assertEqual(
leave_event.unsigned.get("replaces_state"),
knock_result.knock_event_id,
)
def test_denied_knock_ignored_without_knock_in_auth_events(self) -> None:
"""A leave event for our knocked user which does not reference the
knock in its auth events is ignored."""