Skip to main content

de_mls/conversation/
construct.rs

1//! Standalone construction of a [`Conversation`] — no `User` required.
2//!
3//! [`Conversation::create`] and [`Conversation::join`] take the OpenMLS
4//! provider, the plug-in instances, a ready consensus service, and the durable
5//! config as direct arguments. The library builds the per-conversation MLS
6//! service ([`OpenMlsService`]) internally — the creator seeds a fresh group,
7//! the joiner opens one from the welcome — and returns a conversation ready to
8//! drop into a registry.
9
10use std::error::Error as StdError;
11use std::sync::Arc;
12
13use openmls::credentials::CredentialWithKey;
14use openmls::prelude::Ciphersuite;
15use openmls_traits::signatures::Signer;
16use openmls_traits::{OpenMlsProvider, storage::StorageProvider};
17
18use hashgraph_like_consensus::events::ConsensusEventBus;
19
20use crate::{
21    ConsensusPlugin, ConsensusServiceFor, Conversation, ConversationConfig, ConversationError,
22    ConversationEvent, ConversationQueues, ConversationServices, ConversationStateMachine,
23    PeerScoringPlugin, StewardListPlugin,
24    mls_crypto::{MlsService, OpenMlsService},
25};
26
27impl<C, Sc, St> Conversation<C, Sc, St>
28where
29    C: ConsensusPlugin,
30    Sc: PeerScoringPlugin,
31    St: StewardListPlugin,
32{
33    /// Create a brand-new conversation we steward. Starts in `Working` with the
34    /// local member installed as sole steward at epoch 0. The library seeds a
35    /// fresh MLS group into `provider` (which it does not retain) from
36    /// `credential` and `ciphersuite`. `member_id` names the local member — the
37    /// opaque id bytes the protocol matches on.
38    #[allow(clippy::too_many_arguments)]
39    pub fn create<Pr>(
40        conversation_id: &str,
41        provider: &Pr,
42        credential: CredentialWithKey,
43        ciphersuite: Ciphersuite,
44        signer: &impl Signer,
45        scoring: Sc,
46        steward: St,
47        consensus: ConsensusServiceFor<C>,
48        app_id: Arc<[u8]>,
49        config: ConversationConfig,
50        member_id: &[u8],
51    ) -> Result<Self, ConversationError>
52    where
53        Pr: OpenMlsProvider,
54        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
55    {
56        let mls = OpenMlsService::new_as_creator(
57            conversation_id.to_string(),
58            provider,
59            credential,
60            ciphersuite,
61            signer,
62        )?;
63        Self::assemble(
64            conversation_id,
65            mls,
66            scoring,
67            steward,
68            consensus,
69            app_id,
70            config,
71            true,
72            member_id,
73        )
74    }
75
76    /// Build a fully-joined conversation from a welcome: the library opens the
77    /// joiner-side MLS group from `welcome_bytes` using `provider` (which must
78    /// hold the joiner's key-package private keys), runs the joiner-side join
79    /// side-effects, and replays the bundled `ConversationSync` (steward list,
80    /// timing, peer scores).
81    ///
82    /// Returns `Ok(None)` when the welcome doesn't address one of our key
83    /// packages — the "not for us" branch, not an error.
84    ///
85    /// The conversation id comes from the MLS group, so the caller needs no
86    /// prior knowledge of the conversation.
87    #[allow(clippy::too_many_arguments)]
88    pub fn join<Pr>(
89        provider: &Pr,
90        welcome_bytes: &[u8],
91        conversation_sync_bytes: &[u8],
92        scoring: Sc,
93        steward: St,
94        consensus: ConsensusServiceFor<C>,
95        app_id: Arc<[u8]>,
96        config: ConversationConfig,
97        member_id: &[u8],
98        signer: &impl Signer,
99    ) -> Result<Option<Self>, ConversationError>
100    where
101        Pr: OpenMlsProvider,
102        <Pr::StorageProvider as StorageProvider<1>>::Error: StdError + Send + Sync + 'static,
103    {
104        let Some(mls) = OpenMlsService::new_from_welcome(provider, welcome_bytes)? else {
105            return Ok(None);
106        };
107        let conversation_id = mls.conversation_id().to_string();
108        let mut conversation = Self::assemble(
109            &conversation_id,
110            mls,
111            scoring,
112            steward,
113            consensus,
114            app_id,
115            config,
116            false,
117            member_id,
118        )?;
119        conversation.on_joined(provider, signer)?;
120        conversation.apply_welcome_sync(provider, conversation_sync_bytes, signer)?;
121        Ok(Some(conversation))
122    }
123
124    /// Shared assembly tail for [`Self::create`] / [`Self::join`]: builds the
125    /// queues and consensus subscription around the pre-built MLS service and
126    /// plug-in instances. The conversation opens in `Working` and emits the
127    /// opening `PhaseChange(Working)` for both paths. `is_creation` bootstraps
128    /// the steward list and scoring with the local member (creator) versus
129    /// leaving them empty until `ConversationSync` (joiner).
130    #[allow(clippy::too_many_arguments)]
131    fn assemble(
132        conversation_id: &str,
133        mls: OpenMlsService,
134        mut scoring: Sc,
135        mut steward_list: St,
136        consensus: ConsensusServiceFor<C>,
137        app_id: Arc<[u8]>,
138        config: ConversationConfig,
139        is_creation: bool,
140        member_id: &[u8],
141    ) -> Result<Self, ConversationError> {
142        let self_member_id_bytes = member_id.to_vec();
143        let queues = ConversationQueues::new(conversation_id);
144
145        // The conversation id is the deterministic-sort salt every member must
146        // share; the library owns it (the integrator hands in a seedless
147        // plug-in) so creator and joiner agree on every elected list.
148        steward_list.set_conversation_id(conversation_id.as_bytes());
149        steward_list.set_max_retries(config.max_reelection_attempts);
150        // Creator path: bootstrap the list with self as sole steward at
151        // epoch 0. Joiner path leaves the plug-in empty until `ConversationSync`.
152        if is_creation {
153            steward_list.install_list(0, std::slice::from_ref(&self_member_id_bytes), 1, 0)?;
154            // Creator is self at `default_score`; under standard config
155            // (`default > threshold`) no cross fires, so we drop the result.
156            let _ = scoring.add_member(&self_member_id_bytes);
157        }
158
159        let state_machine = ConversationStateMachine::new_as_member();
160        let initial_state = state_machine.current_state();
161
162        let consensus_rx = consensus.event_bus().subscribe();
163        let services = ConversationServices {
164            mls,
165            scoring,
166            steward_list,
167            consensus,
168            consensus_rx,
169        };
170        let conversation = Conversation::new(
171            conversation_id.to_string(),
172            queues,
173            services,
174            state_machine,
175            config,
176            Arc::from(member_id),
177            app_id,
178        );
179        // Surface the opening phase so a caller draining conversation events sees
180        // the conversation's starting state without a separate query.
181        conversation.emit_event(ConversationEvent::PhaseChange(initial_state));
182        Ok(conversation)
183    }
184}