Skip to main content

xmtp_mls/groups/
validated_commit.rs

1use super::{
2    MAX_APP_DATA_LENGTH, MAX_GROUP_DESCRIPTION_LENGTH, MAX_GROUP_IMAGE_URL_LENGTH,
3    MAX_GROUP_NAME_LENGTH,
4    group_membership::{GroupMembership, MembershipDiff},
5    group_permissions::{
6        GroupMutablePermissions, GroupMutablePermissionsError, MembershipPolicy, MetadataPolicy,
7        PermissionsPolicy, PolicySet, extract_group_permissions,
8    },
9};
10
11#[cfg(test)]
12mod identity_tests;
13use crate::{
14    context::XmtpSharedContext,
15    identity_updates::{
16        IdentityDependencyError, IdentityRequirement, InstallationDiff, InstallationDiffError,
17        get_installation_diff_local, require_association_state,
18    },
19};
20use openmls::{
21    credentials::{BasicCredential, Credential as OpenMlsCredential, errors::BasicCredentialError},
22    extensions::{Extension, Extensions, UnknownExtension},
23    group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit},
24    messages::proposals::{Proposal, ProposalType},
25    prelude::{LeafNodeIndex, Sender},
26    treesync::LeafNode,
27};
28
29use crate::traits::FromWith;
30use prost::Message;
31use serde::Serialize;
32use std::collections::HashSet;
33use thiserror::Error;
34use xmtp_common::{retry::RetryableError, retryable};
35use xmtp_db::local_commit_log::CommitType;
36use xmtp_db::{DbQuery, StorageError};
37#[cfg(doc)]
38use xmtp_id::associations::AssociationState;
39use xmtp_id::{InboxId, associations::MemberIdentifier};
40use xmtp_mls_common::{
41    group_metadata::{DmMembers, GroupMetadata, GroupMetadataError},
42    group_mutable_metadata::{
43        GroupMutableMetadata, GroupMutableMetadataError, MetadataField,
44        find_mutable_metadata_extension,
45    },
46};
47use xmtp_proto::xmtp::{
48    identity::MlsCredential,
49    mls::message_contents::{
50        GroupMembershipChanges, GroupUpdated as GroupUpdatedProto,
51        group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto},
52    },
53};
54
55#[derive(Debug, Error)]
56pub enum CommitValidationError {
57    /// Committed local state could not be read. Repair or restore before retry.
58    #[error("Committed group state is invalid: {0}")]
59    InstalledState(Box<CommitValidationError>),
60    /// Resolve this proof outside the state transaction, then reload MLS state.
61    #[error(transparent)]
62    IdentityDependency(#[from] IdentityDependencyError),
63    /// Identity updates must precede the group envelope. Not retryable.
64    #[error(
65        "Identity sequence {identity_sequence} does not precede group sequence {envelope_sequence}"
66    )]
67    IdentitySequenceNotBeforeEnvelope {
68        /// Identity update sequence `N` named by the proposed membership.
69        identity_sequence: u64,
70        /// Authenticated group envelope sequence `S`; valid references have `N < S`.
71        envelope_sequence: u64,
72    },
73    #[error("Actor could not be found")]
74    ActorCouldNotBeFound,
75    // Subject of the proposal has an invalid credential
76    #[error("Inbox validation failed for {0}")]
77    InboxValidationFailed(String),
78    #[error("Insufficient permissions")]
79    InsufficientPermissions,
80    #[error("Invalid version format: {0}")]
81    InvalidVersionFormat(String),
82    #[error("Minimum supported protocol version {0} exceeds current version")]
83    ProtocolVersionTooLow(String),
84    // TODO: We will need to relax this once we support external joins
85    #[error("Actor not a member of the group")]
86    ActorNotMember,
87    #[error("Subject not a member of the group")]
88    SubjectDoesNotExist,
89    // Current behaviour is to error out if a Commit includes proposals from multiple actors
90    // TODO: We should relax this once we support self remove
91    #[error("Multiple actors in commit")]
92    MultipleActors,
93    #[error("Missing group membership")]
94    MissingGroupMembership,
95    #[error("Missing mutable metadata")]
96    MissingMutableMetadata,
97    #[error("Unexpected installations added:")]
98    UnexpectedInstallationAdded(Vec<Vec<u8>>),
99    #[error("Sequence ID can only increase")]
100    SequenceIdDecreased,
101    #[error("Unexpected installations removed: {0:?}")]
102    UnexpectedInstallationsRemoved(Vec<Vec<u8>>),
103    #[error(transparent)]
104    GroupMetadata(#[from] GroupMetadataError),
105    #[error(transparent)]
106    MlsCredential(#[from] BasicCredentialError),
107    #[error(transparent)]
108    GroupMutableMetadata(#[from] GroupMutableMetadataError),
109    #[error(transparent)]
110    ProtoDecode(#[from] prost::DecodeError),
111    #[error(transparent)]
112    InstallationDiff(#[from] InstallationDiffError),
113    #[error("Failed to parse group mutable permissions: {0}")]
114    GroupMutablePermissions(#[from] GroupMutablePermissionsError),
115    #[error("PSKs are not supported")]
116    NoPSKSupport,
117    #[error("Unsupported proposal type: {0:?}")]
118    UnsupportedProposalType(ProposalType),
119    #[error(transparent)]
120    StorageError(#[from] StorageError),
121    #[error("Exceeded max characters for this field. Must be under: {length}")]
122    TooManyCharacters { length: usize },
123    #[error("Proposer could not be determined for inbox change in proposal-enabled group")]
124    ProposerNotFound,
125    #[error("Proposals are not enabled on this group")]
126    ProposalsNotEnabled,
127    /// Sender published an `AppDataUpdate(Update)` against
128    /// `MIN_SUPPORTED_PROTOCOL_VERSION` whose new value is below the
129    /// existing floor. Monotonic-only: a downgrade silently unpauses
130    /// peers between the new and old floors, defeating XIP §3's gate.
131    #[error("min_version {requested} would downgrade existing floor {current}")]
132    MinVersionDowngrade { requested: String, current: String },
133    /// Sender published an `AppDataUpdate(Remove)` against
134    /// `MIN_SUPPORTED_PROTOCOL_VERSION` on a group that already has a
135    /// floor set. Explicit unsetting is just a downgrade in disguise,
136    /// rejected for the same XIP §3 reason.
137    #[error("min_version remove is rejected; existing floor is {current}")]
138    MinVersionRemoveOnExistingFloor { current: String },
139    /// A well-known component value in the AppData dictionary failed
140    /// to decode while validating an AppDataUpdate proposal — most
141    /// commonly a malformed `COMPONENT_REGISTRY`. Treated as a
142    /// terminal wire-format violation so the offending commit is
143    /// rejected rather than silently downgraded to "empty registry"
144    /// (which would let a permissive validator state slip in).
145    #[error(transparent)]
146    ComponentSource(#[from] super::app_data::component_source::ComponentSourceError),
147
148    /// All bootstrap-commit-validator failures. The bootstrap path runs
149    /// only during the one-time AppData migration; isolating its many
150    /// failure modes in a sub-enum keeps the steady-state validator's
151    /// surface from being dominated by migration-specific noise.
152    #[error(transparent)]
153    Bootstrap(#[from] super::app_data::bootstrap_validator::BootstrapValidationError),
154    #[error(transparent)]
155    Conversion(#[from] xmtp_proto::ConversionError),
156}
157
158impl crate::worker::NeedsDbReconnect for CommitValidationError {
159    fn needs_db_reconnect(&self) -> bool {
160        match self {
161            Self::InstalledState(error) => error.needs_db_reconnect(),
162            Self::IdentityDependency(error) => error.needs_db_reconnect(),
163            Self::StorageError(error) => error.db_needs_connection(),
164            _ => false,
165        }
166    }
167}
168
169impl RetryableError for CommitValidationError {
170    fn is_retryable(&self) -> bool {
171        match self {
172            CommitValidationError::IdentityDependency(error) => retryable!(error),
173            CommitValidationError::InstallationDiff(diff_error) => retryable!(diff_error),
174            _ => false,
175        }
176    }
177}
178
179impl CommitValidationError {
180    /// Mark a local state failure so malformed wire input cannot hide corruption.
181    pub(crate) fn installed_state(error: impl Into<Self>) -> Self {
182        Self::InstalledState(Box::new(error.into()))
183    }
184
185    /// Only authenticated, deterministic invalid input can advance the prefix.
186    /// Missing state, failed proofs, unsupported versions, and storage failures
187    /// leave the head pending even when their error is not retryable.
188    pub(crate) fn is_safe_rejection(&self) -> bool {
189        match self {
190            Self::IdentityDependency(IdentityDependencyError::MissingReference(_)
191                | IdentityDependencyError::InvalidSequence(_))
192            | Self::IdentitySequenceNotBeforeEnvelope { .. }
193            | Self::ActorCouldNotBeFound
194            | Self::InboxValidationFailed(_)
195            | Self::InsufficientPermissions
196            | Self::InvalidVersionFormat(_)
197            | Self::ActorNotMember
198            | Self::SubjectDoesNotExist
199            | Self::MultipleActors
200            | Self::UnexpectedInstallationAdded(_)
201            | Self::SequenceIdDecreased
202            | Self::UnexpectedInstallationsRemoved(_)
203            | Self::MlsCredential(_)
204            | Self::ProtoDecode(_)
205            | Self::NoPSKSupport
206            | Self::TooManyCharacters { .. }
207            | Self::ProposerNotFound
208            | Self::ProposalsNotEnabled
209            | Self::MinVersionDowngrade { .. }
210            | Self::MinVersionRemoveOnExistingFloor { .. }
211            | Self::MissingGroupMembership
212            | Self::MissingMutableMetadata
213            | Self::GroupMetadata(_)
214            | Self::GroupMutableMetadata(_)
215            | Self::GroupMutablePermissions(_)
216            | Self::ComponentSource(_)
217            | Self::Conversion(_) => true,
218            Self::Bootstrap(error) => !matches!(error,
219                super::app_data::bootstrap_validator::BootstrapValidationError::ProtocolVersionTooLow(_)
220                | super::app_data::bootstrap_validator::BootstrapValidationError::Synthesis(_)),
221            Self::InstalledState(_)
222            | Self::IdentityDependency(_)
223            | Self::InstallationDiff(_)
224            | Self::StorageError(_)
225            | Self::ProtocolVersionTooLow(_)
226            | Self::UnsupportedProposalType(_) => false,
227        }
228    }
229}
230
231#[derive(Clone, PartialEq, Hash, Serialize)]
232pub struct CommitParticipant {
233    pub inbox_id: String,
234    pub installation_id: Vec<u8>,
235    pub is_creator: bool,
236    pub is_admin: bool,
237    pub is_super_admin: bool,
238}
239
240impl std::fmt::Debug for CommitParticipant {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        let Self {
243            inbox_id,
244            installation_id,
245            is_creator,
246            is_admin,
247            is_super_admin,
248        } = &self;
249        write!(
250            f,
251            "CommitParticipant {{ inbox_id={}, installation_id={}, is_creator={}, is_admin={}, is_super_admin={} }}",
252            inbox_id,
253            hex::encode(installation_id),
254            is_creator,
255            is_admin,
256            is_super_admin,
257        )
258    }
259}
260
261impl CommitParticipant {
262    pub fn build(
263        inbox_id: String,
264        installation_id: Vec<u8>,
265        immutable_metadata: &GroupMetadata,
266        mutable_metadata: &GroupMutableMetadata,
267    ) -> Self {
268        let is_creator = inbox_id == immutable_metadata.creator_inbox_id;
269        let is_admin = mutable_metadata.is_admin(&inbox_id);
270        let is_super_admin = mutable_metadata.is_super_admin(&inbox_id);
271
272        Self {
273            inbox_id,
274            installation_id,
275            is_creator,
276            is_admin,
277            is_super_admin,
278        }
279    }
280
281    pub fn from_leaf_node(
282        leaf_node: &LeafNode,
283        immutable_metadata: &GroupMetadata,
284        mutable_metadata: &GroupMutableMetadata,
285    ) -> Result<Self, CommitValidationError> {
286        let inbox_id = inbox_id_from_credential(leaf_node.credential())?;
287        let installation_id = leaf_node.signature_key().as_slice().to_vec();
288
289        Ok(Self::build(
290            inbox_id,
291            installation_id,
292            immutable_metadata,
293            mutable_metadata,
294        ))
295    }
296
297    /// Project this participant into the admin/super-admin view that the
298    /// component-permission validator consumes.
299    fn actor_authority(&self) -> xmtp_mls_common::app_data::validation::ActorAuthority {
300        xmtp_mls_common::app_data::validation::ActorAuthority {
301            is_admin: self.is_admin,
302            is_super_admin: self.is_super_admin,
303        }
304    }
305}
306
307impl From<&CommitParticipant> for xmtp_mls_common::app_data::validation::ActorAuthority {
308    fn from(participant: &CommitParticipant) -> Self {
309        participant.actor_authority()
310    }
311}
312
313#[derive(Debug, Clone, Default, Serialize)]
314pub struct MutableMetadataValidationInfo {
315    pub metadata_field_changes: Vec<MetadataFieldChange>,
316    pub admins_added: Vec<Inbox>,
317    pub admins_removed: Vec<Inbox>,
318    pub super_admins_added: Vec<Inbox>,
319    pub super_admins_removed: Vec<Inbox>,
320    pub num_super_admins: u32,
321    pub minimum_supported_protocol_version: Option<String>,
322}
323
324impl MutableMetadataValidationInfo {
325    pub fn is_empty(&self) -> bool {
326        self.metadata_field_changes.is_empty()
327            && self.admins_added.is_empty()
328            && self.admins_removed.is_empty()
329            && self.super_admins_added.is_empty()
330            && self.super_admins_removed.is_empty()
331            && self.minimum_supported_protocol_version.is_none()
332    }
333}
334
335#[derive(Debug, Clone, Serialize)]
336pub struct Inbox {
337    pub inbox_id: String,
338    #[allow(dead_code)]
339    pub is_creator: bool,
340    pub is_admin: bool,
341    pub is_super_admin: bool,
342    /// The proposer who requested this inbox change (if from a proposal)
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub proposer: Option<CommitParticipant>,
345}
346
347#[derive(Debug, Clone, Serialize)]
348pub struct MetadataFieldChange {
349    pub field_name: String,
350    #[allow(dead_code)]
351    pub old_value: Option<String>,
352    #[allow(dead_code)]
353    pub new_value: Option<String>,
354}
355
356impl MetadataFieldChange {
357    pub fn new(field_name: String, old_value: Option<String>, new_value: Option<String>) -> Self {
358        Self {
359            field_name,
360            old_value,
361            new_value,
362        }
363    }
364}
365
366/// Wrapper around [`semver::Version`] used for the
367/// `MIN_SUPPORTED_PROTOCOL_VERSION` floor and related min-version checks.
368///
369/// Delegates parsing and ordering to the [`semver`] crate so behavior
370/// matches the semver 2.0 spec — most importantly:
371///
372/// * Pre-release versions sort *before* the release: `1.0.0-alpha <
373///   1.0.0-beta < 1.0.0`. The previous hand-rolled implementation got
374///   this backwards (`1.0.0 < 1.0.0-alpha`), which would silently
375///   pause clients running release builds against any group floor set
376///   by a caller passing a pre-release string.
377/// * Pre-release identifiers compare numerically when all-digits, so
378///   `rc2 < rc10` instead of lexicographic `rc10 < rc2`.
379/// * Multi-segment pre-release tags like `1.0.0-alpha.1` parse cleanly
380///   instead of failing with `InvalidVersionFormat`.
381/// * Build metadata (after `+`) parses cleanly. Note: the [`semver`]
382///   crate's `Ord` impl deliberately *includes* build metadata for
383///   total-ordering / `Hash` consistency, deviating from semver 2.0
384///   §10 ("build metadata MUST be ignored when determining version
385///   precedence"). Irrelevant in practice — `CARGO_PKG_VERSION` and
386///   the application-facing `update_group_min_version` callers never
387///   pass `+`-suffixed input.
388#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
389pub struct LibXMTPVersion(semver::Version);
390
391impl LibXMTPVersion {
392    pub fn parse(version_str: &str) -> Result<Self, CommitValidationError> {
393        semver::Version::parse(version_str)
394            .map(Self)
395            .map_err(|_| CommitValidationError::InvalidVersionFormat(version_str.to_string()))
396    }
397
398    /// The parsed form. Spec 006 compares a published minimum against this.
399    pub fn semver(&self) -> &semver::Version {
400        &self.0
401    }
402}
403
404impl std::fmt::Display for LibXMTPVersion {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        std::fmt::Display::fmt(&self.0, f)
407    }
408}
409
410/**
411 * A [`ValidatedCommit`] is a summary of changes coming from a MLS commit, after all of our validation rules have been applied
412 *
413 * Commit Validation Rules:
414 * 1. If the `sequence_id` for an inbox has changed, it can only increase
415 * 2. The client must create an expected diff of installations added and removed based on the difference between the current
416 *    [`GroupMembership`] and the [`GroupMembership`] found in the [`StagedCommit`]
417 * 3. Installations may only be added or removed in the commit if they were added/removed in the expected diff
418 * 4. For updates (either updating a path or via an Update Proposal) clients must verify that the `installation_id` is
419 *    present in the [`AssociationState`] for the `inbox_id` presented in the credential at the `to_sequence_id` found in the
420 *    new [`GroupMembership`].
421 * 5. All proposals must come from group members (proposer permissions are validated, not committer)
422 * 6. No PSK proposals will be allowed
423 * 7. New installations may be missing from the commit but still be present in the expected diff.
424 * 8. Confirms metadata character limit is not exceeded
425 */
426#[derive(Debug, Clone, Serialize)]
427pub struct ValidatedCommit {
428    /// The actor who created the commit (the committer)
429    pub actor: CommitParticipant,
430    /// All unique proposers who created proposals in this commit
431    pub proposers: Vec<CommitParticipant>,
432    pub added_inboxes: Vec<Inbox>,
433    pub removed_inboxes: Vec<Inbox>,
434    pub readded_installations: HashSet<Vec<u8>>,
435    pub metadata_validation_info: MutableMetadataValidationInfo,
436    pub installations_changed: bool,
437    pub permissions_changed: bool,
438    pub dm_members: Option<DmMembers<String>>,
439}
440
441/// Reject any commit that carries a `PreSharedKey` proposal.
442///
443/// Called from both the steady-state and bootstrap commit-validation
444/// paths so the rejection rule lives in one place — drift between the
445/// two paths is a security risk (a steady-state tightening that misses
446/// the bootstrap path would let a sender smuggle a PSK proposal through
447/// a bootstrap-shaped commit).
448fn reject_psk_proposals(staged_commit: &StagedCommit) -> Result<(), CommitValidationError> {
449    if staged_commit.psk_proposals().any(|_| true) {
450        return Err(CommitValidationError::NoPSKSupport);
451    }
452    Ok(())
453}
454
455impl ValidatedCommit {
456    /// Test helper for commits that have not received an envelope sequence.
457    #[cfg(test)]
458    pub async fn from_staged_commit(
459        context: &impl XmtpSharedContext,
460        staged_commit: &StagedCommit,
461        committer_leaf_index: LeafNodeIndex,
462        openmls_group: &OpenMlsGroup,
463    ) -> Result<Self, CommitValidationError> {
464        loop {
465            match Self::from_staged_commit_local(
466                context,
467                &context.db(),
468                staged_commit,
469                committer_leaf_index,
470                openmls_group,
471                u64::MAX,
472            ) {
473                Err(CommitValidationError::IdentityDependency(IdentityDependencyError::Need(
474                    requirement,
475                ))) => {
476                    crate::identity_updates::resolve_identity_requirement(context, &requirement)
477                        .await?;
478                }
479                result => return result,
480            }
481        }
482    }
483
484    /// Validate with the caller's write connection and exact cached proofs.
485    /// This method cannot fetch identities or call an async signature verifier.
486    /// Every referenced identity sequence `N` must precede envelope sequence `S`.
487    /// On `Need`, roll back, resolve outside the writer, then reload MLS state.
488    pub(crate) fn from_staged_commit_local(
489        context: &impl XmtpSharedContext,
490        conn: &impl DbQuery,
491        staged_commit: &StagedCommit,
492        committer_leaf_index: LeafNodeIndex,
493        openmls_group: &OpenMlsGroup,
494        envelope_sequence: u64,
495    ) -> Result<Self, CommitValidationError> {
496        let extensions = openmls_group.extensions();
497        // Capability-aware reads. On post-bootstrap groups, the
498        // legacy `ImmutableMetadata` and `GroupMutableMetadata`
499        // extensions are stripped — read from the AppData dictionary
500        // instead. Bootstrap commits themselves are detected below
501        // and route into `validate_bootstrap_and_build`, which uses
502        // these pre-flip values as the canonical source.
503        let is_migrated = super::app_data::is_migrated_extensions(extensions);
504        // PAUSE BEFORE PARSE: when the group's committed floor already
505        // exceeds this client's version, every migrated-state read
506        // below (dict-seeded metadata, registry loads, per-proposal
507        // dispatch) may encounter wire formats introduced after this
508        // version — and any error they raise is a non-retryable
509        // rejection, i.e. a fork against above-floor peers. Surface
510        // the version gap first so the group pauses and the commit is
511        // reprocessed after upgrade. Deliberately reads only the
512        // pre-commit dict (committed, already-validated state) — the
513        // commit that *raises* the floor is instead paused by the
514        // post-policy check at the end of this function, after its
515        // super-admin permission has been verified. See
516        // `committed_floor_exceeding` for the full rationale.
517        if is_migrated
518            && let Some(min_version) = super::app_data::committed_floor_exceeding(
519                openmls_group,
520                context.version_info().pkg_semver(),
521            )
522        {
523            return Err(CommitValidationError::ProtocolVersionTooLow(min_version));
524        }
525        let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
526            .map_err(CommitValidationError::installed_state)?;
527
528        // Bootstrap detection MUST run before the steady-state
529        // extractors below — bootstrap commits strip MUTABLE_METADATA,
530        // GROUP_PERMISSIONS, and GROUP_MEMBERSHIP from
531        // `new_group_extensions`, so `extract_metadata_changes` /
532        // `extract_permissions_changed` / membership-diff would all
533        // surface MissingExtension errors before bootstrap-specific
534        // validation could ever run. The pre-flip extensions still
535        // carry the legacy set, so the metadata reads above are safe.
536        if super::app_data::bootstrap_validator::is_bootstrap_commit(staged_commit, extensions) {
537            validate_identity_sequence_order(
538                &extract_group_membership(extensions)
539                    .map_err(CommitValidationError::installed_state)?,
540                envelope_sequence,
541            )?;
542            return Self::validate_bootstrap_and_build(
543                staged_commit,
544                committer_leaf_index,
545                openmls_group,
546                immutable_metadata,
547                mutable_metadata,
548                context.version_info().pkg_version(),
549            );
550        }
551
552        // On migrated groups the legacy `GROUP_PERMISSIONS_EXTENSION_ID`
553        // is gone — membership policy lives in the AppData
554        // dictionary's COMPONENT_REGISTRY entry under
555        // `GROUP_MEMBERSHIP`. Per-component AppDataUpdate enforcement
556        // runs separately in
557        // `validate_app_data_update_proposals_in_commit`, but the
558        // legacy code paths here (extract_permissions_changed +
559        // standalone Add/Remove proposer permission checks) still
560        // need a `GroupMutablePermissions` instance to evaluate
561        // against. We derive the membership-affecting bits from the
562        // registry so post-bootstrap commits enforce the same policy
563        // a pre-bootstrap GCE-extension lookup would.
564        let group_permissions: GroupMutablePermissions = if is_migrated {
565            super::app_data::policy::membership_policy_set_from_registry(openmls_group)
566                .map_err(CommitValidationError::installed_state)?
567        } else {
568            GroupMutablePermissions::try_from(extensions)
569                .map_err(CommitValidationError::installed_state)?
570        };
571        let current_group_members = get_current_group_members(openmls_group);
572
573        let existing_group_extensions = openmls_group.extensions();
574        let proposals_enabled = super::check_proposals_enabled(existing_group_extensions);
575        let new_group_extensions = staged_commit.group_context().extensions();
576
577        // On migrated groups, load the pre-commit COMPONENT_REGISTRY
578        // exactly once and thread it through both
579        // `read_post_commit_component_bytes` (here) and
580        // `validate_app_data_update_proposals_in_commit` (further
581        // down). This collapses two independent dict reads on every
582        // migrated-commit validation into one.
583        //
584        // Pre-commit semantics are the documented convention across
585        // the migrated commit path — see the doc on
586        // `read_post_commit_component_bytes` for the full statement
587        // and the bootstrap-commit carve-out.
588        //
589        // On migrated groups, metadata changes flow as AppDataUpdate
590        // proposals — there is no legacy GroupMutableMetadata
591        // extension on either side to diff. Per-component policy
592        // enforcement happens through
593        // `validate_app_data_update_proposals_in_commit` below;
594        // character limits are enforced at the sender (host APIs like
595        // `update_group_name`) and via Component-level
596        // `validate_invariant` hooks. An empty struct is the correct
597        // "no legacy metadata changes" view — *except* for
598        // `MIN_SUPPORTED_PROTOCOL_VERSION`, where the validator below
599        // relies on the post-commit floor being surfaced so old
600        // clients reject commits raising the floor above their
601        // pkg_version. Compute it capability-aware: pre-commit dict
602        // overlaid with any `AppDataUpdate(MIN_SUPPORTED_PROTOCOL_VERSION)`
603        // proposals carried by the staged commit, last-write-wins.
604        // Mirrors the unmigrated branch's reliance on
605        // `extract_metadata_changes` returning the new GMM attribute
606        // even when nothing else changed.
607        let (metadata_validation_info, migrated_registry) = if is_migrated {
608            let registry = super::app_data::load_component_registry(openmls_group)
609                .map_err(CommitValidationError::installed_state)?;
610            let min_version_bytes =
611                super::app_data::component_source::read_post_commit_component_bytes(
612                    xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
613                    openmls_group,
614                    staged_commit,
615                    &registry,
616                )
617                .map_err(xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from)?;
618            let minimum_supported_protocol_version = match min_version_bytes {
619                Some(bytes) => Some(String::from_utf8(bytes).map_err(|e| {
620                    CommitValidationError::GroupMutableMetadata(
621                        GroupMutableMetadataError::MalformedComponent {
622                            component_id: Some(
623                                xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
624                            ),
625                            reason: format!("invalid utf-8: {e}"),
626                        },
627                    )
628                })?),
629                None => None,
630            };
631            (
632                MutableMetadataValidationInfo {
633                    minimum_supported_protocol_version,
634                    ..Default::default()
635                },
636                Some(registry),
637            )
638        } else {
639            (
640                extract_metadata_changes(
641                    &immutable_metadata,
642                    &mutable_metadata,
643                    existing_group_extensions,
644                    new_group_extensions,
645                )?,
646                None,
647            )
648        };
649
650        // Enforce character limits for specific metadata fields
651        for field_change in &metadata_validation_info.metadata_field_changes {
652            if let Some(new_value) = &field_change.new_value {
653                match field_change.field_name.as_str() {
654                    val if val == MetadataField::Description.as_str()
655                        && new_value.len() > MAX_GROUP_DESCRIPTION_LENGTH =>
656                    {
657                        return Err(CommitValidationError::TooManyCharacters {
658                            length: MAX_GROUP_DESCRIPTION_LENGTH,
659                        });
660                    }
661                    val if val == MetadataField::GroupName.as_str()
662                        && new_value.len() > MAX_GROUP_NAME_LENGTH =>
663                    {
664                        return Err(CommitValidationError::TooManyCharacters {
665                            length: MAX_GROUP_NAME_LENGTH,
666                        });
667                    }
668                    val if val == MetadataField::GroupImageUrlSquare.as_str()
669                        && new_value.len() > MAX_GROUP_IMAGE_URL_LENGTH =>
670                    {
671                        return Err(CommitValidationError::TooManyCharacters {
672                            length: MAX_GROUP_IMAGE_URL_LENGTH,
673                        });
674                    }
675                    val if val == MetadataField::AppData.as_str()
676                        && new_value.len() > MAX_APP_DATA_LENGTH =>
677                    {
678                        return Err(CommitValidationError::TooManyCharacters {
679                            length: MAX_APP_DATA_LENGTH,
680                        });
681                    }
682                    _ => {}
683                }
684            }
685        }
686
687        // On migrated groups the legacy GROUP_PERMISSIONS_EXTENSION_ID
688        // was stripped at bootstrap and stays absent — permission
689        // changes flow as `AppDataUpdate(COMPONENT_REGISTRY)` which
690        // `validate_app_data_update_proposals_in_commit` validates.
691        // Skip the legacy extension diff to avoid `MissingExtension`.
692        let permissions_changed = if is_migrated {
693            false
694        } else {
695            extract_permissions_changed(&group_permissions, new_group_extensions)?
696        };
697        // Get the committer who created the commit and all unique proposers.
698        // The committer may differ from the proposers (e.g., when one member commits
699        // proposals created by other members).
700        let (actor, proposers) = extract_committer_and_proposers(
701            staged_commit,
702            committer_leaf_index,
703            openmls_group,
704            &immutable_metadata,
705            &mutable_metadata,
706        )?;
707
708        reject_psk_proposals(staged_commit)?;
709
710        // AppDataUpdate proposals carried by a commit (inline OR by
711        // reference, since `staged_commit.app_data_update_proposals()`
712        // iterates both) never flow through `validate_proposal()` —
713        // that path only handles standalone proposal-by-reference
714        // messages — so this is where their permission check lives.
715        // Bootstrap commits are routed earlier in this function and
716        // never reach this path; their dispatch is via
717        // `validate_bootstrap_and_build`.
718        validate_app_data_update_proposals_in_commit(
719            staged_commit,
720            openmls_group,
721            &immutable_metadata,
722            &mutable_metadata,
723            migrated_registry.as_ref(),
724        )?;
725
726        // Get the installations actually added and removed in the commit
727        let ProposalChanges {
728            mut added_installations,
729            mut removed_installations,
730            mut credentials_to_verify,
731            added_inbox_proposers,
732            removed_inbox_proposers,
733            gce_proposer,
734        } = get_proposal_changes(
735            staged_commit,
736            openmls_group,
737            &immutable_metadata,
738            &mutable_metadata,
739        )?;
740
741        // Get the expected diff of installations added and removed based on the difference between the current
742        // group membership and the new group membership.
743        // Also gets back the added and removed inbox ids from the expected diff
744        let expected_diff = ExpectedDiff::from_staged_commit_with_proposers(
745            conn,
746            staged_commit,
747            openmls_group,
748            envelope_sequence,
749            proposals_enabled,
750            &gce_proposer,
751            &added_inbox_proposers,
752            &removed_inbox_proposers,
753        )?;
754
755        let ExpectedDiff {
756            old_group_membership,
757            new_group_membership,
758            expected_installation_diff,
759            added_inboxes,
760            removed_inboxes,
761        } = expected_diff;
762
763        let installations_changed =
764            !added_installations.is_empty() || !removed_installations.is_empty();
765
766        let mut failed_installations: HashSet<Vec<u8>> = new_group_membership
767            .failed_installations
768            .iter()
769            .cloned()
770            .collect();
771
772        // Remove readded installations from the added/removed/failed lists before going through validation
773        let readded_installations = extract_readded_installations(
774            &actor,
775            &mut added_installations,
776            &mut removed_installations,
777            &mut failed_installations,
778        );
779        // Ensure that the expected diff matches the added/removed installations in the proposals
780        expected_diff_matches_commit(
781            &expected_installation_diff,
782            added_installations,
783            removed_installations,
784            current_group_members,
785            failed_installations,
786        )?;
787        credentials_to_verify.push(actor.clone());
788
789        // Verify the credentials of the following entities
790        // 1. The actor who created the commit
791        // 2. Anyone referenced in an update proposal
792        // Satisfies Rule 4
793        for participant in credentials_to_verify {
794            let inbox_id = &participant.inbox_id;
795            let sequence_id = match new_group_membership.get(inbox_id) {
796                None => return Err(CommitValidationError::SubjectDoesNotExist),
797                Some(0) if old_group_membership.get(inbox_id) == Some(&0) => {
798                    // An unchanged creation placeholder uses the authenticated
799                    // committed leaf. A later identity tip cannot change this
800                    // decision. New keys still require an exact nonzero proof.
801                    let known_leaf = openmls_group.members().any(|member| {
802                        member.signature_key == participant.installation_id
803                            && inbox_id_from_credential(&member.credential)
804                                .is_ok_and(|known_inbox| known_inbox == *inbox_id)
805                    });
806                    if !known_leaf {
807                        return Err(CommitValidationError::InboxValidationFailed(
808                            inbox_id.clone(),
809                        ));
810                    }
811                    continue;
812                }
813                Some(sequence_id) => *sequence_id,
814            };
815            let inbox_state = require_association_state(
816                conn,
817                &IdentityRequirement {
818                    inbox_id: inbox_id.clone(),
819                    sequence_id,
820                },
821            )?;
822
823            if inbox_state
824                .get(&MemberIdentifier::installation(participant.installation_id))
825                .is_none()
826            {
827                return Err(CommitValidationError::InboxValidationFailed(
828                    participant.inbox_id,
829                ));
830            }
831        }
832
833        let verified_commit = Self {
834            actor,
835            proposers,
836            added_inboxes,
837            removed_inboxes,
838            readded_installations,
839            metadata_validation_info,
840            installations_changed,
841            permissions_changed,
842            dm_members: immutable_metadata.dm_members,
843        };
844
845        // On migrated groups the legacy GROUP_PERMISSIONS extension
846        // is gone — reuse the synthesized stub already built above
847        // for the same reason (per-component policy enforcement
848        // happens via `validate_app_data_update_proposals_in_commit`,
849        // and the legacy commit-level policy_set.evaluate_commit
850        // would otherwise reject every commit on a migrated group
851        // because there's no extension to extract from).
852        let policy_set = if is_migrated {
853            group_permissions.clone()
854        } else {
855            extract_group_permissions(openmls_group)
856                .map_err(CommitValidationError::installed_state)?
857        };
858        if !policy_set.policies.evaluate_commit(&verified_commit) {
859            return Err(CommitValidationError::InsufficientPermissions);
860        }
861        if let Some(min_version) = &verified_commit
862            .metadata_validation_info
863            .minimum_supported_protocol_version
864        {
865            let current_version = context.version_info().pkg_semver();
866            let min_supported_version = LibXMTPVersion::parse(min_version)?;
867            tracing::info!(
868                "Validating commit with min_supported_version: {:?}, current_version: {:?}",
869                min_supported_version,
870                current_version
871            );
872
873            if min_supported_version > *current_version {
874                return Err(CommitValidationError::ProtocolVersionTooLow(
875                    min_version.clone(),
876                ));
877            }
878        }
879        Ok(verified_commit)
880    }
881
882    // Reuse intent kind here to represent the commit type, even if it's an external commit
883    // This is for debugging purposes only, so an approximation is fine
884    pub fn debug_commit_type(&self) -> CommitType {
885        let metadata_info = &self.metadata_validation_info;
886        if !self.added_inboxes.is_empty()
887            || !self.removed_inboxes.is_empty()
888            || self.installations_changed
889        {
890            CommitType::UpdateGroupMembership
891        } else if self.permissions_changed {
892            CommitType::UpdatePermission
893        } else if !metadata_info.admins_added.is_empty()
894            || !metadata_info.admins_removed.is_empty()
895            || !metadata_info.super_admins_added.is_empty()
896            || !metadata_info.super_admins_removed.is_empty()
897        {
898            CommitType::UpdateAdminList
899        } else if !metadata_info.metadata_field_changes.is_empty() {
900            CommitType::MetadataUpdate
901        } else {
902            CommitType::KeyUpdate
903        }
904    }
905
906    pub fn is_empty(&self) -> bool {
907        self.added_inboxes.is_empty()
908            && self.removed_inboxes.is_empty()
909            && self.metadata_validation_info.is_empty()
910    }
911
912    pub fn actor_inbox_id(&self) -> InboxId {
913        self.actor.inbox_id.clone()
914    }
915
916    pub fn actor_installation_id(&self) -> Vec<u8> {
917        self.actor.installation_id.clone()
918    }
919
920    /// Build a `ValidatedCommit` for the one-time AppData-migration
921    /// bootstrap commit.
922    ///
923    /// Bootstrap commits don't add or remove members, don't change the
924    /// per-inbox sequence ids, and don't change the legacy permissions
925    /// (their state is migrated to the AppData dictionary, not
926    /// modified). They're validated against the receiver-derived
927    /// canonical subset and a super-admin proposer requirement; the
928    /// resulting `ValidatedCommit` reports "no diff" on every
929    /// steady-state field so downstream policy evaluation and
930    /// installation-diff checks see a no-op.
931    fn validate_bootstrap_and_build(
932        staged_commit: &StagedCommit,
933        committer_leaf_index: LeafNodeIndex,
934        openmls_group: &OpenMlsGroup,
935        immutable_metadata: GroupMetadata,
936        mutable_metadata: GroupMutableMetadata,
937        own_version: &str,
938    ) -> Result<Self, CommitValidationError> {
939        reject_psk_proposals(staged_commit)?;
940
941        let (actor, proposers) = extract_committer_and_proposers(
942            staged_commit,
943            committer_leaf_index,
944            openmls_group,
945            &immutable_metadata,
946            &mutable_metadata,
947        )?;
948
949        let gce_proposer = super::app_data::bootstrap_validator::extract_gce_proposer(
950            staged_commit,
951            openmls_group,
952            &immutable_metadata,
953            &mutable_metadata,
954        )?
955        .ok_or(CommitValidationError::ProposerNotFound)?;
956
957        // A bootstrap seeding a floor above this client pauses the
958        // group (same variant the steady-state floor checks emit, so
959        // the pause machinery in `mls_sync` applies) rather than
960        // surfacing as an opaque byte-compare `Mismatch` — which
961        // above-floor members wouldn't share, i.e. a fork.
962        super::app_data::bootstrap_validator::validate_bootstrap_commit(
963            staged_commit,
964            openmls_group,
965            &gce_proposer,
966            own_version,
967        )
968        .map_err(|e| {
969            match e {
970            super::app_data::bootstrap_validator::BootstrapValidationError::ProtocolVersionTooLow(
971                min_version,
972            ) => CommitValidationError::ProtocolVersionTooLow(min_version),
973            other => other.into(),
974        }
975        })?;
976
977        Ok(Self {
978            actor,
979            proposers,
980            added_inboxes: Vec::new(),
981            removed_inboxes: Vec::new(),
982            readded_installations: HashSet::new(),
983            metadata_validation_info: MutableMetadataValidationInfo::default(),
984            installations_changed: false,
985            permissions_changed: false,
986            dm_members: immutable_metadata.dm_members,
987        })
988    }
989}
990
991impl From<ValidatedCommit> for GroupMembershipChanges {
992    fn from(_commit: ValidatedCommit) -> Self {
993        // TODO: Use new GroupMembershipChanges
994
995        GroupMembershipChanges {
996            members_added: vec![],
997            members_removed: vec![],
998            installations_added: vec![],
999            installations_removed: vec![],
1000        }
1001    }
1002}
1003
1004use std::collections::HashMap;
1005
1006struct ProposalChanges {
1007    added_installations: HashSet<Vec<u8>>,
1008    removed_installations: HashSet<Vec<u8>>,
1009    credentials_to_verify: Vec<CommitParticipant>,
1010    /// Maps inbox_id to the proposer who proposed adding it
1011    added_inbox_proposers: HashMap<String, CommitParticipant>,
1012    /// Maps inbox_id to the proposer who proposed removing it
1013    removed_inbox_proposers: HashMap<String, CommitParticipant>,
1014    /// The proposer of the GCE proposal (if any) - this affects membership changes
1015    gce_proposer: Option<CommitParticipant>,
1016}
1017
1018/**
1019 * Extracts the installations added and removed via proposals in the commit.
1020 * Also returns a list of credentials from existing members that need verification (caused by update proposals)
1021 * Tracks which proposer created each proposal for permission validation.
1022 */
1023fn get_proposal_changes(
1024    staged_commit: &StagedCommit,
1025    openmls_group: &OpenMlsGroup,
1026    immutable_metadata: &GroupMetadata,
1027    mutable_metadata: &GroupMutableMetadata,
1028) -> Result<ProposalChanges, CommitValidationError> {
1029    // The actual installations added and removed via proposals in the commit
1030    let mut added_installations: HashSet<Vec<u8>> = HashSet::new();
1031    let mut removed_installations: HashSet<Vec<u8>> = HashSet::new();
1032    let mut credentials_to_verify: Vec<CommitParticipant> = vec![];
1033    let mut added_inbox_proposers: HashMap<String, CommitParticipant> = HashMap::new();
1034    let mut removed_inbox_proposers: HashMap<String, CommitParticipant> = HashMap::new();
1035    let mut gce_proposer: Option<CommitParticipant> = None;
1036
1037    for proposal in staged_commit.queued_proposals() {
1038        // Extract the proposer for this proposal
1039        let proposer = match proposal.sender() {
1040            Sender::Member(leaf_index) => extract_commit_participant(
1041                leaf_index,
1042                openmls_group,
1043                immutable_metadata,
1044                mutable_metadata,
1045            )?,
1046            _ => return Err(CommitValidationError::ActorNotMember),
1047        };
1048
1049        match proposal.proposal() {
1050            // For update proposals, we need to validate that the credential and installation key
1051            // are valid for the inbox_id in the current group membership state
1052            Proposal::Update(update_proposal) => {
1053                credentials_to_verify.push(CommitParticipant::from_leaf_node(
1054                    update_proposal.leaf_node(),
1055                    immutable_metadata,
1056                    mutable_metadata,
1057                )?);
1058            }
1059            // For Add Proposals, all we need to do is validate that the installation_id is in the expected diff
1060            Proposal::Add(add_proposal) => {
1061                // We don't need to validate the credential here, since we've already validated it as part of
1062                // building the expected installation diff
1063                let leaf_node = add_proposal.key_package().leaf_node();
1064                let installation_id = leaf_node.signature_key().as_slice().to_vec();
1065                let inbox_id = inbox_id_from_credential(leaf_node.credential())?;
1066                added_installations.insert(installation_id);
1067                added_inbox_proposers.insert(inbox_id, proposer);
1068            }
1069            // For Remove Proposals, all we need to do is validate that the installation_id is in the expected diff
1070            Proposal::Remove(remove_proposal) => {
1071                let leaf_node = openmls_group
1072                    .member_at(remove_proposal.removed())
1073                    .ok_or(CommitValidationError::SubjectDoesNotExist)?;
1074                let installation_id = leaf_node.signature_key.to_vec();
1075                let inbox_id = inbox_id_from_credential(&leaf_node.credential)?;
1076                removed_installations.insert(installation_id);
1077                removed_inbox_proposers.insert(inbox_id, proposer);
1078            }
1079            // For GroupContextExtensions proposals, track the proposer for membership changes
1080            Proposal::GroupContextExtensions(_) => {
1081                gce_proposer = Some(proposer);
1082            }
1083            _ => continue,
1084        }
1085    }
1086
1087    Ok(ProposalChanges {
1088        added_installations,
1089        removed_installations,
1090        credentials_to_verify,
1091        added_inbox_proposers,
1092        removed_inbox_proposers,
1093        gce_proposer,
1094    })
1095}
1096
1097/**
1098 * Extracts the latest `GroupMembership` from the staged commit.
1099 *
1100 * Returns an error if the extension is not found.
1101 */
1102fn get_latest_group_membership(
1103    staged_commit: &StagedCommit,
1104) -> Result<GroupMembership, CommitValidationError> {
1105    for proposal in staged_commit.queued_proposals() {
1106        match proposal.proposal() {
1107            Proposal::GroupContextExtensions(group_context_extensions) => {
1108                let new_group_membership: GroupMembership =
1109                    extract_group_membership(group_context_extensions.extensions())?;
1110                tracing::info!(
1111                    "Group context extensions proposal found: {:?}",
1112                    new_group_membership
1113                );
1114                return Ok(new_group_membership);
1115            }
1116            _ => continue,
1117        }
1118    }
1119
1120    extract_group_membership(staged_commit.group_context().extensions())
1121}
1122
1123/// Membership changes derived from the exact old and proposed identity proofs.
1124struct ExpectedDiff {
1125    /// The membership before this commit. The commit cannot change it.
1126    old_group_membership: GroupMembership,
1127    /// Proposed inbox sequences. They cannot rewrite the old proof requirements.
1128    new_group_membership: GroupMembership,
1129    /// Installation changes authorized by those exact identity snapshots.
1130    expected_installation_diff: InstallationDiff,
1131    added_inboxes: Vec<Inbox>,
1132    removed_inboxes: Vec<Inbox>,
1133}
1134
1135/// Read committed metadata once through the active extension representation.
1136fn read_committed_metadata(
1137    group: &OpenMlsGroup,
1138) -> Result<(GroupMetadata, GroupMutableMetadata), CommitValidationError> {
1139    let extensions = group.extensions();
1140    if !super::app_data::is_migrated_extensions(extensions) {
1141        return Ok((extensions.try_into()?, extensions.try_into()?));
1142    }
1143    let seed = super::app_data::component_source::read_group_metadata_from_dict(group)?
1144        .ok_or(GroupMetadataError::MissingExtension)?;
1145    let immutable =
1146        GroupMetadata::try_from(xmtp_proto::xmtp::mls::message_contents::GroupMetadataV1 {
1147            conversation_type: seed.conversation_type,
1148            creator_inbox_id: seed.creator_inbox_id,
1149            creator_account_address: String::new(),
1150            dm_members: seed.dm_members,
1151            oneshot_message: seed.oneshot,
1152        })?;
1153    let mut mutable = GroupMutableMetadata::new(HashMap::new(), Vec::new(), Vec::new());
1154    super::app_data::component_source::merge_app_data_into_mutable_metadata(&mut mutable, group)?;
1155    Ok((immutable, mutable))
1156}
1157
1158/// Require each identity sequence `N` to precede group envelope sequence `S`.
1159/// `N >= S` is invalid on every receiver, independent of cache or replica state.
1160fn validate_identity_sequence_order(
1161    membership: &GroupMembership,
1162    envelope_sequence: u64,
1163) -> Result<(), CommitValidationError> {
1164    for identity_sequence in membership.members.values() {
1165        if *identity_sequence >= envelope_sequence {
1166            return Err(CommitValidationError::IdentitySequenceNotBeforeEnvelope {
1167                identity_sequence: *identity_sequence,
1168                envelope_sequence,
1169            });
1170        }
1171    }
1172    Ok(())
1173}
1174
1175impl ExpectedDiff {
1176    /// Derive installation changes from cached proofs on the caller's connection.
1177    /// Missing exact proofs return `Need`; this method does not fetch identities.
1178    #[allow(clippy::too_many_arguments)]
1179    pub(super) fn from_staged_commit_with_proposers(
1180        conn: &impl DbQuery,
1181        staged_commit: &StagedCommit,
1182        openmls_group: &OpenMlsGroup,
1183        envelope_sequence: u64,
1184        proposals_enabled: bool,
1185        gce_proposer: &Option<CommitParticipant>,
1186        added_inbox_proposers: &HashMap<String, CommitParticipant>,
1187        removed_inbox_proposers: &HashMap<String, CommitParticipant>,
1188    ) -> Result<Self, CommitValidationError> {
1189        let extensions = openmls_group.extensions();
1190        let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
1191            .map_err(CommitValidationError::installed_state)?;
1192
1193        reject_psk_proposals(staged_commit)?;
1194
1195        let expected_diff = Self::extract_expected_diff_with_proposers(
1196            conn,
1197            staged_commit,
1198            envelope_sequence,
1199            extensions,
1200            &immutable_metadata,
1201            &mutable_metadata,
1202            proposals_enabled,
1203            gce_proposer,
1204            added_inbox_proposers,
1205            removed_inbox_proposers,
1206        )?;
1207
1208        Ok(expected_diff)
1209    }
1210
1211    /// Generates an expected diff with proposer attribution for each inbox change.
1212    /// This is used when validating commits with proposals from multiple members.
1213    #[allow(clippy::too_many_arguments)]
1214    fn extract_expected_diff_with_proposers(
1215        conn: &impl DbQuery,
1216        staged_commit: &StagedCommit,
1217        envelope_sequence: u64,
1218        existing_group_extensions: &Extensions<GroupContext>,
1219        immutable_metadata: &GroupMetadata,
1220        mutable_metadata: &GroupMutableMetadata,
1221        proposals_enabled: bool,
1222        gce_proposer: &Option<CommitParticipant>,
1223        added_inbox_proposers: &HashMap<String, CommitParticipant>,
1224        removed_inbox_proposers: &HashMap<String, CommitParticipant>,
1225    ) -> Result<ExpectedDiff, CommitValidationError> {
1226        let old_group_membership = extract_group_membership(existing_group_extensions)
1227            .map_err(CommitValidationError::installed_state)?;
1228        let new_group_membership = get_latest_group_membership(staged_commit)?;
1229        validate_identity_sequence_order(&new_group_membership, envelope_sequence)?;
1230        let membership_diff = old_group_membership.diff(&new_group_membership);
1231
1232        validate_membership_diff(
1233            &old_group_membership,
1234            &new_group_membership,
1235            &membership_diff,
1236        )?;
1237
1238        // For added inboxes, try to find the proposer from:
1239        // 1. The original Add proposal proposer for this specific inbox
1240        // 2. The GCE proposer (if membership changed via GCE proposal without a direct Add proposal)
1241        // When proposals are enabled, a proposer must always be determinable.
1242        let added_inboxes = membership_diff
1243            .added_inboxes
1244            .iter()
1245            .map(|inbox_id| {
1246                // Look up the proposer who proposed adding this specific inbox.
1247                // Falls back to the GCE proposer if no direct Add proposal was found
1248                // (e.g., membership changed via a GroupContextExtensions proposal).
1249                let proposer = added_inbox_proposers
1250                    .get(inbox_id.as_str())
1251                    .cloned()
1252                    .or_else(|| gce_proposer.clone());
1253                match proposer {
1254                    Some(p) => Ok(build_inbox_with_proposer(
1255                        inbox_id,
1256                        immutable_metadata,
1257                        mutable_metadata,
1258                        p,
1259                    )),
1260                    None if proposals_enabled => Err(CommitValidationError::ProposerNotFound),
1261                    None => Ok(build_inbox(inbox_id, immutable_metadata, mutable_metadata)),
1262                }
1263            })
1264            .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
1265
1266        // For removed inboxes, try to find the proposer from:
1267        // 1. The original Remove proposal proposer for this specific inbox
1268        // 2. The GCE proposer (if membership changed via GCE proposal without a direct Remove proposal)
1269        // When proposals are enabled, a proposer must always be determinable.
1270        let removed_inboxes = membership_diff
1271            .removed_inboxes
1272            .iter()
1273            .map(|inbox_id| {
1274                let proposer = removed_inbox_proposers
1275                    .get(inbox_id.as_str())
1276                    .cloned()
1277                    .or_else(|| gce_proposer.clone());
1278                match proposer {
1279                    Some(p) => Ok(build_inbox_with_proposer(
1280                        inbox_id,
1281                        immutable_metadata,
1282                        mutable_metadata,
1283                        p,
1284                    )),
1285                    None if proposals_enabled => Err(CommitValidationError::ProposerNotFound),
1286                    None => Ok(build_inbox(inbox_id, immutable_metadata, mutable_metadata)),
1287                }
1288            })
1289            .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
1290
1291        let expected_installation_diff = get_installation_diff_local(
1292            conn,
1293            &old_group_membership,
1294            &new_group_membership,
1295            &membership_diff,
1296        )?;
1297
1298        Ok(ExpectedDiff {
1299            old_group_membership,
1300            new_group_membership,
1301            expected_installation_diff,
1302            added_inboxes,
1303            removed_inboxes,
1304        })
1305    }
1306}
1307
1308/// Superadmins are permitted to readd installations, e.g. for fork recovery
1309/// We can take these readded installations out of the list of installations to validate
1310pub(super) fn extract_readded_installations(
1311    actor: &CommitParticipant,
1312    added_installations: &mut HashSet<Vec<u8>>,
1313    removed_installations: &mut HashSet<Vec<u8>>,
1314    failed_installations: &mut HashSet<Vec<u8>>,
1315) -> HashSet<Vec<u8>> {
1316    if !actor.is_super_admin {
1317        return HashSet::new();
1318    }
1319    let successfully_readded = added_installations
1320        .intersection(removed_installations)
1321        .cloned()
1322        .collect::<HashSet<Vec<u8>>>();
1323    added_installations.retain(|installation_id| !successfully_readded.contains(installation_id));
1324    removed_installations.retain(|installation_id| !successfully_readded.contains(installation_id));
1325
1326    // We only want to intersect with *remaining* removed installations here, to avoid double counting
1327    let unsuccessfully_readded = failed_installations
1328        .intersection(removed_installations)
1329        .cloned()
1330        .collect::<HashSet<Vec<u8>>>();
1331    failed_installations
1332        .retain(|installation_id| !unsuccessfully_readded.contains(installation_id));
1333    removed_installations
1334        .retain(|installation_id| !unsuccessfully_readded.contains(installation_id));
1335
1336    successfully_readded
1337        .union(&unsuccessfully_readded)
1338        .cloned()
1339        .collect()
1340}
1341
1342/// Compare the list of installations added and removed in the commit to the expected diff based on the changes
1343/// to the inbox state.
1344/// Satisfies Rule 3 and Rule 7
1345fn expected_diff_matches_commit(
1346    expected_diff: &InstallationDiff,
1347    added_installations: HashSet<Vec<u8>>,
1348    removed_installations: HashSet<Vec<u8>>,
1349    existing_installation_ids: HashSet<Vec<u8>>,
1350    failed_installation_ids: HashSet<Vec<u8>>,
1351) -> Result<(), CommitValidationError> {
1352    // Check and make sure that any added installations are either:
1353    // 1. In the expected diff
1354    // 2. Already a member of the group (for example, the group creator is already a member on the first commit)
1355
1356    let unknown_adds = added_installations
1357        .into_iter()
1358        .filter(|installation_id| {
1359            !expected_diff.added_installations.contains(installation_id)
1360                && !existing_installation_ids.contains(installation_id)
1361        })
1362        .collect::<Vec<Vec<u8>>>();
1363    if !unknown_adds.is_empty() {
1364        return Err(CommitValidationError::UnexpectedInstallationAdded(
1365            unknown_adds,
1366        ));
1367    }
1368
1369    let filtered_expected: HashSet<_> = expected_diff
1370        .removed_installations
1371        .iter()
1372        .filter(|id| !failed_installation_ids.contains(*id))
1373        .cloned()
1374        .collect();
1375
1376    if removed_installations != filtered_expected {
1377        let unexpected: Vec<_> = removed_installations
1378            .difference(&expected_diff.removed_installations)
1379            .cloned()
1380            .collect();
1381
1382        return Err(CommitValidationError::UnexpectedInstallationsRemoved(
1383            unexpected,
1384        ));
1385    }
1386
1387    Ok(())
1388}
1389
1390fn get_current_group_members(openmls_group: &OpenMlsGroup) -> HashSet<Vec<u8>> {
1391    openmls_group
1392        .members()
1393        .map(|member| member.signature_key)
1394        .collect()
1395}
1396
1397/// Validate that the new group membership is a valid state transition from the old group membership.
1398/// Enforces Rule 1 from above
1399fn validate_membership_diff(
1400    old_membership: &GroupMembership,
1401    new_membership: &GroupMembership,
1402    diff: &MembershipDiff<'_>,
1403) -> Result<(), CommitValidationError> {
1404    for inbox_id in diff.updated_inboxes.iter() {
1405        let old_sequence_id = old_membership
1406            .get(inbox_id)
1407            .ok_or(CommitValidationError::SubjectDoesNotExist)?;
1408        let new_sequence_id = new_membership
1409            .get(inbox_id)
1410            .ok_or(CommitValidationError::SubjectDoesNotExist)?;
1411
1412        if new_sequence_id.lt(old_sequence_id) {
1413            return Err(CommitValidationError::SequenceIdDecreased);
1414        }
1415    }
1416
1417    Ok(())
1418}
1419
1420/// Validate a single `AppDataUpdate` (component_id + operation) against
1421/// `registry` on behalf of `actor`.
1422///
1423/// Shared core for both validator entry points:
1424/// [`validate_proposal`] (standalone proposal-by-reference messages) and
1425/// [`validate_app_data_update_proposals_in_commit`] (proposals inside
1426/// commits, inline or referenced). Both paths must enforce identical
1427/// permission checks; lifting the loop here keeps them in lockstep so a
1428/// future change can't drift the two implementations apart.
1429///
1430/// Reads the pre-commit stored bytes for `component_id` from the
1431/// group's AppData dictionary and threads them into the expansion step
1432/// so `RemoveByHash` mutations can be resolved back to the concrete
1433/// inbox id being removed. If the component has no prior entry (first
1434/// write), `read_from_app_data_dict` returns `None`, which
1435/// [`expand_app_data_update_to_changes`] treats as an empty prior set
1436/// — `Insert` / `Remove` deltas expand normally, and any `RemoveByHash`
1437/// surfaces `value: None` (the CRDT apply step later rejects with
1438/// `KeyNotFound`). This matches the `Bytes` component case where a
1439/// first-time `Update` has no prior value to diff against.
1440///
1441/// Returns `Err(InsufficientPermissions)` on the first failure (expand or
1442/// per-element check) so the caller can reject the wider message wholesale.
1443fn validate_one_app_data_update(
1444    component_id: xmtp_mls_common::app_data::component_id::ComponentId,
1445    operation: &openmls::messages::proposals::AppDataUpdateOperation,
1446    actor: xmtp_mls_common::app_data::validation::ActorAuthority,
1447    proposer_inbox_id: &str,
1448    registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
1449    openmls_group: &OpenMlsGroup,
1450) -> Result<(), CommitValidationError> {
1451    use super::app_data::component_source::read_from_app_data_dict;
1452
1453    // Pull the pre-commit stored bytes for this component so the expansion
1454    // step can resolve `RemoveByHash` mutations back to the concrete
1455    // inbox id being removed. `None` is a legal first-write state — see
1456    // the fn docstring above for how the expansion handles it.
1457    let old_value = read_from_app_data_dict(component_id, openmls_group);
1458
1459    validate_one_app_data_update_with_old_value(
1460        component_id,
1461        operation,
1462        actor,
1463        proposer_inbox_id,
1464        registry,
1465        old_value.as_deref(),
1466    )
1467}
1468
1469/// Receive-side enforcement of `MIN_SUPPORTED_PROTOCOL_VERSION`
1470/// monotonicity. A proposal that lowers the floor (or removes it
1471/// while one was set) is rejected before it can reach the dict.
1472/// `Update(new)` with `new >= old` (or `old` absent / unparseable)
1473/// passes. `Remove` with an existing floor fails — explicit unsetting
1474/// of the floor is just a downgrade in disguise.
1475///
1476/// This is the source-of-truth check; the send-side guard in
1477/// `update_group_min_version` is a friendlier UX layer over the same
1478/// invariant. An attacker patching out the send-side gate still hits
1479/// this one on every receiver.
1480fn enforce_min_version_monotonicity(
1481    operation: &openmls::messages::proposals::AppDataUpdateOperation,
1482    old_value: Option<&[u8]>,
1483) -> Result<(), CommitValidationError> {
1484    use openmls::messages::proposals::AppDataUpdateOperation;
1485    // First-set on a group with no prior floor is always allowed —
1486    // there's nothing to downgrade against.
1487    let Some(old_bytes) = old_value else {
1488        return Ok(());
1489    };
1490    // If the prior bytes don't parse as semver, we can't compare.
1491    // Treat as "no prior floor" and accept — refusing every future
1492    // update on a malformed prior would brick the group.
1493    let Ok(old_str) = std::str::from_utf8(old_bytes) else {
1494        return Ok(());
1495    };
1496    let Ok(old_v) = LibXMTPVersion::parse(old_str) else {
1497        return Ok(());
1498    };
1499    match operation {
1500        AppDataUpdateOperation::Update(payload) => {
1501            let new_bytes = payload.as_slice();
1502            let new_str = std::str::from_utf8(new_bytes).map_err(|_| {
1503                CommitValidationError::InvalidVersionFormat(format!("{:?}", new_bytes))
1504            })?;
1505            let new_v = LibXMTPVersion::parse(new_str)?;
1506            if new_v < old_v {
1507                return Err(CommitValidationError::MinVersionDowngrade {
1508                    requested: new_str.to_string(),
1509                    current: old_str.to_string(),
1510                });
1511            }
1512            Ok(())
1513        }
1514        AppDataUpdateOperation::Remove => {
1515            Err(CommitValidationError::MinVersionRemoveOnExistingFloor {
1516                current: old_str.to_string(),
1517            })
1518        }
1519    }
1520}
1521
1522/// Pure core of [`validate_one_app_data_update`] with `old_value`
1523/// passed explicitly so unit tests can exercise the
1524/// expand → per-change policy loop without a real MLS group.
1525pub(super) fn validate_one_app_data_update_with_old_value(
1526    component_id: xmtp_mls_common::app_data::component_id::ComponentId,
1527    operation: &openmls::messages::proposals::AppDataUpdateOperation,
1528    actor: xmtp_mls_common::app_data::validation::ActorAuthority,
1529    proposer_inbox_id: &str,
1530    registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
1531    old_value: Option<&[u8]>,
1532) -> Result<(), CommitValidationError> {
1533    use xmtp_mls_common::app_data::{
1534        registry_table::lookup_component,
1535        validation::{ComponentChange, validate_component_write},
1536    };
1537
1538    // Source-of-truth monotonicity for `MIN_SUPPORTED_PROTOCOL_VERSION`.
1539    // Runs ahead of the per-element policy loop so a downgrade fails
1540    // fast with a structured error rather than passing the policy
1541    // check and silently relaxing the pause gate. See
1542    // `enforce_min_version_monotonicity` for the rule shape.
1543    if component_id
1544        == xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION
1545    {
1546        enforce_min_version_monotonicity(operation, old_value).inspect_err(|err| {
1547            tracing::warn!(
1548                proposer_inbox_id,
1549                component_id = %component_id,
1550                error = %err,
1551                "AppDataUpdate proposal rejected: min_version monotonicity"
1552            );
1553        })?;
1554    }
1555
1556    // Two dispatch shapes:
1557    //
1558    // - **Known component**: expand via the per-id `Component` impl
1559    //   (decodes Set/Map deltas into per-element changes) and run both
1560    //   layers — registry policy AND per-component invariant.
1561    //
1562    // - **Unknown component** (no per-id impl on this client; the
1563    //   sender shipped a newer release): look the component's
1564    //   registered [`ComponentType`] up in the registry and run the
1565    //   type-aware expansion. Same per-element change list a typed
1566    //   client would produce, fed through the same policy loop. The
1567    //   per-component invariant hook is skipped — there's no per-id
1568    //   trait method to call — but registry-policy enforcement still
1569    //   gates the write, so deny-by-default applies.
1570    let component = lookup_component(component_id);
1571    let changes = if let Some(component) = component {
1572        component
1573            .expand_to_changes(operation, old_value)
1574            .map_err(|e| {
1575                let wrapped = super::app_data::component_source::ComponentSourceError::from(e);
1576                tracing::warn!(
1577                    proposer_inbox_id,
1578                    component_id = %component_id,
1579                    error = %wrapped,
1580                    "AppDataUpdate proposal rejected: failed to expand payload"
1581                );
1582                CommitValidationError::InsufficientPermissions
1583            })?
1584    } else {
1585        match super::app_data::component_source::expand_app_data_update_to_changes(
1586            component_id,
1587            operation,
1588            old_value,
1589            registry,
1590        ) {
1591            Ok(changes) => changes,
1592            Err(err) => {
1593                tracing::warn!(
1594                    proposer_inbox_id,
1595                    component_id = %component_id,
1596                    error = %err,
1597                    "AppDataUpdate proposal rejected"
1598                );
1599                return Err(CommitValidationError::InsufficientPermissions);
1600            }
1601        }
1602    };
1603
1604    for change in &changes {
1605        let cc = ComponentChange::builder()
1606            .component_id(component_id)
1607            .op(change.op)
1608            .actor(actor)
1609            .maybe_new_value(change.value.as_deref())
1610            .build();
1611
1612        // Layer 1: registry-based policy. Applies to both known and
1613        // unknown components — every component requires a registry
1614        // entry (deny by default).
1615        if let Err(e) = validate_component_write(&cc, registry) {
1616            tracing::warn!(
1617                proposer_inbox_id,
1618                component_id = %component_id,
1619                op = %change.op,
1620                error = %e,
1621                "AppDataUpdate proposal rejected"
1622            );
1623            return Err(CommitValidationError::InsufficientPermissions);
1624        }
1625
1626        // Layer 2: per-component invariants. Only available for known
1627        // components — unknown ids have nothing to invoke. Skipping
1628        // the invariant is the cost of forward compatibility (see
1629        // module-level docstring).
1630        if let Some(component) = component
1631            && let Err(e) = component.validate_invariant(&cc, registry)
1632        {
1633            tracing::warn!(
1634                proposer_inbox_id,
1635                component_id = %component_id,
1636                op = %change.op,
1637                error = %e,
1638                "AppDataUpdate proposal rejected: component invariant violated"
1639            );
1640            return Err(CommitValidationError::InsufficientPermissions);
1641        }
1642    }
1643
1644    Ok(())
1645}
1646
1647/// Resolve the proposer leaf index for a proposal sender, rejecting
1648/// senders that can't legally propose `AppDataUpdate`.
1649///
1650/// External senders and new-member proposals can't carry
1651/// `AppDataUpdate` by design — only an existing leaf can propose one.
1652/// Pulled out so the rejection reason is a single code path that can
1653/// be unit-tested without constructing a `StagedCommit`.
1654pub(super) fn app_data_update_proposer_leaf(
1655    sender: &Sender,
1656) -> Result<&LeafNodeIndex, CommitValidationError> {
1657    match sender {
1658        Sender::Member(leaf_index) => Ok(leaf_index),
1659        Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
1660            Err(CommitValidationError::ActorNotMember)
1661        }
1662    }
1663}
1664
1665/// Validate every `AppDataUpdate` proposal carried by `staged_commit`
1666/// against the group's component registry.
1667///
1668/// `staged_commit.app_data_update_proposals()` iterates both inline
1669/// proposals and references that resolve into the group's proposal
1670/// store, so this covers both shapes. `validate_proposal()` covers the
1671/// standalone-proposal-by-reference path (proposals that arrive as
1672/// their own message), but commits never flow through
1673/// `validate_proposal` — they go through `from_staged_commit`. Without
1674/// this helper, `AppDataUpdate` proposals committed alongside a commit
1675/// would bypass `validate_component_write` entirely, since
1676/// `extract_metadata_changes` only inspects the legacy mutable-metadata
1677/// extension.
1678///
1679/// Delegates the per-proposal permission check to
1680/// [`validate_one_app_data_update`] so the core logic stays shared with
1681/// the standalone-proposal path in [`validate_proposal`].
1682///
1683/// # Registry semantics
1684///
1685/// `preloaded_registry`, when `Some`, is the **pre-commit**
1686/// `COMPONENT_REGISTRY` — the same view used by
1687/// [`super::app_data::component_source::read_post_commit_component_bytes`]
1688/// and any other per-component check on this commit. Pre-commit (not
1689/// post-commit) is the documented convention across the migrated commit
1690/// path: registry mutations and writes that depend on those mutations
1691/// MUST land in separate commits. Bootstrap commits are the only
1692/// "register + write in the same commit" legitimate pattern and route
1693/// through a dedicated validator instead.
1694///
1695/// `None` defers the registry load to this function, which only
1696/// materializes it lazily after a peek confirms at least one
1697/// `AppDataUpdate` proposal exists. The split lets the caller share a
1698/// single registry across this helper and
1699/// `read_post_commit_component_bytes` on the migrated branch, while
1700/// unmigrated callers (or commits with zero `AppDataUpdate` proposals)
1701/// pay zero load cost.
1702fn validate_app_data_update_proposals_in_commit(
1703    staged_commit: &StagedCommit,
1704    openmls_group: &OpenMlsGroup,
1705    immutable_metadata: &GroupMetadata,
1706    mutable_metadata: &GroupMutableMetadata,
1707    preloaded_registry: Option<&xmtp_mls_common::app_data::component_registry::ComponentRegistry>,
1708) -> Result<(), CommitValidationError> {
1709    use super::app_data::load_component_registry;
1710    use std::collections::HashMap;
1711    use xmtp_mls_common::app_data::{component_id::ComponentId, validation::ActorAuthority};
1712
1713    // Peek first: the common case is zero AppDataUpdate proposals, in
1714    // which case we skip the registry load and the per-proposer work
1715    // entirely. This runs on every commit's validation path.
1716    //
1717    // Safety of the early-exit against unresolvable references: OpenMLS
1718    // rejects commits that reference proposals it can't resolve against
1719    // the group's proposal store *before* `from_staged_commit` is called
1720    // (see `process_message`'s reference-resolution pass). So
1721    // `staged_commit.app_data_update_proposals()` iterates only
1722    // inline-or-successfully-resolved proposals — an attacker can't
1723    // smuggle in a dangling reference that would `peek()` as `None` and
1724    // bypass the loop.
1725    let mut proposals = staged_commit.app_data_update_proposals().peekable();
1726    if proposals.peek().is_none() {
1727        return Ok(());
1728    }
1729
1730    // Use the caller's pre-loaded registry when available; otherwise
1731    // load lazily. `owned_registry` keeps the loaded value alive for
1732    // the `registry` borrow.
1733    let owned_registry;
1734    let registry = match preloaded_registry {
1735        Some(r) => r,
1736        None => {
1737            owned_registry = load_component_registry(openmls_group)
1738                .map_err(CommitValidationError::installed_state)?;
1739            &owned_registry
1740        }
1741    };
1742
1743    // A single commit's bootstrap can carry multiple AppDataUpdate proposals
1744    // from the same leaf; cache extracted `CommitParticipant`s so we don't
1745    // re-walk the admin lists and re-parse the credential for every one.
1746    let mut participants: HashMap<LeafNodeIndex, CommitParticipant> = HashMap::new();
1747
1748    for queued in proposals {
1749        let app_data = queued.app_data_update_proposal();
1750        let proposer_leaf = app_data_update_proposer_leaf(queued.sender())?;
1751        let proposer = match participants.get(proposer_leaf) {
1752            Some(cached) => cached,
1753            None => {
1754                let fresh = extract_commit_participant(
1755                    proposer_leaf,
1756                    openmls_group,
1757                    immutable_metadata,
1758                    mutable_metadata,
1759                )?;
1760                participants.entry(*proposer_leaf).or_insert(fresh)
1761            }
1762        };
1763
1764        validate_one_app_data_update(
1765            ComponentId::from(app_data.component_id()),
1766            app_data.operation(),
1767            ActorAuthority::from(proposer),
1768            &proposer.inbox_id,
1769            registry,
1770            openmls_group,
1771        )?;
1772    }
1773
1774    Ok(())
1775}
1776
1777/// Extracts the [`CommitParticipant`] from the [`LeafNodeIndex`]
1778pub(super) fn extract_commit_participant(
1779    leaf_index: &LeafNodeIndex,
1780    group: &OpenMlsGroup,
1781    immutable_metadata: &GroupMetadata,
1782    mutable_metadata: &GroupMutableMetadata,
1783) -> Result<CommitParticipant, CommitValidationError> {
1784    if let Some(leaf_node) = group.member_at(*leaf_index) {
1785        let installation_id = leaf_node.signature_key.to_vec();
1786        let inbox_id = inbox_id_from_credential(&leaf_node.credential)?;
1787        Ok(CommitParticipant::build(
1788            inbox_id,
1789            installation_id,
1790            immutable_metadata,
1791            mutable_metadata,
1792        ))
1793    } else {
1794        // TODO: Handle external joins/commits
1795        Err(CommitValidationError::ActorNotMember)
1796    }
1797}
1798
1799/// Get the [`GroupMembership`] from a `GroupContext` struct.
1800///
1801/// Post-migration the legacy `GROUP_MEMBERSHIP_EXTENSION_ID` is gone —
1802/// we reconstruct from the AppData dictionary's `GROUP_MEMBERSHIP`
1803/// component. Pre-migration the legacy extension is authoritative.
1804#[tracing::instrument(level = "trace", skip_all)]
1805pub fn extract_group_membership(
1806    extensions: &Extensions<GroupContext>,
1807) -> Result<GroupMembership, CommitValidationError> {
1808    if let Some(proto) = super::app_data::component_source::read_group_membership_from_dict(
1809        extensions,
1810    )
1811    .map_err(|e| {
1812        CommitValidationError::GroupMutableMetadata(
1813            xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from(e),
1814        )
1815    })? {
1816        // Proto and `GroupMembership` carry the same two fields; build
1817        // directly to skip a wasteful `encode → decode` round-trip
1818        // through `try_from(bytes)`.
1819        return Ok(GroupMembership {
1820            members: proto.members,
1821            failed_installations: proto.failed_installations,
1822        });
1823    }
1824
1825    for extension in extensions.iter() {
1826        if let Extension::Unknown(
1827            xmtp_configuration::GROUP_MEMBERSHIP_EXTENSION_ID,
1828            UnknownExtension(group_membership),
1829        ) = extension
1830        {
1831            return Ok(GroupMembership::try_from(group_membership.clone())?);
1832        }
1833    }
1834
1835    Err(CommitValidationError::MissingGroupMembership)
1836}
1837
1838/**
1839 * Extracts the changes to the mutable metadata in the commit.
1840 *
1841 * Returns an error if the extension is not found in either the old or new group context.
1842 */
1843fn extract_metadata_changes(
1844    immutable_metadata: &GroupMetadata,
1845    // We already have the old mutable metadata, so save parsing it a second time
1846    old_mutable_metadata: &GroupMutableMetadata,
1847    old_group_extensions: &Extensions<GroupContext>,
1848    new_group_extensions: &Extensions<GroupContext>,
1849) -> Result<MutableMetadataValidationInfo, CommitValidationError> {
1850    let old_mutable_metadata_ext = find_mutable_metadata_extension(old_group_extensions)
1851        .ok_or(CommitValidationError::MissingMutableMetadata)?;
1852    let new_mutable_metadata_ext = find_mutable_metadata_extension(new_group_extensions)
1853        .ok_or(CommitValidationError::MissingMutableMetadata)?;
1854
1855    // Before even decoding the new metadata, make sure that something has changed. Otherwise we know there is
1856    // nothing to do
1857    if old_mutable_metadata_ext.eq(new_mutable_metadata_ext) {
1858        let minimum_supported_protocol_version: Option<String> = old_mutable_metadata
1859            .attributes
1860            .get(MetadataField::MinimumSupportedProtocolVersion.as_str())
1861            .map(|s| s.to_string());
1862        return Ok(MutableMetadataValidationInfo {
1863            minimum_supported_protocol_version,
1864            ..Default::default()
1865        });
1866    }
1867
1868    let new_mutable_metadata: GroupMutableMetadata = new_mutable_metadata_ext.try_into()?;
1869
1870    let metadata_field_changes =
1871        mutable_metadata_field_changes(old_mutable_metadata, &new_mutable_metadata);
1872
1873    Ok(MutableMetadataValidationInfo {
1874        metadata_field_changes,
1875        admins_added: get_added_members(
1876            &old_mutable_metadata.admin_list,
1877            &new_mutable_metadata.admin_list,
1878            immutable_metadata,
1879            old_mutable_metadata,
1880        ),
1881        admins_removed: get_removed_members(
1882            &old_mutable_metadata.admin_list,
1883            &new_mutable_metadata.admin_list,
1884            immutable_metadata,
1885            old_mutable_metadata,
1886        ),
1887        super_admins_added: get_added_members(
1888            &old_mutable_metadata.super_admin_list,
1889            &new_mutable_metadata.super_admin_list,
1890            immutable_metadata,
1891            old_mutable_metadata,
1892        ),
1893        super_admins_removed: get_removed_members(
1894            &old_mutable_metadata.super_admin_list,
1895            &new_mutable_metadata.super_admin_list,
1896            immutable_metadata,
1897            old_mutable_metadata,
1898        ),
1899        num_super_admins: new_mutable_metadata.super_admin_list.len() as u32,
1900        minimum_supported_protocol_version: new_mutable_metadata
1901            .attributes
1902            .get(MetadataField::MinimumSupportedProtocolVersion.as_str())
1903            .map(|s| s.to_string()),
1904    })
1905}
1906
1907// Returns true if the permissions have changed, false otherwise
1908fn extract_permissions_changed(
1909    old_group_permissions: &GroupMutablePermissions,
1910    new_group_extensions: &Extensions<GroupContext>,
1911) -> Result<bool, CommitValidationError> {
1912    let new_group_permissions: GroupMutablePermissions = new_group_extensions.try_into()?;
1913    Ok(!old_group_permissions.eq(&new_group_permissions))
1914}
1915
1916fn find_unknown_extension(
1917    extensions: &Extensions<GroupContext>,
1918    extension_type: u16,
1919) -> Option<&Vec<u8>> {
1920    extensions.iter().find_map(|extension| {
1921        if let Extension::Unknown(id, UnknownExtension(bytes)) = extension
1922            && *id == extension_type
1923        {
1924            return Some(bytes);
1925        }
1926        None
1927    })
1928}
1929
1930/**
1931 * Gets the list of inboxes present in the new group membership that are not present in the old group membership.
1932 */
1933fn get_added_members(
1934    old: &[String],
1935    new: &[String],
1936    immutable_metadata: &GroupMetadata,
1937    mutable_metadata: &GroupMutableMetadata,
1938) -> Vec<Inbox> {
1939    new.iter()
1940        .filter(|new_inbox| !old.contains(new_inbox))
1941        .map(|inbox_id| build_inbox(inbox_id, immutable_metadata, mutable_metadata))
1942        .collect()
1943}
1944
1945/**
1946 * Gets the list of inboxes present in the old group membership that are not present in the new group membership.
1947 */
1948fn get_removed_members(
1949    old: &[String],
1950    new: &[String],
1951    immutable_metadata: &GroupMetadata,
1952    mutable_metadata: &GroupMutableMetadata,
1953) -> Vec<Inbox> {
1954    old.iter()
1955        .filter(|old_inbox| !new.contains(old_inbox))
1956        .map(|inbox_id| build_inbox(inbox_id, immutable_metadata, mutable_metadata))
1957        .collect()
1958}
1959
1960fn build_inbox(
1961    inbox_id: &String,
1962    immutable_metadata: &GroupMetadata,
1963    mutable_metadata: &GroupMutableMetadata,
1964) -> Inbox {
1965    Inbox {
1966        inbox_id: inbox_id.to_string(),
1967        is_admin: mutable_metadata.is_admin(inbox_id),
1968        is_super_admin: mutable_metadata.is_super_admin(inbox_id),
1969        is_creator: immutable_metadata.creator_inbox_id.eq(inbox_id),
1970        proposer: None,
1971    }
1972}
1973
1974fn build_inbox_with_proposer(
1975    inbox_id: &String,
1976    immutable_metadata: &GroupMetadata,
1977    mutable_metadata: &GroupMutableMetadata,
1978    proposer: CommitParticipant,
1979) -> Inbox {
1980    Inbox {
1981        inbox_id: inbox_id.to_string(),
1982        is_admin: mutable_metadata.is_admin(inbox_id),
1983        is_super_admin: mutable_metadata.is_super_admin(inbox_id),
1984        is_creator: immutable_metadata.creator_inbox_id.eq(inbox_id),
1985        proposer: Some(proposer),
1986    }
1987}
1988
1989/**
1990 * Extracts the changes to the mutable metadata in the commit.
1991 */
1992fn mutable_metadata_field_changes(
1993    old_metadata: &GroupMutableMetadata,
1994    new_metadata: &GroupMutableMetadata,
1995) -> Vec<MetadataFieldChange> {
1996    let all_keys = old_metadata
1997        .attributes
1998        .keys()
1999        .chain(new_metadata.attributes.keys())
2000        .fold(HashSet::new(), |mut key_set, key| {
2001            key_set.insert(key);
2002            key_set
2003        });
2004
2005    all_keys
2006        .into_iter()
2007        .filter_map(|key| {
2008            let old_val = old_metadata.attributes.get(key);
2009            let new_val = new_metadata.attributes.get(key);
2010            if old_val.ne(&new_val) {
2011                Some(MetadataFieldChange::new(
2012                    key.clone(),
2013                    old_val.cloned(),
2014                    new_val.cloned(),
2015                ))
2016            } else {
2017                None
2018            }
2019        })
2020        .collect()
2021}
2022
2023/// Extracts the inbox ID from a credential.
2024fn inbox_id_from_credential(
2025    credential: &OpenMlsCredential,
2026) -> Result<String, CommitValidationError> {
2027    let basic_credential = BasicCredential::try_from(credential.clone())?;
2028    let identity_bytes = basic_credential.identity();
2029    let decoded = MlsCredential::decode(identity_bytes)?;
2030
2031    Ok(decoded.inbox_id)
2032}
2033
2034/// Takes a [`StagedCommit`] and extracts the committer and all unique proposers.
2035///
2036/// `committer_leaf_index` is the verified sender of the commit message — for
2037/// received commits, that's `ProcessedMessage::sender()` after OpenMLS has
2038/// validated the framing signature against the leaf at that index; for our
2039/// own commits being applied from an intent, that's `mls_group.own_leaf_index()`.
2040/// Either way the cryptographic signature is the source of truth, so we
2041/// don't need a path update to identify the committer.
2042///
2043/// Returns (committer, proposers) where:
2044/// - `committer` is the actor who created the commit
2045/// - `proposers` is a list of all unique members who created proposals
2046///
2047/// Note: The committer may differ from the proposers — this is valid when one
2048/// member commits proposals created by other members.
2049fn extract_committer_and_proposers(
2050    staged_commit: &StagedCommit,
2051    committer_leaf_index: LeafNodeIndex,
2052    openmls_group: &OpenMlsGroup,
2053    immutable_metadata: &GroupMetadata,
2054    mutable_metadata: &GroupMutableMetadata,
2055) -> Result<(CommitParticipant, Vec<CommitParticipant>), CommitValidationError> {
2056    // Collect all unique proposers from the proposals
2057    let mut proposer_leaf_indices: Vec<&LeafNodeIndex> = Vec::new();
2058    for proposal in staged_commit.queued_proposals() {
2059        match proposal.sender() {
2060            Sender::Member(member_leaf_node_index) => {
2061                // Only add if not already in the list
2062                if !proposer_leaf_indices.contains(&member_leaf_node_index) {
2063                    proposer_leaf_indices.push(member_leaf_node_index);
2064                }
2065            }
2066            _ => return Err(CommitValidationError::ActorNotMember),
2067        }
2068    }
2069
2070    // Convert all proposer leaf indices to CommitParticipants
2071    let mut proposers: Vec<CommitParticipant> = Vec::new();
2072    for leaf_index in &proposer_leaf_indices {
2073        let participant = extract_commit_participant(
2074            leaf_index,
2075            openmls_group,
2076            immutable_metadata,
2077            mutable_metadata,
2078        )?;
2079        proposers.push(participant);
2080    }
2081
2082    let committer = extract_commit_participant(
2083        &committer_leaf_index,
2084        openmls_group,
2085        immutable_metadata,
2086        mutable_metadata,
2087    )?;
2088
2089    Ok((committer, proposers))
2090}
2091
2092/// Validates a single proposal by checking if the proposer has the required permissions.
2093/// Returns Ok(()) if the proposal is valid, or an error if validation fails.
2094///
2095/// This function should be called when receiving proposals to ensure they are valid
2096/// before they are stored and later committed.
2097pub fn validate_proposal(
2098    proposal: &QueuedProposal,
2099    openmls_group: &OpenMlsGroup,
2100    policy_set: &PolicySet,
2101    immutable_metadata: &GroupMetadata,
2102    mutable_metadata: &GroupMutableMetadata,
2103) -> Result<(), CommitValidationError> {
2104    // Extract the proposer from the proposal
2105    let proposer = match proposal.sender() {
2106        Sender::Member(leaf_index) => extract_commit_participant(
2107            leaf_index,
2108            openmls_group,
2109            immutable_metadata,
2110            mutable_metadata,
2111        )?,
2112        Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
2113            // External and new member proposals are not supported
2114            return Err(CommitValidationError::ActorNotMember);
2115        }
2116    };
2117
2118    let unsupported_error =
2119        || CommitValidationError::UnsupportedProposalType(proposal.proposal().proposal_type());
2120
2121    // Validate based on proposal type
2122    match proposal.proposal() {
2123        Proposal::Add(add_proposal) => {
2124            // Check if the proposer has permission to add members
2125            let added_inbox_id =
2126                inbox_id_from_credential(add_proposal.key_package().leaf_node().credential())?;
2127            let inbox = Inbox {
2128                inbox_id: added_inbox_id.clone(),
2129                is_creator: false,
2130                is_admin: false,
2131                is_super_admin: false,
2132                proposer: Some(proposer.clone()),
2133            };
2134            if !policy_set.add_member_policy.evaluate(&proposer, &inbox) {
2135                // DM bypass: allow adding the other DM participant even if policy denies
2136                let is_dm_add = immutable_metadata.dm_members.as_ref().is_some_and(|dm| {
2137                    (added_inbox_id == dm.member_one_inbox_id.as_ref()
2138                        || added_inbox_id == dm.member_two_inbox_id.as_ref())
2139                        && added_inbox_id != proposer.inbox_id
2140                });
2141                if !is_dm_add {
2142                    tracing::warn!(
2143                        proposer_inbox_id = %proposer.inbox_id,
2144                        "Proposal rejected: proposer does not have permission to add members"
2145                    );
2146                    return Err(CommitValidationError::InsufficientPermissions);
2147                }
2148            }
2149        }
2150        Proposal::Remove(remove_proposal) => {
2151            // Check if the proposer has permission to remove members
2152            // Get the inbox_id of the member being removed
2153            let removed_member = openmls_group
2154                .member_at(remove_proposal.removed())
2155                .ok_or(CommitValidationError::SubjectDoesNotExist)?;
2156            let removed_inbox_id = inbox_id_from_credential(&removed_member.credential)?;
2157            let removed_is_admin = mutable_metadata.admin_list.contains(&removed_inbox_id);
2158            let removed_is_super_admin = mutable_metadata.is_super_admin(&removed_inbox_id);
2159
2160            // Super admins cannot be removed
2161            if removed_is_super_admin {
2162                tracing::warn!(
2163                    proposer_inbox_id = %proposer.inbox_id,
2164                    removed_inbox_id = %removed_inbox_id,
2165                    "Proposal rejected: cannot remove super admin"
2166                );
2167                return Err(CommitValidationError::InsufficientPermissions);
2168            }
2169
2170            let removed_inbox = Inbox {
2171                inbox_id: removed_inbox_id.clone(),
2172                is_creator: immutable_metadata.creator_inbox_id == removed_inbox_id,
2173                is_admin: removed_is_admin,
2174                is_super_admin: removed_is_super_admin,
2175                proposer: Some(proposer.clone()),
2176            };
2177
2178            if !policy_set
2179                .remove_member_policy
2180                .evaluate(&proposer, &removed_inbox)
2181            {
2182                tracing::warn!(
2183                    proposer_inbox_id = %proposer.inbox_id,
2184                    removed_inbox_id = %removed_inbox_id,
2185                    "Proposal rejected: proposer does not have permission to remove members"
2186                );
2187                return Err(CommitValidationError::InsufficientPermissions);
2188            }
2189        }
2190        Proposal::GroupContextExtensions(gce_proposal) => {
2191            let existing_extensions = openmls_group.extensions();
2192            let new_extensions = gce_proposal.extensions();
2193
2194            // Check for mutable metadata changes (group name, admin list, etc.)
2195            let old_meta = find_mutable_metadata_extension(existing_extensions);
2196            let new_meta = find_mutable_metadata_extension(new_extensions);
2197            if old_meta.is_some() && new_meta.is_none() {
2198                tracing::warn!(
2199                    proposer_inbox_id = %proposer.inbox_id,
2200                    "GCE proposal rejected: cannot remove mutable metadata extension"
2201                );
2202                return Err(CommitValidationError::InsufficientPermissions);
2203            }
2204            if let (Some(old_meta), Some(new_meta)) = (old_meta, new_meta)
2205                && old_meta != new_meta
2206            {
2207                let metadata_changes = extract_metadata_changes(
2208                    immutable_metadata,
2209                    mutable_metadata,
2210                    existing_extensions,
2211                    new_extensions,
2212                )?;
2213
2214                for change in &metadata_changes.metadata_field_changes {
2215                    if let Some(policy) = policy_set.update_metadata_policy.get(&change.field_name)
2216                        && !policy.evaluate(&proposer, change)
2217                    {
2218                        tracing::warn!(
2219                            proposer_inbox_id = %proposer.inbox_id,
2220                            field = %change.field_name,
2221                            "GCE proposal rejected: no permission to update metadata field"
2222                        );
2223                        return Err(CommitValidationError::InsufficientPermissions);
2224                    }
2225                }
2226
2227                if !metadata_changes.admins_added.is_empty()
2228                    && !policy_set.add_admin_policy.evaluate(&proposer)
2229                {
2230                    tracing::warn!(
2231                        proposer_inbox_id = %proposer.inbox_id,
2232                        "GCE proposal rejected: no permission to add admins"
2233                    );
2234                    return Err(CommitValidationError::InsufficientPermissions);
2235                }
2236                if !metadata_changes.admins_removed.is_empty()
2237                    && !policy_set.remove_admin_policy.evaluate(&proposer)
2238                {
2239                    tracing::warn!(
2240                        proposer_inbox_id = %proposer.inbox_id,
2241                        "GCE proposal rejected: no permission to remove admins"
2242                    );
2243                    return Err(CommitValidationError::InsufficientPermissions);
2244                }
2245
2246                if (!metadata_changes.super_admins_added.is_empty()
2247                    || !metadata_changes.super_admins_removed.is_empty())
2248                    && !proposer.is_super_admin
2249                {
2250                    tracing::warn!(
2251                        proposer_inbox_id = %proposer.inbox_id,
2252                        "GCE proposal rejected: only super admins can modify super admin list"
2253                    );
2254                    return Err(CommitValidationError::InsufficientPermissions);
2255                }
2256            }
2257
2258            // Check for permission changes (only super admin can
2259            // change permissions). On migrated groups the legacy
2260            // GROUP_PERMISSIONS, MUTABLE_METADATA, and GROUP_MEMBERSHIP
2261            // extensions are all stripped at bootstrap; their state
2262            // lives in the AppData dictionary and changes flow as
2263            // `AppDataUpdate` proposals validated against the dict's
2264            // policy entries. A GCE proposal that (re-)introduces any
2265            // of these legacy extensions on a migrated group is
2266            // therefore unconditionally rejected — otherwise a
2267            // non-super-admin peer could smuggle an arbitrary policy
2268            // set, metadata change, or membership view through the
2269            // legacy extension because the post-migration check below
2270            // would have nothing to diff against.
2271            let migrated_for_perms = super::app_data::is_migrated_extensions(existing_extensions);
2272            if migrated_for_perms {
2273                for (ext_id, ext_name) in [
2274                    (
2275                        xmtp_configuration::GROUP_PERMISSIONS_EXTENSION_ID,
2276                        "GROUP_PERMISSIONS",
2277                    ),
2278                    (
2279                        xmtp_configuration::MUTABLE_METADATA_EXTENSION_ID,
2280                        "MUTABLE_METADATA",
2281                    ),
2282                    (
2283                        xmtp_configuration::GROUP_MEMBERSHIP_EXTENSION_ID,
2284                        "GROUP_MEMBERSHIP",
2285                    ),
2286                ] {
2287                    if find_unknown_extension(new_extensions, ext_id).is_some() {
2288                        tracing::warn!(
2289                            proposer_inbox_id = %proposer.inbox_id,
2290                            extension = ext_name,
2291                            "GCE proposal rejected: legacy extension cannot be (re-)added to a migrated group"
2292                        );
2293                        return Err(CommitValidationError::InsufficientPermissions);
2294                    }
2295                }
2296            } else {
2297                let old_permissions: GroupMutablePermissions = existing_extensions.try_into()?;
2298                if !proposer.is_super_admin
2299                    && let Ok(true) = extract_permissions_changed(&old_permissions, new_extensions)
2300                {
2301                    tracing::warn!(
2302                        proposer_inbox_id = %proposer.inbox_id,
2303                        "GCE proposal rejected: only super admins can change permissions"
2304                    );
2305                    return Err(CommitValidationError::InsufficientPermissions);
2306                }
2307            }
2308        }
2309        Proposal::Update(update_proposal) => {
2310            // Update proposals are allowed for the member themselves, but the new leaf node's
2311            // credential must match the proposer's identity to prevent identity swaps.
2312            let new_inbox_id = inbox_id_from_credential(update_proposal.leaf_node().credential())?;
2313            if new_inbox_id != proposer.inbox_id {
2314                tracing::warn!(
2315                    proposer_inbox_id = %proposer.inbox_id,
2316                    proposer_installation_id = hex::encode(&proposer.installation_id),
2317                    leaf_index = ?proposal.sender(),
2318                    new_inbox_id = %new_inbox_id,
2319                    new_installation_id = hex::encode(update_proposal.leaf_node().signature_key().as_slice()),
2320                    "Update proposal rejected: new leaf node credential does not match proposer"
2321                );
2322                return Err(CommitValidationError::ActorNotMember);
2323            }
2324        }
2325        Proposal::PreSharedKey(_) => {
2326            return Err(unsupported_error());
2327        }
2328        Proposal::ReInit(_) => {
2329            return Err(unsupported_error());
2330        }
2331        Proposal::ExternalInit(_) => {
2332            return Err(unsupported_error());
2333        }
2334        Proposal::Custom(_) => {
2335            return Err(unsupported_error());
2336        }
2337        Proposal::AppDataUpdate(app_data) => {
2338            use super::app_data::load_component_registry;
2339            use xmtp_mls_common::app_data::{
2340                component_id::ComponentId, validation::ActorAuthority,
2341            };
2342
2343            let registry = load_component_registry(openmls_group)?;
2344
2345            // Delegate to the shared helper so the commit-time path
2346            // (`validate_app_data_update_proposals_in_commit`) and this
2347            // standalone-proposal-by-reference path can't drift apart.
2348            validate_one_app_data_update(
2349                ComponentId::from(app_data.component_id()),
2350                app_data.operation(),
2351                ActorAuthority::from(&proposer),
2352                &proposer.inbox_id,
2353                &registry,
2354                openmls_group,
2355            )?;
2356        }
2357        Proposal::AppEphemeral(_) => {
2358            return Err(unsupported_error());
2359        }
2360        Proposal::SelfRemove => {
2361            return Err(unsupported_error());
2362        }
2363    }
2364
2365    Ok(())
2366}
2367
2368impl From<&MetadataFieldChange> for MetadataFieldChangeProto {
2369    fn from(change: &MetadataFieldChange) -> Self {
2370        MetadataFieldChangeProto {
2371            field_name: change.field_name.clone(),
2372            old_value: change.old_value.clone(),
2373            new_value: change.new_value.clone(),
2374        }
2375    }
2376}
2377
2378impl From<&Inbox> for InboxProto {
2379    fn from(inbox: &Inbox) -> Self {
2380        InboxProto {
2381            inbox_id: inbox.inbox_id.clone(),
2382        }
2383    }
2384}
2385
2386// Implement the generic conversion: the TARGET (GroupUpdatedProto) declares what params it needs.
2387// Here it's `BuildOpts`, but it could be `&dyn Policy`, `&[u8]`, etc.
2388impl FromWith<ValidatedCommit> for GroupUpdatedProto {
2389    /// Extra parameter is a list of inbox IDs who requested self-removal (pending removals).
2390    type Params = Vec<String>;
2391
2392    fn from_with(commit: ValidatedCommit, pending_removals: &Self::Params) -> Self {
2393        use std::collections::HashSet;
2394
2395        // Convert the pending removals list into a set for fast lookup
2396        let pending_set: HashSet<&str> = pending_removals.iter().map(String::as_str).collect();
2397
2398        // Partition removed inboxes:
2399        //  - left_inboxes: those present in pending_removals
2400        //  - removed_inboxes: all others
2401        let (left_inboxes, removed_inboxes): (Vec<Inbox>, Vec<Inbox>) = commit
2402            .removed_inboxes
2403            .into_iter()
2404            .partition(|inb| pending_set.contains(inb.inbox_id.as_str()));
2405
2406        GroupUpdatedProto {
2407            initiated_by_inbox_id: commit.actor.inbox_id.clone(),
2408            added_inboxes: commit.added_inboxes.iter().map(InboxProto::from).collect(),
2409            removed_inboxes: removed_inboxes.iter().map(InboxProto::from).collect(),
2410            metadata_field_changes: commit
2411                .metadata_validation_info
2412                .metadata_field_changes
2413                .iter()
2414                .map(MetadataFieldChangeProto::from)
2415                .collect(),
2416            left_inboxes: left_inboxes.iter().map(InboxProto::from).collect(),
2417            added_admin_inboxes: commit
2418                .metadata_validation_info
2419                .admins_added
2420                .iter()
2421                .map(InboxProto::from)
2422                .collect(),
2423            removed_admin_inboxes: commit
2424                .metadata_validation_info
2425                .admins_removed
2426                .iter()
2427                .map(InboxProto::from)
2428                .collect(),
2429            added_super_admin_inboxes: commit
2430                .metadata_validation_info
2431                .super_admins_added
2432                .iter()
2433                .map(InboxProto::from)
2434                .collect(),
2435            removed_super_admin_inboxes: commit
2436                .metadata_validation_info
2437                .super_admins_removed
2438                .iter()
2439                .map(InboxProto::from)
2440                .collect(),
2441        }
2442    }
2443}
2444
2445#[cfg(test)]
2446mod permission_on_receive_tests {
2447    //! Pins the receive-side permission check on `AppDataUpdate`
2448    //! proposals. Every proposal that reaches
2449    //! [`validate_one_app_data_update_with_old_value`] runs through
2450    //! `validate_component_write` (registry policy + hardcoded
2451    //! super-admin gating). Without this guarantee an attacker who
2452    //! patched out the send-side permission check could still poison
2453    //! the dictionary as long as their proposal landed in a commit.
2454    use super::*;
2455    use openmls::messages::proposals::AppDataUpdateOperation;
2456    use xmtp_mls_common::app_data::{
2457        component_id::ComponentId, component_registry::ComponentRegistry,
2458        validation::ActorAuthority,
2459    };
2460
2461    fn non_admin_actor() -> ActorAuthority {
2462        ActorAuthority {
2463            is_admin: false,
2464            is_super_admin: false,
2465        }
2466    }
2467
2468    fn admin_actor() -> ActorAuthority {
2469        ActorAuthority {
2470            is_admin: true,
2471            is_super_admin: false,
2472        }
2473    }
2474
2475    #[xmtp_common::test(unwrap_try = true)]
2476    fn non_admin_writing_super_admin_only_component_is_rejected() {
2477        let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
2478        let registry = ComponentRegistry::new();
2479        let err = validate_one_app_data_update_with_old_value(
2480            ComponentId::COMPONENT_REGISTRY,
2481            &operation,
2482            non_admin_actor(),
2483            "test-inbox",
2484            &registry,
2485            None,
2486        )
2487        .expect_err("non-admin write to super-admin-only component must be rejected");
2488        assert!(
2489            matches!(err, CommitValidationError::InsufficientPermissions),
2490            "expected InsufficientPermissions, got {err:?}"
2491        );
2492    }
2493
2494    #[xmtp_common::test(unwrap_try = true)]
2495    fn plain_admin_writing_super_admin_only_component_is_rejected() {
2496        let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
2497        let registry = ComponentRegistry::new();
2498        let err = validate_one_app_data_update_with_old_value(
2499            ComponentId::COMPONENT_REGISTRY,
2500            &operation,
2501            admin_actor(),
2502            "test-inbox",
2503            &registry,
2504            None,
2505        )
2506        .expect_err("plain-admin write to super-admin-only component must be rejected");
2507        assert!(
2508            matches!(err, CommitValidationError::InsufficientPermissions),
2509            "expected InsufficientPermissions, got {err:?}"
2510        );
2511    }
2512
2513    #[xmtp_common::test(unwrap_try = true)]
2514    fn non_admin_writing_component_with_no_registry_entry_is_rejected() {
2515        // Unknown component in well-known range with no registry
2516        // entry → deny by default at the registry-policy layer
2517        // (validate_component_write Layer 3), regardless of actor role.
2518        let unknown_id = ComponentId::new(0x80FF);
2519        let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
2520        let registry = ComponentRegistry::new();
2521        let err = validate_one_app_data_update_with_old_value(
2522            unknown_id,
2523            &operation,
2524            non_admin_actor(),
2525            "test-inbox",
2526            &registry,
2527            None,
2528        )
2529        .expect_err("write to unregistered component must be rejected");
2530        assert!(
2531            matches!(err, CommitValidationError::InsufficientPermissions),
2532            "expected InsufficientPermissions, got {err:?}"
2533        );
2534    }
2535}
2536
2537#[cfg(test)]
2538mod min_version_monotonicity_tests {
2539    use super::*;
2540    use openmls::messages::proposals::AppDataUpdateOperation;
2541
2542    fn update_op(s: &str) -> AppDataUpdateOperation {
2543        AppDataUpdateOperation::Update(s.as_bytes().to_vec().into())
2544    }
2545
2546    #[xmtp_common::test(unwrap_try = true)]
2547    fn first_set_with_no_prior_floor_is_allowed() {
2548        enforce_min_version_monotonicity(&update_op("1.11.0-dev"), None)?;
2549    }
2550
2551    #[xmtp_common::test(unwrap_try = true)]
2552    fn equal_version_is_allowed() {
2553        enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"1.11.0-dev"))?;
2554    }
2555
2556    #[xmtp_common::test(unwrap_try = true)]
2557    fn higher_version_is_allowed() {
2558        enforce_min_version_monotonicity(&update_op("1.11.0"), Some(b"1.11.0-dev"))?;
2559        enforce_min_version_monotonicity(&update_op("1.12.0"), Some(b"1.11.0-dev"))?;
2560        enforce_min_version_monotonicity(&update_op("2.0.0"), Some(b"1.11.0-dev"))?;
2561    }
2562
2563    #[xmtp_common::test(unwrap_try = true)]
2564    fn lower_version_is_rejected() {
2565        let err = enforce_min_version_monotonicity(&update_op("1.10.0"), Some(b"1.11.0-dev"))
2566            .expect_err("downgrade must be rejected");
2567        assert!(
2568            matches!(
2569                err,
2570                CommitValidationError::MinVersionDowngrade { ref requested, ref current }
2571                if requested == "1.10.0" && current == "1.11.0-dev"
2572            ),
2573            "expected MinVersionDowngrade, got {err:?}",
2574        );
2575    }
2576
2577    #[xmtp_common::test(unwrap_try = true)]
2578    fn remove_with_prior_floor_is_rejected() {
2579        let err =
2580            enforce_min_version_monotonicity(&AppDataUpdateOperation::Remove, Some(b"1.11.0-dev"))
2581                .expect_err("remove on a set floor must be rejected");
2582        assert!(
2583            matches!(
2584                err,
2585                CommitValidationError::MinVersionRemoveOnExistingFloor { ref current }
2586                if current == "1.11.0-dev"
2587            ),
2588            "expected MinVersionRemoveOnExistingFloor, got {err:?}",
2589        );
2590    }
2591
2592    #[xmtp_common::test(unwrap_try = true)]
2593    fn remove_with_no_prior_floor_is_allowed() {
2594        enforce_min_version_monotonicity(&AppDataUpdateOperation::Remove, None)?;
2595    }
2596
2597    #[xmtp_common::test(unwrap_try = true)]
2598    fn malformed_prior_skips_check() {
2599        // Lenient on unparseable prior bytes — refusing every future
2600        // update on a malformed floor would brick the group.
2601        enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"not-a-version"))?;
2602        enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(&[0xff, 0xfe, 0xfd]))?;
2603    }
2604
2605    #[xmtp_common::test(unwrap_try = true)]
2606    fn malformed_new_value_surfaces_parse_error() {
2607        let err =
2608            enforce_min_version_monotonicity(&update_op("not-a-version"), Some(b"1.11.0-dev"))
2609                .expect_err("malformed new value must error");
2610        assert!(
2611            matches!(err, CommitValidationError::InvalidVersionFormat(_)),
2612            "expected InvalidVersionFormat, got {err:?}",
2613        );
2614    }
2615
2616    #[xmtp_common::test(unwrap_try = true)]
2617    fn prerelease_ordering_matches_semver() {
2618        // semver §11: pre-release sorts BEFORE the release. Bumping
2619        // from a pre-release to the corresponding release is allowed;
2620        // going the other way is a downgrade.
2621        enforce_min_version_monotonicity(&update_op("1.10.0"), Some(b"1.10.0-rc.1"))?;
2622        let err = enforce_min_version_monotonicity(&update_op("1.10.0-rc.1"), Some(b"1.10.0"))
2623            .expect_err("rc → release reverse must be rejected");
2624        assert!(
2625            matches!(err, CommitValidationError::MinVersionDowngrade { .. }),
2626            "expected MinVersionDowngrade, got {err:?}",
2627        );
2628    }
2629
2630    #[xmtp_common::test(unwrap_try = true)]
2631    fn dev_prerelease_is_lower_than_release() {
2632        // The default `PROPOSALS_MIN_PROTOCOL_VERSION` is the
2633        // `-dev` pre-release of the workspace version, so by
2634        // semver §11 the release of the same x.y.z must sort
2635        // above it. Lock both directions:
2636        //   - LibXMTPVersion comparator agrees,
2637        //   - bumping the floor from `-dev` to the release is allowed,
2638        //   - the reverse is a downgrade.
2639        let dev = LibXMTPVersion::parse("1.11.0-dev")?;
2640        let release = LibXMTPVersion::parse("1.11.0")?;
2641        assert!(
2642            release > dev,
2643            "expected 1.11.0 > 1.11.0-dev per semver §11, got release={release:?} dev={dev:?}",
2644        );
2645        assert!(dev < release, "expected 1.11.0-dev < 1.11.0 per semver §11");
2646
2647        enforce_min_version_monotonicity(&update_op("1.11.0"), Some(b"1.11.0-dev"))?;
2648
2649        let err = enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"1.11.0"))
2650            .expect_err("release → -dev reverse must be rejected");
2651        assert!(
2652            matches!(
2653                err,
2654                CommitValidationError::MinVersionDowngrade { ref requested, ref current }
2655                if requested == "1.11.0-dev" && current == "1.11.0"
2656            ),
2657            "expected MinVersionDowngrade, got {err:?}",
2658        );
2659    }
2660}