1use std::sync::{
14 Arc, OnceLock,
15 atomic::{AtomicBool, Ordering},
16};
17
18use parking_lot::RwLock;
19use prost::Message;
20use xmtp_api::{ApiClientWrapper, ApiError};
21use xmtp_common::{ErrorCode, RetryableError};
22use xmtp_configuration::{
23 BACKEND_DEFAULT_MAX_UPLOAD_BYTES, ConfigProvider, ServerConfiguration,
24 ServerConfigurationError, StoredConfigProvider,
25 attachments::{check_base_url, check_max_upload_bytes, check_retention_seconds},
26};
27use xmtp_db::prelude::*;
28use xmtp_db::{StorageError, server_configuration::StoredServerConfiguration};
29use xmtp_events::{ClientEvent, ClientRejectedByServer, EventWriter, RejectionCause};
30use xmtp_proto::api_client::XmtpBackendClient;
31use xmtp_proto::backend_v1;
32
33#[derive(Debug, thiserror::Error)]
38pub enum ConfigurationFetchError {
39 #[error("the deployment record could not be written: {0}")]
40 StorageLocation(#[from] crate::storage_location::StorageLocationError),
41 #[error("the backend did not serve its configuration: {0}")]
44 Api(#[from] ApiError),
45 #[error("the fetched configuration could not be stored: {0}")]
47 Storage(#[from] StorageError),
48}
49
50impl xmtp_common::RetryableError for ConfigurationFetchError {
51 fn is_retryable(&self) -> bool {
52 match self {
53 Self::Api(e) => e.is_retryable(),
54 Self::Storage(e) => e.is_retryable(),
55 Self::StorageLocation(_) => false,
56 }
57 }
58
59 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
60 match self {
61 Self::Api(e) => e.rate_limit_delay(),
62 _ => None,
63 }
64 }
65}
66
67impl ConfigurationFetchError {
68 #[deny(clippy::wildcard_enum_match_arm)]
69 pub fn db_needs_connection(&self) -> bool {
70 match self {
71 Self::Storage(error) => error.db_needs_connection(),
72 Self::StorageLocation(_) | Self::Api(_) => false,
73 }
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, ErrorCode)]
80pub enum BlockedConnection {
81 #[error("this database is bound to backend {stored}, but {received} answered")]
87 BackendMismatch { stored: String, received: String },
88 #[error("client version {client} is below the {minimum} this backend requires")]
93 ClientVersionTooOld { client: String, minimum: String },
94}
95
96impl RetryableError for BlockedConnection {
97 fn is_retryable(&self) -> bool {
98 false
99 }
100}
101
102#[derive(Debug, thiserror::Error, ErrorCode)]
104pub enum ServerConfigError {
105 #[error("server configuration unavailable: {0}")]
110 Unavailable(#[source] Box<ConfigurationFetchError>),
111 #[error("server configuration invalid: {0}")]
116 Invalid(#[from] ServerConfigurationError),
117 #[error(transparent)]
119 #[error_code(inherit)]
120 Blocked(#[from] BlockedConnection),
121 #[error("client is closed")]
126 Closed,
127}
128
129impl From<ConfigurationFetchError> for ServerConfigError {
130 fn from(error: ConfigurationFetchError) -> Self {
131 Self::Unavailable(Box::new(error))
132 }
133}
134
135impl RetryableError for ServerConfigError {
136 fn is_retryable(&self) -> bool {
137 match self {
138 Self::Unavailable(error) => error.is_retryable(),
139 Self::Invalid(_) | Self::Blocked(_) | Self::Closed => false,
140 }
141 }
142
143 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
144 match self {
145 Self::Unavailable(error) => error.rate_limit_delay(),
146 Self::Invalid(_) | Self::Blocked(_) | Self::Closed => None,
147 }
148 }
149}
150
151#[derive(Clone)]
156pub struct ServerConfigurationHandle {
157 pub admission: Arc<tokio::sync::Mutex<()>>,
160 pub pending: Arc<AtomicBool>,
162 pub fetch: Arc<OnceLock<Arc<dyn xmtp_api::preflight::ConfigurationFetch>>>,
164 deployment_recorder: Arc<RwLock<Option<crate::storage_location::DeploymentRecorder>>>,
165 provider: Arc<dyn ConfigProvider>,
166 blocked_connection: Arc<RwLock<Option<BlockedConnection>>>,
167 event_writer: Arc<RwLock<Option<Arc<dyn EventWriter<()>>>>>,
168 restricted_chains: Option<Arc<[String]>>,
171}
172
173impl std::fmt::Debug for ServerConfigurationHandle {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.debug_struct("ServerConfigurationHandle")
176 .field("identifier", &self.configuration().identifier)
177 .field("blocked_connection", &*self.blocked_connection.read())
178 .finish()
179 }
180}
181
182impl Default for ServerConfigurationHandle {
183 fn default() -> Self {
184 Self::new(Arc::new(StoredConfigProvider::default()))
185 }
186}
187
188impl ServerConfigurationHandle {
189 #[cfg(any(test, feature = "test-utils"))]
191 pub fn custom_verifier(&self) -> bool {
192 self.restricted_chains.is_none()
193 }
194
195 pub fn requires_preflight(&self) -> bool {
197 self.pending.load(Ordering::Acquire)
198 }
199
200 pub fn new(provider: Arc<dyn ConfigProvider>) -> Self {
211 let sanitized = {
212 let supplied = provider.server_configuration();
213 let limits = supplied.limits.without_zeroes();
214 let attachments = supplied.attachments.as_ref().and_then(|offer| {
215 let mut offer = offer.clone();
216 if offer.max_upload_bytes == 0 {
217 offer.max_upload_bytes = BACKEND_DEFAULT_MAX_UPLOAD_BYTES;
218 }
219 let checked = check_base_url(&offer.base_url)
220 .and_then(|_| check_max_upload_bytes(offer.max_upload_bytes))
221 .and_then(|_| check_retention_seconds(offer.retention_seconds));
222 if let Err(reason) = checked {
223 tracing::warn!(
224 field = reason.field(),
225 reason = reason.reason(),
226 "ignoring unusable attachment storage offer"
227 );
228 None
229 } else {
230 Some(offer)
231 }
232 });
233 (limits != supplied.limits || attachments != supplied.attachments).then(|| {
234 ServerConfiguration {
235 limits,
236 attachments,
237 ..supplied.clone()
238 }
239 })
240 };
241 Self {
242 deployment_recorder: Arc::default(),
243 provider: match sanitized {
244 Some(configuration) => Arc::new(StoredConfigProvider::new(configuration)),
245 None => provider,
246 },
247 blocked_connection: Arc::default(),
248 event_writer: Arc::default(),
249 restricted_chains: None,
250 admission: Arc::default(),
251 pending: Arc::default(),
252 fetch: Arc::default(),
253 }
254 }
255
256 #[cfg(any(test, feature = "test-utils"))]
258 pub fn with_application_components(
259 mut self,
260 components: Vec<xmtp_configuration::ApplicationComponentDefinition>,
261 ) -> Self {
262 let mut configuration = self.configuration().clone();
263 configuration.application_components = components;
264 self.provider = Arc::new(StoredConfigProvider::new(configuration));
265 self
266 }
267
268 pub fn with_chain_restriction(mut self, custom_verifier: bool) -> Self {
272 self.restricted_chains = (!custom_verifier).then(|| {
273 Arc::<[String]>::from(self.configuration().smart_contract_wallet_chains.clone())
274 });
275 self
276 }
277
278 pub fn restrict(&self, request: &mut xmtp_id::associations::builder::SignatureRequest) {
281 if let Some(chains) = self.restricted_chains.clone() {
282 request.restrict_chains(chains);
283 }
284 }
285
286 pub fn configuration(&self) -> &ServerConfiguration {
289 self.provider.server_configuration()
290 }
291
292 pub fn commit_log_enabled(&self) -> bool {
296 self.configuration().mls.commit_log_enabled()
297 }
298
299 pub fn blocked_connection(&self) -> Option<BlockedConnection> {
301 self.blocked_connection.read().clone()
302 }
303
304 pub fn set_event_writer(&self, writer: Arc<dyn EventWriter<()>>) {
306 *self.event_writer.write() = Some(writer);
307 }
308
309 pub fn set_deployment_recorder(&self, recorder: crate::storage_location::DeploymentRecorder) {
311 *self.deployment_recorder.write() = Some(recorder);
312 }
313
314 pub async fn record_deployment(&self, identifier: &str) -> Result<(), ServerConfigError> {
316 let recorder = self.deployment_recorder.read().clone();
317 if let Some(recorder) = recorder {
318 recorder
319 .record(identifier)
320 .await
321 .map_err(ConfigurationFetchError::StorageLocation)?;
322 }
323 Ok(())
324 }
325
326 pub fn check(&self) -> Result<(), BlockedConnection> {
330 match self.blocked_connection.read().as_ref() {
331 Some(blocked_connection) => Err(blocked_connection.clone()),
332 None => Ok(()),
333 }
334 }
335
336 pub fn blocked_or<E: From<BlockedConnection>>(&self, error: E) -> E {
342 match self.check() {
343 Err(blocked) => blocked.into(),
344 Ok(()) => error,
345 }
346 }
347
348 pub fn block_connection(&self, reason: BlockedConnection) -> BlockedConnection {
351 let mut guard = self.blocked_connection.write();
352 if let Some(held) = guard.as_ref() {
353 return held.clone();
354 }
355 let error = reason.clone();
356 let event = match &reason {
357 BlockedConnection::BackendMismatch { .. } => ClientRejectedByServer {
358 cause: RejectionCause::BackendMismatch,
359 min_libxmtp_version: None,
360 },
361 BlockedConnection::ClientVersionTooOld { minimum, .. } => ClientRejectedByServer {
362 cause: RejectionCause::VersionTooOld,
363 min_libxmtp_version: Some(minimum.clone()),
364 },
365 };
366 *guard = Some(reason);
367 drop(guard);
368 if let Some(writer) = self.event_writer.read().as_ref() {
369 writer.emit(Some(ClientEvent::ClientRejectedByServer(event)), None);
370 }
371 error
372 }
373}
374
375fn snapshot_from(stored: &StoredServerConfiguration) -> ServerConfiguration {
381 match backend_v1::GetConfigurationResponse::decode(stored.response.as_slice()) {
382 Ok(response) => ServerConfiguration::from(response),
383 Err(error) => {
384 tracing::warn!(
385 identifier = %stored.identifier,
386 %error,
387 "stored server configuration does not decode; using compiled defaults"
388 );
389 ServerConfiguration {
390 identifier: stored.identifier.clone(),
391 ..Default::default()
392 }
393 }
394 }
395}
396
397pub fn validated(
399 response: &backend_v1::GetConfigurationResponse,
400) -> Result<ServerConfiguration, ServerConfigurationError> {
401 let configuration = ServerConfiguration::from(response.clone());
402 configuration.validate()?;
403 Ok(configuration)
404}
405
406pub(crate) async fn fetch_and_store<ApiClient>(
412 api: &ApiClientWrapper<ApiClient>,
413 db: &impl DbQuery,
414 handle: &ServerConfigurationHandle,
415) -> Result<ServerConfiguration, ServerConfigError>
416where
417 ApiClient: XmtpBackendClient,
418{
419 handle.check()?;
420
421 let response = api
422 .get_configuration()
423 .await
424 .map_err(ConfigurationFetchError::Api)?;
425 let configuration = commit_configuration(response, api.backend_url(), db, handle)?;
426 handle.record_deployment(&configuration.identifier).await?;
427 Ok(configuration)
428}
429
430pub fn commit_configuration(
433 response: backend_v1::GetConfigurationResponse,
434 backend_url: Option<&str>,
435 db: &impl DbQuery,
436 handle: &ServerConfigurationHandle,
437) -> Result<ServerConfiguration, ServerConfigError> {
438 let configuration = validated(&response)?;
439
440 let stored = db.server_configuration().map_err(storage_unavailable)?;
443 if let Some(stored) = stored.as_ref()
444 && !stored.identifier.is_empty()
445 && stored.identifier != configuration.identifier
446 {
447 tracing::error!(
448 stored = %stored.identifier,
449 received = %configuration.identifier,
450 "this database is bound to a different backend deployment"
451 );
452 if let Err(error) = db.record_server_configuration_conflict(&configuration.identifier) {
455 tracing::error!(%error, "could not record the conflicting backend identifier");
456 }
457 return Err(handle
458 .block_connection(BlockedConnection::BackendMismatch {
459 stored: stored.identifier.clone(),
460 received: configuration.identifier.clone(),
461 })
462 .into());
463 }
464
465 db.store_server_configuration(
466 &configuration.identifier,
467 normalized_url(backend_url.unwrap_or_default()),
468 &response.encode_to_vec(),
469 xmtp_common::time::now_ns(),
470 )
471 .map_err(storage_unavailable)?;
472
473 Ok(configuration)
474}
475
476pub async fn fetch_server_configuration<ApiClient>(
483 api: &ApiClientWrapper<ApiClient>,
484) -> Result<ServerConfiguration, ServerConfigError>
485where
486 ApiClient: XmtpBackendClient,
487{
488 let response = api
489 .get_configuration()
490 .await
491 .map_err(ConfigurationFetchError::Api)?;
492 Ok(validated(&response)?)
493}
494
495pub(crate) fn normalized_url(url: &str) -> &str {
501 url.trim_end_matches('/')
502}
503
504fn storage_unavailable(error: StorageError) -> ServerConfigError {
505 ConfigurationFetchError::Storage(error).into()
506}
507
508pub async fn resolve<ApiClient>(
518 api: &ApiClientWrapper<ApiClient>,
519 db: &impl DbQuery,
520 allow_offline: bool,
521) -> Result<ServerConfigurationHandle, ServerConfigError>
522where
523 ApiClient: XmtpBackendClient,
524{
525 let stored = db.server_configuration().map_err(storage_unavailable)?;
526
527 if let Some(conflicting) = stored
530 .as_ref()
531 .and_then(|row| row.conflicting_identifier.clone())
532 {
533 return Err(BlockedConnection::BackendMismatch {
534 stored: stored.map(|row| row.identifier).unwrap_or_default(),
535 received: conflicting,
536 }
537 .into());
538 }
539
540 let pending = allow_offline
541 && stored.as_ref().is_some_and(|stored| {
542 api.backend_url()
543 .is_some_and(|url| normalized_url(url) != normalized_url(&stored.backend_url))
544 });
545 let configuration = match (allow_offline, stored) {
546 (true, None) => ServerConfiguration::default(),
549 (true, Some(stored)) => snapshot_from(&stored),
551 (false, None) => {
553 let handle = ServerConfigurationHandle::default();
554 fetch_and_store(api, db, &handle).await?
555 }
556 (false, Some(stored)) => {
557 let moved = api
561 .backend_url()
562 .is_some_and(|url| normalized_url(url) != normalized_url(&stored.backend_url));
563 if moved {
564 tracing::info!(
565 from = %stored.backend_url,
566 to = api.backend_url().unwrap_or_default(),
567 "backend URL changed; re-reading the deployment configuration"
568 );
569 let handle = ServerConfigurationHandle::default();
570 fetch_and_store(api, db, &handle).await?
571 } else {
572 snapshot_from(&stored)
573 }
574 }
575 };
576
577 let mut handle =
578 ServerConfigurationHandle::new(Arc::new(StoredConfigProvider::new(configuration)));
579 handle.pending = Arc::new(AtomicBool::new(pending));
580 Ok(handle)
581}
582
583pub fn check_minimum_version(
587 configuration: &ServerConfiguration,
588 client_version: &semver::Version,
589) -> Result<(), ServerConfigError> {
590 let Some(minimum) = configuration.minimum_version()? else {
591 return Ok(());
592 };
593 if xmtp_configuration::version_is_below(client_version, &minimum) {
594 return Err(BlockedConnection::ClientVersionTooOld {
595 client: client_version.to_string(),
596 minimum: minimum.to_string(),
597 }
598 .into());
599 }
600 Ok(())
601}
602
603#[cfg(any(test, feature = "test-utils"))]
604#[doc(hidden)]
605pub mod test_support;
606
607#[cfg(test)]
608mod tests;