1use std::collections::{BTreeMap, BTreeSet};
6use std::fs::File;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11use anyhow::{Context, anyhow};
12use serde_json::{Map as JsonMap, Value};
13use sha2::{Digest, Sha256};
14use zip::{ZipArchive, result::ZipError};
15
16use crate::plan::{ResolvedPackInfo, SetupPlanMetadata};
17use crate::{bundle, bundle_source::BundleSource, discovery};
18
19use super::plan_builders::compute_simple_hash;
20use super::types::SetupConfig;
21
22#[derive(Debug)]
23pub struct ApplyPackSetupReport {
24 pub provider_updates: usize,
25 pub pending_setup_actions: Vec<crate::setup_actions::SetupAction>,
26}
27
28fn resolve_secret_answer_keys(pack_path: &Path, provider_id: &str) -> Option<BTreeSet<String>> {
43 let form = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)?;
44 let secret_ids = form
45 .questions
46 .iter()
47 .filter(|q| q.secret)
48 .map(|q| crate::secret_name::canonical_secret_name(&q.id))
49 .collect::<BTreeSet<String>>();
50 Some(secret_ids)
51}
52
53fn is_secret_answer_key(answer_key: &str, secret_keys: &BTreeSet<String>) -> bool {
68 let norm = crate::secret_name::canonical_secret_name(answer_key);
69 secret_keys
70 .iter()
71 .any(|secret| secret == &norm || secret.ends_with(&norm))
72}
73
74fn strip_secret_answer_keys(answers: &Value, secret_keys: &BTreeSet<String>) -> Value {
82 let Some(map) = answers.as_object() else {
83 return answers.clone();
84 };
85 let mut filtered = serde_json::Map::with_capacity(map.len());
86 for (key, value) in map {
87 if is_secret_answer_key(key, secret_keys) {
88 continue;
89 }
90 filtered.insert(key.clone(), value.clone());
91 }
92 Value::Object(filtered)
93}
94
95fn redact_secret_answer_values_to_uri_refs(
102 answers: &Value,
103 secret_keys: &BTreeSet<String>,
104 env: &str,
105 tenant: &str,
106 team: Option<&str>,
107 provider_id: &str,
108) -> Value {
109 let Some(map) = answers.as_object() else {
110 return answers.clone();
111 };
112 let mut filtered = serde_json::Map::with_capacity(map.len());
113 for (key, value) in map {
114 if is_secret_answer_key(key, secret_keys) {
115 let uri = crate::canonical_secret_uri(env, tenant, team, provider_id, key);
116 filtered.insert(key.clone(), Value::String(uri));
117 } else {
118 filtered.insert(key.clone(), value.clone());
119 }
120 }
121 Value::Object(filtered)
122}
123
124fn secret_keys_or_fail_closed(
136 resolved: Option<BTreeSet<String>>,
137 answers: &Value,
138 provider_id: &str,
139) -> anyhow::Result<BTreeSet<String>> {
140 match resolved {
141 Some(set) => Ok(set),
142 None if answers_have_content(answers) => anyhow::bail!(
143 "B12a: refusing to write setup-answers for `{provider_id}` — the pack ships no \
144 classifiable setup metadata (no setup.yaml / qa/*.json / secret-requirements), so \
145 we can't tell which answers are secrets and won't risk writing plaintext. \
146 Install/repair the pack with a setup.yaml (`secret: true` flags) or an \
147 `assets/secret-requirements.json`, or pass an explicit pack ref, then retry.",
148 ),
149 None => Ok(BTreeSet::new()),
150 }
151}
152
153fn answers_have_content(answers: &Value) -> bool {
158 let Some(map) = answers.as_object() else {
159 return false;
160 };
161 map.iter().any(|(key, v)| {
162 if matches!(key.as_str(), "public_base_url" | "oauth_callback_base_url") {
168 return false;
169 }
170 match v {
171 Value::String(s) => !s.is_empty(),
172 Value::Null => false,
173 _ => true,
174 }
175 })
176}
177
178fn try_emit_pack_config_input(
182 bundle_path: &Path,
183 pack_path: &Path,
184 env: &str,
185 provider_id: &str,
186 answers: &Value,
187 trace_context: &str,
188) {
189 let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
190 else {
191 return;
192 };
193 let bundle_id = crate::qa::persist::infer_bundle_id(bundle_path);
194 if let Err(err) = crate::qa::persist::emit_pack_config_input(
195 bundle_path,
196 env,
197 &bundle_id,
198 provider_id,
199 answers,
200 &form_spec,
201 ) {
202 tracing::warn!(
203 provider_id = %provider_id,
204 env = %env,
205 error = %err,
206 "pack-config-input emission failed ({trace_context}); runtime falls back to DevStore via C4.2 compat shim",
207 );
208 }
209}
210
211pub fn execute_create_bundle(
213 bundle_path: &Path,
214 metadata: &SetupPlanMetadata,
215) -> anyhow::Result<()> {
216 bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
217 .context("failed to create bundle structure")
218}
219
220pub fn execute_resolve_packs(
222 _bundle_path: &Path,
223 metadata: &SetupPlanMetadata,
224) -> anyhow::Result<Vec<ResolvedPackInfo>> {
225 let mut resolved = Vec::new();
226 let mut failures = Vec::new();
227
228 for pack_ref in &metadata.pack_refs {
229 match resolve_pack_ref(pack_ref) {
230 Ok(resolved_path) => {
231 let canonical = resolved_path
232 .canonicalize()
233 .unwrap_or(resolved_path.clone());
234 let pack_meta = discovery::read_pack_meta(&canonical)?;
235 resolved.push(ResolvedPackInfo {
236 source_ref: pack_ref.clone(),
237 mapped_ref: canonical.display().to_string(),
238 resolved_digest: compute_file_digest(&canonical)
239 .unwrap_or_else(|_| format!("sha256:{}", compute_simple_hash(pack_ref))),
240 pack_id: pack_meta.map(|meta| meta.pack_id).unwrap_or_else(|| {
241 canonical
242 .file_stem()
243 .and_then(|s| s.to_str())
244 .unwrap_or("unknown")
245 .to_string()
246 }),
247 entry_flows: Vec::new(),
248 cached_path: canonical.clone(),
249 output_path: canonical,
250 });
251 }
252 Err(err) => {
253 failures.push(format!("{pack_ref}: {err}"));
254 }
255 }
256 }
257
258 if !failures.is_empty() {
259 anyhow::bail!(
260 "failed to resolve {} pack ref(s):\n{}",
261 failures.len(),
262 failures.join("\n")
263 );
264 }
265
266 Ok(resolved)
267}
268
269pub fn execute_add_packs_to_bundle(
271 bundle_path: &Path,
272 resolved_packs: &[ResolvedPackInfo],
273) -> anyhow::Result<()> {
274 let mut metadata_entries = Vec::new();
275
276 for pack in resolved_packs {
277 let target_dir = get_pack_target_dir(bundle_path, &pack.pack_id);
279 std::fs::create_dir_all(&target_dir)?;
280
281 let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
282 let source_path = pack.cached_path.canonicalize().ok();
283 let existing_target_path = target_path.canonicalize().ok();
284 if pack.cached_path.exists() && source_path != existing_target_path {
285 std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
286 format!(
287 "failed to copy pack {} to {}",
288 pack.cached_path.display(),
289 target_path.display()
290 )
291 })?;
292 }
293
294 let reference = target_path
295 .strip_prefix(bundle_path)
296 .unwrap_or(&target_path)
297 .to_string_lossy()
298 .replace('\\', "/");
299 let kind = if reference.starts_with("providers/") {
300 bundle::BundleReferenceKind::ExtensionProvider
301 } else {
302 bundle::BundleReferenceKind::AppPack
303 };
304 metadata_entries.push(bundle::BundleReference {
305 kind,
306 reference,
307 digest: Some(pack.resolved_digest.clone()),
308 });
309 }
310
311 bundle::register_bundle_references(bundle_path, &metadata_entries, None)?;
312 Ok(())
313}
314
315pub fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
320 const DOMAIN_PREFIXES: &[&str] = &[
321 "messaging-",
322 "events-",
323 "oauth-",
324 "secrets-",
325 "mcp-",
326 "state-",
327 ];
328
329 for prefix in DOMAIN_PREFIXES {
330 if pack_id.starts_with(prefix) {
331 let domain = prefix.trim_end_matches('-');
332 return bundle_path.join("providers").join(domain);
333 }
334 }
335
336 bundle_path.join("packs")
338}
339
340pub fn execute_apply_pack_setup(
342 bundle_path: &Path,
343 metadata: &SetupPlanMetadata,
344 config: &SetupConfig,
345) -> anyhow::Result<ApplyPackSetupReport> {
346 let mut count = 0;
347 let mut pending_setup_actions = Vec::new();
348
349 if !metadata.providers_remove.is_empty() {
350 count += execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
351 }
352
353 auto_install_provider_packs(bundle_path, metadata);
356
357 let discovered = if bundle_path.exists() {
359 discovery::discover(bundle_path).ok()
360 } else {
361 None
362 };
363
364 let provider_ids = setup_provider_ids(metadata, discovered.as_ref());
365
366 for provider_id in provider_ids {
368 let empty_answers = Value::Object(serde_json::Map::new());
369 let answers = metadata
370 .setup_answers
371 .get(&provider_id)
372 .unwrap_or(&empty_answers);
373 let mut effective_answers = answers.clone();
374 let pack_path = discovered.as_ref().and_then(|d| {
375 d.find_setup_target(&provider_id)
376 .map(|p| p.pack_path.as_path())
377 });
378 if !crate::provider_state::provider_enabled(&effective_answers) {
379 let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
380 let config_dir = bundle_path.join("state").join("config").join(&provider_id);
381 std::fs::create_dir_all(&config_dir)?;
382 let config_path = config_dir.join("setup-answers.json");
383 let content = serde_json::to_string_pretty(&persisted_answers)
384 .context("failed to serialize setup answers")?;
385 std::fs::write(&config_path, content).with_context(|| {
386 format!(
387 "failed to write setup answers to: {}",
388 config_path.display()
389 )
390 })?;
391 let env = crate::resolve_env(Some(&config.env));
392 let rt = tokio::runtime::Runtime::new()
393 .context("failed to create tokio runtime for secrets persistence")?;
394 rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
395 bundle_path,
396 &env,
397 &config.tenant,
398 config.team.as_deref(),
399 &provider_id,
400 &persisted_answers,
401 pack_path,
402 ))?;
403 if let Some(pack_path) = pack_path {
404 crate::config_envelope::write_provider_config_envelope(
405 &bundle_path.join(".providers"),
406 &provider_id,
407 "setup-input",
408 &persisted_answers,
409 pack_path,
410 false,
411 )
412 .with_context(|| {
413 format!(
414 "failed to write provider config envelope for {} using {}",
415 provider_id,
416 pack_path.display()
417 )
418 })?;
419 try_emit_pack_config_input(
420 bundle_path,
421 pack_path,
422 &env,
423 &provider_id,
424 &persisted_answers,
425 "setup-input path",
426 );
427 }
428 count += 1;
429 continue;
430 }
431 let mut setup_actions = crate::setup_actions::extract_setup_actions(
432 &provider_id,
433 &config.tenant,
434 config.team.as_deref(),
435 answers,
436 )?;
437 setup_actions.extend(extract_pack_setup_actions(
438 discovered.as_ref(),
439 &provider_id,
440 &config.tenant,
441 config.team.as_deref(),
442 )?);
443 defer_registration_actions_missing_inputs(&mut setup_actions, &effective_answers);
444 run_setup_action_registrations(SetupActionRegistrationContext {
445 bundle_path,
446 discovered: discovered.as_ref(),
447 provider_id: &provider_id,
448 config,
449 bundle_name: metadata.bundle_name.as_deref(),
450 public_base_url: metadata.static_routes.public_base_url.as_deref(),
451 answers: &mut effective_answers,
452 actions: &mut setup_actions,
453 })?;
454 hydrate_oauth_install_actions(&mut setup_actions, &effective_answers);
455 if !setup_actions.is_empty() {
456 crate::setup_actions::sign_pending_oauth_actions(bundle_path, &mut setup_actions)?;
457 crate::setup_actions::persist_setup_actions(bundle_path, &setup_actions)?;
458 pending_setup_actions.extend(setup_actions.clone());
459 }
460 let slack_team = slack_team_id_from_answers(&effective_answers);
465 if let Some(team) = slack_team.as_deref() {
466 apply_slack_team_to_app_url(&mut effective_answers, team);
467 }
468 let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
469
470 let config_dir = bundle_path.join("state").join("config").join(&provider_id);
472 std::fs::create_dir_all(&config_dir)?;
473
474 let pack_path = discovered.as_ref().and_then(|d| {
478 d.find_setup_target(&provider_id)
479 .map(|p| p.pack_path.as_path())
480 });
481 let env = crate::resolve_env(Some(&config.env));
482
483 let resolved_secret_keys: Option<BTreeSet<String>> =
496 pack_path.and_then(|pp| resolve_secret_answer_keys(pp, &provider_id));
497 let secret_keys = secret_keys_or_fail_closed(resolved_secret_keys, answers, &provider_id)?;
498 let mut answers_for_disk = strip_secret_answer_keys(answers, &secret_keys);
499 if let Some(team) = slack_team.as_deref() {
500 apply_slack_team_to_app_url(&mut answers_for_disk, team);
501 }
502 let mut envelope_answers = redact_secret_answer_values_to_uri_refs(
503 answers,
504 &secret_keys,
505 &env,
506 &config.tenant,
507 config.team.as_deref(),
508 &provider_id,
509 );
510 if let Some(team) = slack_team.as_deref() {
511 apply_slack_team_to_app_url(&mut envelope_answers, team);
512 }
513
514 let config_path = config_dir.join("setup-answers.json");
515 let content = serde_json::to_string_pretty(&answers_for_disk)
516 .context("failed to serialize setup answers")?;
517 std::fs::write(&config_path, content).with_context(|| {
518 format!(
519 "failed to write setup answers to: {}",
520 config_path.display()
521 )
522 })?;
523
524 if config.verbose {
525 let team_display = config.team.as_deref().unwrap_or("(none)");
526 println!(
527 " [secrets] scope: env={env}, tenant={}, team={team_display}, provider={provider_id}",
528 config.tenant
529 );
530 let example_uri = crate::canonical_secret_uri(
531 &env,
532 &config.tenant,
533 config.team.as_deref(),
534 &provider_id,
535 "_example_key",
536 );
537 println!(" [secrets] URI pattern: {example_uri}");
538 if let Some(config_map) = persisted_answers.as_object() {
539 let keys: Vec<&String> = config_map.keys().collect();
540 println!(" [secrets] answer keys: {keys:?}");
541 }
542 }
543 let rt = tokio::runtime::Runtime::new()
544 .context("failed to create tokio runtime for secrets persistence")?;
545 let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
546 bundle_path,
547 &env,
548 &config.tenant,
549 config.team.as_deref(),
550 &provider_id,
551 &persisted_answers,
552 pack_path,
553 ))?;
554 if config.verbose {
555 if persisted.is_empty() {
556 println!(
557 " [secrets] WARNING: 0 key(s) persisted for {provider_id} (all values empty?)"
558 );
559 } else {
560 println!(
561 " [secrets] persisted {} key(s) for {provider_id}: {:?}",
562 persisted.len(),
563 persisted
564 );
565 }
566 }
567
568 if let Some(pack_path) = pack_path {
573 crate::config_envelope::write_provider_config_envelope(
574 &bundle_path.join(".providers"),
575 &provider_id,
576 "setup-input",
577 &envelope_answers,
578 pack_path,
579 false,
580 )
581 .with_context(|| {
582 format!(
583 "failed to write provider config envelope for {} using {}",
584 provider_id,
585 pack_path.display()
586 )
587 })?;
588 } else if config.verbose {
589 println!(
590 " [config] WARNING: no resolved pack path for {provider_id}; skipped config envelope write"
591 );
592 }
593
594 if let Some(pack_path) = pack_path {
597 try_emit_pack_config_input(
598 bundle_path,
599 pack_path,
600 &env,
601 &provider_id,
602 &persisted_answers,
603 "apply-answers path",
604 );
605 }
606
607 match crate::tenant_config::sync_oauth_to_tenant_config(
609 bundle_path,
610 &config.tenant,
611 &provider_id,
612 &persisted_answers,
613 ) {
614 Ok(true) => {
615 if config.verbose {
616 println!(" [oauth] updated tenant config for {provider_id}");
617 }
618 }
619 Ok(false) => {}
620 Err(e) => {
621 println!(" [oauth] WARNING: failed to update tenant config: {e}");
622 }
623 }
624
625 match crate::tenant_config::sync_skin_to_tenant_config(
627 bundle_path,
628 &config.tenant,
629 &provider_id,
630 &persisted_answers,
631 ) {
632 Ok(true) => {
633 if config.verbose {
634 println!(" [skin] updated tenant config for {provider_id}");
635 }
636 }
637 Ok(false) => {}
638 Err(e) => {
639 println!(" [skin] WARNING: failed to update tenant config: {e}");
640 }
641 }
642
643 if provider_id.contains("webchat-gui") && config.verbose {
645 let preview = answers
646 .as_object()
647 .and_then(|m| m.get("nav_links"))
648 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "<unserializable>".into()))
649 .unwrap_or_else(|| "<absent>".into());
650 println!(" [nav_links] received answer for {provider_id}: {preview}");
651 }
652 match crate::tenant_config::sync_nav_links_to_tenant_config(
653 bundle_path,
654 &config.tenant,
655 &provider_id,
656 &persisted_answers,
657 ) {
658 Ok(true) => {
659 if config.verbose {
660 println!(" [nav_links] updated tenant config for {provider_id}");
661 }
662 }
663 Ok(false) => {}
664 Err(e) => {
665 println!(" [nav_links] WARNING: failed to update tenant config: {e}");
666 }
667 }
668
669 if let Some(result) = crate::webhook::register_webhook(
671 &provider_id,
672 &persisted_answers,
673 &config.tenant,
674 config.team.as_deref(),
675 ) {
676 let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
677 if ok {
678 println!(" [webhook] registered for {provider_id}");
679 } else {
680 let err = result
681 .get("error")
682 .and_then(Value::as_str)
683 .unwrap_or("unknown");
684 println!(" [webhook] WARNING: registration failed for {provider_id}: {err}");
685 }
686 }
687
688 count += 1;
689 }
690
691 crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
692 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
693 bundle_path,
694 &metadata.deployment_targets,
695 );
696
697 let provider_configs: Vec<(String, Value)> = metadata
699 .setup_answers
700 .iter()
701 .filter(|(_, val)| crate::provider_state::provider_enabled(val))
702 .map(|(id, val)| (id.clone(), val.clone()))
703 .collect();
704 let team = config.team.as_deref().unwrap_or("default");
705 crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
706
707 Ok(ApplyPackSetupReport {
708 provider_updates: count,
709 pending_setup_actions,
710 })
711}
712
713fn setup_provider_ids(
714 metadata: &SetupPlanMetadata,
715 discovered: Option<&crate::discovery::DiscoveryResult>,
716) -> BTreeSet<String> {
717 let mut provider_ids: BTreeSet<String> = metadata.setup_answers.keys().cloned().collect();
718 if let Some(discovered) = discovered {
719 for provider in discovered.setup_targets() {
720 if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path)
721 && !spec.setup_actions.is_empty()
722 {
723 provider_ids.insert(provider.provider_id.clone());
724 }
725 }
726 }
727 provider_ids
728}
729
730fn extract_pack_setup_actions(
731 discovered: Option<&crate::discovery::DiscoveryResult>,
732 provider_id: &str,
733 tenant: &str,
734 team: Option<&str>,
735) -> anyhow::Result<Vec<crate::setup_actions::SetupAction>> {
736 let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
737 return Ok(Vec::new());
738 };
739 let Some(spec) = crate::setup_input::load_setup_spec(&provider.pack_path)? else {
740 return Ok(Vec::new());
741 };
742 if spec.setup_actions.is_empty() {
743 return Ok(Vec::new());
744 }
745 let setup_actions = spec
746 .setup_actions
747 .into_iter()
748 .map(|mut action| {
749 if let Some(obj) = action.as_object_mut() {
750 obj.remove("provider_id");
751 obj.remove("tenant");
752 obj.remove("team");
753 }
754 action
755 })
756 .collect::<Vec<_>>();
757 let value = serde_json::json!({ "setup_actions": setup_actions });
758 crate::setup_actions::extract_setup_actions(provider_id, tenant, team, &value)
759}
760
761fn defer_registration_actions_missing_inputs(
762 actions: &mut Vec<crate::setup_actions::SetupAction>,
763 answers: &Value,
764) {
765 actions.retain(|action| {
766 if action.extra.get("registration").is_none() {
767 return true;
768 }
769 let registration_satisfied = match action.kind {
770 crate::setup_actions::SetupActionKind::OauthInstallButton => {
771 client_id_for_action(action, answers).is_some()
772 }
773 crate::setup_actions::SetupActionKind::OpenUrl => {
774 !registration_output_missing(action, answers)
775 }
776 _ => return true,
777 };
778 registration_satisfied
779 || registration_has_any_declared_input(action.extra.get("registration"), answers)
780 });
781}
782
783fn registration_output_missing(
787 action: &crate::setup_actions::SetupAction,
788 answers: &Value,
789) -> bool {
790 let Some(registration_obj) = action.extra.get("registration").and_then(Value::as_object) else {
791 return true;
792 };
793 let Some(answers_obj) = answers.as_object() else {
794 return true;
795 };
796 !registration_obj.iter().any(|(key, field_value)| {
797 key.ends_with("_output")
798 && field_value
799 .as_str()
800 .map(str::trim)
801 .filter(|field_name| !field_name.is_empty())
802 .and_then(|field_name| answers_obj.get(field_name))
803 .is_some_and(|value| !is_empty_value(value))
804 })
805}
806
807fn registration_has_any_declared_input(registration: Option<&Value>, answers: &Value) -> bool {
808 let Some(registration_obj) = registration.and_then(Value::as_object) else {
809 return false;
810 };
811 let Some(answers_obj) = answers.as_object() else {
812 return false;
813 };
814 registration_obj.iter().any(|(key, field_value)| {
815 key.ends_with("_field")
816 && field_value
817 .as_str()
818 .map(str::trim)
819 .filter(|field_name| !field_name.is_empty())
820 .and_then(|field_name| answers_obj.get(field_name))
821 .is_some_and(|value| !is_empty_value(value))
822 })
823}
824
825struct SetupActionRegistrationContext<'a> {
826 bundle_path: &'a Path,
827 discovered: Option<&'a crate::discovery::DiscoveryResult>,
828 provider_id: &'a str,
829 config: &'a SetupConfig,
830 bundle_name: Option<&'a str>,
831 public_base_url: Option<&'a str>,
832 answers: &'a mut Value,
833 actions: &'a mut [crate::setup_actions::SetupAction],
834}
835
836fn run_setup_action_registrations(ctx: SetupActionRegistrationContext<'_>) -> anyhow::Result<()> {
837 let SetupActionRegistrationContext {
838 bundle_path,
839 discovered,
840 provider_id,
841 config,
842 bundle_name,
843 public_base_url,
844 answers,
845 actions,
846 } = ctx;
847
848 let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
849 if actions
850 .iter()
851 .any(|action| needs_setup_action_registration(action, answers))
852 {
853 anyhow::bail!("provider pack not found for setup action registration: {provider_id}");
854 }
855 return Ok(());
856 };
857
858 for action in actions {
859 if !needs_setup_action_registration(action, answers) {
860 if action.kind == crate::setup_actions::SetupActionKind::OpenUrl
867 && action.extra.get("registration").is_some()
868 {
869 resolve_open_url_action(action, answers)?;
870 }
871 continue;
872 }
873 let registration = action
874 .extra
875 .get("registration")
876 .cloned()
877 .ok_or_else(|| anyhow!("setup action registration metadata missing"))?;
878 let request = build_registration_request(
879 provider_id,
880 config,
881 bundle_name,
882 public_base_url,
883 answers,
884 action,
885 ®istration,
886 )?;
887 let output = invoke_registration_operation(
888 bundle_path,
889 &provider.pack_path,
890 ®istration,
891 &request,
892 config,
893 )
894 .with_context(|| {
895 format!(
896 "failed to run setup action registration {} for {}",
897 action.id, provider_id
898 )
899 })?;
900 if let Some(error) = registration_error_message(&output) {
901 anyhow::bail!(
902 "setup action registration {} returned an error: {}",
903 action.id,
904 error
905 );
906 }
907 merge_registration_output(action, answers, ®istration, &output)?;
908 match action.kind {
909 crate::setup_actions::SetupActionKind::OauthInstallButton
910 if client_id_for_action(action, answers).is_none()
911 && !authorize_url_has_query_key(
912 action.authorize_url.as_deref(),
913 "client_id",
914 ) =>
915 {
916 anyhow::bail!(
917 "setup action registration {} did not produce a client_id",
918 action.id
919 );
920 }
921 crate::setup_actions::SetupActionKind::OpenUrl => {
922 resolve_open_url_action(action, answers)?;
923 }
924 _ => {}
925 }
926 }
927 Ok(())
928}
929
930fn needs_setup_action_registration(
931 action: &crate::setup_actions::SetupAction,
932 answers: &Value,
933) -> bool {
934 if action.extra.get("registration").is_none() {
935 return false;
936 }
937 match action.kind {
938 crate::setup_actions::SetupActionKind::OauthInstallButton => {
939 client_id_for_action(action, answers).is_none()
940 && !authorize_url_has_query_key(action.authorize_url.as_deref(), "client_id")
941 }
942 crate::setup_actions::SetupActionKind::OpenUrl => {
943 registration_output_missing(action, answers)
944 }
945 _ => false,
946 }
947}
948
949fn resolve_open_url_action(
954 action: &mut crate::setup_actions::SetupAction,
955 answers: &Value,
956) -> anyhow::Result<()> {
957 let Some(template) = action
958 .extra
959 .get("url_template")
960 .and_then(Value::as_str)
961 .map(str::trim)
962 .filter(|value| !value.is_empty())
963 else {
964 return Ok(());
965 };
966 if !template.starts_with("https://") {
967 anyhow::bail!(
968 "setup action {} url_template must be an https:// URL",
969 action.id
970 );
971 }
972 let placeholder = regex::Regex::new(r"\{([A-Za-z0-9_.-]+)\}")
973 .expect("static url template placeholder regex is valid");
974 let answers_obj = answers.as_object();
975 let mut unresolved = false;
976 let resolved = placeholder.replace_all(template, |caps: ®ex::Captures<'_>| {
977 let name = &caps[1];
978 let value = answers_obj
979 .and_then(|obj| obj.get(name))
980 .and_then(Value::as_str)
981 .map(str::trim)
982 .filter(|value| !value.is_empty());
983 match value {
984 Some(value) => percent_encode_url_component(value),
985 None => {
986 unresolved = true;
987 String::new()
988 }
989 }
990 });
991 if !unresolved {
992 action
993 .extra
994 .insert("url".into(), Value::String(resolved.into_owned()));
995 }
996 Ok(())
997}
998
999fn percent_encode_url_component(value: &str) -> String {
1002 let mut out = String::with_capacity(value.len());
1003 for byte in value.bytes() {
1004 match byte {
1005 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1006 out.push(byte as char);
1007 }
1008 _ => out.push_str(&format!("%{byte:02X}")),
1009 }
1010 }
1011 out
1012}
1013
1014fn authorize_url_has_query_key(url: Option<&str>, key: &str) -> bool {
1015 url.and_then(|value| url::Url::parse(value).ok())
1016 .is_some_and(|parsed| parsed.query_pairs().any(|(candidate, _)| candidate == key))
1017}
1018
1019fn build_registration_request(
1020 provider_id: &str,
1021 config: &SetupConfig,
1022 bundle_name: Option<&str>,
1023 public_base_url: Option<&str>,
1024 answers: &Value,
1025 action: &crate::setup_actions::SetupAction,
1026 registration: &Value,
1027) -> anyhow::Result<Value> {
1028 let registration_obj = registration
1029 .as_object()
1030 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1031 let answers_obj = answers
1032 .as_object()
1033 .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1034 let setup_callback_base =
1042 if action.kind == crate::setup_actions::SetupActionKind::OauthInstallButton {
1043 std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
1044 .ok()
1045 .map(|value| value.trim().trim_end_matches('/').to_string())
1046 .filter(|value| value.starts_with("https://"))
1047 } else {
1048 None
1049 };
1050 let effective_public_base_url =
1051 setup_callback_base
1052 .as_deref()
1053 .or(public_base_url)
1054 .or_else(|| {
1055 answers_obj
1056 .get("public_base_url")
1057 .and_then(Value::as_str)
1058 .map(str::trim)
1059 .filter(|value| !value.is_empty())
1060 });
1061 let effective_team = config.team.as_deref().unwrap_or("default");
1062 let mut input = JsonMap::new();
1063 input.insert("answers".into(), answers.clone());
1064 input.insert("provider_id".into(), Value::String(provider_id.to_string()));
1065 input.insert("tenant".into(), Value::String(config.tenant.clone()));
1066 input.insert("team".into(), Value::String(effective_team.to_string()));
1067 if let Some(public_base_url) = effective_public_base_url {
1068 input.insert(
1069 "public_base_url".into(),
1070 Value::String(public_base_url.to_string()),
1071 );
1072 }
1073 input.insert("action_id".into(), Value::String(action.id.clone()));
1074
1075 for (key, field_value) in registration_obj {
1076 let Some(input_name) = key.strip_suffix("_field") else {
1077 continue;
1078 };
1079 let Some(field_name) = field_value
1080 .as_str()
1081 .map(str::trim)
1082 .filter(|v| !v.is_empty())
1083 else {
1084 continue;
1085 };
1086 if let Some(value) = answers_obj
1087 .get(field_name)
1088 .filter(|value| !is_empty_value(value))
1089 {
1090 input.insert(field_name.to_string(), value.clone());
1091 input.insert(input_name.to_string(), value.clone());
1092 }
1093 }
1094
1095 if input.get("app_name").is_none()
1096 && let Some(app_name) = registration_app_name(action, bundle_name)
1097 {
1098 input.insert("app_name".into(), Value::String(app_name.clone()));
1099 if let Some(field_name) = registration_obj
1100 .get("app_name_field")
1101 .and_then(Value::as_str)
1102 .map(str::trim)
1103 .filter(|value| !value.is_empty())
1104 {
1105 input.insert(field_name.to_string(), Value::String(app_name));
1106 }
1107 }
1108
1109 let mut context = JsonMap::new();
1110 context.insert("provider_id".into(), Value::String(provider_id.to_string()));
1111 context.insert("tenant".into(), Value::String(config.tenant.clone()));
1112 context.insert("team".into(), Value::String(effective_team.to_string()));
1113 if let Some(public_base_url) = effective_public_base_url {
1114 context.insert(
1115 "public_base_url".into(),
1116 Value::String(public_base_url.to_string()),
1117 );
1118 }
1119 if let Some(app_name) = input.get("app_name") {
1120 context.insert("app_name".into(), app_name.clone());
1121 }
1122 input.insert("context".into(), Value::Object(context));
1123 Ok(Value::Object(input))
1124}
1125
1126fn registration_app_name(
1127 action: &crate::setup_actions::SetupAction,
1128 bundle_name: Option<&str>,
1129) -> Option<String> {
1130 let bundle_name = bundle_name
1131 .map(str::trim)
1132 .filter(|value| !value.is_empty())
1133 .unwrap_or("Greentic");
1134 if let Some(template) = action
1135 .extra
1136 .get("app_name_template")
1137 .and_then(Value::as_str)
1138 .map(str::trim)
1139 .filter(|value| !value.is_empty())
1140 {
1141 let rendered = template
1142 .replace("{{ bundle_name }}", bundle_name)
1143 .replace("{{bundle_name}}", bundle_name)
1144 .trim()
1145 .to_string();
1146 if !rendered.is_empty() {
1147 return Some(rendered);
1148 }
1149 }
1150 action
1151 .extra
1152 .get("default_app_name")
1153 .and_then(Value::as_str)
1154 .map(str::trim)
1155 .filter(|value| !value.is_empty())
1156 .map(ToString::to_string)
1157}
1158
1159fn invoke_registration_operation(
1160 bundle_path: &Path,
1161 pack_path: &Path,
1162 registration: &Value,
1163 request: &Value,
1164 config: &SetupConfig,
1165) -> anyhow::Result<Value> {
1166 let registration_obj = registration
1167 .as_object()
1168 .ok_or_else(|| anyhow!("setup component invocation must be an object"))?;
1169 let component_ref = registration_obj
1170 .get("component_ref")
1171 .and_then(Value::as_str)
1172 .map(str::trim)
1173 .filter(|value| !value.is_empty())
1174 .ok_or_else(|| anyhow!("setup component invocation missing component_ref"))?;
1175 let op = registration_obj
1176 .get("op")
1177 .and_then(Value::as_str)
1178 .map(str::trim)
1179 .filter(|value| !value.is_empty())
1180 .ok_or_else(|| anyhow!("setup component invocation missing op"))?;
1181
1182 if let Some(result) = registration_obj
1183 .get("result")
1184 .or_else(|| registration_obj.get("mock_result"))
1185 .or_else(|| registration_obj.get("outputs"))
1186 {
1187 return Ok(result.clone());
1188 }
1189
1190 if let Ok(component) = read_registration_component(pack_path, component_ref)
1191 && let Some(output) = invoke_json_registration_component(&component, op, request)
1192 {
1193 return Ok(output);
1194 }
1195
1196 invoke_wasm_registration_component(bundle_path, pack_path, component_ref, op, request, config)
1197}
1198
1199fn slack_team_id_from_answers(answers: &Value) -> Option<String> {
1204 let obj = answers.as_object()?;
1205 let app_url = obj.get("slack_app_url").and_then(Value::as_str)?;
1206 if !app_url.contains("app_redirect") || app_url.contains("team=") {
1207 return None;
1208 }
1209 let token = obj
1210 .get("slack_bot_token")
1211 .or_else(|| obj.get("bot_token"))
1212 .and_then(Value::as_str)
1213 .map(str::trim)
1214 .filter(|t| t.starts_with("xoxb"))?;
1215 let mut response = crate::http_client::api_agent_any_status()
1216 .post("https://slack.com/api/auth.test")
1217 .header("Authorization", &format!("Bearer {token}"))
1218 .send_empty()
1219 .ok()?;
1220 let body: Value = response.body_mut().read_json().ok()?;
1221 if body.get("ok").and_then(Value::as_bool) != Some(true) {
1222 eprintln!(
1223 "[oauth-token] auth.test failed: {:?} — Add to Slack link stays unpinned",
1224 body.get("error").and_then(Value::as_str)
1225 );
1226 return None;
1227 }
1228 let team = body
1229 .get("team_id")
1230 .and_then(Value::as_str)
1231 .map(str::trim)
1232 .filter(|t| !t.is_empty())?
1233 .to_string();
1234 eprintln!("[oauth-token] auth.test ok: team_id={team} — pinning Add to Slack link");
1235 Some(team)
1236}
1237
1238fn apply_slack_team_to_app_url(answers: &mut Value, team: &str) {
1242 let Some(obj) = answers.as_object_mut() else {
1243 return;
1244 };
1245 if let Some(url) = obj
1246 .get("slack_app_url")
1247 .and_then(Value::as_str)
1248 .filter(|url| url.contains("app_redirect") && !url.contains("team="))
1249 {
1250 let separator = if url.contains('?') { '&' } else { '?' };
1251 let pinned = format!("{url}{separator}team={team}");
1252 obj.insert("slack_app_url".to_string(), Value::String(pinned));
1253 }
1254 obj.entry("slack_team_id".to_string())
1255 .or_insert_with(|| Value::String(team.to_string()));
1256}
1257
1258pub fn invoke_setup_component_operation(
1259 bundle_path: &Path,
1260 pack_path: &Path,
1261 component_ref: &str,
1262 op: &str,
1263 request: &Value,
1264 config: &SetupConfig,
1265) -> anyhow::Result<Value> {
1266 let registration = serde_json::json!({
1267 "component_ref": component_ref,
1268 "op": op,
1269 });
1270 invoke_registration_operation(bundle_path, pack_path, ®istration, request, config)
1271}
1272
1273#[derive(Default)]
1274struct SetupRegistrationSecrets {
1275 values: Mutex<BTreeMap<String, Vec<u8>>>,
1278 dev_store: Option<Arc<greentic_secrets_lib::DevStore>>,
1282}
1283
1284impl SetupRegistrationSecrets {
1285 fn with_dev_store(dev_store: Option<Arc<greentic_secrets_lib::DevStore>>) -> Self {
1286 Self {
1287 values: Mutex::new(BTreeMap::new()),
1288 dev_store,
1289 }
1290 }
1291}
1292
1293fn secret_uri_candidates(path: &str) -> Vec<String> {
1306 let mut out = vec![path.to_string()];
1307 let Some(rest) = path.strip_prefix("secrets://") else {
1308 return out;
1309 };
1310 let segs: Vec<&str> = rest.splitn(5, '/').collect();
1311 if segs.len() != 5 {
1312 return out;
1313 }
1314 let (env, tenant, team, provider, key) = (segs[0], segs[1], segs[2], segs[3], segs[4]);
1315
1316 let env_variants: Vec<&str> = if env == crate::DEFAULT_ENV_ID {
1317 vec![crate::DEFAULT_ENV_ID, crate::LEGACY_ENV_ID]
1318 } else if env == crate::LEGACY_ENV_ID {
1319 vec![crate::LEGACY_ENV_ID, crate::DEFAULT_ENV_ID]
1320 } else {
1321 vec![env]
1322 };
1323 let mut provider_variants = vec![provider.to_string()];
1324 for alt in [provider.replace('-', "_"), provider.replace('_', "-")] {
1325 if alt != provider && !provider_variants.contains(&alt) {
1326 provider_variants.push(alt);
1327 }
1328 }
1329
1330 for env in &env_variants {
1331 for provider in &provider_variants {
1332 let candidate = format!("secrets://{env}/{tenant}/{team}/{provider}/{key}");
1333 if !out.contains(&candidate) {
1334 out.push(candidate);
1335 }
1336 }
1337 }
1338 out
1339}
1340
1341#[async_trait::async_trait]
1342impl greentic_secrets_lib::SecretsManager for SetupRegistrationSecrets {
1343 async fn read(&self, path: &str) -> greentic_secrets_lib::Result<Vec<u8>> {
1344 {
1345 let values = self.values.lock().map_err(|_| {
1346 greentic_secrets_lib::SecretError::Backend(
1347 "setup component secrets lock poisoned".into(),
1348 )
1349 })?;
1350 if let Some(value) = values.get(path) {
1351 return Ok(value.clone());
1352 }
1353 }
1354 if let Some(store) = &self.dev_store {
1355 use greentic_secrets_lib::SecretsStore;
1356 for candidate in secret_uri_candidates(path) {
1357 match store.get(&candidate).await {
1358 Ok(bytes) => return Ok(bytes),
1359 Err(err) => {
1360 tracing::debug!(candidate, error = %err, "setup secrets read miss");
1361 }
1362 }
1363 }
1364 }
1365 Err(greentic_secrets_lib::SecretError::NotFound(
1366 path.to_string(),
1367 ))
1368 }
1369
1370 async fn write(&self, path: &str, bytes: &[u8]) -> greentic_secrets_lib::Result<()> {
1371 let mut values = self.values.lock().map_err(|_| {
1372 greentic_secrets_lib::SecretError::Backend(
1373 "setup component secrets lock poisoned".into(),
1374 )
1375 })?;
1376 values.insert(path.to_string(), bytes.to_vec());
1377 Ok(())
1378 }
1379
1380 async fn delete(&self, path: &str) -> greentic_secrets_lib::Result<()> {
1381 let mut values = self.values.lock().map_err(|_| {
1382 greentic_secrets_lib::SecretError::Backend(
1383 "setup component secrets lock poisoned".into(),
1384 )
1385 })?;
1386 values.remove(path);
1387 Ok(())
1388 }
1389}
1390
1391fn invoke_wasm_registration_component(
1392 bundle_path: &Path,
1393 pack_path: &Path,
1394 component_ref: &str,
1395 op: &str,
1396 request: &Value,
1397 config: &SetupConfig,
1398) -> anyhow::Result<Value> {
1399 use greentic_runner_host::component_api::node::{
1400 ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx,
1401 };
1402 use greentic_runner_host::config::{OperatorPolicy, SecretsPolicy};
1403 use greentic_runner_host::pack::{ComponentResolution, PackRuntime};
1404 use greentic_runner_host::provider::ProviderBinding;
1405 use greentic_runner_host::storage::{new_session_store, new_state_store};
1406 use greentic_runner_host::{HostConfig, RunnerWasiPolicy};
1407 use std::sync::Arc;
1408
1409 let bindings_path = bundle_path
1410 .join("state")
1411 .join("config")
1412 .join("setup-component-bindings.yaml");
1413 if let Some(parent) = bindings_path.parent() {
1414 std::fs::create_dir_all(parent)?;
1415 }
1416 std::fs::write(
1417 &bindings_path,
1418 format!(
1419 r#"tenant: {}
1420flow_type_bindings:
1421 messaging:
1422 adapter: setup-component
1423 config: {{}}
1424 secrets: []
1425rate_limits: {{}}
1426retry: {{}}
1427timers: []
1428"#,
1429 config.tenant
1430 ),
1431 )
1432 .with_context(|| format!("write {}", bindings_path.display()))?;
1433
1434 let mut host_config = HostConfig::load_from_path(&bindings_path)
1435 .with_context(|| format!("load {}", bindings_path.display()))?;
1436 host_config.secrets_policy = SecretsPolicy::allow_all();
1437 host_config.operator_policy = OperatorPolicy::allow_all();
1438 let host_config = Arc::new(host_config);
1439
1440 let session_store = new_session_store();
1441 let state_store = new_state_store();
1442 unsafe { std::env::set_var("GREENTIC_PROVIDER_CORE_ONLY", "0") };
1449
1450 let reg_env = crate::resolve_env(Some(&config.env));
1455 let dev_store = crate::secrets::open_dev_store_for_env(bundle_path, ®_env)
1456 .ok()
1457 .map(Arc::new);
1458 let secrets: greentic_runner_host::secrets::DynSecretsManager =
1459 Arc::new(SetupRegistrationSecrets::with_dev_store(dev_store));
1460 let pack = greentic_runner_host::runtime::block_on(PackRuntime::load(
1461 pack_path,
1462 Arc::clone(&host_config),
1463 None,
1464 Some(pack_path),
1465 Some(Arc::clone(&session_store)),
1466 Some(Arc::clone(&state_store)),
1467 Arc::new(RunnerWasiPolicy::default()),
1468 secrets,
1469 None,
1470 false,
1471 ComponentResolution::default(),
1472 ))
1473 .with_context(|| format!("load setup component pack {}", pack_path.display()))?;
1474
1475 let exec_ctx = ComponentExecCtx {
1476 tenant: ComponentTenantCtx {
1477 tenant: config.tenant.clone(),
1478 team: config.team.clone(),
1479 user: None,
1480 trace_id: None,
1481 i18n_id: None,
1482 correlation_id: Some(format!("setup-component:{component_ref}:{op}")),
1483 deadline_unix_ms: None,
1484 attempt: 1,
1485 idempotency_key: Some(format!("setup-component:{component_ref}:{op}")),
1486 },
1487 i18n_id: None,
1488 flow_id: format!("setup-component/{op}"),
1489 node_id: Some(component_ref.to_string()),
1490 };
1491 let input_json = serde_json::to_vec(request)?;
1492 let binding = ProviderBinding {
1493 provider_id: Some(component_ref.to_string()),
1494 provider_type: component_ref.to_string(),
1495 component_ref: component_ref.to_string(),
1496 export: "schema-core-api".to_string(),
1497 world: "greentic:provider/schema-core@1.0.0".to_string(),
1498 config_json: None,
1499 pack_ref: None,
1500 };
1501 match greentic_runner_host::runtime::block_on(pack.invoke_provider(
1502 &binding,
1503 exec_ctx.clone(),
1504 op,
1505 input_json,
1506 )) {
1507 Ok(output) => Ok(output),
1508 Err(provider_err) => {
1509 let input_json = serde_json::to_string(request)?;
1510 greentic_runner_host::runtime::block_on(pack.invoke_component(
1511 component_ref,
1512 exec_ctx,
1513 op,
1514 None,
1515 input_json,
1516 ))
1517 .with_context(|| {
1518 format!(
1519 "invoke setup component '{component_ref}' op '{op}' (provider path failed: {provider_err})"
1520 )
1521 })
1522 }
1523 }
1524}
1525
1526fn read_registration_component(pack_path: &Path, component_ref: &str) -> anyhow::Result<Value> {
1527 let file = File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1528 let mut archive = match ZipArchive::new(file) {
1529 Ok(archive) => archive,
1530 Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => {
1531 anyhow::bail!("{} is not a zip pack", pack_path.display())
1532 }
1533 Err(err) => return Err(err.into()),
1534 };
1535 let candidates = registration_component_candidates(component_ref);
1536 for candidate in candidates {
1537 match archive.by_name(&candidate) {
1538 Ok(mut entry) => {
1539 let mut raw = String::new();
1540 entry
1541 .read_to_string(&mut raw)
1542 .with_context(|| format!("read setup component {candidate}"))?;
1543 return serde_json::from_str(&raw)
1544 .or_else(|_| serde_yaml_bw::from_str(&raw))
1545 .with_context(|| format!("parse setup component {candidate}"));
1546 }
1547 Err(ZipError::FileNotFound) => continue,
1548 Err(err) => return Err(err.into()),
1549 }
1550 }
1551 anyhow::bail!(
1552 "setup component_ref '{}' not found in {}",
1553 component_ref,
1554 pack_path.display()
1555 )
1556}
1557
1558fn registration_component_candidates(component_ref: &str) -> Vec<String> {
1559 let trimmed = component_ref.trim().trim_start_matches("./");
1560 let mut candidates = vec![trimmed.to_string()];
1561 if !trimmed.ends_with(".json") && !trimmed.ends_with(".yaml") && !trimmed.ends_with(".yml") {
1562 candidates.push(format!("{trimmed}.json"));
1563 candidates.push(format!("components/{trimmed}.json"));
1564 candidates.push(format!("assets/{trimmed}.json"));
1565 candidates.push(format!("assets/components/{trimmed}.json"));
1566 }
1567 candidates.sort();
1568 candidates.dedup();
1569 candidates
1570}
1571
1572fn invoke_json_registration_component(
1573 component: &Value,
1574 op: &str,
1575 request: &Value,
1576) -> Option<Value> {
1577 let obj = component.as_object()?;
1578 if let Some(operations) = obj.get("operations").and_then(Value::as_object)
1579 && let Some(operation) = operations.get(op)
1580 {
1581 return operation_result(operation, request);
1582 }
1583 if let Some(ops) = obj.get("ops").and_then(Value::as_array) {
1584 for operation in ops {
1585 if operation.get("op").and_then(Value::as_str) == Some(op)
1586 || operation.get("name").and_then(Value::as_str) == Some(op)
1587 || operation.get("id").and_then(Value::as_str) == Some(op)
1588 {
1589 return operation_result(operation, request);
1590 }
1591 }
1592 }
1593 obj.get(op)
1594 .and_then(|operation| operation_result(operation, request))
1595}
1596
1597fn operation_result(operation: &Value, request: &Value) -> Option<Value> {
1598 if let Some(result) = operation
1599 .get("result")
1600 .or_else(|| operation.get("output"))
1601 .or_else(|| operation.get("outputs"))
1602 {
1603 return Some(result.clone());
1604 }
1605 if operation.get("echo_request").and_then(Value::as_bool) == Some(true) {
1606 return Some(request.clone());
1607 }
1608 if operation.is_object() {
1609 return Some(operation.clone());
1610 }
1611 None
1612}
1613
1614fn merge_registration_output(
1615 action: &mut crate::setup_actions::SetupAction,
1616 answers: &mut Value,
1617 registration: &Value,
1618 output: &Value,
1619) -> anyhow::Result<()> {
1620 let registration_obj = registration
1621 .as_object()
1622 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1623 let output_obj = output
1624 .as_object()
1625 .ok_or_else(|| anyhow!("setup action registration output must be an object"))?;
1626 let answers_obj = answers
1627 .as_object_mut()
1628 .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1629
1630 for (mapping_key, source_value) in registration_obj {
1631 let Some(generic_key) = mapping_key.strip_suffix("_output") else {
1632 continue;
1633 };
1634 let Some(source_key) = source_value
1635 .as_str()
1636 .map(str::trim)
1637 .filter(|value| !value.is_empty())
1638 else {
1639 continue;
1640 };
1641 let Some(value) = output_obj
1642 .get(source_key)
1643 .or_else(|| output_obj.get(generic_key))
1644 .filter(|value| !is_empty_value(value))
1645 .cloned()
1646 else {
1647 continue;
1648 };
1649 answers_obj.insert(source_key.to_string(), value.clone());
1650 answers_obj.insert(generic_key.to_string(), value.clone());
1651 if generic_key == "client_id" {
1652 if let Some(client_id_field) =
1653 action.extra.get("client_id_field").and_then(Value::as_str)
1654 {
1655 answers_obj.insert(client_id_field.to_string(), value.clone());
1656 }
1657 action.extra.insert("client_id".into(), value);
1658 } else {
1659 action.extra.insert(generic_key.to_string(), value);
1660 }
1661 }
1662 Ok(())
1663}
1664
1665fn registration_error_message(output: &Value) -> Option<String> {
1666 if output.get("ok").and_then(Value::as_bool) == Some(false) {
1667 return output
1668 .get("error")
1669 .and_then(Value::as_str)
1670 .map(ToString::to_string)
1671 .or_else(|| Some(output.to_string()));
1672 }
1673 None
1674}
1675
1676fn is_empty_value(value: &Value) -> bool {
1677 match value {
1678 Value::Null => true,
1679 Value::String(value) => value.trim().is_empty(),
1680 Value::Array(values) => values.is_empty(),
1681 Value::Object(values) => values.is_empty(),
1682 Value::Bool(_) | Value::Number(_) => false,
1683 }
1684}
1685
1686fn hydrate_oauth_install_actions(
1687 actions: &mut [crate::setup_actions::SetupAction],
1688 answers: &Value,
1689) {
1690 for action in actions {
1691 if action.kind != crate::setup_actions::SetupActionKind::OauthInstallButton {
1692 continue;
1693 }
1694 let client_id = client_id_for_action(action, answers);
1695 let Some(authorize_url) = action.authorize_url.as_mut() else {
1696 continue;
1697 };
1698 let Ok(mut parsed) = url::Url::parse(authorize_url) else {
1699 continue;
1700 };
1701 if !parsed.query_pairs().any(|(key, _)| key == "client_id")
1702 && let Some(client_id) = client_id
1703 {
1704 parsed
1705 .query_pairs_mut()
1706 .append_pair("client_id", &client_id);
1707 }
1708 if !parsed.query_pairs().any(|(key, _)| key == "scope")
1709 && let Some(scopes) = action.extra.get("scopes").and_then(Value::as_array)
1710 {
1711 let scope = scopes
1712 .iter()
1713 .filter_map(Value::as_str)
1714 .map(str::trim)
1715 .filter(|value| !value.is_empty())
1716 .collect::<Vec<_>>()
1717 .join(",");
1718 if !scope.is_empty() {
1719 parsed.query_pairs_mut().append_pair("scope", &scope);
1720 }
1721 }
1722 *authorize_url = parsed.to_string();
1723 }
1724}
1725
1726fn client_id_for_action(
1727 action: &crate::setup_actions::SetupAction,
1728 answers: &Value,
1729) -> Option<String> {
1730 let obj = answers.as_object()?;
1731 let mut keys = Vec::new();
1732 if let Some(field) = action.extra.get("client_id_field").and_then(Value::as_str) {
1733 keys.push(field);
1734 }
1735 keys.extend(["client_id", "oauth_client_id"]);
1736 keys.into_iter().find_map(|key| {
1737 obj.get(key)
1738 .and_then(Value::as_str)
1739 .map(str::trim)
1740 .filter(|value| !value.is_empty())
1741 .map(ToString::to_string)
1742 })
1743}
1744
1745fn compute_file_digest(path: &Path) -> anyhow::Result<String> {
1746 let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
1747 let digest = Sha256::digest(bytes);
1748 let encoded = digest
1749 .iter()
1750 .map(|byte| format!("{byte:02x}"))
1751 .collect::<String>();
1752 Ok(format!("sha256:{encoded}"))
1753}
1754
1755fn resolve_pack_ref(pack_ref: &str) -> anyhow::Result<PathBuf> {
1756 let source = BundleSource::parse(pack_ref)?;
1757 let resolved = source.resolve()?;
1758
1759 if resolved.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
1760 anyhow::bail!(
1761 "resolved pack ref is not a .gtpack file: {}",
1762 resolved.display()
1763 );
1764 }
1765
1766 Ok(resolved)
1767}
1768
1769pub fn execute_remove_provider_artifacts(
1771 bundle_path: &Path,
1772 providers_remove: &[String],
1773) -> anyhow::Result<usize> {
1774 let mut removed = 0usize;
1775 let discovered = discovery::discover(bundle_path).ok();
1776 for provider_id in providers_remove {
1777 if let Some(discovered) = discovered.as_ref()
1778 && let Some(provider) = discovered
1779 .providers
1780 .iter()
1781 .find(|provider| provider.provider_id == *provider_id)
1782 {
1783 if provider.pack_path.exists() {
1784 std::fs::remove_file(&provider.pack_path).with_context(|| {
1785 format!(
1786 "failed to remove provider pack {}",
1787 provider.pack_path.display()
1788 )
1789 })?;
1790 }
1791 removed += 1;
1792 } else {
1793 let target_dir = get_pack_target_dir(bundle_path, provider_id);
1794 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1795 if target_path.exists() {
1796 std::fs::remove_file(&target_path).with_context(|| {
1797 format!("failed to remove provider pack {}", target_path.display())
1798 })?;
1799 removed += 1;
1800 }
1801 }
1802
1803 let config_dir = bundle_path.join("state").join("config").join(provider_id);
1804 if config_dir.exists() {
1805 std::fs::remove_dir_all(&config_dir).with_context(|| {
1806 format!(
1807 "failed to remove provider config dir {}",
1808 config_dir.display()
1809 )
1810 })?;
1811 }
1812 }
1813 Ok(removed)
1814}
1815
1816pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
1825 let bundle_abs =
1826 std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
1827
1828 let installed_ids: std::collections::HashSet<String> = discovery::discover(bundle_path)
1829 .map(|d| {
1830 d.providers
1831 .into_iter()
1832 .chain(d.app_packs)
1833 .map(|p| p.provider_id)
1834 .collect()
1835 })
1836 .unwrap_or_default();
1837
1838 for provider_id in metadata.setup_answers.keys() {
1839 if installed_ids.contains(provider_id) {
1840 continue;
1841 }
1842 let target_dir = get_pack_target_dir(bundle_path, provider_id);
1843 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1844 if target_path.exists() {
1845 continue;
1846 }
1847
1848 let domain = domain_from_provider_id(provider_id);
1850
1851 if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
1853 if let Err(err) = std::fs::create_dir_all(&target_dir) {
1854 eprintln!(
1855 " [provider] WARNING: failed to create {}: {err}",
1856 target_dir.display()
1857 );
1858 continue;
1859 }
1860 match std::fs::copy(&source, &target_path) {
1861 Ok(_) => println!(
1862 " [provider] installed {provider_id}.gtpack from {}",
1863 source.display()
1864 ),
1865 Err(err) => eprintln!(
1866 " [provider] WARNING: failed to copy {}: {err}",
1867 source.display()
1868 ),
1869 }
1870 } else {
1871 eprintln!(" [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
1872 }
1873 }
1874}
1875
1876pub fn domain_from_provider_id(provider_id: &str) -> &str {
1878 const DOMAIN_PREFIXES: &[&str] = &[
1879 "messaging-",
1880 "events-",
1881 "oauth-",
1882 "secrets-",
1883 "mcp-",
1884 "state-",
1885 "telemetry-",
1886 ];
1887 for prefix in DOMAIN_PREFIXES {
1888 if provider_id.starts_with(prefix) {
1889 return prefix.trim_end_matches('-');
1890 }
1891 }
1892 "messaging" }
1894
1895pub fn find_provider_pack_source(
1901 provider_id: &str,
1902 domain: &str,
1903 bundle_abs: &Path,
1904) -> Option<PathBuf> {
1905 let parent = bundle_abs.parent()?;
1906 let filename = format!("{provider_id}.gtpack");
1907
1908 if let Ok(entries) = std::fs::read_dir(parent) {
1910 for entry in entries.flatten() {
1911 let sibling = entry.path();
1912 if sibling == *bundle_abs || !sibling.is_dir() {
1913 continue;
1914 }
1915 let candidate = sibling.join("providers").join(domain).join(&filename);
1916 if candidate.is_file() {
1917 return Some(candidate);
1918 }
1919 }
1920 }
1921
1922 for ancestor in parent.ancestors().take(4) {
1924 let candidate = ancestor
1925 .join("greentic-messaging-providers")
1926 .join("target")
1927 .join("packs")
1928 .join(&filename);
1929 if candidate.is_file() {
1930 return Some(candidate);
1931 }
1932 }
1933
1934 None
1935}
1936
1937pub fn execute_write_gmap_rules(
1939 bundle_path: &Path,
1940 metadata: &SetupPlanMetadata,
1941) -> anyhow::Result<()> {
1942 for tenant_sel in &metadata.tenants {
1943 let gmap_path =
1944 bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
1945
1946 if let Some(parent) = gmap_path.parent() {
1947 std::fs::create_dir_all(parent)?;
1948 }
1949
1950 let mut content = String::new();
1952 if tenant_sel.allow_paths.is_empty() {
1953 content.push_str("_ = forbidden\n");
1954 } else {
1955 for path in &tenant_sel.allow_paths {
1956 content.push_str(&format!("{} = allowed\n", path));
1957 }
1958 content.push_str("_ = forbidden\n");
1959 }
1960
1961 std::fs::write(&gmap_path, content)
1962 .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
1963 }
1964 Ok(())
1965}
1966
1967pub fn execute_copy_resolved_manifests(
1969 bundle_path: &Path,
1970 metadata: &SetupPlanMetadata,
1971) -> anyhow::Result<Vec<PathBuf>> {
1972 let mut manifests = Vec::new();
1973 let resolved_dir = bundle_path.join("resolved");
1974 std::fs::create_dir_all(&resolved_dir)?;
1975
1976 for tenant_sel in &metadata.tenants {
1977 let filename =
1978 bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
1979 let manifest_path = resolved_dir.join(&filename);
1980
1981 if !manifest_path.exists() {
1983 std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
1984 }
1985 manifests.push(manifest_path);
1986 }
1987
1988 Ok(manifests)
1989}
1990
1991pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
1993 bundle::validate_bundle_exists(bundle_path)
1994}
1995
1996pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
2006 tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
2007 Ok(())
2008}
2009
2010#[cfg(test)]
2011mod tests {
2012 use super::*;
2013 use crate::platform_setup::StaticRoutesPolicy;
2014 use std::collections::BTreeSet;
2015
2016 #[test]
2017 fn secret_uri_candidates_cover_env_and_provider_branches() {
2018 let cands =
2021 secret_uri_candidates("secrets://dev/demo/_/messaging-slack/slack_signing_secret");
2022 assert!(
2023 cands
2024 .contains(&"secrets://dev/demo/_/messaging-slack/slack_signing_secret".to_string())
2025 );
2026 assert!(
2027 cands.contains(
2028 &"secrets://local/demo/_/messaging_slack/slack_signing_secret".to_string()
2029 )
2030 );
2031 assert!(
2032 cands.contains(
2033 &"secrets://local/demo/_/messaging-slack/slack_signing_secret".to_string()
2034 )
2035 );
2036 assert!(
2037 cands
2038 .contains(&"secrets://dev/demo/_/messaging_slack/slack_signing_secret".to_string())
2039 );
2040 assert_eq!(
2042 cands[0],
2043 "secrets://dev/demo/_/messaging-slack/slack_signing_secret"
2044 );
2045 }
2046
2047 #[test]
2048 fn secret_uri_candidates_do_not_alias_custom_env() {
2049 let cands = secret_uri_candidates("secrets://prod/acme/team/messaging_slack/bot_token");
2050 assert!(cands.contains(&"secrets://prod/acme/team/messaging_slack/bot_token".to_string()));
2053 assert!(cands.contains(&"secrets://prod/acme/team/messaging-slack/bot_token".to_string()));
2054 assert!(
2055 !cands
2056 .iter()
2057 .any(|c| c.contains("/dev/") || c.contains("/local/"))
2058 );
2059 }
2060
2061 #[test]
2062 fn secret_uri_candidates_passthrough_non_secret_paths() {
2063 assert_eq!(
2064 secret_uri_candidates("SLACK_BOT_TOKEN"),
2065 vec!["SLACK_BOT_TOKEN".to_string()]
2066 );
2067 }
2068
2069 fn empty_metadata(pack_refs: Vec<String>) -> SetupPlanMetadata {
2070 SetupPlanMetadata {
2071 bundle_name: None,
2072 pack_refs,
2073 tenants: Vec::new(),
2074 default_assignments: Vec::new(),
2075 providers: Vec::new(),
2076 update_ops: BTreeSet::new(),
2077 remove_targets: BTreeSet::new(),
2078 packs_remove: Vec::new(),
2079 providers_remove: Vec::new(),
2080 tenants_remove: Vec::new(),
2081 access_changes: Vec::new(),
2082 static_routes: StaticRoutesPolicy::default(),
2083 deployment_targets: Vec::new(),
2084 setup_answers: serde_json::Map::new(),
2085 tunnel: None,
2086 telemetry: None,
2087 }
2088 }
2089
2090 #[test]
2091 fn resolve_packs_errors_when_any_pack_ref_fails() {
2092 let metadata = empty_metadata(vec!["/definitely/missing/example.gtpack".to_string()]);
2093 let err = execute_resolve_packs(Path::new("."), &metadata).unwrap_err();
2094 let message = err.to_string();
2095
2096 assert!(message.contains("failed to resolve 1 pack ref"));
2097 assert!(message.contains("/definitely/missing/example.gtpack"));
2098 }
2099
2100 #[test]
2105 fn auto_install_skips_when_pack_id_matches_under_custom_filename() {
2106 use std::io::Write;
2107 use zip::write::{FileOptions, ZipWriter};
2108
2109 let temp = tempfile::tempdir().expect("tempdir");
2110 let bundle = temp.path().join("bundle");
2111 let messaging_dir = bundle.join("providers").join("messaging");
2112 std::fs::create_dir_all(&messaging_dir).expect("create messaging dir");
2113
2114 let custom_pack = messaging_dir.join("messaging-webchat-gui-3aigent.gtpack");
2115 let file = std::fs::File::create(&custom_pack).expect("create pack file");
2116 let mut writer = ZipWriter::new(file);
2117 let options: FileOptions<'_, ()> =
2118 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2119 writer
2120 .start_file("pack.manifest.json", options)
2121 .expect("start manifest");
2122 writer
2123 .write_all(
2124 serde_json::json!({
2125 "pack_id": "messaging-webchat-gui",
2126 "display_name": "WebChat GUI",
2127 })
2128 .to_string()
2129 .as_bytes(),
2130 )
2131 .expect("write manifest");
2132 writer.finish().expect("finish zip");
2133
2134 let canonical_pack = messaging_dir.join("messaging-webchat-gui.gtpack");
2135 assert!(!canonical_pack.exists(), "precondition: canonical absent");
2136
2137 let mut metadata = empty_metadata(vec![]);
2138 metadata.setup_answers.insert(
2139 "messaging-webchat-gui".to_string(),
2140 serde_json::Value::Object(serde_json::Map::new()),
2141 );
2142
2143 auto_install_provider_packs(&bundle, &metadata);
2144
2145 assert!(
2146 custom_pack.exists(),
2147 "custom-named pack must be left in place"
2148 );
2149 assert!(
2150 !canonical_pack.exists(),
2151 "must not auto-install canonical-named duplicate when pack_id already present"
2152 );
2153 }
2154
2155 fn secret_keys_for(keys: &[&str]) -> BTreeSet<String> {
2156 keys.iter()
2157 .map(|k| crate::secret_name::canonical_secret_name(k))
2158 .collect()
2159 }
2160
2161 #[test]
2162 fn envelope_redaction_replaces_secret_values_with_canonical_uri_refs() {
2163 let secret_keys = secret_keys_for(&["api_key", "oauth_client_secret"]);
2164
2165 let answers = serde_json::json!({
2166 "model": "gpt-4o-mini",
2167 "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK",
2168 "oauth_client_secret": "PLAINTEXT-OAUTH-SECRET",
2169 "non_secret_url": "https://api.openai.com/v1"
2170 });
2171
2172 let redacted = redact_secret_answer_values_to_uri_refs(
2173 &answers,
2174 &secret_keys,
2175 "dev",
2176 "demo",
2177 Some("default"),
2178 "openai-llm",
2179 );
2180
2181 let map = redacted.as_object().expect("object");
2182 assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2183 assert_eq!(
2184 map["non_secret_url"].as_str(),
2185 Some("https://api.openai.com/v1")
2186 );
2187 assert_eq!(
2190 map["api_key"].as_str(),
2191 Some("secrets://dev/demo/_/openai_llm/api_key"),
2192 "secret value must be replaced with canonical secrets:// URI",
2193 );
2194 assert_eq!(
2195 map["oauth_client_secret"].as_str(),
2196 Some("secrets://dev/demo/_/openai_llm/oauth_client_secret"),
2197 );
2198
2199 let json = serde_json::to_string(&redacted).expect("serialize");
2200 assert!(
2201 !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2202 "api_key plaintext leaked into envelope JSON: {json}",
2203 );
2204 assert!(
2205 !json.contains("PLAINTEXT-OAUTH-SECRET"),
2206 "oauth_client_secret plaintext leaked into envelope JSON: {json}",
2207 );
2208 }
2209
2210 #[test]
2211 fn setup_answers_redaction_drops_secret_keys_entirely() {
2212 let secret_keys = secret_keys_for(&["api_key"]);
2217 let answers = serde_json::json!({
2218 "model": "gpt-4o-mini",
2219 "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK"
2220 });
2221
2222 let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2223 let map = stripped.as_object().expect("object");
2224 assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2225 assert!(
2226 !map.contains_key("api_key"),
2227 "secret key must be removed entirely from setup-answers",
2228 );
2229 let json = serde_json::to_string(&stripped).expect("serialize");
2230 assert!(
2231 !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2232 "plaintext leaked into setup-answers: {json}",
2233 );
2234 assert!(
2235 !json.contains("secrets://"),
2236 "setup-answers must not carry URI refs either — readers fetch via SecretsManager",
2237 );
2238 }
2239
2240 #[test]
2241 fn is_secret_answer_key_matches_aliases_via_canonical_suffix() {
2242 let secret_keys = secret_keys_for(&["webex_bot_token"]);
2247 assert!(is_secret_answer_key("bot_token", &secret_keys));
2248 assert!(is_secret_answer_key("BOT_TOKEN", &secret_keys));
2249 assert!(is_secret_answer_key("webex_bot_token", &secret_keys));
2250 assert!(!is_secret_answer_key("model", &secret_keys));
2252 assert!(!is_secret_answer_key("bot_url", &secret_keys));
2253 }
2254
2255 #[test]
2256 fn is_secret_answer_key_does_not_over_match_reverse_direction() {
2257 let secret_keys = secret_keys_for(&["token"]);
2264 assert!(is_secret_answer_key("token", &secret_keys));
2265 assert!(
2266 !is_secret_answer_key("bot_token", &secret_keys),
2267 "answer key longer than the secret key must not match (reverse direction removed)",
2268 );
2269 assert!(!is_secret_answer_key("refresh_token", &secret_keys));
2270 }
2271
2272 #[test]
2273 fn is_secret_answer_key_punctuation_only_key_does_not_match_unrelated_secret() {
2274 let secret_keys = secret_keys_for(&["api_key"]);
2278 assert!(!is_secret_answer_key("", &secret_keys));
2279 assert!(!is_secret_answer_key("---", &secret_keys));
2280 }
2281
2282 #[test]
2283 fn alias_answer_key_redacted_in_setup_answers_and_envelope() {
2284 let secret_keys = secret_keys_for(&["webex_bot_token"]);
2287 let answers = serde_json::json!({"bot_token": "T0K3N-MUST-NOT-LEAK"});
2288
2289 let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2290 assert!(
2291 stripped.as_object().unwrap().is_empty(),
2292 "alias-matched secret key must be dropped from setup-answers",
2293 );
2294
2295 let envelope = redact_secret_answer_values_to_uri_refs(
2296 &answers,
2297 &secret_keys,
2298 "dev",
2299 "demo",
2300 None,
2301 "messaging-webex",
2302 );
2303 assert_eq!(
2304 envelope["bot_token"].as_str(),
2305 Some("secrets://dev/demo/_/messaging_webex/bot_token"),
2306 );
2307 let json = serde_json::to_string(&envelope).unwrap();
2308 assert!(!json.contains("T0K3N-MUST-NOT-LEAK"));
2309 }
2310
2311 #[test]
2312 fn secret_keys_fail_closed_distinguishes_none_from_empty_set() {
2313 let content = serde_json::json!({"model": "gpt-4o"});
2314 let empty = serde_json::json!({});
2315
2316 let r = secret_keys_or_fail_closed(Some(BTreeSet::new()), &content, "p").unwrap();
2320 assert!(r.is_empty(), "Some(empty) proceeds with no redaction");
2321
2322 let set = secret_keys_for(&["api_key"]);
2324 let r = secret_keys_or_fail_closed(Some(set.clone()), &content, "p").unwrap();
2325 assert_eq!(r, set);
2326
2327 assert!(secret_keys_or_fail_closed(None, &content, "p").is_err());
2329
2330 assert!(
2332 secret_keys_or_fail_closed(None, &empty, "p")
2333 .unwrap()
2334 .is_empty()
2335 );
2336 }
2337
2338 #[test]
2339 fn answers_have_content_distinguishes_empty_from_meaningful() {
2340 assert!(!answers_have_content(&serde_json::json!({})));
2341 assert!(!answers_have_content(&serde_json::json!({"a": null})));
2342 assert!(!answers_have_content(&serde_json::json!({"a": ""})));
2343 assert!(answers_have_content(&serde_json::json!({"a": "value"})));
2344 assert!(answers_have_content(&serde_json::json!({"a": 42})));
2345 assert!(answers_have_content(&serde_json::json!({"a": true})));
2346 assert!(answers_have_content(&serde_json::json!({"a": ["x"]})));
2347 }
2348}