Skip to main content

xmtp_mls/groups/
state.rs

1//! Admin lists, consent, epoch state, and group context.
2
3use super::*;
4
5impl<Context> MlsGroup<Context>
6where
7    Context: XmtpSharedContext,
8{
9    /// Updates the admin list of the group and syncs the changes to the network.
10    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
11    #[cfg_attr(
12        not(any(test, feature = "test-utils")),
13        tracing::instrument(level = "trace", skip(self))
14    )]
15    pub async fn update_admin_list(
16        &self,
17        action_type: UpdateAdminListType,
18        inbox_id: String,
19    ) -> Result<(), GroupError> {
20        if self.metadata().await?.conversation_type == ConversationType::Dm {
21            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
22        }
23        let intent_action_type = match action_type {
24            UpdateAdminListType::Add => AdminListActionType::Add,
25            UpdateAdminListType::Remove => AdminListActionType::Remove,
26            UpdateAdminListType::AddSuper => AdminListActionType::AddSuper,
27            UpdateAdminListType::RemoveSuper => AdminListActionType::RemoveSuper,
28        };
29        let intent_data: Vec<u8> =
30            UpdateAdminListIntentData::new(intent_action_type, inbox_id).into();
31        let intent = QueueIntent::update_admin_list()
32            .data(intent_data)
33            .queue(self)?;
34
35        let _ = self.sync_until_intent_resolved(intent.id).await?;
36        Ok(())
37    }
38
39    /// Find the `inbox_id` of the group member who added the member to the group
40    pub fn added_by_inbox_id(&self) -> Result<String, GroupError> {
41        let conn = self.context.db();
42        let group = conn
43            .find_group(&self.group_id)?
44            .ok_or(NotFound::GroupById(self.group_id))?;
45        Ok(group.added_by_inbox_id)
46    }
47
48    /// Find the `consent_state` of the group
49    pub fn consent_state(&self) -> Result<ConsentState, GroupError> {
50        let conn = self.context.db();
51        let record =
52            conn.get_consent_record(hex::encode(self.group_id), ConsentType::ConversationId)?;
53
54        match record {
55            Some(rec) => Ok(rec.state),
56            None => Ok(ConsentState::Unknown),
57        }
58    }
59
60    // Returns new consent records. Does not broadcast changes.
61    pub fn quietly_update_consent_state(
62        &self,
63        state: ConsentState,
64        db: &impl DbQuery,
65    ) -> Result<Vec<StoredConsentRecord>, GroupError> {
66        let consent_record = StoredConsentRecord::new(
67            ConsentType::ConversationId,
68            state,
69            hex::encode(self.group_id),
70        );
71
72        Ok(db.insert_or_replace_consent_records(std::slice::from_ref(&consent_record))?)
73    }
74
75    #[tracing::instrument(skip_all, level = "trace")]
76    pub fn update_consent_state(&self, state: ConsentState) -> Result<(), GroupError> {
77        let db = self.context.db();
78        let new_records: Vec<PreferenceUpdate> = self
79            .quietly_update_consent_state(state, &db)?
80            .into_iter()
81            .map(PreferenceUpdate::Consent)
82            .collect();
83
84        if !new_records.is_empty() {
85            self.context.task_channels().wake_notifications();
86            // Dispatch an update event so it can be synced across devices
87            let _ = self
88                .context
89                .worker_events()
90                .send(SyncWorkerEvent::SyncPreferences(new_records.clone()));
91            // Broadcast the changes
92            let _ = self
93                .context
94                .local_events()
95                .send(LocalEvents::PreferencesChanged(new_records));
96        }
97
98        Ok(())
99    }
100
101    /// Get the current epoch number of the group.
102    pub async fn epoch(&self) -> Result<u64, GroupError> {
103        self.with_group_snapshot(|mls_group| Ok(mls_group.epoch().as_u64()))
104    }
105
106    /// Get the encryption state of the current epoch. Should match for all installations
107    /// in the same epoch.
108    #[cfg(test)]
109    pub(crate) async fn epoch_authenticator(&self) -> Result<Vec<u8>, GroupError> {
110        self.with_group_snapshot(|mls_group| {
111            Ok(mls_group.epoch_authenticator().as_slice().to_vec())
112        })
113    }
114
115    pub async fn cursor(&self) -> Result<Cursor, GroupError> {
116        let db = self.context.db();
117        let msgs = db.get_last_cursor(self.group_id, EntityKind::ApplicationMessage)?;
118        Ok(msgs)
119    }
120
121    pub async fn local_commit_log(&self) -> Result<Vec<LocalCommitLog>, GroupError> {
122        Ok(self.context.db().get_group_logs(&self.group_id)?)
123    }
124
125    pub async fn remote_commit_log(&self) -> Result<Vec<RemoteCommitLog>, GroupError> {
126        Ok(self.context.db().get_remote_commit_log_after_cursor(
127            &self.group_id,
128            0,
129            RemoteCommitLogOrder::AscendingByRowid,
130        )?)
131    }
132
133    pub async fn debug_info(&self) -> Result<ConversationDebugInfo, GroupError> {
134        let epoch = self.epoch().await?;
135        let cursor = self.cursor().await?;
136        let commit_log = self.local_commit_log().await?;
137        let remote_commit_log = self.remote_commit_log().await?;
138        let db = self.context.db();
139
140        let stored_group = match db.find_group(&self.group_id)? {
141            Some(group) => group,
142            None => {
143                return Err(GroupError::NotFound(NotFound::GroupById(self.group_id)));
144            }
145        };
146
147        Ok(ConversationDebugInfo {
148            epoch,
149            maybe_forked: stored_group.maybe_forked,
150            fork_details: stored_group.fork_details,
151            is_commit_log_forked: stored_group.is_commit_log_forked,
152            local_commit_log: format!("{:?}", commit_log),
153            remote_commit_log: format!("{:?}", remote_commit_log),
154            cursor: vec![cursor],
155        })
156    }
157
158    /// Update this installation's leaf key in the group by creating a key update commit
159    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
160    #[cfg_attr(
161        not(any(test, feature = "test-utils")),
162        tracing::instrument(level = "trace", skip(self))
163    )]
164    pub async fn key_update(&self) -> Result<(), GroupError> {
165        let intent = QueueIntent::key_update().queue(self)?;
166        let _ = self.sync_until_intent_resolved(intent.id).await?;
167        Ok(())
168    }
169
170    /// Checks if the current user is active in the group.
171    ///
172    /// If the current user has been kicked out of the group, `is_active` will return `false`
173    #[tracing::instrument(skip_all, level = "trace")]
174    pub fn is_active(&self) -> Result<bool, GroupError> {
175        // Restored groups that are not yet added are inactive
176        let Some(stored_group) = self.context.db().find_group(&self.group_id)? else {
177            return Err(GroupError::NotFound(NotFound::GroupById(self.group_id)));
178        };
179        if matches!(
180            stored_group.membership_state,
181            GroupMembershipState::Restored
182        ) {
183            return Ok(false);
184        }
185
186        self.with_group_snapshot(|mls_group| Ok(mls_group.is_active()))
187    }
188
189    /// Returns the membership state of the current user in this group.
190    #[tracing::instrument(skip_all, level = "trace")]
191    pub fn membership_state(&self) -> Result<GroupMembershipState, GroupError> {
192        let stored_group = self
193            .context
194            .db()
195            .find_group(&self.group_id)?
196            .ok_or_else(|| GroupError::NotFound(NotFound::GroupById(self.group_id)))?;
197        Ok(stored_group.membership_state)
198    }
199
200    /// Get the `GroupMetadata` of the group.
201    ///
202    /// On migrated groups the legacy immutable-metadata extension has
203    /// been removed; synthesize from dict (CONVERSATION_TYPE,
204    /// CREATOR_INBOX_ID, DM_MEMBERS, ONESHOT_MESSAGE). On unmigrated
205    /// groups, the legacy extension is authoritative.
206    ///
207    /// Migrated-but-no-seeds is treated as a hard error rather than
208    /// falling through to the legacy extension — the bootstrap commit
209    /// strips the legacy `GroupContextExtension`, so falling through
210    /// would surface an unrelated `MissingExtension` from the legacy
211    /// path. Returning `MissingExtension` directly here keeps the
212    /// failure shape callers already handle while making the
213    /// "incomplete migration" condition explicit at the originating
214    /// site.
215    pub async fn metadata(&self) -> Result<GroupMetadata, GroupError> {
216        self.with_group_snapshot(|mls_group| {
217            if self::app_data::is_migrated_group(mls_group) {
218                let seed =
219                    self::app_data::component_source::read_group_metadata_from_dict(mls_group)
220                        .map_err(MetadataPermissionsError::from)?
221                        .ok_or_else(|| {
222                            MetadataPermissionsError::from(GroupMetadataError::MissingExtension)
223                        })?;
224                use xmtp_proto::xmtp::mls::message_contents::GroupMetadataV1 as GroupMetadataProto;
225                // `creator_account_address` has been `""` on the
226                // legacy write path since long before this migration
227                // (see the `TODO: remove from proto` note in
228                // `xmtp_mls_common::group_metadata`). The field is
229                // effectively dead — no consumer reads it — so the
230                // migrated synthesis keeps it empty to match legacy
231                // bytes exactly.
232                let proto = GroupMetadataProto {
233                    conversation_type: seed.conversation_type,
234                    creator_inbox_id: seed.creator_inbox_id,
235                    creator_account_address: String::new(),
236                    dm_members: seed.dm_members,
237                    oneshot_message: seed.oneshot,
238                };
239                return Ok(GroupMetadata::try_from(proto).map_err(MetadataPermissionsError::from)?);
240            }
241            extract_group_metadata(mls_group.extensions())
242                .map_err(MetadataPermissionsError::from)
243                .map_err(Into::into)
244        })
245    }
246
247    /// Read the group's `GroupContext` from storage — a single KV round-trip,
248    /// no ratchet tree, no secrets, no commit lock. All group metadata lives in
249    /// the context extensions, so metadata reads go through this rather than a
250    /// full `OpenMlsGroup::load`. The context key is written atomically on
251    /// commit, so a single-key read is metadata-consistent.
252    ///
253    /// (The pre-refactor sync `load_mls_group_with_lock` used only an *advisory*
254    /// lock for these reads — a failed `get_lock_sync` was ignored and the read
255    /// proceeded anyway — so dropping it changes nothing for reads.)
256    pub(crate) fn load_group_context(&self) -> Result<openmls::group::GroupContext, GroupError> {
257        use openmls_traits::storage::StorageProvider as _;
258        self.context
259            .mls_storage()
260            .group_context::<_, openmls::group::GroupContext>(&self.group_id.to_openmls())
261            .map_err(GroupError::from)?
262            .ok_or_else(|| GroupError::from(StorageError::from(NotFound::GroupById(self.group_id))))
263    }
264
265    /// Get the `GroupMutableMetadata` of the group.
266    ///
267    /// Post-migration (dict contains `COMPONENT_REGISTRY` — see
268    /// [`self::app_data::is_migrated_group`]) the legacy GMM extension
269    /// is gone; we start with an empty base and
270    /// `merge_app_data_into_mutable_metadata` populates every field
271    /// from the AppData dict. Pre-migration we read the legacy GMM
272    /// extension authoritatively. The overlay helper itself also
273    /// checks the migration marker (defense in depth), so a stray
274    /// dict entry on a pre-bootstrap group can't silently shadow
275    /// legacy values.
276    ///
277    /// Intentionally distinct from `proposals_enabled`: a group can
278    /// have `proposals_enabled == true` but not yet have completed
279    /// its bootstrap commit, during which window the legacy GMM is
280    /// still authoritative.
281    pub fn mutable_metadata(&self) -> Result<GroupMutableMetadata, GroupError> {
282        use self::app_data::component_source::ComponentSourceError;
283        let ctx = self.load_group_context()?;
284        self::app_data::component_source::extract_group_mutable_metadata_capability_aware_from_extensions(
285            ctx.extensions(),
286        )
287        .map_err(|e| match e {
288            // Inner `GroupMutableMetadataError` originates from the legacy
289            // `TryFrom<&Extensions>` path on unmigrated groups; the
290            // `From<ComponentSourceError>` impl preserves it verbatim so binding
291            // consumers that pattern-match on `MetadataPermissionsError::Mutable`
292            // keep lighting up on `MissingExtension`.
293            ComponentSourceError::GroupMutableMetadata(inner) => {
294                GroupError::MetadataPermissionsError(MetadataPermissionsError::Mutable(inner))
295            }
296            other => GroupError::MetadataPermissionsError(
297                MetadataPermissionsError::ComponentSource(other),
298            ),
299        })
300    }
301
302    /// Pre-L implementation of [`Self::mutable_metadata`]: a full
303    /// `OpenMlsGroup::load`. Retained only as the baseline the
304    /// read-amplification benchmark measures the context-read path against.
305    #[cfg(test)]
306    pub(crate) fn mutable_metadata_via_full_load(
307        &self,
308    ) -> Result<GroupMutableMetadata, GroupError> {
309        use self::app_data::component_source::ComponentSourceError;
310        self.load_mls_group_with_lock(self.context.mls_storage(), |mls_group| {
311            self::app_data::component_source::extract_group_mutable_metadata_capability_aware(
312                &mls_group,
313            )
314            .map_err(|e| match e {
315                ComponentSourceError::GroupMutableMetadata(inner) => {
316                    GroupError::MetadataPermissionsError(MetadataPermissionsError::Mutable(inner))
317                }
318                other => GroupError::MetadataPermissionsError(
319                    MetadataPermissionsError::ComponentSource(other),
320                ),
321            })
322        })
323    }
324
325    pub fn permissions(&self) -> Result<GroupMutablePermissions, GroupError> {
326        let ctx = self.load_group_context()?;
327        let permissions: GroupMutablePermissions = ctx
328            .extensions()
329            .try_into()
330            .map_err(MetadataPermissionsError::from)?;
331        Ok(permissions)
332    }
333
334    /// Capability-aware single-component read.
335    ///
336    /// Reads the group's `GroupContext` (via [`Self::load_group_context`]),
337    /// then uses the [`self::app_data::typed_facade::MlsGroupAppData`] facade to
338    /// read exactly one [`Component`](xmtp_mls_common::app_data::typed::Component)
339    /// out of its extensions — avoiding both the full `OpenMlsGroup::load` and
340    /// the full `GroupMutableMetadata` composite parse that a naive read would
341    /// run on every call.
342    ///
343    /// Returns `Ok(None)` when the component has no stored value
344    /// (legacy GMM attribute missing on unmigrated groups, or dict slot
345    /// absent on migrated groups).
346    ///
347    /// Preserves the pre-refactor `GroupError::MetadataPermissionsError(...)`
348    /// shape that `self.mutable_metadata()` produced. For the corrupted-
349    /// legacy-GMM case the inner `GroupMutableMetadataError` is peeled
350    /// out of `ComponentSourceError::GroupMutableMetadata(...)` and
351    /// surfaced as `MetadataPermissionsError::Mutable(inner)`, matching
352    /// what binding consumers used to see. Other `ComponentSourceError`
353    /// variants (TLS codec, set/map apply failures) surface as
354    /// `MetadataPermissionsError::ComponentSource(other)`.
355    pub(in crate::groups) fn read_single_component<C>(&self) -> Result<Option<C::Value>, GroupError>
356    where
357        C: xmtp_mls_common::app_data::typed::Component,
358    {
359        use self::app_data::component_source::ComponentSourceError;
360        let ctx = self.load_group_context()?;
361        let facade = self::app_data::typed_facade::MlsGroupAppData::new(ctx.extensions());
362        facade.get::<C>().map_err(|e| match e {
363            ComponentSourceError::GroupMutableMetadata(inner) => {
364                GroupError::MetadataPermissionsError(MetadataPermissionsError::Mutable(inner))
365            }
366            other => GroupError::MetadataPermissionsError(
367                MetadataPermissionsError::ComponentSource(other),
368            ),
369        })
370    }
371
372    /// Fetches the message disappearing settings for a given group ID.
373    ///
374    /// Returns `Some(MessageDisappearingSettings)` if the group exists and has valid settings,
375    /// `None` if the group or settings are missing, or `Err(ClientError)` on a database error.
376    pub fn disappearing_settings(&self) -> Result<Option<MessageDisappearingSettings>, GroupError> {
377        let conn = self.context.db();
378        let stored_group: Option<StoredGroup> = conn.fetch(&self.group_id)?;
379
380        let settings = stored_group.and_then(|group| {
381            let from_ns = group.message_disappear_from_ns?;
382            let in_ns = group.message_disappear_in_ns?;
383
384            Some(MessageDisappearingSettings { from_ns, in_ns })
385        });
386
387        Ok(settings)
388    }
389
390    /// Find all the duplicate dms for this group
391    pub fn find_duplicate_dms(&self) -> Result<Vec<MlsGroup<Context>>, ClientError> {
392        let duplicates = self.context.db().other_dms(&self.group_id)?;
393
394        let mls_groups = duplicates
395            .into_iter()
396            .map(|g| {
397                MlsGroup::new(
398                    self.context.clone(),
399                    g.id,
400                    g.dm_id,
401                    g.conversation_type,
402                    g.created_at_ns,
403                )
404            })
405            .collect();
406
407        Ok(mls_groups)
408    }
409
410    /// Used for testing that dm group validation works as expected.
411    ///
412    /// See the `test_validate_dm_group` test function for more details.
413    #[cfg(test)]
414    pub fn create_test_dm_group(
415        context: Context,
416        dm_target_inbox_id: InboxId,
417        custom_protected_metadata: Option<Extension>,
418        custom_mutable_metadata: Option<Extension>,
419        custom_group_membership: Option<Extension>,
420        custom_mutable_permissions: Option<PolicySet>,
421        opts: Option<DMMetadataOptions>,
422    ) -> Result<Self, GroupError> {
423        let provider = context.mls_provider();
424        let commit_log_enabled = context.server_configuration().commit_log_enabled();
425
426        let protected_metadata = custom_protected_metadata.unwrap_or_else(|| {
427            build_dm_protected_metadata_extension(context.inbox_id(), dm_target_inbox_id.clone())
428                .unwrap()
429        });
430        let mutable_metadata = custom_mutable_metadata.unwrap_or_else(|| {
431            build_dm_mutable_metadata_extension_default(
432                context.inbox_id(),
433                &dm_target_inbox_id,
434                opts.unwrap_or_default(),
435                commit_log_enabled,
436            )
437            .unwrap()
438        });
439        let group_membership = custom_group_membership
440            .unwrap_or_else(|| build_starting_group_membership_extension(context.inbox_id(), 0));
441        let mutable_permissions = custom_mutable_permissions.unwrap_or_else(PolicySet::new_dm);
442        let mutable_permission_extension =
443            build_mutable_permissions_extension(mutable_permissions)?;
444
445        let group_config = build_group_config(
446            protected_metadata,
447            mutable_metadata,
448            group_membership,
449            mutable_permission_extension,
450        )?;
451
452        let mls_group = OpenMlsGroup::from_creation_logged(
453            &provider,
454            context.identity(),
455            &group_config,
456            commit_log_enabled,
457        )?;
458        let group_id: GroupId = mls_group.group_id().try_into()?;
459        let stored_group = StoredGroup::builder()
460            .id(group_id)
461            .created_at_ns(now_ns())
462            .membership_state(GroupMembershipState::Allowed)
463            .added_by_inbox_id(context.inbox_id().to_string())
464            .dm_id(Some(
465                DmMembers {
466                    member_one_inbox_id: context.inbox_id().to_string(),
467                    member_two_inbox_id: dm_target_inbox_id,
468                }
469                .to_string(),
470            ))
471            .build()?;
472
473        stored_group.store(&context.db())?;
474        Ok(Self::new_from_arc(
475            context,
476            group_id,
477            stored_group.dm_id.clone(),
478            ConversationType::Dm,
479            stored_group.created_at_ns,
480        ))
481    }
482}