Skip to main content

xmtp_mls/
builder.rs

1use crate::{
2    StorageError, XmtpApi,
3    attachments::AttachmentRuntime,
4    client::{Client, ClientError, DeviceSync},
5    context::{CoreContext, XmtpMlsLocalContext, XmtpSharedContext},
6    groups::change_callbacks::UnstableChangeCallbacks,
7    identity::{Identity, IdentityStrategy},
8    identity_updates::load_identity_updates,
9    mutex_registry::MutexRegistry,
10    server_configuration::ServerConfigurationHandle,
11    utils::VersionInfo,
12    worker::{WorkerRunner, tasks::TaskWorker},
13    worker::{device_sync::worker::SyncWorker, disappearing_messages::DisappearingMessagesWorker},
14};
15use futures::FutureExt;
16use prost::Message as _;
17use std::sync::Arc;
18use std::sync::atomic::AtomicBool;
19use thiserror::Error;
20use tokio_util::sync::CancellationToken;
21use tracing::debug;
22use xmtp_api::ApiClientWrapper;
23use xmtp_api_backend::TrackedStatsClient;
24use xmtp_common::snippet::Snippet;
25use xmtp_common::{ErrorCode, Event, Retry};
26use xmtp_cryptography::signature::IdentifierValidationError;
27use xmtp_db::{XmtpDb, sql_key_store::SqlKeyStore};
28use xmtp_db::{XmtpMlsStorageProvider, prelude::*};
29use xmtp_id::scw_verifier::SmartContractSignatureVerifier;
30use xmtp_macro::log_event;
31use xmtp_proto::api_client::XmtpBackendClient;
32use xmtp_proto::xmtp::mls::database::{
33    ProcessPendingSelfRemove, Task as TaskProto, task::Task as TaskKind,
34};
35
36pub use crate::core::config::{DeviceSyncMode, ForkRecoveryOpts, ForkRecoveryPolicy};
37
38type ContextParts<Api, S, Db> = Arc<XmtpMlsLocalContext<Api, Db, S>>;
39/// Opens the store at the paths `build` resolves for a storage location.
40#[cfg(not(target_arch = "wasm32"))]
41type LocationStoreOpener<Db> = Box<
42    dyn FnOnce(
43            crate::storage_location::ResolvedPaths,
44        ) -> xmtp_common::BoxDynFuture<'static, Result<Db, ClientBuilderError>>
45        + Send
46        + Sync,
47>;
48#[cfg(target_arch = "wasm32")]
49type LocationStoreOpener<Db> = Box<
50    dyn FnOnce(
51        crate::storage_location::ResolvedPaths,
52    ) -> xmtp_common::BoxDynFuture<'static, Result<Db, ClientBuilderError>>,
53>;
54
55#[cfg(not(target_arch = "wasm32"))]
56fn open_location_store(
57    paths: crate::storage_location::ResolvedPaths,
58    key: xmtp_db::EncryptionKey,
59) -> xmtp_common::BoxDynFuture<'static, Result<xmtp_db::DefaultStore, ClientBuilderError>> {
60    Box::pin(async move {
61        let parent = paths
62            .db_path
63            .parent()
64            .ok_or(crate::storage_location::StorageLocationError::InboxId)?;
65        xmtp_attachments::create_private_directory(parent)
66            .await
67            .map_err(crate::storage_location::StorageLocationError::from)?;
68        let db = xmtp_db::NativeDb::builder()
69            .persistent(paths.db_path.to_string_lossy().into_owned())
70            .key(key)
71            .build()?;
72        Ok(xmtp_db::EncryptedMessageStore::new(db)?)
73    })
74}
75
76// Every identity request uses an admitted snapshot, including the lookup
77// needed to find a data directory's database.
78fn admit_configuration<ApiClient: XmtpBackendClient>(
79    api_client: &mut ApiClientWrapper<ApiClient>,
80    configuration: &xmtp_configuration::ServerConfiguration,
81    client_version: &semver::Version,
82) -> Result<(), ClientError> {
83    crate::server_configuration::check_minimum_version(configuration, client_version)?;
84    // implements: CONF-051
85    if configuration.auth.enabled && !api_client.has_credential_source() {
86        return Err(ClientError::AuthRequired {
87            required_scopes: configuration.auth.required_scopes.clone(),
88        });
89    }
90    let snapshot = Arc::new(configuration.clone());
91    api_client
92        .api_client
93        .set_limits(Arc::new(snapshot.limits.clone()));
94    api_client.set_configuration(snapshot);
95    Ok(())
96}
97
98#[derive(Error, Debug, ErrorCode)]
99pub enum ClientBuilderError {
100    /// The deployment storage path could not be resolved or opened.
101    /// May be retryable if local storage becomes available.
102    #[error(transparent)]
103    #[error_code("StorageLocation")]
104    StorageLocation(#[from] crate::storage_location::StorageLocationError),
105    /// Attachment storage could not be prepared or cleaned.
106    /// May be retryable if local storage becomes available.
107    #[error(transparent)]
108    #[error_code("Attachment")]
109    Attachment(#[from] crate::attachments::AttachmentClientError),
110    #[error(transparent)]
111    #[error_code(inherit)]
112    AddressValidation(#[from] IdentifierValidationError),
113    /// Missing parameter.
114    ///
115    /// Required builder parameter not provided. Not retryable.
116    #[error("Missing parameter: {parameter}")]
117    MissingParameter { parameter: &'static str },
118    /// Client error.
119    ///
120    /// Client operation failed during build. May be retryable.
121    #[error(transparent)]
122    ClientError(#[from] crate::client::ClientError),
123    /// Storage error.
124    ///
125    /// Storage initialization failed. Not retryable.
126    #[error("Storage Error")]
127    StorageError(#[from] StorageError),
128    /// Identity error.
129    ///
130    /// Identity creation/loading failed. Not retryable.
131    #[error(transparent)]
132    Identity(#[from] crate::identity::IdentityError),
133    /// API error.
134    ///
135    /// API client initialization failed. Retryable.
136    #[error(transparent)]
137    WrappedApiError(#[from] xmtp_api::ApiError),
138    /// Group error.
139    ///
140    /// Group operation failed during build. Not retryable.
141    #[error(transparent)]
142    GroupError(#[from] Box<crate::groups::GroupError>),
143    /// Device sync error.
144    ///
145    /// Device sync setup failed. Not retryable.
146    #[error(transparent)]
147    DeviceSync(#[from] Box<crate::worker::device_sync::DeviceSyncError>),
148    /// Offline build failed.
149    ///
150    /// Builder tried to access the network in offline mode. Not retryable.
151    #[error("Offline build failed, builder tried to access the network")]
152    OfflineBuildFailed,
153}
154
155impl From<crate::worker::device_sync::DeviceSyncError> for ClientBuilderError {
156    fn from(value: crate::worker::device_sync::DeviceSyncError) -> Self {
157        ClientBuilderError::DeviceSync(Box::new(value))
158    }
159}
160
161impl From<crate::groups::GroupError> for ClientBuilderError {
162    fn from(value: crate::groups::GroupError) -> Self {
163        ClientBuilderError::GroupError(Box::new(value))
164    }
165}
166
167pub struct ClientBuilder<ApiClient, S, Db = xmtp_db::DefaultStore> {
168    pub(crate) deployment_recorder: Option<crate::storage_location::DeploymentRecorder>,
169    pub(crate) data_location: Option<crate::storage_location::StorageLocation>,
170    pub(crate) location_store_opener: Option<LocationStoreOpener<Db>>,
171    pub(crate) mls_storage_factory: Option<fn(&Db) -> S>,
172    pub(crate) storage_location_selected: bool,
173    pub(crate) storage_location_conflict: bool,
174    pub(crate) attachments_dir: Option<std::path::PathBuf>,
175    pub(crate) attachment_options: xmtp_attachments::AttachmentOptions,
176    pub(crate) api_client: Option<ApiClient>,
177    pub(crate) identity: Option<Identity>,
178    pub(crate) store: Option<Db>,
179    pub(crate) identity_strategy: IdentityStrategy,
180    pub(crate) scw_verifier: Option<Box<dyn SmartContractSignatureVerifier>>,
181    /// Whether the app supplied its own verifier, exempt from the chain check.
182    pub(crate) custom_scw_verifier: bool,
183    pub(crate) device_sync_worker_mode: DeviceSyncMode,
184    pub(crate) fork_recovery_opts: Option<ForkRecoveryOpts>,
185    /// Unstable: group-change callbacks the host registered at construction.
186    pub(crate) change_callbacks: UnstableChangeCallbacks,
187    pub(crate) stream_policy: crate::subscriptions::policy::StreamPolicy,
188    pub(crate) incoming_factory:
189        Option<Arc<dyn crate::subscriptions::incoming::SubscriptionFactory>>,
190    pub(crate) version_info: VersionInfo,
191    pub(crate) allow_offline: bool,
192    pub(crate) disable_commit_log_worker: bool,
193    pub(crate) mls_storage: Option<S>,
194    pub(crate) disable_workers: bool,
195    pub(crate) worker_config: crate::worker::WorkerConfig,
196    /// A snapshot supplied by the caller. When present the client
197    /// never fetches, stores, refreshes, or checks the identifier. Rust tests
198    /// only; not exposed through the bindings.
199    pub(crate) config_provider: Option<Arc<dyn xmtp_configuration::ConfigProvider>>,
200    #[cfg(any(test, feature = "test-utils"))]
201    application_components_for_test:
202        Option<Vec<xmtp_configuration::ApplicationComponentDefinition>>,
203}
204
205impl Client<()> {
206    /// Get the builder for this [`Client`]
207    pub fn builder(strategy: IdentityStrategy) -> ClientBuilder<(), ()> {
208        ClientBuilder::<(), ()>::new(strategy)
209    }
210}
211
212impl<ApiClient, S, Db> ClientBuilder<ApiClient, S, Db> {
213    /// Override internal limits for controlled tests.
214    #[cfg(any(test, feature = "test-hooks"))]
215    pub fn stream_policy(mut self, settings: crate::subscriptions::policy::StreamPolicy) -> Self {
216        self.stream_policy = settings;
217        self
218    }
219
220    #[tracing::instrument(level = "trace", skip_all)]
221    pub fn new(identity_strategy: IdentityStrategy) -> Self {
222        Self {
223            deployment_recorder: None,
224            data_location: None,
225            location_store_opener: None,
226            mls_storage_factory: None,
227            storage_location_selected: false,
228            storage_location_conflict: false,
229            attachments_dir: None,
230            attachment_options: xmtp_attachments::AttachmentOptions::default(),
231            identity_strategy,
232            api_client: None,
233            identity: None,
234            store: None,
235            scw_verifier: None,
236            custom_scw_verifier: false,
237            device_sync_worker_mode: DeviceSyncMode::Enabled,
238            fork_recovery_opts: None,
239            change_callbacks: UnstableChangeCallbacks::default(),
240            stream_policy: crate::subscriptions::policy::StreamPolicy::default(),
241            incoming_factory: None,
242            version_info: VersionInfo::default(),
243            allow_offline: false,
244            disable_commit_log_worker: false,
245            mls_storage: None,
246            disable_workers: false,
247            worker_config: crate::worker::WorkerConfig::default(),
248            config_provider: None,
249            #[cfg(any(test, feature = "test-utils"))]
250            application_components_for_test: None,
251        }
252    }
253}
254
255#[cfg(any(test, feature = "test-utils"))]
256impl<ApiClient, S, Db> ClientBuilder<ApiClient, S, Db>
257where
258    ApiClient: Clone,
259    Db: Clone,
260    S: Clone,
261{
262    pub fn from_client(
263        client: Client<ContextParts<ApiClient, S, Db>>,
264    ) -> ClientBuilder<ApiClient, S, Db> {
265        let cloned_api: ApiClient = client.context.api_client.api_client.raw_for_test().clone();
266        ClientBuilder {
267            deployment_recorder: None,
268            data_location: None,
269            location_store_opener: None,
270            mls_storage_factory: None,
271            storage_location_selected: false,
272            storage_location_conflict: false,
273            attachments_dir: client.context.features.attachments.dir.clone(),
274            attachment_options: client.context.features.attachments.options.clone(),
275            api_client: Some(cloned_api),
276            identity: Some(client.context.identity.clone()),
277            store: Some(client.context.store.clone()),
278            identity_strategy: IdentityStrategy::CachedOnly,
279            scw_verifier: Some(Box::new(client.context.scw_verifier.clone())),
280            custom_scw_verifier: client.context.server_configuration.custom_verifier(),
281            device_sync_worker_mode: client.context.device_sync.mode,
282            fork_recovery_opts: Some(client.context.fork_recovery_opts.clone()),
283            change_callbacks: client.context.change_callbacks.clone(),
284            stream_policy: client.context.features.incoming.policy().clone(),
285            incoming_factory: client.context.features.incoming.original_factory.clone(),
286            version_info: client.context.version_info.clone(),
287            allow_offline: false,
288            disable_commit_log_worker: false,
289            mls_storage: Some(client.context.mls_storage.clone()),
290            disable_workers: false,
291            worker_config: client.context.worker_config.clone(),
292            config_provider: None,
293            #[cfg(any(test, feature = "test-utils"))]
294            application_components_for_test: None,
295        }
296    }
297}
298
299/// One-time backfill of `ProcessPendingSelfRemove` tasks for groups that already
300/// had pending leave requests before self-removal became event-driven. Such rows
301/// have no incoming LeaveRequest to re-fire, so without this they'd only be
302/// processed if a new one arrived. Best-effort and idempotent (deduped per group).
303///
304/// TODO(#3748): removable once every client has shipped a release that enqueues
305/// these tasks inline — safe to delete by the next-next stable release.
306fn backfill_pending_self_remove_tasks<C>(context: &C) -> Result<(), StorageError>
307where
308    C: XmtpSharedContext,
309{
310    let db = context.db();
311    for raw_id in db.get_groups_have_pending_leave_request()? {
312        let Ok(group_id) = xmtp_proto::types::GroupId::try_from(raw_id.as_slice()) else {
313            continue;
314        };
315        let now = xmtp_common::time::now_ns();
316        let proto = TaskProto {
317            task: Some(TaskKind::ProcessPendingSelfRemove(
318                ProcessPendingSelfRemove {
319                    group_id: group_id.to_vec(),
320                },
321            )),
322        };
323        let task = xmtp_db::tasks::NewTask::builder()
324            .originating_message_sequence_id(0)
325            .created_at_ns(now)
326            .next_attempt_at_ns(now)
327            .build(proto)?;
328        // Insert-if-absent per group: leaves any live retrying task (and its
329        // backoff) untouched, only replacing dead rows. Safe to call on every
330        // startup without resurrecting exhausted tasks.
331        db.upsert_pending_self_remove_task(&group_id, task)?;
332    }
333    Ok(())
334}
335
336// TODO: the return type is temp and
337// will be modified in subsequent PRs
338impl<ApiClient, S, Db> ClientBuilder<ApiClient, S, Db> {
339    /// build a client in offline mode.
340    /// returns an error if the client failed to build as offline
341    pub fn build_offline(self) -> Result<Client<ContextParts<ApiClient, S, Db>>, ClientBuilderError>
342    where
343        ApiClient: XmtpApi + 'static,
344        Db: xmtp_db::XmtpDb + 'static,
345        S: XmtpMlsStorageProvider + 'static,
346    {
347        self.build()
348            .now_or_never()
349            .ok_or(ClientBuilderError::OfflineBuildFailed)
350            .flatten()
351    }
352
353    /// Build the client. When the identity strategy carries an identifier,
354    /// the build requires it to belong to the inbox of the identity it loads,
355    /// including a stored one: it checks the newest association state after
356    /// it loads the inbox's identity updates and before it starts any worker.
357    /// An offline build loads none and checks the newest state the database
358    /// stores, which can predate a revocation. Before the inbox's first
359    /// identity update, only the identifier whose inbox at the strategy's
360    /// nonce is that inbox belongs to it. A failure is
361    /// [`IdentityError::IdentifierNotInInbox`](crate::identity::IdentityError::IdentifierNotInInbox).
362    #[tracing::instrument(err, skip_all, fields(operation = "mls.build_client"))]
363    pub async fn build(self) -> Result<Client<ContextParts<ApiClient, S, Db>>, ClientBuilderError>
364    where
365        ApiClient: XmtpApi + 'static,
366        Db: xmtp_db::XmtpDb + 'static,
367        S: XmtpMlsStorageProvider + 'static,
368    {
369        let ClientBuilder {
370            mut deployment_recorder,
371            data_location,
372            location_store_opener,
373            mls_storage_factory,
374            storage_location_conflict,
375            mut attachments_dir,
376            attachment_options,
377            mut api_client,
378            identity,
379            mut store,
380            mut identity_strategy,
381            mut scw_verifier,
382            custom_scw_verifier,
383
384            device_sync_worker_mode,
385            fork_recovery_opts,
386            change_callbacks,
387            stream_policy,
388            incoming_factory,
389            version_info,
390            allow_offline,
391            disable_commit_log_worker,
392            mut mls_storage,
393            disable_workers,
394            worker_config,
395            config_provider,
396            #[cfg(any(test, feature = "test-utils"))]
397            application_components_for_test,
398            ..
399        } = self;
400
401        if storage_location_conflict {
402            return Err(crate::storage_location::StorageLocationError::ConflictingStore.into());
403        }
404
405        let api_client = api_client
406            .take()
407            .ok_or(ClientBuilderError::MissingParameter {
408                parameter: "api_client",
409            })?;
410
411        let mut scw_verifier = scw_verifier
412            .take()
413            .ok_or(ClientBuilderError::MissingParameter {
414                parameter: "scw_verifier",
415            })?;
416
417        let mut api_client = ApiClientWrapper::new(api_client, Retry::default());
418        let config_provider = config_provider.map(ServerConfigurationHandle::new);
419        let mut data_dir_opened_identifier = None;
420        if let Some(location) = data_location {
421            use crate::storage_location::StorageLocationError;
422            let data_dir = matches!(
423                location,
424                crate::storage_location::StorageLocation::DataDir(_)
425            );
426            // A data directory's path holds the inbox. Only an online build
427            // can look up an unknown one.
428            let look_up_inbox = data_dir && identity_strategy.inbox_id().is_none();
429            if look_up_inbox
430                && (allow_offline
431                    || !matches!(
432                        identity_strategy,
433                        IdentityStrategy::CreateForIdentifier { .. }
434                    ))
435            {
436                return Err(StorageLocationError::InboxId.into());
437            }
438            let backend_url = api_client.backend_url().unwrap_or_default().to_owned();
439            if data_dir && backend_url.trim_end_matches('/').is_empty() {
440                return Err(StorageLocationError::BackendUrl.into());
441            }
442            let recorder = location.recorder(&backend_url);
443            let recorded = match &recorder {
444                Some(recorder) => recorder.lookup().await?,
445                None => None,
446            };
447            let mut fetched = None;
448            // The request that looks up an inbox carries the identifier, and
449            // the database it finds may hold no configuration. So that build
450            // asks the deployment for its configuration first, even for a
451            // recorded URL.
452            let opened_identifier = match recorded {
453                Some(identifier) if !look_up_inbox => Some(identifier),
454                _ if !data_dir => None,
455                recorded => {
456                    if allow_offline {
457                        return Err(StorageLocationError::OfflineMissingDeployment.into());
458                    }
459                    let response = api_client.get_configuration().await.map_err(|error| {
460                        ClientBuilderError::ClientError(ClientError::ConfigurationUnavailable(
461                            Box::new(crate::server_configuration::ConfigurationFetchError::Api(
462                                error,
463                            )),
464                        ))
465                    })?;
466                    let configuration = crate::server_configuration::validated(&response)
467                        .map_err(ClientError::from)?;
468                    let identifier = configuration.identifier.clone();
469                    if let Some(recorded) = recorded
470                        && recorded != identifier
471                    {
472                        return Err(ClientError::BackendMismatch {
473                            stored: recorded,
474                            received: identifier,
475                        }
476                        .into());
477                    }
478                    if look_up_inbox {
479                        let admission = config_provider
480                            .as_ref()
481                            .map(ServerConfigurationHandle::configuration)
482                            .unwrap_or(&configuration);
483                        if admission.identifier != identifier {
484                            return Err(StorageLocationError::DeploymentMismatch.into());
485                        }
486                        admit_configuration(
487                            &mut api_client,
488                            admission,
489                            version_info.pkg_semver().semver(),
490                        )?;
491                    }
492                    fetched = Some((identifier.clone(), response));
493                    Some(identifier)
494                }
495            };
496            // An explicit database is opened first, and its build looks up
497            // an unknown inbox after the configuration checks below.
498            if look_up_inbox {
499                identity_strategy = identity_strategy.with_inbox(&api_client).await?;
500            }
501            let paths = location.resolve_identifier(
502                identity_strategy.inbox_id().unwrap_or_default(),
503                opened_identifier.as_deref().unwrap_or_default(),
504            )?;
505            let opener = location_store_opener.ok_or(StorageLocationError::ConflictingStore)?;
506            let opened = opener(paths.clone()).await?;
507            if let Some((identifier, response)) = fetched {
508                // A stored configuration stays, and the checks below compare
509                // it with this backend.
510                if opened.db().server_configuration()?.is_none() {
511                    opened.db().store_server_configuration(
512                        &identifier,
513                        backend_url.trim_end_matches('/'),
514                        &response.encode_to_vec(),
515                        xmtp_common::time::now_ns(),
516                    )?;
517                }
518                if let Some(recorder) = &recorder {
519                    recorder.record(&identifier).await?;
520                }
521            }
522            store = Some(opened);
523            attachments_dir = Some(paths.attachments_dir);
524            if data_dir {
525                data_dir_opened_identifier = opened_identifier.clone();
526            }
527            deployment_recorder = recorder.map(|recorder| match opened_identifier {
528                Some(identifier) => recorder.for_opened_identifier(identifier),
529                None => recorder,
530            });
531        }
532        let store = store
533            .take()
534            .ok_or(ClientBuilderError::MissingParameter { parameter: "store" })?;
535        let mls_storage = mls_storage
536            .take()
537            .or_else(|| mls_storage_factory.map(|factory| factory(&store)))
538            .ok_or(ClientBuilderError::MissingParameter {
539                parameter: "mls_storage",
540            })?;
541
542        let conn = store.db();
543
544        // The configuration is resolved before any identity
545        // work, so a deployment that refuses this client refuses it before the
546        // database gains an identity. A caller-supplied provider
547        // short-circuits every network and database path here, which is what
548        // keeps `build_offline` free of a pending future.
549        let has_config_provider = config_provider.is_some();
550        let server_configuration = match config_provider {
551            Some(provider) => provider,
552            None => crate::server_configuration::resolve(&api_client, &conn, allow_offline)
553                .await
554                .map_err(ClientError::from)?,
555        };
556
557        #[cfg(any(test, feature = "test-utils"))]
558        let server_configuration = match application_components_for_test {
559            Some(components) => server_configuration.with_application_components(components),
560            None => server_configuration,
561        };
562
563        if has_config_provider && let Some(opened_identifier) = data_dir_opened_identifier {
564            let provider_identifier = &server_configuration.configuration().identifier;
565            let stored_identifier = conn.server_configuration()?.map(|row| row.identifier);
566            if provider_identifier != &opened_identifier
567                || stored_identifier.as_deref().is_some_and(|identifier| {
568                    !identifier.is_empty() && identifier != provider_identifier
569                })
570            {
571                return Err(
572                    crate::storage_location::StorageLocationError::DeploymentMismatch.into(),
573                );
574            }
575        }
576
577        let server_configuration = server_configuration.with_chain_restriction(custom_scw_verifier);
578        if server_configuration.requires_preflight() {
579            api_client.api_client.require_preflight();
580        }
581        if let Some(recorder) = deployment_recorder {
582            if !has_config_provider && !server_configuration.configuration().identifier.is_empty() {
583                recorder
584                    .record(&server_configuration.configuration().identifier)
585                    .await?;
586            }
587            server_configuration.set_deployment_recorder(recorder);
588        }
589
590        admit_configuration(
591            &mut api_client,
592            server_configuration.configuration(),
593            version_info.pkg_semver().semver(),
594        )?;
595        // A shared backend can admit a different snapshot for another client.
596        // Keep this client's receiving limits before its first lazy stream opens.
597        let incoming_factory = incoming_factory
598            .map(|factory| factory.bind_limits(api_client.limits()).unwrap_or(factory));
599        // Clone after admission, so the default verifier sizes its verify
600        // requests by the published limits.
601        if !custom_scw_verifier {
602            scw_verifier = Box::new(api_client.clone());
603        }
604
605        let identifier_check = match &identity_strategy {
606            IdentityStrategy::CreateIfNotFound {
607                identifier, nonce, ..
608            }
609            | IdentityStrategy::CreateForIdentifier { identifier, nonce } => {
610                Some((identifier.clone(), *nonce))
611            }
612            _ => None,
613        };
614        let mut identity = if let Some(identity) = identity {
615            identity
616        } else {
617            identity_strategy
618                .initialize_identity(&api_client, &mls_storage, &scw_verifier)
619                .await?
620        };
621
622        // The registration request is handed to the app to
623        // sign, so bind it to the chains the deployment accepts before it
624        // leaves the client.
625        if let Some(request) = identity.signature_request.as_mut() {
626            server_configuration.restrict(request);
627        }
628
629        debug!(
630            inbox_id = identity.inbox_id(),
631            installation_id = identity.installation_keys.public_bytes().snippet(),
632            "Initialized identity"
633        );
634        if !allow_offline {
635            // get sequence_id from identity updates and loaded into the DB
636            load_identity_updates(
637                &api_client,
638                &conn,
639                vec![identity.inbox_id.as_str()].as_slice(),
640            )
641            .await
642            .map_err(ClientError::from)?;
643        }
644        // The identity store trusts a stored identity of the same inbox. The
645        // check runs on the identity updates loaded above and before any
646        // worker, so a rejected identifier makes no request as the stored
647        // installation. Offline, it reads only the database.
648        // implements: IDENT-096
649        if let Some((identifier, nonce)) = identifier_check {
650            let inbox_id = identity.inbox_id.as_str();
651            let membership = if allow_offline {
652                crate::identity_updates::stored_identifier_membership(&conn, inbox_id, &identifier)
653                    .await
654                    .map_err(ClientError::from)?
655            } else {
656                crate::identity_updates::identifier_membership(
657                    &conn,
658                    inbox_id,
659                    &identifier,
660                    &scw_verifier,
661                )
662                .await
663                .map_err(ClientError::from)?
664            };
665            if !crate::identity_updates::identifier_opens_inbox(
666                membership,
667                &identifier,
668                nonce,
669                &identity.inbox_id,
670            ) {
671                return Err(crate::identity::IdentityError::IdentifierNotInInbox {
672                    inbox_id: identity.inbox_id.clone(),
673                }
674                .into());
675            }
676        }
677
678        // Own-installation events start after the own-inbox updates stored
679        // now, including any the load above stored.
680        let own_inbox_stored_through = conn
681            .get_latest_sequence_id(&[identity.inbox_id.as_str()])
682            .map_err(StorageError::from)?
683            .get(identity.inbox_id.as_str())
684            .copied();
685
686        // Fold the legacy single-worker toggles into the unified enable map so
687        // there is one source of truth for "is worker X enabled" that code paths
688        // when it registers worker subscriptions. The old fields keep working;
689        // they just write here.
690        let mut worker_config = worker_config;
691        if disable_workers {
692            // Disable optional workers. The HMAC epoch observer still runs for
693            // every open client.
694            for kind in [
695                crate::worker::WorkerKind::DeviceSync,
696                crate::worker::WorkerKind::DisappearingMessages,
697                crate::worker::WorkerKind::CommitLog,
698                crate::worker::WorkerKind::TaskRunner,
699                crate::worker::WorkerKind::ConfigurationRefresh,
700                crate::worker::WorkerKind::AttachmentCleanup,
701            ] {
702                worker_config.enabled.insert(kind, false);
703            }
704        }
705        if matches!(device_sync_worker_mode, DeviceSyncMode::Disabled) {
706            worker_config
707                .enabled
708                .entry(crate::worker::WorkerKind::DeviceSync)
709                .or_insert(false);
710        }
711        if disable_commit_log_worker {
712            worker_config
713                .enabled
714                .entry(crate::worker::WorkerKind::CommitLog)
715                .or_insert(false);
716        }
717
718        let events = xmtp_events::EventBus::new();
719        let events_for_lifetime = events.clone();
720        let public_event_writer: Arc<dyn xmtp_events::EventWriter<()>> =
721            Arc::new(xmtp_events::PublicBusWriter::new(&events));
722        server_configuration.set_event_writer(public_event_writer.clone());
723        api_client
724            .api_client
725            .register_client_event_writer(&public_event_writer);
726        let mut workers = WorkerRunner::new();
727        let incoming_runtime = crate::subscriptions::incoming::IncomingRuntime::new(
728            stream_policy.clone(),
729            incoming_factory,
730        )
731        .with_preflight(api_client.api_client.clone());
732        let attachments =
733            Arc::new(AttachmentRuntime::new(attachments_dir, attachment_options).await?);
734        let context = Arc::new(XmtpMlsLocalContext {
735            identity,
736            mls_storage,
737            store,
738            api_client,
739            version_info,
740            server_configuration,
741            scw_verifier: Arc::new(scw_verifier),
742            mutexes: MutexRegistry::new(),
743            hooks: Default::default(),
744            events,
745            public_event_writer,
746            registration_event_pending: Arc::new(AtomicBool::new(false)),
747            device_sync: DeviceSync {
748                mode: device_sync_worker_mode,
749            },
750            fork_recovery_opts: fork_recovery_opts.unwrap_or_default(),
751            change_callbacks,
752            stream_policy,
753            worker_config,
754
755            worker_metrics: workers.sync_metrics_slot().clone(),
756            task_channels: workers.task_channels().clone(),
757            cancellation_token: CancellationToken::new(),
758            shutdown_complete: Arc::new(AtomicBool::new(false)),
759            foreground_calls: Default::default(),
760            delivery_owner: Default::default(),
761            retired_delivery_owner: Default::default(),
762            features: crate::features::MlsFeatures {
763                attachments,
764                incoming: Arc::new(incoming_runtime),
765                identity_resolutions: Arc::new(
766                    crate::identity_updates::IdentityResolutionRegistry::starting_after(
767                        own_inbox_stored_through,
768                    ),
769                ),
770            },
771        });
772
773        crate::core::preflight::bind_preflight(&context);
774
775        // register workers
776        if let Err(error) = context.features.attachments.sweep(&context).await {
777            tracing::warn!(%error, "attachment cleanup failed during client build");
778        }
779        if let Err(error) = context
780            .features
781            .attachments
782            .ensure_reconciled(&context)
783            .await
784        {
785            tracing::warn!(%error, "attachment reconciliation failed during client build");
786        }
787        if !disable_workers {
788            use crate::worker::WorkerKind;
789            // One source of truth for enablement: the folded WorkerConfig map.
790            let enabled = |k| context.worker_config().worker_enabled(k);
791
792            // Keep the original `device_sync_worker_enabled()` AND-check to
793            // preserve the exact legacy semantics alongside the map gate.
794            if enabled(WorkerKind::DeviceSync) && context.device_sync_worker_enabled() {
795                workers.register_new_worker::<SyncWorker<ContextParts<ApiClient, S, Db>>, _>(
796                    context.clone(),
797                );
798            }
799            if enabled(WorkerKind::DisappearingMessages) {
800                workers
801                    .register_new_worker::<DisappearingMessagesWorker<ContextParts<ApiClient, S, Db>>, _>(
802                        context.clone(),
803                    );
804            }
805            // Enable CommitLogWorker based on configuration
806            // The deployment decides whether the commit log runs,
807            // falling back to the compiled default when it says nothing.
808            if enabled(WorkerKind::CommitLog)
809                && context
810                    .server_configuration()
811                    .configuration()
812                    .mls
813                    .commit_log_enabled()
814                && !disable_commit_log_worker
815            {
816                workers.register_new_worker::<
817                crate::groups::commit_log::CommitLogWorker<ContextParts<ApiClient, S, Db>>,
818                _,
819                >(context.clone());
820            }
821            // Only a client that reads its configuration refreshes it.
822            // A caller-supplied provider owns its own values.
823            if enabled(WorkerKind::ConfigurationRefresh) && !has_config_provider {
824                workers.register_new_worker::<crate::core::config_refresh::ConfigurationWorker<
825                    ContextParts<ApiClient, S, Db>,
826                >, _>(context.clone());
827            }
828            if enabled(WorkerKind::TaskRunner) {
829                workers.register_new_worker::<TaskWorker<
830                    ContextParts<ApiClient, S, Db>,
831                    crate::task_handlers::MlsTaskHandlers,
832                >, _>(context.clone());
833                // KP maintenance is deliberately coupled to the TaskRunner (no
834                // standalone fallback): disabling the TaskRunner — per-kind via
835                // WorkerConfig or globally via disable_workers — disables KP
836                // rotation/deletion with it. Seeding failure is fatal to the
837                // build: the DB was already opened/migrated above, so an error
838                // here means it is broken; building a client whose critical
839                // maintenance silently never got seeded would be worse.
840                crate::worker::key_package_maintenance::seed_and_reconcile_kp_tasks(&context)?;
841                // One-time backfill: pending self-removes recorded before the
842                // worker became event-driven have no LeaveRequest to re-fire, so
843                // seed a ProcessPendingSelfRemove task for each already-flagged
844                // group. Best-effort (logged, never fails the build).
845                //
846                // TODO: remove this migration once all clients have shipped a
847                // release that enqueues these tasks inline — safe to delete by the
848                // next-next stable release.
849                if let Err(e) = backfill_pending_self_remove_tasks(&context) {
850                    tracing::warn!(
851                        "pending-self-remove backfill failed (will rely on next sync): {e}"
852                    );
853                }
854            }
855            if enabled(WorkerKind::AttachmentCleanup)
856                && context.features.attachments.store.is_some()
857            {
858                workers.register_new_worker::<crate::attachments::cleanup::AttachmentCleanup<
859                    ContextParts<ApiClient, S, Db>,
860                >, _>(context.clone());
861            }
862        }
863
864        // Every open client observes HMAC epoch changes, including clients
865        // that disable the other background workers.
866        workers.register_new_worker::<
867            crate::worker::hmac_epoch::HmacEpochWorker<ContextParts<ApiClient, S, Db>>,
868            _,
869        >(context.clone());
870
871        let workers = Arc::new(workers);
872
873        workers.spawn(context.clone());
874
875        log_event!(
876            Event::ClientCreated,
877            context.installation_id(),
878            inbox_id = context.inbox_id(),
879            device_sync_enabled = context.device_sync_worker_enabled(),
880            disabled_workers = disable_workers,
881        );
882
883        let installation_id = context.installation_id();
884        let client = Client {
885            context,
886            installation_id,
887            workers,
888            app_lifetime: Arc::new(crate::client::AppLifetime {
889                events: events_for_lifetime,
890            }),
891        };
892
893        Ok(client)
894    }
895
896    pub fn identity(self, identity: Identity) -> Self {
897        Self {
898            identity: Some(identity),
899            ..self
900        }
901    }
902
903    pub fn attachments_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
904        self.storage_location_conflict |= self.storage_location_selected;
905        self.attachments_dir = Some(path.into());
906        self
907    }
908
909    pub fn attachment_options(mut self, options: xmtp_attachments::AttachmentOptions) -> Self {
910        self.attachment_options = options;
911        self
912    }
913
914    /// Save a deployment-scoped location. `build` resolves it after all options are set.
915    ///
916    /// Native only. Browser builds have no encrypted storage; call `data_location_with`
917    /// and open the store with `WasmDb::new_strict`.
918    #[cfg(not(target_arch = "wasm32"))]
919    pub async fn data_location(
920        self,
921        location: crate::storage_location::StorageLocation,
922        key: xmtp_db::EncryptionKey,
923    ) -> Result<ClientBuilder<ApiClient, S, xmtp_db::DefaultStore>, ClientBuilderError> {
924        self.data_location_with(location, move |paths| open_location_store(paths, key))
925    }
926
927    /// Save a deployment-scoped location that `opener` opens. `build` resolves
928    /// the paths after all options are set and passes them to `opener`, so a
929    /// host can open the store with its own connection options.
930    pub fn data_location_with<NewDb>(
931        self,
932        location: crate::storage_location::StorageLocation,
933        opener: impl FnOnce(
934            crate::storage_location::ResolvedPaths,
935        )
936            -> xmtp_common::BoxDynFuture<'static, Result<NewDb, ClientBuilderError>>
937        + xmtp_common::MaybeSend
938        + xmtp_common::MaybeSync
939        + 'static,
940    ) -> Result<ClientBuilder<ApiClient, S, NewDb>, ClientBuilderError> {
941        location.validate()?;
942        let conflict = self.storage_location_conflict
943            || self.store.is_some()
944            || self.mls_storage.is_some()
945            || self.mls_storage_factory.is_some()
946            || self.storage_location_selected
947            || self.attachments_dir.is_some();
948        Ok(ClientBuilder {
949            deployment_recorder: None,
950            data_location: Some(location),
951            location_store_opener: Some(Box::new(opener)),
952            mls_storage_factory: None,
953            storage_location_selected: true,
954            storage_location_conflict: conflict,
955            attachments_dir: self.attachments_dir,
956            attachment_options: self.attachment_options,
957            api_client: self.api_client,
958            identity: self.identity,
959            store: None,
960            identity_strategy: self.identity_strategy,
961            scw_verifier: self.scw_verifier,
962            custom_scw_verifier: self.custom_scw_verifier,
963            device_sync_worker_mode: self.device_sync_worker_mode,
964            fork_recovery_opts: self.fork_recovery_opts,
965            change_callbacks: self.change_callbacks,
966            stream_policy: self.stream_policy,
967            incoming_factory: self.incoming_factory,
968            version_info: self.version_info,
969            allow_offline: self.allow_offline,
970            disable_commit_log_worker: self.disable_commit_log_worker,
971            mls_storage: self.mls_storage,
972            disable_workers: self.disable_workers,
973            worker_config: self.worker_config,
974            config_provider: self.config_provider,
975            #[cfg(any(test, feature = "test-utils"))]
976            application_components_for_test: self.application_components_for_test,
977        })
978    }
979
980    /// Unstable: register callbacks notified when group state changes.
981    ///
982    /// Registration is construction-time by necessity — the changes these
983    /// report arrive from the stream and sync paths, where no SDK call is on
984    /// the stack. Passing [`UnstableChangeCallbacks::default`] (nothing set)
985    /// is equivalent to not calling this at all.
986    ///
987    /// See [`crate::groups::change_callbacks`] for the delivery contract.
988    pub fn unstable_change_callbacks(self, change_callbacks: UnstableChangeCallbacks) -> Self {
989        Self {
990            change_callbacks,
991            ..self
992        }
993    }
994
995    pub fn store<NewDb>(self, db: NewDb) -> ClientBuilder<ApiClient, S, NewDb> {
996        ClientBuilder {
997            deployment_recorder: self.deployment_recorder,
998            data_location: self.data_location,
999            location_store_opener: None,
1000            mls_storage_factory: None,
1001            storage_location_selected: self.storage_location_selected,
1002            storage_location_conflict: self.storage_location_conflict
1003                || self.storage_location_selected,
1004            store: Some(db),
1005            api_client: self.api_client,
1006            identity: self.identity,
1007            identity_strategy: self.identity_strategy,
1008            scw_verifier: self.scw_verifier,
1009            custom_scw_verifier: self.custom_scw_verifier,
1010            device_sync_worker_mode: self.device_sync_worker_mode,
1011            fork_recovery_opts: self.fork_recovery_opts,
1012            change_callbacks: self.change_callbacks,
1013            stream_policy: self.stream_policy,
1014            incoming_factory: self.incoming_factory,
1015            version_info: self.version_info,
1016            allow_offline: self.allow_offline,
1017            disable_commit_log_worker: self.disable_commit_log_worker,
1018            mls_storage: self.mls_storage,
1019            disable_workers: self.disable_workers,
1020            worker_config: self.worker_config,
1021            config_provider: self.config_provider,
1022            #[cfg(any(test, feature = "test-utils"))]
1023            application_components_for_test: self.application_components_for_test,
1024            attachments_dir: self.attachments_dir,
1025            attachment_options: self.attachment_options,
1026        }
1027    }
1028
1029    /// Use the default SQlite MLS Key-Value Store
1030    pub fn default_mls_store(
1031        self,
1032    ) -> Result<
1033        ClientBuilder<ApiClient, SqlKeyStore<<Db as XmtpDb>::DbQuery>, Db>,
1034        ClientBuilderError,
1035    >
1036    where
1037        Db: XmtpDb,
1038    {
1039        Ok(ClientBuilder {
1040            deployment_recorder: self.deployment_recorder,
1041            data_location: self.data_location,
1042            location_store_opener: self.location_store_opener,
1043            mls_storage_factory: Some(|store: &Db| SqlKeyStore::new(store.db())),
1044            storage_location_selected: self.storage_location_selected,
1045            storage_location_conflict: self.storage_location_conflict,
1046            api_client: self.api_client,
1047            identity: self.identity,
1048            identity_strategy: self.identity_strategy,
1049            scw_verifier: self.scw_verifier,
1050            custom_scw_verifier: self.custom_scw_verifier,
1051            device_sync_worker_mode: self.device_sync_worker_mode,
1052            fork_recovery_opts: self.fork_recovery_opts,
1053            change_callbacks: self.change_callbacks,
1054            stream_policy: self.stream_policy,
1055            incoming_factory: self.incoming_factory,
1056            version_info: self.version_info,
1057            allow_offline: self.allow_offline,
1058            disable_commit_log_worker: self.disable_commit_log_worker,
1059            mls_storage: self
1060                .store
1061                .as_ref()
1062                .map(|store| SqlKeyStore::new(store.db())),
1063            store: self.store,
1064            disable_workers: self.disable_workers,
1065            worker_config: self.worker_config,
1066            config_provider: self.config_provider,
1067            #[cfg(any(test, feature = "test-utils"))]
1068            application_components_for_test: self.application_components_for_test,
1069            attachments_dir: self.attachments_dir,
1070            attachment_options: self.attachment_options,
1071        })
1072    }
1073
1074    pub fn mls_storage<NewS>(self, mls_storage: NewS) -> ClientBuilder<ApiClient, NewS, Db> {
1075        let storage_location_conflict =
1076            self.storage_location_conflict || self.storage_location_selected;
1077        ClientBuilder {
1078            deployment_recorder: self.deployment_recorder,
1079            data_location: self.data_location,
1080            location_store_opener: self.location_store_opener,
1081            mls_storage_factory: None,
1082            storage_location_selected: self.storage_location_selected,
1083            storage_location_conflict,
1084            store: self.store,
1085            api_client: self.api_client,
1086            identity: self.identity,
1087            identity_strategy: self.identity_strategy,
1088            scw_verifier: self.scw_verifier,
1089            custom_scw_verifier: self.custom_scw_verifier,
1090            device_sync_worker_mode: self.device_sync_worker_mode,
1091            fork_recovery_opts: self.fork_recovery_opts,
1092            change_callbacks: self.change_callbacks,
1093            stream_policy: self.stream_policy,
1094            incoming_factory: self.incoming_factory,
1095            version_info: self.version_info,
1096            allow_offline: self.allow_offline,
1097            disable_commit_log_worker: self.disable_commit_log_worker,
1098            mls_storage: Some(mls_storage),
1099            disable_workers: self.disable_workers,
1100            worker_config: self.worker_config,
1101            config_provider: self.config_provider,
1102            #[cfg(any(test, feature = "test-utils"))]
1103            application_components_for_test: self.application_components_for_test,
1104            attachments_dir: self.attachments_dir,
1105            attachment_options: self.attachment_options,
1106        }
1107    }
1108
1109    pub fn with_disable_workers(mut self, disable_workers: bool) -> Self {
1110        self.disable_workers = disable_workers;
1111        self
1112    }
1113
1114    pub fn with_device_sync_worker_mode(self, mode: Option<DeviceSyncMode>) -> Self {
1115        Self {
1116            device_sync_worker_mode: mode.unwrap_or(DeviceSyncMode::Enabled),
1117            ..self
1118        }
1119    }
1120
1121    pub fn device_sync_worker_mode(self, mode: DeviceSyncMode) -> Self {
1122        Self {
1123            device_sync_worker_mode: mode,
1124            ..self
1125        }
1126    }
1127
1128    pub fn fork_recovery_opts(self, opts: ForkRecoveryOpts) -> Self {
1129        Self {
1130            fork_recovery_opts: Some(opts),
1131            ..self
1132        }
1133    }
1134
1135    /// Configure background-worker intervals, jitter, and per-worker
1136    /// enablement. See [`crate::worker::WorkerConfig`].
1137    pub fn worker_config(mut self, cfg: crate::worker::WorkerConfig) -> Self {
1138        self.worker_config = cfg;
1139        self
1140    }
1141
1142    /// Replace only the application catalogue after normal configuration admission.
1143    /// Used by SDK metadata test fixtures.
1144    #[cfg(any(test, feature = "test-utils"))]
1145    pub fn application_components_for_test(
1146        mut self,
1147        components: Vec<xmtp_configuration::ApplicationComponentDefinition>,
1148    ) -> Self {
1149        self.application_components_for_test = Some(components);
1150        self
1151    }
1152
1153    /// Supply the server configuration instead of reading it.
1154    ///
1155    /// With a provider in place the client does not fetch, store, or refresh
1156    /// the configuration. A data directory still checks its deployment identifier.
1157    /// Rust callers only; the bindings do not expose this.
1158    pub fn config_provider(
1159        mut self,
1160        provider: Arc<dyn xmtp_configuration::ConfigProvider>,
1161    ) -> Self {
1162        self.config_provider = Some(provider);
1163        self
1164    }
1165
1166    /// Attach a query-only API client. Receipt uses ordered Query pages.
1167    /// Standard streaming clients use `api_client_with_streams` at construction.
1168    pub fn api_client<A>(self, api_client: A) -> ClientBuilder<A, S, Db> {
1169        ClientBuilder {
1170            deployment_recorder: self.deployment_recorder,
1171            data_location: self.data_location,
1172            location_store_opener: self.location_store_opener,
1173            mls_storage_factory: self.mls_storage_factory,
1174            storage_location_selected: self.storage_location_selected,
1175            storage_location_conflict: self.storage_location_conflict,
1176            api_client: Some(api_client),
1177            identity: self.identity,
1178            identity_strategy: self.identity_strategy,
1179            scw_verifier: self.scw_verifier,
1180            custom_scw_verifier: self.custom_scw_verifier,
1181            store: self.store,
1182            device_sync_worker_mode: self.device_sync_worker_mode,
1183            fork_recovery_opts: self.fork_recovery_opts,
1184            change_callbacks: self.change_callbacks,
1185            stream_policy: self.stream_policy,
1186            incoming_factory: None,
1187            version_info: self.version_info,
1188            allow_offline: self.allow_offline,
1189            disable_commit_log_worker: self.disable_commit_log_worker,
1190            mls_storage: self.mls_storage,
1191            disable_workers: self.disable_workers,
1192            worker_config: self.worker_config,
1193            config_provider: self.config_provider,
1194            #[cfg(any(test, feature = "test-utils"))]
1195            application_components_for_test: self.application_components_for_test,
1196            attachments_dir: self.attachments_dir,
1197            attachment_options: self.attachment_options,
1198        }
1199    }
1200
1201    xmtp_common::if_native! {
1202        /// Attach a native API client with a lazy shared Bidi receiver.
1203        /// No transport task or connection starts until the first receiving interest.
1204        pub fn api_client_with_streams<A>(self, api_client: A) -> ClientBuilder<A, S, Db>
1205        where
1206            A: xmtp_proto::api_client::XmtpMlsBidiStreams
1207                + crate::subscriptions::router_callbacks::ApiClientIdentity
1208                + Clone
1209                + Send
1210                + Sync
1211                + 'static,
1212            A::SubscribeStream: 'static,
1213        {
1214            let factory = crate::subscriptions::incoming::streaming_factory(api_client.clone());
1215            let mut builder = self.api_client(api_client);
1216            builder.incoming_factory = Some(factory);
1217            builder
1218        }
1219    }
1220
1221    xmtp_common::if_wasm! {
1222        /// Attach a browser API client with a lazy static-stream receiver.
1223        /// The factory owns the API client, not the client context.
1224        pub fn api_client_with_streams<A>(self, api_client: A) -> ClientBuilder<A, S, Db>
1225        where
1226            A: xmtp_proto::api_client::XmtpMlsStreams + Clone + 'static,
1227        {
1228            let factory = crate::subscriptions::incoming::streaming_factory(api_client.clone());
1229            let mut builder = self.api_client(api_client);
1230            builder.incoming_factory = Some(factory);
1231            builder
1232        }
1233    }
1234
1235    pub fn maybe_version(
1236        mut self,
1237        version: Option<VersionInfo>,
1238    ) -> ClientBuilder<ApiClient, S, Db> {
1239        if let Some(v) = version {
1240            self.version_info = v;
1241        }
1242        self
1243    }
1244
1245    pub fn version(self, version_info: VersionInfo) -> ClientBuilder<ApiClient, S, Db> {
1246        Self {
1247            version_info,
1248            ..self
1249        }
1250    }
1251
1252    /// Skip network calls when building a client. An offline build loads no
1253    /// identity update, so its identifier check (see [`Self::build`]) uses
1254    /// the newest state the database stores, which can predate a revocation.
1255    pub fn with_allow_offline(
1256        self,
1257        allow_offline: Option<bool>,
1258    ) -> ClientBuilder<ApiClient, S, Db> {
1259        Self {
1260            allow_offline: allow_offline.unwrap_or(false),
1261            ..self
1262        }
1263    }
1264
1265    /// Control whether the CommitLogWorker background task is enabled.
1266    /// Useful for tests that need deterministic commit log operations.
1267    #[cfg(any(test, feature = "test-utils"))]
1268    pub fn with_commit_log_worker(self, enabled: bool) -> Self {
1269        Self {
1270            disable_commit_log_worker: !enabled,
1271            ..self
1272        }
1273    }
1274
1275    #[cfg(any(test, feature = "test-utils"))]
1276    pub fn enable_sqlite_triggers(self) -> Self
1277    where
1278        Db: XmtpDb,
1279    {
1280        let db = self.store.as_ref().expect("unwrapping in test env").conn();
1281        let db = xmtp_db::DbConnection::new(db);
1282        db.register_triggers();
1283        db.disable_memory_security();
1284        self
1285    }
1286
1287    pub fn enable_api_stats(
1288        self,
1289    ) -> Result<ClientBuilder<TrackedStatsClient<ApiClient>, S, Db>, ClientBuilderError> {
1290        if self.api_client.is_none() {
1291            return Err(ClientBuilderError::MissingParameter {
1292                parameter: "api_client",
1293            });
1294        }
1295
1296        Ok(ClientBuilder {
1297            deployment_recorder: self.deployment_recorder,
1298            data_location: self.data_location,
1299            location_store_opener: self.location_store_opener,
1300            mls_storage_factory: self.mls_storage_factory,
1301            storage_location_selected: self.storage_location_selected,
1302            storage_location_conflict: self.storage_location_conflict,
1303            api_client: Some(TrackedStatsClient::new(
1304                self.api_client.expect("checked for none"),
1305            )),
1306            identity: self.identity,
1307            identity_strategy: self.identity_strategy,
1308            scw_verifier: self.scw_verifier,
1309            custom_scw_verifier: self.custom_scw_verifier,
1310            store: self.store,
1311
1312            device_sync_worker_mode: self.device_sync_worker_mode,
1313            fork_recovery_opts: self.fork_recovery_opts,
1314            change_callbacks: self.change_callbacks,
1315            stream_policy: self.stream_policy,
1316            incoming_factory: self.incoming_factory,
1317            version_info: self.version_info,
1318            allow_offline: self.allow_offline,
1319            disable_commit_log_worker: self.disable_commit_log_worker,
1320            mls_storage: self.mls_storage,
1321            disable_workers: self.disable_workers,
1322            worker_config: self.worker_config,
1323            config_provider: self.config_provider,
1324            #[cfg(any(test, feature = "test-utils"))]
1325            application_components_for_test: self.application_components_for_test,
1326            attachments_dir: self.attachments_dir,
1327            attachment_options: self.attachment_options,
1328        })
1329    }
1330
1331    pub fn with_scw_verifier(
1332        self,
1333        verifier: impl SmartContractSignatureVerifier + 'static,
1334    ) -> ClientBuilder<ApiClient, S, Db> {
1335        ClientBuilder {
1336            deployment_recorder: self.deployment_recorder,
1337            data_location: self.data_location,
1338            location_store_opener: self.location_store_opener,
1339            mls_storage_factory: self.mls_storage_factory,
1340            storage_location_selected: self.storage_location_selected,
1341            storage_location_conflict: self.storage_location_conflict,
1342            api_client: self.api_client,
1343            identity: self.identity,
1344            identity_strategy: self.identity_strategy,
1345            scw_verifier: Some(Box::new(verifier)),
1346            custom_scw_verifier: true,
1347            store: self.store,
1348
1349            device_sync_worker_mode: self.device_sync_worker_mode,
1350            fork_recovery_opts: self.fork_recovery_opts,
1351            change_callbacks: self.change_callbacks,
1352            stream_policy: self.stream_policy,
1353            incoming_factory: self.incoming_factory,
1354            version_info: self.version_info,
1355            allow_offline: self.allow_offline,
1356            disable_commit_log_worker: self.disable_commit_log_worker,
1357            mls_storage: self.mls_storage,
1358            disable_workers: self.disable_workers,
1359            worker_config: self.worker_config,
1360            config_provider: self.config_provider,
1361            #[cfg(any(test, feature = "test-utils"))]
1362            application_components_for_test: self.application_components_for_test,
1363            attachments_dir: self.attachments_dir,
1364            attachment_options: self.attachment_options,
1365        }
1366    }
1367
1368    /// Build the client with a default remote verifier
1369    /// requires the 'api' to be set.
1370    pub fn with_remote_verifier(self) -> Result<ClientBuilder<ApiClient, S, Db>, ClientBuilderError>
1371    where
1372        ApiClient: Clone + XmtpApi + 'static,
1373    {
1374        let api = self
1375            .api_client
1376            .clone()
1377            .ok_or(ClientBuilderError::MissingParameter {
1378                parameter: "api_client",
1379            })?;
1380
1381        Ok(ClientBuilder {
1382            deployment_recorder: self.deployment_recorder,
1383            data_location: self.data_location,
1384            location_store_opener: self.location_store_opener,
1385            mls_storage_factory: self.mls_storage_factory,
1386            storage_location_selected: self.storage_location_selected,
1387            storage_location_conflict: self.storage_location_conflict,
1388            api_client: self.api_client,
1389            identity: self.identity,
1390            identity_strategy: self.identity_strategy,
1391            scw_verifier: Some(Box::new(ApiClientWrapper::new(api, Retry::default()))
1392                as Box<dyn SmartContractSignatureVerifier>),
1393            // The default verifier replaces the caller's verifier, so the
1394            // exemption from the chain restriction ends here.
1395            custom_scw_verifier: false,
1396            store: self.store,
1397            device_sync_worker_mode: self.device_sync_worker_mode,
1398            fork_recovery_opts: self.fork_recovery_opts,
1399            change_callbacks: self.change_callbacks,
1400            stream_policy: self.stream_policy,
1401            incoming_factory: self.incoming_factory,
1402            version_info: self.version_info,
1403            allow_offline: self.allow_offline,
1404            disable_commit_log_worker: self.disable_commit_log_worker,
1405            mls_storage: self.mls_storage,
1406            disable_workers: self.disable_workers,
1407            worker_config: self.worker_config,
1408            config_provider: self.config_provider,
1409            #[cfg(any(test, feature = "test-utils"))]
1410            application_components_for_test: self.application_components_for_test,
1411            attachments_dir: self.attachments_dir,
1412            attachment_options: self.attachment_options,
1413        })
1414    }
1415}
1416
1417#[cfg(test)]
1418mod worker_registration_tests {
1419    use crate::tester;
1420    use crate::worker::{WorkerConfig, WorkerKind};
1421
1422    #[xmtp_common::test(unwrap_try = true)]
1423    #[cfg_attr(target_arch = "wasm32", ignore)]
1424    async fn disabled_worker_is_not_registered() {
1425        let mut cfg = WorkerConfig::default();
1426        cfg.enabled.insert(WorkerKind::DisappearingMessages, false);
1427        tester!(alix, worker_config: cfg);
1428
1429        let kinds = alix.client.workers.registered_kinds();
1430        assert!(
1431            !kinds.contains(&WorkerKind::DisappearingMessages),
1432            "disabled worker must not be registered, got {kinds:?}"
1433        );
1434        assert!(
1435            kinds.contains(&WorkerKind::TaskRunner),
1436            "un-disabled worker must still be registered, got {kinds:?}"
1437        );
1438        let subscriptions = alix.client.workers.subscribed_kinds();
1439        assert!(!subscriptions.contains(&WorkerKind::DisappearingMessages));
1440        assert!(subscriptions.contains(&WorkerKind::TaskRunner));
1441    }
1442
1443    #[xmtp_common::test(unwrap_try = true)]
1444    #[cfg_attr(target_arch = "wasm32", ignore)]
1445    async fn disabled_optional_workers_keep_the_epoch_observer() {
1446        tester!(alix, disable_workers);
1447        assert_eq!(
1448            alix.client.workers.registered_kinds(),
1449            vec![WorkerKind::HmacEpoch]
1450        );
1451        assert!(alix.client.workers.is_running());
1452    }
1453}