Skip to main content

xmtp_mls/groups/
mod.rs

1pub mod app_data;
2pub mod hash {
3    pub use xmtp_cryptography::hash::sha256_bytes as sha256;
4}
5mod builders;
6mod lifecycle;
7mod membership;
8mod messages;
9mod metadata;
10mod metadata_fields;
11mod state;
12pub use crate::core::change_callbacks;
13pub use builders::*;
14pub use state::{ConversationStateSnapshot, GroupMetadataSnapshot};
15pub mod commit_log;
16pub mod context;
17pub(crate) mod deletion;
18#[cfg(any(test, feature = "diagnostics"))]
19pub(crate) mod diagnostics;
20mod error;
21pub mod intents;
22pub mod members;
23pub mod message_id;
24pub mod message_list;
25pub(super) mod mls_ext;
26pub(super) mod mls_sync;
27mod notifications;
28pub mod oneshot;
29pub mod send_message_opts;
30pub(super) mod subscriptions;
31pub mod summary;
32pub(crate) mod targets;
33#[cfg(any(test, feature = "test-utils"))]
34#[doc(hidden)]
35pub mod test_support;
36#[cfg(any(test, feature = "test-hooks"))]
37#[doc(hidden)]
38pub use mls_sync::test_mocks;
39#[cfg(test)]
40mod tests;
41mod traits;
42pub mod validated_commit;
43pub mod welcome_pointer;
44pub mod welcome_sync;
45mod welcomes;
46pub use welcomes::*;
47
48pub use self::group_permissions::PreconfiguredPolicies;
49use self::{
50    group_membership::GroupMembership,
51    group_permissions::GroupMutablePermissions,
52    group_permissions::PolicySet,
53    intents::{
54        AdminListActionType, PermissionPolicyOption, PermissionUpdateType,
55        UpdateAdminListIntentData, UpdateMetadataIntentData, UpdatePermissionIntentData,
56    },
57};
58use crate::context::XmtpSharedContext;
59#[cfg(any(test, feature = "test-hooks"))]
60use crate::core::commit_lock::GroupCommitLock;
61use crate::groups::message_id::{calculate_message_id, decode_message_reference};
62use crate::groups::send_message_opts::SendMessageOpts;
63use crate::groups::{
64    intents::{QueueIntent, ReaddInstallationsIntentData},
65    mls_ext::CommitLogStorer,
66};
67use crate::state_tx::state_write;
68pub use error::*;
69use intents::SendMessageIntentData;
70pub use intents::UpdateGroupMembershipResult;
71// An error type that other errors wrap transparently, so a caller that walks
72// an error chain can open it.
73pub use mls_sync::GroupMessageProcessingError;
74use openmls::{
75    credentials::CredentialType,
76    extensions::{
77        Extension, ExtensionType, Extensions, RequiredCapabilitiesExtension, UnknownExtension,
78    },
79    group::MlsGroupCreateConfig,
80    messages::proposals::ProposalType,
81    prelude::{Capabilities, MlsGroup as OpenMlsGroup, WireFormatPolicy},
82};
83use prost::Message;
84use std::collections::HashMap;
85use std::{collections::HashSet, sync::Arc};
86use tokio::sync::Mutex;
87use xmtp_common::{Event, log_event, time::now_ns};
88use xmtp_configuration::{
89    GROUP_MEMBERSHIP_EXTENSION_ID, MAX_PAST_EPOCHS, SEND_MESSAGE_UPDATE_INSTALLATIONS_INTERVAL_NS,
90    WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID, WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID,
91};
92use xmtp_content_types::delete_message::DeleteMessageCodec;
93use xmtp_content_types::leave_request::LeaveRequestCodec;
94use xmtp_content_types::{ContentCodec, encoded_content_to_bytes};
95use xmtp_content_types::{
96    reaction::{LegacyReaction, ReactionCodec},
97    reply::ReplyCodec,
98};
99use xmtp_cryptography::configuration::CIPHERSUITE;
100use xmtp_cryptography::secret::Zeroize;
101use xmtp_db::Fetch;
102use xmtp_db::pending_remove::QueryPendingRemove;
103use xmtp_db::prelude::*;
104use xmtp_db::user_preferences::HmacKey;
105use xmtp_db::{
106    NotFound, StorageError,
107    group_intent::{IntentState, StoredGroupIntent},
108    group_message::ContentType,
109    refresh_state::EntityKind,
110};
111use xmtp_db::{Store, StoreOrIgnore};
112use xmtp_db::{TransactionOutcome, TransactionOutcome::Continue, XmtpOpenMlsProviderRef};
113use xmtp_db::{
114    XmtpMlsStorageProvider,
115    remote_commit_log::{RemoteCommitLog, RemoteCommitLogOrder},
116};
117use xmtp_db::{
118    consent_record::{
119        ConsentActError, ConsentKey, ConsentState, ConsentWrite, Decision, Owed,
120        conversation_consent, write_consent,
121    },
122    group::{ConversationType, GroupMembershipState, StoredGroup},
123    group_message::{DeliveryStatus, GroupMessageKind, MsgQueryArgs, StoredGroupMessage},
124};
125use xmtp_db::{group_message::LatestMessageTimeBySender, local_commit_log::LocalCommitLog};
126use xmtp_id::associations::Identifier;
127use xmtp_id::{AsIdRef, InboxId, InboxIdRef};
128use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
129use xmtp_mls_group_state::{
130    app_data::components::{
131        inbox_id_set::{AdminListComponent, SuperAdminListComponent},
132        metadata_attributes::{
133            AppDataComponent, GroupDescriptionComponent, GroupImageUrlComponent, GroupNameComponent,
134        },
135    },
136    group::GroupMetadataOptions,
137    group_metadata::{DmMembers, GroupMetadata, GroupMetadataError, extract_group_metadata},
138    group_mutable_metadata::{
139        GroupMutableMetadata, GroupMutableMetadataError, MessageDisappearingSettings, MetadataField,
140    },
141};
142pub use xmtp_mls_validation::{group_membership, group_permissions};
143use xmtp_proto::xmtp::mls::message_contents::content_types::{DeleteMessage, LeaveRequest};
144use xmtp_proto::{
145    types::{Cursor, GroupId},
146    xmtp::mls::message_contents::{
147        ContentTypeId, EncodedContent, OneshotMessage, PlaintextEnvelope,
148        content_types::ReactionV2,
149        plaintext_envelope::{Content, V1},
150    },
151};
152
153use xmtp_mls_group_state::app_data::components::metadata_attributes::{
154    MAX_APP_DATA_LENGTH, MAX_GROUP_DESCRIPTION_LENGTH, MAX_GROUP_IMAGE_URL_LENGTH,
155    MAX_GROUP_NAME_LENGTH,
156};
157const DEFAULT_IDEMPOTENCY_KEY_BYTES: usize = 16;
158
159/// An LibXMTP MlsGroup
160/// _NOTE:_ The Eq implementation compares [`GroupId`], so a dm group with the same identity will be
161/// different.
162/// the Hash implementation hashes the [`GroupId`]
163pub struct MlsGroup<Context> {
164    pub group_id: GroupId,
165    pub dm_id: Option<String>,
166    pub conversation_type: ConversationType,
167    pub created_at_ns: i64,
168    pub context: Context,
169    #[cfg(any(test, feature = "test-hooks"))]
170    mls_commit_lock: Arc<GroupCommitLock>,
171    mutex: Arc<Mutex<()>>,
172}
173
174impl<C> std::hash::Hash for MlsGroup<C> {
175    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
176        self.group_id.hash(state);
177    }
178}
179
180impl<C> PartialEq for MlsGroup<C> {
181    fn eq(&self, other: &Self) -> bool {
182        self.group_id == other.group_id
183    }
184}
185
186impl<C> Eq for MlsGroup<C> {}
187
188impl<Context> std::fmt::Debug for MlsGroup<Context>
189where
190    Context: XmtpSharedContext,
191{
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
193        let id = xmtp_common::fmt::truncate_hex(hex::encode(self.group_id));
194        let inbox_id = self.context.inbox_id();
195        let installation = self.context.installation_id().to_string();
196        let time = chrono::DateTime::from_timestamp_nanos(self.created_at_ns);
197        write!(
198            f,
199            "Group {{ id: [{}], created: [{}], client: [{}], installation: [{}] }}",
200            id,
201            time.format("%H:%M:%S"),
202            inbox_id,
203            installation
204        )
205    }
206}
207
208/// One conversation of a list page. It carries no message content.
209pub struct ConversationListItem<Context> {
210    pub group: MlsGroup<Context>,
211    pub membership_state: GroupMembershipState,
212    pub added_by_inbox_id: String,
213    pub is_commit_log_forked: Option<bool>,
214    /// The time the list orders by: the creation time in creation order, and
215    /// the activity time (`MlsGroup::last_activity_ns(None)`) in activity
216    /// order.
217    pub listing_time_ns: i64,
218}
219
220impl<Context: XmtpSharedContext> Clone for MlsGroup<Context> {
221    fn clone(&self) -> Self {
222        Self {
223            group_id: self.group_id,
224            dm_id: self.dm_id.clone(),
225            conversation_type: self.conversation_type,
226            created_at_ns: self.created_at_ns,
227            context: self.context.clone(),
228            mutex: self.mutex.clone(),
229            #[cfg(any(test, feature = "test-hooks"))]
230            mls_commit_lock: self.mls_commit_lock.clone(),
231        }
232    }
233}
234
235#[derive(Debug, Clone, PartialEq)]
236pub struct ConversationDebugInfo {
237    pub epoch: u64,
238    pub maybe_forked: bool,
239    pub fork_details: String,
240    pub is_commit_log_forked: Option<bool>,
241    pub local_commit_log: String,
242    pub remote_commit_log: String,
243    pub cursor: Vec<Cursor>,
244}
245
246#[derive(Debug, Clone, PartialEq)]
247pub enum UpdateAdminListType {
248    Add,
249    Remove,
250    AddSuper,
251    RemoveSuper,
252}
253
254#[derive(Debug, Clone, Copy)]
255enum AdminListKind {
256    Admin,
257    SuperAdmin,
258}
259
260/// Fields extracted from content of a message that should be stored in the DB
261pub struct QueryableContentFields {
262    pub content_type: ContentType,
263    pub version_major: i32,
264    pub version_minor: i32,
265    pub authority_id: String,
266    pub reference_id: Option<Vec<u8>>,
267}
268
269impl Default for QueryableContentFields {
270    fn default() -> Self {
271        Self {
272            content_type: ContentType::Unknown, // Or whatever the appropriate default is
273            version_major: 0,
274            version_minor: 0,
275            authority_id: String::new(),
276            reference_id: None,
277        }
278    }
279}
280
281impl QueryableContentFields {
282    /// Whether content of this type carries a reference id in its payload.
283    pub(crate) fn has_reference(content_type_id: &ContentTypeId) -> bool {
284        content_type_id.authority_id == "xmtp.org"
285            && matches!(
286                (
287                    content_type_id.type_id.as_str(),
288                    content_type_id.version_major
289                ),
290                (ReplyCodec::TYPE_ID, 1)
291                    | (ReactionCodec::TYPE_ID, ReactionCodec::MAJOR_VERSION)
292                    | (ReactionCodec::TYPE_ID, 1)
293                    | (
294                        DeleteMessageCodec::TYPE_ID,
295                        DeleteMessageCodec::MAJOR_VERSION
296                    )
297            )
298    }
299
300    /// The fields of a content type with no reference id.
301    pub(crate) fn from_type(content_type_id: &ContentTypeId) -> Self {
302        QueryableContentFields {
303            content_type: ContentType::from_identifier(
304                &content_type_id.authority_id,
305                &content_type_id.type_id,
306                content_type_id.version_major,
307            ),
308            version_major: content_type_id.version_major as i32,
309            version_minor: content_type_id.version_minor as i32,
310            authority_id: content_type_id.authority_id.to_string(),
311            reference_id: None,
312        }
313    }
314}
315
316impl TryFrom<EncodedContent> for QueryableContentFields {
317    type Error = prost::DecodeError;
318
319    fn try_from(content: EncodedContent) -> Result<Self, Self::Error> {
320        let content_type_id = content.r#type.clone().unwrap_or_default();
321
322        let type_id_str = content_type_id.type_id.clone();
323
324        // Invalid compressed content has no searchable reference. Message
325        // decoding still exposes the original envelope to the app.
326        // The content can hold a device-sync HMAC root key, so the decoded
327        // copy is wiped once its reference is read.
328        let mut decoded = xmtp_content_types::compression::decompress(content).ok();
329        let reference_id = decoded.as_ref().and_then(|content| {
330            if content_type_id.authority_id != "xmtp.org" {
331                return None;
332            }
333            match (type_id_str.as_str(), content_type_id.version_major) {
334                (ReplyCodec::TYPE_ID, 1) => ReplyCodec::decode(content.clone())
335                    .ok()
336                    .and_then(|reply| decode_message_reference(&reply.reference)),
337                (ReactionCodec::TYPE_ID, ReactionCodec::MAJOR_VERSION) => {
338                    ReactionV2::decode(content.content.as_slice())
339                        .ok()
340                        .and_then(|reaction| decode_message_reference(&reaction.reference))
341                }
342                (ReactionCodec::TYPE_ID, 1) => {
343                    LegacyReaction::decode(&content.content).and_then(|legacy_reaction| {
344                        decode_message_reference(&legacy_reaction.reference)
345                    })
346                }
347                (DeleteMessageCodec::TYPE_ID, DeleteMessageCodec::MAJOR_VERSION) => {
348                    DeleteMessage::decode(content.content.as_slice())
349                        .ok()
350                        .and_then(|delete_msg| decode_message_reference(&delete_msg.message_id))
351                }
352                _ => None,
353            }
354        });
355        if let Some(content) = &mut decoded {
356            content.content.zeroize();
357        }
358
359        Ok(QueryableContentFields {
360            reference_id,
361            ..QueryableContentFields::from_type(&content_type_id)
362        })
363    }
364}
365
366impl<Context: Clone> From<MlsGroup<&Context>> for MlsGroup<Context> {
367    fn from(group: MlsGroup<&Context>) -> MlsGroup<Context> {
368        MlsGroup::<Context> {
369            context: group.context.clone(),
370            group_id: group.group_id,
371            dm_id: group.dm_id,
372            created_at_ns: group.created_at_ns,
373            #[cfg(any(test, feature = "test-hooks"))]
374            mls_commit_lock: group.mls_commit_lock,
375            mutex: group.mutex,
376            conversation_type: group.conversation_type,
377        }
378    }
379}
380
381/// An MLS extension type advertised by an installation's key package or
382/// present in a group's context.
383///
384/// Mirrors openmls [`ExtensionType`]; unknown/forward-compatibility variants
385/// are preserved verbatim so callers can match on what they understand and
386/// ignore the rest. This is a generic capability primitive — callers filter
387/// it to answer specific questions (e.g. "is this group migrated to the
388/// proposal flow?" = a list contains [`MlsExtensionType::AppDataDictionary`]).
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub enum MlsExtensionType {
391    ApplicationId,
392    RatchetTree,
393    RequiredCapabilities,
394    ExternalPub,
395    ExternalSenders,
396    LastResort,
397    AppDataDictionary,
398    /// An extension type this build does not have a named variant for.
399    Unknown(u16),
400    /// A GREASE value used to exercise extensibility.
401    Grease(u16),
402}
403
404impl From<ExtensionType> for MlsExtensionType {
405    fn from(value: ExtensionType) -> Self {
406        match value {
407            ExtensionType::ApplicationId => MlsExtensionType::ApplicationId,
408            ExtensionType::RatchetTree => MlsExtensionType::RatchetTree,
409            ExtensionType::RequiredCapabilities => MlsExtensionType::RequiredCapabilities,
410            ExtensionType::ExternalPub => MlsExtensionType::ExternalPub,
411            ExtensionType::ExternalSenders => MlsExtensionType::ExternalSenders,
412            ExtensionType::LastResort => MlsExtensionType::LastResort,
413            ExtensionType::AppDataDictionary => MlsExtensionType::AppDataDictionary,
414            ExtensionType::Unknown(id) => MlsExtensionType::Unknown(id),
415            ExtensionType::Grease(id) => MlsExtensionType::Grease(id),
416        }
417    }
418}
419
420/// Capability snapshot for a single installation (device) of a member.
421#[derive(Debug, Clone)]
422pub struct InstallationCapabilities {
423    pub installation_id: Vec<u8>,
424    /// True for the local (this device's) installation.
425    pub is_own: bool,
426    /// The MLS extension types this installation advertises, taken from its
427    /// *latest published* key package. Empty when `capabilities_known` is
428    /// false.
429    pub supported_extensions: Vec<MlsExtensionType>,
430    /// Whether capabilities were determined. `false` means the key package
431    /// could not be fetched or failed verification — distinct from an
432    /// installation that advertises no extensions.
433    pub capabilities_known: bool,
434}
435
436/// Per-inbox grouping of installation capabilities. Callers map `inbox_id`
437/// back to a profile to attribute capabilities to a person.
438#[derive(Debug, Clone)]
439pub struct InboxCapabilities {
440    pub inbox_id: InboxId,
441    pub installations: Vec<InstallationCapabilities>,
442}
443
444/// A generic membership/capability snapshot for a group.
445///
446/// This intentionally reports raw facts rather than answers, so callers can
447/// filter it to whatever question they care about. For the proposal
448/// (app-data-dictionary) migration specifically: the group is already
449/// migrated when `context_extensions` contains
450/// [`MlsExtensionType::AppDataDictionary`], it is eligible to migrate when
451/// every installation's `supported_extensions` contains it, and the inboxes
452/// blocking migration are those with an installation that does not.
453#[derive(Debug, Clone)]
454pub struct GroupMembershipCapabilities {
455    /// Extension types present in the group's context.
456    pub context_extensions: Vec<MlsExtensionType>,
457    /// Per-inbox, per-installation capability breakdown — one entry per member
458    /// inbox, in no particular order.
459    pub members: Vec<InboxCapabilities>,
460}