Skip to main content

xmtp_mls/groups/
mod.rs

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