Skip to main content

xmtp_configuration/common/
server.rs

1//! What one backend deployment publishes about itself, and the provider every
2//! consumer reads it through.
3//!
4//! The backend answers `ConfigurationService.GetConfiguration` with these
5//! values. A client fetches them once, stores them, and holds one immutable
6//! snapshot for the life of the client. Consumers never read the database for
7//! a configuration value; they read the provider.
8
9use crate::{
10    BACKEND_DEFAULT_GROUP_MESSAGE_SECONDS, BACKEND_DEFAULT_KEY_PACKAGE_SECONDS,
11    BACKEND_DEFAULT_MAX_ENVELOPE_BYTES, BACKEND_DEFAULT_MAX_IDENTITY_ENTRIES,
12    BACKEND_DEFAULT_MAX_LOOKUP_IDENTIFIERS, BACKEND_DEFAULT_MAX_NEWEST_FULL_TOPICS,
13    BACKEND_DEFAULT_MAX_NEWEST_METADATA_TOPICS, BACKEND_DEFAULT_MAX_PING_BURST,
14    BACKEND_DEFAULT_MAX_PING_FRAMES_PER_SECOND, BACKEND_DEFAULT_MAX_PUBLISH_TOPICS,
15    BACKEND_DEFAULT_MAX_QUERY_LIMIT, BACKEND_DEFAULT_MAX_QUERY_TOPICS,
16    BACKEND_DEFAULT_MAX_REQUEST_BYTES, BACKEND_DEFAULT_MAX_RESPONSE_BYTES,
17    BACKEND_DEFAULT_MAX_SCW_SIGNATURES, BACKEND_DEFAULT_MAX_STATIC_TOPICS,
18    BACKEND_DEFAULT_MAX_STREAM_TOPICS, BACKEND_DEFAULT_MAX_UPDATE_ADDS,
19    BACKEND_DEFAULT_MAX_UPDATE_BURST, BACKEND_DEFAULT_MAX_UPDATE_FRAMES_PER_SECOND,
20    BACKEND_DEFAULT_MAX_UPDATE_REMOVES, BACKEND_DEFAULT_QUERY_LIMIT,
21    BACKEND_DEFAULT_WELCOME_SECONDS, ENABLE_COMMIT_LOG, MAX_GROUP_SIZE,
22    MAX_INSTALLATIONS_PER_INBOX,
23};
24
25/// How long after one refresh run ends before the next one starts, before
26/// jitter (CFG-046). The first run starts this long after build.
27pub const CONFIGURATION_REFRESH_INTERVAL: std::time::Duration =
28    std::time::Duration::from_secs(3600);
29
30/// Random spread added to each wait, so a fleet of clients does not refresh
31/// in lockstep (CFG-046).
32pub const CONFIGURATION_REFRESH_JITTER: std::time::Duration = std::time::Duration::from_secs(360);
33
34/// Attempts in one refresh run. After the last failure the run ends and the
35/// stored copy is left alone (CFG-047).
36pub const CONFIGURATION_REFRESH_ATTEMPTS: usize = 3;
37
38/// Waits between the attempts of one run (CFG-046).
39pub const CONFIGURATION_REFRESH_BACKOFF: [std::time::Duration; 2] = [
40    std::time::Duration::from_secs(5),
41    std::time::Duration::from_secs(30),
42];
43
44/// Longest operator identifier, in bytes. Shared with the backend so one rule
45/// governs what it accepts and what a client will store.
46pub const MAX_SERVER_IDENTIFIER_BYTES: usize = 256;
47
48/// Largest published numeric value, `2^53 - 1`.
49///
50/// Spec 006 §7 maps every `uint64` to a JavaScript `number`, which holds
51/// integers exactly only up to this value. A deployment that publishes more
52/// would reach a JavaScript app rounded, so the client refuses it (CFG-044)
53/// rather than reading a number the deployment never published.
54pub const MAX_PUBLISHED_VALUE: u64 = 9_007_199_254_740_991;
55
56/// Why a published configuration cannot be used.
57#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
58pub enum ServerConfigurationError {
59    #[error(
60        "identifier must be 1 to {MAX_SERVER_IDENTIFIER_BYTES} bytes with no whitespace or control characters"
61    )]
62    Identifier,
63    #[error("min_libxmtp_version {version:?} is not a semantic version")]
64    MinimumVersion { version: String },
65    #[error("{chain:?} is not a CAIP-2 chain identifier")]
66    Chain { chain: String },
67    #[error("{field} is {value}, above the {MAX_PUBLISHED_VALUE} an SDK integer holds exactly")]
68    Magnitude { field: &'static str, value: u64 },
69}
70
71/// Check the shape of an operator identifier. The backend refuses to start
72/// with one that fails, and a client refuses to store one.
73pub fn validate_server_identifier(identifier: &str) -> Result<(), ServerConfigurationError> {
74    if identifier.is_empty()
75        || identifier.len() > MAX_SERVER_IDENTIFIER_BYTES
76        || identifier
77            .chars()
78            .any(|c| c.is_whitespace() || c.is_control())
79    {
80        return Err(ServerConfigurationError::Identifier);
81    }
82    Ok(())
83}
84
85/// Refuse one published number an SDK integer could not carry exactly.
86fn within_range(field: &'static str, value: u64) -> Result<(), ServerConfigurationError> {
87    if value > MAX_PUBLISHED_VALUE {
88        return Err(ServerConfigurationError::Magnitude { field, value });
89    }
90    Ok(())
91}
92
93/// A CAIP-2 identifier is `namespace:reference`, where the namespace is three
94/// to eight lowercase characters and the reference is one to thirty-two. The
95/// client only needs the shape; the verifier owns the routes.
96pub fn is_caip2_chain_id(chain: &str) -> bool {
97    let Some((namespace, reference)) = chain.split_once(':') else {
98        return false;
99    };
100    (3..=8).contains(&namespace.len())
101        && !reference.is_empty()
102        && reference.len() <= 32
103        && namespace
104            .chars()
105            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
106        && reference
107            .chars()
108            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
109}
110
111/// Compare two versions on major, minor, and patch only. A prerelease tag
112/// never makes a client too old for a minimum it otherwise satisfies.
113///
114/// Returns `true` when `version` is below `minimum`.
115pub fn version_is_below(version: &semver::Version, minimum: &semver::Version) -> bool {
116    (version.major, version.minor, version.patch) < (minimum.major, minimum.minor, minimum.patch)
117}
118
119/// Public identity of one accepted signing key. Never the key itself.
120#[derive(Clone, Debug, Default, PartialEq, Eq)]
121pub struct SigningKeyDescription {
122    pub kid: String,
123    pub alg: String,
124}
125
126/// What a client must present to be admitted. The client acts only on
127/// `enabled` and `required_scopes`; the rest exists for operator tooling.
128#[derive(Clone, Debug, Default, PartialEq, Eq)]
129pub struct AuthConfiguration {
130    pub enabled: bool,
131    pub keys: Vec<SigningKeyDescription>,
132    pub audiences: Vec<String>,
133    pub issuers: Vec<String>,
134    pub required_scopes: Vec<String>,
135}
136
137/// How long the deployment keeps each payload kind, in seconds.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct RetentionConfiguration {
140    pub group_message_seconds: u64,
141    pub welcome_seconds: u64,
142    pub key_package_seconds: u64,
143}
144
145impl Default for RetentionConfiguration {
146    fn default() -> Self {
147        Self {
148            group_message_seconds: BACKEND_DEFAULT_GROUP_MESSAGE_SECONDS,
149            welcome_seconds: BACKEND_DEFAULT_WELCOME_SECONDS,
150            key_package_seconds: BACKEND_DEFAULT_KEY_PACKAGE_SECONDS,
151        }
152    }
153}
154
155/// Request shapes the deployment accepts. A client chunks its work to these
156/// values rather than to its compiled constants.
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub struct LimitsConfiguration {
159    pub max_envelope_bytes: usize,
160    pub max_request_bytes: usize,
161    pub max_response_bytes: usize,
162    pub max_publish_topics: usize,
163    pub max_query_topics: usize,
164    pub max_query_limit: usize,
165    pub default_query_limit: usize,
166    pub max_newest_metadata_topics: usize,
167    pub max_newest_full_topics: usize,
168    pub max_update_adds: usize,
169    pub max_update_removes: usize,
170    pub max_stream_topics: usize,
171    pub max_static_topics: usize,
172    pub max_lookup_identifiers: usize,
173    pub max_scw_signatures: usize,
174    pub max_identity_entries: usize,
175    pub max_update_frames_per_second: u32,
176    pub max_update_burst: u32,
177    pub max_ping_frames_per_second: u32,
178    pub max_ping_burst: u32,
179}
180
181impl Default for LimitsConfiguration {
182    fn default() -> Self {
183        Self {
184            max_envelope_bytes: BACKEND_DEFAULT_MAX_ENVELOPE_BYTES,
185            max_request_bytes: BACKEND_DEFAULT_MAX_REQUEST_BYTES,
186            max_response_bytes: BACKEND_DEFAULT_MAX_RESPONSE_BYTES,
187            max_publish_topics: BACKEND_DEFAULT_MAX_PUBLISH_TOPICS,
188            max_query_topics: BACKEND_DEFAULT_MAX_QUERY_TOPICS,
189            max_query_limit: BACKEND_DEFAULT_MAX_QUERY_LIMIT,
190            default_query_limit: BACKEND_DEFAULT_QUERY_LIMIT,
191            max_newest_metadata_topics: BACKEND_DEFAULT_MAX_NEWEST_METADATA_TOPICS,
192            max_newest_full_topics: BACKEND_DEFAULT_MAX_NEWEST_FULL_TOPICS,
193            max_update_adds: BACKEND_DEFAULT_MAX_UPDATE_ADDS,
194            max_update_removes: BACKEND_DEFAULT_MAX_UPDATE_REMOVES,
195            max_stream_topics: BACKEND_DEFAULT_MAX_STREAM_TOPICS,
196            max_static_topics: BACKEND_DEFAULT_MAX_STATIC_TOPICS,
197            max_lookup_identifiers: BACKEND_DEFAULT_MAX_LOOKUP_IDENTIFIERS,
198            max_scw_signatures: BACKEND_DEFAULT_MAX_SCW_SIGNATURES,
199            max_identity_entries: BACKEND_DEFAULT_MAX_IDENTITY_ENTRIES,
200            max_update_frames_per_second: BACKEND_DEFAULT_MAX_UPDATE_FRAMES_PER_SECOND,
201            max_update_burst: BACKEND_DEFAULT_MAX_UPDATE_BURST,
202            max_ping_frames_per_second: BACKEND_DEFAULT_MAX_PING_FRAMES_PER_SECOND,
203            max_ping_burst: BACKEND_DEFAULT_MAX_PING_BURST,
204        }
205    }
206}
207
208impl LimitsConfiguration {
209    /// The same snapshot with every zero replaced by the compiled default.
210    ///
211    /// CFG-031 already applies this rule to what arrives on the wire, so no
212    /// published configuration can carry a zero. A snapshot built in Rust and
213    /// handed in through a `ConfigProvider` (CFG-033) skips that conversion,
214    /// and a zero chunk dimension would panic the transport that slices its
215    /// work into chunks of it (CFG-064). Applying the wire rule once more,
216    /// where the transport reads the value, means it never can.
217    pub fn without_zeroes(&self) -> Self {
218        let default = Self::default();
219        macro_rules! or_default {
220            ($($field:ident),+ $(,)?) => {
221                Self {
222                    $($field: if self.$field == 0 { default.$field } else { self.$field }),+
223                }
224            };
225        }
226        or_default!(
227            max_envelope_bytes,
228            max_request_bytes,
229            max_response_bytes,
230            max_publish_topics,
231            max_query_topics,
232            max_query_limit,
233            default_query_limit,
234            max_newest_metadata_topics,
235            max_newest_full_topics,
236            max_update_adds,
237            max_update_removes,
238            max_stream_topics,
239            max_static_topics,
240            max_lookup_identifiers,
241            max_scw_signatures,
242            max_identity_entries,
243            max_update_frames_per_second,
244            max_update_burst,
245            max_ping_frames_per_second,
246            max_ping_burst,
247        )
248    }
249}
250
251/// Advisory group shapes. The backend publishes them and does not enforce them.
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub struct MlsConfiguration {
254    pub max_group_members: usize,
255    pub max_installations_per_inbox: usize,
256    /// Absent means the client keeps its compiled default. `false` is distinct
257    /// from absent, so an operator can switch the commit log off explicitly.
258    pub commit_log_enabled: Option<bool>,
259}
260
261impl MlsConfiguration {
262    /// Whether this deployment wants clients to write and read the commit log.
263    pub fn commit_log_enabled(&self) -> bool {
264        self.commit_log_enabled.unwrap_or(ENABLE_COMMIT_LOG)
265    }
266}
267
268impl Default for MlsConfiguration {
269    fn default() -> Self {
270        Self {
271            max_group_members: MAX_GROUP_SIZE,
272            max_installations_per_inbox: MAX_INSTALLATIONS_PER_INBOX,
273            commit_log_enabled: None,
274        }
275    }
276}
277
278/// One immutable snapshot of what a deployment published.
279///
280/// `Default` is the compiled fallback used before any fetch succeeds, and for
281/// any field the deployment left at zero.
282#[derive(Clone, Debug, Default, PartialEq, Eq)]
283pub struct ServerConfiguration {
284    /// Stable operator-chosen name. Empty only before a first fetch succeeds.
285    pub identifier: String,
286    pub server_version: String,
287    /// Empty when the operator published no minimum.
288    pub min_libxmtp_version: String,
289    pub auth: AuthConfiguration,
290    pub retention: RetentionConfiguration,
291    pub limits: LimitsConfiguration,
292    pub mls: MlsConfiguration,
293    /// CAIP-2 chain ids this deployment verifies smart contract wallet
294    /// signatures on. Empty rejects every app-supplied signature.
295    pub smart_contract_wallet_chains: Vec<String>,
296}
297
298impl ServerConfiguration {
299    /// Apply the rules of CFG-044: the identifier must be well formed, any
300    /// minimum version must parse, every chain must be CAIP-2, and every
301    /// numeric value must survive the trip to an SDK integer intact.
302    pub fn validate(&self) -> Result<(), ServerConfigurationError> {
303        validate_server_identifier(&self.identifier)?;
304        self.minimum_version()?;
305        for chain in &self.smart_contract_wallet_chains {
306            if !is_caip2_chain_id(chain) {
307                return Err(ServerConfigurationError::Chain {
308                    chain: chain.clone(),
309                });
310            }
311        }
312        self.validate_magnitudes()
313    }
314
315    /// §7: every `uint64` reaches JavaScript as a `number`, so a value above
316    /// `MAX_PUBLISHED_VALUE` would arrive rounded. Refusing it here is what
317    /// makes that mapping exact for every SDK. The four `uint32` rates cannot
318    /// reach the bound, so they need no check.
319    fn validate_magnitudes(&self) -> Result<(), ServerConfigurationError> {
320        within_range(
321            "group_message_seconds",
322            self.retention.group_message_seconds,
323        )?;
324        within_range("welcome_seconds", self.retention.welcome_seconds)?;
325        within_range("key_package_seconds", self.retention.key_package_seconds)?;
326
327        macro_rules! bounded {
328            ($owner:ident: $($field:ident),+ $(,)?) => {
329                $(within_range(stringify!($field), self.$owner.$field as u64)?;)+
330            };
331        }
332        bounded!(
333            limits: max_envelope_bytes,
334            max_request_bytes,
335            max_response_bytes,
336            max_publish_topics,
337            max_query_topics,
338            max_query_limit,
339            default_query_limit,
340            max_newest_metadata_topics,
341            max_newest_full_topics,
342            max_update_adds,
343            max_update_removes,
344            max_stream_topics,
345            max_static_topics,
346            max_lookup_identifiers,
347            max_scw_signatures,
348            max_identity_entries,
349        );
350        bounded!(mls: max_group_members, max_installations_per_inbox);
351        Ok(())
352    }
353
354    /// The published minimum, parsed. `None` admits every client version.
355    pub fn minimum_version(&self) -> Result<Option<semver::Version>, ServerConfigurationError> {
356        if self.min_libxmtp_version.is_empty() {
357            return Ok(None);
358        }
359        semver::Version::parse(&self.min_libxmtp_version)
360            .map(Some)
361            .map_err(|_| ServerConfigurationError::MinimumVersion {
362                version: self.min_libxmtp_version.clone(),
363            })
364    }
365
366    /// Whether this deployment accepts a smart contract wallet signature on
367    /// `chain`. An empty list accepts none.
368    pub fn accepts_chain(&self, chain: &str) -> bool {
369        self.smart_contract_wallet_chains
370            .iter()
371            .any(|accepted| accepted == chain)
372    }
373}
374
375/// One immutable configuration, read by every consumer in section 6.4.
376///
377/// A provider never touches the database. The snapshot is resolved once at
378/// build and handed to the client whole.
379pub trait ConfigProvider:
380    std::fmt::Debug + xmtp_common::MaybeSend + xmtp_common::MaybeSync
381{
382    fn server_configuration(&self) -> &ServerConfiguration;
383}
384
385/// The provider backed by the copy stored in the client database.
386#[derive(Debug, Clone, Default)]
387pub struct StoredConfigProvider(ServerConfiguration);
388
389impl StoredConfigProvider {
390    pub fn new(configuration: ServerConfiguration) -> Self {
391        Self(configuration)
392    }
393}
394
395impl ConfigProvider for StoredConfigProvider {
396    fn server_configuration(&self) -> &ServerConfiguration {
397        &self.0
398    }
399}
400
401/// A provider a test constructs from any values, with no database and no
402/// fetch. Passing one to the builder disables fetch, store, refresh, and the
403/// identifier check.
404#[derive(Debug, Clone, Default)]
405pub struct StaticConfigProvider(ServerConfiguration);
406
407impl StaticConfigProvider {
408    pub fn new(configuration: ServerConfiguration) -> Self {
409        Self(configuration)
410    }
411
412    /// Start from the compiled defaults and change only what a test needs.
413    pub fn edited(edit: impl FnOnce(&mut ServerConfiguration)) -> Self {
414        let mut configuration = ServerConfiguration {
415            identifier: "org.xmtp.static".to_owned(),
416            ..Default::default()
417        };
418        edit(&mut configuration);
419        Self(configuration)
420    }
421}
422
423impl ConfigProvider for StaticConfigProvider {
424    fn server_configuration(&self) -> &ServerConfiguration {
425        &self.0
426    }
427}
428
429#[cfg(test)]
430mod tests;