Skip to main content

xmtp_mls/groups/welcomes/
xmtp_welcome.rs

1//! XMTP Welcome Processing
2//! Processes a new welcome from the network
3
4use std::collections::HashSet;
5
6use crate::groups::MetadataPermissionsError;
7use crate::groups::mls_ext::CommitLogStorer;
8use crate::groups::mls_ext::ResolvedWelcome;
9use crate::groups::oneshot::Oneshot;
10use crate::groups::welcomes::WelcomeMembership;
11use crate::identity_updates::{
12    IdentityDependencyError, IdentityRequirement, InstallationDiffError,
13};
14use crate::state_tx::{state_write, state_write_with_events};
15use crate::subscriptions::internal::{InternalEvent, PreferenceOrigin, emit_preference_updates};
16use crate::worker::device_sync::is_own_sync_group;
17use crate::{
18    context::XmtpSharedContext,
19    groups::{
20        GroupError, MlsGroup, ValidateGroupMembership, mls_ext::DecryptedWelcome, validate_dm_group,
21    },
22    intents::ProcessIntentError,
23};
24use derive_builder::Builder;
25use openmls::group::MlsGroup as OpenMlsGroup;
26use prost::Message;
27use xmtp_common::{snippet::Snippet, time::now_ns};
28use xmtp_content_types::ContentCodec;
29use xmtp_content_types::group_updated::GroupUpdatedCodec;
30use xmtp_db::TransactionOutcome::{Continue, Rollback};
31use xmtp_db::{
32    TransactionOutcome, XmtpOpenMlsProviderRef,
33    consent_record::{ConsentState, StoredConsentRecord},
34    group::{ConversationType, GroupMembershipState, StoredGroup},
35    group_message::{ContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage},
36    incoming_envelope::{JoinAnchorMode, NetworkEntityKind, StoredIncomingEnvelope, StreamTopic},
37    prelude::*,
38    refresh_state::EntityKind,
39};
40use xmtp_events::{
41    ClientEvent, ConversationJoined, ConversationType as EventConversationType, EventBuffer,
42    EventWriter, JoinOrigin,
43};
44use xmtp_mls_common::group_metadata::extract_group_metadata;
45use xmtp_mls_common::libxmtp_version::LibXMTPVersion;
46
47use xmtp_mls_common::app_data::component_source::extract_group_mutable_metadata_capability_aware;
48use xmtp_proto::types::Cursor;
49use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, GroupUpdated, group_updated::Inbox};
50
51use xmtp_proto::types::GroupId;
52/// Create a group from a decrypted and decoded welcome message.
53/// An existing group can be replaced only after its removal has been processed.
54///
55/// # Parameters
56/// * `context` - The client context to use for group operations
57/// * `welcome` - The encrypted welcome message
58/// * `pending` - The exact durable Welcome row that this attempt must complete.
59/// * `validator` - The validator to use to check the group membership
60#[derive(Builder)]
61#[builder(
62    pattern = "owned",
63    setter(strip_option),
64    build_fn(error = "GroupError", private)
65)]
66pub struct XmtpWelcome<'a, C, V> {
67    context: C,
68    /// Immutable network input. Each attempt reads its private keys again.
69    welcome: &'a xmtp_proto::types::WelcomeMessage,
70    /// Exact durable row to complete in the same transaction as the join.
71    pending: StoredIncomingEnvelope,
72    validator: V,
73}
74
75impl<'a, C, V> XmtpWelcome<'a, C, V> {
76    pub fn builder() -> XmtpWelcomeBuilder<'a, C, V> {
77        Default::default()
78    }
79}
80
81/// A committed join or safe rejection that completes the pending Welcome row.
82enum CommitResult<C> {
83    /// Invalid input was rejected without keeping trial MLS writes.
84    FailedForever(GroupError),
85    /// Successfully decrypted and processed
86    Ok(Option<MlsGroup<C>>),
87}
88
89/// Only invalid input can complete a rejected Welcome. Local failures remain pending.
90pub(crate) fn terminal_welcome_error(error: &GroupError) -> bool {
91    use openmls::prelude::WelcomeError;
92    matches!(
93        error,
94        GroupError::InvalidWelcomeMetadata
95            | GroupError::InvalidGroupMembership
96            | GroupError::MetadataPermissionsError(_)
97            | GroupError::NoPSKSupport
98            | GroupError::TlsError(_)
99            | GroupError::CredentialError(_)
100            | GroupError::Identity(
101                crate::identity::IdentityError::Decode(_)
102                    | crate::identity::IdentityError::BasicCredential(_)
103            )
104            | GroupError::ConversionError(_)
105            | GroupError::UnwrapWelcome(_)
106            | GroupError::ProcessIntent(ProcessIntentError::WelcomeAlreadyProcessed(_))
107            | GroupError::InstallationDiff(InstallationDiffError::IdentityDependency(
108                IdentityDependencyError::InvalidSequence(_)
109                    | IdentityDependencyError::MissingReference(_)
110            ))
111            | GroupError::WelcomeError(
112                WelcomeError::GroupSecrets(_)
113                    | WelcomeError::CiphersuiteMismatch
114                    | WelcomeError::GroupInfo(_)
115                    | WelcomeError::JoinerSecretNotFound
116                    | WelcomeError::MissingRatchetTree
117                    | WelcomeError::ConfirmationTagMismatch
118                    | WelcomeError::InvalidGroupInfoSignature
119                    | WelcomeError::UnknownSender
120                    | WelcomeError::NotAWelcomeMessage
121                    | WelcomeError::MalformedWelcomeMessage
122                    | WelcomeError::UnableToDecrypt
123                    | WelcomeError::PublicTreeError(_)
124                    | WelcomeError::LeafNodeValidation(_)
125            )
126    )
127}
128
129impl<C> CommitResult<C> {
130    fn into_result(self) -> Result<Option<MlsGroup<C>>, GroupError> {
131        match self {
132            Self::FailedForever(err) => Err(err),
133            Self::Ok(group) => Ok(group),
134        }
135    }
136}
137
138impl<'a, C, V> XmtpWelcomeBuilder<'a, C, V>
139where
140    C: XmtpSharedContext,
141    V: ValidateGroupMembership,
142{
143    /// Resolve dependencies from a rolled-back trial, then install from fresh state.
144    // Named explicitly (derived `mls.process` is too generic) and without `err`:
145    // duplicate welcomes exit as Err(WelcomeAlreadyProcessed), an expected
146    // outcome; unexpected failures set status on mls.process_new_welcome above.
147    #[tracing::instrument(skip_all, fields(operation = "mls.process_welcome"))]
148    pub async fn process(self) -> Result<Option<MlsGroup<C>>, GroupError> {
149        let this = self.build()?;
150        this.check_pending(&this.context.db())?;
151
152        let (resolved, membership) = match this.validate_membership().await {
153            Err(error) => return this.reject_or_retry(error),
154            Ok(validated) => validated,
155        };
156        let commit_result = this.commit_or_fail_forever(&resolved, Some(&membership), None)?;
157        commit_result.into_result()
158    }
159
160    /// Restage under the writer and return missing dependencies to the scheduler.
161    /// Only a matching resolver result can prove an identity reference is absent.
162    pub(crate) fn process_resolved(
163        self,
164        resolved: &ResolvedWelcome,
165        missing_reference: Option<&IdentityRequirement>,
166    ) -> Result<Option<MlsGroup<C>>, GroupError> {
167        let this = self.build()?;
168        this.commit_or_fail_forever(resolved, None, missing_reference)?
169            .into_result()
170    }
171}
172
173impl<'a, C, V> XmtpWelcome<'a, C, V>
174where
175    C: XmtpSharedContext,
176    V: ValidateGroupMembership,
177    <C::MlsStorage as XmtpMlsStorageProvider>::Connection: xmtp_db::ConnectionExt,
178{
179    fn topic(&self) -> StreamTopic {
180        StreamTopic {
181            entity_id: self.context.installation_id().to_vec(),
182            kind: NetworkEntityKind::Welcome,
183        }
184    }
185
186    /// Require the same pending row before any join or rejection can commit.
187    fn check_pending(&self, db: &impl DbQuery) -> Result<(), GroupError> {
188        let current = db.pending_envelope(&self.topic(), self.welcome.cursor)?;
189        if current.is_none_or(|row| row.envelope != self.pending.envelope)
190            || self.pending.sequence_id != self.welcome.cursor.0 as i64
191            || self.pending.entity_id != self.context.installation_id()
192        {
193            return Err(ProcessIntentError::WelcomeAlreadyProcessed(self.welcome.cursor).into());
194        }
195        Ok(())
196    }
197
198    fn reject_or_retry(&self, error: GroupError) -> Result<Option<MlsGroup<C>>, GroupError> {
199        if terminal_welcome_error(&error) {
200            state_write(self.context.mls_storage(), |tx| {
201                let storage = tx.storage();
202                let db = storage.db();
203                self.check_pending(&db)?;
204                db.record_terminal_rejection(
205                    &self.topic(),
206                    self.welcome.cursor,
207                    "invalid_welcome",
208                )?;
209                db.complete_pending_envelope(&self.topic(), self.welcome.cursor)?;
210                Ok::<_, GroupError>(Continue(()))
211            })?;
212        }
213        Err(error)
214    }
215
216    /// Roll back trial MLS writes before resolving exact identity proofs.
217    /// Only immutable input and public membership data cross the network await.
218    async fn validate_membership(
219        &self,
220    ) -> Result<(ResolvedWelcome, WelcomeMembership), GroupError> {
221        let resolved = ResolvedWelcome::resolve(self.welcome, &self.context).await?;
222        let mut membership = None;
223        state_write(self.context.mls_storage(), |tx| {
224            let storage = tx.storage();
225            let decrypted = resolved.stage(self.welcome, &storage)?;
226            self.join_anchor(&decrypted)?;
227            membership = Some(WelcomeMembership::from_staged(&decrypted.staged_welcome)?);
228            Ok::<_, GroupError>(Rollback::<()>)
229        })?;
230        let membership = membership.ok_or(GroupError::UninitializedResult)?;
231        membership.validate_sequences(self.welcome.sequence_id())?;
232        self.validator.check_initial_membership(&membership).await?;
233        Ok((resolved, membership))
234    }
235
236    /// Require an authenticated anchor before this Welcome's sequence.
237    /// Zero is valid only for epoch zero or an Oneshot Welcome.
238    // implements: JOIN-036, JOIN-037, JOIN-038
239    fn join_anchor(&self, decrypted: &DecryptedWelcome) -> Result<Cursor, GroupError> {
240        let metadata = extract_group_metadata(
241            decrypted
242                .staged_welcome
243                .public_group()
244                .group_context()
245                .extensions(),
246        )
247        .map_err(MetadataPermissionsError::from)?;
248        let anchor = match &decrypted.welcome_metadata {
249            Some(metadata) => metadata.message_cursor,
250            None if metadata.conversation_type == ConversationType::Oneshot => 0,
251            None => return Err(GroupError::InvalidWelcomeMetadata),
252        };
253        let initial = metadata.conversation_type == ConversationType::Oneshot
254            || decrypted
255                .staged_welcome
256                .public_group()
257                .group_context()
258                .epoch()
259                .as_u64()
260                == 0;
261        if anchor >= self.welcome.sequence_id() || (anchor == 0 && !initial) {
262            return Err(GroupError::InvalidWelcomeMetadata);
263        }
264        Ok(Cursor(anchor))
265    }
266
267    /// Commit a valid Welcome or a safe rejection with its pending-row completion.
268    /// Other failures roll back all state. Send events only after commit.
269    fn commit_or_fail_forever(
270        &self,
271        resolved: &ResolvedWelcome,
272        membership: Option<&WelcomeMembership>,
273        missing_reference: Option<&IdentityRequirement>,
274    ) -> Result<CommitResult<C>, GroupError> {
275        tracing::debug!("attempting to commit welcome={}", &self.welcome.cursor);
276        let commit_result = state_write_with_events(
277            self.context.mls_storage(),
278            self.context.events(),
279            |tx, event_buffer| {
280                let storage = tx.storage();
281                self.check_pending(&storage.db())?;
282                // Savepoint transaction
283                let result = event_buffer.savepoint(|event_buffer| {
284                    storage.savepoint(|conn| {
285                        self.commit(conn, resolved, membership, event_buffer)
286                            .map(Continue)
287                    })
288                });
289                let db = storage.db();
290                // Only the resolver can prove that an exact identity reference is absent.
291                let result = result.map_err(|error| match (&error, missing_reference) {
292                    (
293                        GroupError::InstallationDiff(InstallationDiffError::IdentityDependency(
294                            IdentityDependencyError::Need(required),
295                        )),
296                        Some(missing),
297                    ) if required == missing => InstallationDiffError::IdentityDependency(
298                        IdentityDependencyError::MissingReference(missing.clone()),
299                    )
300                    .into(),
301                    _ => error,
302                });
303                match result {
304                    Err(err) if terminal_welcome_error(&err) => {
305                        db.record_terminal_rejection(
306                            &self.topic(),
307                            self.welcome.cursor,
308                            "invalid_welcome",
309                        )?;
310                        db.complete_pending_envelope(&self.topic(), self.welcome.cursor)?;
311                        // return ok to commit the transaction
312                        Ok(Continue(CommitResult::FailedForever(err)))
313                    }
314                    // roll everything back to retry
315                    Err(e) => Err(e),
316                    Ok(Continue(group)) => {
317                        db.complete_pending_envelope(&self.topic(), self.welcome.cursor)?;
318                        self.context.task_channels().mark_notification_changed();
319                        Ok(Continue(CommitResult::Ok(group)))
320                    }
321                    Ok(Rollback) => {
322                        unreachable!("savepoint never intentionally rolls back here")
323                    }
324                }
325            },
326        )
327        .map(TransactionOutcome::into_continued)?;
328        Ok(commit_result)
329    }
330
331    /// Restage and recheck the join against this writer's keys, proofs, and group.
332    /// An active older group must process its removal before replacement.
333    /// The caller commits the join and pending-row completion together.
334    // implements: JOIN-080
335    fn commit(
336        &self,
337        tx: &mut impl TransactionalKeyStore,
338        resolved: &ResolvedWelcome,
339        expected_membership: Option<&WelcomeMembership>,
340        event_buffer: &EventBuffer<'_, InternalEvent>,
341    ) -> Result<Option<MlsGroup<C>>, GroupError> {
342        let Self {
343            welcome, context, ..
344        } = self;
345
346        let storage = tx.key_store();
347        let db = storage.db();
348        let provider = XmtpOpenMlsProviderRef::new(&storage);
349
350        self.check_pending(&db)?;
351        let decrypted = resolved.stage(welcome, &storage)?;
352        let anchor = self.join_anchor(&decrypted)?;
353        let membership = WelcomeMembership::from_staged(&decrypted.staged_welcome)?;
354        membership.validate_sequences(welcome.sequence_id())?;
355        if expected_membership.is_some_and(|expected| *expected != membership) {
356            return Err(GroupError::LockUnavailable);
357        }
358        let DecryptedWelcome {
359            staged_welcome,
360            added_by_inbox_id,
361            added_by_installation_id,
362            welcome_metadata: _,
363        } = decrypted;
364        let metadata =
365            extract_group_metadata(staged_welcome.public_group().group_context().extensions())
366                .map_err(MetadataPermissionsError::from)?;
367        // Reject before identity checks, which can wait on the network.
368        // implements: SYNC-010
369        if metadata.conversation_type == ConversationType::Sync
370            && !is_own_sync_group(
371                context.inbox_id(),
372                &added_by_inbox_id,
373                staged_welcome.public_group().members(),
374            )
375        {
376            tracing::warn!(
377                added_by_inbox_id,
378                "rejecting a sync group Welcome that names another inbox"
379            );
380            return Err(GroupError::InvalidGroupMembership);
381        }
382        self.validator.check_verified_membership(&membership, &db)?;
383        // implements: DMS-015, JOIN-081
384        // A Restored row carries historical identity. A Welcome can activate
385        // it only when its authenticated kind and DM pair are unchanged.
386        let group_id = GroupId::try_from(staged_welcome.public_group().group_id())?;
387        let existing_group = db.find_group(&group_id)?;
388        if let Some(existing) = &existing_group
389            && existing.membership_state == GroupMembershipState::Restored
390        {
391            if existing.conversation_type != metadata.conversation_type {
392                return Err(GroupError::InvalidWelcomeMetadata);
393            }
394            if existing.conversation_type == ConversationType::Dm {
395                let archived_pair =
396                    crate::groups::parse_canonical_dm_id(existing.dm_id.as_deref())?;
397                if metadata.dm_members.as_ref().map(String::from).as_deref()
398                    != Some(archived_pair.to_string().as_str())
399                {
400                    return Err(MetadataPermissionsError::from(
401                        crate::groups::DmValidationError::StoredDmIdMismatch,
402                    )
403                    .into());
404                }
405            }
406        }
407        if metadata.conversation_type == ConversationType::Oneshot {
408            Oneshot::process_welcome(
409                &provider,
410                welcome.cursor,
411                added_by_inbox_id,
412                added_by_installation_id,
413                metadata,
414            )?;
415            return Ok(None);
416        }
417
418        let mut anchor_mode = JoinAnchorMode::Advance;
419
420        if let Some(existing) = &existing_group
421            && existing.membership_state != GroupMembershipState::Restored
422        {
423            let mut current = OpenMlsGroup::load(&storage, &group_id.to_openmls())?
424                .ok_or(xmtp_db::NotFound::MlsGroup(group_id))?;
425            let processed = db.latest_cursor_for_id(group_id, &[EntityKind::ApplicationMessage])?;
426            let incoming_epoch = staged_welcome.public_group().group_context().epoch();
427            let active = current.is_active();
428            // A remove-and-re-add commit can retire this installation at the join anchor.
429            // Removal can advance its public epoch without installing that epoch's secrets.
430            // Welcome publication order does not establish MLS epoch order.
431            if processed == anchor && !current.is_active() && current.epoch() <= incoming_epoch {
432                anchor_mode = JoinAnchorMode::InactiveReadd;
433            }
434            if processed > anchor
435                || (processed == anchor && anchor_mode != JoinAnchorMode::InactiveReadd)
436                || (active && current.epoch() >= incoming_epoch)
437            {
438                return Err(ProcessIntentError::WelcomeAlreadyProcessed(welcome.cursor).into());
439            }
440            if active {
441                return Err(GroupError::WelcomeGroupPrefixPending {
442                    group_id,
443                    anchor: anchor.0,
444                });
445            }
446            // A replacement Welcome starts a new membership generation.
447            // Clear the old proposals in memory and storage before installing
448            // it. OpenMLS stores proposal references by group ID, not epoch.
449            current.clear_pending_proposals(&storage)?;
450            db.forget_received_proposals(group_id)?;
451        }
452
453        // The checks above allow PendingRemove only for a valid inactive rejoin.
454        // It is not Restored, and an active MLS group returns before this point.
455        let is_readd_after_leaving = existing_group
456            .as_ref()
457            .is_some_and(|g| g.membership_state == GroupMembershipState::PendingRemove);
458
459        let mls_group = OpenMlsGroup::from_welcome_logged(
460            &provider,
461            staged_welcome,
462            &added_by_inbox_id,
463            &added_by_installation_id,
464            context.server_configuration().commit_log_enabled(),
465        )?;
466        let dm_members = metadata.dm_members;
467        let conversation_type = metadata.conversation_type;
468        // Required metadata must not bypass the version check.
469        let mutable_metadata = Some(
470            extract_group_mutable_metadata_capability_aware(&mls_group)
471                .map_err(|_| GroupError::InvalidWelcomeMetadata)?,
472        );
473        let disappearing_settings = mutable_metadata.as_ref().and_then(|metadata| {
474            MlsGroup::<C>::conversation_message_disappearing_settings_from_extensions(metadata).ok()
475        });
476
477        if let Some(min_version) = mutable_metadata
478            .as_ref()
479            .and_then(MlsGroup::<C>::min_protocol_version_from_extensions)
480        {
481            let required = LibXMTPVersion::parse(&min_version)
482                .map_err(|_| GroupError::InvalidWelcomeMetadata)?;
483            if required > *context.version_info().pkg_semver() {
484                return Err(GroupError::UnsupportedWelcomeVersion(min_version));
485            }
486        }
487
488        // A Welcome is peer state, even on a clean database. Validate action
489        // entries and complete policy trees before admitting any group type.
490        {
491            let registry = crate::groups::app_data::load_component_registry(&mls_group)
492                .map_err(|_| GroupError::InvalidWelcomeMetadata)?;
493            xmtp_mls_common::app_data::policy_set::validate_registry_action_policies(&registry)
494                .map_err(|error| {
495                    tracing::warn!(%error, "invalid registry action policy in Welcome");
496                    GroupError::InvalidWelcomeMetadata
497                })?;
498        }
499
500        // Determine the membership state
501        // If the user is being re-added after leaving, set to ALLOWED
502        // Otherwise, new members start in PENDING state
503        let membership_state = if is_readd_after_leaving {
504            tracing::info!(
505                group_id = %group_id,
506                "User is being re-added after leaving/removal, setting membership state to ALLOWED"
507            );
508            GroupMembershipState::Allowed
509        } else {
510            tracing::debug!(
511                group_id = %group_id,
512                "User is being added to new group, setting membership state to PENDING"
513            );
514            GroupMembershipState::Pending
515        };
516
517        let mut group = StoredGroup::builder();
518        group
519            .id(group_id)
520            .created_at_ns(now_ns())
521            .added_by_inbox_id(&added_by_inbox_id)
522            .cursor(welcome.cursor)
523            .conversation_type(conversation_type)
524            .dm_id(dm_members.map(String::from))
525            .message_disappear_from_ns(disappearing_settings.as_ref().map(|m| m.from_ns))
526            .message_disappear_in_ns(disappearing_settings.as_ref().map(|m| m.in_ns))
527            .should_publish_commit_log(MlsGroup::<C>::check_should_publish_commit_log(
528                context.inbox_id().to_string(),
529                mutable_metadata,
530            ));
531
532        let to_store = match conversation_type {
533            ConversationType::Group => group.membership_state(membership_state).build()?,
534            ConversationType::Dm => {
535                validate_dm_group(context, &mls_group, &added_by_inbox_id)?;
536                group
537                    .membership_state(membership_state)
538                    .last_message_ns(welcome.timestamp())
539                    .build()?
540            }
541            ConversationType::Sync => {
542                // The own inbox's sync groups are Allowed; others were rejected above.
543                group
544                    .membership_state(GroupMembershipState::Allowed)
545                    .build()?
546            }
547            ConversationType::Oneshot => {
548                unreachable!("StagedWelcome of type Oneshot should already be handled")
549            }
550        };
551
552        tracing::debug!("storing group with welcome id {}", welcome.cursor);
553
554        // If this is a re-add after leaving, update the existing group's membership state
555        // before calling insert_or_replace_group
556        if is_readd_after_leaving && let Some(ref existing) = existing_group {
557            tracing::info!(
558                group_id = %existing.id,
559                "Updating existing group membership state from PENDING_REMOVE to ALLOWED"
560            );
561            db.update_group_membership(existing.id, GroupMembershipState::Allowed)?;
562        }
563
564        // Insert the group, replace a Restored placeholder, or advance an existing cursor.
565        let stored_group = db.insert_or_replace_group(to_store)?;
566        let consent_entity = hex::encode(stored_group.id);
567        let prior_consent = db.get_consent_record(
568            consent_entity.clone(),
569            xmtp_db::consent_record::ConsentType::ConversationId,
570        )?;
571
572        StoredConsentRecord::stitch_dm_consent(&db, &stored_group)?;
573
574        // Create a GroupUpdated payload
575        let current_inbox_id = context.inbox_id().to_string();
576        let added_payload = GroupUpdated {
577            initiated_by_inbox_id: added_by_inbox_id.clone(),
578            added_inboxes: vec![Inbox {
579                inbox_id: current_inbox_id.clone(),
580            }],
581            removed_inboxes: vec![],
582            metadata_field_changes: vec![],
583            left_inboxes: vec![],
584            added_admin_inboxes: vec![],
585            removed_admin_inboxes: vec![],
586            added_super_admin_inboxes: vec![],
587            removed_super_admin_inboxes: vec![],
588        };
589
590        let encoded_added_payload = GroupUpdatedCodec::encode(added_payload)?;
591        let mut encoded_added_payload_bytes = Vec::new();
592        encoded_added_payload.encode(&mut encoded_added_payload_bytes)?;
593
594        let added_idempotency_key = format!("{}_welcome_added", welcome.created_ns);
595        let added_message_id = crate::utils::id::calculate_message_id(
596            stored_group.id,
597            encoded_added_payload_bytes.as_slice(),
598            &added_idempotency_key,
599        );
600
601        let added_content_type = encoded_added_payload.r#type.unwrap_or_else(|| {
602            tracing::warn!("Missing content type in encoded added payload, using default values");
603            ContentTypeId {
604                authority_id: "unknown".to_string(),
605                type_id: "unknown".to_string(),
606                version_major: 0,
607                version_minor: 0,
608            }
609        });
610
611        let cursor = anchor.0 as i64;
612
613        // this is the commit that brought us into the group
614        let added_msg = StoredGroupMessage {
615            id: added_message_id,
616            group_id: stored_group.id,
617            decrypted_message_bytes: encoded_added_payload_bytes,
618            sent_at_ns: welcome.timestamp(),
619            kind: GroupMessageKind::MembershipChange,
620            sender_installation_id: added_by_installation_id,
621            sender_inbox_id: added_by_inbox_id.clone(),
622            delivery_status: DeliveryStatus::Published,
623            content_type: ContentType::from_identifier(
624                &added_content_type.authority_id,
625                &added_content_type.type_id,
626                added_content_type.version_major,
627            ),
628            version_major: added_content_type.version_major as i32,
629            version_minor: added_content_type.version_minor as i32,
630            authority_id: added_content_type.authority_id,
631            reference_id: None,
632            sequence_id: cursor,
633            envelope_hash: None,
634            expiry_ns: None,
635            expire_at_ns: None,
636            inserted_at_ns: 0, // Will be set by database
637            should_push: true,
638            // Matches the key used to derive `added_message_id` above.
639            idempotency_key: added_idempotency_key,
640        };
641
642        added_msg.store_or_ignore(&db)?;
643
644        tracing::debug!("created GroupUpdated message for welcome, inbox_id={current_inbox_id}");
645
646        let group = MlsGroup::new(
647            context.clone(),
648            stored_group.id,
649            stored_group.dm_id,
650            stored_group.conversation_type,
651            stored_group.created_at_ns,
652        );
653
654        // If this group is created by us - auto-consent to it.
655        if context.inbox_id() == metadata.creator_inbox_id {
656            group.quietly_update_consent_state(ConsentState::Allowed, &db)?;
657        } else if is_readd_after_leaving {
658            // If user is being re-added after leaving, reset consent to Unknown
659            // This requires the user to explicitly accept being added back
660            tracing::info!(
661                group_id = %group.group_id,
662                "Resetting consent state to Unknown for re-added user"
663            );
664            group.quietly_update_consent_state(ConsentState::Unknown, &db)?;
665        }
666
667        if !stored_group.conversation_type.is_virtual() {
668            let final_consent = db.get_consent_record(
669                consent_entity,
670                xmtp_db::consent_record::ConsentType::ConversationId,
671            )?;
672            if prior_consent.as_ref().map(|record| record.state)
673                != final_consent.as_ref().map(|record| record.state)
674                && let Some(record) = final_consent
675            {
676                emit_preference_updates(
677                    event_buffer,
678                    vec![
679                        crate::worker::device_sync::preference_sync::PreferenceUpdate::Consent(
680                            record,
681                        ),
682                    ],
683                    PreferenceOrigin::Local,
684                    &db,
685                )?;
686            }
687        }
688
689        // State, progress, and removal of pre-join work commit together.
690        db.install_group_anchor(group.group_id, anchor, anchor_mode)?;
691        db.record_welcome_discovery(group.group_id, welcome.cursor)?;
692        MlsGroup::<C>::mark_readd_requests_as_responded(
693            &storage,
694            &group.group_id,
695            &HashSet::from([context.installation_id().to_vec()]),
696            cursor,
697        )?;
698        event_buffer.emit(
699            (!stored_group.conversation_type.is_virtual()).then(|| {
700                ClientEvent::ConversationJoined(ConversationJoined {
701                    group_id: group.group_id.to_vec(),
702                    conversation_type: if stored_group.conversation_type == ConversationType::Dm {
703                        EventConversationType::Dm
704                    } else {
705                        EventConversationType::Group
706                    },
707                    origin: JoinOrigin::Welcomed,
708                    adder_inbox_id: Some(added_by_inbox_id.clone()),
709                })
710            }),
711            Some(InternalEvent::GroupJoined {
712                group_id: group.group_id,
713                is_sync: stored_group.conversation_type == ConversationType::Sync,
714                origin: crate::subscriptions::internal::GroupOrigin::Welcomed,
715            }),
716        );
717
718        tracing::debug!(
719            inbox_id = %current_inbox_id,
720            installation_id = %self.context.installation_id().as_slice().snippet(),
721            group_id = %group.group_id,
722            welcome_id = welcome.cursor.0,
723
724            cursor = cursor,
725            "updated message cursor from welcome metadata"
726        );
727
728        Ok(Some(group))
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use crate::groups::InitialMembershipValidator;
736    use crate::tester;
737    use crate::utils::test::MlsGroupExt;
738
739    struct UnavailableValidator;
740
741    impl ValidateGroupMembership for UnavailableValidator {
742        async fn check_initial_membership(
743            &self,
744            _welcome: &WelcomeMembership,
745        ) -> Result<(), GroupError> {
746            Err(GroupError::LockUnavailable)
747        }
748    }
749
750    #[xmtp_common::test(unwrap_try = true)]
751    async fn trial_validation_failure_preserves_welcome_keys() {
752        tester!(alix, disable_workers);
753        tester!(bo, disable_workers);
754        let alix_group = alix.create_group(None, None)?;
755        alix_group.invite(&bo).await?;
756        let welcome = bo
757            .context
758            .api()
759            .query_welcome_messages(bo.context.installation_id())
760            .await?
761            .pop()?;
762
763        let events = bo.context.events().subscribe(
764            xmtp_events::EventFilter::new([xmtp_events::EventKind::ConversationJoined]),
765            Some(10),
766        );
767        let result = XmtpWelcome::builder()
768            .context(bo.context.clone())
769            .welcome(&welcome)
770            .pending(
771                crate::groups::welcome_sync::pending_welcome_for_test(&bo.context, &welcome)
772                    .await?,
773            )
774            .validator(UnavailableValidator)
775            .process()
776            .await;
777        assert!(matches!(result, Err(GroupError::LockUnavailable)));
778        assert!(events.drain().is_empty());
779        assert!(bo.context.db().find_group(&alix_group.group_id)?.is_none());
780        assert_eq!(
781            bo.context
782                .db()
783                .get_last_cursor(bo.context.installation_id(), EntityKind::Welcome)?,
784            Cursor(0)
785        );
786
787        let bo_group = bo.sync_welcomes().await?.pop()?;
788        assert!(matches!(
789            events.drain().pop().and_then(|event| event.client),
790            Some(xmtp_events::ClientEvent::ConversationJoined(joined)) if joined.group_id == bo_group.group_id.to_vec()
791        ));
792        alix_group.test_can_talk_with(&bo_group).await?;
793    }
794
795    struct JoinDuringValidation<C> {
796        context: C,
797        welcome: xmtp_proto::types::WelcomeMessage,
798    }
799
800    impl<C: XmtpSharedContext> ValidateGroupMembership for JoinDuringValidation<C> {
801        async fn check_initial_membership(
802            &self,
803            _welcome: &WelcomeMembership,
804        ) -> Result<(), GroupError> {
805            let group = XmtpWelcome::builder()
806                .context(self.context.clone())
807                .welcome(&self.welcome)
808                .pending(
809                    crate::groups::welcome_sync::pending_welcome_for_test(
810                        &self.context,
811                        &self.welcome,
812                    )
813                    .await?,
814                )
815                .validator(InitialMembershipValidator::new(self.context.clone()))
816                .process()
817                .await?
818                .ok_or(GroupError::UninitializedResult)?;
819            group.sync_with_conn().await?;
820            Ok(())
821        }
822    }
823
824    #[xmtp_common::test(unwrap_try = true)]
825    async fn fresh_install_rejects_welcome_after_another_writer_advances_group() {
826        tester!(alix, disable_workers);
827        tester!(bo, disable_workers);
828        let alix_group = alix.create_group(None, None)?;
829        alix_group.invite(&bo).await?;
830        alix_group
831            .update_group_name("advanced during validation".into())
832            .await?;
833        let welcome = bo
834            .context
835            .api()
836            .query_welcome_messages(bo.context.installation_id())
837            .await?
838            .pop()?;
839
840        let result = XmtpWelcome::builder()
841            .context(bo.context.clone())
842            .welcome(&welcome)
843            .pending(
844                crate::groups::welcome_sync::pending_welcome_for_test(&bo.context, &welcome)
845                    .await?,
846            )
847            .validator(JoinDuringValidation {
848                context: bo.context.clone(),
849                welcome: welcome.clone(),
850            })
851            .process()
852            .await;
853        assert!(matches!(
854            result,
855            Err(GroupError::ProcessIntent(
856                ProcessIntentError::WelcomeAlreadyProcessed(_)
857            ))
858        ));
859        let bo_group = bo.group(&alix_group.group_id)?;
860        assert_eq!(
861            alix_group.epoch_authenticator().await?,
862            bo_group.epoch_authenticator().await?
863        );
864        alix_group.test_can_talk_with(&bo_group).await?;
865    }
866
867    // verifies: JOIN-044
868    #[rstest::rstest]
869    #[case::late_active_prefix(false)]
870    #[case::live_retired_controller(true)]
871    #[xmtp_common::test(unwrap_try = true)]
872    async fn rejoin_keeps_messages_before_removal(#[case] live_receiver: bool) {
873        use crate::subscriptions::incoming::{
874            IncomingCoordinator, IncomingRegistration, IncomingScope,
875        };
876        use xmtp_common::time::{Duration, timeout};
877
878        tester!(alix, disable_workers);
879        tester!(bo, disable_workers);
880        let alix_group = alix.create_group(None, None).unwrap();
881        alix_group.invite(&bo).await.unwrap();
882        let bo_group = bo.sync_welcomes().await.unwrap().pop().unwrap();
883        let lease = live_receiver.then(|| {
884            IncomingCoordinator::for_context(&bo.context).acquire(IncomingScope::AllGroups)
885        });
886        let topic = xmtp_proto::types::Topic::new_group_message(bo_group.group_id);
887        alix_group.send_msg(b"before removal").await;
888        alix_group.remove_members(&[bo.inbox_id()]).await.unwrap();
889        if let Some(lease) = &lease {
890            timeout(Duration::from_secs(10), async {
891                loop {
892                    if lease.snapshot().topics.iter().any(|entry| {
893                        entry.topic == topic && entry.registration == IncomingRegistration::Removed
894                    }) {
895                        break;
896                    }
897                    lease.changed().await;
898                }
899            })
900            .await
901            .unwrap();
902        }
903        alix_group.invite(&bo).await.unwrap();
904
905        bo.sync_welcomes().await.unwrap();
906        let messages = bo
907            .context
908            .db()
909            .get_group_messages(&bo_group.group_id, &Default::default())
910            .unwrap();
911        assert!(
912            messages
913                .iter()
914                .any(|message| message.decrypted_message_bytes == b"before removal")
915        );
916        assert_eq!(
917            alix_group.epoch_authenticator().await.unwrap(),
918            bo_group.epoch_authenticator().await.unwrap()
919        );
920        if let Some(lease) = &lease {
921            alix_group.send_msg(b"after rejoin").await;
922            timeout(Duration::from_secs(10), async {
923                loop {
924                    let messages = bo
925                        .context
926                        .db()
927                        .get_group_messages(&bo_group.group_id, &Default::default())
928                        .unwrap();
929                    if messages
930                        .iter()
931                        .any(|message| message.decrypted_message_bytes == b"after rejoin")
932                    {
933                        return Ok::<_, xmtp_db::StorageError>(());
934                    }
935                    lease.changed().await;
936                }
937            })
938            .await
939            .unwrap()
940            .unwrap();
941        }
942        alix_group.test_can_talk_with(&bo_group).await.unwrap();
943    }
944}