Skip to main content

lxmf_sdk/types/
config.rs

1use crate::error::{code, ErrorCategory, SdkError};
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4use std::collections::BTreeMap;
5
6#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "kebab-case")]
8#[non_exhaustive]
9pub enum Profile {
10    DesktopFull,
11    DesktopLocalRuntime,
12    EmbeddedAlloc,
13}
14
15#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17#[non_exhaustive]
18pub enum BindMode {
19    LocalOnly,
20    Remote,
21}
22
23#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25#[non_exhaustive]
26pub enum AuthMode {
27    LocalTrusted,
28    Token,
29    Mtls,
30}
31
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum OverflowPolicy {
36    Reject,
37    DropOldest,
38    Block,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43#[non_exhaustive]
44pub enum StoreForwardCapacityPolicy {
45    RejectNew,
46    DropOldest,
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51#[non_exhaustive]
52pub enum StoreForwardEvictionPriority {
53    OldestFirst,
54    TerminalFirst,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct StoreForwardConfig {
60    pub max_messages: usize,
61    pub max_message_age_ms: u64,
62    pub capacity_policy: StoreForwardCapacityPolicy,
63    pub eviction_priority: StoreForwardEvictionPriority,
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum EventSinkKind {
70    Webhook,
71    Mqtt,
72    Custom,
73}
74
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76#[non_exhaustive]
77pub struct EventSinkConfig {
78    pub enabled: bool,
79    pub max_event_bytes: usize,
80    pub allow_kinds: Vec<EventSinkKind>,
81    #[serde(default)]
82    pub extensions: BTreeMap<String, JsonValue>,
83}
84
85#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
86#[non_exhaustive]
87pub struct EventStreamConfig {
88    pub max_poll_events: usize,
89    pub max_event_bytes: usize,
90    pub max_batch_bytes: usize,
91    pub max_extension_keys: usize,
92}
93
94#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
95#[serde(rename_all = "snake_case")]
96#[non_exhaustive]
97pub enum RedactionTransform {
98    Hash,
99    Truncate,
100    Redact,
101}
102
103#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct RedactionConfig {
106    pub enabled: bool,
107    pub sensitive_transform: RedactionTransform,
108    pub break_glass_allowed: bool,
109    pub break_glass_ttl_ms: Option<u64>,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
113#[non_exhaustive]
114pub struct TokenAuthConfig {
115    pub issuer: String,
116    pub audience: String,
117    pub jti_cache_ttl_ms: u64,
118    pub clock_skew_ms: u64,
119    pub shared_secret: String,
120}
121
122#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
123#[non_exhaustive]
124pub struct MtlsAuthConfig {
125    pub ca_bundle_path: String,
126    pub require_client_cert: bool,
127    pub allowed_san: Option<String>,
128    pub client_cert_path: Option<String>,
129    pub client_key_path: Option<String>,
130}
131
132#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
133#[non_exhaustive]
134pub struct RpcBackendConfig {
135    pub listen_addr: String,
136    pub read_timeout_ms: u64,
137    pub write_timeout_ms: u64,
138    pub max_header_bytes: usize,
139    pub max_body_bytes: usize,
140    pub token_auth: Option<TokenAuthConfig>,
141    pub mtls_auth: Option<MtlsAuthConfig>,
142}
143
144#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
145#[non_exhaustive]
146pub struct SdkConfig {
147    pub profile: Profile,
148    pub bind_mode: BindMode,
149    pub auth_mode: AuthMode,
150    pub overflow_policy: OverflowPolicy,
151    pub block_timeout_ms: Option<u64>,
152    #[serde(default = "default_store_forward_for_deser")]
153    pub store_forward: StoreForwardConfig,
154    pub event_stream: EventStreamConfig,
155    #[serde(default = "default_event_sink_for_deser")]
156    pub event_sink: EventSinkConfig,
157    pub idempotency_ttl_ms: u64,
158    pub redaction: RedactionConfig,
159    pub rpc_backend: Option<RpcBackendConfig>,
160    #[serde(default)]
161    pub extensions: BTreeMap<String, JsonValue>,
162}
163
164const DEFAULT_RPC_LISTEN_ADDR: &str = "127.0.0.1:4242";
165
166fn default_event_stream(profile: &Profile) -> EventStreamConfig {
167    match profile {
168        Profile::DesktopFull => EventStreamConfig {
169            max_poll_events: 256,
170            max_event_bytes: 65_536,
171            max_batch_bytes: 1_048_576,
172            max_extension_keys: 32,
173        },
174        Profile::DesktopLocalRuntime => EventStreamConfig {
175            max_poll_events: 64,
176            max_event_bytes: 32_768,
177            max_batch_bytes: 1_048_576,
178            max_extension_keys: 32,
179        },
180        Profile::EmbeddedAlloc => EventStreamConfig {
181            max_poll_events: 32,
182            max_event_bytes: 8_192,
183            max_batch_bytes: 262_144,
184            max_extension_keys: 32,
185        },
186    }
187}
188
189fn default_redaction() -> RedactionConfig {
190    RedactionConfig {
191        enabled: true,
192        sensitive_transform: RedactionTransform::Hash,
193        break_glass_allowed: false,
194        break_glass_ttl_ms: None,
195    }
196}
197
198fn default_rpc_backend(listen_addr: impl Into<String>) -> RpcBackendConfig {
199    RpcBackendConfig {
200        listen_addr: listen_addr.into(),
201        read_timeout_ms: 5_000,
202        write_timeout_ms: 5_000,
203        max_header_bytes: 16_384,
204        max_body_bytes: 1_048_576,
205        token_auth: None,
206        mtls_auth: None,
207    }
208}
209
210fn default_store_forward(profile: &Profile) -> StoreForwardConfig {
211    match profile {
212        Profile::DesktopFull | Profile::DesktopLocalRuntime => StoreForwardConfig {
213            max_messages: 50_000,
214            max_message_age_ms: 604_800_000,
215            capacity_policy: StoreForwardCapacityPolicy::DropOldest,
216            eviction_priority: StoreForwardEvictionPriority::TerminalFirst,
217        },
218        Profile::EmbeddedAlloc => StoreForwardConfig {
219            max_messages: 2_000,
220            max_message_age_ms: 86_400_000,
221            capacity_policy: StoreForwardCapacityPolicy::DropOldest,
222            eviction_priority: StoreForwardEvictionPriority::TerminalFirst,
223        },
224    }
225}
226
227fn default_store_forward_for_deser() -> StoreForwardConfig {
228    default_store_forward(&Profile::DesktopFull)
229}
230
231fn default_event_sink(profile: &Profile) -> EventSinkConfig {
232    let max_event_bytes = match profile {
233        Profile::DesktopFull => 65_536,
234        Profile::DesktopLocalRuntime => 32_768,
235        Profile::EmbeddedAlloc => 8_192,
236    };
237    EventSinkConfig {
238        enabled: false,
239        max_event_bytes,
240        allow_kinds: vec![EventSinkKind::Webhook, EventSinkKind::Mqtt, EventSinkKind::Custom],
241        extensions: BTreeMap::new(),
242    }
243}
244
245fn default_event_sink_for_deser() -> EventSinkConfig {
246    default_event_sink(&Profile::DesktopFull)
247}
248
249impl SdkConfig {
250    pub fn desktop_local_default() -> Self {
251        Self {
252            profile: Profile::DesktopLocalRuntime,
253            bind_mode: BindMode::LocalOnly,
254            auth_mode: AuthMode::LocalTrusted,
255            overflow_policy: OverflowPolicy::Reject,
256            block_timeout_ms: None,
257            store_forward: default_store_forward(&Profile::DesktopLocalRuntime),
258            event_stream: default_event_stream(&Profile::DesktopLocalRuntime),
259            event_sink: default_event_sink(&Profile::DesktopLocalRuntime),
260            idempotency_ttl_ms: 43_200_000,
261            redaction: default_redaction(),
262            rpc_backend: Some(default_rpc_backend(DEFAULT_RPC_LISTEN_ADDR)),
263            extensions: BTreeMap::new(),
264        }
265    }
266
267    pub fn desktop_full_default() -> Self {
268        Self {
269            profile: Profile::DesktopFull,
270            bind_mode: BindMode::LocalOnly,
271            auth_mode: AuthMode::LocalTrusted,
272            overflow_policy: OverflowPolicy::Reject,
273            block_timeout_ms: None,
274            store_forward: default_store_forward(&Profile::DesktopFull),
275            event_stream: default_event_stream(&Profile::DesktopFull),
276            event_sink: default_event_sink(&Profile::DesktopFull),
277            idempotency_ttl_ms: 86_400_000,
278            redaction: default_redaction(),
279            rpc_backend: Some(default_rpc_backend(DEFAULT_RPC_LISTEN_ADDR)),
280            extensions: BTreeMap::new(),
281        }
282    }
283
284    pub fn embedded_alloc_default() -> Self {
285        Self {
286            profile: Profile::EmbeddedAlloc,
287            bind_mode: BindMode::LocalOnly,
288            auth_mode: AuthMode::LocalTrusted,
289            overflow_policy: OverflowPolicy::Reject,
290            block_timeout_ms: None,
291            store_forward: default_store_forward(&Profile::EmbeddedAlloc),
292            event_stream: default_event_stream(&Profile::EmbeddedAlloc),
293            event_sink: default_event_sink(&Profile::EmbeddedAlloc),
294            idempotency_ttl_ms: 7_200_000,
295            redaction: default_redaction(),
296            rpc_backend: Some(RpcBackendConfig {
297                listen_addr: DEFAULT_RPC_LISTEN_ADDR.to_owned(),
298                read_timeout_ms: 2_000,
299                write_timeout_ms: 2_000,
300                max_header_bytes: 8_192,
301                max_body_bytes: 65_536,
302                token_auth: None,
303                mtls_auth: None,
304            }),
305            extensions: BTreeMap::new(),
306        }
307    }
308
309    pub fn with_rpc_listen_addr(mut self, listen_addr: impl Into<String>) -> Self {
310        let listen_addr = listen_addr.into();
311        match self.rpc_backend.as_mut() {
312            Some(backend) => backend.listen_addr = listen_addr,
313            None => self.rpc_backend = Some(default_rpc_backend(listen_addr)),
314        }
315        self
316    }
317
318    pub fn with_token_auth(
319        mut self,
320        issuer: impl Into<String>,
321        audience: impl Into<String>,
322        shared_secret: impl Into<String>,
323    ) -> Self {
324        self.bind_mode = BindMode::Remote;
325        self.auth_mode = AuthMode::Token;
326        let backend =
327            self.rpc_backend.get_or_insert_with(|| default_rpc_backend(DEFAULT_RPC_LISTEN_ADDR));
328        backend.mtls_auth = None;
329        backend.token_auth = Some(TokenAuthConfig {
330            issuer: issuer.into(),
331            audience: audience.into(),
332            jti_cache_ttl_ms: 60_000,
333            clock_skew_ms: 5_000,
334            shared_secret: shared_secret.into(),
335        });
336        self
337    }
338
339    pub fn with_mtls_auth(mut self, ca_bundle_path: impl Into<String>) -> Self {
340        self.bind_mode = BindMode::Remote;
341        self.auth_mode = AuthMode::Mtls;
342        let backend =
343            self.rpc_backend.get_or_insert_with(|| default_rpc_backend(DEFAULT_RPC_LISTEN_ADDR));
344        backend.token_auth = None;
345        backend.mtls_auth = Some(MtlsAuthConfig {
346            ca_bundle_path: ca_bundle_path.into(),
347            require_client_cert: false,
348            allowed_san: None,
349            client_cert_path: None,
350            client_key_path: None,
351        });
352        self
353    }
354
355    pub fn with_mtls_client_credentials(
356        mut self,
357        client_cert_path: impl Into<String>,
358        client_key_path: impl Into<String>,
359    ) -> Self {
360        self.bind_mode = BindMode::Remote;
361        self.auth_mode = AuthMode::Mtls;
362        let mtls =
363            self.rpc_backend.get_or_insert_with(|| default_rpc_backend(DEFAULT_RPC_LISTEN_ADDR));
364        mtls.token_auth = None;
365        if mtls.mtls_auth.is_none() {
366            mtls.mtls_auth = Some(MtlsAuthConfig {
367                ca_bundle_path: "ca.pem".to_owned(),
368                require_client_cert: true,
369                allowed_san: None,
370                client_cert_path: None,
371                client_key_path: None,
372            });
373        }
374        let auth = mtls.mtls_auth.as_mut().expect("mtls auth");
375        auth.require_client_cert = true;
376        auth.client_cert_path = Some(client_cert_path.into());
377        auth.client_key_path = Some(client_key_path.into());
378        self
379    }
380
381    pub fn with_store_forward_limits(
382        mut self,
383        max_messages: usize,
384        max_message_age_ms: u64,
385    ) -> Self {
386        self.store_forward.max_messages = max_messages;
387        self.store_forward.max_message_age_ms = max_message_age_ms;
388        self
389    }
390
391    pub fn with_store_forward_policy(
392        mut self,
393        capacity_policy: StoreForwardCapacityPolicy,
394        eviction_priority: StoreForwardEvictionPriority,
395    ) -> Self {
396        self.store_forward.capacity_policy = capacity_policy;
397        self.store_forward.eviction_priority = eviction_priority;
398        self
399    }
400
401    pub fn with_event_sink(
402        mut self,
403        enabled: bool,
404        max_event_bytes: usize,
405        allow_kinds: Vec<EventSinkKind>,
406    ) -> Self {
407        self.event_sink.enabled = enabled;
408        self.event_sink.max_event_bytes = max_event_bytes;
409        self.event_sink.allow_kinds = allow_kinds;
410        self
411    }
412
413    pub fn validate(&self) -> Result<(), SdkError> {
414        if self.overflow_policy == OverflowPolicy::Block && self.block_timeout_ms.is_none() {
415            return Err(SdkError::new(
416                code::VALIDATION_INVALID_ARGUMENT,
417                ErrorCategory::Validation,
418                "overflow_policy=block requires block_timeout_ms",
419            )
420            .with_user_actionable(true)
421            .with_detail("field", JsonValue::String("block_timeout_ms".to_owned())));
422        }
423
424        if self.event_stream.max_extension_keys > 32 {
425            return Err(SdkError::new(
426                code::VALIDATION_MAX_EXTENSION_KEYS_EXCEEDED,
427                ErrorCategory::Validation,
428                "event stream extension key limit exceeds contract maximum",
429            )
430            .with_user_actionable(true)
431            .with_detail("limit_name", JsonValue::String("max_extension_keys".to_owned()))
432            .with_detail("limit_value", JsonValue::from(self.event_stream.max_extension_keys)));
433        }
434
435        if !(256..=2_097_152).contains(&self.event_sink.max_event_bytes) {
436            return Err(SdkError::new(
437                code::VALIDATION_INVALID_ARGUMENT,
438                ErrorCategory::Validation,
439                "event_sink.max_event_bytes must be in the range 256..=2097152",
440            )
441            .with_user_actionable(true)
442            .with_detail("field", JsonValue::String("event_sink.max_event_bytes".to_owned())));
443        }
444
445        if self.event_sink.allow_kinds.is_empty() {
446            return Err(SdkError::new(
447                code::VALIDATION_INVALID_ARGUMENT,
448                ErrorCategory::Validation,
449                "event_sink.allow_kinds must include at least one kind",
450            )
451            .with_user_actionable(true)
452            .with_detail("field", JsonValue::String("event_sink.allow_kinds".to_owned())));
453        }
454
455        if self.event_sink.enabled && !self.redaction.enabled {
456            return Err(SdkError::new(
457                code::SECURITY_REDACTION_REQUIRED,
458                ErrorCategory::Security,
459                "event sink requires redaction.enabled=true",
460            )
461            .with_user_actionable(true)
462            .with_detail("field", JsonValue::String("redaction.enabled".to_owned())));
463        }
464
465        if self.store_forward.max_messages == 0 {
466            return Err(SdkError::new(
467                code::VALIDATION_INVALID_ARGUMENT,
468                ErrorCategory::Validation,
469                "store_forward.max_messages must be greater than zero",
470            )
471            .with_user_actionable(true)
472            .with_detail("field", JsonValue::String("store_forward.max_messages".to_owned())));
473        }
474
475        if self.store_forward.max_message_age_ms == 0 {
476            return Err(SdkError::new(
477                code::VALIDATION_INVALID_ARGUMENT,
478                ErrorCategory::Validation,
479                "store_forward.max_message_age_ms must be greater than zero",
480            )
481            .with_user_actionable(true)
482            .with_detail(
483                "field",
484                JsonValue::String("store_forward.max_message_age_ms".to_owned()),
485            ));
486        }
487
488        match self.bind_mode {
489            BindMode::LocalOnly => {
490                if self.auth_mode != AuthMode::LocalTrusted {
491                    return Err(SdkError::new(
492                        code::SECURITY_AUTH_REQUIRED,
493                        ErrorCategory::Security,
494                        "local_only bind mode requires local_trusted auth mode",
495                    )
496                    .with_user_actionable(true));
497                }
498            }
499            BindMode::Remote => {
500                if !matches!(self.auth_mode, AuthMode::Token | AuthMode::Mtls) {
501                    return Err(SdkError::new(
502                        code::SECURITY_REMOTE_BIND_DISALLOWED,
503                        ErrorCategory::Security,
504                        "remote bind mode requires token or mtls auth mode",
505                    )
506                    .with_user_actionable(true));
507                }
508            }
509        }
510
511        match self.auth_mode {
512            AuthMode::LocalTrusted => {}
513            AuthMode::Token => {
514                let token_auth =
515                    self.rpc_backend.as_ref().and_then(|backend| backend.token_auth.as_ref());
516                let Some(token_auth) = token_auth else {
517                    return Err(SdkError::new(
518                        code::SECURITY_AUTH_REQUIRED,
519                        ErrorCategory::Security,
520                        "token auth mode requires rpc_backend.token_auth configuration",
521                    )
522                    .with_user_actionable(true));
523                };
524                if token_auth.issuer.trim().is_empty() || token_auth.audience.trim().is_empty() {
525                    return Err(SdkError::new(
526                        code::VALIDATION_INVALID_ARGUMENT,
527                        ErrorCategory::Validation,
528                        "token auth configuration requires issuer and audience",
529                    )
530                    .with_user_actionable(true));
531                }
532                if token_auth.jti_cache_ttl_ms == 0 {
533                    return Err(SdkError::new(
534                        code::VALIDATION_INVALID_ARGUMENT,
535                        ErrorCategory::Validation,
536                        "token auth jti_cache_ttl_ms must be greater than zero",
537                    )
538                    .with_user_actionable(true));
539                }
540                if token_auth.shared_secret.trim().is_empty() {
541                    return Err(SdkError::new(
542                        code::SECURITY_AUTH_REQUIRED,
543                        ErrorCategory::Security,
544                        "token auth shared_secret must be configured",
545                    )
546                    .with_user_actionable(true));
547                }
548            }
549            AuthMode::Mtls => {
550                if self.profile == Profile::EmbeddedAlloc {
551                    return Err(SdkError::new(
552                        code::VALIDATION_INVALID_ARGUMENT,
553                        ErrorCategory::Validation,
554                        "embedded-alloc profile does not support mtls auth mode",
555                    )
556                    .with_user_actionable(true));
557                }
558                let mtls_auth =
559                    self.rpc_backend.as_ref().and_then(|backend| backend.mtls_auth.as_ref());
560                let Some(mtls_auth) = mtls_auth else {
561                    return Err(SdkError::new(
562                        code::SECURITY_AUTH_REQUIRED,
563                        ErrorCategory::Security,
564                        "mtls auth mode requires rpc_backend.mtls_auth configuration",
565                    )
566                    .with_user_actionable(true));
567                };
568                if mtls_auth.ca_bundle_path.trim().is_empty() {
569                    return Err(SdkError::new(
570                        code::VALIDATION_INVALID_ARGUMENT,
571                        ErrorCategory::Validation,
572                        "mtls auth mode requires non-empty rpc_backend.mtls_auth.ca_bundle_path",
573                    )
574                    .with_user_actionable(true));
575                }
576                let client_cert_path = mtls_auth
577                    .client_cert_path
578                    .as_deref()
579                    .map(str::trim)
580                    .filter(|value| !value.is_empty());
581                let client_key_path = mtls_auth
582                    .client_key_path
583                    .as_deref()
584                    .map(str::trim)
585                    .filter(|value| !value.is_empty());
586                if client_cert_path.is_some() ^ client_key_path.is_some() {
587                    return Err(SdkError::new(
588                        code::VALIDATION_INVALID_ARGUMENT,
589                        ErrorCategory::Validation,
590                        "mtls client certificate and key paths must be configured together",
591                    )
592                    .with_user_actionable(true));
593                }
594                if mtls_auth.require_client_cert
595                    && (client_cert_path.is_none() || client_key_path.is_none())
596                {
597                    return Err(SdkError::new(
598                        code::SECURITY_AUTH_REQUIRED,
599                        ErrorCategory::Security,
600                        "mtls auth mode with require_client_cert=true requires client_cert_path and client_key_path",
601                    )
602                    .with_user_actionable(true));
603                }
604            }
605        }
606
607        Ok(())
608    }
609}