Skip to main content

xmtp_mls/groups/
metadata.rs

1//! Mutable metadata, permissions, and group settings.
2
3use super::*;
4
5impl<Context> MlsGroup<Context>
6where
7    Context: XmtpSharedContext,
8{
9    /// Updates the name of the group. Will error if the user does not have the appropriate permissions
10    /// to perform these updates.
11    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
12    #[cfg_attr(
13        not(any(test, feature = "test-utils")),
14        tracing::instrument(level = "trace", skip(self))
15    )]
16    pub async fn update_group_name(&self, group_name: String) -> Result<(), GroupError> {
17        self.ensure_not_paused().await?;
18
19        if group_name.len() > MAX_GROUP_NAME_LENGTH {
20            return Err(GroupError::TooManyCharacters {
21                length: MAX_GROUP_NAME_LENGTH,
22            });
23        }
24        if self.metadata().await?.conversation_type == ConversationType::Dm {
25            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
26        }
27        let intent_data: Vec<u8> =
28            UpdateMetadataIntentData::new_update_group_name(group_name).into();
29        let intent = QueueIntent::metadata_update()
30            .data(intent_data)
31            .queue(self)?;
32
33        let _ = self.sync_until_intent_resolved(intent.id).await?;
34        Ok(())
35    }
36
37    /// Set the group's opaque `app_data` slot.
38    ///
39    /// `expected_app_data` is an optional compare-and-swap guard. When
40    /// `Some`, the update is abandoned with [`GroupError::AppDataSuperseded`]
41    /// unless the committed value still equals it — including when another
42    /// member's commit wins the epoch race *after* this intent was published.
43    /// Callers reconciling structured state should pass the value they merged
44    /// against, so a concurrent write is reported rather than overwritten.
45    ///
46    /// `None` keeps the historical last-writer-wins behavior: whatever landed
47    /// in the meantime is overwritten.
48    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
49    #[cfg_attr(
50        not(any(test, feature = "test-utils")),
51        tracing::instrument(level = "trace", skip(self))
52    )]
53    pub async fn update_app_data(
54        &self,
55        app_data: String,
56        expected_app_data: Option<String>,
57    ) -> Result<(), GroupError> {
58        self.ensure_not_paused().await?;
59
60        if app_data.len() > MAX_APP_DATA_LENGTH {
61            return Err(GroupError::TooManyCharacters {
62                length: MAX_APP_DATA_LENGTH,
63            });
64        }
65        if self.metadata().await?.conversation_type == ConversationType::Dm {
66            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
67        }
68
69        // Fail the already-stale case before touching the network. The
70        // authoritative check runs again at publish time, which is what
71        // catches a change that lands between here and the commit.
72        if let Some(expected) = &expected_app_data {
73            // Read the slot itself rather than going through `app_data()`: a
74            // group whose `app_data` has never been set is a legitimate "not
75            // what you expected", and reporting it as `MissingExtension` would
76            // hand the caller an error where it asked a question. A genuine
77            // read failure still propagates — an unreadable group is not
78            // evidence that someone else wrote the field.
79            let actual = self.read_single_component::<AppDataComponent>()?;
80            if actual.as_deref() != Some(expected.as_str()) {
81                return Err(GroupError::AppDataSuperseded {
82                    expected: expected.clone(),
83                    // An unset slot reports as empty. The guard cannot yet
84                    // *express* "I expect this to be unset" — the intent's
85                    // `expected_field_value` is an optional string, where
86                    // absent already means "no guard" — so an unset slot can
87                    // only ever be a mismatch here, never an expectation.
88                    actual: actual.unwrap_or_default(),
89                });
90            }
91        }
92
93        let intent_data: Vec<u8> =
94            UpdateMetadataIntentData::new_update_app_data(app_data, expected_app_data.clone())
95                .into();
96        let intent = QueueIntent::metadata_update()
97            .data(intent_data)
98            .queue(self)?;
99
100        match self.sync_until_intent_resolved(intent.id).await {
101            Ok(_) => Ok(()),
102            Err(err) => {
103                // A guarded intent that lost the race is marked `Superseded`
104                // rather than `Error`; translate it into the typed error so a
105                // stale write is distinguishable from a genuine sync failure.
106                if let Some(expected) = expected_app_data
107                    && matches!(
108                        self.context.db().fetch(&intent.id),
109                        Ok(Some(StoredGroupIntent {
110                            state: IntentState::Superseded,
111                            ..
112                        }))
113                    )
114                {
115                    // Superseded is only set after the publish path read the
116                    // committed value successfully, so this read should too;
117                    // if it somehow fails, that error is the honest one.
118                    return Err(GroupError::AppDataSuperseded {
119                        expected,
120                        actual: self.app_data()?,
121                    });
122                }
123                Err(err)
124            }
125        }
126    }
127
128    /// Updates min version of the group to match this client's version.
129    /// Not publicly exposed because:
130    /// - Setting the min version to pre-release versions may not behave as expected
131    /// - When the version is not explicitly specified, unexpected behavior may arise,
132    ///   for example if the code is left in across multiple version bumps.
133    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
134    #[cfg_attr(
135        not(any(test, feature = "test-utils")),
136        tracing::instrument(level = "trace", skip(self))
137    )]
138    #[allow(dead_code)]
139    pub(crate) async fn update_group_min_version_to_match_self(&self) -> Result<(), GroupError> {
140        let version = self.context.version_info().pkg_version();
141        self.update_group_min_version(version).await
142    }
143
144    /// Updates min version of the group to match the given version.
145    ///
146    /// # Arguments
147    /// * `version` - The libxmtp version to update the group min version to.
148    ///   This is a semver-formatted string matching the Cargo.toml in the
149    ///   libxmtp dependency, and does not match mobile or web release versions.
150    ///   Comparison is done via the [`semver`] crate's `Ord` impl, so
151    ///   pre-release identifiers (e.g. `"1.0.0-rc.1"`) sort BEFORE the
152    ///   corresponding release (`"1.0.0"`) per semver 2.0 §11. Build
153    ///   metadata (`+...`) parses but is included in ordering by the
154    ///   semver crate — avoid passing it unless you understand the
155    ///   total-ordering implication.
156    ///
157    /// # Returns
158    /// A `Result` indicating success or failure of the operation.
159    pub async fn update_group_min_version(&self, version: &str) -> Result<(), GroupError> {
160        self.ensure_not_paused().await?;
161
162        // Footgun guards (apply on send side; receive side enforces
163        // the same monotonicity invariant as the source of truth):
164        //
165        // 1. `version > own pkg_version` would pause this client (and
166        //    every peer at or below this version) the moment the bump
167        //    lands. Refuse.
168        // 2. `version < current floor` would silently unpause peers
169        //    between the new and old floors, defeating the gate. Refuse.
170        //    Lenient on an unparseable current floor — mirror the
171        //    receive-side behavior in `enforce_min_version_monotonicity`
172        //    rather than have the send-side refuse where the receive-
173        //    side accepts. (Brick recovery: a group with malformed
174        //    legacy GMM bytes shouldn't be permanently un-bumpable.)
175        let target_v =
176            LibXMTPVersion::parse(version).map_err(|e| GroupError::InvalidMinVersion {
177                value: version.to_string(),
178                reason: e.to_string(),
179            })?;
180        let own_version_str = self.context.version_info().pkg_version().to_string();
181        let own_v =
182            LibXMTPVersion::parse(&own_version_str).map_err(|e| GroupError::InvalidMinVersion {
183                value: own_version_str.clone(),
184                reason: format!("own pkg_version: {e}"),
185            })?;
186        if target_v > own_v {
187            return Err(GroupError::MinVersionExceedsOwnVersion {
188                requested: version.to_string(),
189                own: own_version_str,
190            });
191        }
192        let current_str = self
193            .mutable_metadata()?
194            .attributes
195            .get(MetadataField::MinimumSupportedProtocolVersion.as_str())
196            .cloned();
197        if let Some(current_str) = current_str.as_deref()
198            && !current_str.is_empty()
199        {
200            match LibXMTPVersion::parse(current_str) {
201                Ok(current_v) => {
202                    if target_v < current_v {
203                        return Err(GroupError::MinVersionDowngrade {
204                            requested: version.to_string(),
205                            current: current_str.to_string(),
206                        });
207                    }
208                }
209                Err(e) => {
210                    // Observability: malformed prior floor is an operator-
211                    // visible signal that the legacy GMM bytes are corrupt.
212                    // Leniency below preserves brick-recovery; the warning
213                    // surfaces the corruption.
214                    tracing::warn!(
215                        current = %current_str,
216                        error = %e,
217                        "update_group_min_version: existing min_version is unparseable; \
218                         proceeding without downgrade check"
219                    );
220                }
221            }
222        }
223
224        tracing::info!("update_group_min_version: queuing bump to {}", version);
225        let intent_data: Vec<u8> =
226            UpdateMetadataIntentData::new_update_group_min_version_to_match_self(
227                version.to_string(),
228            )
229            .into();
230        let intent = QueueIntent::metadata_update()
231            .data(intent_data)
232            .queue(self)?;
233
234        let _ = self.sync_until_intent_resolved(intent.id).await?;
235        Ok(())
236    }
237
238    /// Updates the commit log signer of the group. Will error if the user does not have the appropriate permissions
239    /// to perform these updates.
240    pub async fn update_commit_log_signer(
241        &self,
242        commit_log_signer: xmtp_cryptography::Secret,
243    ) -> Result<(), GroupError> {
244        self.ensure_not_paused().await?;
245
246        if self.metadata().await?.conversation_type == ConversationType::Dm {
247            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
248        }
249        let intent_data: Vec<u8> =
250            UpdateMetadataIntentData::new_update_commit_log_signer(commit_log_signer).into();
251        let intent = QueueIntent::metadata_update()
252            .data(intent_data)
253            .queue(self)?;
254
255        let _ = self.sync_until_intent_resolved(intent.id).await?;
256        Ok(())
257    }
258
259    pub(in crate::groups) fn min_protocol_version_from_extensions(
260        mutable_metadata: &GroupMutableMetadata,
261    ) -> Option<String> {
262        mutable_metadata
263            .attributes
264            .get(&MetadataField::MinimumSupportedProtocolVersion.to_string())
265            .map(|v| v.to_string())
266    }
267
268    /// Updates the permission policy of the group. This requires super admin permissions.
269    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
270    #[cfg_attr(
271        not(any(test, feature = "test-utils")),
272        tracing::instrument(level = "trace", skip(self))
273    )]
274    pub async fn update_permission_policy(
275        &self,
276        permission_update_type: PermissionUpdateType,
277        permission_policy: PermissionPolicyOption,
278        metadata_field: Option<MetadataField>,
279    ) -> Result<(), GroupError> {
280        self.ensure_not_paused().await?;
281
282        if self.metadata().await?.conversation_type == ConversationType::Dm {
283            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
284        }
285        if permission_update_type == PermissionUpdateType::UpdateMetadata
286            && metadata_field.is_none()
287        {
288            return Err(MetadataPermissionsError::InvalidPermissionUpdate.into());
289        }
290
291        let intent_data: Vec<u8> = UpdatePermissionIntentData::new(
292            permission_update_type,
293            permission_policy,
294            metadata_field.as_ref().map(|field| field.to_string()),
295        )
296        .into();
297
298        let intent = QueueIntent::update_permission()
299            .data(intent_data)
300            .queue(self)?;
301
302        let _ = self.sync_until_intent_resolved(intent.id).await?;
303        Ok(())
304    }
305
306    /// Retrieves the group name from the group's mutable metadata extension.
307    pub fn group_name(&self) -> Result<String, GroupError> {
308        self.read_single_component::<GroupNameComponent>()?
309            .ok_or_else(|| {
310                MetadataPermissionsError::from(GroupMutableMetadataError::MissingExtension).into()
311            })
312    }
313
314    /// Retrieves the app_data field from the group's mutable metadata extension
315    pub fn app_data(&self) -> Result<String, GroupError> {
316        self.read_single_component::<AppDataComponent>()?
317            .ok_or_else(|| {
318                MetadataPermissionsError::from(GroupMutableMetadataError::MissingExtension).into()
319            })
320    }
321
322    /// Updates the description of the group.
323    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
324    #[cfg_attr(
325        not(any(test, feature = "test-utils")),
326        tracing::instrument(level = "trace", skip(self))
327    )]
328    pub async fn update_group_description(
329        &self,
330        group_description: String,
331    ) -> Result<(), GroupError> {
332        self.ensure_not_paused().await?;
333
334        if group_description.len() > MAX_GROUP_DESCRIPTION_LENGTH {
335            return Err(GroupError::TooManyCharacters {
336                length: MAX_GROUP_DESCRIPTION_LENGTH,
337            });
338        }
339
340        if self.metadata().await?.conversation_type == ConversationType::Dm {
341            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
342        }
343        let intent_data: Vec<u8> =
344            UpdateMetadataIntentData::new_update_group_description(group_description).into();
345        let intent = QueueIntent::metadata_update()
346            .data(intent_data)
347            .queue(self)?;
348
349        let _ = self.sync_until_intent_resolved(intent.id).await?;
350        Ok(())
351    }
352
353    pub fn group_description(&self) -> Result<String, GroupError> {
354        self.read_single_component::<GroupDescriptionComponent>()?
355            .ok_or_else(|| {
356                GroupError::MetadataPermissionsError(
357                    GroupMutableMetadataError::MissingExtension.into(),
358                )
359            })
360    }
361
362    /// Updates the image URL (square) of the group.
363    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
364    #[cfg_attr(
365        not(any(test, feature = "test-utils")),
366        tracing::instrument(level = "trace", skip(self))
367    )]
368    pub async fn update_group_image_url_square(
369        &self,
370        group_image_url_square: String,
371    ) -> Result<(), GroupError> {
372        self.ensure_not_paused().await?;
373
374        if group_image_url_square.len() > MAX_GROUP_IMAGE_URL_LENGTH {
375            return Err(GroupError::TooManyCharacters {
376                length: MAX_GROUP_IMAGE_URL_LENGTH,
377            });
378        }
379
380        if self.metadata().await?.conversation_type == ConversationType::Dm {
381            return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into());
382        }
383        let intent_data: Vec<u8> =
384            UpdateMetadataIntentData::new_update_group_image_url_square(group_image_url_square)
385                .into();
386        let intent = QueueIntent::metadata_update()
387            .data(intent_data)
388            .queue(self)?;
389
390        let _ = self.sync_until_intent_resolved(intent.id).await?;
391        Ok(())
392    }
393
394    /// Retrieves the image URL (square) of the group from the group's mutable metadata extension.
395    pub fn group_image_url_square(&self) -> Result<String, GroupError> {
396        self.read_single_component::<GroupImageUrlComponent>()?
397            .ok_or_else(|| {
398                MetadataPermissionsError::Mutable(GroupMutableMetadataError::MissingExtension)
399                    .into()
400            })
401    }
402
403    pub async fn update_conversation_message_disappearing_settings(
404        &self,
405        settings: MessageDisappearingSettings,
406    ) -> Result<(), GroupError> {
407        self.ensure_not_paused().await?;
408
409        self.update_conversation_message_disappear_from_ns(settings.from_ns)
410            .await?;
411        self.update_conversation_message_disappear_in_ns(settings.in_ns)
412            .await
413    }
414
415    pub async fn remove_conversation_message_disappearing_settings(
416        &self,
417    ) -> Result<(), GroupError> {
418        self.ensure_not_paused().await?;
419
420        self.update_conversation_message_disappearing_settings(
421            MessageDisappearingSettings::default(),
422        )
423        .await
424    }
425
426    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
427    #[cfg_attr(
428        not(any(test, feature = "test-utils")),
429        tracing::instrument(level = "trace", skip(self))
430    )]
431    pub(in crate::groups) async fn update_conversation_message_disappear_from_ns(
432        &self,
433        expire_from_ms: i64,
434    ) -> Result<(), GroupError> {
435        self.ensure_not_paused().await?;
436
437        let intent_data: Vec<u8> =
438            UpdateMetadataIntentData::new_update_conversation_message_disappear_from_ns(
439                expire_from_ms,
440            )
441            .into();
442        let intent = QueueIntent::metadata_update()
443            .data(intent_data)
444            .queue(self)?;
445        let _ = self.sync_until_intent_resolved(intent.id).await?;
446        Ok(())
447    }
448
449    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
450    #[cfg_attr(
451        not(any(test, feature = "test-utils")),
452        tracing::instrument(level = "trace", skip(self))
453    )]
454    pub(in crate::groups) async fn update_conversation_message_disappear_in_ns(
455        &self,
456        expire_in_ms: i64,
457    ) -> Result<(), GroupError> {
458        self.ensure_not_paused().await?;
459
460        let intent_data: Vec<u8> =
461            UpdateMetadataIntentData::new_update_conversation_message_disappear_in_ns(expire_in_ms)
462                .into();
463        let intent = QueueIntent::metadata_update()
464            .data(intent_data)
465            .queue(self)?;
466        let _ = self.sync_until_intent_resolved(intent.id).await?;
467        Ok(())
468    }
469
470    /// If group is not paused, will return None, otherwise will return the version that the group is paused for
471    pub fn paused_for_version(&self) -> Result<Option<String>, GroupError> {
472        let paused_for_version = self.context.db().get_group_paused_version(&self.group_id)?;
473        Ok(paused_for_version)
474    }
475
476    #[tracing::instrument(skip_all, level = "trace")]
477    pub(in crate::groups) async fn ensure_not_paused(&self) -> Result<(), GroupError> {
478        if let Some(min_version) = self.context.db().get_group_paused_version(&self.group_id)? {
479            Err(GroupError::GroupPausedUntilUpdate(min_version))
480        } else {
481            Ok(())
482        }
483    }
484
485    pub fn conversation_message_disappearing_settings(
486        &self,
487    ) -> Result<MessageDisappearingSettings, GroupError> {
488        let metadata = self.mutable_metadata()?;
489        Self::conversation_message_disappearing_settings_from_extensions(&metadata)
490    }
491
492    pub fn conversation_message_disappearing_settings_from_extensions(
493        mutable_metadata: &GroupMutableMetadata,
494    ) -> Result<MessageDisappearingSettings, GroupError> {
495        let disappear_from_ns = mutable_metadata
496            .attributes
497            .get(&MetadataField::MessageDisappearFromNS.to_string());
498        let disappear_in_ns = mutable_metadata
499            .attributes
500            .get(&MetadataField::MessageDisappearInNS.to_string());
501
502        if let (Some(Ok(message_disappear_from_ns)), Some(Ok(message_disappear_in_ns))) = (
503            disappear_from_ns.map(|s| s.parse::<i64>()),
504            disappear_in_ns.map(|s| s.parse::<i64>()),
505        ) {
506            Ok(MessageDisappearingSettings::new(
507                message_disappear_from_ns,
508                message_disappear_in_ns,
509            ))
510        } else {
511            Err(GroupError::MetadataPermissionsError(
512                GroupMetadataError::MissingExtension.into(),
513            ))
514        }
515    }
516
517    pub fn pending_remove_list(&self) -> Result<Vec<String>, GroupError> {
518        self.context
519            .db()
520            .get_pending_remove_users(&self.group_id)
521            .map_err(Into::into)
522    }
523
524    /// Checks if the given inbox ID is the pending-remove list of the group at the most recently synced epoch.
525    pub fn is_in_pending_remove(&self, inbox_id: &str) -> Result<bool, GroupError> {
526        self.context
527            .db()
528            .get_user_pending_remove_status(&self.group_id, inbox_id)
529            .map_err(Into::into)
530    }
531
532    /// Retrieves the admin list of the group from the group's mutable metadata extension.
533    ///
534    /// Element order: on migrated groups the dict-backed `TlsSet<InboxId>`
535    /// is iterated in sorted-by-raw-bytes order. On unmigrated groups
536    /// the legacy `GroupMutableMetadata.admin_list` is returned in its
537    /// stored (insertion) order. Both contracts pre-date this refactor;
538    /// preserving each side avoids surprising binding consumers that
539    /// rely on the pre-migration order.
540    pub fn admin_list(&self) -> Result<Vec<String>, GroupError> {
541        self.read_admin_set_preserving_legacy_order(AdminListKind::Admin)
542    }
543
544    /// Retrieves the super admin list of the group from the group's mutable metadata extension.
545    ///
546    /// Same ordering contract as [`Self::admin_list`].
547    pub fn super_admin_list(&self) -> Result<Vec<String>, GroupError> {
548        self.read_admin_set_preserving_legacy_order(AdminListKind::SuperAdmin)
549    }
550
551    fn read_admin_set_preserving_legacy_order(
552        &self,
553        kind: AdminListKind,
554    ) -> Result<Vec<String>, GroupError> {
555        let ctx = self.load_group_context()?;
556        let extensions = ctx.extensions();
557        if self::app_data::is_migrated_extensions(extensions) {
558            let facade = self::app_data::typed_facade::MlsGroupAppData::new(extensions);
559            let set = match kind {
560                AdminListKind::Admin => facade.get::<AdminListComponent>(),
561                AdminListKind::SuperAdmin => facade.get::<SuperAdminListComponent>(),
562            }
563            .map_err(|e| {
564                GroupError::MetadataPermissionsError(MetadataPermissionsError::ComponentSource(e))
565            })?;
566            Ok(set
567                .map(|s| s.iter().map(|id| id.to_hex()).collect())
568                .unwrap_or_default())
569        } else {
570            // Unmigrated: return the Vec<String> straight from the legacy GMM
571            // extension so callers keep their pre-migration insertion order.
572            // Propagate decode errors (e.g. a corrupted legacy GMM extension)
573            // via the same `MetadataPermissionsError::Mutable(...)` shape that
574            // pre-refactor `mutable_metadata()?.admin_list` produced — a soft
575            // `.ok()` here would convert a loud failure into silent "no admins"
576            // data corruption. `MissingExtension` is the legacy "no extension on
577            // the group" case and remains the soft-skip → empty Vec contract.
578            let metadata = match xmtp_mls_common::group_mutable_metadata::extract_legacy_group_mutable_metadata_from_extensions(
579                extensions,
580            ) {
581                Ok(m) => Some(m),
582                Err(xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::MissingExtension) => {
583                    // Expected on very old groups created before the legacy GMM
584                    // extension existed; logged at debug to give operators
585                    // visibility without spamming warn on a legitimate state. An
586                    // empty list is the contract callers expect (admin_list /
587                    // super_admin_list return `Ok(vec![])` here, not `Err`).
588                    tracing::debug!(
589                        group_id = %self.group_id,
590                        kind = ?kind,
591                        "unmigrated group has no legacy GroupMutableMetadata extension; returning empty admin set"
592                    );
593                    None
594                }
595                Err(e) => {
596                    return Err(GroupError::MetadataPermissionsError(
597                        MetadataPermissionsError::Mutable(e),
598                    ));
599                }
600            };
601            Ok(metadata
602                .map(|m| match kind {
603                    AdminListKind::Admin => m.admin_list,
604                    AdminListKind::SuperAdmin => m.super_admin_list,
605                })
606                .unwrap_or_default())
607        }
608    }
609
610    /// Checks if the given inbox ID is an admin of the group at the most recently synced epoch.
611    pub fn is_admin(&self, inbox_id: String) -> Result<bool, GroupError> {
612        let mutable_metadata = self.mutable_metadata()?;
613        Ok(mutable_metadata.admin_list.contains(&inbox_id))
614    }
615
616    /// Checks if the given inbox ID is a super admin of the group at the most recently synced epoch.
617    pub fn is_super_admin(&self, inbox_id: String) -> Result<bool, GroupError> {
618        let mutable_metadata = self.mutable_metadata()?;
619        Ok(mutable_metadata.super_admin_list.contains(&inbox_id))
620    }
621
622    /// Checks if the given inbox ID is a super admin of the group at the most recently synced epoch
623    pub fn is_super_admin_without_lock(
624        &self,
625        mls_group: &OpenMlsGroup,
626        inbox_id: String,
627    ) -> Result<bool, GroupMutableMetadataError> {
628        // On migrated groups, the legacy GMM extension is gone — read
629        // SUPER_ADMIN_LIST from the AppData dict. A missing dict
630        // entry on a migrated group is treated as "no super-admins":
631        // falling through to `GroupMutableMetadata::try_from(mls_group)`
632        // would hit `MissingExtension` because bootstrap has already
633        // stripped the legacy GMM. Today bootstrap always seeds an
634        // (empty or populated) `SUPER_ADMIN_LIST` entry so the `None`
635        // branch is defensive, but the explicit handling keeps the
636        // read-side safe against any future weakening of that
637        // invariant.
638        //
639        // On unmigrated groups we fall back to the legacy GMM
640        // extension — that path is unchanged.
641        if self::app_data::is_migrated_group(mls_group) {
642            let list = self::app_data::component_source::read_super_admin_list_from_dict(mls_group)
643                .map_err(GroupMutableMetadataError::from)?
644                .unwrap_or_default();
645            return Ok(list.contains(&inbox_id));
646        }
647        let mutable_metadata = GroupMutableMetadata::try_from(mls_group)?;
648        Ok(mutable_metadata.super_admin_list.contains(&inbox_id))
649    }
650
651    /// Retrieves the conversation type of the group from the group's metadata extension.
652    pub async fn conversation_type(&self) -> Result<ConversationType, GroupError> {
653        let conversation_type = self.context.db().get_conversation_type(&self.group_id)?;
654        Ok(conversation_type)
655    }
656}