Skip to main content

quadra_a_core/
config.rs

1use crate::e2e::LocalE2EConfig;
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
7pub struct Config {
8    pub identity: Option<IdentityConfig>,
9    #[serde(default, rename = "deviceIdentity")]
10    pub device_identity: Option<DeviceIdentityConfig>,
11    #[serde(rename = "agentCard")]
12    pub agent_card: Option<AgentCardConfig>,
13    #[serde(default)]
14    pub published: Option<bool>,
15    #[serde(default, rename = "relayInviteToken")]
16    pub relay_invite_token: Option<String>,
17    #[serde(default)]
18    pub aliases: HashMap<String, String>,
19    #[serde(default, rename = "trustConfig")]
20    pub trust_config: Option<TrustConfig>,
21    #[serde(default, rename = "reachabilityPolicy")]
22    pub reachability_policy: Option<ReachabilityPolicy>,
23    #[serde(default)]
24    pub e2e: Option<LocalE2EConfig>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "lowercase")]
29pub enum ReachabilityMode {
30    Adaptive,
31    Fixed,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ReachabilityPolicy {
36    pub mode: ReachabilityMode,
37    #[serde(default, rename = "bootstrapProviders")]
38    pub bootstrap_providers: Vec<String>,
39    #[serde(
40        default = "default_target_provider_count",
41        rename = "targetProviderCount"
42    )]
43    pub target_provider_count: u32,
44    #[serde(
45        default = "default_auto_discover_providers",
46        rename = "autoDiscoverProviders"
47    )]
48    pub auto_discover_providers: bool,
49    #[serde(default, rename = "operatorLock")]
50    pub operator_lock: bool,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct IdentityConfig {
55    pub did: String,
56    #[serde(rename = "publicKey")]
57    pub public_key: String,
58    #[serde(rename = "privateKey")]
59    pub private_key: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63pub struct DeviceIdentityConfig {
64    pub seed: String,
65    #[serde(rename = "deviceId")]
66    pub device_id: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct AgentCardConfig {
71    pub name: String,
72    pub description: String,
73    pub capabilities: Vec<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct TrustConfig {
78    #[serde(default)]
79    pub endorsements: HashMap<String, EndorsementV2>,
80    #[serde(default)]
81    pub trust_scores: HashMap<String, CachedTrustScore>,
82    #[serde(default)]
83    pub blocked_agents: HashSet<String>,
84    #[serde(default)]
85    pub blocked_reasons: HashMap<String, String>,
86    #[serde(default)]
87    pub allowed_agents: HashMap<String, AllowedAgent>,
88    #[serde(default)]
89    pub collusion_penalties: HashMap<String, CachedCollusionPenalty>,
90    #[serde(default)]
91    pub seed_peers: Vec<String>,
92    #[serde(default = "default_max_recursion_depth")]
93    pub max_recursion_depth: u8,
94    #[serde(default)]
95    pub decay_half_life: HashMap<String, u32>,
96    #[serde(default = "default_collusion_external_ratio_threshold")]
97    pub collusion_external_ratio_threshold: f64,
98    #[serde(default = "default_collusion_min_cluster_size")]
99    pub collusion_min_cluster_size: usize,
100    #[serde(default = "default_trust_cache_ttl_seconds")]
101    pub trust_cache_ttl_seconds: u64,
102    #[serde(default = "default_scc_cache_ttl_seconds")]
103    pub scc_cache_ttl_seconds: u64,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct EndorsementV2 {
108    pub endorser: String,
109    pub endorsee: String,
110    pub domain: Option<String>,
111    #[serde(rename = "type")]
112    pub endorsement_type: String,
113    pub strength: f64,
114    pub comment: Option<String>,
115    pub timestamp: u64,
116    pub expires: Option<u64>,
117    pub version: String,
118    pub signature: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CachedTrustScore {
123    pub score: f64,
124    pub computed_at: u64,
125    pub ttl: u64,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct CachedCollusionPenalty {
130    pub penalty: f64,
131    pub computed_at: u64,
132    pub ttl: u64,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct AllowedAgent {
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub note: Option<String>,
139    #[serde(default, rename = "addedAt", skip_serializing_if = "Option::is_none")]
140    pub added_at: Option<u64>,
141}
142
143fn default_max_recursion_depth() -> u8 {
144    3
145}
146
147fn default_target_provider_count() -> u32 {
148    3
149}
150
151fn default_auto_discover_providers() -> bool {
152    true
153}
154
155fn default_bootstrap_providers() -> Vec<String> {
156    vec!["ws://relay-sg-1.quadra-a.com:8080".to_string()]
157}
158
159fn default_collusion_external_ratio_threshold() -> f64 {
160    0.2
161}
162
163fn default_collusion_min_cluster_size() -> usize {
164    4
165}
166
167fn default_trust_cache_ttl_seconds() -> u64 {
168    300
169}
170
171fn default_scc_cache_ttl_seconds() -> u64 {
172    300
173}
174
175fn normalize_optional_string(value: Option<&str>) -> Option<String> {
176    value
177        .map(str::trim)
178        .filter(|value| !value.is_empty())
179        .map(ToOwned::to_owned)
180}
181
182fn normalize_provider_urls(values: Vec<String>) -> Vec<String> {
183    let mut normalized = Vec::new();
184    for value in values.into_iter().map(|value| value.trim().to_string()) {
185        if value.is_empty() || normalized.iter().any(|existing| existing == &value) {
186            continue;
187        }
188        normalized.push(value);
189    }
190    normalized
191}
192
193fn env_bootstrap_providers() -> Vec<String> {
194    let env_value = std::env::var("QUADRA_A_RELAY_URLS")
195        .ok()
196        .or_else(|| std::env::var("HW1_RELAY_URLS").ok());
197
198    let providers = env_value
199        .map(|value| {
200            value
201                .split(',')
202                .map(|entry| entry.trim().to_string())
203                .collect::<Vec<_>>()
204        })
205        .unwrap_or_default();
206
207    let normalized = normalize_provider_urls(providers);
208    if normalized.is_empty() {
209        default_bootstrap_providers()
210    } else {
211        normalized
212    }
213}
214
215fn env_disable_auto_relay_supplement() -> bool {
216    normalize_optional_string(
217        std::env::var("QUADRA_A_DISABLE_AUTO_RELAY_SUPPLEMENT")
218            .ok()
219            .as_deref(),
220    )
221    .map(|value| matches!(value.to_lowercase().as_str(), "1" | "true" | "yes" | "on"))
222    .unwrap_or(false)
223}
224
225fn current_unix_seconds() -> u64 {
226    SystemTime::now()
227        .duration_since(UNIX_EPOCH)
228        .unwrap_or_default()
229        .as_secs()
230}
231
232impl Default for ReachabilityPolicy {
233    fn default() -> Self {
234        let auto_discover_providers = !env_disable_auto_relay_supplement();
235        Self {
236            mode: if auto_discover_providers {
237                ReachabilityMode::Adaptive
238            } else {
239                ReachabilityMode::Fixed
240            },
241            bootstrap_providers: env_bootstrap_providers(),
242            target_provider_count: default_target_provider_count(),
243            auto_discover_providers,
244            operator_lock: false,
245        }
246    }
247}
248
249impl Default for TrustConfig {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl TrustConfig {
256    pub fn new() -> Self {
257        let mut decay_half_life = HashMap::new();
258        decay_half_life.insert("default".to_string(), 90);
259        decay_half_life.insert("translation".to_string(), 30);
260        decay_half_life.insert("transcription".to_string(), 30);
261        decay_half_life.insert("data-entry".to_string(), 30);
262        decay_half_life.insert("moderation".to_string(), 30);
263        decay_half_life.insert("research".to_string(), 180);
264        decay_half_life.insert("architecture".to_string(), 180);
265        decay_half_life.insert("security-audit".to_string(), 180);
266        decay_half_life.insert("legal-review".to_string(), 180);
267
268        Self {
269            endorsements: HashMap::new(),
270            trust_scores: HashMap::new(),
271            blocked_agents: HashSet::new(),
272            blocked_reasons: HashMap::new(),
273            allowed_agents: HashMap::new(),
274            collusion_penalties: HashMap::new(),
275            seed_peers: Vec::new(),
276            max_recursion_depth: default_max_recursion_depth(),
277            decay_half_life,
278            collusion_external_ratio_threshold: default_collusion_external_ratio_threshold(),
279            collusion_min_cluster_size: default_collusion_min_cluster_size(),
280            trust_cache_ttl_seconds: default_trust_cache_ttl_seconds(),
281            scc_cache_ttl_seconds: default_scc_cache_ttl_seconds(),
282        }
283    }
284
285    pub fn add_endorsement(&mut self, endorsement: EndorsementV2) {
286        self.endorsements
287            .insert(endorsement_cache_key(&endorsement), endorsement);
288    }
289
290    pub fn block_agent(&mut self, did: String) {
291        self.blocked_agents.insert(did.clone());
292        self.allowed_agents.remove(&did);
293    }
294
295    pub fn block_agent_with_reason(&mut self, did: String, reason: Option<String>) {
296        self.block_agent(did.clone());
297        if let Some(reason) = reason.map(|value| value.trim().to_string()) {
298            if !reason.is_empty() {
299                self.blocked_reasons.insert(did, reason);
300            }
301        }
302    }
303
304    pub fn unblock_agent(&mut self, did: &str) {
305        self.blocked_agents.remove(did);
306        self.blocked_reasons.remove(did);
307    }
308
309    pub fn is_blocked(&self, did: &str) -> bool {
310        self.blocked_agents.contains(did)
311    }
312
313    pub fn blocked_reason(&self, did: &str) -> Option<&str> {
314        self.blocked_reasons.get(did).map(String::as_str)
315    }
316
317    pub fn allow_agent(&mut self, did: String, note: Option<String>) {
318        self.unblock_agent(&did);
319        self.allowed_agents.insert(
320            did,
321            AllowedAgent {
322                note: note
323                    .map(|value| value.trim().to_string())
324                    .filter(|value| !value.is_empty()),
325                added_at: Some(current_unix_seconds()),
326            },
327        );
328    }
329
330    pub fn is_allowed(&self, did: &str) -> bool {
331        self.allowed_agents.contains_key(did)
332    }
333
334    pub fn cache_trust_score(&mut self, did: String, score: f64, ttl_seconds: u64) {
335        self.trust_scores.insert(
336            did,
337            CachedTrustScore {
338                score,
339                computed_at: current_unix_seconds(),
340                ttl: ttl_seconds,
341            },
342        );
343    }
344
345    pub fn get_cached_trust_score(&self, did: &str) -> Option<f64> {
346        self.trust_scores.get(did).and_then(|cached| {
347            let now = current_unix_seconds();
348            if now.saturating_sub(cached.computed_at) < cached.ttl {
349                Some(cached.score)
350            } else {
351                None
352            }
353        })
354    }
355
356    pub fn cache_collusion_penalty(&mut self, did: String, penalty: f64, ttl_seconds: u64) {
357        self.collusion_penalties.insert(
358            did,
359            CachedCollusionPenalty {
360                penalty,
361                computed_at: current_unix_seconds(),
362                ttl: ttl_seconds,
363            },
364        );
365    }
366
367    pub fn get_cached_collusion_penalty(&self, did: &str) -> Option<f64> {
368        self.collusion_penalties.get(did).and_then(|cached| {
369            let now = current_unix_seconds();
370            if now.saturating_sub(cached.computed_at) < cached.ttl {
371                Some(cached.penalty)
372            } else {
373                None
374            }
375        })
376    }
377}
378
379pub fn resolve_reachability_policy(
380    explicit_relay: Option<&str>,
381    config: Option<&Config>,
382) -> ReachabilityPolicy {
383    let mut policy = config
384        .and_then(|cfg| cfg.reachability_policy.clone())
385        .unwrap_or_default();
386
387    if policy.bootstrap_providers.is_empty() {
388        policy.bootstrap_providers = env_bootstrap_providers();
389    } else {
390        policy.bootstrap_providers = normalize_provider_urls(policy.bootstrap_providers);
391    }
392
393    if let Some(relay) = normalize_optional_string(explicit_relay) {
394        policy.bootstrap_providers = vec![relay];
395        policy.mode = ReachabilityMode::Fixed;
396        policy.auto_discover_providers = false;
397        policy.target_provider_count = 1;
398    }
399
400    if policy.target_provider_count == 0 {
401        policy.target_provider_count = default_target_provider_count();
402    }
403
404    if matches!(policy.mode, ReachabilityMode::Fixed) && policy.bootstrap_providers.is_empty() {
405        policy.bootstrap_providers = default_bootstrap_providers();
406    }
407
408    if policy.bootstrap_providers.is_empty() {
409        policy.bootstrap_providers = default_bootstrap_providers();
410    }
411
412    policy
413}
414
415pub fn resolve_relay_invite_token(
416    explicit: Option<&str>,
417    config: Option<&Config>,
418) -> Option<String> {
419    normalize_optional_string(explicit)
420        .or_else(|| {
421            normalize_optional_string(std::env::var("QUADRA_A_INVITE_TOKEN").ok().as_deref())
422        })
423        .or_else(|| normalize_optional_string(std::env::var("HW1_INVITE_TOKEN").ok().as_deref()))
424        .or_else(|| {
425            config.and_then(|cfg| normalize_optional_string(cfg.relay_invite_token.as_deref()))
426        })
427}
428
429pub fn endorsement_cache_key(endorsement: &EndorsementV2) -> String {
430    format!(
431        "{}:{}:{}:{}",
432        endorsement.endorser,
433        endorsement.endorsee,
434        endorsement.domain.as_deref().unwrap_or("*"),
435        endorsement.endorsement_type
436    )
437}
438
439#[cfg(test)]
440mod tests {
441    use super::Config;
442    use crate::e2e::ensure_local_e2e_config;
443    use crate::identity::{derive_did, KeyPair};
444    use serde_json::json;
445
446    #[test]
447    fn parses_empty_e2e_config_and_rebuilds_valid_state() {
448        let keypair = KeyPair::generate();
449        let did = derive_did(keypair.verifying_key.as_bytes());
450        let mut config: Config = serde_json::from_value(json!({
451            "identity": {
452                "did": did,
453                "publicKey": keypair.public_key_hex(),
454                "privateKey": keypair.private_key_hex(),
455            },
456            "e2e": {}
457        }))
458        .expect("config parses");
459
460        assert!(config.e2e.as_ref().is_some_and(|e2e| !e2e.is_valid()));
461
462        let created = ensure_local_e2e_config(&mut config).expect("e2e config rebuilt");
463        assert!(created);
464        assert!(config.e2e.as_ref().is_some_and(|e2e| e2e.is_valid()));
465        assert!(config
466            .device_identity
467            .as_ref()
468            .is_some_and(|device_identity| !device_identity.seed.is_empty()
469                && !device_identity.device_id.is_empty()));
470    }
471
472    #[test]
473    fn backfills_device_identity_from_existing_device_id() {
474        let keypair = KeyPair::generate();
475        let did = derive_did(keypair.verifying_key.as_bytes());
476        let device_id = "device-existing".to_string();
477        let mut config: Config = serde_json::from_value(json!({
478            "identity": {
479                "did": did,
480                "publicKey": keypair.public_key_hex(),
481                "privateKey": keypair.private_key_hex(),
482            },
483            "e2e": {
484                "currentDeviceId": device_id,
485                "devices": {
486                    "device-existing": {
487                        "deviceId": "device-existing",
488                        "createdAt": 1,
489                        "identityKey": {
490                            "publicKey": "11",
491                            "privateKey": "22"
492                        },
493                        "signedPreKey": {
494                            "signedPreKeyId": 1,
495                            "publicKey": "33",
496                            "privateKey": "44",
497                            "signature": "55",
498                            "createdAt": 1
499                        },
500                        "oneTimePreKeys": [],
501                        "lastResupplyAt": 1,
502                        "sessions": {}
503                    }
504                }
505            }
506        }))
507        .expect("config parses");
508
509        let created = ensure_local_e2e_config(&mut config).expect("device identity backfilled");
510        assert!(!created);
511        assert_eq!(
512            config
513                .device_identity
514                .as_ref()
515                .expect("device identity present")
516                .device_id,
517            "device-existing"
518        );
519    }
520}