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}