Skip to main content

xmtp_mls/groups/
membership.rs

1//! Adding, removing, and re-adding members.
2
3use super::*;
4use prost::Message;
5use xmtp_mls_group_state::app_data::{
6    components::tls_map_components::GroupMembershipComponent, typed_facade::MlsGroupAppData,
7};
8use xmtp_proto::ShortHex;
9use xmtp_proto::xmtp::mls::message_contents::{GroupMembershipEntry, group_membership_entry};
10
11impl<Context> MlsGroup<Context>
12where
13    Context: XmtpSharedContext,
14{
15    ///
16    /// Add members to the group by account address
17    ///
18    /// An account resolves to an inbox only when the newest state of the inbox
19    /// the backend names holds it. The add fails with
20    /// [`GroupError::AddressNotFound`], before any commit, when an account has
21    /// no such inbox.
22    ///
23    /// If any existing members have new installations that have not been added or removed, the
24    /// group membership will be updated to include those changes as well.
25    /// # Returns
26    /// - `Ok(UpdateGroupMembershipResult)`: Contains details about the membership changes, including:
27    ///   - `added_members`: list of added installations
28    ///   - `removed_members`: A list of installations that were removed.
29    ///   - `members_with_errors`: A list of members that encountered errors during the update.
30    /// - `Err(GroupError)`: If the operation fails due to an error.
31    #[tracing::instrument(level = "trace", skip_all)]
32    pub async fn add_members_by_identity(
33        &self,
34        account_identifiers: &[Identifier],
35    ) -> Result<UpdateGroupMembershipResult, GroupError> {
36        // The member limit is enforced in `add_members`, the one method every
37        // entry point reaches.
38        self.add_members_by_targets(&crate::inbox_handles::account_targets(account_identifiers))
39            .await
40    }
41
42    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", skip_all, fields(inbox_id = %self.context.inbox_id(), inbox_ids = ?inbox_ids.as_ref().iter().map(|i| i.as_ref()).collect::<Vec<_>>())))]
43    #[cfg_attr(
44        not(any(test, feature = "test-utils")),
45        tracing::instrument(level = "trace", skip_all)
46    )]
47    pub async fn add_members<S: AsIdRef>(
48        &self,
49        inbox_ids: impl AsRef<[S]>,
50    ) -> Result<UpdateGroupMembershipResult, GroupError> {
51        self.ensure_not_paused().await?;
52
53        let ids = inbox_ids
54            .as_ref()
55            .iter()
56            .map(AsIdRef::as_ref)
57            .collect::<Vec<&str>>();
58        let intent_data = self
59            .get_membership_update_intent(ids.as_slice(), &[])
60            .await?;
61
62        // TODO:nm this isn't the best test for whether the request is valid
63        // If some existing group member has an update, this will return an intent with changes
64        // when we really should return an error
65        let mut result = UpdateGroupMembershipResult::from(intent_data.clone());
66
67        if intent_data.is_empty() {
68            tracing::warn!("Member already added");
69            return Ok(result);
70        }
71
72        // The deployment sets the ceiling, checked here — the one
73        // method both entry points reach — before the commit is built and
74        // before anything is published. The count comes from the group's own
75        // membership extension unioned with the inboxes this commit would add,
76        // not from `members()`: that helper skips an inbox still at sequence id
77        // zero, which is exactly where a just-created group's creator sits.
78        let max_members = self
79            .context
80            .server_configuration()
81            .configuration()
82            .mls
83            .max_group_members;
84        let existing = self.with_group_snapshot(|group| {
85            Ok(xmtp_mls_validation::commit::extract_group_membership(
86                group.extensions(),
87            )?)
88        })?;
89        let resulting: HashSet<&str> = existing
90            .inbox_ids()
91            .into_iter()
92            .chain(intent_data.membership_updates.keys().map(String::as_str))
93            .collect();
94        if resulting.len() > max_members {
95            return Err(GroupError::UserLimitExceeded);
96        }
97
98        let intent = QueueIntent::update_group_membership()
99            .data(intent_data)
100            .queue(self)?;
101
102        self.sync_until_intent_resolved(intent.id).await?;
103        let epoch = self.epoch().await?;
104
105        // Publication can discover failures after the request was built,
106        // including additions suppressed to fit an envelope. Read the final
107        // entries for this request so split-child failures reach its caller.
108        result.failed_installations = self.with_group_snapshot(|group| {
109            let entries = MlsGroupAppData::new(group.extensions())
110                .get::<GroupMembershipComponent>()?
111                .ok_or(GroupError::InvalidGroupMembership)?;
112            let mut failed = Vec::new();
113            for (inbox, bytes) in entries.iter() {
114                if !result.added_members.contains_key(&inbox.to_hex()) {
115                    continue;
116                }
117                let entry = GroupMembershipEntry::decode(bytes.as_slice())?;
118                let Some(group_membership_entry::Version::V1(entry)) = entry.version else {
119                    return Err(GroupError::InvalidGroupMembership);
120                };
121                failed.extend(entry.failed_installations);
122            }
123            failed.sort_unstable();
124            failed.dedup();
125            Ok(failed)
126        })?;
127
128        log_event!(
129            Event::AddedMembers,
130            self.context.installation_id(),
131            group_id = self.group_id,
132            members = ?ids,
133            epoch
134        );
135
136        Ok(result)
137    }
138
139    /// Add the DM peer when this DM's `GROUP_MEMBERSHIP` lacks it, before a
140    /// message is queued in the DM.
141    ///
142    /// A creation that stops between storing the DM and adding the peer
143    /// leaves a DM that holds only its creator. A DM peer can never leave or
144    /// be removed, so a missing peer always means such an interruption.
145    /// Await this before queueing a message in a DM: intents publish in queue
146    /// order, so a message queued before the add is encrypted at an epoch
147    /// the peer never joins. Does nothing for a group that is not a DM, an
148    /// inactive DM (such as a restored placeholder), or a DM whose pair does
149    /// not include this inbox.
150    ///
151    /// Returns Ok when the add is published with a receipt but its processing
152    /// is not confirmed when the wait ends. That outcome belongs to the add,
153    /// not to the caller's message, which is not queued yet. The backend holds
154    /// the add, and nothing is prepared behind a published state change until
155    /// its echo is applied (SEND-022), so a message queued next is built at an
156    /// epoch that holds the peer. Every other failure of the add, including a
157    /// wait that ends before the add has a receipt, is returned.
158    // implements: DMS-020
159    pub async fn ensure_dm_peer(&self) -> Result<(), GroupError> {
160        match self.add_missing_dm_peer().await {
161            Err(error @ GroupError::PublishedButUnconfirmed { .. }) => {
162                tracing::warn!(
163                    group_id = self.group_id.short_hex(),
164                    ?error,
165                    "the DM peer add is published but unconfirmed; later intents wait behind it"
166                );
167                Ok(())
168            }
169            result => result,
170        }
171    }
172
173    /// Add the DM peer when this DM's `GROUP_MEMBERSHIP` lacks it, and return
174    /// every failure of the add, as a new DM's creation does.
175    // implements: DMS-020
176    pub(crate) async fn add_missing_dm_peer(&self) -> Result<(), GroupError> {
177        let Some(peer) = self.missing_dm_peer()? else {
178            return Ok(());
179        };
180        tracing::info!(
181            group_id = self.group_id.short_hex(),
182            "adding the missing peer of an interrupted DM creation"
183        );
184        self.add_members(&[peer]).await?;
185        // An add can resolve as a no-op, such as one that would remove this
186        // installation's own leaf after its inbox revoked it.
187        if self.missing_dm_peer()?.is_some() {
188            return Err(GroupError::InvalidGroupMembership);
189        }
190        Ok(())
191    }
192
193    /// The peer of this DM when its `GROUP_MEMBERSHIP` lacks it. None for a
194    /// group that is not an active DM of this inbox.
195    fn missing_dm_peer(&self) -> Result<Option<String>, GroupError> {
196        if self.conversation_type != ConversationType::Dm || !self.is_active()? {
197            return Ok(None);
198        }
199        // Read the membership extension, not `members()`: that helper skips
200        // an inbox at sequence id zero, where a joiner sees the creator.
201        self.with_group_snapshot(|group| {
202            let Some(pair) = extract_group_metadata(group.extensions())
203                .map_err(MetadataPermissionsError::from)?
204                .dm_members
205            else {
206                return Ok(None);
207            };
208            let own = self.context.inbox_id();
209            let peer = if pair.member_one_inbox_id == own {
210                pair.member_two_inbox_id
211            } else if pair.member_two_inbox_id == own {
212                pair.member_one_inbox_id
213            } else {
214                return Ok(None);
215            };
216            let membership =
217                xmtp_mls_validation::commit::extract_group_membership(group.extensions())?;
218            Ok::<_, GroupError>(membership.get(&peer).is_none().then_some(peer))
219        })
220    }
221
222    /// Removes members from the group by their account addresses. An account
223    /// resolves to an inbox only when the newest state of the inbox the
224    /// backend names holds it; an account with no such inbox is skipped.
225    ///
226    /// # Arguments
227    /// * `client` - The XMTP client.
228    /// * `account_addresses_to_remove` - A vector of account addresses to remove from the group.
229    ///
230    /// # Returns
231    /// A `Result` indicating success or failure of the operation.
232    pub async fn remove_members_by_identity(
233        &self,
234        account_addresses_to_remove: &[Identifier],
235    ) -> Result<(), GroupError> {
236        self.remove_members_by_targets(&crate::inbox_handles::account_targets(
237            account_addresses_to_remove,
238        ))
239        .await
240    }
241
242    /// Removes members from the group by their inbox IDs.
243    ///
244    /// # Arguments
245    /// * `client` - The XMTP client.
246    /// * `inbox_ids` - A vector of inbox IDs to remove from the group.
247    ///
248    /// # Returns
249    /// A `Result` indicating success or failure of the operation.
250    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", skip_all, fields(inbox_id = %self.context.inbox_id(), inbox_ids = ?inbox_ids)))]
251    #[cfg_attr(
252        not(any(test, feature = "test-utils")),
253        tracing::instrument(level = "trace", skip_all)
254    )]
255    pub async fn remove_members(&self, inbox_ids: &[InboxIdRef<'_>]) -> Result<(), GroupError> {
256        self.remove_inboxes(inbox_ids, false).await
257    }
258
259    async fn remove_inboxes(&self, ids: &[InboxIdRef<'_>], leave: bool) -> Result<(), GroupError> {
260        self.ensure_not_paused().await?;
261        let mut intent_data = self.get_membership_update_intent(&[], ids).await?;
262        if leave {
263            intent_data =
264                intent_data.with_leave_removals(ids.iter().map(|s| s.to_string()).collect());
265        }
266        let intent = QueueIntent::update_group_membership()
267            .data(intent_data)
268            .queue(self)?;
269
270        let _ = self.sync_until_intent_resolved(intent.id).await?;
271
272        Ok(())
273    }
274
275    /// Removes and readds installations from the MLS tree.
276    ///
277    /// The installation list should be validated beforehand - invalid installations
278    /// will simply be omitted at the time that the intent's publish data is computed.
279    ///
280    /// # Arguments
281    /// * `installations` - A vector of installations to readd.
282    ///
283    /// # Returns
284    /// A `Result` indicating success or failure of the operation.
285    #[allow(dead_code)]
286    pub(crate) async fn readd_installations(
287        &self,
288        installations: Vec<Vec<u8>>,
289    ) -> Result<(), GroupError> {
290        self.ensure_not_paused().await?;
291
292        let readd_min_version =
293            LibXMTPVersion::parse(xmtp_configuration::MIN_RECOVERY_REQUEST_VERSION)
294                .map_err(super::validated_commit::CommitValidationError::from)?;
295        let metadata = self.live_mutable_metadata()?;
296        let group_version = metadata
297            .attributes()
298            .get(MetadataField::MinimumSupportedProtocolVersion.as_str());
299        let group_min_version =
300            LibXMTPVersion::parse(group_version.unwrap_or(&"0.0.0".to_string()))
301                .map_err(super::validated_commit::CommitValidationError::from)?;
302
303        if readd_min_version > group_min_version {
304            self.update_group_min_version(xmtp_configuration::MIN_RECOVERY_REQUEST_VERSION)
305                .await?;
306        }
307
308        let intent_data: Vec<u8> = ReaddInstallationsIntentData::new(installations.clone()).into();
309        let intent = QueueIntent::readd_installations()
310            .data(intent_data)
311            .queue(self)?;
312
313        let _ = self.sync_until_intent_resolved(intent.id).await?;
314
315        Ok(())
316    }
317
318    /// Process this group's pending self-remove requests end-to-end: remove the
319    /// members still in the group that requested removal, then clean up stale
320    /// pending-remove rows. Idempotent and a no-op when this client is not a
321    /// super-admin, so it is safe to call from both the inline message-processing
322    /// fast-path and the durable `TaskRunner` retry path.
323    pub(crate) async fn process_pending_self_removals(&self) -> Result<(), GroupError> {
324        // Both helpers early-return on an empty pending list; cleanup owns the
325        // flag-clear. Keeping the empty-check inside cleanup (rather than a
326        // separate clear here) avoids racing a concurrent LeaveRequest insert and
327        // wrongly clearing the flag.
328        self.remove_members_pending_removal().await?;
329        self.cleanup_pending_removal_list().await?;
330        Ok(())
331    }
332
333    /// Removes all members from the group who are currently in the pending removal list.
334    ///
335    /// Only admins and super admins can call this function. Validates permissions, filters
336    /// out invalid removal requests and performs batch removal of valid pending members.
337    ///
338    /// # Returns
339    /// * `Ok(())` - All valid pending members were successfully removed
340    /// * `Err(GroupError)` - Failed to retrieve metadata, validate permissions or execute removals
341    pub async fn remove_members_pending_removal(&self) -> Result<(), GroupError> {
342        let pending_removal_list = self.pending_remove_list()?;
343
344        if pending_removal_list.is_empty() {
345            tracing::debug!(
346                group_id = %self.group_id,
347                inbox_id = %self.context.inbox_id(),
348                "Group has no pending removal members"
349            );
350            return Ok(());
351        }
352
353        let is_super_admin = self.live_is_super_admin(self.context.inbox_id())?;
354        if !is_super_admin {
355            tracing::debug!(
356                group_id = %self.group_id,
357                inbox_id = %self.context.inbox_id(),
358                "Current inbox ID is not in admin or super admin list, skipping pending removal processing"
359            );
360            return Ok(());
361        }
362
363        // Get current group members to validate which ones actually exist
364        let members = self.members().await?;
365        let member_inbox_ids: HashSet<String> =
366            members.iter().map(|m| m.inbox_id.clone()).collect();
367
368        // Filter pending removals to only include actual group members
369        let valid_removals: Vec<&str> = pending_removal_list
370            .iter()
371            .filter(|inbox_id| member_inbox_ids.contains(*inbox_id))
372            .map(|s| s.as_str())
373            .collect();
374
375        if valid_removals.is_empty() {
376            tracing::warn!(
377                group_id = %self.group_id,
378                pending_count = pending_removal_list.len(),
379                "No valid members found in pending removal list"
380            );
381            return Ok(());
382        }
383        // Log members that are in pending list but not in group
384        let invalid_removals: Vec<&String> = pending_removal_list
385            .iter()
386            .filter(|inbox_id| !member_inbox_ids.contains(*inbox_id))
387            .collect();
388
389        if !invalid_removals.is_empty() {
390            tracing::warn!(
391                group_id = %self.group_id,
392                invalid_members = ?invalid_removals,
393                "Some members in pending removal list are not in the group"
394            );
395        }
396
397        // Remove all valid members at once
398        tracing::info!(
399            group_id = %self.group_id,
400            removing_count = valid_removals.len(),
401            members_to_remove = ?valid_removals,
402            "Removing pending members from group"
403        );
404
405        match self.remove_inboxes(&valid_removals, true).await {
406            Ok(_) => {
407                tracing::info!(
408                    group_id = %self.group_id,
409                    removed_count = valid_removals.len(),
410                    removed_members = ?valid_removals,
411                    "Successfully removed all pending members from group"
412                );
413            }
414            Err(e) => {
415                tracing::error!(
416                    group_id = %self.group_id,
417                    removed_members = ?valid_removals,
418                    error = %e,
419                    "Failed to remove pending members from group"
420                );
421                return Err(e);
422            }
423        }
424
425        Ok(())
426    }
427
428    /// Removes members from the pending removal list who are no longer in the group.
429    ///
430    /// Iterates through all members in the pending removal list, checking each one to see
431    /// if they're still in the group. If a member is no longer in the group, they are
432    /// removed from the pending list. The pending list is refreshed after each removal
433    /// to ensure we're working with the most current data.
434    ///
435    /// # Returns
436    /// * `Ok(())` - Successfully processed all pending removal members
437    /// * `Err(GroupError)` - Failed to retrieve data or update the pending list
438    pub async fn cleanup_pending_removal_list(&self) -> Result<(), GroupError> {
439        tracing::debug!(
440            group_id = %self.group_id,
441            "Starting pending removal list cleanup"
442        );
443
444        // Get both lists upfront
445        let pending_removal_list = self.pending_remove_list()?;
446
447        if pending_removal_list.is_empty() {
448            tracing::debug!(
449                group_id = %self.group_id,
450                "No pending removals to clean up"
451            );
452            // Clear the pending leave request status
453            self.context
454                .db()
455                .set_group_has_pending_leave_request_status(&self.group_id, Some(false))?;
456            return Ok(());
457        }
458
459        // Get current group members
460        let current_members = self.members().await?;
461        let current_member_ids: Vec<String> = current_members
462            .iter()
463            .map(|member| member.inbox_id.clone())
464            .collect();
465
466        // Calculate removed members: users in pending list but not in current group
467        let removed_members: Vec<String> = pending_removal_list
468            .iter()
469            .filter(|pending_user| !current_member_ids.contains(pending_user))
470            .cloned()
471            .collect();
472
473        if !removed_members.is_empty() {
474            tracing::info!(
475                group_id = %self.group_id,
476                removed_count = removed_members.len(),
477                removed_members = ?removed_members,
478                "Removing members from pending removal list - they are no longer in the group"
479            );
480
481            // Remove all users who are no longer in the group from pending list
482            self.context
483                .db()
484                .delete_pending_remove_users(&self.group_id, removed_members)?;
485        }
486
487        // After cleanup, check if there are any pending removals left
488        let remaining_pending_list = self.pending_remove_list()?;
489        if remaining_pending_list.is_empty() {
490            // Clear the pending leave request status if no pending removals remain
491            self.context
492                .db()
493                .set_group_has_pending_leave_request_status(&self.group_id, Some(false))?;
494        }
495
496        tracing::info!(
497            group_id = %self.group_id,
498            remaining_pending = remaining_pending_list.len(),
499            "Finished cleaning up pending removal list"
500        );
501
502        Ok(())
503    }
504
505    // implements: GMOD-031
506    pub async fn leave_group(&self) -> Result<(), GroupError> {
507        self.ensure_not_paused().await?;
508
509        // Check if user is a member
510        let is_member = self.is_member().await?;
511        if !is_member {
512            return Err(GroupLeaveValidationError::NotAGroupMember.into());
513        }
514
515        //check member size
516        let members = self.members().await?;
517
518        // check if the group has other members
519        if members.len() == 1 {
520            return Err(GroupLeaveValidationError::SingleMemberLeaveRejected.into());
521        }
522
523        // check if the conversation is not a DM
524        if self.metadata().await?.conversation_type == ConversationType::Dm {
525            return Err(GroupLeaveValidationError::DmLeaveForbidden.into());
526        }
527
528        let is_super_admin = self.live_is_super_admin(self.context.inbox_id())?;
529
530        // super-admin cannot leave a group; must be demoted first
531        // since SuperAdmins can't remove other SuperAdmins they need to be demoted first
532        if is_super_admin {
533            return Err(GroupLeaveValidationError::SuperAdminLeaveForbidden.into());
534        }
535
536        if !self.is_in_pending_remove(self.context.inbox_id())? {
537            let content = LeaveRequestCodec::encode(LeaveRequest {
538                authenticated_note: None,
539            })?;
540            self.send_message(
541                &encoded_content_to_bytes(content),
542                SendMessageOpts::default(),
543            )
544            .await?;
545        };
546        Ok(())
547    }
548
549    /// Checks if the current user is a member of the group.
550    /// Returns true if the user is a member, false otherwise.
551    #[tracing::instrument(level = "debug", skip(self))]
552    async fn is_member(&self) -> Result<bool, GroupError> {
553        let members = self.members().await?;
554        Ok(members
555            .iter()
556            .any(|m| m.inbox_id == self.context.inbox_id()))
557    }
558}