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}