Skip to main content

xmtp_mls/groups/
metadata.rs

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