Skip to main content

xmtp_mls/
server_configuration.rs

1//! Resolving, storing, and refreshing what the backend publishes about itself.
2//!
3//! Spec 006 §6. The client holds one immutable snapshot for its life (CFG-030)
4//! and reads every value in §6.4 through the provider, never the database. This
5//! module owns the three places the database is touched: the resolve that runs
6//! once inside `build`, the refresh worker, and the explicit refresh the SDKs
7//! expose.
8
9use std::sync::Arc;
10
11use parking_lot::RwLock;
12use prost::Message;
13use xmtp_api::{ApiClientWrapper, ApiError};
14use xmtp_configuration::{
15    ConfigProvider, ServerConfiguration, ServerConfigurationError, StoredConfigProvider,
16};
17use xmtp_db::prelude::*;
18use xmtp_db::{StorageError, server_configuration::StoredServerConfiguration};
19use xmtp_proto::api_client::XmtpBackendClient;
20use xmtp_proto::backend_v1;
21
22use crate::client::ClientError;
23
24/// Why a configuration read could not produce a usable copy.
25///
26/// Carried by [`ClientError::ConfigurationUnavailable`] so a caller can tell a
27/// backend that refused from a database that would not accept the answer.
28#[derive(Debug, thiserror::Error)]
29pub enum ConfigurationFetchError {
30    /// The backend did not answer, or answered with an error. A backend with
31    /// no `ConfigurationService` answers `UNIMPLEMENTED`; there is no shim.
32    #[error("the backend did not serve its configuration: {0}")]
33    Api(#[from] ApiError),
34    /// The answer arrived but could not be stored.
35    #[error("the fetched configuration could not be stored: {0}")]
36    Storage(#[from] StorageError),
37}
38
39impl xmtp_common::RetryableError for ConfigurationFetchError {
40    fn is_retryable(&self) -> bool {
41        match self {
42            Self::Api(e) => e.is_retryable(),
43            Self::Storage(e) => e.is_retryable(),
44        }
45    }
46}
47
48/// A condition that, once observed, fails every later call for the life of the
49/// client (CFG-051, CFG-054, CFG-061).
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub enum ConfigurationLatch {
52    /// This database is bound to one deployment and another one answered.
53    BackendMismatch { stored: String, received: String },
54    /// The deployment now requires a newer client than this build.
55    ClientVersionTooOld { client: String, minimum: String },
56}
57
58impl From<&ConfigurationLatch> for ClientError {
59    fn from(latch: &ConfigurationLatch) -> Self {
60        match latch {
61            ConfigurationLatch::BackendMismatch { stored, received } => {
62                ClientError::BackendMismatch {
63                    stored: stored.clone(),
64                    received: received.clone(),
65                }
66            }
67            ConfigurationLatch::ClientVersionTooOld { client, minimum } => {
68                ClientError::ClientVersionTooOld {
69                    client: client.clone(),
70                    minimum: minimum.clone(),
71                }
72            }
73        }
74    }
75}
76
77/// The snapshot the client was built with, plus the latch a refresh may set.
78///
79/// Cloning shares both: every consumer sees the same latch the moment a
80/// refresh trips it.
81#[derive(Clone)]
82pub struct ServerConfigurationHandle {
83    provider: Arc<dyn ConfigProvider>,
84    latch: Arc<RwLock<Option<ConfigurationLatch>>>,
85    /// The chains an app-supplied smart contract wallet signature may name
86    /// (CFG-069, CFG-070). `None` when the app supplied its own verifier,
87    /// which CFG-069 exempts from the check.
88    restricted_chains: Option<Arc<[String]>>,
89}
90
91impl std::fmt::Debug for ServerConfigurationHandle {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("ServerConfigurationHandle")
94            .field("identifier", &self.configuration().identifier)
95            .field("latch", &*self.latch.read())
96            .finish()
97    }
98}
99
100impl Default for ServerConfigurationHandle {
101    fn default() -> Self {
102        Self::new(Arc::new(StoredConfigProvider::default()))
103    }
104}
105
106impl ServerConfigurationHandle {
107    /// Hold one snapshot, with no zero left in its limits.
108    ///
109    /// CFG-031 replaces a zero on the wire with the compiled default, but a
110    /// snapshot an app builds in Rust and hands in through a `ConfigProvider`
111    /// (CFG-033) never passes through that conversion, and a zero dimension
112    /// would panic the `chunks(limit)` calls in `xmtp_api` (CFG-064). Every
113    /// snapshot reaches a client through this constructor, so sanitizing here
114    /// is what keeps the zero out of all three readers at once: this handle,
115    /// the wrapper that chunks with it, and the transport.
116    pub fn new(provider: Arc<dyn ConfigProvider>) -> Self {
117        let sanitized = {
118            let supplied = provider.server_configuration();
119            let limits = supplied.limits.without_zeroes();
120            (limits != supplied.limits).then(|| ServerConfiguration {
121                limits,
122                ..supplied.clone()
123            })
124        };
125        Self {
126            provider: match sanitized {
127                Some(configuration) => Arc::new(StoredConfigProvider::new(configuration)),
128                None => provider,
129            },
130            latch: Arc::default(),
131            restricted_chains: None,
132        }
133    }
134
135    /// Restrict app-supplied smart contract wallet signatures to the chains the
136    /// snapshot names (CFG-069, CFG-070). Skipped entirely when the app
137    /// supplied its own verifier, which CFG-069 exempts.
138    pub(crate) fn with_chain_restriction(mut self, custom_verifier: bool) -> Self {
139        self.restricted_chains = (!custom_verifier).then(|| {
140            Arc::<[String]>::from(self.configuration().smart_contract_wallet_chains.clone())
141        });
142        self
143    }
144
145    /// Bind a signature request to the chains this deployment accepts, before
146    /// it is handed to the app (CFG-069, CFG-070).
147    pub fn restrict(&self, request: &mut xmtp_id::associations::builder::SignatureRequest) {
148        if let Some(chains) = self.restricted_chains.clone() {
149            request.restrict_chains(chains);
150        }
151    }
152
153    /// The snapshot. Fixed for the life of the client (CFG-030): a refresh
154    /// rewrites the stored row, never this value.
155    pub fn configuration(&self) -> &ServerConfiguration {
156        self.provider.server_configuration()
157    }
158
159    /// Whether this deployment keeps a commit log (CFG-068). Read at every
160    /// write and read site, so a deployment that turns it off turns it off for
161    /// every group this client touches.
162    pub fn commit_log_enabled(&self) -> bool {
163        self.configuration().mls.commit_log_enabled()
164    }
165
166    /// The latched failure, if one has been observed.
167    pub fn latched(&self) -> Option<ConfigurationLatch> {
168        self.latch.read().clone()
169    }
170
171    /// Fail when a latch is set. Every call that reaches the network goes
172    /// through here (CFG-051, CFG-061).
173    pub fn check(&self) -> Result<(), ClientError> {
174        match self.latch.read().as_ref() {
175            Some(latch) => Err(latch.into()),
176            None => Ok(()),
177        }
178    }
179
180    /// Latch the first failure seen. A later one does not displace it: the
181    /// first cause is the one worth reporting.
182    pub(crate) fn latch(&self, latch: ConfigurationLatch) -> ClientError {
183        let mut guard = self.latch.write();
184        let held = guard.get_or_insert(latch);
185        ClientError::from(&*held)
186    }
187}
188
189/// Decode a stored row into a snapshot.
190///
191/// CFG-042: a copy that does not decode is a warning, not a failure. The
192/// identifier stays available for the binding check and every value falls back
193/// to its compiled default.
194fn snapshot_from(stored: &StoredServerConfiguration) -> ServerConfiguration {
195    match backend_v1::GetConfigurationResponse::decode(stored.response.as_slice()) {
196        Ok(response) => ServerConfiguration::from(response),
197        Err(error) => {
198            tracing::warn!(
199                identifier = %stored.identifier,
200                %error,
201                "stored server configuration does not decode; using compiled defaults"
202            );
203            ServerConfiguration {
204                identifier: stored.identifier.clone(),
205                ..Default::default()
206            }
207        }
208    }
209}
210
211/// Validate a fetched response and turn it into a snapshot (CFG-044).
212fn validated(
213    response: &backend_v1::GetConfigurationResponse,
214) -> Result<ServerConfiguration, ServerConfigurationError> {
215    let configuration = ServerConfiguration::from(response.clone());
216    configuration.validate()?;
217    Ok(configuration)
218}
219
220/// Fetch, validate, and store one copy, applying the identifier binding.
221///
222/// Shared by the build-time fetch (CFG-040, CFG-055), the refresh worker
223/// (CFG-048), and the explicit refresh the SDKs expose (CFG-082).
224pub(crate) async fn fetch_and_store<ApiClient>(
225    api: &ApiClientWrapper<ApiClient>,
226    db: &impl DbQuery,
227    handle: &ServerConfigurationHandle,
228) -> Result<ServerConfiguration, ClientError>
229where
230    ApiClient: XmtpBackendClient,
231{
232    handle.check()?;
233
234    let response = api.get_configuration().await.map_err(|error| {
235        ClientError::ConfigurationUnavailable(Box::new(ConfigurationFetchError::Api(error)))
236    })?;
237    let configuration = validated(&response)?;
238
239    // CFG-051: the identifier, not the URL, is the binding. A row with an
240    // empty identifier was written by an offline build and binds nothing.
241    let stored = db.server_configuration().map_err(storage_unavailable)?;
242    if let Some(stored) = stored.as_ref()
243        && !stored.identifier.is_empty()
244        && stored.identifier != configuration.identifier
245    {
246        tracing::error!(
247            stored = %stored.identifier,
248            received = %configuration.identifier,
249            "this database is bound to a different backend deployment"
250        );
251        // CFG-054: a failure to record it keeps the in-memory latch, so this
252        // client still fails every later call.
253        if let Err(error) = db.record_server_configuration_conflict(&configuration.identifier) {
254            tracing::error!(%error, "could not record the conflicting backend identifier");
255        }
256        return Err(handle.latch(ConfigurationLatch::BackendMismatch {
257            stored: stored.identifier.clone(),
258            received: configuration.identifier.clone(),
259        }));
260    }
261
262    db.store_server_configuration(
263        &configuration.identifier,
264        normalized_url(api.backend_url().unwrap_or_default()),
265        &response.encode_to_vec(),
266        xmtp_common::time::now_ns(),
267    )
268    .map_err(storage_unavailable)?;
269
270    Ok(configuration)
271}
272
273/// Read a deployment's configuration with no database and no client (CFG-081).
274///
275/// The one call that needs no credential (CFG-045), so an app can learn whether
276/// the deployment requires authentication, which scopes it wants, and which
277/// chains it accepts before it decides how to build a client. Nothing is
278/// stored and no identifier binding is applied: there is no database to bind to.
279pub async fn fetch_server_configuration<ApiClient>(
280    api: &ApiClientWrapper<ApiClient>,
281) -> Result<ServerConfiguration, ClientError>
282where
283    ApiClient: XmtpBackendClient,
284{
285    let response = api.get_configuration().await.map_err(|error| {
286        ClientError::ConfigurationUnavailable(Box::new(ConfigurationFetchError::Api(error)))
287    })?;
288    Ok(validated(&response)?)
289}
290
291/// The form a backend URL is stored and compared in (CFG-055).
292///
293/// A transport reports the URI it dialled, which for `http://host:port` carries
294/// a trailing slash the app never typed. Normalising both sides keeps a purely
295/// cosmetic difference from looking like a move to another deployment.
296fn normalized_url(url: &str) -> &str {
297    url.trim_end_matches('/')
298}
299
300fn storage_unavailable(error: StorageError) -> ClientError {
301    ClientError::ConfigurationUnavailable(Box::new(ConfigurationFetchError::Storage(error)))
302}
303
304/// Resolve the snapshot `build` will hold (CFG-040 to CFG-043, CFG-052,
305/// CFG-055).
306///
307/// The minimum-version check of CFG-060 is deliberately not here: it applies to
308/// the snapshot the client ends up holding, which may have come from a
309/// caller-supplied provider (CFG-033) that never reached this function.
310///
311/// Runs before any identity work. Offline, it never awaits a network call, so
312/// `build_offline` still completes without polling a pending future (CFG-094).
313pub(crate) async fn resolve<ApiClient>(
314    api: &ApiClientWrapper<ApiClient>,
315    db: &impl DbQuery,
316    allow_offline: bool,
317) -> Result<ServerConfigurationHandle, ClientError>
318where
319    ApiClient: XmtpBackendClient,
320{
321    let stored = db.server_configuration().map_err(storage_unavailable)?;
322
323    // CFG-052: a recorded conflict fails every build until the database is
324    // replaced with one created for the backend the app now uses.
325    if let Some(conflicting) = stored
326        .as_ref()
327        .and_then(|row| row.conflicting_identifier.clone())
328    {
329        return Err(ClientError::BackendMismatch {
330            stored: stored.map(|row| row.identifier).unwrap_or_default(),
331            received: conflicting,
332        });
333    }
334
335    let configuration = match (allow_offline, stored) {
336        // CFG-043: offline with no copy is compiled defaults and an empty
337        // identifier. The first successful refresh writes the row.
338        (true, None) => ServerConfiguration::default(),
339        // CFG-043: offline with a copy uses it, and skips the URL check.
340        (true, Some(stored)) => snapshot_from(&stored),
341        // CFG-040: online with no copy fetches before any identity work.
342        (false, None) => {
343            let handle = ServerConfigurationHandle::default();
344            fetch_and_store(api, db, &handle).await?
345        }
346        (false, Some(stored)) => {
347            // CFG-042 and CFG-055: the stored copy is used as is unless the
348            // configured URL moved, in which case the identifier is checked
349            // again before anything else happens.
350            let moved = api
351                .backend_url()
352                .is_some_and(|url| normalized_url(url) != normalized_url(&stored.backend_url));
353            if moved {
354                tracing::info!(
355                    from = %stored.backend_url,
356                    to = api.backend_url().unwrap_or_default(),
357                    "backend URL changed; re-reading the deployment configuration"
358                );
359                let handle = ServerConfigurationHandle::default();
360                fetch_and_store(api, db, &handle).await?
361            } else {
362                snapshot_from(&stored)
363            }
364        }
365    };
366
367    Ok(ServerConfigurationHandle::new(Arc::new(
368        StoredConfigProvider::new(configuration),
369    )))
370}
371
372/// CFG-060 and CFG-061: compare on major, minor, and patch only, so a
373/// prerelease tag never makes a client too old.
374pub(crate) fn check_minimum_version(
375    configuration: &ServerConfiguration,
376    client_version: &semver::Version,
377) -> Result<(), ClientError> {
378    let Some(minimum) = configuration.minimum_version()? else {
379        return Ok(());
380    };
381    if xmtp_configuration::version_is_below(client_version, &minimum) {
382        return Err(ClientError::ClientVersionTooOld {
383            client: client_version.to_string(),
384            minimum: minimum.to_string(),
385        });
386    }
387    Ok(())
388}
389
390pub mod worker;
391
392#[cfg(test)]
393mod tests;