1use crate::gtbind::PackBinding;
2use crate::gtbind::TenantBindings;
3use crate::oauth::OAuthBrokerConfig;
4use crate::runner::mocks::MocksConfig;
5use crate::trace::TraceConfig;
6use crate::validate::ValidationConfig;
7use anyhow::{Context, Result};
8use parking_lot::RwLock;
9use serde::Deserialize;
10use serde_json::Value;
11use serde_yaml_bw as serde_yaml;
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::str::FromStr;
16use std::sync::Arc;
17
18#[derive(Debug, Clone)]
19pub struct HostConfig {
20 pub tenant: String,
21 pub bindings_path: PathBuf,
22 pub flow_type_bindings: HashMap<String, FlowBinding>,
23 pub rate_limits: RateLimits,
24 pub retry: FlowRetryConfig,
25 pub http_enabled: bool,
26 pub secrets_policy: SecretsPolicy,
27 pub state_store_policy: StateStorePolicy,
28 pub webhook_policy: WebhookPolicy,
29 pub timers: Vec<TimerBinding>,
30 pub oauth: Option<OAuthConfig>,
31 pub mocks: Option<MocksConfig>,
32 pub pack_bindings: Vec<PackBinding>,
33 pub env_passthrough: Vec<String>,
34 pub trace: TraceConfig,
35 pub validation: ValidationConfig,
36 pub operator_policy: OperatorPolicy,
37 pub fast2flow: Fast2FlowRoutingConfig,
38 #[cfg(feature = "agentic-worker")]
43 pub agents: HashMap<String, greentic_aw_runtime::AgentConfig>,
44 #[cfg(feature = "agentic-worker")]
52 pub graphs: HashMap<String, greentic_aw_runtime::graph::GraphConfig>,
53}
54
55#[derive(Debug, Clone, Deserialize)]
56pub struct BindingsFile {
57 pub tenant: String,
58 #[serde(default)]
59 pub flow_type_bindings: HashMap<String, FlowBinding>,
60 #[serde(default)]
61 pub rate_limits: RateLimits,
62 #[serde(default)]
63 pub retry: FlowRetryConfig,
64 #[serde(default)]
65 pub timers: Vec<TimerBinding>,
66 #[serde(default)]
67 pub oauth: Option<OAuthConfig>,
68 #[serde(default)]
69 pub mocks: Option<MocksConfig>,
70 #[serde(default)]
71 pub state_store: StateStorePolicy,
72 #[serde(default)]
73 pub operator: OperatorPolicyConfig,
74 #[serde(default)]
75 pub fast2flow: Fast2FlowRoutingConfig,
76 #[cfg(feature = "agentic-worker")]
81 #[serde(default)]
82 pub agents: HashMap<String, greentic_aw_runtime::AgentConfig>,
83 #[cfg(feature = "agentic-worker")]
88 #[serde(default)]
89 pub graphs: HashMap<String, greentic_aw_runtime::graph::GraphConfig>,
90}
91
92#[derive(Debug, Clone, Deserialize)]
93pub struct FlowBinding {
94 pub adapter: String,
95 #[serde(default)]
96 pub config: serde_yaml::Value,
97 #[serde(default)]
98 pub secrets: Vec<String>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102pub struct RateLimits {
103 #[serde(default = "default_messaging_qps")]
104 pub messaging_send_qps: u32,
105 #[serde(default = "default_messaging_burst")]
106 pub messaging_burst: u32,
107}
108
109#[derive(Debug, Clone)]
110pub struct SecretsPolicy {
111 binding_allowed: HashSet<String>,
113 flow_discovered: Arc<RwLock<HashSet<String>>>,
118 allow_all: bool,
119}
120
121#[derive(Debug, Clone, Deserialize, Default)]
122pub struct OperatorPolicyConfig {
123 #[serde(default)]
124 pub allowed_providers: Vec<String>,
125 #[serde(default)]
126 pub allowed_ops: HashMap<String, Vec<String>>,
127}
128
129#[derive(Debug, Clone)]
130pub struct OperatorPolicy {
131 allow_all: bool,
132 allowed_providers: HashSet<String>,
133 allowed_ops: HashMap<String, HashSet<String>>,
134}
135
136#[derive(Debug, Clone, Deserialize)]
137pub struct Fast2FlowRoutingConfig {
138 #[serde(default)]
139 pub enabled: bool,
140 #[serde(default = "default_fast2flow_component_ref")]
141 pub component_ref: String,
142 #[serde(default = "default_fast2flow_operation")]
143 pub operation: String,
144 #[serde(default)]
145 pub scope: Option<String>,
146 #[serde(default)]
147 pub registry_path: String,
148 #[serde(default)]
149 pub indexes_path: String,
150 #[serde(default = "default_fast2flow_time_budget_ms")]
151 pub time_budget_ms: u64,
152}
153
154impl Default for Fast2FlowRoutingConfig {
155 fn default() -> Self {
156 Self {
157 enabled: false,
158 component_ref: default_fast2flow_component_ref(),
159 operation: default_fast2flow_operation(),
160 scope: None,
161 registry_path: String::new(),
162 indexes_path: String::new(),
163 time_budget_ms: default_fast2flow_time_budget_ms(),
164 }
165 }
166}
167
168fn default_fast2flow_component_ref() -> String {
169 "fast2flow-routing".to_owned()
170}
171
172fn default_fast2flow_operation() -> String {
173 "route".to_owned()
174}
175
176fn default_fast2flow_time_budget_ms() -> u64 {
177 250
178}
179
180#[derive(Debug, Clone, Deserialize)]
181pub struct FlowRetryConfig {
182 #[serde(default = "default_retry_attempts")]
183 pub max_attempts: u32,
184 #[serde(default = "default_retry_base_delay_ms")]
185 pub base_delay_ms: u64,
186}
187
188#[derive(Debug, Clone, Default)]
189pub struct WebhookPolicy {
190 allow_paths: Vec<String>,
191 deny_paths: Vec<String>,
192}
193
194#[derive(Debug, Clone, Deserialize)]
195pub struct StateStorePolicy {
196 #[serde(default = "default_state_store_allow")]
197 pub allow: bool,
198}
199
200#[derive(Debug, Clone, Deserialize)]
201pub struct WebhookBindingConfig {
202 #[serde(default)]
203 pub allow_paths: Vec<String>,
204 #[serde(default)]
205 pub deny_paths: Vec<String>,
206}
207
208#[derive(Debug, Clone, Deserialize)]
209pub struct TimerBinding {
210 pub flow_id: String,
211 pub cron: String,
212 #[serde(default)]
213 pub schedule_id: Option<String>,
214}
215
216#[derive(Debug, Clone, Deserialize)]
217pub struct OAuthConfig {
218 pub http_base_url: String,
219 pub nats_url: String,
220 pub provider: String,
221 #[serde(default)]
222 pub env: Option<String>,
223 #[serde(default)]
224 pub team: Option<String>,
225 #[serde(default)]
226 pub shared_secret: Option<String>,
227}
228
229impl HostConfig {
230 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
231 let path = path.as_ref();
232 let content = fs::read_to_string(path)
233 .with_context(|| format!("failed to read bindings file {path:?}"))?;
234 let bindings: BindingsFile = serde_yaml::from_str(&content)
235 .with_context(|| format!("failed to parse bindings file {path:?}"))?;
236
237 let secrets_policy = SecretsPolicy::from_bindings(&bindings);
238 let http_enabled = bindings.flow_type_bindings.contains_key("messaging");
239 let webhook_policy = bindings
240 .flow_type_bindings
241 .get("webhook")
242 .and_then(|binding| {
243 serde_yaml::from_value::<WebhookBindingConfig>(binding.config.clone())
244 .map(WebhookPolicy::from)
245 .map_err(|err| {
246 tracing::warn!(error = %err, "failed to parse webhook binding config");
247 err
248 })
249 .ok()
250 })
251 .unwrap_or_default();
252
253 Ok(Self {
254 tenant: bindings.tenant.clone(),
255 bindings_path: path.to_path_buf(),
256 flow_type_bindings: bindings.flow_type_bindings.clone(),
257 rate_limits: bindings.rate_limits.clone(),
258 retry: bindings.retry.clone(),
259 http_enabled,
260 secrets_policy,
261 state_store_policy: bindings.state_store.clone(),
262 webhook_policy,
263 timers: bindings.timers.clone(),
264 oauth: bindings.oauth.clone(),
265 mocks: bindings.mocks.clone(),
266 pack_bindings: Vec::new(),
267 env_passthrough: Vec::new(),
268 trace: TraceConfig::from_env(),
269 validation: ValidationConfig::from_env(),
270 operator_policy: OperatorPolicy::from_config(bindings.operator.clone()),
271 fast2flow: bindings.fast2flow.clone(),
272 #[cfg(feature = "agentic-worker")]
273 agents: bindings.agents.clone(),
274 #[cfg(feature = "agentic-worker")]
275 graphs: bindings.graphs.clone(),
276 })
277 }
278
279 pub fn from_gtbind(bindings: TenantBindings) -> Self {
280 Self {
281 tenant: bindings.tenant,
282 bindings_path: PathBuf::from("<gtbind>"),
283 flow_type_bindings: HashMap::new(),
284 rate_limits: RateLimits::default(),
285 retry: FlowRetryConfig::default(),
286 http_enabled: true,
291 secrets_policy: SecretsPolicy::allow_all(),
292 state_store_policy: StateStorePolicy::default(),
293 webhook_policy: WebhookPolicy::default(),
294 timers: Vec::new(),
295 oauth: None,
296 mocks: None,
297 pack_bindings: bindings.packs,
298 env_passthrough: bindings.env_passthrough,
299 trace: TraceConfig::from_env(),
300 validation: ValidationConfig::from_env(),
301 operator_policy: OperatorPolicy::allow_all(),
302 fast2flow: Fast2FlowRoutingConfig::default(),
303 #[cfg(feature = "agentic-worker")]
307 agents: HashMap::new(),
308 #[cfg(feature = "agentic-worker")]
311 graphs: HashMap::new(),
312 }
313 }
314
315 pub fn messaging_binding(&self) -> Option<&FlowBinding> {
316 self.flow_type_bindings.get("messaging")
317 }
318
319 pub fn retry_config(&self) -> FlowRetryConfig {
320 self.retry.clone()
321 }
322
323 pub fn oauth_broker_config(&self) -> Option<OAuthBrokerConfig> {
324 let env_secret = std::env::var("GREENTIC_OAUTH_BROKER_SHARED_SECRET").ok();
325 self.oauth_broker_config_with_env(env_secret.as_deref())
326 }
327
328 fn oauth_broker_config_with_env(&self, env_secret: Option<&str>) -> Option<OAuthBrokerConfig> {
332 let oauth = self.oauth.as_ref()?;
333 let mut cfg = OAuthBrokerConfig::new(&oauth.http_base_url, &oauth.nats_url);
334 if !oauth.provider.is_empty() {
335 cfg.default_provider = Some(oauth.provider.clone());
336 }
337 if let Some(team) = &oauth.team
338 && !team.is_empty()
339 {
340 cfg.team = Some(team.clone());
341 }
342 cfg.shared_secret = env_secret
344 .map(str::to_owned)
345 .or_else(|| oauth.shared_secret.clone());
346 Some(cfg)
347 }
348
349 pub fn tenant_ctx(&self) -> greentic_types::TenantCtx {
352 let env = std::env::var("GREENTIC_ENV").unwrap_or_else(|_| "local".to_string());
353 let env_id = greentic_types::EnvId::from_str(&env)
354 .unwrap_or_else(|_| greentic_types::EnvId::new("local").expect("local env id"));
355 let tenant_id = greentic_types::TenantId::from_str(&self.tenant)
356 .unwrap_or_else(|_| greentic_types::TenantId::new("local").expect("tenant id"));
357 greentic_types::TenantCtx::new(env_id, tenant_id)
358 }
359}
360
361impl SecretsPolicy {
362 fn from_bindings(bindings: &BindingsFile) -> Self {
363 let binding_allowed = bindings
364 .flow_type_bindings
365 .values()
366 .flat_map(|binding| binding.secrets.iter().cloned())
367 .collect::<HashSet<_>>();
368 Self {
369 binding_allowed,
370 flow_discovered: Arc::new(RwLock::new(HashSet::new())),
371 allow_all: false,
372 }
373 }
374
375 pub fn is_allowed(&self, key: &str) -> bool {
376 if self.allow_all || self.binding_allowed.contains(key) {
377 return true;
378 }
379 self.flow_discovered.read().contains(key)
380 }
381
382 pub fn allow_all() -> Self {
383 Self {
384 binding_allowed: HashSet::new(),
385 flow_discovered: Arc::new(RwLock::new(HashSet::new())),
386 allow_all: true,
387 }
388 }
389
390 pub fn register_flow_secret(&self, name: &str) {
399 if name.is_empty() {
400 return;
401 }
402 self.flow_discovered.write().insert(name.to_string());
403 }
404
405 pub fn register_flow_secret_refs(&self, value: &Value) {
409 match value {
410 Value::Object(map) => {
411 for (key, val) in map {
412 if key.ends_with("_secret")
413 && let Value::String(name) = val
414 {
415 self.register_flow_secret(name);
416 } else {
417 self.register_flow_secret_refs(val);
418 }
419 }
420 }
421 Value::Array(items) => {
422 for item in items {
423 self.register_flow_secret_refs(item);
424 }
425 }
426 _ => {}
427 }
428 }
429}
430
431impl OperatorPolicy {
432 pub fn from_config(config: OperatorPolicyConfig) -> Self {
433 let allowed_providers = config.allowed_providers.into_iter().collect::<HashSet<_>>();
434 let allowed_ops = config
435 .allowed_ops
436 .into_iter()
437 .map(|(provider, ops)| (provider, ops.into_iter().collect::<HashSet<_>>()))
438 .collect::<HashMap<_, _>>();
439 let allow_all = allowed_providers.is_empty() && allowed_ops.is_empty();
440 Self {
441 allow_all,
442 allowed_providers,
443 allowed_ops,
444 }
445 }
446
447 pub fn allow_all() -> Self {
448 Self {
449 allow_all: true,
450 allowed_providers: HashSet::new(),
451 allowed_ops: HashMap::new(),
452 }
453 }
454
455 pub fn allows_provider(&self, provider_id: Option<&str>, provider_type: &str) -> bool {
456 if self.allow_all {
457 return true;
458 }
459 provider_id
460 .map(|id| self.allowed_providers.contains(id))
461 .unwrap_or(false)
462 || self.allowed_providers.contains(provider_type)
463 }
464
465 pub fn allows_op(&self, provider_id: Option<&str>, provider_type: &str, op_id: &str) -> bool {
466 if self.allow_all {
467 return true;
468 }
469 if let Some(ops) = provider_id.and_then(|id| self.allowed_ops.get(id)) {
470 return ops.contains(op_id);
471 }
472 if let Some(ops) = self.allowed_ops.get(provider_type) {
473 return ops.contains(op_id);
474 }
475 self.allows_provider(provider_id, provider_type)
476 }
477}
478
479impl Default for RateLimits {
480 fn default() -> Self {
481 Self {
482 messaging_send_qps: default_messaging_qps(),
483 messaging_burst: default_messaging_burst(),
484 }
485 }
486}
487
488impl Default for StateStorePolicy {
489 fn default() -> Self {
490 Self {
491 allow: default_state_store_allow(),
492 }
493 }
494}
495
496fn default_messaging_qps() -> u32 {
497 10
498}
499
500fn default_messaging_burst() -> u32 {
501 20
502}
503
504fn default_state_store_allow() -> bool {
505 true
506}
507
508impl From<WebhookBindingConfig> for WebhookPolicy {
509 fn from(value: WebhookBindingConfig) -> Self {
510 Self {
511 allow_paths: value.allow_paths,
512 deny_paths: value.deny_paths,
513 }
514 }
515}
516
517impl WebhookPolicy {
518 pub fn is_allowed(&self, path: &str) -> bool {
519 if self
520 .deny_paths
521 .iter()
522 .any(|prefix| path.starts_with(prefix))
523 {
524 return false;
525 }
526
527 if self.allow_paths.is_empty() {
528 return true;
529 }
530
531 self.allow_paths
532 .iter()
533 .any(|prefix| path.starts_with(prefix))
534 }
535}
536
537impl TimerBinding {
538 pub fn schedule_id(&self) -> &str {
539 self.schedule_id.as_deref().unwrap_or(self.flow_id.as_str())
540 }
541}
542
543impl Default for FlowRetryConfig {
544 fn default() -> Self {
545 Self {
546 max_attempts: default_retry_attempts(),
547 base_delay_ms: default_retry_base_delay_ms(),
548 }
549 }
550}
551
552#[cfg(test)]
553mod operator_policy_tests {
554 use super::{OperatorPolicy, OperatorPolicyConfig};
555 use std::collections::HashMap;
556
557 #[test]
558 fn policy_allows_configured_provider_op() {
559 let mut allowed_ops = HashMap::new();
560 allowed_ops.insert("provider.allowed".into(), vec!["op1".into(), "op2".into()]);
561 let config = OperatorPolicyConfig {
562 allowed_providers: vec!["provider.allowed".into()],
563 allowed_ops,
564 };
565 let policy = OperatorPolicy::from_config(config);
566 assert!(policy.allows_provider(Some("provider.allowed"), "provider.allowed"));
567 assert!(policy.allows_op(Some("provider.allowed"), "provider.allowed", "op1"));
568 assert!(!policy.allows_op(Some("provider.allowed"), "provider.allowed", "other"));
569 assert!(!policy.allows_provider(Some("provider.denied"), "provider.denied"));
570 }
571
572 #[test]
573 fn policy_allow_all_defaults_true() {
574 let policy = OperatorPolicy::allow_all();
575 assert!(policy.allows_provider(None, "any"));
576 assert!(policy.allows_op(None, "any", "op"));
577 }
578}
579
580fn default_retry_attempts() -> u32 {
581 3
582}
583
584fn default_retry_base_delay_ms() -> u64 {
585 250
586}
587
588#[cfg(test)]
589#[allow(clippy::items_after_test_module)]
590mod tests {
591 use super::*;
592 use crate::gtbind::{PackBinding, TenantBindings};
593 use std::collections::HashMap;
594 use std::path::PathBuf;
595
596 fn host_config_with_oauth(oauth: Option<OAuthConfig>) -> HostConfig {
597 HostConfig {
598 tenant: "tenant-a".to_string(),
599 bindings_path: PathBuf::from("/tmp/bindings.yaml"),
600 flow_type_bindings: HashMap::new(),
601 rate_limits: RateLimits::default(),
602 retry: FlowRetryConfig::default(),
603 http_enabled: false,
604 secrets_policy: SecretsPolicy::allow_all(),
605 state_store_policy: StateStorePolicy::default(),
606 webhook_policy: WebhookPolicy::default(),
607 timers: Vec::new(),
608 oauth,
609 mocks: None,
610 pack_bindings: Vec::new(),
611 env_passthrough: Vec::new(),
612 trace: TraceConfig::from_env(),
613 validation: ValidationConfig::from_env(),
614 operator_policy: OperatorPolicy::allow_all(),
615 fast2flow: Fast2FlowRoutingConfig::default(),
616 #[cfg(feature = "agentic-worker")]
617 agents: HashMap::new(),
618 #[cfg(feature = "agentic-worker")]
619 graphs: HashMap::new(),
620 }
621 }
622
623 #[cfg(feature = "agentic-worker")]
624 #[test]
625 fn load_from_path_parses_agents_section() {
626 let yaml = r#"
629tenant: acme
630agents:
631 greeter:
632 agent_id: greeter
633 system_prompt: "You are a greeter."
634 tools: []
635 llm:
636 provider: openai
637 model: gpt-4o-mini
638 limits:
639 max_iter: 8
640 timeout: 60
641 max_history_turns: 20
642 llm_retry_attempts: 3
643 llm_retry_backoff: 250
644 provider_failure_message: null
645 daily_token_cap_per_tenant: null
646"#;
647 let dir = tempfile::tempdir().unwrap();
648 let path = dir.path().join("bindings.yaml");
649 std::fs::write(&path, yaml).unwrap();
650 let cfg = HostConfig::load_from_path(&path).unwrap();
651 assert!(cfg.agents.contains_key("greeter"));
652 let greeter = &cfg.agents["greeter"];
653 assert_eq!(greeter.system_prompt, "You are a greeter.");
654 assert_eq!(greeter.limits.max_iter, 8);
655 assert_eq!(greeter.limits.timeout, std::time::Duration::from_secs(60));
657 assert_eq!(
658 greeter.limits.llm_retry_backoff,
659 std::time::Duration::from_millis(250)
660 );
661 }
662
663 #[cfg(feature = "agentic-worker")]
664 #[test]
665 fn load_from_path_omitted_agents_section_yields_empty_map() {
666 let yaml = "tenant: acme\n";
667 let dir = tempfile::tempdir().unwrap();
668 let path = dir.path().join("bindings.yaml");
669 std::fs::write(&path, yaml).unwrap();
670 let cfg = HostConfig::load_from_path(&path).unwrap();
671 assert!(cfg.agents.is_empty());
672 }
673
674 #[test]
675 fn host_config_loads_fast2flow_routing_block() {
676 let temp = tempfile::TempDir::new().expect("tempdir");
677 let path = temp.path().join("bindings.yaml");
678 std::fs::write(
679 &path,
680 r#"
681tenant: demo
682fast2flow:
683 enabled: true
684 component_ref: router.fast2flow
685 operation: handle-hook
686 scope: tenant-a
687 registry_path: /mnt/registry
688 indexes_path: /mnt/indexes
689 time_budget_ms: 750
690"#,
691 )
692 .expect("write bindings");
693
694 let cfg = HostConfig::load_from_path(&path).expect("load config");
695
696 assert!(cfg.fast2flow.enabled);
697 assert_eq!(cfg.fast2flow.component_ref, "router.fast2flow");
698 assert_eq!(cfg.fast2flow.operation, "handle-hook");
699 assert_eq!(cfg.fast2flow.scope.as_deref(), Some("tenant-a"));
700 assert_eq!(cfg.fast2flow.registry_path, "/mnt/registry");
701 assert_eq!(cfg.fast2flow.indexes_path, "/mnt/indexes");
702 assert_eq!(cfg.fast2flow.time_budget_ms, 750);
703 }
704
705 #[test]
706 fn secrets_policy_register_flow_secret_refs_walks_nested_objects() {
707 let policy = SecretsPolicy {
708 binding_allowed: HashSet::new(),
709 flow_discovered: Arc::new(RwLock::new(HashSet::new())),
710 allow_all: false,
711 };
712
713 assert!(!policy.is_allowed("llm-api-key"));
715
716 let node_config = serde_json::json!({
719 "api_key_secret": "llm-api-key",
720 "provider": "openai",
721 "fallback": {
722 "secondary_api_key_secret": "openrouter-key",
723 "model": "gpt-4o",
724 },
725 "list": [
726 { "tertiary_secret": "another-key" },
727 { "non_secret_field": "ignored" },
728 ],
729 "ignored_field": ""
730 });
731
732 policy.register_flow_secret_refs(&node_config);
733
734 assert!(policy.is_allowed("llm-api-key"));
735 assert!(policy.is_allowed("openrouter-key"));
736 assert!(policy.is_allowed("another-key"));
737 assert!(!policy.is_allowed("non_secret_field"));
738 assert!(!policy.is_allowed("ignored"));
739 }
740
741 #[test]
742 fn secrets_policy_ignores_empty_or_non_string_secret_values() {
743 let policy = SecretsPolicy {
744 binding_allowed: HashSet::new(),
745 flow_discovered: Arc::new(RwLock::new(HashSet::new())),
746 allow_all: false,
747 };
748
749 let node_config = serde_json::json!({
750 "api_key_secret": "",
751 "fallback_secret": null,
752 "numeric_secret": 42,
753 "real_secret": "good-key",
754 });
755
756 policy.register_flow_secret_refs(&node_config);
757
758 assert!(policy.is_allowed("good-key"));
759 assert!(!policy.is_allowed(""));
760 }
761
762 #[test]
763 fn oauth_broker_config_absent_without_block() {
764 let cfg = host_config_with_oauth(None);
765 assert!(cfg.oauth_broker_config().is_none());
766 }
767
768 #[test]
769 fn oauth_broker_config_maps_fields() {
770 let cfg = host_config_with_oauth(Some(OAuthConfig {
771 http_base_url: "https://oauth.example/".into(),
772 nats_url: "nats://broker:4222".into(),
773 provider: "demo".into(),
774 env: None,
775 team: Some("ops".into()),
776 shared_secret: None,
777 }));
778 let broker = cfg
781 .oauth_broker_config_with_env(None)
782 .expect("missing broker config");
783 assert_eq!(broker.http_base_url, "https://oauth.example/");
784 assert_eq!(broker.nats_url, "nats://broker:4222");
785 assert_eq!(broker.default_provider.as_deref(), Some("demo"));
786 assert_eq!(broker.team.as_deref(), Some("ops"));
787 assert!(broker.shared_secret.is_none());
788 }
789
790 #[test]
791 fn oauth_broker_config_maps_shared_secret_from_yaml() {
792 let cfg = host_config_with_oauth(Some(OAuthConfig {
793 http_base_url: "https://oauth.example/".into(),
794 nats_url: "nats://broker:4222".into(),
795 provider: "demo".into(),
796 env: None,
797 team: None,
798 shared_secret: Some("yaml-secret".into()),
799 }));
800 let broker = cfg
802 .oauth_broker_config_with_env(None)
803 .expect("missing broker config");
804 assert_eq!(broker.shared_secret.as_deref(), Some("yaml-secret"));
805 }
806
807 #[test]
808 fn oauth_broker_config_env_overrides_yaml_shared_secret() {
809 let cfg = host_config_with_oauth(Some(OAuthConfig {
810 http_base_url: "https://oauth.example/".into(),
811 nats_url: "nats://broker:4222".into(),
812 provider: "demo".into(),
813 env: None,
814 team: None,
815 shared_secret: Some("yaml-secret".into()),
816 }));
817 let broker = cfg
818 .oauth_broker_config_with_env(Some("env-secret"))
819 .expect("missing broker config");
820 assert_eq!(broker.shared_secret.as_deref(), Some("env-secret"));
821 }
822
823 #[test]
824 fn oauth_broker_config_env_provides_secret_when_yaml_absent() {
825 let cfg = host_config_with_oauth(Some(OAuthConfig {
826 http_base_url: "https://oauth.example/".into(),
827 nats_url: "nats://broker:4222".into(),
828 provider: "demo".into(),
829 env: None,
830 team: None,
831 shared_secret: None,
832 }));
833 let broker = cfg
834 .oauth_broker_config_with_env(Some("env-only-secret"))
835 .expect("missing broker config");
836 assert_eq!(broker.shared_secret.as_deref(), Some("env-only-secret"));
837 }
838
839 #[test]
840 fn gtbind_configs_enable_outbound_http() {
841 let cfg = HostConfig::from_gtbind(TenantBindings {
842 tenant: "demo".into(),
843 packs: vec![PackBinding {
844 pack_id: "deep-research-demo".into(),
845 pack_ref: "deep-research-demo@0.1.0".into(),
846 pack_locator: None,
847 flows: vec!["main".into()],
848 }],
849 env_passthrough: Vec::new(),
850 });
851
852 assert!(
853 cfg.http_enabled,
854 "gtbind-backed tenants should allow outbound component HTTP"
855 );
856 }
857}