Designate a device to claim the key bundle on accepted-knock auto-join

A server-initiated join lands on all the user's devices in the same
sync instant, so each would otherwise eagerly download the MSC4268 room
key bundle. Send the most recently active device an
org.matrix.msc4509.key_bundle_claim to-device hint (MSC4509) so exactly
one device claims eagerly; the rest defer until first needed.

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-15 16:51:18 +01:00
co-authored by Claude Fable 5
parent e53dfc0954
commit b618cc4b0a
3 changed files with 82 additions and 2 deletions
+1 -1
View File
@@ -1 +1 @@
Add a new `auto_accept_invites.enabled_for_accepted_knocks` option which, when enabled, automatically joins a user to a room when an invite arrives for a knock they have pending in that room (i.e. their knock was accepted), instead of requiring the user to accept the resulting invite by hand. Also fix the `on_new_event` module callback breaking event persistence when a rejected event was received over federation.
Add a new `auto_accept_invites.enabled_for_accepted_knocks` option which, when enabled, automatically joins a user to a room when an invite arrives for a knock they have pending in that room (i.e. their knock was accepted), instead of requiring the user to accept the resulting invite by hand; the server designates one of the user's devices to download any MSC4268 room key bundle. Also fix the `on_new_event` module callback breaking event persistence when a rejected event was received over federation.
+56
View File
@@ -26,9 +26,14 @@ from synapse.api.constants import AccountDataTypes, EventTypes, Membership
from synapse.api.errors import SynapseError
from synapse.config.auto_accept_invites import AutoAcceptInvitesConfig
from synapse.module_api import EventBase, ModuleApi, run_as_background_process
from synapse.types import create_requester
logger = logging.getLogger(__name__)
# MSC4509: to-device message designating one of a user's devices as the eager
# downloader of an MSC4268 room key bundle after a server-initiated join.
UNSTABLE_KEY_BUNDLE_CLAIM_TYPE = "org.matrix.msc4509.key_bundle_claim"
class InviteAutoAccepter:
def __init__(self, config: AutoAcceptInvitesConfig, api: ModuleApi):
@@ -251,3 +256,54 @@ class InviteAutoAccepter:
if join_event is not None:
break
if join_event is not None:
# The join was made by the server, so it lands on all of the
# user's devices in the same sync instant. Designate one device to
# eagerly download any MSC4268 room key bundle for the room, lest
# every device does (MSC4509). Best effort: the hint is advisory,
# and without it clients fall back to claiming the bundle lazily.
try:
await self._send_key_bundle_claim_hint(target, room_id)
except Exception as e:
logger.warning(
"Failed to send key bundle claim hint to %s for %s: %s",
target,
room_id,
e,
)
async def _send_key_bundle_claim_hint(self, user_id: str, room_id: str) -> None:
"""Send an `org.matrix.msc4509.key_bundle_claim` to-device message to
the user's most recently active device, designating it as the one
device which should eagerly download an MSC4268 room key bundle for
the room the user was just joined to.
Args:
user_id: the (local) user who was joined to the room
room_id: the room they were joined to
"""
# This is bundled with Synapse (rather than a true module), so reach
# into the homeserver for the device machinery: the module API doesn't
# expose device listing or to-device sending.
hs = self._api._hs
devices = await hs.get_device_handler().get_devices_by_user(user_id)
if not devices:
return
# Pick the device the user is most likely to be actively using. The
# worst a stale pick costs is the bundle being claimed lazily instead.
device = max(devices, key=lambda d: d.get("last_seen_ts") or 0)
device_id = device["device_id"]
logger.info(
"Designating device %s of %s to claim any key bundle for %s",
device_id,
user_id,
room_id,
)
await hs.get_device_message_handler().send_device_message(
create_requester(user_id),
UNSTABLE_KEY_BUNDLE_CLAIM_TYPE,
{user_id: {device_id: {"room_id": room_id}}},
)
+25 -1
View File
@@ -32,7 +32,10 @@ from synapse.api.constants import EventTypes
from synapse.api.errors import SynapseError
from synapse.config._base import RootConfig
from synapse.config.auto_accept_invites import AutoAcceptInvitesConfig
from synapse.events.auto_accept_invites import InviteAutoAccepter
from synapse.events.auto_accept_invites import (
UNSTABLE_KEY_BUNDLE_CLAIM_TYPE,
InviteAutoAccepter,
)
from synapse.handlers.sync import JoinedSyncResult, SyncRequestKey
from synapse.module_api import ModuleApi
from synapse.rest import admin
@@ -123,6 +126,27 @@ class AutoAcceptInvitesTestCase(FederatingHomeserverTestCase):
self.assertEqual(len(join_updates), 1)
self.assertEqual(join_updates[0].room_id, room_id)
# The join was server-initiated, so the knocker's device should have
# been designated (via a to-device message) to eagerly claim any
# MSC4268 room key bundle for the room.
devices = self.get_success(
self.hs.get_datastores().main.get_devices_by_user(knocker_id)
)
self.assertEqual(len(devices), 1, devices)
device_id = next(iter(devices))
to_stream_id = self.hs.get_datastores().main.get_to_device_stream_token()
messages, _ = self.get_success(
self.hs.get_datastores().main.get_messages_for_device(
knocker_id, device_id, 0, to_stream_id
)
)
claim_messages = [
m for m in messages if m["type"] == UNSTABLE_KEY_BUNDLE_CLAIM_TYPE
]
self.assertEqual(len(claim_messages), 1, messages)
self.assertEqual(claim_messages[0]["content"], {"room_id": room_id})
self.assertEqual(claim_messages[0]["sender"], knocker_id)
def test_no_auto_join_on_accepted_knock_by_default(self) -> None:
"""With `enabled_for_accepted_knocks` off (the default), an accepted
knock stays an ordinary invite."""