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 crate::core::sync_tasks::schedule_newcomer_adds;
5use crate::groups::MetadataPermissionsError;
6use crate::groups::mls_ext::CommitLogStorer;
7use crate::groups::mls_ext::ResolvedWelcome;
8use crate::groups::oneshot::Oneshot;
9use crate::groups::welcomes::WelcomeMembership;
10use crate::identity_updates::{
11    IdentityDependencyError, IdentityRequirement, InstallationDiffError, require_association_state,
12};
13use crate::state_tx::{state_write, state_write_with_events};
14use crate::subscriptions::internal::{InternalEvent, dm_event_context, emit_root_key_changed};
15use crate::worker::device_sync::is_own_sync_group;
16use crate::{
17    context::XmtpSharedContext,
18    groups::{
19        GroupError, MlsGroup, ValidateGroupMembership, mls_ext::DecryptedWelcome, validate_dm_group,
20    },
21    intents::ProcessIntentError,
22};
23use derive_builder::Builder;
24use openmls::group::MlsGroup as OpenMlsGroup;
25use prost::Message;
26use xmtp_common::{snippet::Snippet, time::now_ns};
27use xmtp_content_types::ContentCodec;
28use xmtp_content_types::group_updated::GroupUpdatedCodec;
29use xmtp_db::TransactionOutcome::{Continue, Rollback};
30use xmtp_db::pending_remove::QueryPendingRemove;
31use xmtp_db::{
32    TransactionOutcome, XmtpOpenMlsProviderRef,
33    group::{ConversationType, GroupMembershipState, StoredGroup},
34    group_message::{ContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage},
35    incoming_envelope::{JoinAnchorMode, NetworkEntityKind, StoredIncomingEnvelope, StreamTopic},
36    prelude::*,
37    refresh_state::EntityKind,
38};
39use xmtp_events::{
40    ClientEvent, ConversationJoined, ConversationType as EventConversationType, EventBuffer,
41    EventWriter, JoinOrigin,
42};
43use xmtp_id::associations::MemberIdentifier;
44use xmtp_mls_group_state::app_data::group_root::check_group_root;
45use xmtp_mls_group_state::group_metadata::extract_group_metadata;
46use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
47
48use xmtp_mls_group_state::app_data::component_source::extract_group_mutable_metadata_capability_aware;
49use xmtp_proto::types::Cursor;
50use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, GroupUpdated, group_updated::Inbox};
51
52use xmtp_proto::types::GroupId;
53/// Create a group from a decrypted and decoded welcome message.
54/// An existing group can be replaced only after its removal has been processed.
55///
56/// # Parameters
57/// * `context` - The client context to use for group operations
58/// * `welcome` - The encrypted welcome message
59/// * `pending` - The exact durable Welcome row that this attempt must complete.
60/// * `validator` - The validator to use to check the group membership
61#[derive(Builder)]
62#[builder(
63    pattern = "owned",
64    setter(strip_option),
65    build_fn(error = "GroupError", private)
66)]
67pub struct XmtpWelcome<'a, C, V> {
68    context: C,
69    /// Immutable network input. Each attempt reads its private keys again.
70    welcome: &'a xmtp_proto::types::WelcomeMessage,
71    /// Exact durable row to complete in the same transaction as the join.
72    pending: StoredIncomingEnvelope,
73    validator: V,
74}
75
76impl<'a, C, V> XmtpWelcome<'a, C, V> {
77    pub fn builder() -> XmtpWelcomeBuilder<'a, C, V> {
78        Default::default()
79    }
80}
81
82/// A committed join or safe rejection that completes the pending Welcome row.
83enum CommitResult<C> {
84    /// Invalid input was rejected without keeping trial MLS writes.
85    FailedForever(GroupError),
86    /// Successfully decrypted and processed
87    Ok(Option<MlsGroup<C>>),
88}
89
90/// Only invalid input can complete a rejected Welcome. Local failures remain pending.
91// implements: JOIN-048, JOIN-027, JOIN-043
92pub(crate) fn terminal_welcome_error(error: &GroupError) -> bool {
93    use openmls::prelude::WelcomeError;
94    matches!(
95        error,
96        GroupError::InvalidWelcomeMetadata
97            // Processed progress never rewinds, so a stale anchor stays stale.
98            | GroupError::Storage(xmtp_db::StorageError::Stream(
99                xmtp_db::stream_storage::StreamStorageError::StaleJoinAnchor
100            ))
101            | GroupError::WelcomeGroupRoot(_)
102            | GroupError::InvalidGroupMembership
103            | GroupError::MetadataPermissionsError(_)
104            | GroupError::NoPSKSupport
105            | GroupError::TlsError(_)
106            | GroupError::CredentialError(_)
107            | GroupError::Identity(
108                crate::identity::IdentityError::Decode(_)
109                    | crate::identity::IdentityError::BasicCredential(_)
110            )
111            | GroupError::ConversionError(_)
112            | GroupError::UnwrapWelcome(_)
113            | GroupError::ProcessIntent(ProcessIntentError::WelcomeAlreadyProcessed(_))
114            | GroupError::InstallationDiff(InstallationDiffError::IdentityDependency(
115                IdentityDependencyError::InvalidSequence(_)
116                    | IdentityDependencyError::MissingReference(_)
117            ))
118            | GroupError::WelcomeError(
119                WelcomeError::GroupSecrets(_)
120                    // openmls loads PSKs before the NoPSKSupport check.
121                    | WelcomeError::Psk(_)
122                    | WelcomeError::CiphersuiteMismatch
123                    | WelcomeError::GroupInfo(_)
124                    | WelcomeError::JoinerSecretNotFound
125                    | WelcomeError::MissingRatchetTree
126                    | WelcomeError::ConfirmationTagMismatch
127                    | WelcomeError::InvalidGroupInfoSignature
128                    | WelcomeError::UnknownSender
129                    | WelcomeError::NotAWelcomeMessage
130                    | WelcomeError::MalformedWelcomeMessage
131                    | WelcomeError::UnableToDecrypt
132                    | WelcomeError::PublicTreeError(_)
133                    | WelcomeError::LeafNodeValidation(_)
134            )
135    )
136}
137
138impl<C> CommitResult<C> {
139    fn into_result(self) -> Result<Option<MlsGroup<C>>, GroupError> {
140        match self {
141            Self::FailedForever(err) => Err(err),
142            Self::Ok(group) => Ok(group),
143        }
144    }
145}
146
147impl<'a, C, V> XmtpWelcomeBuilder<'a, C, V>
148where
149    C: XmtpSharedContext,
150    V: ValidateGroupMembership,
151{
152    /// Resolve dependencies from a rolled-back trial, then install from fresh state.
153    // Named explicitly (derived `mls.process` is too generic) and without `err`:
154    // duplicate welcomes exit as Err(WelcomeAlreadyProcessed), an expected
155    // outcome; unexpected failures set status on mls.process_new_welcome above.
156    #[tracing::instrument(skip_all, fields(operation = "mls.process_welcome"))]
157    pub async fn process(self) -> Result<Option<MlsGroup<C>>, GroupError> {
158        let this = self.build()?;
159        this.check_pending(&this.context.db())?;
160
161        let (resolved, membership) = match this.validate_membership().await {
162            Err(error) => return this.reject_or_retry(error),
163            Ok(validated) => validated,
164        };
165        let commit_result = this.commit_or_fail_forever(&resolved, Some(&membership), None)?;
166        commit_result.into_result()
167    }
168
169    /// Restage under the writer and return missing dependencies to the scheduler.
170    /// Only a matching resolver result can prove an identity reference is absent.
171    pub(crate) fn process_resolved(
172        self,
173        resolved: &ResolvedWelcome,
174        missing_reference: Option<&IdentityRequirement>,
175    ) -> Result<Option<MlsGroup<C>>, GroupError> {
176        let this = self.build()?;
177        this.commit_or_fail_forever(resolved, None, missing_reference)?
178            .into_result()
179    }
180}
181
182impl<'a, C, V> XmtpWelcome<'a, C, V>
183where
184    C: XmtpSharedContext,
185    V: ValidateGroupMembership,
186    <C::MlsStorage as XmtpMlsStorageProvider>::Connection: xmtp_db::ConnectionExt,
187{
188    fn topic(&self) -> StreamTopic {
189        StreamTopic {
190            entity_id: self.context.installation_id().to_vec(),
191            kind: NetworkEntityKind::Welcome,
192        }
193    }
194
195    /// Require the same pending row before any join or rejection can commit.
196    fn check_pending(&self, db: &impl DbQuery) -> Result<(), GroupError> {
197        let current = db.pending_envelope(&self.topic(), self.welcome.cursor)?;
198        if current.is_none_or(|row| row.envelope != self.pending.envelope)
199            || self.pending.sequence_id != self.welcome.cursor.0 as i64
200            || self.pending.entity_id != self.context.installation_id()
201        {
202            return Err(ProcessIntentError::WelcomeAlreadyProcessed(self.welcome.cursor).into());
203        }
204        Ok(())
205    }
206
207    fn reject_or_retry(&self, error: GroupError) -> Result<Option<MlsGroup<C>>, GroupError> {
208        if terminal_welcome_error(&error) {
209            state_write(self.context.mls_storage(), |tx| {
210                let storage = tx.storage();
211                let db = storage.db();
212                self.check_pending(&db)?;
213                db.record_terminal_rejection(
214                    &self.topic(),
215                    self.welcome.cursor,
216                    "invalid_welcome",
217                )?;
218                db.complete_pending_envelope(&self.topic(), self.welcome.cursor)?;
219                Ok::<_, GroupError>(Continue(()))
220            })?;
221        }
222        Err(error)
223    }
224
225    /// Roll back trial MLS writes before resolving exact identity proofs.
226    /// Only immutable input and public membership data cross the network await.
227    async fn validate_membership(
228        &self,
229    ) -> Result<(ResolvedWelcome, WelcomeMembership), GroupError> {
230        let resolved = ResolvedWelcome::resolve(self.welcome, &self.context).await?;
231        let mut membership = None;
232        state_write(self.context.mls_storage(), |tx| {
233            let storage = tx.storage();
234            let decrypted = resolved.stage(self.welcome, &storage)?;
235            self.join_anchor(&decrypted)?;
236            membership = Some(WelcomeMembership::from_staged(&decrypted.staged_welcome)?);
237            Ok::<_, GroupError>(Rollback::<()>)
238        })?;
239        let membership = membership.ok_or(GroupError::UninitializedResult)?;
240        membership.validate_sequences(self.welcome.sequence_id())?;
241        self.validator.check_initial_membership(&membership).await?;
242        Ok((resolved, membership))
243    }
244
245    /// Require an authenticated anchor before this Welcome's sequence.
246    /// Zero is valid only for epoch zero or an Oneshot Welcome.
247    // implements: JOIN-036, JOIN-037, JOIN-038
248    fn join_anchor(&self, decrypted: &DecryptedWelcome) -> Result<Cursor, GroupError> {
249        let metadata =
250            extract_group_metadata(decrypted.staged_welcome.group_context().extensions())
251                .map_err(MetadataPermissionsError::from)?;
252        let anchor = match &decrypted.welcome_metadata {
253            Some(metadata) => metadata.message_cursor,
254            None if metadata.conversation_type == ConversationType::Oneshot => 0,
255            None => return Err(GroupError::InvalidWelcomeMetadata),
256        };
257        let initial = metadata.conversation_type == ConversationType::Oneshot
258            || decrypted.staged_welcome.group_context().epoch().as_u64() == 0;
259        if anchor >= self.welcome.sequence_id() || (anchor == 0 && !initial) {
260            return Err(GroupError::InvalidWelcomeMetadata);
261        }
262        Ok(Cursor(anchor))
263    }
264
265    /// Commit a valid Welcome or a safe rejection with its pending-row completion.
266    /// Other failures roll back all state. Send events only after commit.
267    fn commit_or_fail_forever(
268        &self,
269        resolved: &ResolvedWelcome,
270        membership: Option<&WelcomeMembership>,
271        missing_reference: Option<&IdentityRequirement>,
272    ) -> Result<CommitResult<C>, GroupError> {
273        tracing::debug!("attempting to commit welcome={}", &self.welcome.cursor);
274        let commit_result = state_write_with_events(
275            self.context.mls_storage(),
276            self.context.events(),
277            |tx, event_buffer| {
278                let storage = tx.storage();
279                self.check_pending(&storage.db())?;
280                // Savepoint transaction
281                let result = event_buffer.savepoint(|event_buffer| {
282                    storage.savepoint(|conn| {
283                        self.commit(conn, resolved, membership, event_buffer)
284                            .map(Continue)
285                    })
286                });
287                let db = storage.db();
288                // Only the resolver can prove that an exact identity reference is absent.
289                let result = result.map_err(|error| match (&error, missing_reference) {
290                    (
291                        GroupError::InstallationDiff(InstallationDiffError::IdentityDependency(
292                            IdentityDependencyError::Need(required),
293                        )),
294                        Some(missing),
295                    ) if required.contains(missing) => InstallationDiffError::IdentityDependency(
296                        IdentityDependencyError::MissingReference(missing.clone()),
297                    )
298                    .into(),
299                    _ => error,
300                });
301                match result {
302                    Err(err) if terminal_welcome_error(&err) => {
303                        db.record_terminal_rejection(
304                            &self.topic(),
305                            self.welcome.cursor,
306                            "invalid_welcome",
307                        )?;
308                        db.complete_pending_envelope(&self.topic(), self.welcome.cursor)?;
309                        // return ok to commit the transaction
310                        Ok(Continue(CommitResult::FailedForever(err)))
311                    }
312                    // roll everything back to retry
313                    Err(e) => Err(e),
314                    Ok(Continue(group)) => {
315                        db.complete_pending_envelope(&self.topic(), self.welcome.cursor)?;
316                        // Rotate the key package this Welcome used only once it is
317                        // installed, in the same transaction. Receipt, a hold, or a
318                        // rejection never queues rotation.
319                        crate::worker::key_package_maintenance::queue_key_rotation_in(&storage)?;
320                        event_buffer.emit(None, Some(InternalEvent::TaskScheduled));
321                        self.context.task_channels().mark_notification_changed();
322                        Ok(Continue(CommitResult::Ok(group)))
323                    }
324                    Ok(Rollback) => {
325                        unreachable!("savepoint never intentionally rolls back here")
326                    }
327                }
328            },
329        )
330        .map(TransactionOutcome::into_continued)?;
331        Ok(commit_result)
332    }
333
334    /// Restage and recheck the join against this writer's keys, proofs, and group.
335    /// An active older group must process its removal before replacement.
336    /// The caller commits the join and pending-row completion together.
337    // implements: JOIN-080
338    fn commit(
339        &self,
340        tx: &mut impl TransactionalKeyStore,
341        resolved: &ResolvedWelcome,
342        expected_membership: Option<&WelcomeMembership>,
343        event_buffer: &EventBuffer<'_, InternalEvent>,
344    ) -> Result<Option<MlsGroup<C>>, GroupError> {
345        let Self {
346            welcome, context, ..
347        } = self;
348
349        let storage = tx.key_store();
350        let db = storage.db();
351        let provider = XmtpOpenMlsProviderRef::new(&storage);
352
353        self.check_pending(&db)?;
354        let decrypted = resolved.stage(welcome, &storage)?;
355        let anchor = self.join_anchor(&decrypted)?;
356        let membership = WelcomeMembership::from_staged(&decrypted.staged_welcome)?;
357        membership.validate_sequences(welcome.sequence_id())?;
358        if expected_membership.is_some_and(|expected| *expected != membership) {
359            return Err(GroupError::LockUnavailable);
360        }
361        let DecryptedWelcome {
362            staged_welcome,
363            added_by_inbox_id,
364            added_by_installation_id,
365            welcome_metadata: _,
366        } = decrypted;
367        let metadata = extract_group_metadata(staged_welcome.group_context().extensions())
368            .map_err(MetadataPermissionsError::from)?;
369        // implements: JOIN-083
370        // The root must derive the group id. No 32-byte key equals a 16-byte
371        // id, so this also rejects every Welcome for a legacy group, and a
372        // Welcome naming a restored placeholder activates it only when it
373        // comes from a holder of the placeholder's root.
374        // The commit-log id is stored with the row; the keys are wiped here.
375        let commit_log_id = check_group_root(
376            staged_welcome.group_context().extensions(),
377            staged_welcome.group_context().group_id().as_slice(),
378        )
379        .map_err(GroupError::WelcomeGroupRoot)?
380        .commit_log_id();
381        // Reject before identity checks, which can wait on the network.
382        // implements: SYNC-010
383        if metadata.conversation_type == ConversationType::Sync
384            && !is_own_sync_group(
385                context.inbox_id(),
386                &added_by_inbox_id,
387                staged_welcome.members(),
388            )
389        {
390            tracing::warn!(
391                added_by_inbox_id,
392                "rejecting a sync group Welcome that names another inbox"
393            );
394            return Err(GroupError::InvalidGroupMembership);
395        }
396        self.validator.check_verified_membership(&membership, &db)?;
397        // The membership check proved the adder at the named own state, so the
398        // client holds every own update through it; a newer one can only have
399        // revoked it.
400        // implements: JOIN-095
401        if added_by_inbox_id == context.inbox_id() {
402            let newest = IdentityRequirement {
403                inbox_id: added_by_inbox_id.clone(),
404                sequence_id: db.get_latest_sequence_id_for_inbox(&added_by_inbox_id)? as u64,
405            };
406            // Not cached yet: `Need` holds the Welcome and the resolver computes it.
407            let own =
408                require_association_state(&db, &newest).map_err(InstallationDiffError::from)?;
409            let adder = MemberIdentifier::installation(added_by_installation_id.clone());
410            if own.get(&adder).is_none() {
411                tracing::warn!("rejecting a Welcome from a revoked own installation");
412                return Err(GroupError::InvalidGroupMembership);
413            }
414        }
415        // implements: JOIN-089
416        // A held row's conversation type and dm id never change through a
417        // Welcome, for a Restored placeholder and a live group alike. This runs
418        // before the Oneshot branch and the cursor and epoch gate, so a changed
419        // identity is rejected at once and never held for the group's topic.
420        let group_id = GroupId::try_from(staged_welcome.group_context().group_id())?;
421        let existing_group = db.find_group(&group_id)?;
422        if let Some(existing) = &existing_group {
423            if existing.conversation_type != metadata.conversation_type {
424                return Err(GroupError::InvalidWelcomeMetadata);
425            }
426            if existing.conversation_type == ConversationType::Dm {
427                let stored_pair = crate::groups::parse_canonical_dm_id(existing.dm_id.as_deref())?;
428                if metadata.dm_members.as_ref().map(String::from).as_deref()
429                    != Some(stored_pair.to_string().as_str())
430                {
431                    return Err(MetadataPermissionsError::from(
432                        crate::groups::DmValidationError::StoredDmIdMismatch,
433                    )
434                    .into());
435                }
436            }
437        }
438        if metadata.conversation_type == ConversationType::Oneshot {
439            Oneshot::process_welcome(
440                &provider,
441                context.inbox_id(),
442                welcome.cursor,
443                added_by_installation_id,
444                metadata,
445            )?;
446            return Ok(None);
447        }
448
449        let mut anchor_mode = JoinAnchorMode::Advance;
450
451        if let Some(existing) = &existing_group
452            && existing.membership_state != GroupMembershipState::Restored
453        {
454            let mut current = OpenMlsGroup::load(&storage, &group_id.to_openmls())?
455                .ok_or(xmtp_db::NotFound::MlsGroup(group_id))?;
456            let processed = db.latest_cursor_for_id(group_id, &[EntityKind::ApplicationMessage])?;
457            let incoming_epoch = staged_welcome.group_context().epoch();
458            let active = current.is_active();
459            // implements: JOIN-042
460            // Only the commit that removed this installation re-adds it here. Its
461            // GroupInfo signer is a leaf of the tree that commit left us. Self-removal
462            // keeps that epoch and tree hash, not the confirmed transcript hash.
463            if processed == anchor
464                && !active
465                && current.epoch() == incoming_epoch
466                && current.public_group().group_context().tree_hash()
467                    == staged_welcome.group_context().tree_hash()
468            {
469                anchor_mode = JoinAnchorMode::InactiveReadd;
470            }
471            // implements: JOIN-042
472            // A lower epoch is a stale branch whatever anchor the sender chose,
473            // so it never moves the stored cursor or drops queued envelopes.
474            if processed > anchor
475                || (processed == anchor && anchor_mode != JoinAnchorMode::InactiveReadd)
476                || current.epoch() > incoming_epoch
477                || (active && current.epoch() >= incoming_epoch)
478            {
479                return Err(ProcessIntentError::WelcomeAlreadyProcessed(welcome.cursor).into());
480            }
481            if active {
482                return Err(GroupError::WelcomeGroupPrefixPending {
483                    group_id,
484                    anchor: anchor.0,
485                });
486            }
487            // A replacement Welcome starts a new membership generation.
488            // Clear the old proposals in memory and storage before installing
489            // it. OpenMLS stores proposal references by group ID, not epoch.
490            current.clear_pending_proposals(&storage)?;
491            db.forget_received_proposals(group_id)?;
492        }
493
494        // The checks above allow PendingRemove only for a valid inactive rejoin.
495        // It is not Restored, and an active MLS group returns before this point.
496        let is_readd_after_leaving = existing_group
497            .as_ref()
498            .is_some_and(|g| g.membership_state == GroupMembershipState::PendingRemove);
499
500        let prior_membership_cutoff = db.get_local_commit_log_cursor(&group_id)?.unwrap_or(0);
501        let mls_group = OpenMlsGroup::from_welcome_logged(
502            &provider,
503            staged_welcome,
504            &added_by_inbox_id,
505            &added_by_installation_id,
506            context.server_configuration().commit_log_enabled(),
507        )?;
508        // implements: FORK-089
509        // A Welcome carries no prior publisher tenure history. Its authenticated
510        // group-message anchor is the boundary for this installed membership.
511        let publisher_boundary =
512            i64::try_from(anchor.0).map_err(|_| xmtp_db::StorageError::DbSerialize)?;
513        let publisher_boundaries = xmtp_mls_validation::ForkActors::from_group(&mls_group)?
514            .publisher_keys(&mls_group)?
515            .into_iter()
516            .map(|author| (author, publisher_boundary))
517            .collect();
518        db.set_commit_log_publisher_boundaries(&group_id, &publisher_boundaries)?;
519        let dm_members = metadata.dm_members;
520        let conversation_type = metadata.conversation_type;
521        // Required metadata must not bypass the version check.
522        let mutable_metadata = Some(
523            extract_group_mutable_metadata_capability_aware(&mls_group)
524                .map_err(|_| GroupError::InvalidWelcomeMetadata)?,
525        );
526        let disappearing_settings = mutable_metadata.as_ref().and_then(|metadata| {
527            MlsGroup::<C>::conversation_message_disappearing_settings_from_extensions(metadata).ok()
528        });
529
530        if let Some(min_version) = mutable_metadata
531            .as_ref()
532            .and_then(MlsGroup::<C>::min_protocol_version_from_extensions)
533        {
534            let required = LibXMTPVersion::parse(&min_version)
535                .map_err(|_| GroupError::InvalidWelcomeMetadata)?;
536            if required > *context.version_info().pkg_semver() {
537                return Err(GroupError::UnsupportedWelcomeVersion(min_version));
538            }
539        }
540
541        // A Welcome is peer state, even on a clean database. Validate action
542        // entries and complete policy trees before admitting any group type.
543        {
544            let registry = crate::groups::app_data::load_component_registry(&mls_group)
545                .map_err(|_| GroupError::InvalidWelcomeMetadata)?;
546            xmtp_mls_group_state::app_data::policy_set::validate_registry_action_policies(
547                &registry,
548            )
549            .map_err(|error| {
550                tracing::warn!(%error, "invalid registry action policy in Welcome");
551                GroupError::InvalidWelcomeMetadata
552            })?;
553        }
554
555        // implements: GMOD-032
556        // A readd that never removed the inbox leaves its leave request pending.
557        let leave_pending = is_readd_after_leaving
558            && db.get_user_pending_remove_status(&group_id, context.inbox_id())?;
559        let membership_state = if leave_pending {
560            GroupMembershipState::PendingRemove
561        } else if is_readd_after_leaving {
562            tracing::info!(
563                group_id = %group_id,
564                "User is being re-added after leaving/removal, setting membership state to ALLOWED"
565            );
566            GroupMembershipState::Allowed
567        } else {
568            tracing::debug!(
569                group_id = %group_id,
570                "User is being added to new group, setting membership state to PENDING"
571            );
572            GroupMembershipState::Pending
573        };
574
575        // implements: SYNC-031
576        let joined_at_ns = if conversation_type == ConversationType::Sync {
577            xmtp_db::group::next_sync_join_time(&db)?
578        } else {
579            now_ns()
580        };
581        let mut group = StoredGroup::builder();
582        group
583            .id(group_id)
584            .created_at_ns(joined_at_ns)
585            .added_by_inbox_id(&added_by_inbox_id)
586            .cursor(welcome.cursor)
587            .conversation_type(conversation_type)
588            .dm_id(dm_members.map(String::from))
589            .message_disappear_from_ns(disappearing_settings.as_ref().map(|m| m.from_ns))
590            .message_disappear_in_ns(disappearing_settings.as_ref().map(|m| m.in_ns))
591            .commit_log_public_key(Some(commit_log_id.to_vec()))
592            .should_publish_commit_log(MlsGroup::<C>::check_should_publish_commit_log(
593                context.inbox_id().to_string(),
594                mutable_metadata,
595            ));
596
597        let to_store = match conversation_type {
598            ConversationType::Group => group.membership_state(membership_state).build()?,
599            ConversationType::Dm => {
600                validate_dm_group(context, &mls_group, &added_by_inbox_id)?;
601                group
602                    .membership_state(membership_state)
603                    .last_message_ns(welcome.timestamp())
604                    .build()?
605            }
606            ConversationType::Sync => {
607                // The own inbox's sync groups are Allowed; others were rejected above.
608                group
609                    .membership_state(GroupMembershipState::Allowed)
610                    .build()?
611            }
612            ConversationType::Oneshot => {
613                unreachable!("StagedWelcome of type Oneshot should already be handled")
614            }
615        };
616
617        tracing::debug!("storing group with welcome id {}", welcome.cursor);
618
619        // If this is a re-add after leaving, update the existing group's membership state
620        // before calling insert_or_replace_group
621        if is_readd_after_leaving && let Some(ref existing) = existing_group {
622            tracing::info!(
623                group_id = %existing.id,
624                "Updating existing group membership state after readd"
625            );
626            db.update_group_membership(existing.id, membership_state)?;
627        }
628
629        // implements: FORK-041
630        if existing_group
631            .as_ref()
632            .is_some_and(|group| group.membership_state != GroupMembershipState::Restored)
633        {
634            db.set_group_commit_log_forked_status(&group_id, None)?;
635        }
636
637        // Insert the group, replace a Restored placeholder, or advance an existing cursor.
638        let should_publish = to_store.should_publish_commit_log;
639        let mut stored_group = db.insert_or_replace_group(to_store)?;
640        // implements: FORK-085
641        // Existing rows retain their fields on rejoin; refresh the publisher role
642        // from this Welcome in the same state transaction.
643        db.set_should_publish_commit_log(&stored_group.id, should_publish)?;
644        // This Welcome starts a new membership even when local logging is off.
645        // Exclude every older local record and durably finish legacy recovery.
646        db.update_cursor(
647            group_id,
648            EntityKind::CommitLogUpload,
649            Cursor(prior_membership_cutoff as u64),
650        )?;
651        db.update_cursor(group_id, EntityKind::CommitLogPublisherRecovery, Cursor(1))?;
652        // This membership has an authenticated Welcome boundary instead of a legacy floor.
653        db.set_commit_log_reader_epoch_floor(&group_id, 0)?;
654        stored_group.should_publish_commit_log = should_publish;
655        // No join stores consent, for any conversation type.
656        // implements: SYNC-014, SYNC-035
657        // A sync Welcome means a new installation. In this transaction, owe it
658        // every record and the root key, and schedule its adds.
659        if stored_group.conversation_type == ConversationType::Sync {
660            schedule_newcomer_adds(&db, event_buffer)?;
661            if xmtp_db::sync_outbox::mark_all_owed(&db)? {
662                emit_root_key_changed(event_buffer, &db, true)?;
663            }
664            event_buffer.emit(None, Some(InternalEvent::OutboxChanged));
665        }
666
667        // Create a GroupUpdated payload
668        let current_inbox_id = context.inbox_id().to_string();
669        let added_payload = GroupUpdated {
670            initiated_by_inbox_id: added_by_inbox_id.clone(),
671            added_inboxes: vec![Inbox {
672                inbox_id: current_inbox_id.clone(),
673            }],
674            removed_inboxes: vec![],
675            metadata_field_changes: vec![],
676            left_inboxes: vec![],
677            added_admin_inboxes: vec![],
678            removed_admin_inboxes: vec![],
679            added_super_admin_inboxes: vec![],
680            removed_super_admin_inboxes: vec![],
681        };
682
683        let encoded_added_payload = GroupUpdatedCodec::encode(added_payload)?;
684        let mut encoded_added_payload_bytes = Vec::new();
685        encoded_added_payload.encode(&mut encoded_added_payload_bytes)?;
686
687        let added_idempotency_key = format!("{}_welcome_added", welcome.created_ns);
688        let added_message_id = crate::groups::message_id::calculate_message_id(
689            stored_group.id,
690            encoded_added_payload_bytes.as_slice(),
691            &added_idempotency_key,
692        );
693
694        let added_content_type = encoded_added_payload.r#type.unwrap_or_else(|| {
695            tracing::warn!("Missing content type in encoded added payload, using default values");
696            ContentTypeId {
697                authority_id: "unknown".to_string(),
698                type_id: "unknown".to_string(),
699                version_major: 0,
700                version_minor: 0,
701            }
702        });
703
704        let cursor = anchor.0 as i64;
705
706        // this is the commit that brought us into the group
707        let added_msg = StoredGroupMessage {
708            id: added_message_id,
709            group_id: stored_group.id,
710            decrypted_message_bytes: encoded_added_payload_bytes,
711            sent_at_ns: welcome.timestamp(),
712            kind: GroupMessageKind::MembershipChange,
713            sender_installation_id: added_by_installation_id,
714            sender_inbox_id: added_by_inbox_id.clone(),
715            delivery_status: DeliveryStatus::Published,
716            content_type: ContentType::from_identifier(
717                &added_content_type.authority_id,
718                &added_content_type.type_id,
719                added_content_type.version_major,
720            ),
721            version_major: added_content_type.version_major as i32,
722            version_minor: added_content_type.version_minor as i32,
723            authority_id: added_content_type.authority_id,
724            reference_id: None,
725            sequence_id: cursor,
726            envelope_hash: None,
727            expiry_ns: None,
728            expiry_unknown: false,
729            deleted_by_inbox_id: None,
730            expire_at_ns: None,
731            inserted_at_ns: 0, // Will be set by database
732            should_push: true,
733            // Matches the key used to derive `added_message_id` above.
734            idempotency_key: added_idempotency_key,
735        };
736
737        added_msg.store_or_ignore(&db)?;
738
739        tracing::debug!("created GroupUpdated message for welcome, inbox_id={current_inbox_id}");
740
741        let group = MlsGroup::new(
742            context.clone(),
743            stored_group.id,
744            stored_group.dm_id,
745            stored_group.conversation_type,
746            stored_group.created_at_ns,
747        );
748
749        // State, progress, and removal of pre-join work commit together.
750        db.install_group_anchor(group.group_id, anchor, anchor_mode)?;
751        db.record_welcome_discovery(group.group_id, welcome.cursor)?;
752        db.update_responded_at_sequence_id(
753            &group.group_id,
754            context.installation_id().as_slice(),
755            cursor,
756        )?;
757        event_buffer.emit_with_context(
758            (!stored_group.conversation_type.is_virtual()).then(|| {
759                ClientEvent::ConversationJoined(ConversationJoined {
760                    group_id: group.group_id.to_vec(),
761                    conversation_type: if stored_group.conversation_type == ConversationType::Dm {
762                        EventConversationType::Dm
763                    } else {
764                        EventConversationType::Group
765                    },
766                    origin: JoinOrigin::Welcomed,
767                    adder_inbox_id: Some(added_by_inbox_id.clone()),
768                })
769            }),
770            Some(InternalEvent::GroupJoined {
771                group_id: group.group_id,
772                is_sync: stored_group.conversation_type == ConversationType::Sync,
773            }),
774            dm_event_context(group.dm_id.as_deref()),
775        );
776
777        tracing::debug!(
778            inbox_id = %current_inbox_id,
779            installation_id = %self.context.installation_id().as_slice().snippet(),
780            group_id = %group.group_id,
781            welcome_id = welcome.cursor.0,
782
783            cursor = cursor,
784            "updated message cursor from welcome metadata"
785        );
786
787        Ok(Some(group))
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use crate::groups::InitialMembershipValidator;
795    use crate::prelude::*;
796    use crate::tester;
797    use crate::utils::test::MlsGroupExt;
798
799    struct UnavailableValidator;
800
801    impl ValidateGroupMembership for UnavailableValidator {
802        async fn check_initial_membership(
803            &self,
804            _welcome: &WelcomeMembership,
805        ) -> Result<(), GroupError> {
806            Err(GroupError::LockUnavailable)
807        }
808    }
809
810    #[xmtp_common::test(unwrap_try = true)]
811    async fn trial_validation_failure_preserves_welcome_keys() {
812        tester!(alix, disable_workers);
813        tester!(bo, disable_workers);
814        let alix_group = alix.create_group(None, None)?;
815        alix_group.invite(&bo).await?;
816        let welcome = bo
817            .context
818            .api()
819            .query_welcome_messages(bo.context.installation_id())
820            .await?
821            .pop()?;
822
823        let events = bo.context.events().subscribe(
824            xmtp_events::EventFilter::new([xmtp_events::EventKind::ConversationJoined]),
825            Some(10),
826        );
827        let result = XmtpWelcome::builder()
828            .context(bo.context.clone())
829            .welcome(&welcome)
830            .pending(
831                crate::groups::welcome_sync::pending_welcome_for_test(&bo.context, &welcome)
832                    .await?,
833            )
834            .validator(UnavailableValidator)
835            .process()
836            .await;
837        assert!(matches!(result, Err(GroupError::LockUnavailable)));
838        assert!(events.drain().is_empty());
839        assert!(bo.context.db().find_group(&alix_group.group_id)?.is_none());
840        assert_eq!(
841            bo.context
842                .db()
843                .get_last_cursor(bo.context.installation_id(), EntityKind::Welcome)?,
844            Cursor(0)
845        );
846
847        let bo_group = bo.sync_welcomes().await?.pop()?;
848        assert!(matches!(
849            events.drain().pop().and_then(|event| event.client),
850            Some(xmtp_events::ClientEvent::ConversationJoined(joined)) if joined.group_id == bo_group.group_id.to_vec()
851        ));
852        alix_group.test_can_talk_with(&bo_group).await?;
853    }
854
855    struct JoinDuringValidation<C> {
856        context: C,
857        welcome: xmtp_proto::types::WelcomeMessage,
858    }
859
860    impl<C: XmtpSharedContext> ValidateGroupMembership for JoinDuringValidation<C> {
861        async fn check_initial_membership(
862            &self,
863            _welcome: &WelcomeMembership,
864        ) -> Result<(), GroupError> {
865            let group = XmtpWelcome::builder()
866                .context(self.context.clone())
867                .welcome(&self.welcome)
868                .pending(
869                    crate::groups::welcome_sync::pending_welcome_for_test(
870                        &self.context,
871                        &self.welcome,
872                    )
873                    .await?,
874                )
875                .validator(InitialMembershipValidator::new(self.context.clone()))
876                .process()
877                .await?
878                .ok_or(GroupError::UninitializedResult)?;
879            group.sync_with_conn().await?;
880            Ok(())
881        }
882    }
883
884    #[xmtp_common::test(unwrap_try = true)]
885    async fn fresh_install_rejects_welcome_after_another_writer_advances_group() {
886        tester!(alix, disable_workers);
887        tester!(bo, disable_workers);
888        let alix_group = alix.create_group(None, None)?;
889        alix_group.invite(&bo).await?;
890        alix_group
891            .update_group_name("advanced during validation".into())
892            .await?;
893        let welcome = bo
894            .context
895            .api()
896            .query_welcome_messages(bo.context.installation_id())
897            .await?
898            .pop()?;
899
900        let result = XmtpWelcome::builder()
901            .context(bo.context.clone())
902            .welcome(&welcome)
903            .pending(
904                crate::groups::welcome_sync::pending_welcome_for_test(&bo.context, &welcome)
905                    .await?,
906            )
907            .validator(JoinDuringValidation {
908                context: bo.context.clone(),
909                welcome: welcome.clone(),
910            })
911            .process()
912            .await;
913        assert!(matches!(
914            result,
915            Err(GroupError::ProcessIntent(
916                ProcessIntentError::WelcomeAlreadyProcessed(_)
917            ))
918        ));
919        let bo_group = bo.group(&alix_group.group_id)?;
920        assert_eq!(
921            alix_group.epoch_authenticator().await?,
922            bo_group.epoch_authenticator().await?
923        );
924        alix_group.test_can_talk_with(&bo_group).await?;
925    }
926
927    /// Process `joiner`'s newest Welcome with the production validator.
928    async fn process_newest_welcome(
929        joiner: &crate::utils::FullXmtpClient,
930    ) -> Result<Option<MlsGroup<crate::utils::TestXmtpMlsContext>>, GroupError> {
931        let welcome = joiner
932            .context
933            .api()
934            .query_welcome_messages(joiner.context.installation_id())
935            .await?
936            .into_iter()
937            .max_by_key(|welcome| welcome.cursor)
938            .ok_or(GroupError::UninitializedResult)?;
939        XmtpWelcome::builder()
940            .context(joiner.context.clone())
941            .welcome(&welcome)
942            .pending(
943                crate::groups::welcome_sync::pending_welcome_for_test(&joiner.context, &welcome)
944                    .await?,
945            )
946            .validator(InitialMembershipValidator::new(joiner.context.clone()))
947            .process()
948            .await
949    }
950
951    /// A Welcome whose group context has no root, a root for another id, or a
952    /// 16-byte group id is rejected for good, and its group is not installed.
953    /// A one-shot Welcome with no root is rejected the same way, before its
954    /// one-shot message is read. The same crafted Welcome with the group's own
955    /// root joins, so the root is the only reason for each rejection.
956    // verifies: JOIN-083, JOIN-048
957    #[xmtp_common::test(unwrap_try = true)]
958    async fn a_welcome_joins_only_when_its_root_names_the_group() {
959        use crate::utils::test::crafted_welcome::{
960            assert_welcome_rejected, send_crafted_welcome, send_crafted_welcome_of_type,
961        };
962        use xmtp_mls_group_keys::group_keys::{GroupKeys, GroupRoot};
963        use xmtp_mls_group_state::app_data::group_root::GroupRootError;
964
965        tester!(alix, disable_workers);
966        tester!(bo, disable_workers);
967        let keyed_id = |root: &GroupRoot| GroupKeys::derive(root).group_id().to_vec();
968        let root = GroupRoot::generate();
969        let other_root = GroupRoot::generate();
970        let legacy_id = xmtp_common::rand_vec::<{ xmtp_configuration::LEGACY_GROUP_ID_BYTES }>();
971
972        for (case, group_id, welcome_root, conversation_type, expected) in [
973            (
974                "no root",
975                keyed_id(&root),
976                None,
977                ConversationType::Group,
978                GroupRootError::Missing,
979            ),
980            (
981                "another group's root",
982                keyed_id(&root),
983                Some(&other_root),
984                ConversationType::Group,
985                GroupRootError::OtherGroup,
986            ),
987            (
988                "a 16-byte group",
989                legacy_id.clone(),
990                Some(&root),
991                ConversationType::Group,
992                GroupRootError::OtherGroup,
993            ),
994            (
995                "a one-shot group with no root",
996                keyed_id(&other_root),
997                None,
998                ConversationType::Oneshot,
999                GroupRootError::Missing,
1000            ),
1001        ] {
1002            send_crafted_welcome_of_type(&alix, &bo, &group_id, welcome_root, conversation_type)
1003                .await;
1004            let cursor = bo
1005                .context
1006                .api()
1007                .query_welcome_messages(bo.context.installation_id())
1008                .await?
1009                .into_iter()
1010                .map(|welcome| welcome.cursor)
1011                .max()
1012                .expect(case);
1013            let result = process_newest_welcome(&bo).await;
1014            assert!(
1015                matches!(&result, Err(GroupError::WelcomeGroupRoot(error)) if *error == expected),
1016                "{case}: {result:?}"
1017            );
1018            let group_id = GroupId::try_from(group_id.as_slice())?;
1019            assert_eq!(
1020                assert_welcome_rejected(&bo, &group_id),
1021                cursor,
1022                "{case}: the rejection is this Welcome's"
1023            );
1024        }
1025
1026        let joined_id = keyed_id(&root);
1027        send_crafted_welcome(&alix, &bo, &joined_id, Some(&root)).await;
1028        let joined = process_newest_welcome(&bo).await?.expect("a group");
1029        assert_eq!(joined.group_id.as_slice(), joined_id.as_slice());
1030    }
1031
1032    /// An own installation that has not loaded its revocation offers a sync
1033    /// group or a group. The joiner holds the revocation, so it rejects the
1034    /// Welcome for good and stores neither the group nor a consent record. A
1035    /// current own adder still joins while the newest own state is uncached.
1036    // verifies: JOIN-095
1037    #[rstest::rstest]
1038    #[case::revoked_sync(ConversationType::Sync, true)]
1039    #[case::revoked_group(ConversationType::Group, true)]
1040    #[case::current_uncached(ConversationType::Group, false)]
1041    #[xmtp_common::test(unwrap_try = true)]
1042    async fn own_adder_must_be_current(#[case] kind: ConversationType, #[case] revoked: bool) {
1043        use crate::utils::test::crafted_welcome::{
1044            assert_welcome_rejected, send_crafted_welcome_of_type,
1045        };
1046        use diesel::prelude::*;
1047        use xmtp_db::{ConnectionExt, schema::association_state::dsl};
1048        use xmtp_id::associations::test_utils::add_wallet_signature;
1049        use xmtp_mls_group_keys::group_keys::{GroupKeys, GroupRoot};
1050
1051        tester!(alix1, disable_workers);
1052        tester!(alix2, from: alix1, disable_workers);
1053        let identity = alix1.identity_updates();
1054        let db = alix1.context.db();
1055        if revoked {
1056            let revoked = vec![alix2.context.installation_id().to_vec()];
1057            let mut revoke = identity.revoke_installations(revoked).await.unwrap();
1058            add_wallet_signature(&mut revoke, &alix1.builder.owner).await;
1059            identity.apply_signature_request(revoke).await.unwrap();
1060        } else {
1061            // A newer own update is stored, but its state is not cached.
1062            tester!(_alix3, from: alix1, disable_workers);
1063            let inbox_id = alix1.inbox_id();
1064            identity
1065                .get_latest_association_state(&db, inbox_id)
1066                .await
1067                .unwrap();
1068            let newest = db.get_latest_sequence_id_for_inbox(inbox_id).unwrap();
1069            let cached = dsl::association_state.filter(dsl::sequence_id.eq(newest));
1070            db.raw_query(|conn| diesel::delete(cached).execute(conn))
1071                .unwrap();
1072        }
1073
1074        let root = GroupRoot::generate();
1075        let group_id = GroupKeys::derive(&root).group_id().to_vec();
1076        send_crafted_welcome_of_type(&alix2, &alix1, &group_id, Some(&root), kind).await;
1077        let group_id = GroupId::try_from(group_id.as_slice()).unwrap();
1078        if !revoked {
1079            // The Welcome can take a moment to become readable.
1080            let joined = xmtp_common::wait_for_some(|| async {
1081                alix1.sync_welcomes().await.unwrap();
1082                db.find_group(&group_id).unwrap()
1083            });
1084            assert!(joined.await.is_some());
1085            return;
1086        }
1087
1088        let result = process_newest_welcome(&alix1).await;
1089        assert!(
1090            matches!(result, Err(GroupError::InvalidGroupMembership)),
1091            "{result:?}"
1092        );
1093        assert_welcome_rejected(&alix1, &group_id);
1094        let consent = db
1095            .consent_row(&xmtp_db::consent_record::ConsentKey::group(&group_id))
1096            .unwrap();
1097        assert!(consent.is_none(), "{consent:?}");
1098    }
1099
1100    /// A former member can offer a second MLS branch under the group's root,
1101    /// at a lower epoch than the removed copy and with an anchor past the
1102    /// removal. It is rejected for good, and the removed copy keeps its
1103    /// epoch, its cursor, and the envelopes queued after the removal.
1104    // verifies: JOIN-042
1105    #[xmtp_common::test(unwrap_try = true)]
1106    async fn inactive_group_rejects_stale_branch_and_keeps_pending_prefix() {
1107        use crate::utils::test::crafted_welcome::{CraftedWelcome, send_crafted_welcome_with};
1108        use xmtp_db::incoming_envelope::QueryIncomingEnvelope;
1109        use xmtp_mls_group_state::app_data::group_root::read_group_root;
1110        use xmtp_proto::types::Topic;
1111
1112        tester!(alix, disable_workers);
1113        tester!(bo, disable_workers);
1114        let alix_group = alix.create_group(None, None)?;
1115        let root = read_group_root(alix_group.load_group_context()?.extensions())?
1116            .expect("a new group carries its root");
1117        alix_group.invite(&bo).await?;
1118        let bo_group = bo.sync_welcomes().await?.pop().expect("initial Welcome");
1119        bo_group.sync().await?;
1120        alix_group.remove_members(&[bo.inbox_id()]).await?;
1121        let _ = bo_group.sync().await;
1122        assert!(!bo_group.is_active()?, "the removal must be applied");
1123
1124        let db = bo.context.db();
1125        let topic = StreamTopic::group(bo_group.group_id);
1126        let before = db.topic_progress(&topic)?;
1127        let old_epoch = bo_group.epoch().await?;
1128        assert!(old_epoch > 1, "the crafted branch joins at epoch one");
1129
1130        // Receive a real group envelope after the removal without processing
1131        // it. The forged anchor names this pending network position.
1132        alix_group.send_msg(b"after removal").await;
1133        bo.mls_store()
1134            .receive_topics_once(
1135                &[Topic::new_group_message(bo_group.group_id)],
1136                bo.context
1137                    .incoming_runtime()
1138                    .policy()
1139                    .incoming_limits(topic.kind),
1140            )
1141            .await?;
1142        let forged_anchor = db.topic_progress(&topic)?.received;
1143        assert!(before.processed < forged_anchor);
1144        assert!(db.pending_envelope(&topic, forged_anchor)?.is_some());
1145
1146        let options = CraftedWelcome {
1147            anchor: forged_anchor.0,
1148            ..Default::default()
1149        };
1150        send_crafted_welcome_with(
1151            &alix,
1152            &bo,
1153            alix_group.group_id.as_slice(),
1154            Some(&root),
1155            options,
1156        )
1157        .await;
1158        let result = process_newest_welcome(&bo).await;
1159        assert!(
1160            matches!(
1161                result,
1162                Err(GroupError::ProcessIntent(
1163                    ProcessIntentError::WelcomeAlreadyProcessed(_)
1164                ))
1165            ),
1166            "{result:?}"
1167        );
1168        bo.assert_newest_welcome_rejected().await;
1169        send_crafted_welcome_with(
1170            &alix,
1171            &bo,
1172            alix_group.group_id.as_slice(),
1173            Some(&root),
1174            CraftedWelcome {
1175                anchor: before.processed.0,
1176                prior_epoch: old_epoch - 1,
1177                ..Default::default()
1178            },
1179        )
1180        .await;
1181        assert!(matches!(
1182            process_newest_welcome(&bo).await,
1183            Err(GroupError::ProcessIntent(
1184                ProcessIntentError::WelcomeAlreadyProcessed(_)
1185            ))
1186        ));
1187        bo.assert_newest_welcome_rejected().await;
1188        assert!(!bo_group.is_active()?);
1189        assert_eq!(bo_group.epoch().await?, old_epoch);
1190        assert_eq!(db.topic_progress(&topic)?.processed, before.processed);
1191        assert!(db.pending_envelope(&topic, forged_anchor)?.is_some());
1192    }
1193
1194    /// A removed former member reuses the group's root to offer a DM, then a
1195    /// one-shot group, under the group's id. Each Welcome is rejected for good
1196    /// before anything is installed or any one-shot handling runs: the stored
1197    /// row, MLS state, and cursor stay as they were.
1198    // verifies: JOIN-089
1199    #[xmtp_common::test(unwrap_try = true)]
1200    async fn replacement_welcome_cannot_change_conversation_type() {
1201        use crate::utils::test::crafted_welcome::{
1202            CraftedWelcome, send_crafted_welcome_of_type, send_crafted_welcome_with,
1203        };
1204        use xmtp_mls_group_state::app_data::group_root::read_group_root;
1205
1206        tester!(alix, disable_workers);
1207        tester!(bo, disable_workers);
1208        tester!(caro, disable_workers);
1209
1210        let original = alix.create_group(None, None)?;
1211        original.invite(&caro).await?;
1212        let caro_group = caro.sync_welcomes().await?.pop().expect("Caro joined");
1213        let retained_root = read_group_root(caro_group.load_group_context()?.extensions())?
1214            .expect("the original group has a root");
1215        original.invite(&bo).await?;
1216        let bo_group = bo.sync_welcomes().await?.pop().expect("Bo joined");
1217        original.remove_members(&[caro.inbox_id()]).await?;
1218        original.remove_members(&[bo.inbox_id()]).await?;
1219        bo_group.sync_with_conn().await?;
1220        assert!(!bo_group.is_active()?);
1221        let row_before_forgery = bo
1222            .context
1223            .db()
1224            .find_group(&original.group_id)?
1225            .expect("stored");
1226        assert_eq!(
1227            row_before_forgery.conversation_type,
1228            ConversationType::Group
1229        );
1230        let anchor = bo_group.cursor().await?;
1231        let prior_epoch = bo_group.epoch().await?;
1232
1233        // Caro's DM with Bo, at the next epoch and anchored at Bo's cursor,
1234        // passes every check a same-type re-add Welcome would.
1235        let options = CraftedWelcome {
1236            conversation_type: ConversationType::Dm,
1237            anchor: anchor.0,
1238            prior_epoch,
1239            ..Default::default()
1240        };
1241        send_crafted_welcome_with(
1242            &caro,
1243            &bo,
1244            original.group_id.as_slice(),
1245            Some(&retained_root),
1246            options,
1247        )
1248        .await;
1249        let result = process_newest_welcome(&bo).await;
1250        assert!(
1251            matches!(result, Err(GroupError::InvalidWelcomeMetadata)),
1252            "{result:?}"
1253        );
1254        bo.assert_newest_welcome_rejected().await;
1255
1256        send_crafted_welcome_of_type(
1257            &caro,
1258            &bo,
1259            original.group_id.as_slice(),
1260            Some(&retained_root),
1261            ConversationType::Oneshot,
1262        )
1263        .await;
1264        let result = process_newest_welcome(&bo).await;
1265        assert!(
1266            matches!(result, Err(GroupError::InvalidWelcomeMetadata)),
1267            "{result:?}"
1268        );
1269        bo.assert_newest_welcome_rejected().await;
1270
1271        assert_eq!(
1272            bo.context.db().find_group(&original.group_id)?,
1273            Some(row_before_forgery)
1274        );
1275        assert!(!bo_group.is_active()?);
1276        assert_eq!(bo_group.epoch().await?, prior_epoch);
1277        assert_eq!(bo_group.cursor().await?, anchor);
1278    }
1279
1280    /// A member of a live DM holds its root and can offer a DM under its id
1281    /// with another pair, at the next epoch and past the DM's cursor, which
1282    /// would otherwise be held for the DM's topic. The client rejects it for
1283    /// good at once and keeps the DM it holds.
1284    // verifies: JOIN-089
1285    #[xmtp_common::test(unwrap_try = true)]
1286    async fn live_dm_rejects_welcome_with_another_pair() {
1287        use crate::groups::DmValidationError;
1288        use crate::utils::test::crafted_welcome::{CraftedWelcome, send_crafted_welcome_with};
1289        use xmtp_mls_group_state::app_data::group_root::read_group_root;
1290
1291        tester!(alix, disable_workers);
1292        tester!(bo, disable_workers);
1293        tester!(caro, disable_workers);
1294        let alix_dm = alix.find_or_create_dm(bo.inbox_id(), None).await?;
1295        let bo_dm = bo.sync_welcomes().await?.pop().expect("Bo joined the DM");
1296        assert!(bo_dm.is_active()?);
1297        let root = read_group_root(alix_dm.load_group_context()?.extensions())?
1298            .expect("a new DM carries its root");
1299        let row_before = bo.context.db().find_group(&alix_dm.group_id)?;
1300        let cursor_before = bo_dm.cursor().await?;
1301        let epoch_before = bo_dm.epoch().await?;
1302
1303        let options = CraftedWelcome {
1304            conversation_type: ConversationType::Dm,
1305            anchor: cursor_before.0 + 1,
1306            prior_epoch: epoch_before,
1307            dm_peer: Some(caro.inbox_id()),
1308            ..Default::default()
1309        };
1310        send_crafted_welcome_with(
1311            &alix,
1312            &bo,
1313            alix_dm.group_id.as_slice(),
1314            Some(&root),
1315            options,
1316        )
1317        .await;
1318        let result = process_newest_welcome(&bo).await;
1319        assert!(
1320            matches!(
1321                result,
1322                Err(GroupError::MetadataPermissionsError(
1323                    MetadataPermissionsError::DmValidation(DmValidationError::StoredDmIdMismatch)
1324                ))
1325            ),
1326            "{result:?}"
1327        );
1328        bo.assert_newest_welcome_rejected().await;
1329        assert_eq!(bo.context.db().find_group(&alix_dm.group_id)?, row_before);
1330        assert_eq!(bo_dm.cursor().await?, cursor_before);
1331        assert_eq!(bo_dm.epoch().await?, epoch_before);
1332        alix_dm.test_can_talk_with(&bo_dm).await?;
1333    }
1334
1335    /// A DM Welcome whose registry differs from the fixed DM tables, here in
1336    /// a `GROUP_MEMBERSHIP` update policy of deny, is rejected for good and
1337    /// the DM never appears. The same crafted Welcome with the creator's
1338    /// registry joins, so the registry is the only reason.
1339    // verifies: DMS-021, JOIN-060
1340    #[xmtp_common::test(unwrap_try = true)]
1341    async fn dm_welcome_with_a_nonstandard_registry_is_rejected() {
1342        use crate::groups::DmValidationError;
1343        use crate::utils::test::crafted_welcome::{CraftedWelcome, send_crafted_welcome_with};
1344        use xmtp_mls_group_keys::group_keys::{GroupKeys, GroupRoot};
1345        use xmtp_mls_group_state::app_data::{
1346            component_id::ComponentId, dm_registry_profile::dm_registry_profiles,
1347        };
1348        use xmtp_proto::xmtp::mls::message_contents::{
1349            MetadataPolicy,
1350            metadata_policy::{Kind, MetadataBasePolicy},
1351        };
1352
1353        tester!(alix, disable_workers);
1354        tester!(bo, disable_workers);
1355        let mut registry = dm_registry_profiles().pop()?;
1356        let mut membership = registry.get(&ComponentId::GROUP_MEMBERSHIP)?.unwrap();
1357        membership.permissions.as_mut().unwrap().update_policy = Some(MetadataPolicy {
1358            kind: Some(Kind::Base(MetadataBasePolicy::Deny as i32)),
1359        });
1360        registry.set(ComponentId::GROUP_MEMBERSHIP, membership)?;
1361
1362        let crafted_dm = async |registry| {
1363            let root = GroupRoot::generate();
1364            let group_id = GroupKeys::derive(&root).group_id().to_vec();
1365            let options = CraftedWelcome {
1366                conversation_type: ConversationType::Dm,
1367                registry,
1368                ..Default::default()
1369            };
1370            send_crafted_welcome_with(&alix, &bo, &group_id, Some(&root), options).await;
1371            group_id
1372        };
1373
1374        let rejected_id = crafted_dm(Some(&registry)).await;
1375        let result = process_newest_welcome(&bo).await;
1376        assert!(
1377            matches!(
1378                result,
1379                Err(GroupError::MetadataPermissionsError(
1380                    MetadataPermissionsError::DmValidation(DmValidationError::InvalidPermissions)
1381                ))
1382            ),
1383            "{result:?}"
1384        );
1385        bo.assert_newest_welcome_rejected().await;
1386        let rejected_id = GroupId::try_from(rejected_id.as_slice())?;
1387        assert!(bo.context.db().find_group(&rejected_id)?.is_none());
1388
1389        let joined_id = crafted_dm(None).await;
1390        let joined = process_newest_welcome(&bo)
1391            .await?
1392            .expect("the creator's registry joins");
1393        assert_eq!(joined.group_id.as_slice(), joined_id.as_slice());
1394    }
1395
1396    /// Another inbox can add this installation to a group whose creator field
1397    /// names this inbox. The join keeps a decision that reached this
1398    /// installation first, and stores no decision when there was none.
1399    // verifies: CONS-058
1400    #[rstest::rstest]
1401    #[case::prior_denied(true)]
1402    #[case::no_record(false)]
1403    #[xmtp_common::test(unwrap_try = true)]
1404    async fn forged_creator_claim_keeps_prior_consent(#[case] prior_denied: bool) {
1405        use crate::utils::test::crafted_welcome::{CraftedWelcome, send_crafted_welcome_with};
1406        use xmtp_db::consent_record::{ConsentKey, ConsentState, ConsentType, StoredConsentRecord};
1407        use xmtp_mls_group_keys::group_keys::{GroupKeys, GroupRoot};
1408
1409        tester!(alix, disable_workers);
1410        tester!(bo, disable_workers);
1411        let root = GroupRoot::generate();
1412        let group_id = GroupKeys::derive(&root).group_id().to_vec();
1413        let entity = hex::encode(&group_id);
1414        // A peer installation may sync a decision before this Welcome.
1415        let prior = prior_denied.then(|| {
1416            StoredConsentRecord::new(
1417                ConsentType::ConversationId,
1418                ConsentState::Denied,
1419                entity.clone(),
1420            )
1421        });
1422        if let Some(prior) = &prior {
1423            prior.merge_into(&bo.context.db()).unwrap();
1424        }
1425
1426        let options = CraftedWelcome {
1427            claimed_creator: Some(bo.inbox_id()),
1428            ..Default::default()
1429        };
1430        send_crafted_welcome_with(&alix, &bo, &group_id, Some(&root), options).await;
1431        let joined = process_newest_welcome(&bo)
1432            .await
1433            .unwrap()
1434            .expect("valid Welcome");
1435        assert_eq!(joined.added_by_inbox_id().unwrap(), alix.inbox_id());
1436        let stored = bo
1437            .context
1438            .db()
1439            .consent_row(&ConsentKey::group(&group_id))
1440            .unwrap();
1441        assert_eq!(
1442            stored.map(|record| (record.state, record.consented_at_ns)),
1443            prior.map(|record| (record.state, record.consented_at_ns))
1444        );
1445    }
1446
1447    /// No join stores consent: a stranger's group that names this inbox as
1448    /// its creator, joined by one installation from the stranger and by
1449    /// another from the first, reads unknown on both, and a group this inbox
1450    /// created, joined by another of its installations, gets no record there.
1451    // verifies: CONS-058
1452    #[xmtp_common::test(unwrap_try = true)]
1453    async fn joining_stores_no_state() {
1454        use crate::utils::test::crafted_welcome::{CraftedWelcome, send_crafted_welcome_with};
1455        use xmtp_db::consent_record::{ConsentKey, ConsentState};
1456        use xmtp_mls_group_keys::group_keys::{GroupKeys, GroupRoot};
1457
1458        tester!(alix, disable_workers);
1459        tester!(bo1, disable_workers);
1460        tester!(bo2, from: bo1, disable_workers);
1461        let root = GroupRoot::generate();
1462        let group_id = GroupKeys::derive(&root).group_id().to_vec();
1463        let options = CraftedWelcome {
1464            claimed_creator: Some(bo1.inbox_id()),
1465            ..Default::default()
1466        };
1467        send_crafted_welcome_with(&alix, &bo1, &group_id, Some(&root), options).await;
1468        let joined = process_newest_welcome(&bo1).await?.expect("valid Welcome");
1469        // bo1 adds its other installation, as a member may.
1470        joined.update_installations().await?;
1471        bo2.sync_welcomes().await?;
1472        let own = bo1.create_group(None, None)?;
1473        own.update_installations().await?;
1474        bo2.sync_welcomes().await?;
1475        for bo in [&bo1, &bo2] {
1476            assert_eq!(
1477                bo.group(&joined.group_id)?.consent_state()?,
1478                ConsentState::Unknown
1479            );
1480            assert!(
1481                bo.db()
1482                    .consent_row(&ConsentKey::group(&group_id))?
1483                    .is_none()
1484            );
1485        }
1486        assert_eq!(
1487            bo1.group(&own.group_id)?.consent_state()?,
1488            ConsentState::Allowed
1489        );
1490        let own_key = ConsentKey::group(own.group_id.as_ref());
1491        assert!(
1492            bo2.db().consent_row(&own_key)?.is_none(),
1493            "the join stored nothing"
1494        );
1495    }
1496
1497    /// A former member of group G holds G's root and can build a DM under
1498    /// G's id. The DM reads its builder's inbox record; the user's decision
1499    /// about G stays under G's id and nothing is moved or owed.
1500    // verifies: CONS-052, CONS-058
1501    #[xmtp_common::test(unwrap_try = true)]
1502    async fn forged_dm_with_group_id_reads_its_builder() {
1503        use crate::utils::test::crafted_welcome::{CraftedWelcome, send_crafted_welcome_with};
1504        use xmtp_db::consent_record::{ConsentKey, ConsentState, ConsentType, StoredConsentRecord};
1505        use xmtp_mls_group_state::app_data::group_root::read_group_root;
1506
1507        tester!(alix, disable_workers);
1508        tester!(bo2, disable_workers);
1509        let group = alix.create_group(None, None)?;
1510        let root = read_group_root(group.load_group_context()?.extensions())?
1511            .expect("a new group carries its root");
1512        // bo2 holds the user's allow of G from sync, but not G.
1513        let g = ConsentKey::group(group.group_id.as_ref());
1514        StoredConsentRecord::new(
1515            ConsentType::ConversationId,
1516            ConsentState::Allowed,
1517            g.entity.clone(),
1518        )
1519        .merge_into(&bo2.db())?;
1520        let options = CraftedWelcome {
1521            conversation_type: ConversationType::Dm,
1522            ..Default::default()
1523        };
1524        send_crafted_welcome_with(&alix, &bo2, group.group_id.as_slice(), Some(&root), options)
1525            .await;
1526        let dm = process_newest_welcome(&bo2)
1527            .await?
1528            .expect("the forged DM joins");
1529        assert_eq!(dm.group_id, group.group_id);
1530        assert_eq!(dm.consent_state()?, ConsentState::Unknown);
1531        assert_eq!(
1532            bo2.db().consent_row(&g)?.map(|r| r.state),
1533            Some(ConsentState::Allowed)
1534        );
1535        assert!(
1536            bo2.db()
1537                .consent_row(&ConsentKey::inbox(alix.inbox_id()))?
1538                .is_none()
1539        );
1540        assert!(!xmtp_db::sync_outbox::has_owed(&bo2.db())?);
1541    }
1542
1543    // verifies: JOIN-044
1544    #[rstest::rstest]
1545    #[case::late_active_prefix(false)]
1546    #[case::live_retired_controller(true)]
1547    #[xmtp_common::test(unwrap_try = true)]
1548    async fn rejoin_keeps_messages_before_removal(#[case] live_receiver: bool) {
1549        use crate::subscriptions::incoming::{
1550            IncomingCoordinator, IncomingRegistration, IncomingScope,
1551        };
1552        use xmtp_common::time::{Duration, timeout};
1553
1554        tester!(alix, disable_workers);
1555        tester!(bo, disable_workers);
1556        let alix_group = alix.create_group(None, None).unwrap();
1557        alix_group.invite(&bo).await.unwrap();
1558        let bo_group = bo.sync_welcomes().await.unwrap().pop().unwrap();
1559        let lease = live_receiver.then(|| {
1560            IncomingCoordinator::for_context(&bo.context).acquire(IncomingScope::AllGroups)
1561        });
1562        let topic = xmtp_proto::types::Topic::new_group_message(bo_group.group_id);
1563        alix_group.send_msg(b"before removal").await;
1564        alix_group.remove_members(&[bo.inbox_id()]).await.unwrap();
1565        if let Some(lease) = &lease {
1566            timeout(Duration::from_secs(10), async {
1567                loop {
1568                    if lease.snapshot().topics.iter().any(|entry| {
1569                        entry.topic == topic && entry.registration == IncomingRegistration::Removed
1570                    }) {
1571                        break;
1572                    }
1573                    lease.changed().await;
1574                }
1575            })
1576            .await
1577            .unwrap();
1578        }
1579        alix_group.invite(&bo).await.unwrap();
1580
1581        bo.sync_welcomes().await.unwrap();
1582        let messages = bo
1583            .context
1584            .db()
1585            .get_group_messages(&bo_group.group_id, &Default::default())
1586            .unwrap();
1587        assert!(
1588            messages
1589                .iter()
1590                .any(|message| message.decrypted_message_bytes == b"before removal")
1591        );
1592        assert_eq!(
1593            alix_group.epoch_authenticator().await.unwrap(),
1594            bo_group.epoch_authenticator().await.unwrap()
1595        );
1596        if let Some(lease) = &lease {
1597            alix_group.send_msg(b"after rejoin").await;
1598            timeout(Duration::from_secs(10), async {
1599                loop {
1600                    let messages = bo
1601                        .context
1602                        .db()
1603                        .get_group_messages(&bo_group.group_id, &Default::default())
1604                        .unwrap();
1605                    if messages
1606                        .iter()
1607                        .any(|message| message.decrypted_message_bytes == b"after rejoin")
1608                    {
1609                        return Ok::<_, xmtp_db::StorageError>(());
1610                    }
1611                    lease.changed().await;
1612                }
1613            })
1614            .await
1615            .unwrap()
1616            .unwrap();
1617        }
1618        alix_group.test_can_talk_with(&bo_group).await.unwrap();
1619    }
1620}