1mod assets;
8
9use std::fs::OpenOptions;
10use std::io::{BufRead, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, Stdio};
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15
16use anyhow::{Context, Result, anyhow};
17use axum::body::{Body, Bytes, to_bytes};
18use axum::extract::{Path as AxumPath, Query, Request, State};
19use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
20use axum::response::{IntoResponse, Response};
21use axum::routing::{any, get, post};
22use axum::{Json, Router};
23use serde::{Deserialize, Serialize};
24use serde_json::{Map as JsonMap, Value};
25use tokio::sync::{Mutex as AsyncMutex, broadcast};
26use url::Url;
27
28use crate::cli_i18n::CliI18n;
29use crate::engine::{SetupConfig, SetupRequest};
30use crate::plan::TenantSelection;
31use crate::platform_setup::StaticRoutesPolicy;
32use crate::qa::wizard;
33use crate::setup_tunnel::{
34 SetupTunnel, inject_setup_public_base_url, is_ephemeral_tunnel_url, should_start_setup_tunnel,
35 start_setup_tunnel,
36};
37use crate::{SetupEngine, SetupMode, discovery, setup_to_formspec};
38
39use crate::qa::shared_questions::HIDDEN_FROM_PROMPTS;
40
41struct UiState {
44 bundle_path: PathBuf,
45 tenant: String,
46 team: Option<String>,
47 env: String,
48 #[allow(dead_code)]
49 advanced: bool,
50 locale: Option<String>,
51 prefill_answers: Option<JsonMap<String, Value>>,
53 output_target: Option<crate::cli_helpers::SetupOutputTarget>,
59 local_base_url: String,
60 setup_session_id: String,
61 setup_tunnel: Mutex<Option<SetupTunnel>>,
62 setup_tunnel_start: AsyncMutex<()>,
63 tunnel_failure_cooldown: Mutex<Option<Instant>>,
70 setup_runtime: Mutex<Option<SetupRuntime>>,
71 setup_runtime_start: AsyncMutex<()>,
72 shutdown_tx: broadcast::Sender<()>,
73 #[allow(dead_code)]
74 result: Mutex<Option<ExecutionResult>>,
75}
76
77struct SetupRuntime {
78 child: Child,
79 info: Arc<Mutex<SetupRuntimeInfo>>,
80}
81
82#[derive(Clone, Debug, Default)]
83struct SetupRuntimeInfo {
84 local_base_url: Option<String>,
85 public_base_url: Option<String>,
86 system_log_line_floor: Option<usize>,
87 ready: bool,
88}
89
90impl Drop for SetupRuntime {
91 fn drop(&mut self) {
92 let _ = self.child.kill();
93 let _ = self.child.wait();
94 }
95}
96
97#[derive(Serialize)]
98#[allow(dead_code)]
99struct ProvidersResponse {
100 bundle_path: String,
101 providers: Vec<ProviderInfo>,
102 provider_forms: Vec<ProviderForm>,
103 shared_questions: Vec<QuestionInfo>,
104}
105
106#[derive(Serialize)]
107struct ProviderInfo {
108 provider_id: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 display_name: Option<String>,
111 domain: String,
112 question_count: usize,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 setup_web_component: Option<Value>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 setup_backend_contract: Option<Value>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 setup_machine: Option<Value>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 setup_actions: Option<Value>,
121}
122
123#[derive(Serialize)]
124struct ProviderForm {
125 provider_id: String,
126 title: String,
127 questions: Vec<QuestionInfo>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
135struct ProviderCatalogItem {
136 id: String,
138 category: String,
140 label: ProviderCatalogLabel,
141 #[serde(rename = "ref")]
144 reference: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148struct ProviderCatalogLabel {
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 i18n_key: Option<String>,
151 fallback: String,
152}
153
154#[derive(Debug, Clone, Deserialize)]
155struct ProviderCatalog {
156 #[serde(default)]
157 #[allow(dead_code)]
158 registry_version: Option<String>,
159 #[serde(default)]
160 items: Vec<ProviderCatalogItem>,
161}
162
163impl ProviderCatalog {
164 fn load_embedded() -> anyhow::Result<Self> {
166 serde_json::from_str(assets::PROVIDERS_CATALOG)
167 .context("failed to parse embedded providers-catalog.json")
168 }
169}
170
171#[derive(Serialize, Clone)]
172struct QuestionInfo {
173 id: String,
174 title: String,
175 kind: String,
176 required: bool,
177 secret: bool,
178 default_value: Option<String>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 saved_value: Option<String>,
181 #[serde(skip_serializing_if = "Option::is_none")]
187 saved_rows: Option<Vec<Value>>,
188 help: Option<String>,
189 choices: Option<Vec<String>>,
190 visible_if: Option<VisibleIfInfo>,
191 placeholder: Option<String>,
192 group: Option<String>,
193 docs_url: Option<String>,
194 #[serde(skip_serializing_if = "Option::is_none")]
197 create_url: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
202 list_columns: Option<Vec<ListColumnInfo>>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 min_rows: Option<usize>,
206 #[serde(skip_serializing_if = "Option::is_none")]
208 max_rows: Option<usize>,
209}
210
211#[derive(Serialize, Clone)]
214struct ListColumnInfo {
215 id: String,
216 title: String,
217 kind: String,
218 required: bool,
219 help: Option<String>,
220 placeholder: Option<String>,
221 choices: Option<Vec<String>>,
222 default_value: Option<String>,
223 #[serde(skip_serializing_if = "std::ops::Not::not")]
227 multilingual: bool,
228}
229
230#[derive(Serialize, Clone)]
231struct VisibleIfInfo {
232 field: String,
233 eq: Option<String>,
234}
235
236struct SetupQuestionExtras {
238 placeholder: Option<String>,
239 group: Option<String>,
240 docs_url: Option<String>,
241 create_url: Option<String>,
242 column_multilingual: std::collections::HashMap<String, bool>,
246}
247
248#[derive(Deserialize)]
249struct ExecuteRequest {
250 answers: JsonMap<String, Value>,
251 #[serde(default)]
252 provider_setup_status: JsonMap<String, Value>,
253 #[serde(default)]
254 tenant: Option<String>,
255 #[serde(default)]
256 team: Option<String>,
257 #[serde(default)]
258 env: Option<String>,
259 #[serde(default)]
260 tunnel: Option<String>,
261}
262
263#[derive(Deserialize)]
264struct ProviderSetupEventRequest {
265 provider_id: String,
266 event_name: String,
267 #[serde(default)]
268 event_detail: Value,
269 #[serde(default)]
270 current_step_id: Option<Value>,
271 #[serde(default)]
272 current_progress: Option<Value>,
273 #[serde(default)]
274 action_name: Option<Value>,
275 #[serde(default)]
276 request_method: Option<Value>,
277 #[serde(default)]
278 request_path: Option<Value>,
279 #[serde(default)]
280 http_status: Option<Value>,
281 #[serde(default)]
282 response_body: Option<Value>,
283 #[serde(default)]
284 error: Option<Value>,
285 #[serde(default)]
286 correlation_id: Option<Value>,
287 #[serde(default)]
288 tenant: Option<String>,
289 #[serde(default)]
290 team: Option<String>,
291 #[serde(default)]
292 env: Option<String>,
293 #[serde(default)]
294 setup_session_id: Option<String>,
295 #[serde(default)]
296 setup_ui_url: Option<String>,
297}
298
299#[derive(Deserialize)]
300struct ProviderSetupEventsQuery {
301 provider_id: String,
302 #[serde(default)]
303 tenant: Option<String>,
304 #[serde(default)]
305 team: Option<String>,
306 #[serde(default)]
307 env: Option<String>,
308 #[serde(default)]
309 limit: Option<usize>,
310}
311
312#[derive(Deserialize)]
313struct DraftSaveRequest {
314 answers: JsonMap<String, Value>,
315 tenant: String,
316 #[serde(default)]
317 team: Option<String>,
318 env: String,
319 #[serde(default)]
320 tunnel: Option<String>,
321}
322
323#[derive(Deserialize)]
324struct SetupActionRequest {
325 provider_id: String,
326 action_id: String,
327 answers: JsonMap<String, Value>,
328 #[serde(default)]
329 tenant: Option<String>,
330 #[serde(default)]
331 team: Option<String>,
332 #[serde(default)]
333 env: Option<String>,
334 #[serde(default)]
335 tunnel: Option<String>,
336}
337
338#[derive(Deserialize)]
339struct SetupPublicUrlRequest {
340 #[serde(default)]
341 tenant: Option<String>,
342 #[serde(default)]
343 team: Option<String>,
344 #[serde(default)]
345 env: Option<String>,
346 #[serde(default)]
347 tunnel: Option<String>,
348}
349
350#[derive(Serialize)]
351struct ScopeResponse {
352 tenant: String,
353 team: Option<String>,
354 env: String,
355 detected_tenant: Option<String>,
356 cloud_deploy: bool,
357 #[serde(skip_serializing_if = "Option::is_none")]
358 tunnel: Option<String>,
359}
360
361#[derive(Serialize, Clone)]
362struct ExecutionResult {
363 success: bool,
364 stdout: String,
365 stderr: String,
366 manual_steps: Vec<crate::webhook::ProviderInstruction>,
367 #[serde(default, skip_serializing_if = "JsonMap::is_empty")]
368 provider_setup_status: JsonMap<String, Value>,
369}
370
371#[derive(Clone, Debug)]
372struct DeclaredStaticRoute {
373 provider_id: String,
374 pack_path: PathBuf,
375 public_path: String,
376 source_root: String,
377}
378
379#[derive(Clone, Debug)]
380struct ProviderBackendContract {
381 provider_id: String,
382 inline: Value,
383 load_error: Option<String>,
384}
385
386#[derive(Clone, Debug)]
387struct DeclaredProviderHttpRoute {
388 provider_id: String,
389 pack_path: PathBuf,
390 methods: Vec<String>,
391 target: ProviderHttpRouteTarget,
392 segments: Vec<ProviderHttpRouteSegment>,
393}
394
395#[derive(Clone, Debug)]
396enum ProviderHttpRouteTarget {
397 SetupComponent { component_ref: String, op: String },
398 ProviderIngress { component_ref: String, op: String },
399}
400
401#[derive(Clone, Debug)]
402enum ProviderHttpRouteSegment {
403 Literal(String),
404 Tenant,
405 Team,
406 Wildcard,
407}
408
409#[derive(Clone, Debug)]
410struct ProviderHttpRouteMatch {
411 route: DeclaredProviderHttpRoute,
412 tenant: String,
413 team: String,
414}
415
416struct ProviderHttpLocalExecution<'a> {
417 contract: &'a ProviderBackendContract,
418 provider_id: &'a str,
419 tenant: &'a str,
420 action: &'a Value,
421 target: &'a str,
422 method: &'a str,
423 payload: Value,
424 runtime_context: Value,
425}
426
427#[allow(clippy::too_many_arguments)]
435pub async fn launch(
436 bundle_path: &Path,
437 tenant: &str,
438 team: Option<&str>,
439 env: &str,
440 advanced: bool,
441 locale: Option<&str>,
442 prefill_answers: Option<JsonMap<String, Value>>,
443 _scope_from_answers: bool,
444 output_target: Option<crate::cli_helpers::SetupOutputTarget>,
445) -> Result<()> {
446 let (shutdown_tx, _) = broadcast::channel::<()>(1);
447
448 let bind_port = std::env::var("GREENTIC_SETUP_BIND_PORT")
453 .ok()
454 .and_then(|value| value.trim().parse::<u16>().ok())
455 .unwrap_or(0);
456 let listener = tokio::net::TcpListener::bind(("127.0.0.1", bind_port)).await?;
457 let port = listener.local_addr()?.port();
458 let url = format!("http://127.0.0.1:{port}");
459 let setup_session_id = format!("setup-{port}-{}", unix_timestamp_millis());
460
461 let state = std::sync::Arc::new(UiState {
462 bundle_path: bundle_path.to_path_buf(),
463 tenant: tenant.to_string(),
464 team: team.map(String::from),
465 env: env.to_string(),
466 advanced,
467 locale: locale.map(String::from),
468 prefill_answers,
469 output_target,
470 local_base_url: url.clone(),
471 setup_session_id,
472 setup_tunnel: Mutex::new(None),
473 setup_tunnel_start: AsyncMutex::new(()),
474 tunnel_failure_cooldown: Mutex::new(None),
475 setup_runtime: Mutex::new(None),
476 setup_runtime_start: AsyncMutex::new(()),
477 shutdown_tx: shutdown_tx.clone(),
478 result: Mutex::new(None),
479 });
480
481 let router = build_router(state.clone());
482
483 eprintln!(
484 "Setup UI started at: {url} (greentic-setup {} build {})",
485 env!("CARGO_PKG_VERSION"),
486 env!("GREENTIC_SETUP_BUILD_SHA")
487 );
488 if std::env::var("GREENTIC_SETUP_NO_OPEN").ok().as_deref() != Some("1") {
489 let _ = open::that(&url);
490 }
491
492 let mut shutdown_rx = shutdown_tx.subscribe();
493 axum::serve(listener, router)
494 .with_graceful_shutdown(async move {
495 let _ = shutdown_rx.recv().await;
496 })
497 .await?;
498
499 Ok(())
500}
501
502fn build_router(state: std::sync::Arc<UiState>) -> Router {
503 Router::new()
504 .route("/", get(serve_index))
505 .route("/app.js", get(serve_js))
506 .route("/style.css", get(serve_css))
507 .route("/api/locales", get(get_locales))
508 .route("/api/scope", get(get_scope))
509 .route("/api/existing-scopes", get(get_existing_scopes))
510 .route("/api/providers", get(get_providers))
511 .route("/api/available-providers", get(get_available_providers))
512 .route("/api/add-provider", post(post_add_provider))
513 .route("/api/result", get(get_result))
514 .route(
515 "/api/provider-setup-events",
516 get(get_provider_setup_events).post(post_provider_setup_event),
517 )
518 .route("/api/draft", post(post_draft))
519 .route("/api/setup-public-url", post(post_setup_public_url))
520 .route("/api/setup-action", post(post_setup_action))
521 .route("/api/execute", post(post_execute))
522 .route("/api/export", post(post_export))
523 .route("/api/decrypt", post(post_decrypt))
524 .route("/oauth/callback/{provider}", get(get_oauth_callback))
525 .route("/v1/web/{*asset_path}", get(get_declared_static_asset))
526 .route(
527 "/v1/setup/{*proxy_path}",
528 any(proxy_declared_provider_http_route),
529 )
530 .route(
531 "/v1/messaging/setup/{*proxy_path}",
532 any(proxy_provider_setup_api),
533 )
534 .route("/api/shutdown", post(post_shutdown))
535 .with_state(state)
536}
537
538async fn serve_index() -> impl IntoResponse {
541 (
542 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
543 assets::INDEX_HTML,
544 )
545}
546
547async fn serve_js() -> impl IntoResponse {
548 (
549 [(
550 header::CONTENT_TYPE,
551 "application/javascript; charset=utf-8",
552 )],
553 assets::APP_JS,
554 )
555}
556
557async fn serve_css() -> impl IntoResponse {
558 (
559 [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
560 assets::STYLE_CSS,
561 )
562}
563
564#[derive(Serialize)]
568struct AvailableProvider {
569 id: String,
570 category: String,
571 label: String,
572 #[serde(rename = "ref")]
573 reference: String,
574}
575
576#[derive(Serialize)]
577struct AvailableProvidersResponse {
578 items: Vec<AvailableProvider>,
579}
580
581async fn get_available_providers(
585 State(state): State<std::sync::Arc<UiState>>,
586) -> Json<AvailableProvidersResponse> {
587 let catalog = match ProviderCatalog::load_embedded() {
588 Ok(catalog) => catalog,
589 Err(err) => {
590 tracing::warn!("providers catalog unavailable: {err:#}");
591 return Json(AvailableProvidersResponse { items: Vec::new() });
592 }
593 };
594
595 let installed: std::collections::HashSet<String> = discovery::discover(&state.bundle_path)
596 .map(|discovered| {
597 discovered
598 .providers
599 .into_iter()
600 .map(|provider| provider.provider_id)
601 .collect()
602 })
603 .unwrap_or_default();
604
605 Json(AvailableProvidersResponse {
606 items: available_provider_items(catalog, &installed),
607 })
608}
609
610fn available_provider_items(
612 catalog: ProviderCatalog,
613 installed: &std::collections::HashSet<String>,
614) -> Vec<AvailableProvider> {
615 catalog
616 .items
617 .into_iter()
618 .filter(|item| !installed.contains(&item.id))
619 .map(|item| AvailableProvider {
620 id: item.id,
621 category: item.category,
622 label: item.label.fallback,
623 reference: item.reference,
624 })
625 .collect()
626}
627
628#[derive(Deserialize)]
629struct AddProviderRequest {
630 id: String,
631}
632
633#[derive(Serialize)]
634struct AddProviderResponse {
635 ok: bool,
636 provider_id: String,
637}
638
639async fn post_add_provider(
644 State(state): State<std::sync::Arc<UiState>>,
645 Json(req): Json<AddProviderRequest>,
646) -> Response {
647 let catalog = match ProviderCatalog::load_embedded() {
648 Ok(catalog) => catalog,
649 Err(err) => return add_provider_error(StatusCode::INTERNAL_SERVER_ERROR, err),
650 };
651 let Some(item) = catalog.items.into_iter().find(|item| item.id == req.id) else {
652 return add_provider_error(
653 StatusCode::NOT_FOUND,
654 anyhow!("unknown provider id: {}", req.id),
655 );
656 };
657
658 match install_catalog_provider(&state.bundle_path, &item).await {
659 Ok(()) => Json(AddProviderResponse {
660 ok: true,
661 provider_id: item.id,
662 })
663 .into_response(),
664 Err(err) => add_provider_error(StatusCode::BAD_GATEWAY, err),
665 }
666}
667
668fn add_provider_error(status: StatusCode, err: anyhow::Error) -> Response {
669 (
670 status,
671 Json(serde_json::json!({ "ok": false, "error": format!("{err:#}") })),
672 )
673 .into_response()
674}
675
676async fn install_catalog_provider(
680 bundle_path: &std::path::Path,
681 item: &ProviderCatalogItem,
682) -> anyhow::Result<()> {
683 let source = crate::bundle_source::BundleSource::parse(&item.reference)?;
684 let pack_path = source.resolve_async().await?;
685 if !pack_path.is_file() {
686 anyhow::bail!(
687 "resolved provider pack is not a file: {}",
688 pack_path.display()
689 );
690 }
691
692 let domain_dir = bundle_path.join("providers").join(&item.category);
693 std::fs::create_dir_all(&domain_dir)
694 .with_context(|| format!("failed to create provider dir {}", domain_dir.display()))?;
695 let dest = domain_dir.join(format!("{}.gtpack", item.id));
696 std::fs::copy(&pack_path, &dest).with_context(|| {
697 format!(
698 "failed to copy provider pack {} -> {}",
699 pack_path.display(),
700 dest.display()
701 )
702 })?;
703 Ok(())
704}
705
706const LOCALE_OPTIONS: &[(&str, &str)] = &[
710 ("en", "English"),
711 ("id", "Bahasa Indonesia"),
712 ("ja", "日本語"),
713 ("zh", "中文"),
714 ("ko", "한국어"),
715 ("es", "Español"),
716 ("fr", "Français"),
717 ("de", "Deutsch"),
718 ("pt", "Português"),
719 ("ru", "Русский"),
720 ("ar", "العربية"),
721 ("th", "ไทย"),
722 ("vi", "Tiếng Việt"),
723 ("tr", "Türkçe"),
724 ("it", "Italiano"),
725 ("nl", "Nederlands"),
726 ("pl", "Polski"),
727 ("sv", "Svenska"),
728 ("hi", "हिन्दी"),
729 ("ms", "Bahasa Melayu"),
730];
731
732async fn get_locales(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
733 let current = state.locale.as_deref().unwrap_or("en");
734 let locales: Vec<Value> = LOCALE_OPTIONS
735 .iter()
736 .map(|(code, label)| {
737 serde_json::json!({
738 "code": code,
739 "label": label,
740 "selected": *code == current,
741 })
742 })
743 .collect();
744 Json(serde_json::json!({ "locales": locales, "current": current }))
745}
746
747#[derive(Deserialize)]
748struct ProviderQuery {
749 locale: Option<String>,
750}
751
752async fn get_scope(State(state): State<std::sync::Arc<UiState>>) -> Json<ScopeResponse> {
753 let bundle_path = &state.bundle_path;
754 let cli_tenant = &state.tenant;
755 let cli_env = &state.env;
756
757 let detected_tenant = detect_tenant_from_bundle(bundle_path);
759
760 let effective_tenant = cli_tenant.clone();
764
765 let cloud_deploy = prefill_has_cloud_deployment_targets(state.prefill_answers.as_ref());
766 let tunnel = crate::platform_setup::load_tunnel_artifact(bundle_path)
767 .ok()
768 .flatten()
769 .and_then(|answers| answers.mode)
770 .map(|mode| mode.trim().to_string())
771 .filter(|mode| !mode.is_empty());
772
773 Json(ScopeResponse {
774 tenant: effective_tenant,
775 team: state.team.clone(),
776 env: cli_env.clone(),
777 detected_tenant,
778 cloud_deploy,
779 tunnel,
780 })
781}
782
783fn prefill_has_cloud_deployment_targets(prefill: Option<&JsonMap<String, Value>>) -> bool {
784 prefill
785 .and_then(|answers| answers.get("platform_setup"))
786 .and_then(|value| value.as_object())
787 .and_then(|platform_setup| platform_setup.get("deployment_targets"))
788 .and_then(|value| value.as_array())
789 .map(|targets| {
790 targets.iter().any(|target| {
791 target
792 .get("target")
793 .and_then(Value::as_str)
794 .is_some_and(|target| matches!(target, "aws" | "gcp" | "azure"))
795 })
796 })
797 .unwrap_or(false)
798}
799
800fn detect_tenant_from_bundle(bundle_dir: &Path) -> Option<String> {
802 let tenants_dir = bundle_dir.join("tenants");
803 let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
804 .ok()?
805 .filter_map(|e| e.ok())
806 .filter(|e| e.path().is_dir())
807 .filter_map(|e| e.file_name().into_string().ok())
808 .collect();
809
810 match entries.len() {
811 0 => None,
812 1 => Some(entries[0].clone()),
813 _ => entries
814 .iter()
815 .find(|t| t.as_str() != "demo")
816 .cloned()
817 .or_else(|| entries.first().cloned()),
818 }
819}
820
821async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
827 let bundle_path = &state.bundle_path;
828
829 let tenants = {
831 let mut t = Vec::new();
832 let tenants_dir = bundle_path.join("tenants");
833 if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
834 for entry in entries.flatten() {
835 if entry.path().is_dir()
836 && let Some(name) = entry.file_name().to_str()
837 {
838 t.push(name.to_string());
839 }
840 }
841 }
842 if t.is_empty() {
843 t.push(state.tenant.clone());
844 }
845 t.sort();
846 if let Some(pos) = t.iter().position(|tenant| tenant == &state.tenant) {
847 let selected = t.remove(pos);
848 t.insert(0, selected);
849 }
850 t
851 };
852
853 let config_dir = bundle_path.join("state").join("config");
855 let mut provider_answers: JsonMap<String, Value> = JsonMap::new();
856 if let Ok(entries) = std::fs::read_dir(&config_dir) {
857 for entry in entries.flatten() {
858 if !entry.path().is_dir() {
859 continue;
860 }
861 let provider_id = entry.file_name().to_string_lossy().to_string();
862 let answers_file = entry.path().join("setup-answers.json");
863 if let Ok(content) = std::fs::read_to_string(&answers_file)
864 && let Ok(parsed) = serde_json::from_str::<Value>(&content)
865 {
866 provider_answers.insert(provider_id, parsed);
867 }
868 }
869 }
870
871 let discovered = discovery::discover(bundle_path).ok();
873 let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
874 .iter()
875 .flat_map(|d| d.setup_targets())
876 .filter_map(|p| {
877 setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id).map(|fs| {
878 wizard::ProviderFormSpec {
879 provider_id: p.provider_id.clone(),
880 form_spec: fs,
881 }
882 })
883 })
884 .collect();
885
886 let envs_to_probe = ["dev", "local"];
887 let mut scopes = Vec::new();
888
889 for tenant in &tenants {
890 for env in &envs_to_probe {
891 let saved =
892 load_saved_secrets(bundle_path, env, tenant, None, &provider_form_specs).await;
893
894 if saved.is_empty() {
895 continue;
896 }
897
898 let mut merged_answers = JsonMap::new();
900 for (pid, file_ans) in &provider_answers {
901 let mut cloned = file_ans.clone();
902 if let Some(map) = cloned.as_object_mut() {
912 let legacy_key = "nav_links_json";
913 let canonical_key = "nav_links";
914 if !map.contains_key(canonical_key)
915 && let Some(Value::String(raw)) = map.get(legacy_key)
916 && let Ok(parsed) = serde_json::from_str::<Value>(raw)
917 && parsed.is_array()
918 {
919 map.insert(canonical_key.to_string(), parsed);
920 }
921 map.remove(legacy_key);
922 }
923 merged_answers.insert(pid.clone(), cloned);
924 }
925 for (pid, secrets) in &saved {
927 let entry = merged_answers
928 .entry(pid.clone())
929 .or_insert_with(|| Value::Object(JsonMap::new()));
930 if let Some(obj) = entry.as_object_mut() {
931 for (k, v) in secrets {
932 obj.insert(k.clone(), Value::String(v.clone()));
933 }
934 }
935 }
936
937 scopes.push(serde_json::json!({
938 "tenant": tenant,
939 "env": env,
940 "team": null,
941 "answers": merged_answers,
942 "providers_done": saved.keys().collect::<Vec<_>>(),
943 }));
944 break; }
946 }
947
948 Json(serde_json::json!({ "scopes": scopes }))
949}
950
951async fn get_providers(
952 State(state): State<std::sync::Arc<UiState>>,
953 axum::extract::Query(query): axum::extract::Query<ProviderQuery>,
954) -> Json<Value> {
955 let bundle_path = &state.bundle_path;
956
957 let locale = query.locale.as_deref().or(state.locale.as_deref());
959
960 let i18n = CliI18n::from_request(locale)
962 .unwrap_or_else(|_| CliI18n::from_request(Some("en")).expect("en locale must exist"));
963 let ui_strings = i18n.keys_with_prefix("ui.");
964
965 let discovered = match discovery::discover(bundle_path) {
966 Ok(d) => d,
967 Err(e) => {
968 return Json(serde_json::json!({
969 "bundle_path": bundle_path.display().to_string(),
970 "providers": [],
971 "provider_forms": [],
972 "shared_questions": [],
973 "i18n": ui_strings,
974 "error": e.to_string(),
975 }));
976 }
977 };
978
979 let setup_targets = discovered.setup_targets();
980
981 let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
982 .iter()
983 .filter_map(|provider| {
984 setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
985 |form_spec| wizard::ProviderFormSpec {
986 provider_id: provider.provider_id.clone(),
987 form_spec,
988 },
989 )
990 })
991 .collect();
992
993 let shared_question_specs = if provider_form_specs.len() > 1 {
995 wizard::collect_shared_questions(&provider_form_specs)
996 .shared_questions
997 .clone()
998 } else {
999 vec![]
1000 };
1001
1002 let static_routes_by_provider = declared_static_routes_by_provider(&setup_targets);
1003
1004 let providers: Vec<ProviderInfo> = setup_targets
1005 .iter()
1006 .map(|p| {
1007 let form = setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id);
1008 let setup_web_component = load_setup_web_component_descriptor(
1009 p,
1010 static_routes_by_provider.get(&p.provider_id),
1011 );
1012 let setup_backend_contract =
1013 load_setup_backend_contract_descriptor(p).map(|contract| contract.inline);
1014 let setup_machine = load_setup_machine_descriptor(p);
1015 let setup_actions = load_setup_actions_descriptor(p);
1016 ProviderInfo {
1017 provider_id: p.provider_id.clone(),
1018 display_name: p.display_name.clone(),
1019 domain: p.domain.clone(),
1020 question_count: form.as_ref().map(|f| f.questions.len()).unwrap_or(0),
1021 setup_web_component,
1022 setup_backend_contract,
1023 setup_machine,
1024 setup_actions,
1025 }
1026 })
1027 .collect();
1028
1029 let mut extras_by_provider: std::collections::HashMap<
1031 String,
1032 std::collections::HashMap<String, SetupQuestionExtras>,
1033 > = std::collections::HashMap::new();
1034 for provider in &setup_targets {
1035 if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path) {
1036 let mut map = std::collections::HashMap::new();
1037 for q in &spec.questions {
1038 let mut column_multilingual = std::collections::HashMap::new();
1039 for col in &q.columns {
1040 if col.multilingual {
1041 column_multilingual.insert(col.key.clone(), true);
1042 }
1043 }
1044 map.insert(
1045 q.name.clone(),
1046 SetupQuestionExtras {
1047 placeholder: q.placeholder.clone(),
1048 group: q.group.clone(),
1049 docs_url: q.docs_url.clone(),
1050 create_url: q.create_url.clone(),
1051 column_multilingual,
1052 },
1053 );
1054 }
1055 extras_by_provider.insert(provider.provider_id.clone(), map);
1056 }
1057 }
1058
1059 let mut help_urls_by_provider: std::collections::HashMap<
1061 String,
1062 std::collections::HashMap<String, String>,
1063 > = std::collections::HashMap::new();
1064 for provider in &setup_targets {
1065 let links = setup_to_formspec::pack_help_urls(&provider.pack_path);
1066 if !links.is_empty() {
1067 help_urls_by_provider.insert(provider.provider_id.clone(), links);
1068 }
1069 }
1070
1071 let saved_secrets = load_saved_secrets(
1073 bundle_path,
1074 &state.env,
1075 &state.tenant,
1076 state.team.as_deref(),
1077 &provider_form_specs,
1078 )
1079 .await;
1080
1081 let prefill = &state.prefill_answers;
1083
1084 let shared_questions: Vec<QuestionInfo> = shared_question_specs
1088 .iter()
1089 .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
1090 .map(|q| {
1091 let mut info = form_question_to_info(q, Some(&i18n));
1092 info.create_url = help_urls_by_provider
1093 .values()
1094 .find_map(|m| m.get(&q.id))
1095 .cloned();
1096 let mut found = false;
1098 if let Some(answers) = prefill {
1099 for pfs in &provider_form_specs {
1100 if let Some(provider_answers) =
1101 answers.get(&pfs.provider_id).and_then(|v| v.as_object())
1102 && let Some(val) = provider_answers
1103 .get(&q.id)
1104 .and_then(value_as_nonempty_string)
1105 {
1106 if !info.secret {
1107 info.saved_value = Some(val);
1108 }
1109 found = true;
1110 break;
1111 }
1112 }
1113 }
1114 if !found {
1116 for secrets in saved_secrets.values() {
1117 if let Some(val) = secrets.get(&q.id) {
1118 if !info.secret {
1119 info.saved_value = Some(val.clone());
1120 }
1121 break;
1122 }
1123 }
1124 }
1125 info
1126 })
1127 .collect();
1128
1129 let provider_forms: Vec<ProviderForm> = provider_form_specs
1130 .iter()
1131 .map(|pfs| {
1132 let extras = extras_by_provider.get(&pfs.provider_id);
1133 let saved = saved_secrets.get(&pfs.provider_id);
1134 let answers = prefill
1135 .as_ref()
1136 .and_then(|a| a.get(&pfs.provider_id))
1137 .and_then(|v| v.as_object());
1138 ProviderForm {
1139 provider_id: pfs.provider_id.clone(),
1140 title: pfs.form_spec.title.clone(),
1141 questions: pfs
1142 .form_spec
1143 .questions
1144 .iter()
1145 .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
1146 .map(|q| {
1147 let mut info = form_question_to_info(q, Some(&i18n));
1148 info.create_url = help_urls_by_provider
1149 .get(&pfs.provider_id)
1150 .and_then(|m| m.get(&q.id))
1151 .cloned();
1152 if let Some(ext) = extras.and_then(|m| m.get(&q.id)) {
1153 if info.placeholder.is_none() {
1154 info.placeholder = ext.placeholder.clone();
1155 }
1156 info.group = ext.group.clone();
1157 info.docs_url = ext.docs_url.clone();
1158 if ext.create_url.is_some() {
1161 info.create_url = ext.create_url.clone();
1162 }
1163 if let Some(ref mut cols) = info.list_columns {
1168 for col in cols.iter_mut() {
1169 if ext
1170 .column_multilingual
1171 .get(&col.id)
1172 .copied()
1173 .unwrap_or(false)
1174 {
1175 col.multilingual = true;
1176 }
1177 }
1178 }
1179 }
1180 if let Some(val) = answers
1182 .and_then(|m| m.get(&q.id))
1183 .and_then(value_as_nonempty_string)
1184 {
1185 if !info.secret {
1186 info.saved_value = Some(val);
1187 }
1188 } else if let Some(val) = saved.and_then(|m| m.get(&q.id))
1189 && !info.secret
1190 {
1191 info.saved_value = Some(val.clone());
1192 }
1193 if matches!(q.kind, qa_spec::QuestionType::List) {
1199 if let Some(arr) = answers
1200 .and_then(|m| m.get(&q.id))
1201 .and_then(Value::as_array)
1202 .filter(|a| !a.is_empty())
1203 {
1204 info.saved_rows = Some(arr.clone());
1205 eprintln!(
1206 "[hydrate] {} {} → saved_rows from prefill: {} row(s)",
1207 pfs.provider_id,
1208 q.id,
1209 arr.len()
1210 );
1211 } else if q.id == "nav_links"
1212 && pfs.provider_id.contains("webchat-gui")
1213 {
1214 match crate::tenant_config::read_existing_nav_links(
1215 &state.bundle_path,
1216 &state.tenant,
1217 ) {
1218 Some(rows) => {
1219 eprintln!(
1220 "[hydrate] {} nav_links → saved_rows from tenant.json: {} row(s)",
1221 pfs.provider_id,
1222 rows.len()
1223 );
1224 info.saved_rows = Some(rows);
1225 }
1226 None => {
1227 eprintln!(
1228 "[hydrate] {} nav_links → tenant.json had no nav_links (bundle_path={}, tenant={})",
1229 pfs.provider_id,
1230 state.bundle_path.display(),
1231 state.tenant
1232 );
1233 }
1234 }
1235 }
1236 }
1237 info
1238 })
1239 .collect(),
1240 }
1241 })
1242 .collect();
1243
1244 Json(serde_json::json!({
1245 "bundle_path": bundle_path.display().to_string(),
1246 "providers": providers,
1247 "provider_forms": provider_forms,
1248 "shared_questions": shared_questions,
1249 "i18n": ui_strings,
1250 }))
1251}
1252
1253fn declared_static_routes_by_provider(
1254 setup_targets: &[&discovery::DetectedProvider],
1255) -> std::collections::HashMap<String, Vec<DeclaredStaticRoute>> {
1256 let mut by_provider = std::collections::HashMap::new();
1257 for provider in setup_targets {
1258 let routes = load_declared_static_routes(provider);
1259 if !routes.is_empty() {
1260 by_provider.insert(provider.provider_id.clone(), routes);
1261 }
1262 }
1263 by_provider
1264}
1265
1266fn load_declared_static_routes(provider: &discovery::DetectedProvider) -> Vec<DeclaredStaticRoute> {
1267 let Ok(Some(extension)) =
1268 discovery::read_pack_extension(&provider.pack_path, "greentic.static-routes.v1")
1269 else {
1270 return Vec::new();
1271 };
1272 let Some(inline) = extension_inline(&extension) else {
1273 return Vec::new();
1274 };
1275 inline
1276 .get("routes")
1277 .and_then(Value::as_array)
1278 .into_iter()
1279 .flatten()
1280 .filter_map(|route| {
1281 let public_path = route.get("public_path")?.as_str()?.trim();
1282 let source_root = route.get("source_root")?.as_str()?.trim();
1283 if !is_safe_same_origin_path(public_path) || !is_safe_pack_relative_path(source_root) {
1284 return None;
1285 }
1286 Some(DeclaredStaticRoute {
1287 provider_id: provider.provider_id.clone(),
1288 pack_path: provider.pack_path.clone(),
1289 public_path: public_path.trim_end_matches('/').to_string(),
1290 source_root: source_root.trim_matches('/').to_string(),
1291 })
1292 })
1293 .collect()
1294}
1295
1296fn load_setup_web_component_descriptor(
1297 provider: &discovery::DetectedProvider,
1298 static_routes: Option<&Vec<DeclaredStaticRoute>>,
1299) -> Option<Value> {
1300 let extension =
1301 discovery::read_pack_extension(&provider.pack_path, "greentic.setup.web-component.v1")
1302 .ok()??;
1303 let inline = extension_inline(&extension)?.clone();
1304 if inline
1305 .get("schema_id")
1306 .and_then(Value::as_str)
1307 .is_some_and(|schema| schema != "greentic.setup.web-component.v1")
1308 {
1309 return None;
1310 }
1311 let module_url = inline.get("module_url")?.as_str()?;
1312 if !is_safe_same_origin_path(module_url) {
1313 return None;
1314 }
1315 let routes = static_routes?;
1316 if !routes
1317 .iter()
1318 .any(|route| route_template_covers_url_template(&route.public_path, module_url))
1319 {
1320 return None;
1321 }
1322 Some(inline)
1323}
1324
1325fn load_setup_backend_contract_descriptor(
1326 provider: &discovery::DetectedProvider,
1327) -> Option<ProviderBackendContract> {
1328 let extension =
1329 discovery::read_pack_extension(&provider.pack_path, "greentic.setup.backend-contract.v1")
1330 .ok()??;
1331 let descriptor = extension_inline(&extension)?.clone();
1332 if descriptor
1333 .get("schema_id")
1334 .and_then(Value::as_str)
1335 .is_some_and(|schema| schema != "greentic.setup.backend-contract.v1")
1336 {
1337 return None;
1338 }
1339 let provider_id = descriptor.get("provider_id")?.as_str()?.trim();
1340 if provider_id != provider.provider_id {
1341 return None;
1342 }
1343 let (inline, load_error) = match descriptor.get("asset").and_then(Value::as_str) {
1344 Some(asset) => match load_setup_backend_contract_asset(provider, asset, &descriptor) {
1345 Ok(contract) => (contract, None),
1346 Err(err) => (descriptor, Some(err.to_string())),
1347 },
1348 None => (descriptor, None),
1349 };
1350 Some(ProviderBackendContract {
1351 provider_id: provider.provider_id.clone(),
1352 inline,
1353 load_error,
1354 })
1355}
1356
1357fn load_setup_machine_descriptor(provider: &discovery::DetectedProvider) -> Option<Value> {
1358 let machine = crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)
1359 .ok()
1360 .flatten()?;
1361 Some(serde_json::json!({
1362 "schema_id": crate::setup_machine::SETUP_MACHINE_EXTENSION,
1363 "provider_id": provider.provider_id,
1364 "id": machine.id,
1365 "version": machine.version,
1366 "display_name": machine.display_name,
1367 "entry_step": machine.entry_step,
1368 "steps": machine.steps.iter().map(|step| {
1369 serde_json::json!({
1370 "id": step.id,
1371 "kind": step.kind,
1372 "title": step.title,
1373 })
1374 }).collect::<Vec<_>>(),
1375 }))
1376}
1377
1378fn load_setup_actions_descriptor(provider: &discovery::DetectedProvider) -> Option<Value> {
1379 let extension =
1380 discovery::read_pack_extension(&provider.pack_path, "greentic.setup.actions.v1").ok()?;
1381 if let Some(extension) = extension {
1382 let mut inline = extension_inline(&extension)?.clone();
1383 if inline
1384 .get("schema_id")
1385 .and_then(Value::as_str)
1386 .is_some_and(|schema| schema != "greentic.setup.actions.v1")
1387 {
1388 return None;
1389 }
1390 let provider_id = inline.get("provider_id")?.as_str()?.trim();
1391 if provider_id != provider.provider_id {
1392 return None;
1393 }
1394 if !inline.get("actions").is_some_and(Value::is_array) {
1395 return None;
1396 }
1397 merge_legacy_setup_actions(provider, &mut inline);
1398 return Some(inline);
1399 }
1400 load_legacy_setup_actions_descriptor(provider)
1401}
1402
1403fn merge_legacy_setup_actions(provider: &discovery::DetectedProvider, descriptor: &mut Value) {
1404 let Some(legacy) = load_legacy_setup_actions_descriptor(provider) else {
1405 return;
1406 };
1407 let Some(legacy_actions) = legacy.get("actions").and_then(Value::as_array) else {
1408 return;
1409 };
1410 let Some(actions) = descriptor.get_mut("actions").and_then(Value::as_array_mut) else {
1411 return;
1412 };
1413 let mut seen_ids = actions
1414 .iter()
1415 .filter_map(|action| action.get("id").and_then(Value::as_str))
1416 .map(ToString::to_string)
1417 .collect::<std::collections::BTreeSet<_>>();
1418 for action in legacy_actions {
1419 let id = action.get("id").and_then(Value::as_str).unwrap_or_default();
1420 if id.is_empty() || seen_ids.insert(id.to_string()) {
1421 actions.push(action.clone());
1422 }
1423 }
1424}
1425
1426fn load_legacy_setup_actions_descriptor(provider: &discovery::DetectedProvider) -> Option<Value> {
1427 let spec = crate::setup_input::load_setup_spec(&provider.pack_path)
1428 .ok()
1429 .flatten()?;
1430 if spec.setup_actions.is_empty() {
1431 return None;
1432 }
1433 let mut actions = spec.setup_actions;
1434 normalize_setup_action_provider_ids(&mut actions, &provider.provider_id);
1435 Some(serde_json::json!({
1436 "schema_id": "greentic.setup.actions.v1",
1437 "provider_id": provider.provider_id,
1438 "source": "legacy_setup_yaml",
1439 "actions": actions,
1440 }))
1441}
1442
1443fn normalize_setup_action_provider_ids(actions: &mut [Value], provider_id: &str) {
1444 for action in actions {
1445 let Some(map) = action.as_object_mut() else {
1446 continue;
1447 };
1448 map.insert(
1449 "provider_id".to_string(),
1450 Value::String(provider_id.to_string()),
1451 );
1452 }
1453}
1454
1455fn load_setup_backend_contract_asset(
1456 provider: &discovery::DetectedProvider,
1457 asset: &str,
1458 descriptor: &Value,
1459) -> Result<Value> {
1460 if !is_safe_pack_relative_path(asset) {
1461 anyhow::bail!("setup backend contract asset path is not safe: {asset}");
1462 }
1463 let mut contract = read_pack_json_asset(&provider.pack_path, asset)
1464 .with_context(|| format!("failed to load setup backend contract asset {asset}"))?;
1465 if contract
1466 .get("schema_id")
1467 .and_then(Value::as_str)
1468 .is_some_and(|schema| schema != "greentic.setup.backend-contract.v1")
1469 {
1470 anyhow::bail!("setup backend contract asset has wrong schema_id");
1471 }
1472 let contract_provider_id = contract
1473 .get("provider_id")
1474 .and_then(Value::as_str)
1475 .unwrap_or_default();
1476 if contract_provider_id != provider.provider_id {
1477 anyhow::bail!("setup backend contract asset provider_id does not match pack provider");
1478 }
1479 if let Some(map) = contract.as_object_mut() {
1480 map.insert(
1481 "descriptor".to_string(),
1482 serde_json::json!({
1483 "schema_id": descriptor.get("schema_id").cloned().unwrap_or(Value::Null),
1484 "provider_id": descriptor.get("provider_id").cloned().unwrap_or(Value::Null),
1485 "asset": descriptor.get("asset").cloned().unwrap_or(Value::Null),
1486 }),
1487 );
1488 }
1489 Ok(contract)
1490}
1491
1492fn read_pack_json_asset(pack_path: &Path, entry_name: &str) -> Result<Value> {
1493 let file = std::fs::File::open(pack_path)
1494 .with_context(|| format!("open provider pack {}", pack_path.display()))?;
1495 let mut archive = zip::ZipArchive::new(file).context("provider pack is not a zip archive")?;
1496 let mut entry = archive
1497 .by_name(entry_name)
1498 .with_context(|| format!("provider pack missing {entry_name}"))?;
1499 let mut text = String::new();
1500 std::io::Read::read_to_string(&mut entry, &mut text)?;
1501 serde_json::from_str(&text).with_context(|| format!("parse provider pack asset {entry_name}"))
1502}
1503
1504fn find_setup_backend_contract(
1505 bundle_path: &Path,
1506 provider_id: &str,
1507) -> Result<Option<ProviderBackendContract>> {
1508 let discovered = discovery::discover(bundle_path)?;
1509 let Some(provider) = discovered.find_setup_target(provider_id) else {
1510 return Ok(None);
1511 };
1512 Ok(load_setup_backend_contract_descriptor(provider))
1513}
1514
1515fn find_setup_actions_descriptor(bundle_path: &Path, provider_id: &str) -> Result<Option<Value>> {
1516 let discovered = discovery::discover(bundle_path)?;
1517 let Some(provider) = discovered.find_setup_target(provider_id) else {
1518 return Ok(None);
1519 };
1520 Ok(load_setup_actions_descriptor(provider))
1521}
1522
1523fn extension_inline(extension: &Value) -> Option<&Value> {
1524 extension.get("inline").or(Some(extension))
1525}
1526
1527fn is_safe_same_origin_path(path: &str) -> bool {
1528 path.starts_with('/')
1529 && !path.starts_with("//")
1530 && !path.contains('\\')
1531 && Url::parse(path).is_err()
1532}
1533
1534fn is_safe_pack_relative_path(path: &str) -> bool {
1535 let path = path.trim_matches('/');
1536 !path.is_empty()
1537 && !path.contains('\\')
1538 && path
1539 .split('/')
1540 .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
1541}
1542
1543fn route_template_covers_url_template(public_path: &str, module_url: &str) -> bool {
1544 let public_path = public_path.trim_end_matches('/');
1545 module_url == public_path || module_url.starts_with(&format!("{public_path}/"))
1546}
1547
1548async fn get_declared_static_asset(
1549 State(state): State<std::sync::Arc<UiState>>,
1550 AxumPath(asset_path): AxumPath<String>,
1551) -> Response {
1552 let request_path = format!("/v1/web/{asset_path}");
1553 let Ok(discovered) = discovery::discover(&state.bundle_path) else {
1554 return status_text(StatusCode::NOT_FOUND, "no bundle packs discovered");
1555 };
1556 let targets = discovered.setup_targets();
1557 for route in declared_static_routes_by_provider(&targets)
1558 .into_values()
1559 .flatten()
1560 {
1561 if let Some(relative_asset) = match_declared_static_route(&route.public_path, &request_path)
1562 {
1563 return serve_pack_asset(&route, &relative_asset);
1564 }
1565 }
1566 status_text(
1567 StatusCode::NOT_FOUND,
1568 "asset is not covered by a declared static route",
1569 )
1570}
1571
1572fn match_declared_static_route(public_path_template: &str, request_path: &str) -> Option<String> {
1573 let route_segments: Vec<&str> = public_path_template
1574 .trim_matches('/')
1575 .split('/')
1576 .filter(|segment| !segment.is_empty())
1577 .collect();
1578 let request_segments: Vec<&str> = request_path
1579 .trim_matches('/')
1580 .split('/')
1581 .filter(|segment| !segment.is_empty())
1582 .collect();
1583 if request_segments.len() < route_segments.len() {
1584 return None;
1585 }
1586 for (route_segment, request_segment) in route_segments.iter().zip(request_segments.iter()) {
1587 let is_placeholder = route_segment.starts_with('{') && route_segment.ends_with('}');
1588 if !is_placeholder && route_segment != request_segment {
1589 return None;
1590 }
1591 if is_placeholder && request_segment.is_empty() {
1592 return None;
1593 }
1594 }
1595 let relative = request_segments[route_segments.len()..].join("/");
1596 if relative.is_empty() || !is_safe_pack_relative_path(&relative) {
1597 return None;
1598 }
1599 Some(relative)
1600}
1601
1602fn serve_pack_asset(route: &DeclaredStaticRoute, relative_asset: &str) -> Response {
1603 let entry_name = format!(
1604 "{}/{}",
1605 route.source_root.trim_matches('/'),
1606 relative_asset.trim_matches('/')
1607 );
1608 let file = match std::fs::File::open(&route.pack_path) {
1609 Ok(file) => file,
1610 Err(err) => {
1611 return status_text(
1612 StatusCode::NOT_FOUND,
1613 &format!("provider pack not readable: {err}"),
1614 );
1615 }
1616 };
1617 let mut archive = match zip::ZipArchive::new(file) {
1618 Ok(archive) => archive,
1619 Err(err) => {
1620 return status_text(
1621 StatusCode::NOT_FOUND,
1622 &format!("provider pack is not a zip archive: {err}"),
1623 );
1624 }
1625 };
1626 let mut entry = match archive.by_name(&entry_name) {
1627 Ok(entry) => entry,
1628 Err(_) => return status_text(StatusCode::NOT_FOUND, "declared asset not found in pack"),
1629 };
1630 let mut bytes = Vec::new();
1631 if let Err(err) = std::io::Read::read_to_end(&mut entry, &mut bytes) {
1632 return status_text(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string());
1633 }
1634 let bytes = maybe_patch_setup_web_component_asset(&entry_name, bytes);
1635 let mut response = Body::from(bytes).into_response();
1636 response.headers_mut().insert(
1637 header::CONTENT_TYPE,
1638 HeaderValue::from_static(content_type_for_path(&entry_name)),
1639 );
1640 response.headers_mut().insert(
1641 "x-greentic-provider",
1642 HeaderValue::from_str(&route.provider_id)
1643 .unwrap_or_else(|_| HeaderValue::from_static("unknown")),
1644 );
1645 response
1646}
1647
1648fn maybe_patch_setup_web_component_asset(entry_name: &str, bytes: Vec<u8>) -> Vec<u8> {
1649 if !entry_name.ends_with(".js") || !entry_name.contains("/setup/") {
1650 return bytes;
1651 }
1652 let text = match String::from_utf8(bytes) {
1653 Ok(text) => text,
1654 Err(err) => return err.into_bytes(),
1655 };
1656 let patched = patch_setup_web_component_reset_guard(text);
1657 patched.into_bytes()
1658}
1659
1660fn patch_setup_web_component_reset_guard(text: String) -> String {
1661 let mut text = text;
1662 let marker = "if (!result || typeof result !== \"object\") return \"\";";
1663 if !text.contains("pending_device_login") && text.contains(marker) {
1664 text = text.replacen(
1665 marker,
1666 "if (!result || typeof result !== \"object\") return \"\";\n if ((result.result && result.result.pending_device_login) || result.pending_device_login) return \"\";",
1667 1,
1668 );
1669 }
1670 let marker = "if (status.blocked) {\n return {\n kind: \"blocked-refresh\",\n label: this._t(\"refreshAfterManualAction\")\n };\n }";
1671 if !text.contains("status.blocked.retryable") && text.contains(marker) {
1672 text = text.replacen(
1673 marker,
1674 "if (status.blocked && !status.blocked.retryable) {\n return {\n kind: \"blocked-refresh\",\n label: this._t(\"refreshAfterManualAction\")\n };\n }",
1675 1,
1676 );
1677 }
1678 let marker = r#"if (publish.ok && !install.ok && addToTeamsUrl) {
1679 if (this._manualActions.addToTeamsOpened) {
1680 return {
1681 kind: "continue",
1682 label: this._t("verifyTeamsInstall")
1683 };
1684 }
1685 return {
1686 kind: "add-to-teams",
1687 label: this._t("addToTeams"),
1688 url: addToTeamsUrl
1689 };
1690 }"#;
1691 if !text.contains("greentic-setup always verifies Teams install after publish")
1692 && text.contains(marker)
1693 {
1694 text = text.replacen(
1695 marker,
1696 r#"if (publish.ok && !install.ok && addToTeamsUrl) {
1697 // greentic-setup always offers install verification after publish; relying
1698 // on sessionStorage to switch from Add to Teams can loop after browser remounts.
1699 return {
1700 kind: "continue",
1701 label: this._t("verifyTeamsInstall"),
1702 stepId: "teams_app_user_install",
1703 addToTeamsUrl
1704 };
1705 }"#,
1706 1,
1707 );
1708 }
1709 let marker = r#"const result = await this._request("POST", this._endpoint("next"), this._collectConfig());"#;
1710 if !text.contains("greentic-setup can run a targeted setup action") && text.contains(marker) {
1711 text = text.replacen(
1712 marker,
1713 r#"// greentic-setup can run a targeted setup action for manual verification
1714 // buttons, instead of republishing whatever the scheduler considers next.
1715 const path = waitAction.stepId
1716 ? this._endpoint("next").replace(/\/next$/, `/action/${encodeURIComponent(waitAction.stepId)}`)
1717 : this._endpoint("next");
1718 const result = await this._request("POST", path, this._collectConfig());"#,
1719 1,
1720 );
1721 }
1722 let marker = r#" _actionHtml(action) {
1723 return `<button type="button" class="primary" data-action="run-current">${this._escape(action.label || this._t("continue"))}</button>`;
1724 }"#;
1725 if !text.contains("greentic-setup exposes Add to Teams next to Verify") && text.contains(marker)
1726 {
1727 text = text.replacen(
1728 marker,
1729 r#" _actionHtml(action) {
1730 const primary = `<button type="button" class="primary" data-action="run-current">${this._escape(action.label || this._t("continue"))}</button>`;
1731 if (action && action.addToTeamsUrl) {
1732 // greentic-setup exposes Add to Teams next to Verify without making
1733 // verification depend on sessionStorage surviving the Teams deep link.
1734 return `${primary}<a class="button" target="_blank" rel="noopener noreferrer" href="${this._escape(action.addToTeamsUrl)}">${this._escape(this._t("addToTeams"))}</a>`;
1735 }
1736 return primary;
1737 }"#,
1738 1,
1739 );
1740 }
1741 let marker = r#" _oauthComplete(kind) {
1742 const values = this._state && this._state.values || {};
1743 const oauth = values.oauth || {};
1744 return Boolean(oauth[kind || "default"] && oauth[kind || "default"].ok);
1745 }"#;
1746 if !text.contains("greentic-setup oauth_resume keeps refreshed OAuth incomplete")
1747 && text.contains(marker)
1748 {
1749 text = text.replacen(
1750 marker,
1751 r#" _oauthComplete(kind) {
1752 const values = this._state && this._state.values || {};
1753 const resume = values.oauth_resume || {};
1754 const normalized = kind || "default";
1755 const tokenKey = resume.token_store_key || "";
1756 if (
1757 // greentic-setup oauth_resume keeps refreshed OAuth incomplete until the
1758 // new device-code token exchange succeeds.
1759 (normalized === "management" && tokenKey === "azure_management_access_token") ||
1760 (normalized === "graph" && tokenKey === "graph_access_token")
1761 ) {
1762 return false;
1763 }
1764 const oauth = values.oauth || {};
1765 return Boolean(oauth[normalized] && oauth[normalized].ok);
1766 }"#,
1767 1,
1768 );
1769 }
1770 let marker = r#" const response = values.last_oauth && values.last_oauth.response || {};
1771 const oauthKind = this._oauthKind();
1772 const codeKey = oauthKind === "management" ? "azure_management_user_code" : "oauth_user_code";
1773 const userCode = cfg[codeKey] || response.user_code || response.userCode;"#;
1774 if !text.contains("greentic-setup prefers newest OAuth response code") && text.contains(marker)
1775 {
1776 text = text.replacen(
1777 marker,
1778 r#" const response = values.last_oauth && values.last_oauth.response || {};
1779 const oauthKind = this._oauthKind();
1780 const codeKey = oauthKind === "management" ? "azure_management_user_code" : "oauth_user_code";
1781 // greentic-setup prefers newest OAuth response code over stale persisted config.
1782 const userCode = response.user_code || response.userCode || cfg[codeKey];"#,
1783 1,
1784 );
1785 }
1786 let marker =
1787 r#"const firstMessage = values.last_activity || values.last_webchat_conversation;"#;
1788 if !text.contains("greentic-setup ignores stale runtime observations") && text.contains(marker)
1789 {
1790 text = text.replacen(
1791 marker,
1792 r#"// greentic-setup ignores stale runtime observations when deciding
1793 // whether the first Teams message has arrived for the current tunnel.
1794 const firstMessage = [values.last_activity, values.last_webchat_conversation].find((value) => value && !value.stale);"#,
1795 1,
1796 );
1797 }
1798 let marker = r#"if (install.ok && !firstMessage && openBotChatUrl) {"#;
1799 if !text.contains("greentic-setup waits for endpoint registration before bot chat")
1800 && text.contains(marker)
1801 {
1802 text = text.replacen(
1803 marker,
1804 r#"const pendingStep = this._currentPendingStepId && this._currentPendingStepId();
1805 if (install.ok && openBotChatUrl && pendingStep && pendingStep !== "first_bot_framework_post") {
1806 // greentic-setup waits for the Bot Framework endpoint registration to be
1807 // current before asking Teams to send the first message.
1808 return {
1809 kind: "continue",
1810 label: this._t("continue") || "Continue setup"
1811 };
1812 }
1813
1814 if (install.ok && !firstMessage && openBotChatUrl) {"#,
1815 1,
1816 );
1817 }
1818 let marker = r#" kind: "open-chat","#;
1819 if !text.contains("greentic-setup waits for endpoint registration before bot chat")
1820 && text.contains(marker)
1821 {
1822 text = text.replacen(
1823 marker,
1824 r#" ...(() => {
1825 const pendingStep = this._currentPendingStepId && this._currentPendingStepId();
1826 if (pendingStep && pendingStep !== "first_bot_framework_post") {
1827 // greentic-setup waits for the Bot Framework endpoint registration to be
1828 // current before asking Teams to send the first message.
1829 return {
1830 kind: "continue",
1831 label: this._t("continue") || "Continue setup"
1832 };
1833 }
1834 return { kind: "open-chat" };
1835 })(),"#,
1836 1,
1837 );
1838 }
1839 let marker = r#"&& !(values.last_activity || values.last_webchat_conversation)"#;
1840 if text.contains("greentic-setup ignores stale runtime observations") && text.contains(marker) {
1841 text = text.replacen(
1842 marker,
1843 r#"&& ![values.last_activity, values.last_webchat_conversation].some((value) => value && !value.stale)"#,
1844 1,
1845 );
1846 }
1847 let marker =
1848 r#"firstMessage: Boolean(values.last_activity || values.last_webchat_conversation)"#;
1849 if text.contains("greentic-setup ignores stale runtime observations") && text.contains(marker) {
1850 text = text.replacen(
1851 marker,
1852 r#"firstMessage: [values.last_activity, values.last_webchat_conversation].some((value) => value && !value.stale)"#,
1853 1,
1854 );
1855 }
1856 let marker = r#" if (!complete) {
1857 return {
1858 kind: "continue",
1859 label: this._t("continue") || "Continue setup"
1860 };
1861 }"#;
1862 if !text.contains("greentic-setup has no next action after observed completion")
1863 && text.contains(marker)
1864 {
1865 text = text.replacen(
1866 marker,
1867 r#" if (complete && firstMessage) {
1868 // greentic-setup has no next action after observed completion.
1869 return null;
1870 }
1871
1872 if (!complete) {
1873 return {
1874 kind: "continue",
1875 label: this._t("continue") || "Continue setup"
1876 };
1877 }"#,
1878 1,
1879 );
1880 }
1881 let marker = r#" if (pending === "first_bot_framework_post") {
1882 return await this._runtimeIngressPreflight();
1883 }"#;
1884 if !text.contains("greentic-setup lets backend verify runtime observation")
1885 && text.contains(marker)
1886 {
1887 text = text.replacen(
1888 marker,
1889 r#" if (pending === "first_bot_framework_post") {
1890 // greentic-setup lets the backend/runtime observation path verify Teams
1891 // activity; browser GET probes against Bot Framework ingress can fail.
1892 return "";
1893 }"#,
1894 1,
1895 );
1896 }
1897 text
1898}
1899
1900async fn proxy_provider_setup_api(
1901 State(state): State<std::sync::Arc<UiState>>,
1902 AxumPath(proxy_path): AxumPath<String>,
1903 headers: HeaderMap,
1904 request: Request,
1905) -> Response {
1906 let method = request.method().clone();
1907 let query = request
1908 .uri()
1909 .query()
1910 .map(|query| format!("?{query}"))
1911 .unwrap_or_default();
1912 let body = match to_bytes(request.into_body(), 10 * 1024 * 1024).await {
1913 Ok(body) => body,
1914 Err(err) => return status_text(StatusCode::BAD_REQUEST, &err.to_string()),
1915 };
1916
1917 match handle_provider_setup_backend_contract(
1918 &state,
1919 method.clone(),
1920 &proxy_path,
1921 &query,
1922 body.clone(),
1923 )
1924 .await
1925 {
1926 Ok(Some(response)) => return response,
1927 Ok(None) => {}
1928 Err(err) => {
1929 return (
1930 StatusCode::BAD_REQUEST,
1931 Json(serde_json::json!({
1932 "ok": false,
1933 "error": err.to_string(),
1934 })),
1935 )
1936 .into_response();
1937 }
1938 }
1939
1940 let declared_path = format!("/v1/messaging/setup/{}", proxy_path.trim_start_matches('/'));
1941 match dispatch_declared_provider_http_route(
1942 &state,
1943 method.as_str(),
1944 &declared_path,
1945 &query,
1946 &headers,
1947 body.clone(),
1948 )
1949 .await
1950 {
1951 Ok(Some(response)) => return response,
1952 Ok(None) => {}
1953 Err(err) => {
1954 return (
1955 StatusCode::BAD_GATEWAY,
1956 Json(serde_json::json!({
1957 "ok": false,
1958 "blocked": true,
1959 "error": err.to_string(),
1960 })),
1961 )
1962 .into_response();
1963 }
1964 }
1965
1966 let Some(runtime_base) = configured_runtime_proxy_base_url() else {
1967 return (
1968 StatusCode::NOT_FOUND,
1969 Json(serde_json::json!({
1970 "ok": false,
1971 "blocked": true,
1972 "error": "provider setup route is not declared by pack",
1973 "expected": format!("/v1/messaging/setup/{proxy_path}"),
1974 "configure": "Declare the route in greentic.http-routes.v1 or use a backend-contract route."
1975 })),
1976 )
1977 .into_response();
1978 };
1979 let target = format!(
1980 "{}/v1/messaging/setup/{}{}",
1981 runtime_base.trim_end_matches('/'),
1982 proxy_path,
1983 query
1984 );
1985 match forward_runtime_request(method.clone(), &target, headers.clone(), body.clone()).await {
1986 Ok(response) if response.status() == StatusCode::NOT_FOUND => {
1987 let Some(fallback_target) =
1988 setup_runtime_fallback_target(&runtime_base, &proxy_path, &query, &method)
1989 else {
1990 return response;
1991 };
1992 match forward_runtime_request(method, &fallback_target, headers, body).await {
1993 Ok(fallback_response) => fallback_response,
1994 Err(err) => (
1995 StatusCode::SERVICE_UNAVAILABLE,
1996 Json(serde_json::json!({
1997 "ok": false,
1998 "blocked": true,
1999 "error": "Provider setup service is not running",
2000 "target": fallback_target,
2001 "detail": err.to_string(),
2002 "configure": "Start the provider setup runtime, then set GREENTIC_SETUP_RUNTIME_URL to its local base URL."
2003 })),
2004 )
2005 .into_response(),
2006 }
2007 }
2008 Ok(response) => response,
2009 Err(err) => (
2010 StatusCode::SERVICE_UNAVAILABLE,
2011 Json(serde_json::json!({
2012 "ok": false,
2013 "blocked": true,
2014 "error": "Provider setup service is not running",
2015 "target": target,
2016 "detail": err.to_string(),
2017 "configure": "Start the provider setup runtime, then set GREENTIC_SETUP_RUNTIME_URL to its local base URL."
2018 })),
2019 )
2020 .into_response(),
2021 }
2022}
2023
2024async fn proxy_declared_provider_http_route(
2025 State(state): State<std::sync::Arc<UiState>>,
2026 AxumPath(proxy_path): AxumPath<String>,
2027 headers: HeaderMap,
2028 request: Request,
2029) -> Response {
2030 let method = request.method().clone();
2031 let query = request
2032 .uri()
2033 .query()
2034 .map(|query| format!("?{query}"))
2035 .unwrap_or_default();
2036 let path = format!("/v1/setup/{}", proxy_path.trim_start_matches('/'));
2037 let body = match to_bytes(request.into_body(), 10 * 1024 * 1024).await {
2038 Ok(body) => body,
2039 Err(err) => return status_text(StatusCode::BAD_REQUEST, &err.to_string()),
2040 };
2041 match dispatch_declared_provider_http_route(
2042 &state,
2043 method.as_str(),
2044 &path,
2045 &query,
2046 &headers,
2047 body,
2048 )
2049 .await
2050 {
2051 Ok(Some(response)) => response,
2052 Ok(None) => status_text(StatusCode::NOT_FOUND, "provider route not declared by pack"),
2053 Err(err) => (
2054 StatusCode::BAD_GATEWAY,
2055 Json(serde_json::json!({
2056 "ok": false,
2057 "blocked": true,
2058 "error": err.to_string(),
2059 })),
2060 )
2061 .into_response(),
2062 }
2063}
2064
2065fn setup_runtime_fallback_target(
2066 runtime_base: &str,
2067 proxy_path: &str,
2068 query: &str,
2069 method: &axum::http::Method,
2070) -> Option<String> {
2071 let segments: Vec<&str> = proxy_path
2072 .trim_matches('/')
2073 .split('/')
2074 .filter(|segment| !segment.is_empty())
2075 .collect();
2076 if segments.len() < 2 {
2077 return None;
2078 }
2079 let suffix = &segments[2..];
2080 let fallback_path = match (method.as_str(), suffix) {
2081 ("GET", []) => "/api/state".to_string(),
2082 ("POST", ["next"]) => "/api/setup/next".to_string(),
2083 ("POST", ["config"]) => "/api/config".to_string(),
2084 ("POST", ["oauth", kind, "start"]) if is_safe_runtime_path_segment(kind) => {
2085 format!("/api/oauth/{kind}/start")
2086 }
2087 ("POST", ["oauth", kind, "complete"]) if is_safe_runtime_path_segment(kind) => {
2088 format!("/api/oauth/{kind}/complete")
2089 }
2090 _ => return None,
2091 };
2092 Some(format!(
2093 "{}{}{}",
2094 runtime_base.trim_end_matches('/'),
2095 fallback_path,
2096 query
2097 ))
2098}
2099
2100async fn handle_provider_setup_backend_contract(
2101 state: &UiState,
2102 method: axum::http::Method,
2103 proxy_path: &str,
2104 _query: &str,
2105 body: Bytes,
2106) -> Result<Option<Response>> {
2107 let segments: Vec<&str> = proxy_path
2108 .trim_matches('/')
2109 .split('/')
2110 .filter(|segment| !segment.is_empty())
2111 .collect();
2112 if segments.len() < 2 {
2113 return Ok(None);
2114 }
2115 let provider_id = segments[0];
2116 let tenant = segments[1];
2117 let suffix = &segments[2..];
2118 if let Some(response) = handle_provider_setup_machine(
2119 state,
2120 method.as_str(),
2121 provider_id,
2122 tenant,
2123 suffix,
2124 body.clone(),
2125 )? {
2126 return Ok(Some(response));
2127 }
2128 let Some(contract) = find_setup_backend_contract(&state.bundle_path, provider_id)? else {
2129 return Ok(None);
2130 };
2131
2132 let response = match (method.as_str(), suffix) {
2133 ("GET", []) => {
2134 Json(setup_backend_contract_state(state, &contract, tenant)?).into_response()
2135 }
2136 ("POST", ["config"]) => {
2137 let body = parse_json_body(body)?;
2138 Json(setup_backend_contract_save_config(
2139 state, &contract, tenant, &body,
2140 )?)
2141 .into_response()
2142 }
2143 ("POST", ["next"]) => {
2144 let body = parse_json_body(body)?;
2145 Json(
2146 setup_backend_contract_next(
2147 state,
2148 &contract,
2149 tenant,
2150 &format!("/v1/messaging/setup/{provider_id}/{tenant}/next"),
2151 &body,
2152 )
2153 .await?,
2154 )
2155 .into_response()
2156 }
2157 ("POST", ["action", step]) if is_safe_runtime_path_segment(step) => {
2158 let body = parse_json_body(body)?;
2159 Json(
2160 setup_backend_contract_action(
2161 state,
2162 &contract,
2163 tenant,
2164 step,
2165 &format!("/v1/messaging/setup/{provider_id}/{tenant}/action/{step}"),
2166 &body,
2167 )
2168 .await?,
2169 )
2170 .into_response()
2171 }
2172 ("POST", ["oauth", kind, "start"]) => {
2173 let body = parse_json_body(body)?;
2174 Json(setup_backend_contract_oauth_start(state, &contract, tenant, kind, &body).await?)
2175 .into_response()
2176 }
2177 ("POST", ["oauth", kind, "complete"]) => {
2178 Json(setup_backend_contract_oauth_complete(state, &contract, tenant, kind).await?)
2179 .into_response()
2180 }
2181 ("GET", _) => contract_unsupported_response(
2182 provider_id,
2183 "This setup backend contract declares an asset route, but the contract does not provide a generic asset mapping for greentic-setup to serve.",
2184 ),
2185 _ => status_text(
2186 StatusCode::NOT_FOUND,
2187 "setup backend route not declared by contract",
2188 ),
2189 };
2190 Ok(Some(response))
2191}
2192
2193fn parse_json_body(body: Bytes) -> Result<Value> {
2194 if body.is_empty() {
2195 return Ok(Value::Object(JsonMap::new()));
2196 }
2197 serde_json::from_slice(&body).context("invalid JSON request body")
2198}
2199
2200fn handle_provider_setup_machine(
2201 state: &UiState,
2202 method: &str,
2203 provider_id: &str,
2204 tenant: &str,
2205 suffix: &[&str],
2206 body: Bytes,
2207) -> Result<Option<Response>> {
2208 let discovered = discovery::discover(&state.bundle_path)?;
2209 let Some(provider) = discovered.find_setup_target(provider_id) else {
2210 return Ok(None);
2211 };
2212 let Some(machine) = crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)?
2213 else {
2214 return Ok(None);
2215 };
2216 let team = state.team.as_deref().unwrap_or("default");
2217 let response = match (method, suffix) {
2218 ("GET", []) => Json(setup_machine_ui_state(
2219 state,
2220 &provider.provider_id,
2221 tenant,
2222 team,
2223 &machine,
2224 )?)
2225 .into_response(),
2226 ("POST", ["next"]) => {
2227 let _ = parse_json_body(body)?;
2228 let output = crate::setup_machine::advance_setup_machine_with_pack(
2229 &state.bundle_path,
2230 Some(&provider.pack_path),
2231 &provider.provider_id,
2232 tenant,
2233 team,
2234 &machine,
2235 false,
2236 )?;
2237 Json(setup_machine_ui_report(
2238 state,
2239 &provider.provider_id,
2240 tenant,
2241 team,
2242 &machine,
2243 Some(output),
2244 )?)
2245 .into_response()
2246 }
2247 ("POST", ["retry"]) => {
2248 let body = parse_json_body(body)?;
2249 let step = body
2250 .get("step")
2251 .or_else(|| body.get("step_id"))
2252 .and_then(Value::as_str);
2253 let output = crate::setup_machine::retry_setup_machine_step(
2254 &state.bundle_path,
2255 &provider.provider_id,
2256 tenant,
2257 team,
2258 &machine,
2259 step,
2260 )?;
2261 Json(setup_machine_ui_report(
2262 state,
2263 &provider.provider_id,
2264 tenant,
2265 team,
2266 &machine,
2267 Some(output),
2268 )?)
2269 .into_response()
2270 }
2271 ("POST", ["reset"]) => {
2272 let _ = parse_json_body(body)?;
2273 let archive_path = crate::setup_machine::reset_setup_machine_state(
2274 &state.bundle_path,
2275 tenant,
2276 team,
2277 &provider.provider_id,
2278 "ui reset",
2279 )?;
2280 let mut report =
2281 setup_machine_ui_state(state, &provider.provider_id, tenant, team, &machine)?;
2282 if let Some(obj) = report.as_object_mut() {
2283 obj.insert(
2284 "reset".to_string(),
2285 serde_json::json!({
2286 "archive_path": archive_path.map(|path| path.display().to_string()),
2287 }),
2288 );
2289 }
2290 Json(report).into_response()
2291 }
2292 ("POST", ["config"]) => Json(setup_machine_ui_state(
2293 state,
2294 &provider.provider_id,
2295 tenant,
2296 team,
2297 &machine,
2298 )?)
2299 .into_response(),
2300 ("GET", _) => status_text(StatusCode::NOT_FOUND, "setup-machine route not declared"),
2301 _ => status_text(StatusCode::NOT_FOUND, "setup-machine route not declared"),
2302 };
2303 Ok(Some(response))
2304}
2305
2306fn contract_unsupported_response(provider_id: &str, message: &str) -> Response {
2307 (
2308 StatusCode::NOT_IMPLEMENTED,
2309 Json(serde_json::json!({
2310 "ok": false,
2311 "blocked": true,
2312 "provider_id": provider_id,
2313 "error": "setup backend contract unsupported",
2314 "detail": message,
2315 "next": "Use a provider setup runtime via GREENTIC_SETUP_RUNTIME_URL only for development fallback, or update greentic-setup with a backend implementation for this contract."
2316 })),
2317 )
2318 .into_response()
2319}
2320
2321async fn dispatch_declared_provider_http_route(
2322 state: &UiState,
2323 method: &str,
2324 path: &str,
2325 query: &str,
2326 headers: &HeaderMap,
2327 body: Bytes,
2328) -> Result<Option<Response>> {
2329 let Some(route_match) = find_declared_provider_http_route(
2330 &state.bundle_path,
2331 method,
2332 path,
2333 state.tenant.as_str(),
2334 state.team.as_deref().unwrap_or("default"),
2335 )?
2336 else {
2337 return Ok(None);
2338 };
2339 let output = invoke_declared_provider_http_route(
2340 state,
2341 &route_match,
2342 method,
2343 path,
2344 query,
2345 headers,
2346 body,
2347 )
2348 .await?;
2349 Ok(Some(provider_http_output_to_response(output)))
2350}
2351
2352fn provider_http_output_to_response(output: Value) -> Response {
2353 let status = output
2354 .get("response")
2355 .and_then(|response| response.get("status"))
2356 .or_else(|| output.get("status"))
2357 .and_then(Value::as_u64)
2358 .and_then(|status| u16::try_from(status).ok())
2359 .and_then(|status| StatusCode::from_u16(status).ok())
2360 .unwrap_or(StatusCode::OK);
2361 let body = output
2362 .get("response")
2363 .and_then(|response| response.get("body_json"))
2364 .or_else(|| output.get("body_json"))
2365 .or_else(|| output.get("body"))
2366 .cloned()
2367 .unwrap_or(output);
2368 (status, Json(body)).into_response()
2369}
2370
2371async fn invoke_declared_provider_http_route(
2372 state: &UiState,
2373 route_match: &ProviderHttpRouteMatch,
2374 method: &str,
2375 path: &str,
2376 query: &str,
2377 headers: &HeaderMap,
2378 body: Bytes,
2379) -> Result<Value> {
2380 let headers_json = provider_ingress_headers_json(method, path, query, headers)?;
2381 let body_json = if body.is_empty() {
2382 "{}".to_string()
2383 } else {
2384 String::from_utf8(body.to_vec()).context("provider route request body is not utf-8")?
2385 };
2386 let request = serde_json::json!({
2387 "v": 1,
2388 "domain": "messaging",
2389 "provider": route_match.route.provider_id,
2390 "tenant": route_match.tenant,
2391 "team": route_match.team,
2392 "method": method,
2393 "path": path,
2394 "query": parse_query_pairs(query),
2395 "headers": headers_json,
2396 "body_json": body_json,
2397 });
2398 let setup_config = SetupConfig {
2399 tenant: route_match.tenant.clone(),
2400 team: Some(route_match.team.clone()),
2401 env: state.env.clone(),
2402 offline: false,
2403 verbose: state.advanced,
2404 };
2405 let output = match &route_match.route.target {
2406 ProviderHttpRouteTarget::SetupComponent { component_ref, op } => {
2407 invoke_setup_component_operation_blocking(
2408 state.bundle_path.clone(),
2409 route_match.route.pack_path.clone(),
2410 component_ref.clone(),
2411 op.clone(),
2412 request,
2413 setup_config,
2414 )
2415 .await?
2416 }
2417 ProviderHttpRouteTarget::ProviderIngress { component_ref, op } => {
2418 anyhow::bail!(
2419 "provider route '{}' for {} declares provider ingress component '{}' op '{}', but greentic-setup requires setup_component_ref/setup_op for setup-time pack routes",
2420 path,
2421 route_match.route.provider_id,
2422 component_ref,
2423 op
2424 );
2425 }
2426 };
2427 Ok(output)
2428}
2429
2430async fn invoke_setup_component_operation_blocking(
2431 bundle_path: PathBuf,
2432 pack_path: PathBuf,
2433 component_ref: String,
2434 op: String,
2435 request: Value,
2436 setup_config: SetupConfig,
2437) -> Result<Value> {
2438 tokio::task::spawn_blocking(move || {
2439 crate::engine::invoke_setup_component_operation(
2440 &bundle_path,
2441 &pack_path,
2442 &component_ref,
2443 &op,
2444 &request,
2445 &setup_config,
2446 )
2447 })
2448 .await
2449 .context("provider setup route task panicked")?
2450}
2451
2452fn provider_ingress_headers_json(
2453 method: &str,
2454 path: &str,
2455 query: &str,
2456 headers: &HeaderMap,
2457) -> Result<String> {
2458 let mut object = JsonMap::new();
2459 for (name, value) in headers {
2460 if let Ok(value) = value.to_str() {
2461 object.insert(name.as_str().to_string(), Value::String(value.to_string()));
2462 }
2463 }
2464 object.insert("method".to_string(), Value::String(method.to_string()));
2465 object.insert("path".to_string(), Value::String(path.to_string()));
2466 object.insert(
2467 "query".to_string(),
2468 Value::String(query.trim_start_matches('?').to_string()),
2469 );
2470 Ok(serde_json::to_string(&Value::Object(object))?)
2471}
2472
2473fn parse_query_pairs(query: &str) -> Vec<(String, String)> {
2474 query
2475 .trim_start_matches('?')
2476 .split('&')
2477 .filter(|part| !part.is_empty())
2478 .map(|part| {
2479 let (key, value) = part.split_once('=').unwrap_or((part, ""));
2480 (key.to_string(), value.to_string())
2481 })
2482 .collect()
2483}
2484
2485fn find_declared_provider_http_route(
2486 bundle_path: &Path,
2487 method: &str,
2488 path: &str,
2489 default_tenant: &str,
2490 default_team: &str,
2491) -> Result<Option<ProviderHttpRouteMatch>> {
2492 let mut routes = load_declared_provider_http_routes(bundle_path)?;
2493 routes.sort_by(|a, b| {
2494 let a_wild = a
2495 .segments
2496 .iter()
2497 .any(|segment| matches!(segment, ProviderHttpRouteSegment::Wildcard));
2498 let b_wild = b
2499 .segments
2500 .iter()
2501 .any(|segment| matches!(segment, ProviderHttpRouteSegment::Wildcard));
2502 b.segments
2503 .len()
2504 .cmp(&a.segments.len())
2505 .then(a_wild.cmp(&b_wild))
2506 });
2507 let request_segments: Vec<&str> = path
2508 .trim_start_matches('/')
2509 .split('/')
2510 .filter(|segment| !segment.is_empty())
2511 .collect();
2512 for route in routes {
2513 if !route.methods.is_empty()
2514 && !route
2515 .methods
2516 .iter()
2517 .any(|candidate| candidate.eq_ignore_ascii_case(method))
2518 {
2519 continue;
2520 }
2521 if let Some((tenant, team)) =
2522 match_provider_http_route(&route, &request_segments, default_tenant, default_team)
2523 {
2524 return Ok(Some(ProviderHttpRouteMatch {
2525 route,
2526 tenant,
2527 team,
2528 }));
2529 }
2530 }
2531 Ok(None)
2532}
2533
2534fn load_declared_provider_http_routes(
2535 bundle_path: &Path,
2536) -> Result<Vec<DeclaredProviderHttpRoute>> {
2537 let discovered = discovery::discover(bundle_path)?;
2538 let mut routes = Vec::new();
2539 for provider in discovered.providers {
2540 let Some(http_routes_extension) =
2541 discovery::read_pack_extension(&provider.pack_path, "greentic.http-routes.v1")?
2542 else {
2543 continue;
2544 };
2545 let http_routes =
2546 extension_inline(&http_routes_extension).unwrap_or(&http_routes_extension);
2547 let ingress_extension =
2548 discovery::read_pack_extension(&provider.pack_path, "messaging.provider_ingress.v1")?;
2549 let ingress = ingress_extension
2550 .as_ref()
2551 .and_then(|extension| extension_inline(extension).or(Some(extension)));
2552 let Some(records) = http_routes.get("routes").and_then(Value::as_array) else {
2553 continue;
2554 };
2555 for record in records {
2556 let Some(pattern) = record
2557 .get("pattern")
2558 .and_then(Value::as_str)
2559 .map(str::trim)
2560 .filter(|value| !value.is_empty())
2561 else {
2562 continue;
2563 };
2564 let methods = record
2565 .get("methods")
2566 .and_then(Value::as_array)
2567 .into_iter()
2568 .flatten()
2569 .filter_map(Value::as_str)
2570 .map(ToString::to_string)
2571 .collect();
2572 let target = match declared_provider_http_route_target(record, ingress) {
2573 Some(target) => target,
2574 None => continue,
2575 };
2576 routes.push(DeclaredProviderHttpRoute {
2577 provider_id: provider.provider_id.clone(),
2578 pack_path: provider.pack_path.clone(),
2579 methods,
2580 target,
2581 segments: parse_provider_http_route_pattern(pattern),
2582 });
2583 }
2584 }
2585 Ok(routes)
2586}
2587
2588fn declared_provider_http_route_target(
2589 record: &Value,
2590 ingress: Option<&Value>,
2591) -> Option<ProviderHttpRouteTarget> {
2592 let setup_component_ref = record
2593 .get("setup_component_ref")
2594 .or_else(|| record.get("component_ref"))
2595 .and_then(Value::as_str)
2596 .map(str::trim)
2597 .filter(|value| !value.is_empty());
2598 if let Some(component_ref) = setup_component_ref {
2599 let op = record
2600 .get("setup_op")
2601 .or_else(|| record.get("op"))
2602 .or_else(|| record.get("provider_op"))
2603 .and_then(Value::as_str)
2604 .map(str::trim)
2605 .filter(|value| !value.is_empty())
2606 .unwrap_or("handle_http")
2607 .to_string();
2608 return Some(ProviderHttpRouteTarget::SetupComponent {
2609 component_ref: component_ref.to_string(),
2610 op,
2611 });
2612 }
2613
2614 let ingress = ingress?;
2615 let component_ref = ingress
2616 .get("component_ref")
2617 .and_then(Value::as_str)
2618 .map(str::trim)
2619 .filter(|value| !value.is_empty())?;
2620 let op = record
2621 .get("provider_op")
2622 .or_else(|| record.get("op"))
2623 .and_then(Value::as_str)
2624 .map(str::trim)
2625 .filter(|value| !value.is_empty())
2626 .unwrap_or("ingest_http")
2627 .to_string();
2628 Some(ProviderHttpRouteTarget::ProviderIngress {
2629 component_ref: component_ref.to_string(),
2630 op,
2631 })
2632}
2633
2634fn parse_provider_http_route_pattern(pattern: &str) -> Vec<ProviderHttpRouteSegment> {
2635 pattern
2636 .trim_start_matches('/')
2637 .split('/')
2638 .filter(|segment| !segment.is_empty())
2639 .map(|segment| {
2640 if segment == "{tenant}" {
2641 ProviderHttpRouteSegment::Tenant
2642 } else if segment == "{team}" {
2643 ProviderHttpRouteSegment::Team
2644 } else if segment.ends_with("*}") || segment == "*" {
2645 ProviderHttpRouteSegment::Wildcard
2646 } else {
2647 ProviderHttpRouteSegment::Literal(segment.to_string())
2648 }
2649 })
2650 .collect()
2651}
2652
2653fn match_provider_http_route(
2654 route: &DeclaredProviderHttpRoute,
2655 request_segments: &[&str],
2656 default_tenant: &str,
2657 default_team: &str,
2658) -> Option<(String, String)> {
2659 let mut tenant = default_tenant.to_string();
2660 let mut team = default_team.to_string();
2661 let mut request_index = 0;
2662 for segment in &route.segments {
2663 match segment {
2664 ProviderHttpRouteSegment::Literal(expected) => {
2665 if request_segments.get(request_index)? != expected {
2666 return None;
2667 }
2668 request_index += 1;
2669 }
2670 ProviderHttpRouteSegment::Tenant => {
2671 tenant = request_segments.get(request_index)?.to_string();
2672 if tenant.is_empty() {
2673 return None;
2674 }
2675 request_index += 1;
2676 }
2677 ProviderHttpRouteSegment::Team => {
2678 team = request_segments.get(request_index)?.to_string();
2679 if team.is_empty() {
2680 return None;
2681 }
2682 request_index += 1;
2683 }
2684 ProviderHttpRouteSegment::Wildcard => {
2685 return Some((tenant, team));
2686 }
2687 }
2688 }
2689 if request_index == request_segments.len() {
2690 Some((tenant, team))
2691 } else {
2692 None
2693 }
2694}
2695
2696fn setup_backend_contract_state(
2697 state: &UiState,
2698 contract: &ProviderBackendContract,
2699 tenant: &str,
2700) -> Result<Value> {
2701 let mut stored = load_setup_backend_contract_state(state, &contract.provider_id, tenant)?;
2702 setup_backend_refresh_renderable_runtime_observation(state, contract, tenant, &mut stored)?;
2703 save_setup_backend_contract_state(state, &contract.provider_id, tenant, &stored)?;
2704 Ok(render_setup_backend_contract_state(
2705 state, contract, tenant, stored,
2706 ))
2707}
2708
2709fn setup_backend_refresh_renderable_runtime_observation(
2710 state: &UiState,
2711 contract: &ProviderBackendContract,
2712 tenant: &str,
2713 stored: &mut JsonMap<String, Value>,
2714) -> Result<()> {
2715 let Some(action) = setup_backend_action_by_id(contract, "first_bot_framework_post") else {
2716 return Ok(());
2717 };
2718 let Some(executor) = action.get("executor") else {
2719 return Ok(());
2720 };
2721 if executor.get("kind").and_then(Value::as_str) != Some("runtime_observation") {
2722 return Ok(());
2723 }
2724 let config = stored
2725 .get("config")
2726 .and_then(Value::as_object)
2727 .cloned()
2728 .unwrap_or_else(|| default_setup_backend_config(state, tenant));
2729 let runtime_context = setup_backend_runtime_context(state, tenant, &config);
2730 let state_key = executor
2731 .get("state_store_key")
2732 .and_then(Value::as_str)
2733 .unwrap_or("last_activity");
2734 setup_backend_refresh_runtime_observation_from_runtime_logs(
2735 state,
2736 tenant,
2737 executor,
2738 stored,
2739 state_key,
2740 &runtime_context,
2741 )
2742}
2743
2744fn setup_machine_ui_state(
2745 state: &UiState,
2746 provider_id: &str,
2747 tenant: &str,
2748 team: &str,
2749 machine: &crate::setup_machine::SetupMachine,
2750) -> Result<Value> {
2751 setup_machine_ui_report(state, provider_id, tenant, team, machine, None)
2752}
2753
2754fn setup_machine_ui_report(
2755 state: &UiState,
2756 provider_id: &str,
2757 tenant: &str,
2758 team: &str,
2759 machine: &crate::setup_machine::SetupMachine,
2760 result: Option<Value>,
2761) -> Result<Value> {
2762 let machine_state = crate::setup_machine::load_or_init_setup_machine_state(
2763 &state.bundle_path,
2764 provider_id,
2765 tenant,
2766 team,
2767 machine,
2768 )?;
2769 let status = crate::setup_machine::render_setup_machine_status(machine, &machine_state);
2770 let items = status
2771 .get("steps")
2772 .and_then(Value::as_array)
2773 .cloned()
2774 .unwrap_or_default();
2775 let ok = machine_state.status == crate::setup_machine::SetupMachineStatus::Complete;
2776 let blocked = setup_machine_blocked(&machine_state.last_error);
2777 let next = if ok {
2778 "Setup complete.".to_string()
2779 } else if let Some(blocked) = blocked.as_ref() {
2780 blocked
2781 .get("summary")
2782 .and_then(Value::as_str)
2783 .unwrap_or("Setup requires attention.")
2784 .to_string()
2785 } else if let Some(result_next) = result
2786 .as_ref()
2787 .and_then(|result| result.get("result"))
2788 .and_then(|result| result.get("next"))
2789 .and_then(Value::as_str)
2790 .filter(|value| !value.trim().is_empty())
2791 {
2792 result_next.to_string()
2793 } else {
2794 "Run the next setup step.".to_string()
2795 };
2796 let mut report = serde_json::json!({
2797 "ok": true,
2798 "values": status.get("outputs").cloned().unwrap_or_else(|| machine_state.outputs.clone()),
2799 "setup_status": {
2800 "ok": ok,
2801 "items": items,
2802 "selected": {
2803 "provider_id": provider_id,
2804 "tenant": tenant,
2805 "team": team,
2806 "env": state.env,
2807 },
2808 "blocked": blocked,
2809 "last_step": machine_state.current_step.clone().unwrap_or_else(|| "complete".to_string()),
2810 "next": next,
2811 "reset": false,
2812 "machine": status,
2813 "last_result": result,
2814 },
2815 });
2816 attach_final_setup_actions(state, provider_id, &mut report);
2817 Ok(report)
2818}
2819
2820fn setup_machine_blocked(last_error: &Option<Value>) -> Option<Value> {
2821 let error = last_error.as_ref()?;
2822 Some(serde_json::json!({
2823 "title": "Setup step blocked",
2824 "summary": error
2825 .get("detail")
2826 .and_then(Value::as_str)
2827 .or_else(|| error.get("error").and_then(Value::as_str))
2828 .unwrap_or("Setup requires attention before it can continue."),
2829 "retryable": error
2830 .get("recoverable")
2831 .and_then(Value::as_bool)
2832 .unwrap_or(true),
2833 "detail": error,
2834 }))
2835}
2836
2837fn setup_backend_contract_save_config(
2838 state: &UiState,
2839 contract: &ProviderBackendContract,
2840 tenant: &str,
2841 body: &Value,
2842) -> Result<Value> {
2843 let mut stored = load_setup_backend_contract_state(state, &contract.provider_id, tenant)?;
2844 let incoming = body
2845 .get("config")
2846 .or_else(|| body.get("values").and_then(|values| values.get("config")))
2847 .unwrap_or(body);
2848 crate::setup_backend_contract::merge_browser_config_update(
2849 &mut stored,
2850 incoming,
2851 &contract.inline,
2852 default_setup_backend_config(state, tenant),
2853 )?;
2854 save_setup_backend_contract_state(state, &contract.provider_id, tenant, &stored)?;
2855 Ok(render_setup_backend_contract_state(
2856 state, contract, tenant, stored,
2857 ))
2858}
2859
2860async fn setup_backend_contract_next(
2861 state: &UiState,
2862 contract: &ProviderBackendContract,
2863 tenant: &str,
2864 request_path: &str,
2865 body: &Value,
2866) -> Result<Value> {
2867 if contract.load_error.is_some() || setup_backend_required_steps(contract).is_empty() {
2868 return setup_backend_contract_state(state, contract, tenant);
2869 }
2870 let _ = setup_backend_contract_save_config(state, contract, tenant, body)?;
2871 let mut stored = load_setup_backend_contract_state(state, &contract.provider_id, tenant)?;
2872 ensure_setup_backend_config_defaults(state, tenant, &mut stored)?;
2873 let state_before = render_setup_backend_contract_state(state, contract, tenant, stored.clone());
2874 let next_step = setup_backend_first_pending_step(state, contract, tenant, &stored);
2875 let action = setup_backend_action_by_id(contract, &next_step).cloned();
2876 let executor = action
2877 .as_ref()
2878 .and_then(|action| action.get("executor"))
2879 .cloned()
2880 .unwrap_or(Value::Null);
2881 let result = if next_step == "complete" {
2882 serde_json::json!({
2883 "ok": true,
2884 "step": "complete",
2885 "next": "Setup complete.",
2886 "result": { "ok": true }
2887 })
2888 } else {
2889 setup_backend_record_step_attempt(&mut stored, &next_step);
2890 setup_backend_execute_action(state, contract, tenant, &mut stored, &next_step).await?
2891 };
2892 crate::setup_backend_contract::record_action_result(
2893 &state.bundle_path,
2894 tenant,
2895 state.team.as_deref().unwrap_or("default"),
2896 &contract.provider_id,
2897 &mut stored,
2898 result.clone(),
2899 )?;
2900 let state_after = render_setup_backend_contract_state(state, contract, tenant, stored.clone());
2901 let _ = persist_setup_backend_next_diagnostic(
2902 state,
2903 contract,
2904 tenant,
2905 request_path,
2906 body,
2907 &next_step,
2908 action.as_ref(),
2909 &executor,
2910 &state_before,
2911 &state_after,
2912 &result,
2913 );
2914 Ok(render_setup_backend_contract_state(
2915 state, contract, tenant, stored,
2916 ))
2917}
2918
2919fn setup_backend_record_step_attempt(stored: &mut JsonMap<String, Value>, step: &str) -> u64 {
2926 let public_base_url = stored
2927 .get("config")
2928 .and_then(|config| config.get("public_base_url"))
2929 .and_then(Value::as_str)
2930 .unwrap_or_default()
2931 .trim_end_matches('/')
2932 .to_string();
2933 let attempts = stored
2934 .entry("step_attempts".to_string())
2935 .or_insert_with(|| Value::Object(JsonMap::new()));
2936 let Some(map) = attempts.as_object_mut() else {
2937 return 1;
2938 };
2939 let previous = map.get(step);
2940 let same_url = previous
2941 .and_then(|entry| entry.get("public_base_url"))
2942 .and_then(Value::as_str)
2943 == Some(public_base_url.as_str());
2944 let count = if same_url {
2945 previous
2946 .and_then(|entry| entry.get("count"))
2947 .and_then(Value::as_u64)
2948 .unwrap_or(0)
2949 + 1
2950 } else {
2951 1
2952 };
2953 map.insert(
2954 step.to_string(),
2955 serde_json::json!({"count": count, "public_base_url": public_base_url}),
2956 );
2957 if count >= 3 {
2958 eprintln!(
2959 "[setup] step {step} is re-running for the {count}th time with an unchanged \
2960 public_base_url ({public_base_url}) — it keeps being treated as not-done. This \
2961 usually means a staleness/currency check is comparing the wrong tunnel (see \
2962 any [setup runtime-context] mismatch lines above)"
2963 );
2964 } else {
2965 eprintln!(
2966 "[setup] executing step {step} (attempt {count}, public_base_url={public_base_url})"
2967 );
2968 }
2969 count
2970}
2971
2972async fn setup_backend_contract_action(
2973 state: &UiState,
2974 contract: &ProviderBackendContract,
2975 tenant: &str,
2976 step: &str,
2977 request_path: &str,
2978 body: &Value,
2979) -> Result<Value> {
2980 if contract.load_error.is_some() || setup_backend_required_steps(contract).is_empty() {
2981 return setup_backend_contract_state(state, contract, tenant);
2982 }
2983 let _ = setup_backend_contract_save_config(state, contract, tenant, body)?;
2984 let mut stored = load_setup_backend_contract_state(state, &contract.provider_id, tenant)?;
2985 ensure_setup_backend_config_defaults(state, tenant, &mut stored)?;
2986 let state_before = render_setup_backend_contract_state(state, contract, tenant, stored.clone());
2987 let action = setup_backend_action_by_id(contract, step).cloned();
2988 let executor = action
2989 .as_ref()
2990 .and_then(|action| action.get("executor"))
2991 .cloned()
2992 .unwrap_or(Value::Null);
2993 let result = if action.is_some() {
2994 setup_backend_record_step_attempt(&mut stored, step);
2995 setup_backend_execute_action(state, contract, tenant, &mut stored, step).await?
2996 } else {
2997 setup_backend_action_error(
2998 "missing_action",
2999 &format!("backend contract has no action for requested step {step}"),
3000 )
3001 };
3002 crate::setup_backend_contract::record_action_result(
3003 &state.bundle_path,
3004 tenant,
3005 state.team.as_deref().unwrap_or("default"),
3006 &contract.provider_id,
3007 &mut stored,
3008 result.clone(),
3009 )?;
3010 let state_after = render_setup_backend_contract_state(state, contract, tenant, stored.clone());
3011 let _ = persist_setup_backend_next_diagnostic(
3012 state,
3013 contract,
3014 tenant,
3015 request_path,
3016 body,
3017 step,
3018 action.as_ref(),
3019 &executor,
3020 &state_before,
3021 &state_after,
3022 &result,
3023 );
3024 Ok(render_setup_backend_contract_state(
3025 state, contract, tenant, stored,
3026 ))
3027}
3028
3029#[allow(clippy::too_many_arguments)]
3030fn persist_setup_backend_next_diagnostic(
3031 state: &UiState,
3032 contract: &ProviderBackendContract,
3033 tenant: &str,
3034 request_path: &str,
3035 body: &Value,
3036 selected_step: &str,
3037 action: Option<&Value>,
3038 executor: &Value,
3039 state_before: &Value,
3040 state_after: &Value,
3041 result: &Value,
3042) -> Result<Value> {
3043 let executor_kind = executor
3044 .get("kind")
3045 .and_then(Value::as_str)
3046 .unwrap_or_default()
3047 .to_string();
3048 let config = state_after
3049 .get("values")
3050 .and_then(|values| values.get("config"))
3051 .and_then(Value::as_object)
3052 .cloned()
3053 .unwrap_or_default();
3054 let resolved_templates =
3055 setup_backend_resolved_executor_templates(state, tenant, &config, executor);
3056 let resolved_capability_route = setup_backend_resolved_capability_route(&resolved_templates);
3057 let result_body = result.get("result").cloned().unwrap_or(Value::Null);
3058 let upstream = setup_backend_upstream_diagnostic(&result_body);
3059 let event_detail = serde_json::json!({
3060 "providerId": contract.provider_id,
3061 "tenant": tenant,
3062 "team": state.team.clone().unwrap_or_else(|| "default".to_string()),
3063 "env": state.env,
3064 "request": {
3065 "method": "POST",
3066 "path": request_path,
3067 "body": body,
3068 },
3069 "selected_contract_step": selected_step,
3070 "selected_executor": {
3071 "kind": executor_kind,
3072 "action_id": action.and_then(|action| action.get("id")).cloned().unwrap_or(Value::Null),
3073 "action_label": action.and_then(|action| action.get("label")).cloned().unwrap_or(Value::Null),
3074 },
3075 "resolved_capability_route": resolved_capability_route,
3076 "resolved_templates": resolved_templates,
3077 "upstream_runtime_url": upstream.get("url").cloned().unwrap_or(Value::Null),
3078 "upstream_status": upstream.get("status").cloned().unwrap_or(Value::Null),
3079 "upstream_body": upstream.get("body").cloned().unwrap_or(Value::Null),
3080 "result": result,
3081 "setup_state_before": state_before,
3082 "setup_state_after": state_after,
3083 });
3084 persist_provider_setup_event(
3085 state,
3086 ProviderSetupEventRequest {
3087 provider_id: contract.provider_id.clone(),
3088 event_name: "greentic-provider-setup-backend-next".to_string(),
3089 event_detail,
3090 current_step_id: Some(Value::String(selected_step.to_string())),
3091 current_progress: state_after
3092 .get("setup_status")
3093 .and_then(|status| status.get("items"))
3094 .cloned(),
3095 action_name: action
3096 .and_then(|action| action.get("label").or_else(|| action.get("id")))
3097 .cloned(),
3098 request_method: Some(Value::String("POST".to_string())),
3099 request_path: Some(Value::String(request_path.to_string())),
3100 http_status: upstream
3101 .get("status")
3102 .cloned()
3103 .or_else(|| Some(Value::Number(serde_json::Number::from(200)))),
3104 response_body: Some(result_body),
3105 error: result
3106 .get("error")
3107 .or_else(|| result.get("next"))
3108 .filter(|_| result.get("ok").and_then(Value::as_bool) != Some(true))
3109 .cloned(),
3110 correlation_id: Some(provider_setup_event_detail_field(
3111 result,
3112 &[
3113 "correlationId",
3114 "correlation_id",
3115 "trace_id",
3116 "traceId",
3117 "request-id",
3118 "client-request-id",
3119 ],
3120 )),
3121 tenant: Some(tenant.to_string()),
3122 team: state.team.clone(),
3123 env: Some(state.env.clone()),
3124 setup_session_id: None,
3125 setup_ui_url: None,
3126 },
3127 )
3128}
3129
3130fn setup_backend_resolved_executor_templates(
3131 state: &UiState,
3132 tenant: &str,
3133 config: &JsonMap<String, Value>,
3134 executor: &Value,
3135) -> Value {
3136 let mut resolved = JsonMap::new();
3137 if let Some(object) = executor.as_object() {
3138 for (key, value) in object {
3139 if (key.ends_with("_template") || key == "url_template")
3140 && let Some(template) = value.as_str()
3141 {
3142 resolved.insert(
3143 key.clone(),
3144 Value::String(setup_backend_expand_template(
3145 state, tenant, config, template,
3146 )),
3147 );
3148 }
3149 }
3150 }
3151 Value::Object(resolved)
3152}
3153
3154fn setup_backend_resolved_capability_route(resolved_templates: &Value) -> Value {
3155 resolved_templates
3156 .get("registration_url_template")
3157 .or_else(|| resolved_templates.get("url_template"))
3158 .cloned()
3159 .unwrap_or(Value::Null)
3160}
3161
3162fn setup_backend_upstream_diagnostic(result_body: &Value) -> Value {
3163 let registration = result_body.get("registration").unwrap_or(&Value::Null);
3164 serde_json::json!({
3165 "url": result_body
3166 .get("target")
3167 .or_else(|| result_body.get("url"))
3168 .or_else(|| result_body.get("registration_url"))
3169 .cloned()
3170 .unwrap_or(Value::Null),
3171 "status": registration
3172 .get("status")
3173 .cloned()
3174 .unwrap_or(Value::Null),
3175 "body": registration
3176 .get("body")
3177 .or_else(|| registration.get("response"))
3178 .cloned()
3179 .unwrap_or_else(|| registration.clone()),
3180 })
3181}
3182
3183async fn setup_backend_contract_oauth_start(
3184 state: &UiState,
3185 contract: &ProviderBackendContract,
3186 tenant: &str,
3187 kind: &str,
3188 body: &Value,
3189) -> Result<Value> {
3190 let _ = setup_backend_contract_save_config(state, contract, tenant, body)?;
3191 let mut stored = load_setup_backend_contract_state(state, &contract.provider_id, tenant)?;
3192 ensure_setup_backend_config_defaults(state, tenant, &mut stored)?;
3193 let Some(action) = setup_backend_oauth_action(contract, kind) else {
3194 return Ok(setup_backend_action_error(
3195 "oauth_device_code",
3196 &format!("no oauth_device_code action declares oauth_kind {kind}"),
3197 ));
3198 };
3199 let result =
3200 setup_backend_execute_oauth_device_code_start(state, contract, tenant, &mut stored, action)
3201 .await?;
3202 crate::setup_backend_contract::record_action_result(
3203 &state.bundle_path,
3204 tenant,
3205 state.team.as_deref().unwrap_or("default"),
3206 &contract.provider_id,
3207 &mut stored,
3208 result.clone(),
3209 )?;
3210 Ok(result)
3211}
3212
3213async fn setup_backend_contract_oauth_complete(
3214 state: &UiState,
3215 contract: &ProviderBackendContract,
3216 tenant: &str,
3217 kind: &str,
3218) -> Result<Value> {
3219 let mut stored = load_setup_backend_contract_state(state, &contract.provider_id, tenant)?;
3220 ensure_setup_backend_config_defaults(state, tenant, &mut stored)?;
3221 let Some(action) = setup_backend_oauth_action(contract, kind) else {
3222 return Ok(setup_backend_action_error(
3223 "oauth_device_code",
3224 &format!("no oauth_device_code action declares oauth_kind {kind}"),
3225 ));
3226 };
3227 let result = setup_backend_execute_oauth_device_code_complete(
3228 state,
3229 contract,
3230 tenant,
3231 &mut stored,
3232 action,
3233 )
3234 .await?;
3235 crate::setup_backend_contract::record_action_result(
3236 &state.bundle_path,
3237 tenant,
3238 state.team.as_deref().unwrap_or("default"),
3239 &contract.provider_id,
3240 &mut stored,
3241 result.clone(),
3242 )?;
3243 Ok(result)
3244}
3245
3246async fn setup_backend_execute_action(
3247 state: &UiState,
3248 contract: &ProviderBackendContract,
3249 tenant: &str,
3250 stored: &mut JsonMap<String, Value>,
3251 step: &str,
3252) -> Result<Value> {
3253 let Some(action) = setup_backend_action_by_id(contract, step) else {
3254 return Ok(setup_backend_action_error(
3255 "missing_action",
3256 &format!("backend contract has no action for required step {step}"),
3257 ));
3258 };
3259 let kind = action
3260 .get("executor")
3261 .and_then(|executor| executor.get("kind"))
3262 .and_then(Value::as_str)
3263 .unwrap_or_default();
3264 match kind {
3265 "oauth_device_code" => {
3266 setup_backend_execute_oauth_device_code_start(state, contract, tenant, stored, action)
3267 .await
3268 }
3269 "microsoft_graph_application" => {
3270 setup_backend_execute_graph_application(contract, tenant, stored, action).await
3271 }
3272 "provider_http" => {
3273 setup_backend_execute_provider_http(state, contract, tenant, stored, action).await
3274 }
3275 "microsoft_graph_teams_app_catalog_publish" => {
3276 setup_backend_execute_teams_app_publish(state, contract, tenant, stored, action).await
3277 }
3278 "microsoft_graph_teams_app_user_install" => {
3279 setup_backend_execute_teams_app_user_install(state, contract, tenant, stored, action)
3280 .await
3281 }
3282 "runtime_observation" => {
3283 setup_backend_execute_runtime_observation(state, contract, tenant, stored, action).await
3284 }
3285 "" => Ok(setup_backend_action_error(
3286 "missing_executor_kind",
3287 &format!("backend contract action {step} has no executor.kind"),
3288 )),
3289 other => Ok(setup_backend_action_error(
3290 other,
3291 &format!("setup backend executor kind is not implemented: {other}"),
3292 )),
3293 }
3294}
3295
3296async fn setup_backend_execute_oauth_device_code_start(
3297 _state: &UiState,
3298 _contract: &ProviderBackendContract,
3299 _tenant: &str,
3300 stored: &mut JsonMap<String, Value>,
3301 action: &Value,
3302) -> Result<Value> {
3303 let executor = setup_backend_executor(action)?;
3304 let config = setup_backend_config_mut(stored)?;
3305 let client_id = setup_backend_oauth_client_id(executor, config)?;
3306 if client_id.is_empty() {
3307 let client_id_key = required_executor_str(executor, "client_id_config_key")?;
3308 return Ok(setup_backend_step_result(
3309 action,
3310 false,
3311 &format!("set {client_id_key}, then retry"),
3312 serde_json::json!({
3313 "ok": false,
3314 "missing_config_key": client_id_key,
3315 }),
3316 ));
3317 }
3318 let authority_tenant = executor
3319 .get("authority_tenant_config_key")
3320 .and_then(Value::as_str)
3321 .map(|key| setup_backend_config_str(config, key))
3322 .filter(|value| !value.is_empty())
3323 .or_else(|| {
3324 executor
3325 .get("authority_tenant_default")
3326 .and_then(Value::as_str)
3327 .map(str::to_string)
3328 })
3329 .unwrap_or_else(|| "organizations".to_string());
3330 let authority_template = required_executor_str(executor, "authority_url_template")?;
3331 let authority = authority_template.replace("{authority_tenant}", &authority_tenant);
3332 let scopes = executor
3333 .get("scopes")
3334 .and_then(Value::as_array)
3335 .into_iter()
3336 .flatten()
3337 .filter_map(Value::as_str)
3338 .collect::<Vec<_>>()
3339 .join(" ");
3340 if scopes.trim().is_empty() {
3341 return Ok(setup_backend_step_result(
3342 action,
3343 false,
3344 "OAuth device-code action has no scopes.",
3345 serde_json::json!({ "ok": false, "error": "oauth_device_code executor missing scopes" }),
3346 ));
3347 }
3348 let device_url = format!("{}/oauth2/v2.0/devicecode", authority.trim_end_matches('/'));
3349 let token_url = format!("{}/oauth2/v2.0/token", authority.trim_end_matches('/'));
3350 let client = reqwest::Client::new();
3351 let response = client
3352 .post(&device_url)
3353 .form(&[
3354 ("client_id", client_id.as_str()),
3355 ("scope", scopes.as_str()),
3356 ])
3357 .send()
3358 .await
3359 .context("OAuth device-code request failed")?;
3360 let status = response.status().as_u16();
3361 let body = response
3362 .json::<Value>()
3363 .await
3364 .context("failed to parse OAuth device-code response")?;
3365 if status >= 400 {
3366 return Ok(setup_backend_step_result(
3367 action,
3368 false,
3369 "OAuth device-code request failed.",
3370 serde_json::json!({ "ok": false, "http_status": status, "body": body }),
3371 ));
3372 }
3373 let device_code = body
3374 .get("device_code")
3375 .and_then(Value::as_str)
3376 .unwrap_or_default()
3377 .trim()
3378 .to_string();
3379 if device_code.is_empty() {
3380 return Ok(setup_backend_step_result(
3381 action,
3382 false,
3383 "OAuth device-code response did not include a device code.",
3384 serde_json::json!({ "ok": false, "body": body }),
3385 ));
3386 }
3387 let oauth_kind = executor
3388 .get("oauth_kind")
3389 .and_then(Value::as_str)
3390 .unwrap_or("default");
3391 let device_code_key = executor
3392 .get("device_code_store_key")
3393 .and_then(Value::as_str)
3394 .unwrap_or("oauth_device_code");
3395 let user_code_key = executor
3396 .get("user_code_store_key")
3397 .and_then(Value::as_str)
3398 .unwrap_or("oauth_user_code");
3399 config.insert(
3400 "oauth_kind".to_string(),
3401 Value::String(oauth_kind.to_string()),
3402 );
3403 config.insert(device_code_key.to_string(), Value::String(device_code));
3404 if let Some(user_code) = body.get("user_code").and_then(Value::as_str) {
3405 config.insert(
3406 user_code_key.to_string(),
3407 Value::String(user_code.to_string()),
3408 );
3409 }
3410 if let Some(verification_uri) = body
3411 .get("verification_uri")
3412 .or_else(|| body.get("verification_url"))
3413 .and_then(Value::as_str)
3414 {
3415 config.insert(
3416 "oauth_verification_uri".to_string(),
3417 Value::String(verification_uri.to_string()),
3418 );
3419 }
3420 config.insert("oauth_token_url".to_string(), Value::String(token_url));
3421 config.insert("oauth_client_id".to_string(), Value::String(client_id));
3422 let login = setup_backend_device_login_payload(config, user_code_key, &body);
3423 stored.insert(
3424 "last_oauth".to_string(),
3425 serde_json::json!({
3426 "kind": oauth_kind,
3427 "response": setup_backend_public_oauth_response(&body),
3428 }),
3429 );
3430 Ok(setup_backend_step_result(
3431 action,
3432 false,
3433 setup_backend_device_login_next_message(),
3434 serde_json::json!({
3435 "ok": false,
3436 "pending_device_login": true,
3437 "login": login,
3438 "body": setup_backend_public_oauth_response(&body),
3439 }),
3440 ))
3441}
3442
3443async fn setup_backend_execute_oauth_device_code_complete(
3444 _state: &UiState,
3445 _contract: &ProviderBackendContract,
3446 _tenant: &str,
3447 stored: &mut JsonMap<String, Value>,
3448 action: &Value,
3449) -> Result<Value> {
3450 let executor = setup_backend_executor(action)?;
3451 let config = setup_backend_config_mut(stored)?;
3452 let oauth_kind = executor
3453 .get("oauth_kind")
3454 .and_then(Value::as_str)
3455 .unwrap_or("default");
3456 let device_code_key = executor
3457 .get("device_code_store_key")
3458 .and_then(Value::as_str)
3459 .unwrap_or("oauth_device_code");
3460 let token_store_key = required_executor_str(executor, "token_store_key")?;
3461 let device_code = setup_backend_config_str(config, device_code_key);
3462 let client_id = setup_backend_config_str(config, "oauth_client_id");
3463 let token_url = setup_backend_config_str(config, "oauth_token_url");
3464 if device_code.is_empty() || client_id.is_empty() || token_url.is_empty() {
3465 return Ok(setup_backend_step_result(
3466 action,
3467 false,
3468 "start device login first",
3469 serde_json::json!({ "ok": false, "error": "device_login_not_started" }),
3470 ));
3471 }
3472 let client = reqwest::Client::new();
3473 let response = client
3474 .post(&token_url)
3475 .form(&[
3476 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
3477 ("client_id", client_id.as_str()),
3478 ("device_code", device_code.as_str()),
3479 ])
3480 .send()
3481 .await
3482 .context("OAuth device-code token polling failed")?;
3483 let status = response.status().as_u16();
3484 let body = response
3485 .json::<Value>()
3486 .await
3487 .context("failed to parse OAuth device-code token response")?;
3488 if let Some(error) = body.get("error").and_then(Value::as_str)
3489 && matches!(error, "authorization_pending" | "slow_down")
3490 {
3491 return Ok(setup_backend_step_result(
3492 action,
3493 false,
3494 "authorization is still pending",
3495 serde_json::json!({ "ok": false, "body": body }),
3496 ));
3497 }
3498 if status >= 400 || body.get("access_token").and_then(Value::as_str).is_none() {
3499 return Ok(setup_backend_step_result(
3500 action,
3501 false,
3502 "OAuth token polling failed.",
3503 serde_json::json!({ "ok": false, "http_status": status, "body": body }),
3504 ));
3505 }
3506 if let Some(token) = body.get("access_token").and_then(Value::as_str) {
3507 config.insert(
3508 token_store_key.to_string(),
3509 Value::String(token.to_string()),
3510 );
3511 }
3512 config.remove(device_code_key);
3513 let user_code_key = executor
3514 .get("user_code_store_key")
3515 .and_then(Value::as_str)
3516 .unwrap_or("oauth_user_code");
3517 config.remove(user_code_key);
3518 config.remove("oauth_kind");
3519 config.remove("oauth_client_id");
3520 config.remove("oauth_token_url");
3521 setup_backend_clear_oauth_resume_for_token(stored, token_store_key);
3522 let oauth = stored
3523 .entry("oauth".to_string())
3524 .or_insert_with(|| Value::Object(JsonMap::new()))
3525 .as_object_mut()
3526 .ok_or_else(|| anyhow!("stored oauth state is not an object"))?;
3527 oauth.insert(
3528 oauth_kind.to_string(),
3529 serde_json::json!({
3530 "ok": true,
3531 "completed_at": setup_backend_timestamp_ms(),
3532 "token_store_key": token_store_key,
3533 }),
3534 );
3535 Ok(setup_backend_step_result(
3536 action,
3537 true,
3538 "click again to continue setup",
3539 serde_json::json!({
3540 "ok": true,
3541 "persisted_keys": [token_store_key],
3542 }),
3543 ))
3544}
3545
3546async fn setup_backend_execute_graph_application(
3547 contract: &ProviderBackendContract,
3548 _tenant: &str,
3549 stored: &mut JsonMap<String, Value>,
3550 action: &Value,
3551) -> Result<Value> {
3552 crate::setup_backend_contract::execute_microsoft_graph_application(
3553 stored,
3554 &contract.provider_id,
3555 action,
3556 )
3557}
3558
3559async fn setup_backend_execute_provider_http(
3560 state: &UiState,
3561 contract: &ProviderBackendContract,
3562 tenant: &str,
3563 stored: &mut JsonMap<String, Value>,
3564 action: &Value,
3565) -> Result<Value> {
3566 let executor = setup_backend_executor(action)?;
3567 let config = setup_backend_config_mut(stored)?;
3568 setup_backend_apply_host_defaults(state, tenant, config);
3569 setup_backend_adopt_runtime_public_base_url(state, tenant, config)?;
3570 if setup_backend_public_base_url_needs_runtime(state, tenant, config)
3571 && let Err(err) = ensure_setup_runtime(state, tenant).await
3572 {
3573 let detail = setup_backend_error_chain(&err);
3574 return Ok(setup_backend_step_result(
3575 action,
3576 false,
3577 "runtime/tunnel not running",
3578 serde_json::json!({
3579 "ok": false,
3580 "blocked": true,
3581 "error": "runtime/tunnel not running",
3582 "detail": detail,
3583 }),
3584 ));
3585 }
3586 if let Err(err) = setup_backend_refresh_public_tunnel_if_needed(state, tenant, config).await {
3587 let detail = setup_backend_error_chain(&err);
3588 return Ok(setup_backend_step_result(
3589 action,
3590 false,
3591 "runtime/tunnel not running",
3592 serde_json::json!({
3593 "ok": false,
3594 "blocked": true,
3595 "error": "runtime/tunnel not running",
3596 "detail": detail,
3597 }),
3598 ));
3599 }
3600 let previous_public_base_url = setup_backend_config_str(config, "public_base_url");
3608 if let Some(adopted) = setup_backend_adopt_runtime_public_base_url(state, tenant, config)? {
3609 eprintln!(
3610 "[setup provider-http] adopted runtime public_base_url {adopted} \
3611 (was {previous_public_base_url:?}) before registering"
3612 );
3613 }
3614
3615 let target = match setup_backend_provider_http_url(state, tenant, config, executor) {
3616 Ok(url) => url,
3617 Err(err) => {
3618 return Ok(setup_backend_step_result(
3619 action,
3620 false,
3621 &err.to_string(),
3622 serde_json::json!({
3623 "ok": false,
3624 "blocked": true,
3625 "error": err.to_string(),
3626 }),
3627 ));
3628 }
3629 };
3630 if setup_backend_template_unresolved(&target) || target.trim().is_empty() {
3631 return Ok(setup_backend_step_result(
3632 action,
3633 false,
3634 "provider_http executor target could not be resolved",
3635 serde_json::json!({
3636 "ok": false,
3637 "blocked": true,
3638 "error": "provider_http executor target could not be resolved",
3639 "target": target,
3640 }),
3641 ));
3642 }
3643
3644 let method = executor
3645 .get("method")
3646 .and_then(Value::as_str)
3647 .and_then(|method| reqwest::Method::from_bytes(method.as_bytes()).ok())
3648 .unwrap_or(reqwest::Method::POST);
3649 let runtime_context = setup_backend_runtime_context(state, tenant, config);
3650 let payload = setup_backend_provider_http_payload(state, contract, tenant, config, action);
3651 if is_safe_same_origin_path(&target) {
3652 return setup_backend_execute_provider_http_local(
3653 state,
3654 stored,
3655 ProviderHttpLocalExecution {
3656 contract,
3657 provider_id: &contract.provider_id,
3658 tenant,
3659 action,
3660 target: &target,
3661 method: method.as_str(),
3662 payload,
3663 runtime_context,
3664 },
3665 )
3666 .await;
3667 }
3668 let client = reqwest::Client::new();
3669 let response =
3670 match setup_backend_json_request(&client, method, &target, None, Some(payload)).await {
3671 Ok(response) => response,
3672 Err(err) => {
3673 return Ok(setup_backend_step_result(
3674 action,
3675 false,
3676 "Provider setup service is not running",
3677 serde_json::json!({
3678 "ok": false,
3679 "blocked": true,
3680 "error": "Provider setup service is not running",
3681 "target": target,
3682 "detail": err.to_string(),
3683 }),
3684 ));
3685 }
3686 };
3687 let ok = response
3688 .get("body")
3689 .and_then(|body| body.get("ok"))
3690 .and_then(Value::as_bool)
3691 .unwrap_or_else(|| response.get("ok").and_then(Value::as_bool).unwrap_or(false));
3692 let state_key = executor
3693 .get("state_store_key")
3694 .and_then(Value::as_str)
3695 .unwrap_or_else(|| {
3696 action
3697 .get("id")
3698 .and_then(Value::as_str)
3699 .unwrap_or("last_provider_http")
3700 });
3701 let result = serde_json::json!({
3702 "ok": ok,
3703 "target": target,
3704 "response": response,
3705 "runtime_context": runtime_context,
3706 });
3707 if !ok
3708 && let Some(oauth_result) =
3709 setup_backend_provider_http_oauth_required_result(contract, action, &result)
3710 {
3711 return Ok(oauth_result);
3712 }
3713 stored.insert(state_key.to_string(), result.clone());
3714 Ok(setup_backend_step_result(
3715 action,
3716 ok,
3717 if ok {
3718 "click again to continue setup"
3719 } else {
3720 "fix provider setup endpoint and retry"
3721 },
3722 result,
3723 ))
3724}
3725
3726async fn setup_backend_execute_provider_http_local(
3727 state: &UiState,
3728 stored: &mut JsonMap<String, Value>,
3729 execution: ProviderHttpLocalExecution<'_>,
3730) -> Result<Value> {
3731 let Some(route_match) = find_declared_provider_http_route(
3732 &state.bundle_path,
3733 execution.method,
3734 execution.target,
3735 execution.tenant,
3736 state.team.as_deref().unwrap_or("default"),
3737 )?
3738 else {
3739 let message = format!(
3740 "provider_http target {} is not declared by pack greentic.http-routes.v1",
3741 execution.target
3742 );
3743 return Ok(setup_backend_step_result(
3744 execution.action,
3745 false,
3746 &message,
3747 serde_json::json!({
3748 "ok": false,
3749 "blocked": true,
3750 "error": message,
3751 "target": execution.target,
3752 "provider_id": execution.provider_id,
3753 }),
3754 ));
3755 };
3756 let body = Bytes::from(serde_json::to_vec(&execution.payload)?);
3757 let headers = HeaderMap::new();
3758 let response = invoke_declared_provider_http_route(
3759 state,
3760 &route_match,
3761 execution.method,
3762 execution.target,
3763 "",
3764 &headers,
3765 body,
3766 )
3767 .await;
3768 let response = match response {
3769 Ok(response) => response,
3770 Err(err) => {
3771 return Ok(setup_backend_step_result(
3772 execution.action,
3773 false,
3774 "Provider pack route failed",
3775 serde_json::json!({
3776 "ok": false,
3777 "blocked": true,
3778 "error": err.to_string(),
3779 "target": execution.target,
3780 "provider_id": execution.provider_id,
3781 }),
3782 ));
3783 }
3784 };
3785 let ok = response
3786 .get("ok")
3787 .and_then(Value::as_bool)
3788 .or_else(|| {
3789 response
3790 .get("response")
3791 .and_then(|response| response.get("body_json"))
3792 .and_then(|body| body.get("ok"))
3793 .and_then(Value::as_bool)
3794 })
3795 .unwrap_or(true);
3796 let state_key = execution
3797 .action
3798 .get("executor")
3799 .and_then(|executor| executor.get("state_store_key"))
3800 .and_then(Value::as_str)
3801 .unwrap_or_else(|| {
3802 execution
3803 .action
3804 .get("id")
3805 .and_then(Value::as_str)
3806 .unwrap_or("last_provider_http")
3807 });
3808 let result = serde_json::json!({
3809 "ok": ok,
3810 "target": execution.target,
3811 "response": response,
3812 "runtime_context": execution.runtime_context,
3813 });
3814 if !ok
3815 && let Some(oauth_result) = setup_backend_provider_http_oauth_required_result(
3816 execution.contract,
3817 execution.action,
3818 &result,
3819 )
3820 {
3821 return Ok(oauth_result);
3822 }
3823 stored.insert(state_key.to_string(), result.clone());
3824 Ok(setup_backend_step_result(
3825 execution.action,
3826 ok,
3827 if ok {
3828 "click again to continue setup"
3829 } else {
3830 "fix provider setup route and retry"
3831 },
3832 result,
3833 ))
3834}
3835
3836async fn setup_backend_execute_teams_app_publish(
3837 state: &UiState,
3838 contract: &ProviderBackendContract,
3839 tenant: &str,
3840 stored: &mut JsonMap<String, Value>,
3841 action: &Value,
3842) -> Result<Value> {
3843 {
3844 let config = setup_backend_config_mut(stored)?;
3845 setup_backend_apply_host_defaults(state, tenant, config);
3846 setup_backend_adopt_runtime_public_base_url(state, tenant, config)?;
3847 }
3848 let provider_pack = setup_backend_provider_pack_path(state, &contract.provider_id)?;
3849 crate::setup_backend_contract::execute_microsoft_graph_teams_app_catalog_publish(
3850 &provider_pack,
3851 stored,
3852 tenant,
3853 state.team.as_deref().unwrap_or("default"),
3854 &state.env,
3855 action,
3856 )
3857}
3858
3859async fn setup_backend_execute_teams_app_user_install(
3860 state: &UiState,
3861 _contract: &ProviderBackendContract,
3862 tenant: &str,
3863 stored: &mut JsonMap<String, Value>,
3864 action: &Value,
3865) -> Result<Value> {
3866 {
3867 let config = setup_backend_config_mut(stored)?;
3868 setup_backend_apply_host_defaults(state, tenant, config);
3869 setup_backend_adopt_runtime_public_base_url(state, tenant, config)?;
3870 }
3871 crate::setup_backend_contract::execute_microsoft_graph_teams_app_user_install(
3872 stored,
3873 tenant,
3874 state.team.as_deref().unwrap_or("default"),
3875 &state.env,
3876 action,
3877 )
3878}
3879
3880async fn setup_backend_execute_runtime_observation(
3881 state: &UiState,
3882 contract: &ProviderBackendContract,
3883 tenant: &str,
3884 stored: &mut JsonMap<String, Value>,
3885 action: &Value,
3886) -> Result<Value> {
3887 let executor = setup_backend_executor(action)?;
3888 let config = setup_backend_config_mut(stored)?;
3889 if let Err(err) =
3890 setup_backend_prepare_runtime_public_base_for_observation(state, tenant, config).await
3891 {
3892 return Ok(setup_backend_runtime_blocked_result(
3893 action,
3894 setup_backend_error_chain(&err),
3895 ));
3896 }
3897 if let Err(err) = setup_backend_refresh_public_tunnel_if_needed(state, tenant, config).await {
3898 let detail = setup_backend_error_chain(&err);
3899 return Ok(setup_backend_step_result(
3900 action,
3901 false,
3902 "runtime/tunnel not running",
3903 serde_json::json!({
3904 "ok": false,
3905 "blocked": true,
3906 "error": "runtime/tunnel not running",
3907 "detail": detail,
3908 }),
3909 ));
3910 }
3911 let runtime_context = setup_backend_runtime_context(state, tenant, config);
3912 let state_key = executor
3913 .get("state_store_key")
3914 .and_then(Value::as_str)
3915 .unwrap_or("last_activity");
3916 if let Some(blocked) = setup_backend_runtime_context_blocked(state, &runtime_context).await {
3917 return Ok(setup_backend_step_result(
3918 action,
3919 false,
3920 "runtime/tunnel not running",
3921 blocked,
3922 ));
3923 }
3924 setup_backend_refresh_runtime_observation_from_runtime_logs(
3925 state,
3926 tenant,
3927 executor,
3928 stored,
3929 state_key,
3930 &runtime_context,
3931 )?;
3932 setup_backend_refresh_runtime_observation_from_provider_state(
3933 state,
3934 contract,
3935 tenant,
3936 stored,
3937 state_key,
3938 &runtime_context,
3939 )
3940 .await?;
3941 if stored
3942 .get(state_key)
3943 .is_some_and(|value| setup_backend_runtime_context_current(value, &runtime_context))
3944 {
3945 return Ok(setup_backend_step_result(
3946 action,
3947 true,
3948 "runtime observation is present",
3949 serde_json::json!({
3950 "ok": true,
3951 "state_store_key": state_key,
3952 "runtime_context": runtime_context,
3953 }),
3954 ));
3955 }
3956 Ok(setup_backend_step_result(
3957 action,
3958 false,
3959 "waiting for runtime observation",
3960 serde_json::json!({
3961 "ok": false,
3962 "waiting": true,
3963 "blocked": true,
3964 "retryable": true,
3965 "error": "runtime observation not present yet",
3966 "detail": "runtime and tunnel are running, but no current observation has been recorded yet",
3967 "provider_id": executor
3968 .get("provider_id")
3969 .and_then(Value::as_str)
3970 .unwrap_or(&contract.provider_id),
3971 "source": executor.get("source").cloned().unwrap_or(Value::Null),
3972 "event": executor.get("event").cloned().unwrap_or(Value::Null),
3973 "state_store_key": state_key,
3974 "runtime_context": runtime_context,
3975 }),
3976 ))
3977}
3978
3979fn setup_backend_refresh_runtime_observation_from_runtime_logs(
3980 state: &UiState,
3981 tenant: &str,
3982 executor: &Value,
3983 stored: &mut JsonMap<String, Value>,
3984 state_key: &str,
3985 runtime_context: &Value,
3986) -> Result<()> {
3987 if executor.get("source").and_then(Value::as_str) != Some("greentic-start")
3988 || executor.get("event").and_then(Value::as_str) != Some("bot_framework_activity_received")
3989 {
3990 return Ok(());
3991 }
3992 let Some(observed) = setup_backend_latest_runtime_activity_from_logs(
3993 state,
3994 tenant,
3995 state.team.as_deref().unwrap_or("default"),
3996 )?
3997 else {
3998 return Ok(());
3999 };
4000 let observed = setup_backend_observation_with_runtime_context(observed, runtime_context);
4001 setup_backend_store_runtime_observation(stored, state_key, observed);
4002 Ok(())
4003}
4004
4005fn setup_backend_latest_runtime_activity_from_logs(
4006 state: &UiState,
4007 tenant: &str,
4008 team: &str,
4009) -> Result<Option<Value>> {
4010 let log_path = state.bundle_path.join("logs").join("system.log");
4011 let file = match std::fs::File::open(&log_path) {
4012 Ok(file) => file,
4013 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
4014 Err(err) => return Err(err).with_context(|| format!("read {}", log_path.display())),
4015 };
4016 let line_floor = setup_backend_setup_runtime_info(state)
4017 .and_then(|info| info.system_log_line_floor)
4018 .unwrap_or(0);
4019 let team_some = format!("team=Some(\"{team}\")");
4020 let team_plain = format!("team={team}");
4021 let tenant_match = format!("tenant={tenant}");
4022 let mut observed = None;
4023 for (index, line) in std::io::BufReader::new(file)
4024 .lines()
4025 .map_while(std::result::Result::ok)
4026 .enumerate()
4027 {
4028 if index < line_floor {
4029 continue;
4030 }
4031 if line.contains("[fast2flow:gate] enter")
4032 && line.contains(&tenant_match)
4033 && (line.contains(&team_some) || line.contains(&team_plain))
4034 {
4035 observed = Some(serde_json::json!({
4036 "source": "greentic-start",
4037 "event": "bot_framework_activity_received",
4038 "tenant": tenant,
4039 "team": team,
4040 "log_path": log_path,
4041 "log_line": index + 1,
4042 }));
4043 }
4044 }
4045 Ok(observed)
4046}
4047
4048async fn setup_backend_refresh_runtime_observation_from_provider_state(
4049 state: &UiState,
4050 contract: &ProviderBackendContract,
4051 tenant: &str,
4052 stored: &mut JsonMap<String, Value>,
4053 state_key: &str,
4054 runtime_context: &Value,
4055) -> Result<()> {
4056 let Some((method, path)) =
4057 setup_backend_contract_state_route(contract, tenant, state.team.as_deref())
4058 else {
4059 return Ok(());
4060 };
4061 let Some(route_match) = find_declared_provider_http_route(
4062 &state.bundle_path,
4063 &method,
4064 &path,
4065 tenant,
4066 state.team.as_deref().unwrap_or("default"),
4067 )?
4068 else {
4069 return Ok(());
4070 };
4071 if !matches!(
4072 route_match.route.target,
4073 ProviderHttpRouteTarget::SetupComponent { .. }
4074 ) {
4075 return Ok(());
4076 }
4077 let response = invoke_declared_provider_http_route(
4078 state,
4079 &route_match,
4080 &method,
4081 &path,
4082 "",
4083 &HeaderMap::new(),
4084 Bytes::new(),
4085 )
4086 .await?;
4087 let Some(observed) = response
4088 .get("values")
4089 .and_then(|values| values.get(state_key))
4090 .filter(|value| !value.is_null())
4091 .cloned()
4092 else {
4093 return Ok(());
4094 };
4095 let observed = setup_backend_observation_with_runtime_context(observed, runtime_context);
4096 setup_backend_store_runtime_observation(stored, state_key, observed);
4097 Ok(())
4098}
4099
4100fn setup_backend_contract_state_route(
4101 contract: &ProviderBackendContract,
4102 tenant: &str,
4103 team: Option<&str>,
4104) -> Option<(String, String)> {
4105 let route = contract
4106 .inline
4107 .get("routes")
4108 .and_then(|routes| routes.get("state"))
4109 .and_then(Value::as_str)
4110 .or_else(|| contract.inline.get("base_path").and_then(Value::as_str))?;
4111 let (method, path) = match route.split_once(' ') {
4112 Some((method, path)) => (method.trim(), path.trim()),
4113 None => ("GET", route.trim()),
4114 };
4115 let team = team.unwrap_or("default");
4116 Some((
4117 method.to_string(),
4118 path.replace("{tenant}", tenant).replace("{team}", team),
4119 ))
4120}
4121
4122fn setup_backend_observation_with_runtime_context(
4123 observed: Value,
4124 runtime_context: &Value,
4125) -> Value {
4126 let observed_at = Value::Number(serde_json::Number::from(setup_backend_timestamp_ms() as u64));
4127 match observed {
4128 Value::Object(mut object) => {
4129 object.insert("runtime_context".to_string(), runtime_context.clone());
4130 object
4131 .entry("last_activity_received_at".to_string())
4132 .or_insert_with(|| observed_at.clone());
4133 Value::Object(object)
4134 }
4135 value => serde_json::json!({
4136 "value": value,
4137 "runtime_context": runtime_context,
4138 "last_activity_received_at": observed_at,
4139 }),
4140 }
4141}
4142
4143fn setup_backend_store_runtime_observation(
4144 stored: &mut JsonMap<String, Value>,
4145 state_key: &str,
4146 observed: Value,
4147) {
4148 if state_key == "last_activity"
4149 && let Some(received_at) = observed.get("last_activity_received_at").cloned()
4150 {
4151 stored.insert("last_activity_received_at".to_string(), received_at);
4152 }
4153 stored.insert(state_key.to_string(), observed);
4154}
4155
4156fn setup_backend_action_by_id<'a>(
4157 contract: &'a ProviderBackendContract,
4158 id: &str,
4159) -> Option<&'a Value> {
4160 contract
4161 .inline
4162 .get("actions")
4163 .and_then(Value::as_array)?
4164 .iter()
4165 .find(|action| action.get("id").and_then(Value::as_str) == Some(id))
4166}
4167
4168fn setup_backend_oauth_action<'a>(
4169 contract: &'a ProviderBackendContract,
4170 kind: &str,
4171) -> Option<&'a Value> {
4172 contract
4173 .inline
4174 .get("actions")
4175 .and_then(Value::as_array)?
4176 .iter()
4177 .find(|action| {
4178 let Some(executor) = action.get("executor").and_then(Value::as_object) else {
4179 return false;
4180 };
4181 executor.get("kind").and_then(Value::as_str) == Some("oauth_device_code")
4182 && executor.get("oauth_kind").and_then(Value::as_str) == Some(kind)
4183 })
4184}
4185
4186fn setup_backend_executor(action: &Value) -> Result<&Value> {
4187 action
4188 .get("executor")
4189 .ok_or_else(|| anyhow!("setup backend action missing executor"))
4190}
4191
4192fn required_executor_str<'a>(executor: &'a Value, key: &str) -> Result<&'a str> {
4193 executor
4194 .get(key)
4195 .and_then(Value::as_str)
4196 .map(str::trim)
4197 .filter(|value| !value.is_empty())
4198 .ok_or_else(|| anyhow!("setup backend executor missing {key}"))
4199}
4200
4201fn setup_backend_config_mut(
4202 stored: &mut JsonMap<String, Value>,
4203) -> Result<&mut JsonMap<String, Value>> {
4204 stored
4205 .entry("config".to_string())
4206 .or_insert_with(|| Value::Object(JsonMap::new()))
4207 .as_object_mut()
4208 .ok_or_else(|| anyhow!("stored config is not an object"))
4209}
4210
4211fn setup_backend_config_str(config: &JsonMap<String, Value>, key: &str) -> String {
4212 config
4213 .get(key)
4214 .and_then(Value::as_str)
4215 .map(str::trim)
4216 .unwrap_or_default()
4217 .to_string()
4218}
4219
4220fn setup_backend_oauth_client_id(
4221 executor: &Value,
4222 config: &JsonMap<String, Value>,
4223) -> Result<String> {
4224 let client_id_key = required_executor_str(executor, "client_id_config_key")?;
4225 let configured_client_id = setup_backend_config_str(config, client_id_key);
4226 if !configured_client_id.is_empty() {
4227 return Ok(configured_client_id);
4228 }
4229 Ok(executor
4230 .get("client_id_default")
4231 .and_then(Value::as_str)
4232 .map(str::trim)
4233 .filter(|value| !value.is_empty())
4234 .unwrap_or_default()
4235 .to_string())
4236}
4237
4238fn setup_backend_step_result(action: &Value, ok: bool, next: &str, result: Value) -> Value {
4239 crate::setup_backend_contract::step_result(action, ok, next, result)
4240}
4241
4242fn setup_backend_runtime_blocked_result(action: &Value, detail: String) -> Value {
4243 setup_backend_step_result(
4244 action,
4245 false,
4246 "runtime/tunnel not running",
4247 serde_json::json!({
4248 "ok": false,
4249 "blocked": true,
4250 "error": "runtime/tunnel not running",
4251 "detail": detail,
4252 }),
4253 )
4254}
4255
4256fn setup_backend_action_error(kind: &str, message: &str) -> Value {
4257 serde_json::json!({
4258 "ok": false,
4259 "error": message,
4260 "executor_kind": kind,
4261 "next": message,
4262 "result": {
4263 "ok": false,
4264 "unsupported": kind != "missing_action" && kind != "missing_executor_kind",
4265 "executor_kind": kind,
4266 "error": message,
4267 }
4268 })
4269}
4270
4271fn setup_backend_error_chain(err: &anyhow::Error) -> String {
4272 err.chain()
4273 .map(ToString::to_string)
4274 .collect::<Vec<_>>()
4275 .join(": ")
4276}
4277
4278fn setup_backend_public_oauth_response(body: &Value) -> Value {
4279 let mut public = body.clone();
4280 if let Some(obj) = public.as_object_mut() {
4281 obj.remove("device_code");
4282 obj.remove("access_token");
4283 obj.remove("refresh_token");
4284 obj.remove("id_token");
4285 }
4286 public
4287}
4288
4289fn setup_backend_device_login_payload(
4290 config: &JsonMap<String, Value>,
4291 user_code_key: &str,
4292 body: &Value,
4293) -> Value {
4294 let interval = body.get("interval").and_then(Value::as_u64).unwrap_or(5);
4295 let expires_in = body
4296 .get("expires_in")
4297 .and_then(Value::as_u64)
4298 .unwrap_or(900);
4299 serde_json::json!({
4300 "url": setup_backend_config_str(config, "oauth_verification_uri"),
4301 "userCode": setup_backend_config_str(config, user_code_key),
4302 "user_code": setup_backend_config_str(config, user_code_key),
4303 "interval": interval,
4304 "expiresIn": expires_in,
4305 })
4306}
4307
4308fn setup_backend_timestamp_ms() -> u128 {
4309 std::time::SystemTime::now()
4310 .duration_since(std::time::UNIX_EPOCH)
4311 .map(|duration| duration.as_millis())
4312 .unwrap_or(0)
4313}
4314
4315fn setup_backend_expand_template(
4316 state: &UiState,
4317 tenant: &str,
4318 config: &JsonMap<String, Value>,
4319 template: &str,
4320) -> String {
4321 let mut expanded = template
4322 .replace("{tenant}", tenant)
4323 .replace("{team}", state.team.as_deref().unwrap_or("default"))
4324 .replace("{env}", &state.env);
4325 if expanded.contains("{public_base_url}") {
4326 let public_base = setup_backend_config_str(config, "public_base_url")
4327 .trim_end_matches('/')
4328 .to_string();
4329 expanded = expanded.replace("{public_base_url}", &public_base);
4330 }
4331 for (key, value) in config {
4332 if let Some(value) = value.as_str() {
4333 expanded = expanded.replace(&format!("{{{key}}}"), value);
4334 }
4335 }
4336 expanded
4337}
4338
4339fn setup_backend_provider_http_url(
4340 state: &UiState,
4341 tenant: &str,
4342 config: &JsonMap<String, Value>,
4343 executor: &Value,
4344) -> Result<String> {
4345 if let Some(template) = executor
4346 .get("url_template")
4347 .or_else(|| executor.get("target_url_template"))
4348 .and_then(Value::as_str)
4349 {
4350 let url = setup_backend_expand_template(state, tenant, config, template);
4351 validate_setup_backend_provider_http_url(&url)?;
4352 return Ok(url);
4353 }
4354
4355 let path_template = executor
4356 .get("path_template")
4357 .or_else(|| executor.get("target_path_template"))
4358 .and_then(Value::as_str)
4359 .ok_or_else(|| anyhow!("provider_http executor requires url_template or path_template"))?;
4360 let path = setup_backend_expand_template(state, tenant, config, path_template);
4361 if !is_safe_same_origin_path(&path) {
4362 anyhow::bail!("provider_http executor path_template must resolve to a safe absolute path");
4363 }
4364 Ok(path)
4365}
4366
4367fn validate_setup_backend_provider_http_url(url: &str) -> Result<()> {
4368 let parsed =
4369 Url::parse(url).with_context(|| format!("provider_http target is not a URL: {url}"))?;
4370 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
4371 anyhow::bail!("provider_http target must be an http(s) URL");
4372 }
4373 Ok(())
4374}
4375
4376fn setup_backend_provider_http_payload(
4377 state: &UiState,
4378 contract: &ProviderBackendContract,
4379 tenant: &str,
4380 config: &JsonMap<String, Value>,
4381 action: &Value,
4382) -> Value {
4383 let executor = action.get("executor").unwrap_or(&Value::Null);
4384 if let Some(template) = executor
4385 .get("body")
4386 .or_else(|| executor.get("body_template"))
4387 .or_else(|| executor.get("request_body"))
4388 {
4389 return setup_backend_expand_json_template(state, tenant, config, template);
4390 }
4391 serde_json::json!({
4392 "provider_id": contract.provider_id,
4393 "tenant": tenant,
4394 "team": state.team.clone().unwrap_or_else(|| "default".to_string()),
4395 "env": state.env,
4396 "step": action.get("id").cloned().unwrap_or(Value::Null),
4397 "config": config,
4398 })
4399}
4400
4401fn setup_backend_expand_json_template(
4402 state: &UiState,
4403 tenant: &str,
4404 config: &JsonMap<String, Value>,
4405 value: &Value,
4406) -> Value {
4407 match value {
4408 Value::String(template) => Value::String(setup_backend_expand_template(
4409 state, tenant, config, template,
4410 )),
4411 Value::Array(items) => Value::Array(
4412 items
4413 .iter()
4414 .map(|item| setup_backend_expand_json_template(state, tenant, config, item))
4415 .collect(),
4416 ),
4417 Value::Object(map) => Value::Object(
4418 map.iter()
4419 .map(|(key, value)| {
4420 (
4421 key.clone(),
4422 setup_backend_expand_json_template(state, tenant, config, value),
4423 )
4424 })
4425 .collect(),
4426 ),
4427 other => other.clone(),
4428 }
4429}
4430
4431async fn setup_backend_json_request(
4432 client: &reqwest::Client,
4433 method: reqwest::Method,
4434 url: &str,
4435 bearer: Option<&str>,
4436 body: Option<Value>,
4437) -> Result<Value> {
4438 let mut request = client.request(method, url);
4439 if let Some(token) = bearer {
4440 request = request.bearer_auth(token);
4441 }
4442 if let Some(body) = body {
4443 request = request.json(&body);
4444 }
4445 let response = request
4446 .send()
4447 .await
4448 .with_context(|| format!("request failed: {url}"))?;
4449 let status = response.status().as_u16();
4450 let body = response.json::<Value>().await.unwrap_or(Value::Null);
4451 Ok(serde_json::json!({
4452 "ok": status < 400,
4453 "status": status,
4454 "body": body,
4455 }))
4456}
4457
4458fn setup_backend_oauth_required_result(
4459 action: &Value,
4460 token_key: &str,
4461 reason: &str,
4462 response: &Value,
4463) -> Option<Value> {
4464 if !setup_backend_response_needs_oauth(response) {
4465 return None;
4466 }
4467 Some(setup_backend_step_result(
4468 action,
4469 false,
4470 &format!("complete OAuth for {token_key}, then retry"),
4471 serde_json::json!({
4472 "ok": false,
4473 "blocked": true,
4474 "error": "oauth_required",
4475 "reason": reason,
4476 "token_store_key": token_key,
4477 "resume_step": action.get("id").and_then(Value::as_str).unwrap_or_default(),
4478 "previous": response,
4479 }),
4480 ))
4481}
4482
4483fn setup_backend_provider_http_oauth_required_result(
4484 contract: &ProviderBackendContract,
4485 action: &Value,
4486 response: &Value,
4487) -> Option<Value> {
4488 if !setup_backend_response_needs_oauth(response) {
4489 return None;
4490 }
4491 let token_key = setup_backend_oauth_token_key_for_action(contract, action, response)?;
4492 setup_backend_oauth_required_result(action, &token_key, "provider setup auth failed", response)
4493}
4494
4495fn setup_backend_response_needs_oauth(response: &Value) -> bool {
4496 let status = response.get("status").and_then(Value::as_u64).unwrap_or(0);
4497 let body = response.get("body").unwrap_or(response);
4498 let text = body.to_string().to_ascii_lowercase();
4499 let says_unauthorized = status == 401
4500 || text.contains("http 401")
4501 || text.contains("status 401")
4502 || text.contains("unauthorized")
4503 || text.contains("invalid token");
4504 let says_expired = text.contains("expired")
4505 || text.contains("expiry")
4506 || text.contains("token exp")
4507 || text.contains("lifetime validation failed")
4508 || text.contains("token is expired");
4509 let says_invalid = text.contains("invalid token")
4510 || text.contains("access token is invalid")
4511 || text.contains("invalid access token");
4512 says_unauthorized && (says_expired || says_invalid)
4513}
4514
4515fn setup_backend_oauth_token_key_for_action(
4516 contract: &ProviderBackendContract,
4517 action: &Value,
4518 response: &Value,
4519) -> Option<String> {
4520 if let Some(token_key) = action
4521 .get("executor")
4522 .and_then(|executor| {
4523 executor
4524 .get("token_store_key")
4525 .or_else(|| executor.get("auth_token_store_key"))
4526 .or_else(|| executor.get("oauth_token_store_key"))
4527 })
4528 .and_then(Value::as_str)
4529 .map(str::trim)
4530 .filter(|value| !value.is_empty())
4531 {
4532 return Some(token_key.to_string());
4533 }
4534 if let Some(token_key) = response
4535 .get("token_store_key")
4536 .or_else(|| response.get("missing_token_store_key"))
4537 .and_then(Value::as_str)
4538 .map(str::trim)
4539 .filter(|value| !value.is_empty())
4540 {
4541 return Some(token_key.to_string());
4542 }
4543 let oauth_token_keys = setup_backend_oauth_token_store_keys(contract);
4544 let mut action_matches = oauth_token_keys
4545 .iter()
4546 .filter(|token_key| setup_backend_value_mentions_token_key(action, token_key))
4547 .cloned()
4548 .collect::<Vec<_>>();
4549 action_matches.sort();
4550 action_matches.dedup();
4551 if action_matches.len() == 1 {
4552 return action_matches.into_iter().next();
4553 }
4554 if oauth_token_keys.len() == 1 {
4555 return oauth_token_keys.into_iter().next();
4556 }
4557 None
4558}
4559
4560fn setup_backend_oauth_token_store_keys(contract: &ProviderBackendContract) -> Vec<String> {
4561 let mut keys = contract
4562 .inline
4563 .get("actions")
4564 .and_then(Value::as_array)
4565 .into_iter()
4566 .flatten()
4567 .filter_map(|action| action.get("executor"))
4568 .filter(|executor| {
4569 executor.get("kind").and_then(Value::as_str) == Some("oauth_device_code")
4570 })
4571 .filter_map(|executor| executor.get("token_store_key").and_then(Value::as_str))
4572 .map(str::trim)
4573 .filter(|value| !value.is_empty())
4574 .map(str::to_string)
4575 .collect::<Vec<_>>();
4576 keys.sort();
4577 keys.dedup();
4578 keys
4579}
4580
4581fn setup_backend_value_mentions_token_key(value: &Value, token_key: &str) -> bool {
4582 match value {
4583 Value::String(text) => text == token_key || text.contains(&format!("{{{token_key}}}")),
4584 Value::Array(items) => items
4585 .iter()
4586 .any(|item| setup_backend_value_mentions_token_key(item, token_key)),
4587 Value::Object(object) => object.iter().any(|(key, value)| {
4588 key == token_key
4589 || key.contains(token_key)
4590 || setup_backend_value_mentions_token_key(value, token_key)
4591 }),
4592 _ => false,
4593 }
4594}
4595
4596fn setup_backend_clear_oauth_resume_for_token(
4597 stored: &mut JsonMap<String, Value>,
4598 token_store_key: &str,
4599) {
4600 crate::setup_backend_contract::clear_oauth_resume_for_token(stored, token_store_key);
4601}
4602
4603fn setup_backend_provider_pack_path(state: &UiState, provider_id: &str) -> Result<PathBuf> {
4604 let discovered = discovery::discover(&state.bundle_path)?;
4605 let provider = discovered
4606 .find_setup_target(provider_id)
4607 .ok_or_else(|| anyhow!("provider pack not found for setup backend asset"))?;
4608 Ok(provider.pack_path.clone())
4609}
4610
4611fn load_setup_backend_contract_state(
4612 state: &UiState,
4613 provider_id: &str,
4614 tenant: &str,
4615) -> Result<JsonMap<String, Value>> {
4616 let team = state.team.as_deref().unwrap_or("default");
4617 let mut stored = crate::setup_backend_contract::load_backend_state(
4618 &state.bundle_path,
4619 &state.env,
4620 tenant,
4621 team,
4622 provider_id,
4623 )?;
4624 ensure_setup_backend_config_defaults(state, tenant, &mut stored)?;
4625 Ok(stored)
4626}
4627
4628fn save_setup_backend_contract_state(
4629 state: &UiState,
4630 provider_id: &str,
4631 tenant: &str,
4632 stored: &JsonMap<String, Value>,
4633) -> Result<()> {
4634 let team = state.team.as_deref().unwrap_or("default");
4635 crate::setup_backend_contract::save_backend_state(
4636 &state.bundle_path,
4637 tenant,
4638 team,
4639 provider_id,
4640 stored,
4641 )?;
4642 Ok(())
4643}
4644
4645fn ensure_setup_backend_config_defaults(
4646 state: &UiState,
4647 tenant: &str,
4648 stored: &mut JsonMap<String, Value>,
4649) -> Result<()> {
4650 let defaults = default_setup_backend_config(state, tenant);
4651 let config = stored
4652 .entry("config".to_string())
4653 .or_insert_with(|| Value::Object(JsonMap::new()))
4654 .as_object_mut()
4655 .ok_or_else(|| anyhow!("stored config is not an object"))?;
4656 for (key, value) in defaults {
4657 if setup_backend_host_default_overrides_empty(&key)
4658 && config
4659 .get(&key)
4660 .and_then(Value::as_str)
4661 .is_some_and(|value| value.trim().is_empty())
4662 {
4663 config.insert(key, value);
4664 } else {
4665 config.entry(key).or_insert(value);
4666 }
4667 }
4668 Ok(())
4669}
4670
4671fn setup_backend_host_default_overrides_empty(key: &str) -> bool {
4672 matches!(key, "public_base_url")
4673}
4674
4675fn default_setup_backend_config(state: &UiState, tenant: &str) -> JsonMap<String, Value> {
4676 let runtime_base = configured_runtime_proxy_base_url()
4677 .or_else(|| setup_backend_runtime_local_base_url(state, tenant));
4678 default_setup_backend_config_with_runtime_base(state, tenant, runtime_base.as_deref())
4679}
4680
4681fn default_setup_backend_config_with_runtime_base(
4682 state: &UiState,
4683 tenant: &str,
4684 runtime_base: Option<&str>,
4685) -> JsonMap<String, Value> {
4686 let mut config = JsonMap::new();
4687 config.insert("tenant".to_string(), Value::String(tenant.to_string()));
4688 config.insert(
4689 "team".to_string(),
4690 Value::String(state.team.clone().unwrap_or_else(|| "default".to_string())),
4691 );
4692 config.insert("env".to_string(), Value::String(state.env.clone()));
4693 setup_backend_apply_host_defaults_with_runtime_base(state, tenant, runtime_base, &mut config);
4694 config
4695}
4696
4697fn setup_backend_apply_host_defaults(
4698 state: &UiState,
4699 tenant: &str,
4700 config: &mut JsonMap<String, Value>,
4701) {
4702 setup_backend_apply_host_defaults_with_runtime_base(
4703 state,
4704 tenant,
4705 configured_runtime_proxy_base_url().as_deref(),
4706 config,
4707 );
4708}
4709
4710fn setup_backend_apply_host_defaults_with_runtime_base(
4711 state: &UiState,
4712 tenant: &str,
4713 _runtime_base: Option<&str>,
4714 config: &mut JsonMap<String, Value>,
4715) {
4716 setup_backend_refresh_ephemeral_public_base_url(state, tenant, config);
4717 if setup_backend_config_str(config, "public_base_url").is_empty()
4718 && let Some(public_base_url) = setup_backend_public_base_url(state, tenant)
4719 {
4720 config.insert(
4721 "public_base_url".to_string(),
4722 Value::String(public_base_url),
4723 );
4724 }
4725}
4726
4727async fn setup_backend_prepare_runtime_public_base_for_observation(
4728 state: &UiState,
4729 tenant: &str,
4730 config: &mut JsonMap<String, Value>,
4731) -> Result<Option<String>> {
4732 if setup_backend_bundle_runtime_startable(state) {
4733 ensure_setup_runtime(state, tenant).await?;
4734 }
4735 setup_backend_adopt_runtime_public_base_url(state, tenant, config)
4736}
4737
4738fn setup_backend_bundle_runtime_startable(state: &UiState) -> bool {
4739 state.bundle_path.is_dir()
4740 && (state.bundle_path.join("bundle.yaml").is_file()
4741 || state.bundle_path.join("greentic.yaml").is_file()
4742 || state.bundle_path.join("bundle.json").is_file())
4743}
4744
4745fn setup_backend_adopt_runtime_public_base_url(
4746 state: &UiState,
4747 tenant: &str,
4748 config: &mut JsonMap<String, Value>,
4749) -> Result<Option<String>> {
4750 let Some((ingress_public_base_url, _source)) =
4755 setup_backend_resolve_ingress_public_base(state, tenant)
4756 else {
4757 return Ok(None);
4758 };
4759 let current = setup_backend_config_str(config, "public_base_url");
4760 if current.trim_end_matches('/') == ingress_public_base_url {
4761 return Ok(None);
4762 }
4763 if current.is_empty() || is_ephemeral_tunnel_public_base_url(¤t) {
4764 config.insert(
4765 "public_base_url".to_string(),
4766 Value::String(ingress_public_base_url.clone()),
4767 );
4768 return Ok(Some(ingress_public_base_url));
4769 }
4770 Ok(None)
4771}
4772
4773fn setup_backend_public_base_url_needs_runtime(
4774 state: &UiState,
4775 _tenant: &str,
4776 config: &JsonMap<String, Value>,
4777) -> bool {
4778 if !setup_backend_bundle_runtime_startable(state) {
4779 return false;
4780 }
4781 let current = setup_backend_config_str(config, "public_base_url");
4782 current.is_empty()
4783 || is_ephemeral_tunnel_public_base_url(¤t)
4784 || !setup_backend_public_base_url_is_external_https(¤t)
4785}
4786
4787fn setup_backend_refresh_ephemeral_public_base_url(
4788 state: &UiState,
4789 tenant: &str,
4790 config: &mut JsonMap<String, Value>,
4791) {
4792 let current = setup_backend_config_str(config, "public_base_url");
4793 if current.is_empty() || !is_ephemeral_tunnel_public_base_url(¤t) {
4794 return;
4795 }
4796 let Some(active) = setup_backend_runtime_public_base_url(state, tenant)
4797 .or_else(|| setup_backend_active_tunnel_public_base_url(state))
4798 else {
4799 return;
4800 };
4801 if active != current.trim_end_matches('/') {
4802 config.insert("public_base_url".to_string(), Value::String(active));
4803 }
4804}
4805
4806async fn setup_backend_refresh_public_tunnel_if_needed(
4807 state: &UiState,
4808 tenant: &str,
4809 config: &mut JsonMap<String, Value>,
4810) -> Result<Option<String>> {
4811 let current = setup_backend_config_str(config, "public_base_url");
4812 let Some(mode) = setup_backend_tunnel_mode(state)?
4813 .or_else(|| setup_backend_infer_tunnel_mode_from_public_base_url(¤t))
4814 else {
4815 return Ok(None);
4816 };
4817 if !matches!(mode.as_str(), "cloudflared" | "ngrok") {
4818 return Ok(None);
4819 }
4820 if !current.is_empty()
4821 && !is_ephemeral_tunnel_public_base_url(¤t)
4822 && setup_backend_public_base_url_is_external_https(¤t)
4823 {
4824 return Ok(None);
4825 }
4826 if let Some(runtime_public_base_url) = setup_backend_runtime_public_base_url(state, tenant) {
4827 if !setup_backend_public_base_url_is_external_https(&runtime_public_base_url)
4828 && !is_ephemeral_tunnel_public_base_url(&runtime_public_base_url)
4829 {
4830 } else if is_ephemeral_tunnel_public_base_url(&runtime_public_base_url)
4834 && !setup_backend_public_tunnel_responds(&runtime_public_base_url).await
4835 {
4836 setup_backend_clear_setup_tunnel(state);
4837 } else {
4838 if current.trim_end_matches('/') == runtime_public_base_url {
4839 return Ok(None);
4840 }
4841 config.insert(
4842 "public_base_url".to_string(),
4843 Value::String(runtime_public_base_url.clone()),
4844 );
4845 return Ok(Some(runtime_public_base_url));
4846 }
4847 }
4848 let local_base_url = setup_backend_tunnel_local_base_url(state, tenant)?;
4849 let public_base_url = ensure_setup_tunnel(state, &mode, &local_base_url).await?;
4850 config.insert(
4851 "public_base_url".to_string(),
4852 Value::String(public_base_url.clone()),
4853 );
4854 Ok(Some(public_base_url))
4855}
4856
4857fn setup_backend_public_base_url_is_external_https(public_base_url: &str) -> bool {
4858 let Ok(url) = url::Url::parse(public_base_url.trim()) else {
4859 return false;
4860 };
4861 if url.scheme() != "https" {
4862 return false;
4863 }
4864 let Some(host) = url.host_str() else {
4865 return false;
4866 };
4867 let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
4868 !matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1")
4869}
4870
4871fn setup_backend_infer_tunnel_mode_from_public_base_url(public_base_url: &str) -> Option<String> {
4872 let url = url::Url::parse(public_base_url).ok()?;
4873 let host = url.host_str()?.to_ascii_lowercase();
4874 if host == "trycloudflare.com" || host.ends_with(".trycloudflare.com") {
4875 return Some("cloudflared".to_string());
4876 }
4877 if host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io") {
4878 return Some("ngrok".to_string());
4879 }
4880 None
4881}
4882
4883fn setup_backend_tunnel_mode(state: &UiState) -> Result<Option<String>> {
4884 if let Some(mode) = crate::platform_setup::load_tunnel_artifact(&state.bundle_path)?
4885 .and_then(|answers| answers.mode)
4886 .map(|mode| mode.trim().to_string())
4887 .filter(|mode| !mode.is_empty())
4888 {
4889 return Ok(Some(mode));
4890 }
4891
4892 setup_backend_default_tunnel_mode(state)
4893}
4894
4895fn setup_backend_default_tunnel_mode(state: &UiState) -> Result<Option<String>> {
4896 if prefill_has_cloud_deployment_targets(state.prefill_answers.as_ref()) {
4897 return Ok(Some("off".to_string()));
4898 }
4899
4900 let deployment_targets =
4901 crate::deployment_targets::reconcile_deployment_targets(&state.bundle_path, &[])
4902 .with_context(|| {
4903 format!(
4904 "resolve deployment targets for {}",
4905 state.bundle_path.display()
4906 )
4907 })?;
4908 if deployment_targets
4909 .iter()
4910 .any(|record| matches!(record.target.as_str(), "aws" | "gcp" | "azure"))
4911 {
4912 return Ok(Some("off".to_string()));
4913 }
4914
4915 let deployer_candidates =
4916 crate::deployment_targets::discover_deployer_pack_candidates(&state.bundle_path)
4917 .with_context(|| {
4918 format!(
4919 "discover deployer packs for {}",
4920 state.bundle_path.display()
4921 )
4922 })?;
4923 if deployer_candidates.is_empty() {
4924 return Ok(Some("cloudflared".to_string()));
4925 }
4926
4927 Ok(None)
4928}
4929
4930fn setup_backend_tunnel_local_base_url(state: &UiState, tenant: &str) -> Result<String> {
4931 configured_runtime_proxy_base_url()
4932 .or_else(|| setup_backend_runtime_local_base_url(state, tenant))
4933 .or_else(|| Some(state.local_base_url.trim_end_matches('/').to_string()))
4934 .filter(|value| !value.is_empty())
4935 .ok_or_else(|| anyhow!("no local base URL available for setup tunnel"))
4936}
4937
4938fn injected_setup_public_base_url() -> Option<String> {
4944 std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
4945 .ok()
4946 .or_else(|| std::env::var("GREENTIC_PUBLIC_BASE_URL").ok())
4947 .or_else(|| std::env::var("PUBLIC_BASE_URL").ok())
4951 .map(|value| value.trim().trim_end_matches('/').to_string())
4952 .filter(|value| value.starts_with("https://"))
4953}
4954
4955fn setup_oauth_callback_base_url() -> Option<String> {
4962 std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
4963 .ok()
4964 .map(|value| value.trim().trim_end_matches('/').to_string())
4965 .filter(|value| value.starts_with("https://"))
4966}
4967
4968fn setup_oauth_callback_base(state: &UiState) -> Option<String> {
4979 injected_setup_public_base_url()
4980 .or_else(|| setup_backend_active_tunnel_public_base_url(state))
4981 .map(|value| value.trim_end_matches('/').to_string())
4982 .filter(|value| value.starts_with("https://"))
4983}
4984
4985fn setup_backend_public_base_url(state: &UiState, tenant: &str) -> Option<String> {
4986 if let Some(value) = std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
4987 .ok()
4988 .or_else(|| std::env::var("GREENTIC_PUBLIC_BASE_URL").ok())
4989 .or_else(|| std::env::var("PUBLIC_BASE_URL").ok())
4992 .map(|value| value.trim().trim_end_matches('/').to_string())
4993 .filter(|value| !value.is_empty())
4994 {
4995 return Some(value);
4996 }
4997 if let Some(value) = setup_backend_runtime_public_base_url(state, tenant) {
4998 return Some(value);
4999 }
5000 if let Some(value) = setup_backend_active_tunnel_public_base_url(state) {
5001 return Some(value);
5002 }
5003 crate::platform_setup::load_effective_static_routes_defaults(
5004 &state.bundle_path,
5005 tenant,
5006 state.team.as_deref(),
5007 )
5008 .ok()
5009 .flatten()
5010 .and_then(|policy| policy.public_base_url)
5011 .map(|value| value.trim().trim_end_matches('/').to_string())
5012 .filter(|value| !value.is_empty())
5013 .or_else(configured_runtime_proxy_base_url)
5014}
5015
5016fn setup_backend_active_tunnel_public_base_url(state: &UiState) -> Option<String> {
5017 let mut guard = state.setup_tunnel.lock().ok()?;
5018 let tunnel = guard.as_mut()?;
5019 if !tunnel.is_running() {
5020 *guard = None;
5021 return None;
5022 }
5023 Some(tunnel.public_base_url.trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
5024}
5025
5026fn setup_backend_tunnel_public_base_url_for_port(state: &UiState, port: u16) -> Option<String> {
5039 setup_backend_tunnel_public_base_url_for_port_at(state, port, None)
5040}
5041
5042fn setup_backend_tunnel_public_base_url_for_port_at(
5047 state: &UiState,
5048 port: u16,
5049 record_root: Option<&Path>,
5050) -> Option<String> {
5051 if let Ok(mut guard) = state.setup_tunnel.lock()
5052 && let Some(tunnel) = guard.as_mut()
5053 && crate::shared_tunnel::local_port_from_base_url(&tunnel.local_base_url) == Some(port)
5054 && tunnel.is_running()
5055 {
5056 return Some(tunnel.public_base_url.trim_end_matches('/').to_string())
5057 .filter(|value| !value.is_empty());
5058 }
5059 let paths = match record_root {
5060 Some(root) => crate::shared_tunnel::shared_tunnel_paths_at(root, port),
5061 None => crate::shared_tunnel::shared_tunnel_paths(port),
5062 };
5063 let (_pid, url) = crate::shared_tunnel::read_record(&paths);
5064 url
5065}
5066
5067fn setup_backend_runtime_public_base_url(state: &UiState, tenant: &str) -> Option<String> {
5068 if let Some(value) = setup_backend_setup_runtime_info(state)
5069 .and_then(|info| info.public_base_url)
5070 .map(|value| value.trim().trim_end_matches('/').to_string())
5071 .filter(|value| !value.is_empty())
5072 {
5073 return Some(value);
5074 }
5075 crate::platform_setup::load_runtime_public_base_url(
5076 &state.bundle_path,
5077 tenant,
5078 state.team.as_deref(),
5079 )
5080 .ok()
5081 .flatten()
5082 .map(|value| value.trim().trim_end_matches('/').to_string())
5083 .filter(|value| !value.is_empty())
5084}
5085
5086fn setup_backend_runtime_local_base_url(state: &UiState, tenant: &str) -> Option<String> {
5087 if let Some(value) = setup_backend_setup_runtime_info(state)
5088 .and_then(|info| info.local_base_url)
5089 .map(|value| value.trim().trim_end_matches('/').to_string())
5090 .filter(|value| !value.is_empty())
5091 {
5092 return Some(value);
5093 }
5094 crate::platform_setup::load_runtime_local_base_url(
5095 &state.bundle_path,
5096 tenant,
5097 state.team.as_deref(),
5098 )
5099 .ok()
5100 .flatten()
5101 .map(|value| value.trim().trim_end_matches('/').to_string())
5102 .filter(|value| !value.is_empty())
5103}
5104
5105fn setup_backend_setup_runtime_info(state: &UiState) -> Option<SetupRuntimeInfo> {
5106 let guard = state.setup_runtime.lock().ok()?;
5107 let runtime = guard.as_ref()?;
5108 runtime.info.lock().ok().map(|info| info.clone())
5109}
5110
5111fn setup_backend_resolve_ingress_public_base(
5123 state: &UiState,
5124 tenant: &str,
5125) -> Option<(String, &'static str)> {
5126 let ingress_port = setup_backend_runtime_local_base_url(state, tenant)
5127 .as_deref()
5128 .and_then(crate::shared_tunnel::local_port_from_base_url);
5129 if let Some(port) = ingress_port
5130 && let Some(url) = setup_backend_tunnel_public_base_url_for_port(state, port)
5131 {
5132 return Some((url, "ingress-port tunnel record"));
5133 }
5134 setup_backend_runtime_public_base_url(state, tenant)
5135 .filter(|value| {
5136 setup_backend_public_base_url_is_external_https(value)
5137 || is_ephemeral_tunnel_public_base_url(value)
5138 })
5139 .map(|url| (url, "runtime endpoints"))
5140}
5141
5142fn setup_backend_runtime_context(
5143 state: &UiState,
5144 tenant: &str,
5145 config: &JsonMap<String, Value>,
5146) -> Value {
5147 let public_base_url = setup_backend_config_str(config, "public_base_url")
5148 .trim_end_matches('/')
5149 .to_string();
5150 let runtime_local_base_url = setup_backend_runtime_local_base_url(state, tenant);
5151 let ingress_port = runtime_local_base_url
5152 .as_deref()
5153 .and_then(crate::shared_tunnel::local_port_from_base_url);
5154 let resolved = setup_backend_resolve_ingress_public_base(state, tenant);
5155 let active_tunnel_public_base_url = resolved.as_ref().map(|(url, _)| url.clone());
5156 if !public_base_url.is_empty()
5161 && is_ephemeral_tunnel_public_base_url(&public_base_url)
5162 && let Some((active, source)) = resolved.as_ref()
5163 && active != &public_base_url
5164 {
5165 eprintln!(
5166 "[setup runtime-context] ingress tunnel mismatch: config public_base_url={public_base_url} \
5167 vs active tunnel={active} (source: {source}, ingress_port={ingress_port:?}, \
5168 runtime_local_base_url={runtime_local_base_url:?}) — dependent steps will be treated as stale"
5169 );
5170 }
5171 serde_json::json!({
5172 "public_base_url": if public_base_url.is_empty() { Value::Null } else { Value::String(public_base_url.clone()) },
5173 "public_base_url_is_ephemeral_tunnel": !public_base_url.is_empty() && is_ephemeral_tunnel_public_base_url(&public_base_url),
5174 "active_tunnel_public_base_url": active_tunnel_public_base_url,
5175 "runtime_local_base_url": runtime_local_base_url,
5176 })
5177}
5178
5179async fn setup_backend_runtime_context_blocked(
5180 state: &UiState,
5181 runtime_context: &Value,
5182) -> Option<Value> {
5183 let public_base_url = runtime_context
5184 .get("public_base_url")
5185 .and_then(Value::as_str)
5186 .unwrap_or_default();
5187 let active_tunnel_public_base_url = runtime_context
5188 .get("active_tunnel_public_base_url")
5189 .and_then(Value::as_str);
5190 if runtime_context
5191 .get("public_base_url_is_ephemeral_tunnel")
5192 .and_then(Value::as_bool)
5193 .unwrap_or(false)
5194 && active_tunnel_public_base_url != Some(public_base_url)
5195 {
5196 return Some(serde_json::json!({
5197 "ok": false,
5198 "blocked": true,
5199 "error": "runtime/tunnel not running",
5200 "reason": "public_base_url came from an ephemeral setup tunnel, but that tunnel is not active for this setup session",
5201 "public_base_url": public_base_url,
5202 "active_tunnel_public_base_url": active_tunnel_public_base_url,
5203 "runtime_context": runtime_context,
5204 }));
5205 }
5206 if let Some(runtime_local_base_url) = runtime_context
5207 .get("runtime_local_base_url")
5208 .and_then(Value::as_str)
5209 && !setup_backend_runtime_base_responds(runtime_local_base_url).await
5210 {
5211 return Some(serde_json::json!({
5212 "ok": false,
5213 "blocked": true,
5214 "error": "runtime/tunnel not running",
5215 "reason": "runtime endpoint artifact exists, but the local runtime is not responding",
5216 "runtime_local_base_url": runtime_local_base_url,
5217 "setup_ui_url": state.local_base_url,
5218 "runtime_context": runtime_context,
5219 }));
5220 }
5221 None
5222}
5223
5224async fn setup_backend_runtime_base_responds(base_url: &str) -> bool {
5225 let Ok(client) = reqwest::Client::builder()
5226 .timeout(Duration::from_millis(750))
5227 .build()
5228 else {
5229 return false;
5230 };
5231 client.get(base_url).send().await.is_ok()
5232}
5233
5234async fn setup_backend_public_tunnel_responds(base_url: &str) -> bool {
5235 let Ok(client) = reqwest::Client::builder()
5236 .timeout(Duration::from_secs(3))
5237 .build()
5238 else {
5239 return false;
5240 };
5241 if client
5242 .get(base_url.trim_end_matches('/'))
5243 .send()
5244 .await
5245 .ok()
5246 .is_some_and(|response| response.status().as_u16() < 500)
5247 {
5248 return true;
5249 }
5250 tunnel_responds_with_pinned_dns(base_url).await
5261}
5262
5263async fn tunnel_responds_with_pinned_dns(base_url: &str) -> bool {
5269 let Ok(parsed) = url::Url::parse(base_url) else {
5270 return false;
5271 };
5272 let Some(host) = parsed.host_str().map(str::to_string) else {
5273 return false;
5274 };
5275 let port = parsed.port_or_known_default().unwrap_or(443);
5276 let Some(ip) = resolve_host_via_public_doh(&host).await else {
5277 return false;
5278 };
5279 let Ok(client) = reqwest::Client::builder()
5280 .timeout(Duration::from_secs(3))
5281 .resolve(&host, std::net::SocketAddr::new(ip, port))
5282 .build()
5283 else {
5284 return false;
5285 };
5286 let alive = client
5287 .get(base_url.trim_end_matches('/'))
5288 .send()
5289 .await
5290 .ok()
5291 .is_some_and(|response| response.status().as_u16() < 500);
5292 if alive {
5293 eprintln!(
5294 "Setup tunnel: {base_url} IS reachable via public DNS ({ip}) — the local \
5295 system resolver has a stale negative cache for this hostname (it will \
5296 self-heal when the negative TTL expires); treating the tunnel as healthy \
5297 since remote services resolve it via their own DNS"
5298 );
5299 }
5300 alive
5301}
5302
5303async fn resolve_host_via_public_doh(host: &str) -> Option<std::net::IpAddr> {
5307 let client = reqwest::Client::builder()
5308 .timeout(Duration::from_secs(3))
5309 .build()
5310 .ok()?;
5311 let response = client
5312 .get(format!("https://1.1.1.1/dns-query?name={host}&type=A"))
5313 .header("accept", "application/dns-json")
5314 .send()
5315 .await
5316 .ok()?;
5317 let body: Value = response.json().await.ok()?;
5318 body.get("Answer")?
5319 .as_array()?
5320 .iter()
5321 .filter(|answer| answer.get("type").and_then(Value::as_u64) == Some(1))
5323 .find_map(|answer| answer.get("data")?.as_str()?.parse().ok())
5324}
5325
5326fn setup_backend_runtime_context_current(value: &Value, current: &Value) -> bool {
5327 if value.is_null() {
5328 return false;
5329 }
5330 let current_public_base_url = current.get("public_base_url").and_then(Value::as_str);
5331 let active_tunnel = current
5332 .get("active_tunnel_public_base_url")
5333 .and_then(Value::as_str);
5334 if current
5342 .get("public_base_url_is_ephemeral_tunnel")
5343 .and_then(Value::as_bool)
5344 .unwrap_or(false)
5345 && active_tunnel.is_some()
5346 && active_tunnel != current_public_base_url
5347 {
5348 return false;
5349 }
5350 let Some(previous) = value.get("runtime_context") else {
5351 return !current
5352 .get("public_base_url_is_ephemeral_tunnel")
5353 .and_then(Value::as_bool)
5354 .unwrap_or(false);
5355 };
5356 previous.get("public_base_url").and_then(Value::as_str) == current_public_base_url
5357}
5358
5359fn is_ephemeral_tunnel_public_base_url(value: &str) -> bool {
5360 is_ephemeral_tunnel_url(value)
5361}
5362
5363fn setup_backend_template_unresolved(value: &str) -> bool {
5364 value.contains('{') && value.contains('}')
5365}
5366
5367fn render_setup_backend_contract_state(
5368 state: &UiState,
5369 contract: &ProviderBackendContract,
5370 tenant: &str,
5371 stored: JsonMap<String, Value>,
5372) -> Value {
5373 let config = stored
5374 .get("config")
5375 .and_then(Value::as_object)
5376 .cloned()
5377 .unwrap_or_else(|| default_setup_backend_config(state, tenant));
5378 let required_steps = setup_backend_required_steps(contract);
5379 let contract_blocked = setup_backend_contract_blocked(contract, &required_steps);
5380 let setup_result = stored
5381 .get("last_setup_result")
5382 .cloned()
5383 .unwrap_or(Value::Null);
5384 let raw_values = setup_backend_render_values(&config, &stored, setup_result.clone());
5385 let teams_app = setup_backend_render_teams_app(&stored);
5386 let items = if contract_blocked.is_some() {
5387 Vec::new()
5388 } else {
5389 setup_backend_contract_items(state, contract, tenant, &stored, &raw_values)
5390 };
5391 let values = setup_backend_filter_stale_action_values(
5392 contract,
5393 &items,
5394 raw_values,
5395 setup_result.clone(),
5396 );
5397 let reset = setup_backend_values_contain_stale_marker(&values);
5398 let ok = items
5399 .iter()
5400 .all(|item| item.get("state").and_then(Value::as_str) == Some("done"));
5401 let ok = ok && !items.is_empty() && contract_blocked.is_none();
5402 let next = if ok {
5403 "Setup complete.".to_string()
5404 } else if let Some(blocked) = contract_blocked.as_ref() {
5405 blocked
5406 .get("summary")
5407 .and_then(Value::as_str)
5408 .unwrap_or("Setup backend contract is blocked.")
5409 .to_string()
5410 } else if setup_backend_pending_device_login(&setup_result) {
5411 setup_backend_device_login_next_message().to_string()
5412 } else if setup_result
5413 .get("ok")
5414 .and_then(Value::as_bool)
5415 .unwrap_or(false)
5416 {
5417 "Continue setup to run the next step.".to_string()
5418 } else if setup_result
5419 .get("next")
5420 .and_then(Value::as_str)
5421 .is_some_and(|next| !next.trim().is_empty())
5422 {
5423 setup_result
5424 .get("next")
5425 .and_then(Value::as_str)
5426 .unwrap_or_default()
5427 .to_string()
5428 } else {
5429 "Click Run next setup step.".to_string()
5430 };
5431 let route_ok = contract_blocked.is_none();
5432 let blocked = contract_blocked
5433 .or_else(|| stored.get("blocked").cloned())
5434 .or_else(|| setup_backend_blocked_from_result(&setup_result));
5435 let mut report = serde_json::json!({
5436 "ok": route_ok,
5437 "values": values,
5438 "teams_app": teams_app,
5439 "setup_status": {
5440 "ok": ok,
5441 "items": items,
5442 "selected": {
5443 "provider_id": contract.provider_id,
5444 "tenant": tenant,
5445 "team": state.team.clone().unwrap_or_else(|| "default".to_string()),
5446 "env": state.env,
5447 },
5448 "blocked": blocked,
5449 "last_step": if ok { Value::String("complete".to_string()) } else { setup_result.get("step").cloned().unwrap_or(Value::Null) },
5450 "next": next,
5451 "reset": reset,
5452 },
5453 });
5454 attach_final_setup_actions(state, &contract.provider_id, &mut report);
5455 report
5456}
5457
5458fn attach_final_setup_actions(state: &UiState, provider_id: &str, report: &mut Value) {
5459 let Ok(Some(descriptor)) = find_setup_actions_descriptor(&state.bundle_path, provider_id)
5460 else {
5461 return;
5462 };
5463 let resolved =
5464 crate::setup_final_actions::resolve_final_setup_actions(provider_id, &descriptor, report);
5465 if let Some(obj) = report.as_object_mut() {
5466 obj.insert(
5467 "final_setup_actions".to_string(),
5468 serde_json::to_value(resolved.actions).unwrap_or_else(|_| Value::Array(Vec::new())),
5469 );
5470 if !resolved.diagnostics.is_empty() {
5471 obj.insert(
5472 "final_setup_action_diagnostics".to_string(),
5473 serde_json::to_value(resolved.diagnostics)
5474 .unwrap_or_else(|_| Value::Array(Vec::new())),
5475 );
5476 }
5477 }
5478}
5479
5480fn setup_backend_blocked_from_result(setup_result: &Value) -> Option<Value> {
5481 crate::setup_backend_contract::blocked_from_result(setup_result)
5482}
5483
5484fn setup_backend_pending_device_login(setup_result: &Value) -> bool {
5485 setup_result
5486 .get("result")
5487 .and_then(|result| result.get("pending_device_login"))
5488 .and_then(Value::as_bool)
5489 .unwrap_or(false)
5490 || setup_result
5491 .get("pending_device_login")
5492 .and_then(Value::as_bool)
5493 .unwrap_or(false)
5494}
5495
5496fn setup_backend_device_login_next_message() -> &'static str {
5497 "Open the sign-in page, enter the code, then continue setup."
5498}
5499
5500fn setup_backend_contract_items(
5501 state: &UiState,
5502 contract: &ProviderBackendContract,
5503 tenant: &str,
5504 stored: &JsonMap<String, Value>,
5505 values: &Value,
5506) -> Vec<Value> {
5507 let completed = stored
5508 .get("completed_steps")
5509 .and_then(Value::as_array)
5510 .cloned()
5511 .unwrap_or_default();
5512 let completed: std::collections::HashSet<&str> =
5513 completed.iter().filter_map(Value::as_str).collect();
5514 let mut previous_pending = false;
5515 setup_backend_required_steps(contract)
5516 .into_iter()
5517 .map(|step| {
5518 let action = setup_backend_action_by_id(contract, step);
5519 let cached_done = completed.contains(step)
5520 && action.is_none_or(|action| {
5521 setup_backend_cached_completion_current(state, tenant, stored, values, action)
5522 });
5523 let independently_done = cached_done
5524 || action.is_some_and(|action| {
5525 setup_backend_action_completion_current(state, tenant, stored, values, action)
5526 });
5527 let durable_done_after_pending = independently_done
5528 && action.is_some_and(setup_backend_action_is_runtime_context_durable);
5529 let done = independently_done && (!previous_pending || durable_done_after_pending);
5530 if !done {
5531 previous_pending = true;
5532 }
5533 serde_json::json!({
5534 "label": step.replace('_', " "),
5535 "state": if done { "done" } else { "pending" },
5536 "detail": Value::Null,
5537 "id": step,
5538 })
5539 })
5540 .collect()
5541}
5542
5543fn setup_backend_render_values(
5544 config: &JsonMap<String, Value>,
5545 stored: &JsonMap<String, Value>,
5546 setup_result: Value,
5547) -> Value {
5548 let mut values = JsonMap::new();
5549 values.insert(
5550 "config".to_string(),
5551 Value::Object(setup_backend_public_config(config)),
5552 );
5553 values.insert("last_setup_result".to_string(), setup_result);
5554 values.insert(
5555 "backend".to_string(),
5556 stored.get("backend").cloned().unwrap_or(Value::Null),
5557 );
5558 for (key, value) in stored {
5559 if matches!(
5560 key.as_str(),
5561 "config" | "completed_steps" | "blocked" | "last_setup_result" | "teams_app"
5562 ) {
5563 continue;
5564 }
5565 values.insert(key.clone(), value.clone());
5566 }
5567 Value::Object(values)
5568}
5569
5570fn setup_backend_filter_stale_action_values(
5571 contract: &ProviderBackendContract,
5572 items: &[Value],
5573 values: Value,
5574 setup_result: Value,
5575) -> Value {
5576 let mut values = values;
5577 let Some(values_obj) = values.as_object_mut() else {
5578 return values;
5579 };
5580 let item_states: std::collections::HashMap<&str, &str> = items
5581 .iter()
5582 .filter_map(|item| {
5583 Some((
5584 item.get("id")?.as_str()?,
5585 item.get("state")?.as_str().unwrap_or("pending"),
5586 ))
5587 })
5588 .collect();
5589 let setup_step = setup_result.get("step").and_then(Value::as_str);
5590 for action in contract
5591 .inline
5592 .get("actions")
5593 .and_then(Value::as_array)
5594 .into_iter()
5595 .flatten()
5596 {
5597 let Some(step_id) = action.get("id").and_then(Value::as_str) else {
5598 continue;
5599 };
5600 if item_states.get(step_id) == Some(&"done") {
5601 continue;
5602 }
5603 let current_action_is_done =
5604 setup_step == Some(step_id) && item_states.get(step_id) == Some(&"done");
5605 if action
5606 .get("executor")
5607 .and_then(|executor| executor.get("kind"))
5608 .and_then(Value::as_str)
5609 == Some("oauth_device_code")
5610 {
5611 setup_backend_mark_oauth_action_value_stale(values_obj, action, step_id);
5612 }
5613 if setup_backend_action_is_runtime_context_durable(action) {
5614 continue;
5615 }
5616 let Some(state_key) = action
5617 .get("executor")
5618 .and_then(|executor| executor.get("state_store_key"))
5619 .and_then(Value::as_str)
5620 else {
5621 continue;
5622 };
5623 if current_action_is_done {
5624 continue;
5625 }
5626 if let Some(value) = values_obj.get_mut(state_key) {
5627 setup_backend_mark_action_value_stale(value, step_id);
5628 }
5629 }
5630 values
5631}
5632
5633fn setup_backend_mark_action_value_stale(value: &mut Value, step_id: &str) {
5634 if let Some(object) = value.as_object_mut() {
5635 if object.get("stale").and_then(Value::as_bool) != Some(true) {
5638 eprintln!(
5639 "[setup] marking stored result for step {step_id} as stale (step is not \
5640 done under the current runtime context); its completion will not count \
5641 until the step re-runs"
5642 );
5643 }
5644 object.insert("ok".to_string(), Value::Bool(false));
5645 object.insert("stale".to_string(), Value::Bool(true));
5646 object.insert("stale_step".to_string(), Value::String(step_id.to_string()));
5647 }
5648}
5649
5650fn setup_backend_mark_oauth_action_value_stale(
5651 values: &mut JsonMap<String, Value>,
5652 action: &Value,
5653 step_id: &str,
5654) {
5655 let oauth_kind = action
5656 .get("executor")
5657 .and_then(|executor| executor.get("oauth_kind"))
5658 .and_then(Value::as_str)
5659 .unwrap_or("default");
5660 let Some(oauth) = values.get_mut("oauth").and_then(Value::as_object_mut) else {
5661 return;
5662 };
5663 if let Some(value) = oauth.get_mut(oauth_kind) {
5664 setup_backend_mark_action_value_stale(value, step_id);
5665 }
5666}
5667
5668fn setup_backend_values_contain_stale_marker(value: &Value) -> bool {
5669 match value {
5670 Value::Object(object) => {
5671 object
5672 .get("stale")
5673 .and_then(Value::as_bool)
5674 .unwrap_or(false)
5675 || object
5676 .values()
5677 .any(setup_backend_values_contain_stale_marker)
5678 }
5679 Value::Array(items) => items.iter().any(setup_backend_values_contain_stale_marker),
5680 _ => false,
5681 }
5682}
5683
5684fn setup_backend_public_config(config: &JsonMap<String, Value>) -> JsonMap<String, Value> {
5685 let mut public = config.clone();
5686 for key in [
5687 "oauth_device_code",
5688 "graph_access_token",
5689 "azure_management_access_token",
5690 "bot_access_token",
5691 "access_token",
5692 "refresh_token",
5693 "id_token",
5694 ] {
5695 public.remove(key);
5696 }
5697 public
5698}
5699
5700fn setup_backend_render_teams_app(stored: &JsonMap<String, Value>) -> Value {
5701 if let Some(value) = stored.get("teams_app") {
5702 return value.clone();
5703 }
5704 let publish = stored
5705 .get("last_teams_app_publish")
5706 .and_then(Value::as_object);
5707 let install = stored
5708 .get("last_teams_app_install")
5709 .and_then(Value::as_object);
5710 let add_to_teams_url = install
5711 .and_then(|value| value.get("add_to_teams_url"))
5712 .or_else(|| {
5713 install.and_then(|value| {
5714 value
5715 .get("response")
5716 .and_then(|response| response.get("add_to_teams_url"))
5717 })
5718 })
5719 .or_else(|| publish.and_then(|value| value.get("add_to_teams_url")))
5720 .or_else(|| {
5721 publish.and_then(|value| {
5722 value
5723 .get("response")
5724 .and_then(|response| response.get("add_to_teams_url"))
5725 })
5726 })
5727 .cloned()
5728 .unwrap_or(Value::Null);
5729 let open_bot_chat_url = install
5730 .and_then(|value| value.get("open_bot_chat_url"))
5731 .or_else(|| {
5732 install.and_then(|value| {
5733 value
5734 .get("response")
5735 .and_then(|response| response.get("open_bot_chat_url"))
5736 })
5737 })
5738 .cloned()
5739 .unwrap_or(Value::Null);
5740 serde_json::json!({
5741 "ok": !add_to_teams_url.is_null() || !open_bot_chat_url.is_null(),
5742 "add_to_teams_url": add_to_teams_url,
5743 "open_bot_chat_url": open_bot_chat_url,
5744 })
5745}
5746
5747fn setup_backend_completion_met(values: &Value, completion: &Value) -> bool {
5748 crate::setup_backend_contract::completion_met(values, completion)
5749}
5750
5751fn setup_backend_action_completion_current(
5752 state: &UiState,
5753 tenant: &str,
5754 stored: &JsonMap<String, Value>,
5755 values: &Value,
5756 action: &Value,
5757) -> bool {
5758 let Some(completion) = action.get("completion") else {
5759 return false;
5760 };
5761 if !setup_backend_completion_met(values, completion) {
5762 return false;
5763 }
5764 let Some(state_key) = action
5765 .get("executor")
5766 .and_then(|executor| executor.get("state_store_key"))
5767 .and_then(Value::as_str)
5768 else {
5769 return true;
5770 };
5771 let Some(stored_value) = stored.get(state_key) else {
5772 return true;
5773 };
5774 if setup_backend_last_result_matches_action(stored, action)
5775 && setup_backend_action_result_runtime_context_current(state, tenant, stored, action)
5776 {
5777 return true;
5778 }
5779 if setup_backend_action_is_runtime_context_durable(action) {
5780 return true;
5781 }
5782 let config = stored
5783 .get("config")
5784 .and_then(Value::as_object)
5785 .cloned()
5786 .unwrap_or_default();
5787 let current = setup_backend_runtime_context(state, tenant, &config);
5788 setup_backend_runtime_context_current(stored_value, ¤t)
5789}
5790
5791fn setup_backend_cached_completion_current(
5792 state: &UiState,
5793 tenant: &str,
5794 stored: &JsonMap<String, Value>,
5795 values: &Value,
5796 action: &Value,
5797) -> bool {
5798 let Some(state_key) = action
5799 .get("executor")
5800 .and_then(|executor| executor.get("state_store_key"))
5801 .and_then(Value::as_str)
5802 else {
5803 return true;
5804 };
5805 let Some(stored_value) = stored.get(state_key) else {
5806 return true;
5807 };
5808 if setup_backend_action_is_runtime_context_durable(action) {
5809 return action
5810 .get("completion")
5811 .is_none_or(|completion| setup_backend_completion_met(values, completion));
5812 }
5813 let config = stored
5814 .get("config")
5815 .and_then(Value::as_object)
5816 .cloned()
5817 .unwrap_or_default();
5818 let current = setup_backend_runtime_context(state, tenant, &config);
5819 if !setup_backend_runtime_context_current(stored_value, ¤t) {
5820 return false;
5821 }
5822 action
5823 .get("completion")
5824 .is_none_or(|completion| setup_backend_completion_met(values, completion))
5825}
5826
5827fn setup_backend_action_is_runtime_context_durable(action: &Value) -> bool {
5828 let action_id = action.get("id").and_then(Value::as_str);
5829 let state_key = action
5830 .get("executor")
5831 .and_then(|executor| executor.get("state_store_key"))
5832 .and_then(Value::as_str);
5833 matches!(
5834 action_id,
5835 Some("teams_app_publish" | "teams_app_user_install")
5836 ) || matches!(
5837 state_key,
5838 Some("last_teams_app_publish" | "last_teams_app_install")
5839 )
5840}
5841
5842fn setup_backend_last_result_matches_action(
5843 stored: &JsonMap<String, Value>,
5844 action: &Value,
5845) -> bool {
5846 let Some(action_id) = action.get("id").and_then(Value::as_str) else {
5847 return false;
5848 };
5849 let Some(result) = stored.get("last_setup_result").and_then(Value::as_object) else {
5850 return false;
5851 };
5852 result.get("step").and_then(Value::as_str) == Some(action_id)
5853 && result.get("ok").and_then(Value::as_bool).unwrap_or(false)
5854}
5855
5856fn setup_backend_action_result_runtime_context_current(
5857 state: &UiState,
5858 tenant: &str,
5859 stored: &JsonMap<String, Value>,
5860 action: &Value,
5861) -> bool {
5862 if setup_backend_action_is_runtime_context_durable(action) {
5863 return true;
5864 }
5865 let Some(result) = stored.get("last_setup_result").and_then(Value::as_object) else {
5866 return true;
5867 };
5868 let Some(result_body) = result.get("result") else {
5869 return true;
5870 };
5871 let Some(result_context) = result_body.get("runtime_context") else {
5872 return true;
5873 };
5874 let config = stored
5875 .get("config")
5876 .and_then(Value::as_object)
5877 .cloned()
5878 .unwrap_or_default();
5879 let current = setup_backend_runtime_context(state, tenant, &config);
5880 setup_backend_runtime_context_current(
5881 &serde_json::json!({ "runtime_context": result_context }),
5882 ¤t,
5883 )
5884}
5885
5886fn setup_backend_required_steps(contract: &ProviderBackendContract) -> Vec<&str> {
5887 crate::setup_backend_contract::required_steps(&contract.inline)
5888}
5889
5890fn setup_backend_contract_blocked(
5891 contract: &ProviderBackendContract,
5892 required_steps: &[&str],
5893) -> Option<Value> {
5894 if let Some(err) = contract.load_error.as_ref() {
5895 return Some(serde_json::json!({
5896 "title": "Setup backend contract could not be loaded",
5897 "summary": "Setup backend contract asset could not be loaded.",
5898 "detail": err,
5899 }));
5900 }
5901 if required_steps.is_empty() {
5902 return Some(serde_json::json!({
5903 "title": "Setup backend contract is incomplete",
5904 "summary": "Setup backend contract has no required_order steps.",
5905 "detail": "The pack must provide an effective greentic.setup.backend-contract.v1 contract with required_order.",
5906 }));
5907 }
5908 None
5909}
5910
5911fn setup_backend_first_pending_step(
5912 state: &UiState,
5913 contract: &ProviderBackendContract,
5914 tenant: &str,
5915 stored: &JsonMap<String, Value>,
5916) -> String {
5917 if contract.load_error.is_some() || setup_backend_required_steps(contract).is_empty() {
5918 return "contract_blocked".to_string();
5919 }
5920 if let Some(token_store_key) = crate::setup_backend_contract::oauth_resume_token(stored)
5921 && let Some(action) = crate::setup_backend_contract::oauth_action_by_token_store_key(
5922 &contract.inline,
5923 token_store_key,
5924 )
5925 && let Some(action_id) = action.get("id").and_then(Value::as_str)
5926 {
5927 return action_id.to_string();
5928 }
5929 let config = stored
5930 .get("config")
5931 .and_then(Value::as_object)
5932 .cloned()
5933 .unwrap_or_default();
5934 let setup_result = stored
5935 .get("last_setup_result")
5936 .cloned()
5937 .unwrap_or(Value::Null);
5938 let values = setup_backend_render_values(&config, stored, setup_result);
5939 setup_backend_contract_items(state, contract, tenant, stored, &values)
5940 .into_iter()
5941 .find(|item| item.get("state").and_then(Value::as_str) != Some("done"))
5942 .and_then(|item| item.get("id").and_then(Value::as_str).map(str::to_string))
5943 .unwrap_or_else(|| "complete".to_string())
5944}
5945
5946fn is_safe_runtime_path_segment(segment: &str) -> bool {
5947 !segment.is_empty()
5948 && segment
5949 .chars()
5950 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
5951}
5952
5953fn configured_runtime_proxy_base_url() -> Option<String> {
5954 std::env::var("GREENTIC_SETUP_RUNTIME_URL")
5955 .ok()
5956 .or_else(|| std::env::var("GREENTIC_RUNTIME_URL").ok())
5957 .map(|value| value.trim().trim_end_matches('/').to_string())
5958 .filter(|value| {
5959 Url::parse(value).ok().is_some_and(|url| {
5960 matches!(url.scheme(), "http" | "https")
5961 && url.host_str().is_some_and(|host| {
5962 host.eq_ignore_ascii_case("localhost")
5963 || host == "127.0.0.1"
5964 || host == "::1"
5965 })
5966 })
5967 })
5968}
5969
5970async fn forward_runtime_request(
5971 method: axum::http::Method,
5972 target: &str,
5973 headers: HeaderMap,
5974 body: Bytes,
5975) -> Result<Response> {
5976 let client = reqwest::Client::new();
5977 let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
5978 let mut builder = client.request(reqwest_method, target);
5979 for (name, value) in headers.iter() {
5980 if matches!(
5981 name.as_str(),
5982 "host" | "connection" | "upgrade" | "transfer-encoding" | "content-length"
5983 ) {
5984 continue;
5985 }
5986 builder = builder.header(name.as_str(), value.as_bytes());
5987 }
5988 let upstream = builder.body(body.to_vec()).send().await?;
5989 let status = StatusCode::from_u16(upstream.status().as_u16())?;
5990 let upstream_headers = upstream.headers().clone();
5991 let bytes = upstream.bytes().await?;
5992 let mut response = Body::from(bytes.to_vec()).into_response();
5993 *response.status_mut() = status;
5994 for (name, value) in upstream_headers.iter() {
5995 if matches!(
5996 name.as_str(),
5997 "connection" | "upgrade" | "transfer-encoding" | "content-length"
5998 ) {
5999 continue;
6000 }
6001 if let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) {
6002 response.headers_mut().insert(name.clone(), header_value);
6003 }
6004 }
6005 Ok(response)
6006}
6007
6008fn status_text(status: StatusCode, text: &str) -> Response {
6009 let mut response = text.to_string().into_response();
6010 *response.status_mut() = status;
6011 response
6012}
6013
6014fn content_type_for_path(path: &str) -> &'static str {
6015 match Path::new(path)
6016 .extension()
6017 .and_then(|extension| extension.to_str())
6018 .unwrap_or_default()
6019 {
6020 "js" | "mjs" => "application/javascript; charset=utf-8",
6021 "css" => "text/css; charset=utf-8",
6022 "html" => "text/html; charset=utf-8",
6023 "json" => "application/json; charset=utf-8",
6024 "svg" => "image/svg+xml",
6025 "png" => "image/png",
6026 "jpg" | "jpeg" => "image/jpeg",
6027 "webp" => "image/webp",
6028 "zip" => "application/zip",
6029 _ => "application/octet-stream",
6030 }
6031}
6032
6033fn persist_provider_setup_event(state: &UiState, req: ProviderSetupEventRequest) -> Result<Value> {
6034 let provider_id = validate_log_path_segment(&req.provider_id, "provider_id")?;
6035 let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
6036 let team = req.team.or_else(|| state.team.clone());
6037 let env = req.env.unwrap_or_else(|| state.env.clone());
6038 let tenant_segment = validate_log_path_segment(&tenant, "tenant")?;
6039 let team_segment = validate_log_path_segment(team.as_deref().unwrap_or("default"), "team")?;
6040 let env_segment = validate_log_path_segment(&env, "env")?;
6041 let event_name = validate_event_name(&req.event_name)?;
6042 let setup_session_id = req
6043 .setup_session_id
6044 .filter(|value| !value.trim().is_empty())
6045 .unwrap_or_else(|| state.setup_session_id.clone());
6046 let setup_ui_url = req
6047 .setup_ui_url
6048 .filter(|value| !value.trim().is_empty())
6049 .unwrap_or_else(|| state.local_base_url.clone());
6050 let event_detail = redact_provider_setup_event_detail(&req.event_detail);
6051 let current_step_id = req.current_step_id.unwrap_or_else(|| {
6052 provider_setup_event_detail_field(
6053 &event_detail,
6054 &[
6055 "currentStepId",
6056 "current_step_id",
6057 "stepId",
6058 "step_id",
6059 "step",
6060 ],
6061 )
6062 });
6063 let current_progress = req.current_progress.unwrap_or_else(|| {
6064 provider_setup_event_detail_field(
6065 &event_detail,
6066 &["currentProgress", "current_progress", "progress"],
6067 )
6068 });
6069 let action_name = req.action_name.unwrap_or_else(|| {
6070 provider_setup_event_detail_field(
6071 &event_detail,
6072 &["actionName", "action_name", "action", "name"],
6073 )
6074 });
6075 let request_method = req.request_method.unwrap_or_else(|| {
6076 provider_setup_event_detail_field(
6077 &event_detail,
6078 &["method", "requestMethod", "request_method"],
6079 )
6080 });
6081 let request_path = req.request_path.unwrap_or_else(|| {
6082 provider_setup_event_detail_field(
6083 &event_detail,
6084 &["path", "requestPath", "request_path", "url"],
6085 )
6086 });
6087 let http_status = req.http_status.unwrap_or_else(|| {
6088 provider_setup_event_detail_field(&event_detail, &["status", "httpStatus", "http_status"])
6089 });
6090 let response_body = req.response_body.unwrap_or_else(|| {
6091 provider_setup_event_detail_field(
6092 &event_detail,
6093 &["responseBody", "response_body", "body", "response"],
6094 )
6095 });
6096 let error = req.error.unwrap_or_else(|| {
6097 provider_setup_event_detail_field(&event_detail, &["error", "message", "detail"])
6098 });
6099 let correlation_id = req.correlation_id.unwrap_or_else(|| {
6100 provider_setup_event_detail_field(
6101 &event_detail,
6102 &[
6103 "correlationId",
6104 "correlation_id",
6105 "trace_id",
6106 "traceId",
6107 "request-id",
6108 "client-request-id",
6109 ],
6110 )
6111 });
6112 let record = serde_json::json!({
6113 "timestamp": unix_timestamp_millis(),
6114 "tenant": tenant,
6115 "team": team,
6116 "env": env,
6117 "provider_id": provider_id,
6118 "event_name": event_name,
6119 "current_step_id": current_step_id,
6120 "current_progress": current_progress,
6121 "action_name": action_name,
6122 "request_method": request_method,
6123 "request_path": request_path,
6124 "http_status": http_status,
6125 "response_body": response_body,
6126 "error": error,
6127 "correlation_id": correlation_id,
6128 "event_detail": event_detail,
6129 "setup_session_id": setup_session_id,
6130 "setup_ui_url": setup_ui_url,
6131 });
6132 let path = provider_setup_event_log_path(
6133 state,
6134 env_segment,
6135 tenant_segment,
6136 team_segment,
6137 provider_id,
6138 );
6139 if let Some(parent) = path.parent() {
6140 std::fs::create_dir_all(parent)
6141 .with_context(|| format!("create setup log dir {}", parent.display()))?;
6142 }
6143 let mut file = OpenOptions::new()
6144 .create(true)
6145 .append(true)
6146 .open(&path)
6147 .with_context(|| format!("open setup log {}", path.display()))?;
6148 serde_json::to_writer(&mut file, &record).context("serialize provider setup event")?;
6149 file.write_all(b"\n")
6150 .with_context(|| format!("append setup log {}", path.display()))?;
6151 Ok(record)
6152}
6153
6154fn read_provider_setup_events(
6155 state: &UiState,
6156 query: &ProviderSetupEventsQuery,
6157) -> Result<Vec<Value>> {
6158 let provider_id = validate_log_path_segment(&query.provider_id, "provider_id")?;
6159 let tenant = query.tenant.clone().unwrap_or_else(|| state.tenant.clone());
6160 let team = query.team.clone().or_else(|| state.team.clone());
6161 let env = query.env.clone().unwrap_or_else(|| state.env.clone());
6162 let tenant_segment = validate_log_path_segment(&tenant, "tenant")?;
6163 let team_segment = validate_log_path_segment(team.as_deref().unwrap_or("default"), "team")?;
6164 let env_segment = validate_log_path_segment(&env, "env")?;
6165 let path = provider_setup_event_log_path(
6166 state,
6167 env_segment,
6168 tenant_segment,
6169 team_segment,
6170 provider_id,
6171 );
6172 let limit = query.limit.unwrap_or(200).clamp(1, 1000);
6173 let file = match std::fs::File::open(&path) {
6174 Ok(file) => file,
6175 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
6176 Err(err) => return Err(err).with_context(|| format!("open setup log {}", path.display())),
6177 };
6178 let mut events = Vec::new();
6179 for line in std::io::BufReader::new(file).lines() {
6180 let line = line?;
6181 if line.trim().is_empty() {
6182 continue;
6183 }
6184 if let Ok(value) = serde_json::from_str::<Value>(&line) {
6185 events.push(value);
6186 }
6187 }
6188 if events.len() > limit {
6189 Ok(events.split_off(events.len() - limit))
6190 } else {
6191 Ok(events)
6192 }
6193}
6194
6195fn provider_setup_event_log_path(
6196 state: &UiState,
6197 env: &str,
6198 tenant: &str,
6199 team: &str,
6200 provider_id: &str,
6201) -> PathBuf {
6202 state
6203 .bundle_path
6204 .join("state")
6205 .join("logs")
6206 .join("setup")
6207 .join(env)
6208 .join(tenant)
6209 .join(team)
6210 .join(format!("{provider_id}.jsonl"))
6211}
6212
6213fn validate_log_path_segment<'a>(value: &'a str, name: &str) -> Result<&'a str> {
6214 let value = value.trim();
6215 if value.is_empty()
6216 || value == "."
6217 || value == ".."
6218 || value.contains('/')
6219 || value.contains('\\')
6220 {
6221 anyhow::bail!("invalid {name}");
6222 }
6223 Ok(value)
6224}
6225
6226fn validate_event_name(value: &str) -> Result<&str> {
6227 let value = value.trim();
6228 if !value.starts_with("greentic-provider-setup-")
6229 || value.contains('/')
6230 || value.contains('\\')
6231 || value.len() > 160
6232 {
6233 anyhow::bail!("invalid provider setup event name");
6234 }
6235 Ok(value)
6236}
6237
6238fn provider_setup_event_detail_field(value: &Value, names: &[&str]) -> Value {
6239 for name in names {
6240 if let Some(found) = provider_setup_event_detail_field_one(value, name) {
6241 return found;
6242 }
6243 }
6244 Value::Null
6245}
6246
6247fn provider_setup_event_detail_field_one(value: &Value, name: &str) -> Option<Value> {
6248 let object = value.as_object()?;
6249 if let Some(found) = object.get(name) {
6250 return Some(found.clone());
6251 }
6252 let normalized = normalize_provider_setup_event_key(name);
6253 for (key, nested) in object {
6254 if normalize_provider_setup_event_key(key) == normalized {
6255 return Some(nested.clone());
6256 }
6257 }
6258 for nested in object.values() {
6259 if nested.is_object()
6260 && let Some(found) = provider_setup_event_detail_field_one(nested, name)
6261 {
6262 return Some(found);
6263 }
6264 }
6265 None
6266}
6267
6268fn normalize_provider_setup_event_key(value: &str) -> String {
6269 value
6270 .chars()
6271 .filter(|ch| *ch != '_' && *ch != '-')
6272 .flat_map(char::to_lowercase)
6273 .collect()
6274}
6275
6276fn unix_timestamp_millis() -> u128 {
6277 SystemTime::now()
6278 .duration_since(UNIX_EPOCH)
6279 .map(|duration| duration.as_millis())
6280 .unwrap_or_default()
6281}
6282
6283fn redact_provider_setup_event_detail(value: &Value) -> Value {
6284 match value {
6285 Value::Object(map) => {
6286 let mut redacted = JsonMap::new();
6287 for (key, value) in map {
6288 let normalized = key
6289 .chars()
6290 .filter(|ch| *ch != '_' && *ch != '-')
6291 .flat_map(char::to_lowercase)
6292 .collect::<String>();
6293 if is_secret_event_key(&normalized) {
6294 redacted.insert(key.clone(), Value::String("[redacted]".to_string()));
6295 } else if normalized == "usercode" {
6296 redacted.insert(
6297 key.clone(),
6298 Value::String(short_sha256_marker(value.as_str().unwrap_or_default())),
6299 );
6300 } else {
6301 redacted.insert(key.clone(), redact_provider_setup_event_detail(value));
6302 }
6303 }
6304 Value::Object(redacted)
6305 }
6306 Value::Array(values) => Value::Array(
6307 values
6308 .iter()
6309 .map(redact_provider_setup_event_detail)
6310 .collect(),
6311 ),
6312 _ => value.clone(),
6313 }
6314}
6315
6316fn is_secret_event_key(normalized_key: &str) -> bool {
6317 matches!(
6318 normalized_key,
6319 "accesstoken"
6320 | "refreshtoken"
6321 | "idtoken"
6322 | "clientsecret"
6323 | "botapppassword"
6324 | "devicecode"
6325 | "oauthdevicecode"
6326 )
6327}
6328
6329fn short_sha256_marker(value: &str) -> String {
6330 use sha2::{Digest, Sha256};
6331 if value.is_empty() {
6332 return "[redacted]".to_string();
6333 }
6334 let digest = Sha256::digest(value.as_bytes());
6335 format!("[sha256:{}]", base16_lower_prefix(&digest, 12))
6336}
6337
6338fn base16_lower_prefix(bytes: &[u8], chars: usize) -> String {
6339 const HEX: &[u8; 16] = b"0123456789abcdef";
6340 let mut out = String::with_capacity(chars);
6341 for byte in bytes {
6342 if out.len() >= chars {
6343 break;
6344 }
6345 out.push(HEX[(byte >> 4) as usize] as char);
6346 if out.len() >= chars {
6347 break;
6348 }
6349 out.push(HEX[(byte & 0x0f) as usize] as char);
6350 }
6351 out
6352}
6353
6354async fn post_execute(
6355 State(state): State<std::sync::Arc<UiState>>,
6356 Json(req): Json<ExecuteRequest>,
6357) -> Json<ExecutionResult> {
6358 let bundle_path = state.bundle_path.clone();
6359 let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
6361 let team = req.team.or_else(|| state.team.clone());
6362 let env = req.env.unwrap_or_else(|| state.env.clone());
6363 let mut answers = req.answers;
6364 let provider_setup_status = req.provider_setup_status;
6365 let tunnel_mode = req.tunnel.as_deref().unwrap_or("off").to_string();
6366
6367 if let Some(mode) = req.tunnel.as_deref() {
6369 let tunnel = crate::platform_setup::TunnelAnswers {
6370 mode: Some(mode.to_string()),
6371 };
6372 let _ = crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel);
6373 }
6374
6375 let setup_public_base_url = if let Some(url) = injected_setup_public_base_url() {
6376 inject_setup_public_base_url(&mut answers, &url);
6379 Some(url)
6380 } else if should_start_setup_tunnel(&tunnel_mode, &answers) {
6381 match ensure_setup_tunnel(state.as_ref(), &tunnel_mode, &state.local_base_url).await {
6382 Ok(url) => {
6383 inject_setup_public_base_url(&mut answers, &url);
6384 Some(url)
6385 }
6386 Err(err) => {
6387 return Json(ExecutionResult {
6388 success: false,
6389 stdout: String::new(),
6390 stderr: format!("Failed to start setup tunnel: {err}"),
6391 manual_steps: vec![],
6392 provider_setup_status,
6393 });
6394 }
6395 }
6396 } else {
6397 None
6398 };
6399
6400 let bundle_path_for_repack = bundle_path.clone();
6401 let mut result = tokio::task::spawn_blocking(move || {
6402 execute_setup(&bundle_path, &tenant, team.as_deref(), &env, answers)
6403 })
6404 .await
6405 .unwrap_or_else(|e| ExecutionResult {
6406 success: false,
6407 stdout: String::new(),
6408 stderr: format!("Task panicked: {e}"),
6409 manual_steps: vec![],
6410 provider_setup_status: JsonMap::new(),
6411 });
6412 result.provider_setup_status = provider_setup_status.clone();
6413 if let Some(public_base_url) = setup_public_base_url.as_deref()
6414 && result.success
6415 {
6416 result.stdout = append_line(
6417 &result.stdout,
6418 &format!("Setup tunnel public_base_url: {public_base_url}"),
6419 );
6420 }
6421
6422 if result.success
6428 && let Some(target) = state.output_target.clone()
6429 {
6430 let repack = tokio::task::spawn_blocking(move || -> Result<String, anyhow::Error> {
6431 use crate::cli_helpers::{SetupOutputTarget, copy_dir_recursive};
6432 use crate::gtbundle;
6433 match target {
6434 SetupOutputTarget::Archive(out) => {
6435 gtbundle::create_gtbundle(&bundle_path_for_repack, &out).with_context(
6436 || {
6437 format!(
6438 "failed to write configured .gtbundle archive to {}",
6439 out.display()
6440 )
6441 },
6442 )?;
6443 Ok(format!("Configured bundle written to: {}", out.display()))
6444 }
6445 SetupOutputTarget::Directory(out) => {
6446 if out.exists() {
6447 if out.is_dir() {
6448 std::fs::remove_dir_all(&out).with_context(|| {
6449 format!(
6450 "failed to replace existing bundle directory {}",
6451 out.display()
6452 )
6453 })?;
6454 } else {
6455 std::fs::remove_file(&out).with_context(|| {
6456 format!("failed to replace existing bundle file {}", out.display())
6457 })?;
6458 }
6459 }
6460 copy_dir_recursive(&bundle_path_for_repack, &out, false)
6461 .context("failed to write configured local bundle directory")?;
6462 Ok(format!("Configured bundle written to: {}", out.display()))
6463 }
6464 }
6465 })
6466 .await;
6467 match repack {
6468 Ok(Ok(msg)) => result.stdout.push_str(&format!("\n{msg}\n")),
6469 Ok(Err(e)) => {
6470 result.success = false;
6471 result
6472 .stderr
6473 .push_str(&format!("\nWrite-back failed: {e:#}\n"));
6474 }
6475 Err(e) => {
6476 result.success = false;
6477 result
6478 .stderr
6479 .push_str(&format!("\nWrite-back panicked: {e}\n"));
6480 }
6481 }
6482 }
6483
6484 *state.result.lock().unwrap() = Some(result.clone());
6485 Json(result)
6486}
6487
6488async fn get_result(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
6489 let result = state.result.lock().unwrap().clone();
6490 match result {
6491 Some(result) => Json(serde_json::json!({
6492 "finished": true,
6493 "result": result,
6494 "success": result.success,
6495 })),
6496 None => Json(serde_json::json!({
6497 "finished": false,
6498 })),
6499 }
6500}
6501
6502async fn post_provider_setup_event(
6503 State(state): State<std::sync::Arc<UiState>>,
6504 Json(req): Json<ProviderSetupEventRequest>,
6505) -> Response {
6506 match persist_provider_setup_event(&state, req) {
6507 Ok(record) => Json(serde_json::json!({
6508 "ok": true,
6509 "record": record,
6510 }))
6511 .into_response(),
6512 Err(err) => (
6513 StatusCode::BAD_REQUEST,
6514 Json(serde_json::json!({
6515 "ok": false,
6516 "error": err.to_string(),
6517 })),
6518 )
6519 .into_response(),
6520 }
6521}
6522
6523async fn get_provider_setup_events(
6524 State(state): State<std::sync::Arc<UiState>>,
6525 Query(query): Query<ProviderSetupEventsQuery>,
6526) -> Response {
6527 match read_provider_setup_events(&state, &query) {
6528 Ok(events) => Json(serde_json::json!({
6529 "ok": true,
6530 "events": events,
6531 }))
6532 .into_response(),
6533 Err(err) => (
6534 StatusCode::BAD_REQUEST,
6535 Json(serde_json::json!({
6536 "ok": false,
6537 "error": err.to_string(),
6538 })),
6539 )
6540 .into_response(),
6541 }
6542}
6543
6544async fn post_draft(
6545 State(state): State<std::sync::Arc<UiState>>,
6546 Json(req): Json<DraftSaveRequest>,
6547) -> Json<Value> {
6548 if let Some(mode) = req.tunnel.as_deref() {
6549 let tunnel = crate::platform_setup::TunnelAnswers {
6550 mode: Some(mode.to_string()),
6551 };
6552 if let Err(err) =
6553 crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel)
6554 {
6555 return Json(serde_json::json!({
6556 "ok": false,
6557 "error": err.to_string(),
6558 }));
6559 }
6560 }
6561 match persist_ui_draft(
6562 &state.bundle_path,
6563 &req.tenant,
6564 req.team.as_deref(),
6565 &req.env,
6566 &req.answers,
6567 )
6568 .await
6569 {
6570 Ok(persisted) => Json(serde_json::json!({
6571 "ok": true,
6572 "persisted": persisted,
6573 })),
6574 Err(err) => Json(serde_json::json!({
6575 "ok": false,
6576 "error": err.to_string(),
6577 })),
6578 }
6579}
6580
6581async fn post_setup_action(
6582 State(state): State<std::sync::Arc<UiState>>,
6583 Json(req): Json<SetupActionRequest>,
6584) -> Json<Value> {
6585 match execute_setup_action(state.as_ref(), req).await {
6586 Ok(value) => Json(value),
6587 Err(err) => Json(serde_json::json!({
6588 "ok": false,
6589 "error": err.to_string(),
6590 })),
6591 }
6592}
6593
6594async fn post_setup_public_url(
6595 State(state): State<std::sync::Arc<UiState>>,
6596 Json(req): Json<SetupPublicUrlRequest>,
6597) -> Json<Value> {
6598 match ensure_setup_public_url(state.as_ref(), req).await {
6599 Ok(public_base_url) => Json(serde_json::json!({
6600 "ok": true,
6601 "public_base_url": public_base_url,
6602 })),
6603 Err(err) => Json(serde_json::json!({
6604 "ok": false,
6605 "error": err.to_string(),
6606 })),
6607 }
6608}
6609
6610async fn ensure_setup_public_url(state: &UiState, req: SetupPublicUrlRequest) -> Result<String> {
6611 let mode = req
6612 .tunnel
6613 .as_deref()
6614 .map(str::trim)
6615 .filter(|mode| !mode.is_empty())
6616 .map(ToString::to_string)
6617 .or_else(|| setup_backend_tunnel_mode(state).ok().flatten())
6618 .unwrap_or_else(|| "off".to_string());
6619 if !matches!(mode.as_str(), "cloudflared" | "ngrok") {
6620 anyhow::bail!("setup tunnel is disabled");
6621 }
6622 let tunnel = crate::platform_setup::TunnelAnswers {
6623 mode: Some(mode.clone()),
6624 };
6625 crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel)?;
6626 let _tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
6627 let _team = req.team.or_else(|| state.team.clone());
6628 let _env = req.env.unwrap_or_else(|| state.env.clone());
6629 ensure_setup_tunnel(state, &mode, &state.local_base_url).await
6630}
6631
6632async fn execute_setup_action(state: &UiState, req: SetupActionRequest) -> Result<Value> {
6633 let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
6634 let team = req.team.or_else(|| state.team.clone());
6635 let env = req.env.unwrap_or_else(|| state.env.clone());
6636 let mut answers = req.answers;
6637 let tunnel_mode = setup_action_tunnel_mode(state, req.tunnel.as_deref())?;
6638 eprintln!(
6639 "[setup-action {}/{}] started (tenant={tenant} team={} env={env} tunnel_mode={tunnel_mode})",
6640 req.provider_id,
6641 req.action_id,
6642 team.as_deref().unwrap_or("default"),
6643 );
6644 ensure_setup_action_provider_answers(&mut answers, &req.provider_id);
6645 if let Some(mode) = req.tunnel.as_deref() {
6646 let tunnel = crate::platform_setup::TunnelAnswers {
6647 mode: Some(mode.to_string()),
6648 };
6649 crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel)?;
6650 }
6651 if let Some(url) = injected_setup_public_base_url() {
6652 eprintln!(
6654 "[setup-action {}/{}] using operator-supplied public_base_url {url}",
6655 req.provider_id, req.action_id
6656 );
6657 inject_setup_public_base_url(&mut answers, &url);
6658 } else if should_start_setup_tunnel(&tunnel_mode, &answers) {
6659 eprintln!(
6660 "[setup-action {}/{}] acquiring {tunnel_mode} tunnel for public_base_url...",
6661 req.provider_id, req.action_id
6662 );
6663 let url = ensure_setup_tunnel(state, &tunnel_mode, &state.local_base_url).await?;
6664 eprintln!(
6665 "[setup-action {}/{}] tunnel ready, public_base_url={url}",
6666 req.provider_id, req.action_id
6667 );
6668 inject_setup_public_base_url(&mut answers, &url);
6669 } else {
6670 eprintln!(
6671 "[setup-action {}/{}] no tunnel needed (mode={tunnel_mode} or public_base_url already set)",
6672 req.provider_id, req.action_id
6673 );
6674 }
6675 persist_ui_draft(&state.bundle_path, &tenant, team.as_deref(), &env, &answers).await?;
6676
6677 let discovered = discovery::discover(&state.bundle_path)?;
6678 let provider = discovered
6679 .find_setup_target(&req.provider_id)
6680 .ok_or_else(|| anyhow!("provider not found: {}", req.provider_id))?;
6681 let descriptor = load_setup_actions_descriptor(provider)
6682 .ok_or_else(|| anyhow!("provider has no setup actions: {}", req.provider_id))?;
6683 let action = descriptor
6684 .get("actions")
6685 .and_then(Value::as_array)
6686 .and_then(|actions| {
6687 actions
6688 .iter()
6689 .find(|action| action.get("id").and_then(Value::as_str) == Some(&req.action_id))
6690 })
6691 .ok_or_else(|| anyhow!("setup action not found: {}", req.action_id))?;
6692 if !matches!(
6700 action.get("kind").and_then(Value::as_str),
6701 Some("oauth_install_button" | "open_url")
6702 ) {
6703 anyhow::bail!("setup action kind is not executable in this phase");
6704 }
6705 let registration = action
6706 .get("registration")
6707 .and_then(Value::as_object)
6708 .ok_or_else(|| anyhow!("setup action missing registration metadata"))?;
6709 let mut config = answers
6710 .get(&req.provider_id)
6711 .and_then(Value::as_object)
6712 .cloned()
6713 .unwrap_or_default();
6714 config.insert("tenant".to_string(), Value::String(tenant.clone()));
6715 config.insert(
6716 "team".to_string(),
6717 Value::String(team.clone().unwrap_or_else(|| "default".to_string())),
6718 );
6719 config.insert("env".to_string(), Value::String(env.clone()));
6720 setup_backend_apply_host_defaults(state, &tenant, &mut config);
6721
6722 let setup_callback_base =
6728 if action.get("kind").and_then(Value::as_str) == Some("oauth_install_button") {
6729 setup_oauth_callback_base(state)
6730 } else {
6731 None
6732 };
6733
6734 let request = {
6742 let mut request_config = config.clone();
6743 if let Some(base) = setup_callback_base.as_deref() {
6744 request_config.insert(
6745 "public_base_url".to_string(),
6746 Value::String(base.to_string()),
6747 );
6748 }
6749 Value::Object(request_config)
6750 };
6751 let setup_config = SetupConfig {
6752 tenant: tenant.clone(),
6753 team: team.clone(),
6754 env: env.clone(),
6755 offline: false,
6756 verbose: state.advanced,
6757 };
6758 let output = if let Some(result) = registration
6759 .get("result")
6760 .or_else(|| registration.get("mock_result"))
6761 .or_else(|| registration.get("outputs"))
6762 {
6763 eprintln!(
6764 "[setup-action {}/{}] using inline registration result (no component invocation)",
6765 req.provider_id, req.action_id
6766 );
6767 result.clone()
6768 } else {
6769 let component_ref = registration
6770 .get("component_ref")
6771 .and_then(Value::as_str)
6772 .map(str::trim)
6773 .filter(|value| !value.is_empty())
6774 .ok_or_else(|| anyhow!("setup action registration missing component_ref"))?;
6775 let op = registration
6776 .get("op")
6777 .and_then(Value::as_str)
6778 .map(str::trim)
6779 .filter(|value| !value.is_empty())
6780 .ok_or_else(|| anyhow!("setup action registration missing op"))?;
6781 eprintln!(
6782 "[setup-action {}/{}] invoking WASM registration op {component_ref}::{op} \
6783 (pack: {}) — this is where the provider's own API gets called",
6784 req.provider_id,
6785 req.action_id,
6786 provider.pack_path.display()
6787 );
6788 let started = Instant::now();
6789 let output = invoke_setup_component_operation_blocking(
6790 state.bundle_path.clone(),
6791 provider.pack_path.clone(),
6792 component_ref.to_string(),
6793 op.to_string(),
6794 request,
6795 setup_config,
6796 )
6797 .await?;
6798 eprintln!(
6799 "[setup-action {}/{}] registration op returned in {:.1}s (ok={})",
6800 req.provider_id,
6801 req.action_id,
6802 started.elapsed().as_secs_f32(),
6803 output
6804 .get("ok")
6805 .and_then(Value::as_bool)
6806 .map_or("unknown".to_string(), |ok| ok.to_string()),
6807 );
6808 output
6809 };
6810 if output.get("ok").and_then(Value::as_bool) == Some(false) {
6811 eprintln!(
6812 "[setup-action {}/{}] FAILED: {}",
6813 req.provider_id,
6814 req.action_id,
6815 output
6816 .get("error")
6817 .and_then(Value::as_str)
6818 .unwrap_or("setup action failed")
6819 );
6820 return Ok(serde_json::json!({
6821 "ok": false,
6822 "provider_id": req.provider_id,
6823 "action_id": req.action_id,
6824 "error": output.get("error").and_then(Value::as_str).unwrap_or("setup action failed"),
6825 "output": redact_setup_action_output(&output),
6826 }));
6827 }
6828
6829 merge_scalar_values(&mut config, &output);
6830 let final_url_context = SetupActionFinalUrlContext {
6831 bundle_root: &state.bundle_path,
6832 tenant: &tenant,
6833 team: team.as_deref(),
6834 provider_id: &req.provider_id,
6835 action_id: &req.action_id,
6836 setup_callback_base: setup_callback_base.as_deref(),
6837 };
6838 if let Some((key, url)) =
6839 setup_action_final_url_value(&descriptor, action, &config, &final_url_context)?
6840 {
6841 eprintln!(
6842 "[setup-action {}/{}] resolved final install URL ({key}): {url}",
6843 req.provider_id, req.action_id
6844 );
6845 config.insert(key, Value::String(url));
6846 } else {
6847 eprintln!(
6848 "[setup-action {}/{}] no final install URL resolved (browser resolves deep_link templates from returned values instead)",
6849 req.provider_id, req.action_id
6850 );
6851 }
6852 crate::qa::persist::persist_all_config_as_secrets(
6853 &state.bundle_path,
6854 &env,
6855 &tenant,
6856 team.as_deref(),
6857 &req.provider_id,
6858 &Value::Object(config.clone()),
6859 Some(&provider.pack_path),
6860 )
6861 .await?;
6862 eprintln!(
6863 "[setup-action {}/{}] complete: outputs persisted as secrets, returning values to the UI",
6864 req.provider_id, req.action_id
6865 );
6866 let safe_values = public_setup_action_values(&config);
6867 Ok(serde_json::json!({
6868 "ok": true,
6869 "provider_id": req.provider_id,
6870 "action_id": req.action_id,
6871 "values": safe_values,
6872 "state": {
6873 "values": safe_values,
6874 "setup_status": { "ok": true }
6875 },
6876 "setup_status": { "ok": true },
6877 "output": redact_setup_action_output(&output),
6878 }))
6879}
6880
6881fn setup_action_tunnel_mode(state: &UiState, requested: Option<&str>) -> Result<String> {
6882 if let Some(mode) = requested
6883 .map(str::trim)
6884 .filter(|mode| !mode.is_empty())
6885 .map(ToString::to_string)
6886 {
6887 return Ok(mode);
6888 }
6889 Ok(setup_backend_tunnel_mode(state)?.unwrap_or_else(|| "off".to_string()))
6890}
6891
6892fn ensure_setup_action_provider_answers(answers: &mut JsonMap<String, Value>, provider_id: &str) {
6893 answers
6894 .entry(provider_id.to_string())
6895 .or_insert_with(|| Value::Object(JsonMap::new()));
6896}
6897
6898#[derive(Deserialize)]
6899struct ExportRequest {
6900 scopes: Vec<ExportScope>,
6901 #[serde(default)]
6902 key: Option<String>,
6903}
6904
6905#[derive(Deserialize)]
6906struct ExportScope {
6907 tenant: String,
6908 #[serde(default)]
6909 team: Option<String>,
6910 env: String,
6911 answers: JsonMap<String, Value>,
6912}
6913
6914async fn post_export(
6915 State(state): State<std::sync::Arc<UiState>>,
6916 Json(req): Json<ExportRequest>,
6917) -> Json<Value> {
6918 let bundle_path = state.bundle_path.clone();
6919
6920 let discovered = discovery::discover(&bundle_path).ok();
6922 let secret_fields: std::collections::HashSet<String> = discovered
6923 .iter()
6924 .flat_map(|d| d.setup_targets())
6925 .filter_map(|p| setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id))
6926 .flat_map(|spec| spec.questions.into_iter())
6927 .filter(|q| q.secret)
6928 .map(|q| q.id)
6929 .collect();
6930
6931 let mut scopes_json = Vec::new();
6932 for scope in &req.scopes {
6933 let mut setup_answers = JsonMap::new();
6934 for (provider_id, provider_answers) in &scope.answers {
6935 let mut encrypted_answers = JsonMap::new();
6936 if let Some(obj) = provider_answers.as_object() {
6937 for (field, value) in obj {
6938 if secret_fields.contains(field) && req.key.is_some() {
6939 let key = req.key.as_deref().unwrap();
6940 match crate::answers_crypto::encrypt_value(value, key) {
6941 Ok(enc) => {
6942 encrypted_answers.insert(field.clone(), enc);
6943 }
6944 Err(_) => {
6945 encrypted_answers.insert(field.clone(), value.clone());
6946 }
6947 }
6948 } else {
6949 encrypted_answers.insert(field.clone(), value.clone());
6950 }
6951 }
6952 }
6953 setup_answers.insert(provider_id.clone(), Value::Object(encrypted_answers));
6954 }
6955 scopes_json.push(serde_json::json!({
6956 "tenant": scope.tenant,
6957 "team": scope.team,
6958 "env": scope.env,
6959 "setup_answers": setup_answers,
6960 }));
6961 }
6962
6963 let doc = if scopes_json.len() == 1 {
6966 let mut single = scopes_json.into_iter().next().unwrap();
6967 if let Some(obj) = single.as_object_mut() {
6968 obj.insert(
6969 "greentic_setup_version".to_string(),
6970 Value::String("1.0.0".to_string()),
6971 );
6972 obj.insert(
6973 "bundle_source".to_string(),
6974 Value::String(bundle_path.display().to_string()),
6975 );
6976 }
6977 single
6978 } else {
6979 serde_json::json!({
6980 "greentic_setup_version": "1.0.0",
6981 "bundle_source": bundle_path.display().to_string(),
6982 "scopes": scopes_json,
6983 })
6984 };
6985
6986 Json(doc)
6987}
6988
6989#[derive(Deserialize)]
6990struct DecryptRequest {
6991 doc: Value,
6992 key: String,
6993}
6994
6995async fn post_decrypt(Json(req): Json<DecryptRequest>) -> Json<Value> {
6996 match crate::answers_crypto::decrypt_tree(&req.doc, &req.key) {
6997 Ok(decrypted) => Json(serde_json::json!({ "ok": true, "doc": decrypted })),
6998 Err(e) => Json(serde_json::json!({ "ok": false, "error": e.to_string() })),
6999 }
7000}
7001
7002async fn get_oauth_callback(
7003 State(state): State<std::sync::Arc<UiState>>,
7004 Query(query): Query<std::collections::HashMap<String, String>>,
7005) -> impl IntoResponse {
7006 let code = query.get("code").cloned().unwrap_or_default();
7007 let oauth_state = query.get("state").cloned().unwrap_or_default();
7008 if code.is_empty() || oauth_state.is_empty() {
7009 return (
7010 axum::http::StatusCode::BAD_REQUEST,
7011 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
7012 oauth_callback_page(
7013 false,
7014 "OAuth setup failed",
7015 "OAuth callback missing code or state.",
7016 ),
7017 );
7018 }
7019 let setup_callback_base = setup_oauth_callback_base(state.as_ref());
7022 match crate::oauth_callback::complete_oauth_callback(
7023 &state.bundle_path,
7024 &state.env,
7025 &crate::oauth_callback::OAuthCallbackInput {
7026 code,
7027 state: oauth_state,
7028 },
7029 "messaging.oauth.v1",
7030 setup_callback_base.as_deref(),
7031 )
7032 .await
7033 {
7034 Ok(report) => {
7035 let message = format!(
7036 "OAuth setup complete for {} ({}/{})",
7037 report.provider_id, report.tenant, report.team
7038 );
7039 (
7040 axum::http::StatusCode::OK,
7041 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
7042 oauth_callback_page(
7043 true,
7044 "OAuth setup complete",
7045 &format!("{message}. You can close this tab and return to setup."),
7046 ),
7047 )
7048 }
7049 Err(err) => {
7050 eprintln!("[oauth-token] callback FAILED: {err:#}");
7051 (
7052 axum::http::StatusCode::BAD_REQUEST,
7053 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
7054 oauth_callback_page(false, "OAuth setup failed", &err.to_string()),
7055 )
7056 }
7057 }
7058}
7059
7060fn oauth_callback_page(success: bool, title: &str, message: &str) -> String {
7061 let status_class = if success { "success" } else { "error" };
7062 let close_script = if success {
7063 r#"<script>
7064setTimeout(function () {
7065 window.close();
7066}, 800);
7067</script>"#
7068 } else {
7069 ""
7070 };
7071 format!(
7072 r#"<!doctype html>
7073<html lang="en">
7074<head>
7075 <meta charset="utf-8">
7076 <meta name="viewport" content="width=device-width, initial-scale=1">
7077 <title>{title}</title>
7078 <style>
7079 body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f8fb; color: #17202a; }}
7080 main {{ width: min(520px, calc(100vw - 32px)); padding: 28px; border: 1px solid #d7dee8; border-radius: 8px; background: #fff; box-shadow: 0 16px 40px rgba(15, 23, 42, .08); }}
7081 h1 {{ margin: 0 0 12px; font-size: 1.35rem; line-height: 1.25; }}
7082 p {{ margin: 0; line-height: 1.55; color: #465466; }}
7083 .success h1 {{ color: #087f5b; }}
7084 .error h1 {{ color: #b42318; }}
7085 </style>
7086</head>
7087<body>
7088 <main class="{status_class}">
7089 <h1>{title}</h1>
7090 <p>{message}</p>
7091 </main>
7092 {close_script}
7093</body>
7094</html>"#,
7095 title = html_escape(title),
7096 message = html_escape(message),
7097 status_class = status_class,
7098 close_script = close_script
7099 )
7100}
7101
7102fn html_escape(value: &str) -> String {
7103 value
7104 .replace('&', "&")
7105 .replace('<', "<")
7106 .replace('>', ">")
7107 .replace('"', """)
7108 .replace('\'', "'")
7109}
7110
7111async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
7112 let _ = state.shutdown_tx.send(());
7113}
7114
7115fn append_line(existing: &str, line: &str) -> String {
7118 if existing.trim().is_empty() {
7119 line.to_string()
7120 } else {
7121 format!("{existing}\n{line}")
7122 }
7123}
7124
7125const TUNNEL_FAILURE_COOLDOWN: Duration = Duration::from_secs(20);
7132
7133async fn ensure_setup_tunnel(state: &UiState, mode: &str, local_base_url: &str) -> Result<String> {
7134 if let Some(existing) = active_setup_tunnel_public_base_url(state, mode, local_base_url)? {
7135 if setup_backend_public_tunnel_responds(&existing).await {
7136 return Ok(existing);
7137 }
7138 setup_backend_clear_setup_tunnel(state);
7143 }
7144
7145 let _start_guard = state.setup_tunnel_start.lock().await;
7146 if let Some(existing) = active_setup_tunnel_public_base_url(state, mode, local_base_url)? {
7147 if setup_backend_public_tunnel_responds(&existing).await {
7148 return Ok(existing);
7149 }
7150 setup_backend_clear_setup_tunnel(state);
7151 }
7152
7153 if let Some(remaining) = setup_backend_tunnel_cooldown_remaining(state)? {
7154 eprintln!(
7155 "Setup tunnel circuit breaker: refusing to spawn another {mode} tunnel, \
7156 {}s left in cooldown after the last attempt never became reachable",
7157 remaining.as_secs()
7158 );
7159 anyhow::bail!(
7160 "{mode} setup tunnel is unavailable: the last attempt never became reachable \
7161 (this usually means the tunnel provider's hostnames aren't routable from this \
7162 network). Not retrying for another {}s to avoid spawning another doomed tunnel \
7163 — switch tunnel.mode if this persists.",
7164 remaining.as_secs()
7165 );
7166 }
7167
7168 let local_base_url = local_base_url.trim_end_matches('/').to_string();
7169 eprintln!("Setup tunnel: spawning {mode} tunnel for {local_base_url}");
7170 let mode_for_task = mode.to_string();
7171 let local_base_url_for_task = local_base_url.clone();
7172 let tunnel = tokio::task::spawn_blocking(move || {
7173 start_setup_tunnel(&mode_for_task, &local_base_url_for_task)
7174 })
7175 .await
7176 .map_err(|err| anyhow!("setup tunnel task failed: {err}"))??;
7177 let public_base_url = tunnel.public_base_url.clone();
7178 eprintln!("Setup tunnel: probing reachability of {public_base_url}");
7179 if wait_for_setup_public_tunnel(&public_base_url).await {
7180 eprintln!("Setup tunnel: {public_base_url} is reachable, using it");
7181 persist_setup_tunnel_handoff(&state.bundle_path, &state.local_base_url, &tunnel);
7182 let mut guard = state
7183 .setup_tunnel
7184 .lock()
7185 .map_err(|_| anyhow!("setup tunnel lock poisoned"))?;
7186 match guard.as_ref() {
7189 Some(previous) if previous.local_base_url != tunnel.local_base_url => eprintln!(
7190 "[setup tunnel-slot] now fronts {} → {} (was {} → {}); port-keyed lookups \
7191 still resolve the previous tunnel via the shared record",
7192 tunnel.local_base_url,
7193 tunnel.public_base_url,
7194 previous.local_base_url,
7195 previous.public_base_url
7196 ),
7197 _ => eprintln!(
7198 "[setup tunnel-slot] now fronts {} → {}",
7199 tunnel.local_base_url, tunnel.public_base_url
7200 ),
7201 }
7202 *guard = Some(tunnel);
7203 if let Ok(mut cooldown) = state.tunnel_failure_cooldown.lock() {
7204 *cooldown = None;
7205 }
7206 return Ok(public_base_url);
7207 }
7208 eprintln!(
7209 "Setup tunnel: {public_base_url} never became reachable; entering {}s cooldown \
7210 before another {mode} tunnel may be spawned",
7211 TUNNEL_FAILURE_COOLDOWN.as_secs()
7212 );
7213 drop(tunnel);
7214 if let Ok(mut cooldown) = state.tunnel_failure_cooldown.lock() {
7215 *cooldown = Some(Instant::now());
7216 }
7217 anyhow::bail!("{mode} setup tunnel URL did not become reachable: {public_base_url}")
7218}
7219
7220fn setup_backend_tunnel_cooldown_remaining(state: &UiState) -> Result<Option<Duration>> {
7223 let guard = state
7224 .tunnel_failure_cooldown
7225 .lock()
7226 .map_err(|_| anyhow!("tunnel failure cooldown lock poisoned"))?;
7227 Ok(guard.and_then(|failed_at| TUNNEL_FAILURE_COOLDOWN.checked_sub(failed_at.elapsed())))
7228}
7229
7230fn persist_setup_tunnel_handoff(
7235 bundle_path: &Path,
7236 setup_ui_local_base_url: &str,
7237 tunnel: &crate::setup_tunnel::SetupTunnel,
7238) {
7239 let Some(local_port) = crate::shared_tunnel::local_port_from_base_url(&tunnel.local_base_url)
7240 else {
7241 return;
7242 };
7243 if crate::shared_tunnel::local_port_from_base_url(setup_ui_local_base_url) == Some(local_port) {
7249 eprintln!(
7250 "[setup tunnel-handoff] skipping handoff for {url}: port {local_port} is the \
7251 Setup-UI port, not a runtime ingress port",
7252 url = tunnel.public_base_url
7253 );
7254 return;
7255 }
7256 eprintln!(
7257 "[setup tunnel-handoff] persisting handoff: port {local_port} → {url}",
7258 url = tunnel.public_base_url
7259 );
7260 let handoff = crate::platform_setup::TunnelHandoff {
7261 service: tunnel.mode.clone(),
7262 local_port,
7263 public_base_url: tunnel.public_base_url.clone(),
7264 };
7265 if let Err(err) = crate::platform_setup::persist_tunnel_handoff_artifact(bundle_path, &handoff)
7266 {
7267 tracing::warn!("failed to persist setup tunnel handoff: {err:#}");
7268 }
7269}
7270
7271fn active_setup_tunnel_public_base_url(
7272 state: &UiState,
7273 mode: &str,
7274 local_base_url: &str,
7275) -> Result<Option<String>> {
7276 let mut guard = state
7277 .setup_tunnel
7278 .lock()
7279 .map_err(|_| anyhow!("setup tunnel lock poisoned"))?;
7280 Ok(
7281 if let Some(tunnel) = guard.as_mut()
7282 && tunnel.mode == mode
7283 && tunnel.local_base_url == local_base_url.trim_end_matches('/')
7284 && tunnel.is_running()
7285 {
7286 Some(tunnel.public_base_url.clone())
7287 } else {
7288 None
7289 },
7290 )
7291}
7292
7293fn setup_backend_clear_setup_tunnel(state: &UiState) {
7294 if let Ok(mut guard) = state.setup_tunnel.lock() {
7295 *guard = None;
7296 }
7297}
7298
7299async fn wait_for_setup_public_tunnel(public_base_url: &str) -> bool {
7300 let deadline = tokio::time::Instant::now() + Duration::from_secs(45);
7301 while tokio::time::Instant::now() < deadline {
7302 if setup_backend_public_tunnel_responds(public_base_url).await {
7303 return true;
7304 }
7305 tokio::time::sleep(Duration::from_millis(500)).await;
7306 }
7307 false
7308}
7309
7310async fn ensure_setup_runtime(state: &UiState, tenant: &str) -> Result<()> {
7311 let _start_guard = state.setup_runtime_start.lock().await;
7312 if let Some(runtime_base_url) = setup_backend_setup_runtime_info(state)
7313 .and_then(|info| info.local_base_url)
7314 .filter(|value| !value.trim().is_empty())
7315 && setup_backend_runtime_base_responds(runtime_base_url.trim_end_matches('/')).await
7316 {
7317 return Ok(());
7318 }
7319 if let Some(runtime_base_url) = crate::platform_setup::load_runtime_local_base_url(
7320 &state.bundle_path,
7321 tenant,
7322 state.team.as_deref(),
7323 )
7324 .ok()
7325 .flatten()
7326 .map(|value| value.trim().trim_end_matches('/').to_string())
7327 .filter(|value| !value.is_empty())
7328 && setup_backend_runtime_base_responds(&runtime_base_url).await
7329 {
7330 return Ok(());
7331 }
7332
7333 {
7334 let mut guard = state
7335 .setup_runtime
7336 .lock()
7337 .map_err(|_| anyhow!("setup runtime lock poisoned"))?;
7338 if let Some(runtime) = guard.as_mut() {
7339 match runtime.child.try_wait() {
7340 Ok(Some(status)) => {
7341 *guard = None;
7342 eprintln!("Setup-started runtime exited before observation: {status}");
7343 }
7344 Ok(None) => {}
7345 Err(err) => {
7346 *guard = None;
7347 eprintln!("Could not inspect setup-started runtime: {err}");
7348 }
7349 }
7350 }
7351 if guard.is_none() {
7352 let system_log_line_floor =
7353 setup_runtime_system_log_line_count(&state.bundle_path).ok();
7354 let runtime_cloudflared = match setup_backend_tunnel_mode(state) {
7359 Ok(Some(mode)) if mode == "cloudflared" => "on",
7360 _ => "off",
7361 };
7362 let mut child = Command::new("greentic-start")
7363 .arg("start")
7364 .arg("--bundle")
7365 .arg(&state.bundle_path)
7366 .arg("--cloudflared")
7367 .arg(runtime_cloudflared)
7368 .arg("--no-browser")
7369 .stdout(Stdio::piped())
7370 .stderr(Stdio::piped())
7371 .spawn()
7372 .with_context(|| format!("start runtime for {}", state.bundle_path.display()))?;
7373 let info = Arc::new(Mutex::new(SetupRuntimeInfo {
7374 system_log_line_floor,
7375 ..SetupRuntimeInfo::default()
7376 }));
7377 if let Some(stdout) = child.stdout.take() {
7378 spawn_setup_runtime_log_reader(stdout, info.clone());
7379 }
7380 if let Some(stderr) = child.stderr.take() {
7381 spawn_setup_runtime_log_reader(stderr, info.clone());
7382 }
7383 *guard = Some(SetupRuntime { child, info });
7384 }
7385 }
7386
7387 let deadline = std::time::Instant::now() + Duration::from_secs(90);
7388 loop {
7389 if let Some(runtime_base_url) = setup_backend_setup_runtime_info(state)
7390 .and_then(|info| info.local_base_url)
7391 .filter(|value| !value.trim().is_empty())
7392 && setup_backend_runtime_base_responds(&runtime_base_url).await
7393 {
7394 return Ok(());
7395 }
7396 {
7397 let mut guard = state
7398 .setup_runtime
7399 .lock()
7400 .map_err(|_| anyhow!("setup runtime lock poisoned"))?;
7401 if let Some(runtime) = guard.as_mut()
7402 && let Some(status) = runtime.child.try_wait().context("inspect setup runtime")?
7403 {
7404 *guard = None;
7405 return Err(anyhow!(
7406 "setup-started runtime exited before it was ready: {status}"
7407 ));
7408 }
7409 }
7410 if std::time::Instant::now() >= deadline {
7411 return Err(anyhow!("runtime did not become ready within 90 seconds"));
7412 }
7413 tokio::time::sleep(Duration::from_millis(750)).await;
7414 }
7415}
7416
7417fn setup_runtime_system_log_line_count(bundle_path: &Path) -> Result<usize> {
7418 let log_path = bundle_path.join("logs").join("system.log");
7419 let file = match std::fs::File::open(&log_path) {
7420 Ok(file) => file,
7421 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0),
7422 Err(err) => return Err(err).with_context(|| format!("read {}", log_path.display())),
7423 };
7424 Ok(std::io::BufReader::new(file).lines().count())
7425}
7426
7427fn spawn_setup_runtime_log_reader<R>(stream: R, info: Arc<Mutex<SetupRuntimeInfo>>)
7428where
7429 R: std::io::Read + Send + 'static,
7430{
7431 std::thread::spawn(move || {
7432 let reader = std::io::BufReader::new(stream);
7433 for line in reader.lines().map_while(std::result::Result::ok) {
7434 let trimmed = line.trim();
7435 if trimmed.is_empty() {
7436 continue;
7437 }
7438 if let Ok(mut guard) = info.lock() {
7439 if let Some(value) = setup_runtime_log_value(trimmed, "HTTP:") {
7440 guard.local_base_url = Some(value);
7441 }
7442 if let Some(value) = setup_runtime_log_value(trimmed, "Public:") {
7443 guard.public_base_url = Some(value);
7444 }
7445 if trimmed.starts_with("Ready.") {
7446 guard.ready = true;
7447 }
7448 }
7449 }
7450 });
7451}
7452
7453fn setup_runtime_log_value(line: &str, label: &str) -> Option<String> {
7454 let value = line.strip_prefix(label)?.trim().trim_end_matches('/');
7455 (!value.is_empty()).then(|| value.to_string())
7456}
7457
7458fn execute_setup(
7459 bundle_path: &Path,
7460 tenant: &str,
7461 team: Option<&str>,
7462 env: &str,
7463 answers: JsonMap<String, Value>,
7464) -> ExecutionResult {
7465 let config = SetupConfig {
7466 tenant: tenant.to_string(),
7467 team: team.map(String::from),
7468 env: env.to_string(),
7469 offline: false,
7470 verbose: true,
7471 };
7472
7473 let static_routes = match StaticRoutesPolicy::normalize(None, env) {
7474 Ok(sr) => sr,
7475 Err(e) => {
7476 return ExecutionResult {
7477 success: false,
7478 stdout: String::new(),
7479 stderr: format!("Failed to normalize static routes: {e}"),
7480 manual_steps: vec![],
7481 provider_setup_status: JsonMap::new(),
7482 };
7483 }
7484 };
7485
7486 let provider_configs: Vec<(String, serde_json::Value)> = answers
7488 .iter()
7489 .map(|(id, val)| (id.clone(), val.clone()))
7490 .collect();
7491 let team_str = team.unwrap_or("default");
7492 let manual_steps =
7493 crate::webhook::collect_post_setup_instructions(&provider_configs, tenant, team_str);
7494
7495 let request = SetupRequest {
7496 bundle: bundle_path.to_path_buf(),
7497 bundle_name: crate::bundle::read_bundle_name(bundle_path).ok().flatten(),
7498 tenants: vec![TenantSelection {
7499 tenant: tenant.to_string(),
7500 team: team.map(String::from),
7501 allow_paths: Vec::new(),
7502 }],
7503 static_routes,
7504 deployment_targets: Vec::new(),
7505 setup_answers: answers,
7506 ..Default::default()
7507 };
7508
7509 let engine = SetupEngine::new(config);
7510
7511 let plan = match engine.plan(SetupMode::Create, &request, false) {
7512 Ok(p) => p,
7513 Err(e) => {
7514 return ExecutionResult {
7515 success: false,
7516 stdout: String::new(),
7517 stderr: format!("Failed to build plan: {e}"),
7518 manual_steps: vec![],
7519 provider_setup_status: JsonMap::new(),
7520 };
7521 }
7522 };
7523
7524 let mut stdout = String::new();
7526 for step in &plan.steps {
7527 stdout.push_str(&format!(" {:?}: {}\n", step.kind, step.description));
7528 }
7529
7530 match engine.execute(&plan) {
7531 Ok(report) => {
7532 stdout.push_str(&format!(
7533 "\n{} provider(s) updated, {} pack(s) resolved.\n",
7534 report.provider_updates,
7535 report.resolved_packs.len()
7536 ));
7537 if !report.warnings.is_empty() {
7538 for w in &report.warnings {
7539 stdout.push_str(&format!(" warning: {w}\n"));
7540 }
7541 }
7542 ExecutionResult {
7543 success: true,
7544 stdout: format!(
7545 "Plan ({} steps):\n{stdout}Setup completed successfully.",
7546 plan.steps.len()
7547 ),
7548 stderr: String::new(),
7549 manual_steps,
7550 provider_setup_status: JsonMap::new(),
7551 }
7552 }
7553 Err(e) => ExecutionResult {
7554 success: false,
7555 stdout,
7556 stderr: format!("Execution failed: {e}"),
7557 manual_steps: vec![],
7558 provider_setup_status: JsonMap::new(),
7559 },
7560 }
7561}
7562
7563async fn load_saved_secrets(
7567 bundle_path: &Path,
7568 env: &str,
7569 tenant: &str,
7570 team: Option<&str>,
7571 provider_form_specs: &[wizard::ProviderFormSpec],
7572) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
7573 use greentic_secrets_lib::SecretsStore;
7574
7575 let store = match crate::secrets::open_dev_store(bundle_path) {
7576 Ok(s) => s,
7577 Err(_) => return std::collections::HashMap::new(),
7578 };
7579
7580 let mut result = std::collections::HashMap::new();
7581 for pfs in provider_form_specs {
7582 let mut values = std::collections::HashMap::new();
7583 for q in &pfs.form_spec.questions {
7584 let uri = crate::canonical_secret_uri(env, tenant, team, &pfs.provider_id, &q.id);
7585 if let Ok(bytes) = store.get(&uri).await
7586 && let Ok(text) = String::from_utf8(bytes)
7587 && !text.is_empty()
7588 {
7589 values.insert(q.id.clone(), text);
7590 }
7591 }
7592 if !values.is_empty() {
7593 result.insert(pfs.provider_id.clone(), values);
7594 }
7595 }
7596 result
7597}
7598
7599async fn persist_ui_draft(
7600 bundle_path: &Path,
7601 tenant: &str,
7602 team: Option<&str>,
7603 env: &str,
7604 answers: &JsonMap<String, Value>,
7605) -> Result<JsonMap<String, Value>> {
7606 let discovered = discovery::discover(bundle_path).ok();
7607 let mut persisted = JsonMap::new();
7608
7609 for (provider_id, provider_answers) in answers {
7610 let Some(config) = provider_answers.as_object() else {
7611 continue;
7612 };
7613 if config.is_empty() {
7614 continue;
7615 }
7616
7617 let pack_path = discovered.as_ref().and_then(|d| {
7618 d.find_setup_target(provider_id)
7619 .map(|provider| provider.pack_path.as_path())
7620 });
7621
7622 let keys = crate::qa::persist::persist_all_config_as_secrets(
7623 bundle_path,
7624 env,
7625 tenant,
7626 team,
7627 provider_id,
7628 provider_answers,
7629 pack_path,
7630 )
7631 .await?;
7632
7633 if !keys.is_empty() {
7634 persisted.insert(provider_id.clone(), serde_json::to_value(keys)?);
7635 }
7636 }
7637
7638 Ok(persisted)
7639}
7640
7641fn value_as_nonempty_string(v: &Value) -> Option<String> {
7643 match v {
7644 Value::String(s) if !s.is_empty() => Some(s.clone()),
7645 Value::Number(n) => Some(n.to_string()),
7646 Value::Bool(b) => Some(b.to_string()),
7647 _ => None,
7648 }
7649}
7650
7651fn merge_scalar_values(target: &mut JsonMap<String, Value>, source: &Value) {
7652 let Some(object) = source.as_object() else {
7653 return;
7654 };
7655 for (key, value) in object {
7656 match value {
7657 Value::String(_) | Value::Number(_) | Value::Bool(_) => {
7658 target.insert(key.clone(), value.clone());
7659 }
7660 _ => {}
7661 }
7662 }
7663}
7664
7665fn public_setup_action_values(values: &JsonMap<String, Value>) -> Value {
7666 Value::Object(
7667 values
7668 .iter()
7669 .filter(|(key, value)| {
7670 !is_setup_action_secret_key(key)
7671 && matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_))
7672 })
7673 .map(|(key, value)| (key.clone(), value.clone()))
7674 .collect(),
7675 )
7676}
7677
7678fn redact_setup_action_output(value: &Value) -> Value {
7679 match value {
7680 Value::Object(object) => Value::Object(
7681 object
7682 .iter()
7683 .map(|(key, value)| {
7684 if is_setup_action_secret_key(key) {
7685 (key.clone(), Value::String("[redacted]".to_string()))
7686 } else {
7687 (key.clone(), redact_setup_action_output(value))
7688 }
7689 })
7690 .collect(),
7691 ),
7692 Value::Array(items) => Value::Array(items.iter().map(redact_setup_action_output).collect()),
7693 _ => value.clone(),
7694 }
7695}
7696
7697fn is_setup_action_secret_key(key: &str) -> bool {
7698 let normalized = key
7699 .chars()
7700 .filter(|ch| ch.is_ascii_alphanumeric())
7701 .flat_map(char::to_lowercase)
7702 .collect::<String>();
7703 normalized.contains("accesstoken")
7704 || normalized.contains("refreshtoken")
7705 || normalized.contains("idtoken")
7706 || normalized.contains("devicecode")
7707 || normalized.contains("clientsecret")
7708 || normalized.contains("signingsecret")
7709 || normalized.contains("password")
7710 || normalized.contains("secret")
7711 || normalized.contains("credential")
7712 || normalized.ends_with("token")
7713}
7714
7715struct SetupActionFinalUrlContext<'a> {
7716 bundle_root: &'a Path,
7717 tenant: &'a str,
7718 team: Option<&'a str>,
7719 provider_id: &'a str,
7720 action_id: &'a str,
7721 setup_callback_base: Option<&'a str>,
7725}
7726
7727fn setup_action_final_url_value(
7728 descriptor: &Value,
7729 action: &Value,
7730 config: &JsonMap<String, Value>,
7731 context: &SetupActionFinalUrlContext<'_>,
7732) -> Result<Option<(String, String)>> {
7733 let key = setup_action_final_url_key(descriptor);
7734 if action.get("kind").and_then(Value::as_str) == Some("open_url")
7738 && let Some(template) = action
7739 .get("url_template")
7740 .and_then(Value::as_str)
7741 .map(str::trim)
7742 .filter(|value| !value.is_empty())
7743 && let Some(url) = resolve_url_template_from_config(template, config)
7744 {
7745 return Ok(Some((
7746 key.clone()
7747 .unwrap_or_else(|| "oauth_authorize_url".to_string()),
7748 url,
7749 )));
7750 }
7751 if let Some(key) = key.as_deref()
7758 && let Some(url) = config
7759 .get(key)
7760 .and_then(Value::as_str)
7761 .map(str::trim)
7762 .filter(|value| !value.is_empty())
7763 {
7764 let rewritten = rewrite_slack_app_redirect(url);
7765 let url_key = if rewritten != url {
7771 "install_url".to_string()
7772 } else {
7773 key.to_string()
7774 };
7775 return Ok(Some((url_key, rewritten)));
7776 }
7777 let Some(url) = build_oauth_install_url(action, config, context)? else {
7778 return Ok(None);
7779 };
7780 let url_key = match key {
7786 Some(k)
7787 if config
7788 .get(&k)
7789 .and_then(Value::as_str)
7790 .map(str::trim)
7791 .is_none_or(str::is_empty) =>
7792 {
7793 k
7794 }
7795 _ => "oauth_authorize_url".to_string(),
7796 };
7797 Ok(Some((url_key, url)))
7798}
7799
7800fn setup_action_final_url_key(descriptor: &Value) -> Option<String> {
7801 descriptor
7802 .get("actions")
7803 .and_then(Value::as_array)?
7804 .iter()
7805 .filter(|action| action.get("kind").and_then(Value::as_str) == Some("deep_link"))
7806 .find_map(|action| {
7807 let template = action.get("url_template").and_then(Value::as_str)?;
7808 if let Some(name) = template
7809 .strip_prefix('{')
7810 .and_then(|value| value.strip_suffix('}'))
7811 .map(str::trim)
7812 .filter(|value| !value.is_empty())
7813 {
7814 return Some(name.to_string());
7815 }
7816 action
7817 .get("requires")
7818 .and_then(Value::as_array)?
7819 .iter()
7820 .filter_map(Value::as_str)
7821 .find(|value| value.ends_with("_url"))
7822 .map(ToString::to_string)
7823 })
7824}
7825
7826fn resolve_url_template_from_config(
7830 template: &str,
7831 config: &JsonMap<String, Value>,
7832) -> Option<String> {
7833 let mut out = String::new();
7834 let mut rest = template;
7835 while let Some(open) = rest.find('{') {
7836 let close = rest[open..].find('}')? + open;
7837 out.push_str(&rest[..open]);
7838 let name = rest[open + 1..close].trim();
7839 let value = config
7840 .get(name)
7841 .and_then(Value::as_str)
7842 .map(str::trim)
7843 .filter(|value| !value.is_empty())?;
7844 out.push_str(value);
7845 rest = &rest[close + 1..];
7846 }
7847 out.push_str(rest);
7848 Some(out)
7849}
7850
7851fn rewrite_slack_app_redirect(url: &str) -> String {
7856 const MARKER: &str = "/app_redirect?app=";
7857 if let Some(idx) = url.find(MARKER) {
7858 let host = &url[..idx];
7859 if host.ends_with("slack.com") || host.ends_with(".slack.com") {
7860 let app = url[idx + MARKER.len()..]
7861 .split(['&', '#'])
7862 .next()
7863 .unwrap_or("")
7864 .trim();
7865 if !app.is_empty() {
7866 return format!("https://api.slack.com/apps/{app}/install-on-team?");
7867 }
7868 }
7869 }
7870 url.to_string()
7871}
7872
7873fn build_oauth_install_url(
7874 action: &Value,
7875 config: &JsonMap<String, Value>,
7876 context: &SetupActionFinalUrlContext<'_>,
7877) -> Result<Option<String>> {
7878 let SetupActionFinalUrlContext {
7879 bundle_root,
7880 tenant,
7881 team,
7882 provider_id,
7883 action_id,
7884 setup_callback_base,
7885 } = *context;
7886 let Some(authorize_url) = config
7887 .get("oauth_authorize_url")
7888 .and_then(Value::as_str)
7889 .or_else(|| action.get("authorize_url").and_then(Value::as_str))
7890 else {
7891 return Ok(None);
7892 };
7893 let mut parsed = Url::parse(authorize_url).context("setup action authorize_url is invalid")?;
7894 if let Some(client_id_field) = action.get("client_id_field").and_then(Value::as_str)
7895 && let Some(client_id) = config.get(client_id_field).and_then(Value::as_str)
7896 && !client_id.trim().is_empty()
7897 {
7898 set_url_query_key(&mut parsed, "client_id", client_id.trim());
7899 }
7900 if let Some(scopes) = action.get("scopes").and_then(Value::as_array) {
7901 let scopes = scopes
7902 .iter()
7903 .filter_map(Value::as_str)
7904 .map(str::trim)
7905 .filter(|value| !value.is_empty())
7906 .collect::<Vec<_>>();
7907 if !scopes.is_empty() {
7908 let separator = action
7909 .get("scope_separator")
7910 .and_then(Value::as_str)
7911 .unwrap_or(",");
7912 if !url_query_contains(&parsed, "scope") {
7913 parsed
7914 .query_pairs_mut()
7915 .append_pair("scope", &scopes.join(separator));
7916 }
7917 }
7918 }
7919 if let Some(redirect_path) = action.get("redirect_path").and_then(Value::as_str) {
7920 let callback_base = setup_callback_base
7927 .map(|value| value.trim_end_matches('/').to_string())
7928 .or_else(setup_oauth_callback_base_url)
7929 .or_else(|| {
7930 config
7931 .get("public_base_url")
7932 .and_then(Value::as_str)
7933 .map(|value| value.trim().trim_end_matches('/').to_string())
7934 .filter(|value| !value.is_empty())
7935 });
7936 if let Some(callback_base) = callback_base {
7937 let redirect_uri = format!(
7938 "{}{}",
7939 callback_base,
7940 if redirect_path.starts_with('/') {
7941 redirect_path.to_string()
7942 } else {
7943 format!("/{redirect_path}")
7944 }
7945 );
7946 set_url_query_key(&mut parsed, "redirect_uri", &redirect_uri);
7947 }
7948 }
7949 let mut persisted_action = action.clone();
7950 let Some(object) = persisted_action.as_object_mut() else {
7951 anyhow::bail!("setup action must be an object");
7952 };
7953 object.insert("id".to_string(), Value::String(action_id.to_string()));
7954 object.insert(
7955 "provider_id".to_string(),
7956 Value::String(provider_id.to_string()),
7957 );
7958 remove_url_query_key(&mut parsed, "state");
7959 object.insert(
7960 "authorize_url".to_string(),
7961 Value::String(parsed.to_string()),
7962 );
7963 let mut actions = crate::setup_actions::extract_setup_actions(
7964 provider_id,
7965 tenant,
7966 team,
7967 &serde_json::json!({ "setup_actions": [persisted_action] }),
7968 )?;
7969 crate::setup_actions::sign_pending_oauth_actions(bundle_root, &mut actions)?;
7970 crate::setup_actions::persist_setup_actions(bundle_root, &actions)?;
7971 Ok(actions.into_iter().find_map(|action| action.authorize_url))
7972}
7973
7974fn url_query_contains(url: &Url, key: &str) -> bool {
7975 url.query_pairs().any(|(candidate, _)| candidate == key)
7976}
7977
7978fn remove_url_query_key(url: &mut Url, key: &str) {
7979 replace_url_query_pairs(url, |candidate, _| candidate != key);
7980}
7981
7982fn set_url_query_key(url: &mut Url, key: &str, value: &str) {
7983 replace_url_query_pairs(url, |candidate, _| candidate != key);
7984 url.query_pairs_mut().append_pair(key, value);
7985}
7986
7987fn replace_url_query_pairs<F>(url: &mut Url, keep: F)
7988where
7989 F: Fn(&str, &str) -> bool,
7990{
7991 let pairs = url
7992 .query_pairs()
7993 .filter(|(candidate, value)| keep(candidate, value))
7994 .map(|(candidate, value)| (candidate.into_owned(), value.into_owned()))
7995 .collect::<Vec<_>>();
7996 url.set_query(None);
7997 if !pairs.is_empty() {
7998 url.query_pairs_mut().extend_pairs(pairs);
7999 }
8000}
8001
8002fn form_question_to_info(q: &qa_spec::QuestionSpec, i18n: Option<&CliI18n>) -> QuestionInfo {
8003 let visible_if = q.visible_if.as_ref().and_then(|v| match v {
8004 qa_spec::Expr::Eq { left, right } => {
8005 let field = match left.as_ref() {
8006 qa_spec::Expr::Answer { path } => path.clone(),
8007 _ => return None,
8008 };
8009 let eq = match right.as_ref() {
8010 qa_spec::Expr::Literal { value } => {
8011 Some(value.as_str().unwrap_or("true").to_string())
8012 }
8013 _ => None,
8014 };
8015 Some(VisibleIfInfo { field, eq })
8016 }
8017 qa_spec::Expr::Answer { path } => Some(VisibleIfInfo {
8018 field: path.clone(),
8019 eq: None,
8020 }),
8021 _ => None,
8022 });
8023
8024 let title_key = format!("ui.q.{}", q.id);
8026 let help_key = format!("ui.q.{}.help", q.id);
8027
8028 let title = i18n
8029 .and_then(|i| {
8030 let t = i.t(&title_key);
8031 if t != title_key { Some(t) } else { None }
8032 })
8033 .unwrap_or_else(|| q.title.clone());
8034
8035 let help = i18n
8036 .and_then(|i| {
8037 let t = i.t(&help_key);
8038 if t != help_key { Some(t) } else { None }
8039 })
8040 .or_else(|| q.description.clone());
8041
8042 let (list_columns, min_rows, max_rows) = q
8043 .list
8044 .as_ref()
8045 .map(|list| {
8046 let cols: Vec<ListColumnInfo> = list
8047 .fields
8048 .iter()
8049 .map(|c| ListColumnInfo {
8050 id: c.id.clone(),
8051 title: c.title.clone(),
8052 kind: format!("{:?}", c.kind),
8053 required: c.required,
8054 help: c.description.clone(),
8055 placeholder: None,
8056 choices: c.choices.clone(),
8057 default_value: c.default_value.clone(),
8058 multilingual: false,
8063 })
8064 .collect();
8065 (Some(cols), list.min_items, list.max_items)
8066 })
8067 .unwrap_or((None, None, None));
8068
8069 QuestionInfo {
8070 id: q.id.clone(),
8071 title,
8072 kind: format!("{:?}", q.kind),
8073 required: q.required,
8074 secret: q.secret,
8075 default_value: q.default_value.clone(),
8076 saved_value: None,
8077 saved_rows: None,
8078 help,
8079 choices: q.choices.clone(),
8080 visible_if,
8081 placeholder: None,
8082 group: None,
8083 docs_url: None,
8084 create_url: None,
8085 list_columns,
8086 min_rows,
8087 max_rows,
8088 }
8089}
8090
8091#[cfg(test)]
8092mod tests {
8093 use super::{
8094 ProviderSetupEventRequest, TUNNEL_FAILURE_COOLDOWN, UiState, build_router,
8095 persist_provider_setup_event, persist_setup_tunnel_handoff, persist_ui_draft,
8096 prefill_has_cloud_deployment_targets, read_provider_setup_events,
8097 redact_provider_setup_event_detail, setup_backend_record_step_attempt,
8098 setup_backend_runtime_context, setup_backend_runtime_context_current,
8099 setup_backend_tunnel_cooldown_remaining, setup_backend_tunnel_public_base_url_for_port_at,
8100 };
8101 use crate::secrets::open_dev_store;
8102 use axum::body::{Body, to_bytes};
8103 use axum::http::{Request, StatusCode};
8104 use greentic_secrets_lib::{SecretFormat, SecretsStore};
8105 use serde_json::{Map as JsonMap, Value, json};
8106 use std::io::Write;
8107 use std::sync::Mutex;
8108 use tokio::sync::broadcast;
8109 use tower::ServiceExt;
8110 use zip::write::SimpleFileOptions;
8111
8112 fn test_ui_state(bundle_root: &std::path::Path) -> std::sync::Arc<UiState> {
8113 let (shutdown_tx, _) = broadcast::channel(1);
8114 std::sync::Arc::new(UiState {
8115 bundle_path: bundle_root.to_path_buf(),
8116 tenant: "demo".to_string(),
8117 team: Some("support".to_string()),
8118 env: "dev".to_string(),
8119 advanced: false,
8120 locale: None,
8121 prefill_answers: None,
8122 output_target: None,
8123 local_base_url: "http://127.0.0.1:12345".to_string(),
8124 setup_session_id: "test-session".to_string(),
8125 setup_tunnel: Mutex::new(None),
8126 setup_tunnel_start: tokio::sync::Mutex::new(()),
8127 tunnel_failure_cooldown: Mutex::new(None),
8128 setup_runtime: Mutex::new(None),
8129 setup_runtime_start: tokio::sync::Mutex::new(()),
8130 shutdown_tx,
8131 result: Mutex::new(None),
8132 })
8133 }
8134
8135 #[test]
8136 fn providers_catalog_parses_and_refs_are_ghcr() {
8137 let catalog = super::ProviderCatalog::load_embedded().expect("catalog parses");
8138 assert!(!catalog.items.is_empty(), "catalog should not be empty");
8139 for item in &catalog.items {
8140 assert_eq!(item.category, "messaging", "seed is messaging-only");
8141 assert!(
8142 item.reference
8143 .starts_with("oci://ghcr.io/greenticai/packs/"),
8144 "unexpected ref: {}",
8145 item.reference
8146 );
8147 assert!(!item.label.fallback.is_empty(), "label needs a fallback");
8148 }
8149 assert!(
8150 catalog
8151 .items
8152 .iter()
8153 .any(|item| item.id == "messaging-slack"),
8154 "slack should be in the seed catalog"
8155 );
8156 }
8157
8158 #[test]
8159 fn available_provider_items_excludes_installed() {
8160 let catalog = super::ProviderCatalog::load_embedded().expect("catalog");
8161 let mut installed = std::collections::HashSet::new();
8162 installed.insert("messaging-slack".to_string());
8163 let items = super::available_provider_items(catalog, &installed);
8164 assert!(
8165 items.iter().all(|item| item.id != "messaging-slack"),
8166 "installed provider must be filtered out"
8167 );
8168 assert!(
8169 items.iter().any(|item| item.id == "messaging-telegram"),
8170 "uninstalled providers remain addable"
8171 );
8172 }
8173
8174 #[tokio::test]
8175 async fn add_provider_copies_pack_into_bundle_providers_dir() {
8176 let bundle = tempfile::tempdir().expect("bundle");
8177 let src = bundle.path().join("src-slack.gtpack");
8181 std::fs::write(&src, b"fake-pack-bytes").expect("write src pack");
8182 let item = super::ProviderCatalogItem {
8183 id: "messaging-slack".to_string(),
8184 category: "messaging".to_string(),
8185 label: super::ProviderCatalogLabel {
8186 i18n_key: None,
8187 fallback: "Slack".to_string(),
8188 },
8189 reference: src.to_string_lossy().to_string(),
8190 };
8191
8192 super::install_catalog_provider(bundle.path(), &item)
8193 .await
8194 .expect("install succeeds");
8195
8196 let dest = bundle
8197 .path()
8198 .join("providers/messaging/messaging-slack.gtpack");
8199 assert!(dest.is_file(), "pack should land in providers/messaging");
8200 assert_eq!(std::fs::read(&dest).unwrap(), b"fake-pack-bytes");
8201 }
8202
8203 #[tokio::test]
8204 async fn available_providers_endpoint_lists_catalog_for_empty_bundle() {
8205 let bundle = tempfile::tempdir().expect("bundle");
8206 let state = test_ui_state(bundle.path());
8207 let response = build_router(state)
8208 .oneshot(
8209 Request::builder()
8210 .uri("/api/available-providers")
8211 .body(Body::empty())
8212 .unwrap(),
8213 )
8214 .await
8215 .unwrap();
8216
8217 assert_eq!(response.status(), StatusCode::OK);
8218 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
8219 let json: Value = serde_json::from_slice(&bytes).unwrap();
8220 let items = json["items"].as_array().expect("items array");
8221 assert!(
8222 items.iter().any(|item| item["id"] == "messaging-slack"),
8223 "empty bundle should offer the full catalog"
8224 );
8225 }
8226
8227 #[test]
8228 fn app_js_renders_the_add_providers_list() {
8229 let app_js = super::assets::APP_JS;
8232 assert!(
8233 app_js.contains("provider-add-list"),
8234 "scrollable list markup"
8235 );
8236 assert!(app_js.contains("data-provider-add"), "add button hook");
8237 assert!(app_js.contains("/api/available-providers"), "catalog fetch");
8238 assert!(app_js.contains("/api/add-provider"), "add call");
8239 assert!(app_js.contains("btn-spinner"), "add button shows a loader");
8241 assert!(
8242 app_js.contains("page-blocker"),
8243 "page is blocked during add"
8244 );
8245 assert!(
8246 app_js.contains("state.addingId"),
8247 "in-flight add is tracked"
8248 );
8249 }
8250
8251 #[test]
8252 fn port_keyed_tunnel_resolution_prefers_slot_then_record() {
8253 let temp = tempfile::tempdir().expect("tempdir");
8254 let record_root = tempfile::tempdir().expect("record root");
8255 let state = test_ui_state(temp.path());
8256
8257 *state.setup_tunnel.lock().unwrap() = Some(crate::setup_tunnel::SetupTunnel::detached(
8260 "cloudflared",
8261 "http://127.0.0.1:12345",
8262 "https://ui-tunnel.trycloudflare.com",
8263 ));
8264 let paths = crate::shared_tunnel::shared_tunnel_paths_at(record_root.path(), 8080);
8266 crate::shared_tunnel::write_record(&paths, 4242, "https://ingress.trycloudflare.com")
8267 .expect("write record");
8268
8269 assert_eq!(
8271 setup_backend_tunnel_public_base_url_for_port_at(
8272 &state,
8273 12345,
8274 Some(record_root.path())
8275 )
8276 .as_deref(),
8277 Some("https://ui-tunnel.trycloudflare.com"),
8278 );
8279 assert_eq!(
8281 setup_backend_tunnel_public_base_url_for_port_at(
8282 &state,
8283 8080,
8284 Some(record_root.path())
8285 )
8286 .as_deref(),
8287 Some("https://ingress.trycloudflare.com"),
8288 );
8289 assert_eq!(
8291 setup_backend_tunnel_public_base_url_for_port_at(
8292 &state,
8293 9999,
8294 Some(record_root.path())
8295 ),
8296 None,
8297 );
8298 }
8299
8300 #[test]
8301 fn runtime_context_never_reports_the_setup_ui_slot_when_ingress_port_is_known() {
8302 let temp = tempfile::tempdir().expect("tempdir");
8303 let state = test_ui_state(temp.path());
8304 let runtime_dir = temp.path().join("state/runtime/demo.support");
8308 std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
8309 std::fs::write(
8310 runtime_dir.join("endpoints.json"),
8311 serde_json::to_string(&json!({
8312 "tenant": "demo",
8313 "team": "support",
8314 "public_base_url": "https://ingress.trycloudflare.com",
8315 "gateway_listen_addr": "127.0.0.1",
8316 "gateway_port": 47391
8317 }))
8318 .unwrap(),
8319 )
8320 .expect("endpoints.json");
8321 *state.setup_tunnel.lock().unwrap() = Some(crate::setup_tunnel::SetupTunnel::detached(
8323 "cloudflared",
8324 "http://127.0.0.1:12345",
8325 "https://ui-tunnel.trycloudflare.com",
8326 ));
8327 let mut config = JsonMap::new();
8328 config.insert(
8329 "public_base_url".to_string(),
8330 json!("https://ingress.trycloudflare.com"),
8331 );
8332
8333 let context = setup_backend_runtime_context(&state, "demo", &config);
8334 assert_eq!(
8338 context
8339 .get("active_tunnel_public_base_url")
8340 .and_then(Value::as_str),
8341 Some("https://ingress.trycloudflare.com"),
8342 );
8343 assert!(setup_backend_runtime_context_current(
8344 &json!({"runtime_context": {"public_base_url": "https://ingress.trycloudflare.com"}}),
8345 &context
8346 ));
8347 }
8348
8349 #[test]
8350 fn runtime_context_unknown_ingress_is_not_stale() {
8351 let temp = tempfile::tempdir().expect("tempdir");
8354 let state = test_ui_state(temp.path());
8355 *state.setup_tunnel.lock().unwrap() = Some(crate::setup_tunnel::SetupTunnel::detached(
8356 "cloudflared",
8357 "http://127.0.0.1:12345",
8358 "https://ui-tunnel.trycloudflare.com",
8359 ));
8360 let mut config = JsonMap::new();
8361 config.insert(
8362 "public_base_url".to_string(),
8363 json!("https://ingress.trycloudflare.com"),
8364 );
8365 let context = setup_backend_runtime_context(&state, "demo", &config);
8366 assert_eq!(
8367 context
8368 .get("active_tunnel_public_base_url")
8369 .and_then(Value::as_str),
8370 None,
8371 "no runtime + no record + Setup-UI slot must resolve to nothing"
8372 );
8373 assert!(setup_backend_runtime_context_current(
8377 &json!({"runtime_context": {"public_base_url": "https://ingress.trycloudflare.com"}}),
8378 &context
8379 ));
8380 }
8381
8382 #[test]
8383 fn runtime_context_current_matches_ingress_tunnel_not_setup_ui_tunnel() {
8384 let ingress = "https://ingress.trycloudflare.com";
8385 let stored = serde_json::json!({
8386 "runtime_context": { "public_base_url": ingress }
8387 });
8388 let current_ok = serde_json::json!({
8391 "public_base_url": ingress,
8392 "public_base_url_is_ephemeral_tunnel": true,
8393 "active_tunnel_public_base_url": ingress,
8394 });
8395 assert!(setup_backend_runtime_context_current(&stored, ¤t_ok));
8396 let current_mismatch = serde_json::json!({
8400 "public_base_url": ingress,
8401 "public_base_url_is_ephemeral_tunnel": true,
8402 "active_tunnel_public_base_url": "https://ui-tunnel.trycloudflare.com",
8403 });
8404 assert!(!setup_backend_runtime_context_current(
8405 &stored,
8406 ¤t_mismatch
8407 ));
8408 }
8409
8410 #[test]
8411 fn tunnel_handoff_never_persists_the_setup_ui_port() {
8412 let bundle = tempfile::tempdir().expect("bundle");
8413 let setup_ui = "http://127.0.0.1:12345";
8414 let handoff_path = bundle
8415 .path()
8416 .join("state/config/platform/tunnel-handoff.json");
8417
8418 let ui_tunnel = crate::setup_tunnel::SetupTunnel::detached(
8421 "cloudflared",
8422 setup_ui,
8423 "https://ui-tunnel.trycloudflare.com",
8424 );
8425 persist_setup_tunnel_handoff(bundle.path(), setup_ui, &ui_tunnel);
8426 assert!(
8427 !handoff_path.exists(),
8428 "Setup-UI-port tunnel must not be handed off"
8429 );
8430
8431 let ingress_tunnel = crate::setup_tunnel::SetupTunnel::detached(
8433 "cloudflared",
8434 "http://127.0.0.1:8080",
8435 "https://ingress.trycloudflare.com",
8436 );
8437 persist_setup_tunnel_handoff(bundle.path(), setup_ui, &ingress_tunnel);
8438 let handoff: Value =
8439 serde_json::from_str(&std::fs::read_to_string(&handoff_path).expect("handoff written"))
8440 .expect("valid json");
8441 assert_eq!(
8442 handoff.get("local_port").and_then(Value::as_u64),
8443 Some(8080)
8444 );
8445 }
8446
8447 #[test]
8448 fn step_attempt_counter_increments_and_resets_on_url_change() {
8449 let mut stored = JsonMap::new();
8450 stored.insert(
8451 "config".to_string(),
8452 serde_json::json!({"public_base_url": "https://a.trycloudflare.com"}),
8453 );
8454 assert_eq!(setup_backend_record_step_attempt(&mut stored, "reg"), 1);
8455 assert_eq!(setup_backend_record_step_attempt(&mut stored, "reg"), 2);
8456 assert_eq!(setup_backend_record_step_attempt(&mut stored, "reg"), 3);
8457 assert_eq!(setup_backend_record_step_attempt(&mut stored, "publish"), 1);
8459 stored.insert(
8461 "config".to_string(),
8462 serde_json::json!({"public_base_url": "https://b.trycloudflare.com"}),
8463 );
8464 assert_eq!(setup_backend_record_step_attempt(&mut stored, "reg"), 1);
8465 assert_eq!(
8467 stored["step_attempts"]["reg"]["count"].as_u64(),
8468 Some(1),
8469 "persisted counter reflects the reset"
8470 );
8471 }
8472
8473 #[test]
8474 fn tunnel_cooldown_blocks_immediately_after_a_failure_then_clears() {
8475 let temp = tempfile::tempdir().expect("tempdir");
8476 let state = test_ui_state(temp.path());
8477
8478 assert_eq!(
8479 setup_backend_tunnel_cooldown_remaining(&state).unwrap(),
8480 None,
8481 "no failure recorded yet, so no cooldown"
8482 );
8483
8484 *state.tunnel_failure_cooldown.lock().unwrap() = Some(std::time::Instant::now());
8485 let remaining = setup_backend_tunnel_cooldown_remaining(&state)
8486 .unwrap()
8487 .expect("should be in cooldown right after a recorded failure");
8488 assert!(remaining <= TUNNEL_FAILURE_COOLDOWN && remaining > std::time::Duration::ZERO);
8489
8490 let long_ago = std::time::Instant::now() - (TUNNEL_FAILURE_COOLDOWN * 2);
8493 *state.tunnel_failure_cooldown.lock().unwrap() = Some(long_ago);
8494 assert_eq!(
8495 setup_backend_tunnel_cooldown_remaining(&state).unwrap(),
8496 None,
8497 "cooldown window has already elapsed"
8498 );
8499 }
8500
8501 fn test_jwt_with_exp(exp: u64) -> String {
8502 let header = base64::Engine::encode(
8503 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
8504 br#"{"alg":"none"}"#,
8505 );
8506 let claims = base64::Engine::encode(
8507 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
8508 format!(r#"{{"exp":{exp}}}"#),
8509 );
8510 format!("{header}.{claims}.")
8511 }
8512
8513 fn write_pack_with_secret_requirements(
8514 path: &std::path::Path,
8515 pack_id: &str,
8516 req_json: &str,
8517 ) -> anyhow::Result<()> {
8518 let file = std::fs::File::create(path)?;
8519 let mut zip = zip::ZipWriter::new(file);
8520 zip.start_file("manifest.json", SimpleFileOptions::default())?;
8521 zip.write_all(format!(r#"{{"pack_id":"{pack_id}"}}"#).as_bytes())?;
8522 zip.start_file(
8523 "assets/secret-requirements.json",
8524 SimpleFileOptions::default(),
8525 )?;
8526 zip.write_all(req_json.as_bytes())?;
8527 zip.finish()?;
8528 Ok(())
8529 }
8530
8531 fn write_pack_with_setup_backend_contract(
8532 path: &std::path::Path,
8533 provider_id: &str,
8534 ) -> anyhow::Result<()> {
8535 let file = std::fs::File::create(path)?;
8536 let mut zip = zip::ZipWriter::new(file);
8537 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8538 zip.write_all(
8539 json!({
8540 "pack_id": provider_id,
8541 "display_name": "Contract Provider",
8542 "extensions": {
8543 "greentic.setup.backend-contract.v1": {
8544 "inline": {
8545 "schema_id": "greentic.setup.backend-contract.v1",
8546 "schema_version": "1.0.0",
8547 "provider_id": provider_id,
8548 "base_path": format!("/v1/messaging/setup/{provider_id}/{{tenant}}"),
8549 "routes": {
8550 "state": format!("GET /v1/messaging/setup/{provider_id}/{{tenant}}"),
8551 "next": format!("POST /v1/messaging/setup/{provider_id}/{{tenant}}/next"),
8552 "config": format!("POST /v1/messaging/setup/{provider_id}/{{tenant}}/config")
8553 },
8554 "server_owned_config_keys": [
8555 "oauth_kind",
8556 "oauth_device_code",
8557 "oauth_user_code",
8558 "graph_access_token",
8559 "azure_management_access_token",
8560 "bot_access_token"
8561 ],
8562 "required_order": [
8563 "admin_consent",
8564 "publish",
8565 "first_runtime_evidence"
8566 ],
8567 "state_shape": {
8568 "setup_status": {
8569 "ok": "boolean",
8570 "items": "array",
8571 "next": "string"
8572 },
8573 "values": {
8574 "config": "object"
8575 }
8576 }
8577 }
8578 }
8579 }
8580 })
8581 .to_string()
8582 .as_bytes(),
8583 )?;
8584 zip.finish()?;
8585 Ok(())
8586 }
8587
8588 fn write_pack_with_setup_machine(
8589 path: &std::path::Path,
8590 provider_id: &str,
8591 ) -> anyhow::Result<()> {
8592 let file = std::fs::File::create(path)?;
8593 let mut zip = zip::ZipWriter::new(file);
8594 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8595 zip.write_all(
8596 json!({
8597 "pack_id": provider_id,
8598 "display_name": "Machine Provider",
8599 "extensions": {
8600 "greentic.setup.machine.v1": {
8601 "inline": {
8602 "version": 1,
8603 "id": "ui-machine",
8604 "display_name": "UI Machine",
8605 "entry_step": "start",
8606 "steps": [
8607 {
8608 "id": "start",
8609 "kind": "manual_action",
8610 "title": "Start",
8611 "auto_complete": true,
8612 "on_success": "complete"
8613 }
8614 ]
8615 }
8616 }
8617 }
8618 })
8619 .to_string()
8620 .as_bytes(),
8621 )?;
8622 zip.finish()?;
8623 Ok(())
8624 }
8625
8626 fn write_pack_with_final_setup_actions(
8627 path: &std::path::Path,
8628 provider_id: &str,
8629 ) -> anyhow::Result<()> {
8630 let file = std::fs::File::create(path)?;
8631 let mut zip = zip::ZipWriter::new(file);
8632 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8633 zip.write_all(
8634 json!({
8635 "pack_id": provider_id,
8636 "display_name": "Action Provider",
8637 "extensions": {
8638 "greentic.setup.actions.v1": {
8639 "kind": "greentic.setup.actions.v1",
8640 "inline": {
8641 "schema_id": "greentic.setup.actions.v1",
8642 "provider_id": provider_id,
8643 "actions": [{
8644 "id": "add-to-provider",
8645 "label": "Add to Provider",
8646 "kind": "deep_link",
8647 "url_template": "{add_url}",
8648 "requires": ["add_url"],
8649 "visible_when": {
8650 "setup_status.ok": true
8651 }
8652 }]
8653 }
8654 }
8655 }
8656 })
8657 .to_string()
8658 .as_bytes(),
8659 )?;
8660 zip.finish()?;
8661 Ok(())
8662 }
8663
8664 fn write_pack_with_legacy_setup_actions(
8665 path: &std::path::Path,
8666 provider_id: &str,
8667 ) -> anyhow::Result<()> {
8668 let file = std::fs::File::create(path)?;
8669 let mut zip = zip::ZipWriter::new(file);
8670 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8671 zip.write_all(
8672 json!({
8673 "pack_id": provider_id,
8674 "display_name": "Legacy Action Provider"
8675 })
8676 .to_string()
8677 .as_bytes(),
8678 )?;
8679 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())?;
8680 zip.write_all(
8681 br#"
8682provider_id: generic
8683version: 1
8684title: Generic provider setup
8685setup_actions:
8686 - id: create_app
8687 label: Create Provider App
8688 kind: oauth_install_button
8689 provider_id: provider-alias
8690 authorize_url: "https://provider.example/install"
8691 redirect_path: "/oauth/callback/provider"
8692 client_id_field: provider_client_id
8693 scopes:
8694 - chat:write
8695 registration:
8696 component_ref: provider-setup
8697 op: setup_app_registration
8698 mock_result:
8699 ok: true
8700 provider_client_id: generated-client
8701 provider_client_secret: generated-secret
8702 oauth_authorize_url: https://provider.example/install?client_id=stale-client&redirect_uri=https%3A%2F%2Fold.example.test%2Fcallback&state=provider-state
8703"#,
8704 )?;
8705 zip.finish()?;
8706 Ok(())
8707 }
8708
8709 fn write_pack_with_final_and_legacy_setup_actions(
8710 path: &std::path::Path,
8711 provider_id: &str,
8712 ) -> anyhow::Result<()> {
8713 let file = std::fs::File::create(path)?;
8714 let mut zip = zip::ZipWriter::new(file);
8715 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8716 zip.write_all(
8717 json!({
8718 "pack_id": provider_id,
8719 "display_name": "Combined Action Provider",
8720 "extensions": {
8721 "greentic.setup.actions.v1": {
8722 "kind": "greentic.setup.actions.v1",
8723 "inline": {
8724 "schema_id": "greentic.setup.actions.v1",
8725 "provider_id": provider_id,
8726 "actions": [{
8727 "id": "add-to-provider",
8728 "label": "Add to Provider",
8729 "kind": "deep_link",
8730 "url_template": "{add_url}",
8731 "requires": ["add_url"],
8732 "visible_when": {
8733 "setup_status.ok": true
8734 }
8735 }]
8736 }
8737 }
8738 }
8739 })
8740 .to_string()
8741 .as_bytes(),
8742 )?;
8743 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())?;
8744 zip.write_all(
8745 br#"
8746provider_id: generic
8747version: 1
8748title: Generic provider setup
8749setup_actions:
8750 - id: create_app
8751 label: Create Provider App
8752 kind: oauth_install_button
8753 provider_id: provider-alias
8754 authorize_url: "https://provider.example/install"
8755 redirect_path: "/oauth/callback/provider"
8756 client_id_field: provider_client_id
8757 scopes:
8758 - chat:write
8759 registration:
8760 component_ref: provider-setup
8761 op: setup_app_registration
8762 mock_result:
8763 ok: true
8764 provider_client_id: generated-client
8765 provider_client_secret: generated-secret
8766 oauth_authorize_url: https://provider.example/install?client_id=stale-client&redirect_uri=https%3A%2F%2Fold.example.test%2Fcallback&state=provider-state
8767"#,
8768 )?;
8769 zip.finish()?;
8770 Ok(())
8771 }
8772
8773 fn write_pack_with_secret_and_public_questions(
8774 path: &std::path::Path,
8775 provider_id: &str,
8776 ) -> anyhow::Result<()> {
8777 let file = std::fs::File::create(path)?;
8778 let mut zip = zip::ZipWriter::new(file);
8779 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8780 zip.write_all(
8781 json!({
8782 "pack_id": provider_id,
8783 "display_name": "Question Provider"
8784 })
8785 .to_string()
8786 .as_bytes(),
8787 )?;
8788 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())?;
8789 zip.write_all(
8790 br#"
8791provider_id: generic
8792version: 1
8793title: Generic provider setup
8794questions:
8795 - name: provider_public_field
8796 title: Public field
8797 kind: string
8798 required: false
8799 - name: provider_secret_field
8800 title: Secret field
8801 kind: string
8802 required: true
8803 secret: true
8804"#,
8805 )?;
8806 zip.finish()?;
8807 Ok(())
8808 }
8809
8810 fn write_pack_with_asset_setup_backend_contract(
8811 path: &std::path::Path,
8812 provider_id: &str,
8813 write_asset: bool,
8814 ) -> anyhow::Result<()> {
8815 let file = std::fs::File::create(path)?;
8816 let mut zip = zip::ZipWriter::new(file);
8817 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8818 zip.write_all(
8819 json!({
8820 "pack_id": provider_id,
8821 "display_name": "Asset Contract Provider",
8822 "extensions": {
8823 "greentic.setup.backend-contract.v1": {
8824 "inline": {
8825 "schema_id": "greentic.setup.backend-contract.v1",
8826 "provider_id": provider_id,
8827 "asset": "assets/setup/backend-contract.json"
8828 }
8829 }
8830 }
8831 })
8832 .to_string()
8833 .as_bytes(),
8834 )?;
8835 if write_asset {
8836 zip.start_file(
8837 "assets/setup/backend-contract.json",
8838 SimpleFileOptions::default(),
8839 )?;
8840 zip.write_all(
8841 json!({
8842 "schema_id": "greentic.setup.backend-contract.v1",
8843 "schema_version": "1.0.0",
8844 "provider_id": provider_id,
8845 "base_path": format!("/v1/messaging/setup/{provider_id}/{{tenant}}"),
8846 "routes": {
8847 "state": format!("GET /v1/messaging/setup/{provider_id}/{{tenant}}"),
8848 "next": format!("POST /v1/messaging/setup/{provider_id}/{{tenant}}/next"),
8849 "config": format!("POST /v1/messaging/setup/{provider_id}/{{tenant}}/config")
8850 },
8851 "server_owned_config_keys": [
8852 "oauth_kind",
8853 "oauth_device_code",
8854 "oauth_user_code",
8855 "graph_access_token",
8856 "azure_management_access_token",
8857 "bot_access_token"
8858 ],
8859 "required_order": [
8860 "graph_admin_consent",
8861 "bot_app_identity",
8862 "bot_framework_endpoint_registration"
8863 ],
8864 "states": [
8865 {"id": "graph_admin_consent"},
8866 {"id": "bot_app_identity"},
8867 {"id": "bot_framework_endpoint_registration"}
8868 ],
8869 "guards": [
8870 {"id": "server-owned-oauth-state"}
8871 ]
8872 })
8873 .to_string()
8874 .as_bytes(),
8875 )?;
8876 }
8877 zip.finish()?;
8878 Ok(())
8879 }
8880
8881 fn write_pack_with_unsupported_setup_action(
8882 path: &std::path::Path,
8883 provider_id: &str,
8884 ) -> anyhow::Result<()> {
8885 let file = std::fs::File::create(path)?;
8886 let mut zip = zip::ZipWriter::new(file);
8887 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
8888 zip.write_all(
8889 json!({
8890 "pack_id": provider_id,
8891 "display_name": "Unsupported Action Provider",
8892 "extensions": {
8893 "greentic.setup.backend-contract.v1": {
8894 "inline": {
8895 "schema_id": "greentic.setup.backend-contract.v1",
8896 "schema_version": "1.0.0",
8897 "provider_id": provider_id,
8898 "base_path": format!("/v1/messaging/setup/{provider_id}/{{tenant}}"),
8899 "routes": {
8900 "state": format!("GET /v1/messaging/setup/{provider_id}/{{tenant}}"),
8901 "next": format!("POST /v1/messaging/setup/{provider_id}/{{tenant}}/next"),
8902 "config": format!("POST /v1/messaging/setup/{provider_id}/{{tenant}}/config")
8903 },
8904 "required_order": ["custom_step"],
8905 "actions_schema_id": "greentic.setup.actions.v1",
8906 "actions": [{
8907 "id": "custom_step",
8908 "executor": {
8909 "kind": "future_executor"
8910 }
8911 }]
8912 }
8913 }
8914 }
8915 })
8916 .to_string()
8917 .as_bytes(),
8918 )?;
8919 zip.finish()?;
8920 Ok(())
8921 }
8922
8923 #[test]
8924 fn oauth_callback_page_tells_user_to_close_success_tab() {
8925 let page = super::oauth_callback_page(
8926 true,
8927 "OAuth setup complete",
8928 "OAuth setup complete for messaging-slack. You can close this tab.",
8929 );
8930
8931 assert!(page.contains("window.close()"));
8932 assert!(page.contains("You can close this tab"));
8933 }
8934
8935 #[test]
8936 fn oauth_device_code_client_id_uses_executor_default_without_materializing_config() {
8937 let executor = json!({
8938 "kind": "oauth_device_code",
8939 "client_id_config_key": "graph_setup_client_id",
8940 "client_id_default": "14d82eec-204b-4c2f-b7e8-296a70dab67e",
8941 "client_id_default_name": "Microsoft Graph Command Line Tools"
8942 });
8943 let config = JsonMap::new();
8944
8945 let client_id = super::setup_backend_oauth_client_id(&executor, &config).unwrap();
8946
8947 assert_eq!(client_id, "14d82eec-204b-4c2f-b7e8-296a70dab67e");
8948 assert!(config.get("graph_setup_client_id").is_none());
8949 }
8950
8951 #[test]
8952 fn oauth_device_code_client_id_stays_empty_without_config_or_default() {
8953 let executor = json!({
8954 "kind": "oauth_device_code",
8955 "client_id_config_key": "graph_setup_client_id"
8956 });
8957 let config = JsonMap::new();
8958
8959 let client_id = super::setup_backend_oauth_client_id(&executor, &config).unwrap();
8960
8961 assert!(client_id.is_empty());
8962 }
8963
8964 #[test]
8965 fn setup_backend_public_config_hides_server_owned_device_code_and_tokens() {
8966 let mut config = JsonMap::new();
8967 config.insert("oauth_kind".to_string(), Value::String("graph".to_string()));
8968 config.insert(
8969 "oauth_user_code".to_string(),
8970 Value::String("ABCD-EFGH".to_string()),
8971 );
8972 config.insert(
8973 "oauth_device_code".to_string(),
8974 Value::String("raw-device-code".to_string()),
8975 );
8976 config.insert(
8977 "graph_access_token".to_string(),
8978 Value::String("raw-access-token".to_string()),
8979 );
8980
8981 let public = super::setup_backend_public_config(&config);
8982
8983 assert_eq!(public["oauth_kind"], "graph");
8984 assert_eq!(public["oauth_user_code"], "ABCD-EFGH");
8985 assert!(public.get("oauth_device_code").is_none());
8986 assert!(public.get("graph_access_token").is_none());
8987 }
8988
8989 #[test]
8990 fn setup_backend_defaults_include_public_base_url_from_static_routes() {
8991 let temp = tempfile::tempdir().expect("tempdir");
8992 crate::platform_setup::persist_static_routes_artifact(
8993 temp.path(),
8994 &crate::platform_setup::StaticRoutesPolicy {
8995 public_web_enabled: true,
8996 public_base_url: Some("https://runtime.example.com/base/".to_string()),
8997 ..crate::platform_setup::StaticRoutesPolicy::default()
8998 },
8999 )
9000 .expect("static routes");
9001 let state = test_ui_state(temp.path());
9002
9003 let config = super::default_setup_backend_config_with_runtime_base(&state, "demo", None);
9004
9005 assert_eq!(
9006 config["public_base_url"],
9007 "https://runtime.example.com/base"
9008 );
9009 }
9010
9011 #[test]
9012 fn setup_backend_defaults_replace_empty_host_values() {
9013 let temp = tempfile::tempdir().expect("tempdir");
9014 crate::platform_setup::persist_static_routes_artifact(
9015 temp.path(),
9016 &crate::platform_setup::StaticRoutesPolicy {
9017 public_web_enabled: true,
9018 public_base_url: Some("https://runtime.example.com".to_string()),
9019 ..crate::platform_setup::StaticRoutesPolicy::default()
9020 },
9021 )
9022 .expect("static routes");
9023 let state = test_ui_state(temp.path());
9024 let mut stored = JsonMap::new();
9025 stored.insert(
9026 "config".to_string(),
9027 json!({
9028 "tenant": "demo",
9029 "team": "support",
9030 "public_base_url": ""
9031 }),
9032 );
9033
9034 super::ensure_setup_backend_config_defaults(&state, "demo", &mut stored).unwrap();
9035 let config = stored["config"].as_object().unwrap();
9036
9037 assert_eq!(config["public_base_url"], "https://runtime.example.com");
9038 }
9039
9040 #[test]
9041 fn setup_backend_defaults_do_not_include_provider_setup_runtime_base_url() {
9042 let temp = tempfile::tempdir().expect("tempdir");
9043 let state = test_ui_state(temp.path());
9044
9045 let config = super::default_setup_backend_config_with_runtime_base(
9046 &state,
9047 "demo",
9048 Some("http://127.0.0.1:9101/"),
9049 );
9050
9051 assert!(config.get("provider_setup_base_url").is_none());
9052 }
9053
9054 #[test]
9055 fn setup_backend_defaults_do_not_include_provider_setup_base_from_runtime_artifact() {
9056 let temp = tempfile::tempdir().expect("tempdir");
9057 let runtime_dir = temp
9058 .path()
9059 .join("state")
9060 .join("runtime")
9061 .join("demo.support");
9062 std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
9063 std::fs::write(
9064 runtime_dir.join("endpoints.json"),
9065 json!({
9066 "tenant": "demo",
9067 "team": "support",
9068 "gateway_listen_addr": "127.0.0.1",
9069 "gateway_port": 8081
9070 })
9071 .to_string(),
9072 )
9073 .expect("runtime endpoints");
9074 let state = test_ui_state(temp.path());
9075
9076 let config = super::default_setup_backend_config(&state, "demo");
9077
9078 assert!(config.get("provider_setup_base_url").is_none());
9079 }
9080
9081 #[test]
9082 fn setup_backend_infers_tunnel_mode_from_ephemeral_public_base_url() {
9083 assert_eq!(
9084 super::setup_backend_infer_tunnel_mode_from_public_base_url(
9085 "https://demo.trycloudflare.com"
9086 )
9087 .as_deref(),
9088 Some("cloudflared")
9089 );
9090 assert_eq!(
9091 super::setup_backend_infer_tunnel_mode_from_public_base_url(
9092 "https://demo.ngrok-free.app"
9093 )
9094 .as_deref(),
9095 Some("ngrok")
9096 );
9097 assert_eq!(
9098 super::setup_backend_infer_tunnel_mode_from_public_base_url(
9099 "https://runtime.example.com"
9100 ),
9101 None
9102 );
9103 }
9104
9105 #[test]
9106 fn setup_backend_tunnel_mode_defaults_to_cloudflared_for_local_setup() {
9107 let temp = tempfile::tempdir().expect("tempdir");
9108 let state = test_ui_state(temp.path());
9109
9110 let mode = super::setup_backend_tunnel_mode(&state).expect("tunnel mode");
9111
9112 assert_eq!(mode.as_deref(), Some("cloudflared"));
9113 }
9114
9115 #[test]
9116 fn setup_backend_tunnel_mode_honors_persisted_off() {
9117 let temp = tempfile::tempdir().expect("tempdir");
9118 crate::platform_setup::persist_tunnel_artifact(
9119 temp.path(),
9120 &crate::platform_setup::TunnelAnswers {
9121 mode: Some("off".to_string()),
9122 },
9123 )
9124 .expect("persist tunnel");
9125 let state = test_ui_state(temp.path());
9126
9127 let mode = super::setup_backend_tunnel_mode(&state).expect("tunnel mode");
9128
9129 assert_eq!(mode.as_deref(), Some("off"));
9130 }
9131
9132 #[test]
9133 fn setup_backend_tunnel_mode_honors_persisted_ngrok() {
9134 let temp = tempfile::tempdir().expect("tempdir");
9135 crate::platform_setup::persist_tunnel_artifact(
9136 temp.path(),
9137 &crate::platform_setup::TunnelAnswers {
9138 mode: Some("ngrok".to_string()),
9139 },
9140 )
9141 .expect("persist tunnel");
9142 let state = test_ui_state(temp.path());
9143
9144 let mode = super::setup_backend_tunnel_mode(&state).expect("tunnel mode");
9145
9146 assert_eq!(mode.as_deref(), Some("ngrok"));
9147 }
9148
9149 #[test]
9150 fn setup_action_tunnel_mode_uses_persisted_selection_when_request_omits_tunnel() {
9151 let temp = tempfile::tempdir().expect("tempdir");
9152 crate::platform_setup::persist_tunnel_artifact(
9153 temp.path(),
9154 &crate::platform_setup::TunnelAnswers {
9155 mode: Some("ngrok".to_string()),
9156 },
9157 )
9158 .expect("persist tunnel");
9159 let state = test_ui_state(temp.path());
9160
9161 assert_eq!(
9162 super::setup_action_tunnel_mode(&state, None).unwrap(),
9163 "ngrok"
9164 );
9165 assert_eq!(
9166 super::setup_action_tunnel_mode(&state, Some("cloudflared")).unwrap(),
9167 "cloudflared"
9168 );
9169 }
9170
9171 #[test]
9172 fn setup_action_provider_answers_allow_public_base_url_injection_for_empty_request() {
9173 let mut answers = JsonMap::new();
9174
9175 super::ensure_setup_action_provider_answers(&mut answers, "messaging-example");
9176
9177 assert!(crate::setup_tunnel::should_start_setup_tunnel(
9178 "ngrok", &answers
9179 ));
9180 crate::setup_tunnel::inject_setup_public_base_url(
9181 &mut answers,
9182 "https://example.ngrok-free.app",
9183 );
9184 assert_eq!(
9185 answers["messaging-example"]["public_base_url"],
9186 json!("https://example.ngrok-free.app")
9187 );
9188 }
9189
9190 #[test]
9191 fn setup_backend_tunnel_mode_does_not_default_when_deployer_pack_exists() {
9192 let temp = tempfile::tempdir().expect("tempdir");
9193 let packs_dir = temp.path().join("packs");
9194 std::fs::create_dir_all(&packs_dir).expect("packs dir");
9195 std::fs::write(packs_dir.join("terraform.gtpack"), b"placeholder").expect("pack");
9196 let state = test_ui_state(temp.path());
9197
9198 let mode = super::setup_backend_tunnel_mode(&state).expect("tunnel mode");
9199
9200 assert_eq!(mode, None);
9201 }
9202
9203 #[test]
9204 fn setup_backend_completion_rejects_stale_ephemeral_tunnel_state() {
9205 let temp = tempfile::tempdir().expect("tempdir");
9206 let state = test_ui_state(temp.path());
9207 let contract = super::ProviderBackendContract {
9208 provider_id: "messaging-example".to_string(),
9209 inline: json!({
9210 "schema_id": "greentic.setup.backend-contract.v1",
9211 "required_order": ["register_endpoint"],
9212 "actions": [{
9213 "id": "register_endpoint",
9214 "completion": {
9215 "state_path": "last_reconcile.ok",
9216 "equals": true
9217 },
9218 "executor": {
9219 "kind": "provider_http",
9220 "state_store_key": "last_reconcile"
9221 }
9222 }]
9223 }),
9224 load_error: None,
9225 };
9226 let mut stored = JsonMap::new();
9227 stored.insert(
9228 "config".to_string(),
9229 json!({
9230 "tenant": "demo",
9231 "team": "support",
9232 "public_base_url": "https://old.trycloudflare.com"
9233 }),
9234 );
9235 stored.insert("last_reconcile".to_string(), json!({"ok": true}));
9236
9237 let rendered =
9238 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
9239
9240 assert_eq!(rendered["setup_status"]["ok"], false);
9241 assert_eq!(rendered["setup_status"]["items"][0]["state"], "pending");
9242 }
9243
9244 #[test]
9245 fn setup_backend_completion_accepts_current_successful_action_with_ephemeral_context() {
9246 let temp = tempfile::tempdir().expect("tempdir");
9247 let state = test_ui_state(temp.path());
9248 let contract = super::ProviderBackendContract {
9249 provider_id: "messaging-example".to_string(),
9250 inline: json!({
9251 "schema_id": "greentic.setup.backend-contract.v1",
9252 "required_order": ["register_endpoint", "publish"],
9253 "actions": [{
9254 "id": "register_endpoint",
9255 "completion": {
9256 "state_path": "last_reconcile.ok",
9257 "equals": true
9258 },
9259 "executor": {
9260 "kind": "provider_http",
9261 "state_store_key": "last_reconcile"
9262 }
9263 }, {
9264 "id": "publish",
9265 "completion": {
9266 "state_path": "last_publish.ok",
9267 "equals": true
9268 },
9269 "executor": {
9270 "kind": "provider_http",
9271 "state_store_key": "last_publish"
9272 }
9273 }]
9274 }),
9275 load_error: None,
9276 };
9277 let mut stored = JsonMap::new();
9278 stored.insert(
9279 "config".to_string(),
9280 json!({
9281 "tenant": "demo",
9282 "team": "support",
9283 "public_base_url": "https://new.trycloudflare.com"
9284 }),
9285 );
9286 stored.insert(
9287 "last_reconcile".to_string(),
9288 json!({
9289 "ok": true,
9290 "runtime_context": {
9291 "public_base_url": "https://new.trycloudflare.com",
9292 "public_base_url_is_ephemeral_tunnel": true,
9293 "active_tunnel_public_base_url": null
9294 }
9295 }),
9296 );
9297 stored.insert(
9298 "last_setup_result".to_string(),
9299 json!({
9300 "step": "register_endpoint",
9301 "ok": true,
9302 "next": "click again"
9303 }),
9304 );
9305
9306 let rendered =
9307 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
9308
9309 assert_eq!(rendered["setup_status"]["ok"], false);
9310 assert_eq!(rendered["setup_status"]["items"][0]["state"], "done");
9311 assert_eq!(rendered["setup_status"]["items"][1]["state"], "pending");
9312 assert_eq!(
9313 rendered["setup_status"]["next"],
9314 "Continue setup to run the next step."
9315 );
9316 }
9317
9318 #[test]
9319 fn setup_backend_hides_current_downstream_output_when_prior_step_pending() {
9320 let temp = tempfile::tempdir().expect("tempdir");
9321 let state = test_ui_state(temp.path());
9322 let runtime_dir = temp.path().join("state/runtime/demo.support");
9326 std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
9327 std::fs::write(
9328 runtime_dir.join("endpoints.json"),
9329 serde_json::to_string(&json!({
9330 "tenant": "demo",
9331 "team": "support",
9332 "public_base_url": "https://rotated.trycloudflare.com",
9333 "gateway_listen_addr": "127.0.0.1",
9334 "gateway_port": 47392
9335 }))
9336 .unwrap(),
9337 )
9338 .expect("endpoints.json");
9339 let contract = super::ProviderBackendContract {
9340 provider_id: "messaging-example".to_string(),
9341 inline: json!({
9342 "schema_id": "greentic.setup.backend-contract.v1",
9343 "required_order": ["register_endpoint", "publish"],
9344 "actions": [{
9345 "id": "register_endpoint",
9346 "completion": {
9347 "state_path": "last_reconcile.ok",
9348 "equals": true
9349 },
9350 "executor": {
9351 "kind": "provider_http",
9352 "state_store_key": "last_reconcile"
9353 }
9354 }, {
9355 "id": "publish",
9356 "completion": {
9357 "state_path": "last_publish.ok",
9358 "equals": true
9359 },
9360 "executor": {
9361 "kind": "provider_http",
9362 "state_store_key": "last_publish"
9363 }
9364 }]
9365 }),
9366 load_error: None,
9367 };
9368 let mut stored = JsonMap::new();
9369 stored.insert(
9370 "config".to_string(),
9371 json!({
9372 "tenant": "demo",
9373 "team": "support",
9374 "public_base_url": "https://stale.trycloudflare.com"
9375 }),
9376 );
9377 stored.insert(
9378 "last_reconcile".to_string(),
9379 json!({
9380 "ok": true,
9381 "runtime_context": {
9382 "public_base_url": "https://stale.trycloudflare.com",
9383 "public_base_url_is_ephemeral_tunnel": true,
9384 "active_tunnel_public_base_url": null
9385 }
9386 }),
9387 );
9388 stored.insert(
9389 "last_publish".to_string(),
9390 json!({"ok": true, "url": "https://example.com"}),
9391 );
9392 stored.insert(
9393 "last_setup_result".to_string(),
9394 json!({
9395 "step": "publish",
9396 "ok": true,
9397 "next": "click again"
9398 }),
9399 );
9400
9401 let rendered =
9402 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
9403
9404 assert_eq!(rendered["setup_status"]["items"][0]["state"], "pending");
9405 assert_eq!(rendered["setup_status"]["items"][1]["state"], "pending");
9406 assert_eq!(rendered["values"]["last_publish"]["ok"], false);
9407 assert_eq!(rendered["values"]["last_publish"]["stale"], true);
9408 }
9409
9410 #[test]
9411 fn setup_backend_completion_rejects_changed_public_base_url_context() {
9412 let temp = tempfile::tempdir().expect("tempdir");
9413 let state = test_ui_state(temp.path());
9414 let contract = super::ProviderBackendContract {
9415 provider_id: "messaging-example".to_string(),
9416 inline: json!({
9417 "schema_id": "greentic.setup.backend-contract.v1",
9418 "required_order": ["register_endpoint"],
9419 "actions": [{
9420 "id": "register_endpoint",
9421 "completion": {
9422 "state_path": "last_reconcile.ok",
9423 "equals": true
9424 },
9425 "executor": {
9426 "kind": "provider_http",
9427 "state_store_key": "last_reconcile"
9428 }
9429 }]
9430 }),
9431 load_error: None,
9432 };
9433 let mut stored = JsonMap::new();
9434 stored.insert(
9435 "config".to_string(),
9436 json!({
9437 "tenant": "demo",
9438 "team": "support",
9439 "public_base_url": "https://new.example.com"
9440 }),
9441 );
9442 stored.insert(
9443 "last_reconcile".to_string(),
9444 json!({
9445 "ok": true,
9446 "runtime_context": {
9447 "public_base_url": "https://old.example.com"
9448 }
9449 }),
9450 );
9451
9452 let rendered =
9453 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
9454
9455 assert_eq!(rendered["setup_status"]["ok"], false);
9456 assert_eq!(rendered["setup_status"]["items"][0]["state"], "pending");
9457 }
9458
9459 #[test]
9460 fn setup_backend_oauth_completion_keeps_done_when_access_token_expires() {
9461 let temp = tempfile::tempdir().expect("tempdir");
9462 let state = test_ui_state(temp.path());
9463 let contract = super::ProviderBackendContract {
9464 provider_id: "messaging-example".to_string(),
9465 inline: json!({
9466 "schema_id": "greentic.setup.backend-contract.v1",
9467 "required_order": ["management_consent"],
9468 "actions": [{
9469 "id": "management_consent",
9470 "completion": {
9471 "state_path": "oauth.management.ok",
9472 "exists": true
9473 },
9474 "executor": {
9475 "kind": "oauth_device_code",
9476 "oauth_kind": "management",
9477 "token_store_key": "management_access_token"
9478 }
9479 }]
9480 }),
9481 load_error: None,
9482 };
9483 let expired_token = test_jwt_with_exp(1);
9484 let mut stored = JsonMap::new();
9485 stored.insert(
9486 "config".to_string(),
9487 json!({
9488 "tenant": "demo",
9489 "team": "support",
9490 "management_access_token": expired_token
9491 }),
9492 );
9493 stored.insert(
9494 "oauth".to_string(),
9495 json!({
9496 "management": {
9497 "ok": true,
9498 "token_store_key": "management_access_token"
9499 }
9500 }),
9501 );
9502
9503 let rendered =
9504 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
9505
9506 assert_eq!(rendered["setup_status"]["ok"], true);
9507 assert_eq!(rendered["setup_status"]["items"][0]["state"], "done");
9508 }
9509
9510 #[test]
9511 fn setup_backend_oauth_required_resume_routes_to_matching_oauth_action() {
9512 let temp = tempfile::tempdir().expect("tempdir");
9513 let state = test_ui_state(temp.path());
9514 let contract = super::ProviderBackendContract {
9515 provider_id: "generic-provider".to_string(),
9516 inline: json!({
9517 "schema_id": "greentic.setup.backend-contract.v1",
9518 "required_order": ["auth", "publish"],
9519 "actions": [{
9520 "id": "auth",
9521 "completion": {
9522 "state_path": "oauth.default.ok",
9523 "exists": true
9524 },
9525 "executor": {
9526 "kind": "oauth_device_code",
9527 "oauth_kind": "default",
9528 "token_store_key": "access_token"
9529 }
9530 }, {
9531 "id": "publish",
9532 "completion": {
9533 "state_path": "last_publish.ok",
9534 "equals": true
9535 },
9536 "executor": {
9537 "kind": "provider_http",
9538 "state_store_key": "last_publish"
9539 }
9540 }]
9541 }),
9542 load_error: None,
9543 };
9544 let mut stored = JsonMap::new();
9545 stored.insert(
9546 "config".to_string(),
9547 json!({
9548 "tenant": "demo",
9549 "team": "support",
9550 "access_token": "expired-token"
9551 }),
9552 );
9553 stored.insert(
9554 "oauth".to_string(),
9555 json!({
9556 "default": {
9557 "ok": true,
9558 "token_store_key": "access_token"
9559 }
9560 }),
9561 );
9562 stored.insert("last_publish".to_string(), json!({"ok": true}));
9563
9564 let publish_action = super::setup_backend_action_by_id(&contract, "publish").unwrap();
9565 let result = super::setup_backend_oauth_required_result(
9566 publish_action,
9567 "access_token",
9568 "authenticated request failed",
9569 &json!({
9570 "ok": false,
9571 "status": 401,
9572 "body": { "error": "Lifetime validation failed, the token is expired." }
9573 }),
9574 )
9575 .unwrap();
9576 crate::setup_backend_contract::update_oauth_resume(&mut stored, &result);
9577
9578 let first_pending =
9579 super::setup_backend_first_pending_step(&state, &contract, "demo", &stored);
9580
9581 assert_eq!(first_pending, "auth");
9582 assert_eq!(stored["oauth_resume"]["token_store_key"], "access_token");
9583 assert_eq!(stored["oauth_resume"]["resume_step"], "publish");
9584 }
9585
9586 #[test]
9587 fn setup_backend_provider_http_oauth_required_uses_token_key_from_action_body() {
9588 let temp = tempfile::tempdir().expect("tempdir");
9589 let state = test_ui_state(temp.path());
9590 let contract = super::ProviderBackendContract {
9591 provider_id: "generic-provider".to_string(),
9592 inline: json!({
9593 "schema_id": "greentic.setup.backend-contract.v1",
9594 "required_order": ["management_auth", "graph_auth", "register"],
9595 "actions": [{
9596 "id": "management_auth",
9597 "completion": {
9598 "state_path": "oauth.management.ok",
9599 "exists": true
9600 },
9601 "executor": {
9602 "kind": "oauth_device_code",
9603 "oauth_kind": "management",
9604 "token_store_key": "management_access_token"
9605 }
9606 }, {
9607 "id": "graph_auth",
9608 "completion": {
9609 "state_path": "oauth.graph.ok",
9610 "exists": true
9611 },
9612 "executor": {
9613 "kind": "oauth_device_code",
9614 "oauth_kind": "graph",
9615 "token_store_key": "graph_access_token"
9616 }
9617 }, {
9618 "id": "register",
9619 "completion": {
9620 "state_path": "last_register.ok",
9621 "equals": true
9622 },
9623 "executor": {
9624 "kind": "provider_http",
9625 "state_store_key": "last_register",
9626 "body": {
9627 "access_token": "{management_access_token}"
9628 }
9629 }
9630 }]
9631 }),
9632 load_error: None,
9633 };
9634 let action = super::setup_backend_action_by_id(&contract, "register").unwrap();
9635 let response = json!({
9636 "ok": false,
9637 "response": {
9638 "ok": false,
9639 "blocked": true,
9640 "error": "request failed (HTTP 401): token is expired"
9641 }
9642 });
9643
9644 let result =
9645 super::setup_backend_provider_http_oauth_required_result(&contract, action, &response)
9646 .unwrap();
9647 let mut stored = JsonMap::new();
9648 stored.insert(
9649 "config".to_string(),
9650 json!({
9651 "tenant": "demo",
9652 "team": "support",
9653 "management_access_token": "expired-management-token",
9654 "graph_access_token": "still-present"
9655 }),
9656 );
9657 stored.insert(
9658 "oauth".to_string(),
9659 json!({
9660 "management": {
9661 "ok": true,
9662 "token_store_key": "management_access_token"
9663 },
9664 "graph": {
9665 "ok": true,
9666 "token_store_key": "graph_access_token"
9667 }
9668 }),
9669 );
9670 crate::setup_backend_contract::update_oauth_resume(&mut stored, &result);
9671
9672 let first_pending =
9673 super::setup_backend_first_pending_step(&state, &contract, "demo", &stored);
9674
9675 assert_eq!(result["result"]["error"], "oauth_required");
9676 assert_eq!(
9677 result["result"]["token_store_key"],
9678 "management_access_token"
9679 );
9680 assert_eq!(first_pending, "management_auth");
9681 }
9682
9683 #[test]
9684 fn setup_backend_provider_http_oauth_required_handles_invalid_access_token() {
9685 let contract = super::ProviderBackendContract {
9686 provider_id: "generic-provider".to_string(),
9687 inline: json!({
9688 "schema_id": "greentic.setup.backend-contract.v1",
9689 "required_order": ["auth", "discover"],
9690 "actions": [{
9691 "id": "auth",
9692 "completion": {
9693 "state_path": "oauth.default.ok",
9694 "exists": true
9695 },
9696 "executor": {
9697 "kind": "oauth_device_code",
9698 "token_store_key": "access_token"
9699 }
9700 }, {
9701 "id": "discover",
9702 "completion": {
9703 "state_path": "last_discover.ok",
9704 "equals": true
9705 },
9706 "executor": {
9707 "kind": "provider_http",
9708 "state_store_key": "last_discover",
9709 "body": {
9710 "access_token": "{access_token}"
9711 }
9712 }
9713 }]
9714 }),
9715 load_error: None,
9716 };
9717 let action = super::setup_backend_action_by_id(&contract, "discover").unwrap();
9718 let response = json!({
9719 "ok": false,
9720 "response": {
9721 "ok": false,
9722 "blocked": true,
9723 "error": "Azure subscription discovery failed (HTTP 401): The access token is invalid."
9724 }
9725 });
9726
9727 let result =
9728 super::setup_backend_provider_http_oauth_required_result(&contract, action, &response)
9729 .expect("invalid token should require OAuth");
9730
9731 assert_eq!(result["result"]["error"], "oauth_required");
9732 assert_eq!(result["result"]["token_store_key"], "access_token");
9733 }
9734
9735 #[test]
9736 fn setup_backend_oauth_resume_clears_after_matching_token_refresh() {
9737 let mut stored = JsonMap::new();
9738 stored.insert(
9739 "oauth_resume".to_string(),
9740 json!({
9741 "token_store_key": "access_token",
9742 "resume_step": "publish"
9743 }),
9744 );
9745
9746 super::setup_backend_clear_oauth_resume_for_token(&mut stored, "other_token");
9747 assert!(stored.get("oauth_resume").is_some());
9748
9749 super::setup_backend_clear_oauth_resume_for_token(&mut stored, "access_token");
9750 assert!(stored.get("oauth_resume").is_none());
9751 }
9752
9753 #[test]
9754 fn setup_backend_render_keeps_completed_outputs_when_token_expires() {
9755 let temp = tempfile::tempdir().expect("tempdir");
9756 let state = test_ui_state(temp.path());
9757 let contract = super::ProviderBackendContract {
9758 provider_id: "generic-provider".to_string(),
9759 inline: json!({
9760 "schema_id": "greentic.setup.backend-contract.v1",
9761 "required_order": ["auth", "publish"],
9762 "actions": [{
9763 "id": "auth",
9764 "completion": {
9765 "state_path": "oauth.default.ok",
9766 "exists": true
9767 },
9768 "executor": {
9769 "kind": "oauth_device_code",
9770 "token_store_key": "access_token"
9771 }
9772 }, {
9773 "id": "publish",
9774 "completion": {
9775 "state_path": "last_publish.ok",
9776 "equals": true
9777 },
9778 "executor": {
9779 "kind": "provider_http",
9780 "state_store_key": "last_publish"
9781 }
9782 }]
9783 }),
9784 load_error: None,
9785 };
9786 let mut stored = JsonMap::new();
9787 stored.insert(
9788 "config".to_string(),
9789 json!({
9790 "tenant": "demo",
9791 "team": "support",
9792 "access_token": test_jwt_with_exp(1)
9793 }),
9794 );
9795 stored.insert(
9796 "oauth".to_string(),
9797 json!({
9798 "default": {
9799 "ok": true,
9800 "token_store_key": "access_token"
9801 }
9802 }),
9803 );
9804 stored.insert(
9805 "last_publish".to_string(),
9806 json!({"ok": true, "url": "https://example.com"}),
9807 );
9808
9809 let rendered =
9810 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
9811
9812 assert_eq!(rendered["setup_status"]["ok"], true);
9813 assert_eq!(rendered["setup_status"]["items"][0]["state"], "done");
9814 assert_eq!(rendered["setup_status"]["items"][1]["state"], "done");
9815 assert_eq!(rendered["setup_status"]["reset"], false);
9816 assert_eq!(rendered["values"]["oauth"]["default"]["ok"], true);
9817 assert_eq!(rendered["values"]["last_publish"]["ok"], true);
9818 }
9819
9820 #[test]
9821 fn setup_web_component_accepts_server_reset_state() {
9822 let source = r#"
9823 _isStaleState(nextState) {
9824 if (!this._state) return false;
9825 const current = this._stateRank(this._state);
9826 const next = this._stateRank(nextState);
9827 if (next.done < current.done) return true;
9828 return false;
9829 }
9830"#;
9831
9832 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
9833
9834 assert!(!patched.contains("setup_status.reset === true"));
9835 assert!(patched.contains("if (next.done < current.done) return true;"));
9836 }
9837
9838 #[test]
9839 fn setup_web_component_pending_device_login_is_not_error_outcome() {
9840 let source = r#"
9841 _outcomeMessage(result) {
9842 if (!result || typeof result !== "object") return "";
9843 if (result.ok === false) {
9844 return this._providerSetupError(result) || result.next || result.error || this._t("actionFailed");
9845 }
9846 return "";
9847 }
9848"#;
9849
9850 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
9851
9852 assert!(patched.contains("pending_device_login"));
9853 assert!(patched.contains("return this._providerSetupError(result)"));
9854 }
9855
9856 #[test]
9857 fn setup_web_component_allows_retryable_blocked_action() {
9858 let source = r#"
9859 _currentAction() {
9860 const status = this._status();
9861 if (status.blocked) {
9862 return {
9863 kind: "blocked-refresh",
9864 label: this._t("refreshAfterManualAction")
9865 };
9866 }
9867 return { kind: "continue", label: this._t("continue") || "Continue setup" };
9868 }
9869"#;
9870
9871 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
9872
9873 assert!(patched.contains("status.blocked && !status.blocked.retryable"));
9874 assert!(patched.contains("kind: \"blocked-refresh\""));
9875 }
9876
9877 #[test]
9878 fn setup_web_component_verifies_teams_install_after_publish_without_session_flag() {
9879 let source = r#"
9880 _currentAction() {
9881 if (publish.ok && !install.ok && addToTeamsUrl) {
9882 if (this._manualActions.addToTeamsOpened) {
9883 return {
9884 kind: "continue",
9885 label: this._t("verifyTeamsInstall")
9886 };
9887 }
9888 return {
9889 kind: "add-to-teams",
9890 label: this._t("addToTeams"),
9891 url: addToTeamsUrl
9892 };
9893 }
9894 }
9895
9896 _actionHtml(action) {
9897 return `<button type="button" class="primary" data-action="run-current">${this._escape(action.label || this._t("continue"))}</button>`;
9898 }
9899
9900 async _executeManagedAction(action) {
9901 let waitAction = action;
9902 if (waitAction.kind === "continue") {
9903 const result = await this._request("POST", this._endpoint("next"), this._collectConfig());
9904 }
9905 }
9906"#;
9907
9908 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
9909
9910 assert!(
9911 patched.contains("greentic-setup always offers install verification after publish")
9912 );
9913 assert!(patched.contains("label: this._t(\"verifyTeamsInstall\")"));
9914 assert!(patched.contains("stepId: \"teams_app_user_install\""));
9915 assert!(patched.contains("addToTeamsUrl"));
9916 assert!(patched.contains("greentic-setup can run a targeted setup action"));
9917 assert!(patched.contains("/action/${encodeURIComponent(waitAction.stepId)}"));
9918 assert!(patched.contains("greentic-setup exposes Add to Teams next to Verify"));
9919 assert!(!patched.contains("kind: \"add-to-teams\""));
9920 }
9921
9922 #[test]
9923 fn setup_web_component_ignores_stale_runtime_observation_for_bot_chat_action() {
9924 let source = r#"
9925 _currentAction() {
9926 const values = this._state && this._state.values || {};
9927 const firstMessage = values.last_activity || values.last_webchat_conversation;
9928 if (install.ok && !firstMessage && openBotChatUrl) {
9929 return {
9930 kind: "open-chat",
9931 label: this._t("openBotChat"),
9932 url: openBotChatUrl
9933 };
9934 }
9935 }
9936
9937 _waitingForFirstBotMessage() {
9938 const values = this._state && this._state.values || {};
9939 return Boolean(
9940 install.ok
9941 && (installData.open_bot_chat_url || teams.open_bot_chat_url)
9942 && !(values.last_activity || values.last_webchat_conversation)
9943 );
9944 }
9945
9946 _snapshot() {
9947 const values = this._state && this._state.values || {};
9948 return {
9949 firstMessage: Boolean(values.last_activity || values.last_webchat_conversation)
9950 };
9951 }
9952
9953 async _preflightAction(action) {
9954 if (!action || action.kind !== "continue") return "";
9955 const pending = this._currentPendingStepId();
9956 if (pending === "first_bot_framework_post") {
9957 return await this._runtimeIngressPreflight();
9958 }
9959 return "";
9960 }
9961"#;
9962
9963 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
9964
9965 assert!(patched.contains("greentic-setup ignores stale runtime observations"));
9966 assert!(patched.contains("value && !value.stale"));
9967 assert!(patched.contains("!value.stale)"));
9968 assert!(
9969 patched.contains("greentic-setup waits for the Bot Framework endpoint registration")
9970 );
9971 assert!(patched.contains("pendingStep !== \"first_bot_framework_post\""));
9972 assert!(patched.contains("greentic-setup lets the backend/runtime observation path"));
9973 assert!(!patched.contains("return await this._runtimeIngressPreflight();"));
9974 assert!(!patched.contains(
9975 "const firstMessage = values.last_activity || values.last_webchat_conversation;"
9976 ));
9977 assert!(!patched.contains(
9978 "firstMessage: Boolean(values.last_activity || values.last_webchat_conversation)"
9979 ));
9980 }
9981
9982 #[test]
9983 fn setup_web_component_suppresses_action_only_after_observed_completion() {
9984 let source = r#"
9985 _currentAction() {
9986 const complete = items.length > 0 && items.every((item) => item.state === "done");
9987 const values = this._state && this._state.values || {};
9988 const firstMessage = values.last_activity || values.last_webchat_conversation;
9989
9990 if (install.ok && !firstMessage && openBotChatUrl) {
9991 return {
9992 kind: "open-chat",
9993 label: this._t("openBotChat"),
9994 url: openBotChatUrl
9995 };
9996 }
9997
9998 if (!complete) {
9999 return {
10000 kind: "continue",
10001 label: this._t("continue") || "Continue setup"
10002 };
10003 }
10004
10005 if (openBotChatUrl) {
10006 return {
10007 kind: "open-chat",
10008 label: this._t("openBotChat"),
10009 url: openBotChatUrl
10010 };
10011 }
10012
10013 return {
10014 kind: "refresh",
10015 label: this._t("refresh")
10016 };
10017 }
10018"#;
10019
10020 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
10021
10022 assert!(patched.contains("greentic-setup has no next action after observed completion"));
10023 assert!(patched.contains("if (complete && firstMessage)"));
10024 assert!(patched.contains("return null;"));
10025 assert!(
10026 patched
10027 .find("if (install.ok && !firstMessage && openBotChatUrl)")
10028 .unwrap()
10029 < patched.find("if (complete && firstMessage)").unwrap()
10030 );
10031 }
10032
10033 #[test]
10034 fn setup_web_component_oauth_resume_keeps_device_login_action_visible() {
10035 let source = r#"
10036 _oauthComplete(kind) {
10037 const values = this._state && this._state.values || {};
10038 const oauth = values.oauth || {};
10039 return Boolean(oauth[kind || "default"] && oauth[kind || "default"].ok);
10040 }
10041"#;
10042
10043 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
10044
10045 assert!(patched.contains("greentic-setup oauth_resume keeps refreshed OAuth incomplete"));
10046 assert!(patched.contains("tokenKey === \"azure_management_access_token\""));
10047 assert!(patched.contains("tokenKey === \"graph_access_token\""));
10048 assert!(patched.contains("return false;"));
10049 }
10050
10051 #[test]
10052 fn setup_web_component_prefers_latest_oauth_response_code_over_stale_config_code() {
10053 let source = r#"
10054 _pendingLoginFromState() {
10055 const cfg = this._config();
10056 const values = this._state && this._state.values || {};
10057 const response = values.last_oauth && values.last_oauth.response || {};
10058 const oauthKind = this._oauthKind();
10059 const codeKey = oauthKind === "management" ? "azure_management_user_code" : "oauth_user_code";
10060 const userCode = cfg[codeKey] || response.user_code || response.userCode;
10061 if (!userCode) return null;
10062 }
10063"#;
10064
10065 let patched = super::patch_setup_web_component_reset_guard(source.to_string());
10066
10067 assert!(patched.contains("greentic-setup prefers newest OAuth response code"));
10068 assert!(patched.contains("response.user_code || response.userCode || cfg[codeKey]"));
10069 assert!(!patched.contains("cfg[codeKey] || response.user_code"));
10070 }
10071
10072 #[test]
10073 fn setup_backend_marks_runtime_tunnel_block_retryable() {
10074 let setup_result = json!({
10075 "ok": false,
10076 "next": "runtime/tunnel not running",
10077 "result": {
10078 "blocked": true,
10079 "error": "runtime/tunnel not running"
10080 }
10081 });
10082
10083 let blocked = super::setup_backend_blocked_from_result(&setup_result).unwrap();
10084
10085 assert_eq!(blocked["retryable"], json!(true));
10086 assert_eq!(blocked["summary"], json!("runtime/tunnel not running"));
10087 }
10088
10089 #[tokio::test]
10090 async fn runtime_observation_blocks_stale_ephemeral_tunnel_state() {
10091 let temp = tempfile::tempdir().expect("tempdir");
10092 let state = test_ui_state(temp.path());
10093 crate::platform_setup::persist_tunnel_artifact(
10094 temp.path(),
10095 &crate::platform_setup::TunnelAnswers {
10096 mode: Some("off".to_string()),
10097 },
10098 )
10099 .expect("persist tunnel off");
10100 let contract = super::ProviderBackendContract {
10101 provider_id: "messaging-example".to_string(),
10102 inline: json!({
10103 "schema_id": "greentic.setup.backend-contract.v1",
10104 "required_order": ["runtime_activity"]
10105 }),
10106 load_error: None,
10107 };
10108 let mut stored = JsonMap::new();
10109 stored.insert(
10110 "config".to_string(),
10111 json!({
10112 "tenant": "demo",
10113 "team": "support",
10114 "public_base_url": "https://old.trycloudflare.com"
10115 }),
10116 );
10117 stored.insert("last_activity".to_string(), json!({"ok": true}));
10118 let action = json!({
10119 "id": "runtime_activity",
10120 "executor": {
10121 "kind": "runtime_observation",
10122 "state_store_key": "last_activity"
10123 }
10124 });
10125
10126 let result = super::setup_backend_execute_runtime_observation(
10127 &state,
10128 &contract,
10129 "demo",
10130 &mut stored,
10131 &action,
10132 )
10133 .await
10134 .unwrap();
10135
10136 assert_eq!(result["ok"], false);
10137 assert_eq!(result["next"], "runtime/tunnel not running");
10138 assert_eq!(result["result"]["error"], "runtime/tunnel not running");
10139 assert_eq!(
10140 stored
10141 .get("config")
10142 .and_then(|config| config.get("public_base_url")),
10143 Some(&json!("https://old.trycloudflare.com"))
10144 );
10145 }
10146
10147 #[test]
10148 fn setup_backend_state_refreshes_runtime_observation_from_logs() {
10149 let temp = tempfile::tempdir().expect("tempdir");
10150 let state = test_ui_state(temp.path());
10151 let logs = temp.path().join("logs");
10152 std::fs::create_dir_all(&logs).expect("logs dir");
10153 std::fs::write(
10154 logs.join("system.log"),
10155 r#"2026-06-19T12:00:00Z INFO [fast2flow:gate] enter tenant=demo team=Some("support")"#,
10156 )
10157 .expect("system log");
10158 let contract = super::ProviderBackendContract {
10159 provider_id: "messaging-teams".to_string(),
10160 inline: json!({
10161 "schema_id": "greentic.setup.backend-contract.v1",
10162 "required_order": ["first_bot_framework_post"],
10163 "actions": [{
10164 "id": "first_bot_framework_post",
10165 "executor": {
10166 "kind": "runtime_observation",
10167 "source": "greentic-start",
10168 "event": "bot_framework_activity_received",
10169 "state_store_key": "last_activity"
10170 },
10171 "completion": {
10172 "state_path": "last_activity",
10173 "exists": true
10174 }
10175 }]
10176 }),
10177 load_error: None,
10178 };
10179 let mut stored = JsonMap::new();
10180 stored.insert(
10181 "config".to_string(),
10182 json!({
10183 "tenant": "demo",
10184 "team": "support",
10185 "public_base_url": "https://runtime.example.com"
10186 }),
10187 );
10188 super::save_setup_backend_contract_state(&state, &contract.provider_id, "demo", &stored)
10189 .expect("save state");
10190
10191 let rendered =
10192 super::setup_backend_contract_state(&state, &contract, "demo").expect("render state");
10193
10194 assert_eq!(rendered["setup_status"]["ok"], true);
10195 assert_eq!(rendered["setup_status"]["items"][0]["state"], "done");
10196 assert_eq!(
10197 rendered["values"]["last_activity"]["event"],
10198 "bot_framework_activity_received"
10199 );
10200 assert!(rendered["values"]["last_activity_received_at"].is_number());
10201 }
10202
10203 #[test]
10204 fn completed_provider_http_step_is_pending_when_runtime_context_is_stale() {
10205 let temp = tempfile::tempdir().expect("tempdir");
10206 let state = test_ui_state(temp.path());
10207 let runtime_dir = temp.path().join("state/runtime/demo.support");
10211 std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
10212 std::fs::write(
10213 runtime_dir.join("endpoints.json"),
10214 serde_json::to_string(&json!({
10215 "tenant": "demo",
10216 "team": "support",
10217 "public_base_url": "https://rotated.trycloudflare.com",
10218 "gateway_listen_addr": "127.0.0.1",
10219 "gateway_port": 47392
10220 }))
10221 .unwrap(),
10222 )
10223 .expect("endpoints.json");
10224 let contract = super::ProviderBackendContract {
10225 provider_id: "messaging-teams".to_string(),
10226 inline: json!({
10227 "schema_id": "greentic.setup.backend-contract.v1",
10228 "required_order": [
10229 "bot_framework_endpoint_registration",
10230 "first_bot_framework_post"
10231 ],
10232 "actions": [
10233 {
10234 "id": "bot_framework_endpoint_registration",
10235 "executor": {
10236 "kind": "provider_http",
10237 "state_store_key": "last_reconcile"
10238 },
10239 "completion": {
10240 "state_path": "last_reconcile.ok",
10241 "equals": true
10242 }
10243 },
10244 {
10245 "id": "first_bot_framework_post",
10246 "executor": {
10247 "kind": "runtime_observation",
10248 "state_store_key": "last_activity"
10249 },
10250 "completion": {
10251 "state_path": "last_activity",
10252 "exists": true
10253 }
10254 }
10255 ]
10256 }),
10257 load_error: None,
10258 };
10259 let stored = JsonMap::from_iter([
10260 (
10261 "config".to_string(),
10262 json!({
10263 "tenant": "demo",
10264 "team": "support",
10265 "public_base_url": "https://old.trycloudflare.com"
10266 }),
10267 ),
10268 (
10269 "completed_steps".to_string(),
10270 json!([
10271 "bot_framework_endpoint_registration",
10272 "first_bot_framework_post"
10273 ]),
10274 ),
10275 (
10276 "last_reconcile".to_string(),
10277 json!({
10278 "ok": true,
10279 "runtime_context": {
10280 "public_base_url": "https://old.trycloudflare.com",
10281 "public_base_url_is_ephemeral_tunnel": true,
10282 "active_tunnel_public_base_url": "https://old.trycloudflare.com"
10283 }
10284 }),
10285 ),
10286 ("last_activity".to_string(), json!({"ok": true})),
10287 ]);
10288
10289 let next = super::setup_backend_first_pending_step(&state, &contract, "demo", &stored);
10290
10291 assert_eq!(next, "bot_framework_endpoint_registration");
10292 }
10293
10294 #[test]
10295 fn teams_publish_and_install_stay_done_when_only_tunnel_context_changes() {
10296 let temp = tempfile::tempdir().expect("tempdir");
10297 let state = test_ui_state(temp.path());
10298 let contract = super::ProviderBackendContract {
10299 provider_id: "messaging-teams".to_string(),
10300 inline: json!({
10301 "schema_id": "greentic.setup.backend-contract.v1",
10302 "required_order": [
10303 "bot_framework_endpoint_registration",
10304 "teams_app_publish",
10305 "teams_app_user_install",
10306 "first_bot_framework_post"
10307 ],
10308 "actions": [
10309 {
10310 "id": "bot_framework_endpoint_registration",
10311 "executor": {
10312 "kind": "provider_http",
10313 "state_store_key": "last_reconcile"
10314 },
10315 "completion": {
10316 "state_path": "last_reconcile.ok",
10317 "equals": true
10318 }
10319 },
10320 {
10321 "id": "teams_app_publish",
10322 "executor": {
10323 "kind": "provider_http",
10324 "state_store_key": "last_teams_app_publish"
10325 },
10326 "completion": {
10327 "state_path": "last_teams_app_publish.ok",
10328 "equals": true
10329 }
10330 },
10331 {
10332 "id": "teams_app_user_install",
10333 "executor": {
10334 "kind": "provider_http",
10335 "state_store_key": "last_teams_app_install"
10336 },
10337 "completion": {
10338 "state_path": "last_teams_app_install.ok",
10339 "equals": true
10340 }
10341 },
10342 {
10343 "id": "first_bot_framework_post",
10344 "executor": {
10345 "kind": "runtime_observation",
10346 "state_store_key": "last_activity"
10347 },
10348 "completion": {
10349 "state_path": "last_activity",
10350 "exists": true
10351 }
10352 }
10353 ]
10354 }),
10355 load_error: None,
10356 };
10357 let stored = JsonMap::from_iter([
10358 (
10359 "config".to_string(),
10360 json!({
10361 "tenant": "demo",
10362 "team": "default",
10363 "public_base_url": "https://new.example.com"
10364 }),
10365 ),
10366 (
10367 "last_reconcile".to_string(),
10368 json!({
10369 "ok": true,
10370 "runtime_context": {
10371 "public_base_url": "https://new.example.com"
10372 }
10373 }),
10374 ),
10375 (
10376 "last_teams_app_publish".to_string(),
10377 json!({
10378 "ok": true,
10379 "response": {
10380 "add_to_teams_url": "https://teams.microsoft.com/l/app/app-id"
10381 },
10382 "runtime_context": {
10383 "public_base_url": "https://old.example.com"
10384 }
10385 }),
10386 ),
10387 (
10388 "last_teams_app_install".to_string(),
10389 json!({
10390 "ok": true,
10391 "response": {
10392 "action": "exists",
10393 "open_bot_chat_url": "https://teams.microsoft.com/l/chat/0/0"
10394 },
10395 "runtime_context": {
10396 "public_base_url": "https://old.example.com"
10397 }
10398 }),
10399 ),
10400 ]);
10401
10402 let rendered =
10403 super::render_setup_backend_contract_state(&state, &contract, "demo", stored.clone());
10404 let next = super::setup_backend_first_pending_step(&state, &contract, "demo", &stored);
10405
10406 assert_eq!(next, "first_bot_framework_post");
10407 assert_eq!(rendered["values"]["last_teams_app_publish"]["ok"], true);
10408 assert_eq!(rendered["values"]["last_teams_app_install"]["ok"], true);
10409 assert_eq!(
10410 rendered["values"]["last_teams_app_install"]["stale"],
10411 Value::Null
10412 );
10413 assert_eq!(
10414 rendered["teams_app"]["open_bot_chat_url"],
10415 "https://teams.microsoft.com/l/chat/0/0"
10416 );
10417 }
10418
10419 #[test]
10420 fn teams_publish_and_install_render_done_even_when_endpoint_must_be_refreshed() {
10421 let temp = tempfile::tempdir().expect("tempdir");
10422 let state = test_ui_state(temp.path());
10423 let contract = super::ProviderBackendContract {
10424 provider_id: "messaging-teams".to_string(),
10425 inline: json!({
10426 "schema_id": "greentic.setup.backend-contract.v1",
10427 "required_order": [
10428 "bot_framework_endpoint_registration",
10429 "teams_app_publish",
10430 "teams_app_user_install",
10431 "first_bot_framework_post"
10432 ],
10433 "actions": [
10434 {
10435 "id": "bot_framework_endpoint_registration",
10436 "executor": {
10437 "kind": "provider_http",
10438 "state_store_key": "last_reconcile"
10439 },
10440 "completion": {
10441 "state_path": "last_reconcile.ok",
10442 "equals": true
10443 }
10444 },
10445 {
10446 "id": "teams_app_publish",
10447 "executor": {
10448 "kind": "provider_http",
10449 "state_store_key": "last_teams_app_publish"
10450 },
10451 "completion": {
10452 "state_path": "last_teams_app_publish.ok",
10453 "equals": true
10454 }
10455 },
10456 {
10457 "id": "teams_app_user_install",
10458 "executor": {
10459 "kind": "provider_http",
10460 "state_store_key": "last_teams_app_install"
10461 },
10462 "completion": {
10463 "state_path": "last_teams_app_install.ok",
10464 "equals": true
10465 }
10466 },
10467 {
10468 "id": "first_bot_framework_post",
10469 "executor": {
10470 "kind": "runtime_observation",
10471 "state_store_key": "last_activity"
10472 },
10473 "completion": {
10474 "state_path": "last_activity",
10475 "exists": true
10476 }
10477 }
10478 ]
10479 }),
10480 load_error: None,
10481 };
10482 let stored = JsonMap::from_iter([
10483 (
10484 "config".to_string(),
10485 json!({
10486 "tenant": "demo",
10487 "team": "default",
10488 "public_base_url": "https://new.example.com"
10489 }),
10490 ),
10491 (
10492 "last_reconcile".to_string(),
10493 json!({
10494 "ok": true,
10495 "runtime_context": {
10496 "public_base_url": "https://old.example.com"
10497 }
10498 }),
10499 ),
10500 (
10501 "last_teams_app_publish".to_string(),
10502 json!({
10503 "ok": true,
10504 "response": {
10505 "add_to_teams_url": "https://teams.microsoft.com/l/app/app-id"
10506 }
10507 }),
10508 ),
10509 (
10510 "last_teams_app_install".to_string(),
10511 json!({
10512 "ok": true,
10513 "response": {
10514 "open_bot_chat_url": "https://teams.microsoft.com/l/chat/0/0"
10515 }
10516 }),
10517 ),
10518 ]);
10519
10520 let rendered =
10521 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
10522 let items = rendered["setup_status"]["items"].as_array().unwrap();
10523
10524 assert_eq!(items[0]["state"], "pending");
10525 assert_eq!(items[1]["state"], "done");
10526 assert_eq!(items[2]["state"], "done");
10527 assert_eq!(items[3]["state"], "pending");
10528 }
10529
10530 #[tokio::test]
10531 async fn provider_http_executor_url_template_calls_external_endpoint() {
10532 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
10533 .await
10534 .expect("bind test provider");
10535 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
10536 let app = axum::Router::new().route(
10537 "/v1/setup/register",
10538 axum::routing::post(|axum::Json(body): axum::Json<Value>| async move {
10539 axum::Json(json!({
10540 "ok": true,
10541 "received": body,
10542 }))
10543 }),
10544 );
10545 let server = tokio::spawn(async move {
10546 axum::serve(listener, app)
10547 .await
10548 .expect("test provider server");
10549 });
10550
10551 let temp = tempfile::tempdir().expect("tempdir");
10552 let state = test_ui_state(temp.path());
10553 let contract = super::ProviderBackendContract {
10554 provider_id: "messaging-example".to_string(),
10555 inline: json!({
10556 "schema_id": "greentic.setup.backend-contract.v1",
10557 "required_order": ["register_endpoint"],
10558 "actions": [{
10559 "id": "register_endpoint",
10560 "completion": {
10561 "state_path": "last_reconcile.ok",
10562 "equals": true
10563 }
10564 }]
10565 }),
10566 load_error: None,
10567 };
10568 let mut stored = JsonMap::new();
10569 stored.insert(
10570 "config".to_string(),
10571 json!({
10572 "tenant": "demo",
10573 "team": "support",
10574 "bot_app_id": "app-id",
10575 "bot_app_password": "app-password",
10576 "public_base_url": "https://runtime.example.com",
10577 "provider_setup_base_url": base_url
10578 }),
10579 );
10580 let action = json!({
10581 "id": "register_endpoint",
10582 "executor": {
10583 "kind": "provider_http",
10584 "url_template": "{provider_setup_base_url}/v1/setup/register",
10585 "body": {
10586 "provider_id": "messaging-example",
10587 "bot_app_id": "{bot_app_id}",
10588 "bot_app_password": "{bot_app_password}",
10589 "messaging_endpoint": "{public_base_url}/v1/messaging/ingress/{tenant}/{team}",
10590 "tenant": "{tenant}",
10591 "team": "{team}"
10592 },
10593 "state_store_key": "last_reconcile"
10594 }
10595 });
10596
10597 let result = super::setup_backend_execute_provider_http(
10598 &state,
10599 &contract,
10600 "demo",
10601 &mut stored,
10602 &action,
10603 )
10604 .await
10605 .unwrap();
10606
10607 assert_eq!(result["ok"], true);
10608 assert_eq!(stored["last_reconcile"]["ok"], true);
10609 assert_eq!(
10610 stored["last_reconcile"]["response"]["body"]["received"]["messaging_endpoint"],
10611 "https://runtime.example.com/v1/messaging/ingress/demo/support"
10612 );
10613 assert_eq!(
10614 stored["last_reconcile"]["response"]["body"]["received"]["bot_app_id"],
10615 "app-id"
10616 );
10617
10618 stored.insert("last_setup_result".to_string(), result);
10619 let rendered =
10620 super::render_setup_backend_contract_state(&state, &contract, "demo", stored);
10621 assert_eq!(rendered["setup_status"]["ok"], true);
10622 server.abort();
10623 }
10624
10625 #[tokio::test]
10626 async fn provider_http_executor_does_not_start_runtime_for_later_observation() {
10627 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
10628 .await
10629 .expect("bind test provider");
10630 let base_url = format!("http://{}", listener.local_addr().expect("addr"));
10631 let app = axum::Router::new().route(
10632 "/v1/setup/register",
10633 axum::routing::post(|axum::Json(body): axum::Json<Value>| async move {
10634 axum::Json(json!({
10635 "ok": true,
10636 "received": body,
10637 }))
10638 }),
10639 );
10640 let server = tokio::spawn(async move {
10641 axum::serve(listener, app)
10642 .await
10643 .expect("test provider server");
10644 });
10645
10646 let temp = tempfile::tempdir().expect("tempdir");
10647 std::fs::write(
10648 temp.path().join("bundle.yaml"),
10649 "schema_version: 1\nbundle_id: runtime-startable\n",
10650 )
10651 .expect("bundle yaml");
10652 let state = test_ui_state(temp.path());
10653 let contract = super::ProviderBackendContract {
10654 provider_id: "messaging-example".to_string(),
10655 inline: json!({
10656 "schema_id": "greentic.setup.backend-contract.v1",
10657 "required_order": ["register_endpoint", "runtime_activity"],
10658 "actions": [
10659 {
10660 "id": "register_endpoint",
10661 "executor": {
10662 "kind": "provider_http",
10663 "url_template": "{provider_setup_base_url}/v1/setup/register",
10664 "body": {
10665 "messaging_endpoint": "{public_base_url}/v1/messaging/ingress/{tenant}/{team}",
10666 "tenant": "{tenant}",
10667 "team": "{team}"
10668 },
10669 "state_store_key": "last_reconcile"
10670 },
10671 "completion": {
10672 "state_path": "last_reconcile.ok",
10673 "equals": true
10674 }
10675 },
10676 {
10677 "id": "runtime_activity",
10678 "executor": {
10679 "kind": "runtime_observation",
10680 "state_store_key": "last_activity"
10681 },
10682 "completion": {
10683 "state_path": "last_activity",
10684 "exists": true
10685 }
10686 }
10687 ]
10688 }),
10689 load_error: None,
10690 };
10691 let mut stored = JsonMap::new();
10692 stored.insert(
10693 "config".to_string(),
10694 json!({
10695 "tenant": "demo",
10696 "team": "support",
10697 "public_base_url": "https://runtime.example.com",
10698 "provider_setup_base_url": base_url
10699 }),
10700 );
10701 let action = super::setup_backend_action_by_id(&contract, "register_endpoint")
10702 .expect("register action")
10703 .clone();
10704
10705 let result = super::setup_backend_execute_provider_http(
10706 &state,
10707 &contract,
10708 "demo",
10709 &mut stored,
10710 &action,
10711 )
10712 .await
10713 .unwrap();
10714
10715 assert_eq!(result["ok"], true);
10716 assert_eq!(stored["last_reconcile"]["ok"], true);
10717 assert_eq!(
10718 stored["last_reconcile"]["response"]["body"]["received"]["messaging_endpoint"],
10719 "https://runtime.example.com/v1/messaging/ingress/demo/support"
10720 );
10721 server.abort();
10722 }
10723
10724 #[tokio::test]
10725 async fn provider_http_executor_path_template_requires_declared_pack_route() {
10726 let temp = tempfile::tempdir().expect("tempdir");
10727 let state = test_ui_state(temp.path());
10728 let contract = super::ProviderBackendContract {
10729 provider_id: "messaging-example".to_string(),
10730 inline: json!({
10731 "schema_id": "greentic.setup.backend-contract.v1",
10732 "required_order": ["register_endpoint"],
10733 "actions": [{
10734 "id": "register_endpoint",
10735 "completion": {
10736 "state_path": "last_reconcile.ok",
10737 "equals": true
10738 }
10739 }]
10740 }),
10741 load_error: None,
10742 };
10743 let mut stored = JsonMap::new();
10744 stored.insert(
10745 "config".to_string(),
10746 json!({
10747 "tenant": "demo",
10748 "team": "support",
10749 "bot_app_id": "app-id",
10750 "bot_app_password": "app-password",
10751 "public_base_url": "https://runtime.example.com",
10752 "provider_setup_base_url": "http://127.0.0.1:9"
10753 }),
10754 );
10755 let action = json!({
10756 "id": "register_endpoint",
10757 "executor": {
10758 "kind": "provider_http",
10759 "path_template": "/v1/setup/register",
10760 "body": {
10761 "provider_id": "messaging-example",
10762 "tenant": "{tenant}",
10763 "team": "{team}"
10764 },
10765 "state_store_key": "last_reconcile"
10766 }
10767 });
10768
10769 let result = super::setup_backend_execute_provider_http(
10770 &state,
10771 &contract,
10772 "demo",
10773 &mut stored,
10774 &action,
10775 )
10776 .await
10777 .unwrap();
10778
10779 assert_eq!(result["ok"], false);
10780 assert_eq!(
10781 result["result"]["error"],
10782 "provider_http target /v1/setup/register is not declared by pack greentic.http-routes.v1"
10783 );
10784 assert!(stored.get("last_reconcile").is_none());
10785 }
10786
10787 #[tokio::test]
10788 async fn graph_application_executor_uses_shared_retryable_missing_token_result() {
10789 let contract = super::ProviderBackendContract {
10790 provider_id: "messaging-teams".to_string(),
10791 inline: json!({
10792 "schema_id": "greentic.setup.backend-contract.v1",
10793 "provider_id": "messaging-teams",
10794 }),
10795 load_error: None,
10796 };
10797 let action = json!({
10798 "id": "register_app",
10799 "executor": {
10800 "kind": "microsoft_graph_application",
10801 "graph_token_store_key": "graph_access_token",
10802 "app_id_config_key": "bot_app_id",
10803 "client_secret_config_key": "bot_client_secret",
10804 "display_name_config_key": "bot_display_name"
10805 }
10806 });
10807 let mut stored = JsonMap::new();
10808 stored.insert(
10809 "config".to_string(),
10810 json!({
10811 "bot_display_name": "Demo Bot"
10812 }),
10813 );
10814
10815 let result =
10816 super::setup_backend_execute_graph_application(&contract, "demo", &mut stored, &action)
10817 .await
10818 .unwrap();
10819
10820 assert_eq!(result["ok"], false);
10821 assert_eq!(
10822 result["result"]["missing_token_store_key"],
10823 "graph_access_token"
10824 );
10825 assert_eq!(result["result"]["blocked"], true);
10826 assert_eq!(result["result"]["retryable"], true);
10827 assert!(stored.get("last_app_registration").is_none());
10828 }
10829
10830 #[test]
10831 fn declared_provider_http_route_matches_tenant_team_and_wildcard() {
10832 let route = super::DeclaredProviderHttpRoute {
10833 provider_id: "messaging-teams".to_string(),
10834 pack_path: std::path::PathBuf::from("messaging-teams.gtpack"),
10835 methods: vec!["POST".to_string()],
10836 target: super::ProviderHttpRouteTarget::SetupComponent {
10837 component_ref: "messaging-teams-setup".to_string(),
10838 op: "handle_http".to_string(),
10839 },
10840 segments: super::parse_provider_http_route_pattern(
10841 "/v1/messaging/setup/messaging-teams/{tenant}/{team}/{action*}",
10842 ),
10843 };
10844 let request_segments = vec![
10845 "v1",
10846 "messaging",
10847 "setup",
10848 "messaging-teams",
10849 "demo",
10850 "support",
10851 "publish",
10852 ];
10853
10854 assert_eq!(
10855 super::match_provider_http_route(&route, &request_segments, "fallback", "default"),
10856 Some(("demo".to_string(), "support".to_string()))
10857 );
10858 assert_eq!(
10859 super::match_provider_http_route(
10860 &route,
10861 &["v1", "messaging", "setup", "other", "demo"],
10862 "fallback",
10863 "default",
10864 ),
10865 None
10866 );
10867 }
10868
10869 #[test]
10870 fn provider_setup_event_redaction_removes_tokens_and_hashes_user_code() {
10871 let redacted = redact_provider_setup_event_detail(&json!({
10872 "state": {
10873 "access_token": "access-secret",
10874 "refresh-token": "refresh-secret",
10875 "idToken": "id-secret",
10876 "client_secret": "client-secret",
10877 "bot_app_password": "bot-password",
10878 "device_code": "device-secret",
10879 "oauth_device_code": "oauth-device-secret",
10880 "user_code": "ABCD-EFGH",
10881 "step": "wait_for_graph_login",
10882 "next": "continue",
10883 "status": 200,
10884 "error_codes": [1, 2],
10885 "trace_id": "trace-1",
10886 "request-id": "request-1"
10887 }
10888 }));
10889
10890 let state = &redacted["state"];
10891 assert_eq!(state["access_token"], "[redacted]");
10892 assert_eq!(state["refresh-token"], "[redacted]");
10893 assert_eq!(state["idToken"], "[redacted]");
10894 assert_eq!(state["client_secret"], "[redacted]");
10895 assert_eq!(state["bot_app_password"], "[redacted]");
10896 assert_eq!(state["device_code"], "[redacted]");
10897 assert_eq!(state["oauth_device_code"], "[redacted]");
10898 assert!(state["user_code"].as_str().unwrap().starts_with("[sha256:"));
10899 assert_eq!(state["step"], "wait_for_graph_login");
10900 assert_eq!(state["next"], "continue");
10901 assert_eq!(state["status"], 200);
10902 assert_eq!(state["trace_id"], "trace-1");
10903 assert_eq!(state["request-id"], "request-1");
10904 }
10905
10906 #[tokio::test]
10907 async fn setup_backend_contract_is_exposed_and_handles_state_without_runtime_proxy() {
10908 let temp = tempfile::tempdir().expect("tempdir");
10909 let providers = temp.path().join("providers/messaging");
10910 std::fs::create_dir_all(&providers).expect("providers");
10911 write_pack_with_setup_backend_contract(
10912 &providers.join("messaging-contract.gtpack"),
10913 "messaging-contract",
10914 )
10915 .expect("pack");
10916
10917 let state = test_ui_state(temp.path());
10918 let app = build_router(state);
10919 let response = app
10920 .oneshot(
10921 Request::builder()
10922 .uri("/api/providers")
10923 .body(Body::empty())
10924 .unwrap(),
10925 )
10926 .await
10927 .expect("providers response");
10928 assert_eq!(response.status(), StatusCode::OK);
10929 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
10930 let body: Value = serde_json::from_slice(&bytes).unwrap();
10931 let provider = body["providers"]
10932 .as_array()
10933 .unwrap()
10934 .iter()
10935 .find(|provider| provider["provider_id"] == "messaging-contract")
10936 .expect("provider");
10937 assert_eq!(
10938 provider["setup_backend_contract"]["schema_id"],
10939 "greentic.setup.backend-contract.v1"
10940 );
10941
10942 let state = test_ui_state(temp.path());
10943 let app = build_router(state.clone());
10944 let response = app
10945 .oneshot(
10946 Request::builder()
10947 .uri("/v1/messaging/setup/messaging-contract/demo")
10948 .body(Body::empty())
10949 .unwrap(),
10950 )
10951 .await
10952 .expect("state response");
10953 assert_eq!(response.status(), StatusCode::OK);
10954 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
10955 let body: Value = serde_json::from_slice(&bytes).unwrap();
10956 if body["ok"] != true {
10957 panic!("{body}");
10958 }
10959 assert_eq!(body["setup_status"]["ok"], false);
10960 assert_eq!(body["setup_status"]["items"].as_array().unwrap().len(), 3);
10961 }
10962
10963 #[tokio::test]
10964 async fn setup_machine_is_exposed_and_advances_through_ui_route() {
10965 let temp = tempfile::tempdir().expect("tempdir");
10966 let providers = temp.path().join("providers/messaging");
10967 std::fs::create_dir_all(&providers).expect("providers");
10968 write_pack_with_setup_machine(
10969 &providers.join("messaging-machine.gtpack"),
10970 "messaging-machine",
10971 )
10972 .expect("pack");
10973
10974 let app = build_router(test_ui_state(temp.path()));
10975 let response = app
10976 .oneshot(
10977 Request::builder()
10978 .uri("/api/providers")
10979 .body(Body::empty())
10980 .unwrap(),
10981 )
10982 .await
10983 .expect("providers response");
10984 assert_eq!(response.status(), StatusCode::OK);
10985 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
10986 let body: Value = serde_json::from_slice(&bytes).unwrap();
10987 let provider = body["providers"]
10988 .as_array()
10989 .unwrap()
10990 .iter()
10991 .find(|provider| provider["provider_id"] == "messaging-machine")
10992 .expect("provider");
10993 assert_eq!(
10994 provider["setup_machine"]["schema_id"],
10995 "greentic.setup.machine.v1"
10996 );
10997 assert_eq!(provider["setup_machine"]["id"], "ui-machine");
10998
10999 let app = build_router(test_ui_state(temp.path()));
11000 let response = app
11001 .oneshot(
11002 Request::builder()
11003 .uri("/v1/messaging/setup/messaging-machine/demo")
11004 .body(Body::empty())
11005 .unwrap(),
11006 )
11007 .await
11008 .expect("state response");
11009 assert_eq!(response.status(), StatusCode::OK);
11010 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11011 let body: Value = serde_json::from_slice(&bytes).unwrap();
11012 assert_eq!(body["setup_status"]["ok"], false);
11013 assert_eq!(body["setup_status"]["items"][0]["id"], "start");
11014
11015 let app = build_router(test_ui_state(temp.path()));
11016 let response = app
11017 .oneshot(
11018 Request::builder()
11019 .method("POST")
11020 .uri("/v1/messaging/setup/messaging-machine/demo/next")
11021 .header("content-type", "application/json")
11022 .body(Body::from("{}"))
11023 .unwrap(),
11024 )
11025 .await
11026 .expect("next response");
11027 assert_eq!(response.status(), StatusCode::OK);
11028 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11029 let body: Value = serde_json::from_slice(&bytes).unwrap();
11030 assert_eq!(body["setup_status"]["ok"], true);
11031 assert_eq!(body["setup_status"]["last_step"], "complete");
11032 let state = crate::setup_machine::load_setup_machine_state(
11033 &crate::setup_machine::setup_machine_state_path(
11034 temp.path(),
11035 "demo",
11036 "support",
11037 "messaging-machine",
11038 ),
11039 )
11040 .expect("machine state");
11041 assert_eq!(
11042 state.status,
11043 crate::setup_machine::SetupMachineStatus::Complete
11044 );
11045 }
11046
11047 #[tokio::test]
11048 async fn setup_actions_pack_extension_is_exposed_for_setup_targets() {
11049 let temp = tempfile::tempdir().expect("tempdir");
11050 let providers = temp.path().join("providers/messaging");
11051 std::fs::create_dir_all(&providers).expect("providers");
11052 write_pack_with_final_setup_actions(
11053 &providers.join("messaging-actions.gtpack"),
11054 "messaging-actions",
11055 )
11056 .expect("pack");
11057
11058 let app = build_router(test_ui_state(temp.path()));
11059 let response = app
11060 .oneshot(
11061 Request::builder()
11062 .uri("/api/providers")
11063 .body(Body::empty())
11064 .unwrap(),
11065 )
11066 .await
11067 .expect("providers response");
11068 assert_eq!(response.status(), StatusCode::OK);
11069 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11070 let body: Value = serde_json::from_slice(&bytes).unwrap();
11071 let provider = body["providers"]
11072 .as_array()
11073 .unwrap()
11074 .iter()
11075 .find(|provider| provider["provider_id"] == "messaging-actions")
11076 .expect("provider");
11077
11078 assert_eq!(
11079 provider["setup_actions"]["schema_id"],
11080 "greentic.setup.actions.v1"
11081 );
11082 assert_eq!(
11083 provider["setup_actions"]["actions"][0]["url_template"],
11084 "{add_url}"
11085 );
11086 }
11087
11088 #[tokio::test]
11089 async fn legacy_setup_actions_are_exposed_for_setup_targets() {
11090 let temp = tempfile::tempdir().expect("tempdir");
11091 let providers = temp.path().join("providers/messaging");
11092 std::fs::create_dir_all(&providers).expect("providers");
11093 write_pack_with_legacy_setup_actions(
11094 &providers.join("messaging-legacy-actions.gtpack"),
11095 "messaging-legacy-actions",
11096 )
11097 .expect("pack");
11098
11099 let app = build_router(test_ui_state(temp.path()));
11100 let response = app
11101 .oneshot(
11102 Request::builder()
11103 .uri("/api/providers")
11104 .body(Body::empty())
11105 .unwrap(),
11106 )
11107 .await
11108 .expect("providers response");
11109 assert_eq!(response.status(), StatusCode::OK);
11110 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11111 let body: Value = serde_json::from_slice(&bytes).unwrap();
11112 let provider = body["providers"]
11113 .as_array()
11114 .unwrap()
11115 .iter()
11116 .find(|provider| provider["provider_id"] == "messaging-legacy-actions")
11117 .expect("provider");
11118
11119 assert_eq!(
11120 provider["setup_actions"]["schema_id"],
11121 "greentic.setup.actions.v1"
11122 );
11123 assert_eq!(
11124 provider["setup_actions"]["actions"][0]["kind"],
11125 "oauth_install_button"
11126 );
11127 assert_eq!(
11128 provider["setup_actions"]["actions"][0]["provider_id"],
11129 "messaging-legacy-actions"
11130 );
11131 assert_eq!(
11132 provider["setup_actions"]["actions"][0]["authorize_url"],
11133 "https://provider.example/install"
11134 );
11135 }
11136
11137 #[tokio::test]
11138 async fn setup_actions_extension_and_legacy_actions_are_both_exposed() {
11139 let temp = tempfile::tempdir().expect("tempdir");
11140 let providers = temp.path().join("providers/messaging");
11141 std::fs::create_dir_all(&providers).expect("providers");
11142 write_pack_with_final_and_legacy_setup_actions(
11143 &providers.join("messaging-combined-actions.gtpack"),
11144 "messaging-combined-actions",
11145 )
11146 .expect("pack");
11147
11148 let app = build_router(test_ui_state(temp.path()));
11149 let response = app
11150 .oneshot(
11151 Request::builder()
11152 .uri("/api/providers")
11153 .body(Body::empty())
11154 .unwrap(),
11155 )
11156 .await
11157 .expect("providers response");
11158 assert_eq!(response.status(), StatusCode::OK);
11159 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11160 let body: Value = serde_json::from_slice(&bytes).unwrap();
11161 let provider = body["providers"]
11162 .as_array()
11163 .unwrap()
11164 .iter()
11165 .find(|provider| provider["provider_id"] == "messaging-combined-actions")
11166 .expect("provider");
11167 let actions = provider["setup_actions"]["actions"].as_array().unwrap();
11168
11169 assert!(actions.iter().any(|action| {
11170 action["kind"] == "oauth_install_button"
11171 && action["authorize_url"] == "https://provider.example/install"
11172 }));
11173 assert!(actions.iter().any(|action| {
11174 action["kind"] == "deep_link" && action["url_template"] == "{add_url}"
11175 }));
11176 }
11177
11178 #[tokio::test]
11179 async fn setup_action_endpoint_runs_registration_and_returns_final_url_value() {
11180 let temp = tempfile::tempdir().expect("tempdir");
11181 let providers = temp.path().join("providers/messaging");
11182 std::fs::create_dir_all(&providers).expect("providers");
11183 write_pack_with_final_and_legacy_setup_actions(
11184 &providers.join("messaging-combined-actions.gtpack"),
11185 "messaging-combined-actions",
11186 )
11187 .expect("pack");
11188
11189 let app = build_router(test_ui_state(temp.path()));
11190 let response = app
11191 .oneshot(
11192 Request::builder()
11193 .method("POST")
11194 .uri("/api/setup-action")
11195 .header("content-type", "application/json")
11196 .body(Body::from(
11197 json!({
11198 "provider_id": "messaging-combined-actions",
11199 "action_id": "create_app",
11200 "tenant": "demo",
11201 "team": "support",
11202 "env": "dev",
11203 "tunnel": "off",
11204 "answers": {
11205 "messaging-combined-actions": {
11206 "public_base_url": "https://runtime.example.test"
11207 }
11208 }
11209 })
11210 .to_string(),
11211 ))
11212 .unwrap(),
11213 )
11214 .await
11215 .expect("setup action response");
11216 assert_eq!(response.status(), StatusCode::OK);
11217 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11218 let body: Value = serde_json::from_slice(&bytes).unwrap();
11219
11220 assert!(body["ok"] == true, "{body}");
11221 let add_url = body["values"]["add_url"].as_str().expect("add url");
11222 assert!(add_url.starts_with("https://provider.example/install?"));
11223 assert!(add_url.contains("client_id=generated-client"));
11224 assert!(!add_url.contains("client_id=stale-client"));
11225 assert!(add_url.contains("scope=chat%3Awrite"));
11226 assert!(add_url.contains(
11227 "redirect_uri=https%3A%2F%2Fruntime.example.test%2Foauth%2Fcallback%2Fprovider"
11228 ));
11229 assert!(!add_url.contains("old.example.test"));
11230 assert!(add_url.contains("state="));
11231 assert!(body["values"].get("provider_client_secret").is_none());
11232 assert_eq!(
11233 body["output"]["provider_client_secret"],
11234 Value::String("[redacted]".to_string())
11235 );
11236 let action = crate::setup_actions::load_setup_action(
11237 temp.path(),
11238 "demo",
11239 "support",
11240 "messaging-combined-actions",
11241 "create_app",
11242 )
11243 .unwrap()
11244 .expect("persisted setup action");
11245 assert_eq!(
11246 action.status,
11247 crate::setup_actions::SetupActionStatus::Pending
11248 );
11249 assert!(action.state.is_some());
11250 assert_eq!(action.authorize_url.as_deref(), Some(add_url));
11251 assert_eq!(action.provider_id, "messaging-combined-actions");
11252 let state = url::Url::parse(add_url)
11253 .unwrap()
11254 .query_pairs()
11255 .find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
11256 .expect("signed state");
11257 assert_ne!(state, "provider-state");
11258 let key = crate::setup_actions::load_or_create_signing_key(temp.path()).unwrap();
11259 let payload = crate::setup_actions::validate_oauth_state(
11260 &state,
11261 &key,
11262 Some("messaging-combined-actions"),
11263 Some("demo"),
11264 Some("support"),
11265 crate::setup_actions::current_epoch_secs(),
11266 )
11267 .unwrap();
11268 assert_eq!(payload.action_id, "create_app");
11269 }
11270
11271 #[tokio::test]
11272 async fn setup_action_endpoint_returns_oauth_url_without_deep_link_action() {
11273 let temp = tempfile::tempdir().expect("tempdir");
11274 let providers = temp.path().join("providers/messaging");
11275 std::fs::create_dir_all(&providers).expect("providers");
11276 write_pack_with_legacy_setup_actions(
11277 &providers.join("messaging-legacy-actions.gtpack"),
11278 "messaging-legacy-actions",
11279 )
11280 .expect("pack");
11281
11282 let app = build_router(test_ui_state(temp.path()));
11283 let response = app
11284 .oneshot(
11285 Request::builder()
11286 .method("POST")
11287 .uri("/api/setup-action")
11288 .header("content-type", "application/json")
11289 .body(Body::from(
11290 json!({
11291 "provider_id": "messaging-legacy-actions",
11292 "action_id": "create_app",
11293 "tenant": "demo",
11294 "team": "support",
11295 "env": "dev",
11296 "tunnel": "off",
11297 "answers": {
11298 "messaging-legacy-actions": {
11299 "public_base_url": "https://runtime.example.test"
11300 }
11301 }
11302 })
11303 .to_string(),
11304 ))
11305 .unwrap(),
11306 )
11307 .await
11308 .expect("setup action response");
11309 assert_eq!(response.status(), StatusCode::OK);
11310 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11311 let body: Value = serde_json::from_slice(&bytes).unwrap();
11312
11313 assert!(body["ok"] == true, "{body}");
11314 let add_url = body["values"]["oauth_authorize_url"]
11315 .as_str()
11316 .expect("oauth authorize url");
11317 assert!(add_url.starts_with("https://provider.example/install?"));
11318 assert!(add_url.contains("client_id=generated-client"));
11319 assert!(!add_url.contains("client_id=stale-client"));
11320 assert!(add_url.contains("scope=chat%3Awrite"));
11321 assert!(add_url.contains(
11322 "redirect_uri=https%3A%2F%2Fruntime.example.test%2Foauth%2Fcallback%2Fprovider"
11323 ));
11324 assert!(!add_url.contains("old.example.test"));
11325 assert!(add_url.contains("state="));
11326 assert!(!add_url.contains("provider-state"));
11327 let action = crate::setup_actions::load_setup_action(
11328 temp.path(),
11329 "demo",
11330 "support",
11331 "messaging-legacy-actions",
11332 "create_app",
11333 )
11334 .unwrap()
11335 .expect("persisted setup action");
11336 assert_eq!(action.authorize_url.as_deref(), Some(add_url));
11337 }
11338
11339 fn write_pack_with_open_url_setup_action(
11340 path: &std::path::Path,
11341 provider_id: &str,
11342 ) -> anyhow::Result<()> {
11343 let file = std::fs::File::create(path)?;
11344 let mut zip = zip::ZipWriter::new(file);
11345 zip.start_file("pack.manifest.json", SimpleFileOptions::default())?;
11346 zip.write_all(
11347 json!({
11348 "pack_id": provider_id,
11349 "display_name": "Open URL Action Provider"
11350 })
11351 .to_string()
11352 .as_bytes(),
11353 )?;
11354 zip.start_file("assets/setup.yaml", SimpleFileOptions::default())?;
11355 zip.write_all(
11356 br#"
11357provider_id: generic
11358version: 1
11359title: Generic provider setup
11360setup_actions:
11361 - id: create_app
11362 label: Create Provider App
11363 kind: open_url
11364 provider_id: provider-alias
11365 url_template: "https://provider.example/apps/{resolved_app_id}/install-on-team?"
11366 registration:
11367 component_ref: provider-setup
11368 op: setup_app_registration
11369 app_id_output: resolved_app_id
11370 mock_result:
11371 ok: true
11372 resolved_app_id: app-from-registration
11373"#,
11374 )?;
11375 zip.finish()?;
11376 Ok(())
11377 }
11378
11379 #[tokio::test]
11380 async fn setup_action_endpoint_runs_registration_for_open_url_action() {
11381 let temp = tempfile::tempdir().expect("tempdir");
11387 let providers = temp.path().join("providers/messaging");
11388 std::fs::create_dir_all(&providers).expect("providers");
11389 write_pack_with_open_url_setup_action(
11390 &providers.join("messaging-open-url-action.gtpack"),
11391 "messaging-open-url-action",
11392 )
11393 .expect("pack");
11394
11395 let app = build_router(test_ui_state(temp.path()));
11396 let response = app
11397 .oneshot(
11398 Request::builder()
11399 .method("POST")
11400 .uri("/api/setup-action")
11401 .header("content-type", "application/json")
11402 .body(Body::from(
11403 json!({
11404 "provider_id": "messaging-open-url-action",
11405 "action_id": "create_app",
11406 "tenant": "demo",
11407 "team": "support",
11408 "env": "dev",
11409 "tunnel": "off",
11410 "answers": {
11411 "messaging-open-url-action": {
11412 "public_base_url": "https://runtime.example.test"
11413 }
11414 }
11415 })
11416 .to_string(),
11417 ))
11418 .unwrap(),
11419 )
11420 .await
11421 .expect("setup action response");
11422 assert_eq!(response.status(), StatusCode::OK);
11423 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11424 let body: Value = serde_json::from_slice(&bytes).unwrap();
11425
11426 assert!(body["ok"] == true, "{body}");
11427 assert_eq!(
11428 body["values"]["resolved_app_id"],
11429 Value::String("app-from-registration".to_string())
11430 );
11431 }
11432
11433 #[tokio::test]
11434 async fn draft_endpoint_persists_selected_tunnel_without_answers() {
11435 let temp = tempfile::tempdir().expect("tempdir");
11436 let app = build_router(test_ui_state(temp.path()));
11437
11438 let response = app
11439 .oneshot(
11440 Request::builder()
11441 .method("POST")
11442 .uri("/api/draft")
11443 .header("content-type", "application/json")
11444 .body(Body::from(
11445 json!({
11446 "tenant": "demo",
11447 "team": "support",
11448 "env": "dev",
11449 "tunnel": "ngrok",
11450 "answers": {}
11451 })
11452 .to_string(),
11453 ))
11454 .unwrap(),
11455 )
11456 .await
11457 .expect("draft response");
11458 assert_eq!(response.status(), StatusCode::OK);
11459 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11460 let body: Value = serde_json::from_slice(&bytes).unwrap();
11461 assert!(body["ok"] == true, "{body}");
11462
11463 let tunnel = crate::platform_setup::load_tunnel_artifact(temp.path())
11464 .unwrap()
11465 .expect("tunnel artifact");
11466 assert_eq!(tunnel.mode.as_deref(), Some("ngrok"));
11467 }
11468
11469 #[tokio::test]
11470 async fn provider_api_does_not_return_saved_values_for_secret_questions() {
11471 let temp = tempfile::tempdir().expect("tempdir");
11472 let providers = temp.path().join("providers/messaging");
11473 std::fs::create_dir_all(&providers).expect("providers");
11474 write_pack_with_secret_and_public_questions(
11475 &providers.join("messaging-question-provider.gtpack"),
11476 "messaging-question-provider",
11477 )
11478 .expect("pack");
11479
11480 let store = open_dev_store(temp.path()).expect("open store");
11481 store
11482 .put(
11483 &crate::canonical_secret_uri(
11484 "dev",
11485 "demo",
11486 Some("support"),
11487 "messaging-question-provider",
11488 "provider_public_field",
11489 ),
11490 SecretFormat::Text,
11491 b"public-value",
11492 )
11493 .await
11494 .expect("store public");
11495 store
11496 .put(
11497 &crate::canonical_secret_uri(
11498 "dev",
11499 "demo",
11500 Some("support"),
11501 "messaging-question-provider",
11502 "provider_secret_field",
11503 ),
11504 SecretFormat::Text,
11505 b"secret-value",
11506 )
11507 .await
11508 .expect("store secret");
11509
11510 let app = build_router(test_ui_state(temp.path()));
11511 let response = app
11512 .oneshot(
11513 Request::builder()
11514 .uri("/api/providers")
11515 .body(Body::empty())
11516 .unwrap(),
11517 )
11518 .await
11519 .expect("providers response");
11520 assert_eq!(response.status(), StatusCode::OK);
11521 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11522 let body: Value = serde_json::from_slice(&bytes).unwrap();
11523 let questions = body["provider_forms"]
11524 .as_array()
11525 .unwrap()
11526 .iter()
11527 .find(|form| form["provider_id"] == "messaging-question-provider")
11528 .expect("provider form")["questions"]
11529 .as_array()
11530 .unwrap();
11531 let public = questions
11532 .iter()
11533 .find(|question| question["id"] == "provider_public_field")
11534 .expect("public question");
11535 let secret = questions
11536 .iter()
11537 .find(|question| question["id"] == "provider_secret_field")
11538 .expect("secret question");
11539
11540 assert_eq!(public["saved_value"], "public-value");
11541 assert!(secret.get("saved_value").is_none());
11542 }
11543
11544 #[test]
11545 fn setup_ui_resolves_final_actions_even_when_setup_result_failed() {
11546 let app_js = include_str!("../../assets/setup-ui/app.js");
11547 assert!(app_js.contains("var finalActions = resolveFinalSetupActions();"));
11548 assert!(!app_js.contains("var finalActions = ok ? resolveFinalSetupActions() : [];"));
11549 }
11550
11551 #[test]
11552 fn setup_ui_resolves_final_actions_from_provider_answers() {
11553 let app_js = include_str!("../../assets/setup-ui/app.js");
11554 assert!(
11555 app_js.contains(
11556 "mergeFinalSetupActionContext(context, scope && scope.answers && scope.answers[provider.provider_id]);"
11557 ),
11558 "final setup actions must see provider form answers such as bot_username and bot_email"
11559 );
11560 }
11561
11562 #[test]
11563 fn setup_ui_provider_setup_phase_accepts_open_url_registration_actions() {
11564 let app_js = include_str!("../../assets/setup-ui/app.js");
11570 assert!(
11571 app_js.contains(
11572 r#"return action && (action.kind === "oauth_install_button" || action.kind === "open_url") && action.registration;"#
11573 ),
11574 "providerSetupPhaseActions must accept open_url registration actions alongside oauth_install_button"
11575 );
11576 }
11577
11578 #[test]
11579 fn setup_ui_opens_returned_install_url_after_provider_setup_action() {
11580 let app_js = include_str!("../../assets/setup-ui/app.js");
11581 assert!(app_js.contains("var popup = openProviderInstallWindow();"));
11582 assert!(app_js.contains("var installUrl = setupActionReturnedFinalUrl(provider, result);"));
11583 assert!(app_js.contains("navigateProviderInstallWindow(popup, installUrl);"));
11584 }
11585
11586 #[test]
11587 fn setup_ui_provider_setup_action_back_and_continue_do_not_trap_admin() {
11588 let app_js = include_str!("../../assets/setup-ui/app.js");
11589 assert!(app_js.contains("var canContinue = completed || !!error || questions.length > 0;"));
11590 assert!(app_js.contains("preActionQuestions(p)"));
11591 assert!(app_js.contains("state.phase = \"providers\";"));
11592 }
11593
11594 #[test]
11595 fn setup_ui_serializes_setup_tunnel_start_without_retrying_new_tunnels() {
11596 let ui_rs = include_str!("mod.rs");
11597 assert!(ui_rs.contains("setup_tunnel_start: AsyncMutex<()>"));
11598 assert!(ui_rs.contains("let _start_guard = state.setup_tunnel_start.lock().await;"));
11599 assert!(ui_rs.contains("Duration::from_secs(45)"));
11600 }
11601
11602 #[test]
11603 fn setup_ui_persists_selected_tunnel_before_provider_steps() {
11604 let app_js = include_str!("../../assets/setup-ui/app.js");
11605 assert!(app_js.contains("state.defaultTunnel = scopeData.tunnel ||"));
11606 assert!(app_js.contains("if (scope.tunnel) payload.tunnel = scope.tunnel;"));
11607 assert!(app_js.contains("Object.keys(payload.answers).length === 0 && !payload.tunnel"));
11608 assert!(app_js.contains(
11609 "persistDraftNow().finally(function () {\n state.phase = \"providers\";"
11610 ));
11611 }
11612
11613 #[test]
11614 fn setup_ui_web_component_continue_is_not_blocked_by_partial_or_failed_setup() {
11615 let app_js = include_str!("../../assets/setup-ui/app.js");
11616 assert!(app_js.contains("function markCanContinue(detail)"));
11617 assert!(app_js.contains("markCanContinue(detail);"));
11618 assert!(app_js.contains("if (submit) submit.disabled = false;"));
11619 assert!(app_js.contains("complete: existing.complete === true"));
11620 assert!(app_js.contains("continued: true"));
11621 assert!(app_js.contains("return !detailProviderId || detailProviderId === providerId;"));
11622 }
11623
11624 #[test]
11625 fn setup_runtime_start_is_serialized() {
11626 let ui_rs = include_str!("mod.rs");
11627 assert!(ui_rs.contains("setup_runtime_start: AsyncMutex<()>"));
11628 assert!(ui_rs.contains("let _start_guard = state.setup_runtime_start.lock().await;"));
11629 }
11630
11631 #[test]
11632 fn setup_ui_prefills_required_public_base_url_from_selected_tunnel() {
11633 let app_js = include_str!("../../assets/setup-ui/app.js");
11634 assert!(app_js.contains("providerNeedsGeneratedPublicBaseUrl(scope, p, form)"));
11635 assert!(app_js.contains("fetch(\"/api/setup-public-url\""));
11636 assert!(app_js.contains("store.public_base_url = result.public_base_url;"));
11637 assert!(app_js.contains("q.id === \"public_base_url\" && q.required === true"));
11638 }
11639
11640 #[test]
11641 fn setup_public_url_endpoint_reuses_setup_tunnel_lifecycle() {
11642 let ui_rs = include_str!("mod.rs");
11643 assert!(ui_rs.contains(".route(\"/api/setup-public-url\", post(post_setup_public_url))"));
11644 assert!(ui_rs.contains("async fn ensure_setup_public_url"));
11645 assert!(ui_rs.contains("ensure_setup_tunnel(state, &mode, &state.local_base_url).await"));
11646 }
11647
11648 #[tokio::test]
11649 async fn setup_backend_contract_descriptor_loads_asset_contract() {
11650 let temp = tempfile::tempdir().expect("tempdir");
11651 let providers = temp.path().join("providers/messaging");
11652 std::fs::create_dir_all(&providers).expect("providers");
11653 write_pack_with_asset_setup_backend_contract(
11654 &providers.join("messaging-teams.gtpack"),
11655 "messaging-teams",
11656 true,
11657 )
11658 .expect("pack");
11659
11660 let state = test_ui_state(temp.path());
11661 let app = build_router(state.clone());
11662 let response = app
11663 .oneshot(
11664 Request::builder()
11665 .uri("/api/providers")
11666 .body(Body::empty())
11667 .unwrap(),
11668 )
11669 .await
11670 .expect("providers response");
11671 assert_eq!(response.status(), StatusCode::OK);
11672 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11673 let body: Value = serde_json::from_slice(&bytes).unwrap();
11674 let provider = body["providers"]
11675 .as_array()
11676 .unwrap()
11677 .iter()
11678 .find(|provider| provider["provider_id"] == "messaging-teams")
11679 .expect("provider");
11680 assert_eq!(
11681 provider["setup_backend_contract"]["descriptor"]["asset"],
11682 "assets/setup/backend-contract.json"
11683 );
11684 assert_eq!(
11685 provider["setup_backend_contract"]["required_order"][0],
11686 "graph_admin_consent"
11687 );
11688
11689 let app = build_router(test_ui_state(temp.path()));
11690 let response = app
11691 .oneshot(
11692 Request::builder()
11693 .uri("/v1/messaging/setup/messaging-teams/demo")
11694 .body(Body::empty())
11695 .unwrap(),
11696 )
11697 .await
11698 .expect("state response");
11699 assert_eq!(response.status(), StatusCode::OK);
11700 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11701 let body: Value = serde_json::from_slice(&bytes).unwrap();
11702 let items = body["setup_status"]["items"].as_array().unwrap();
11703 assert!(!items.is_empty());
11704 assert_eq!(items[0]["id"], "graph_admin_consent");
11705 assert_eq!(items[0]["state"], "pending");
11706 assert_eq!(body["setup_status"]["ok"], false);
11707 }
11708
11709 #[tokio::test]
11710 async fn setup_backend_contract_missing_asset_is_blocked_not_complete() {
11711 let temp = tempfile::tempdir().expect("tempdir");
11712 let providers = temp.path().join("providers/messaging");
11713 std::fs::create_dir_all(&providers).expect("providers");
11714 write_pack_with_asset_setup_backend_contract(
11715 &providers.join("messaging-teams.gtpack"),
11716 "messaging-teams",
11717 false,
11718 )
11719 .expect("pack");
11720
11721 let app = build_router(test_ui_state(temp.path()));
11722 let response = app
11723 .oneshot(
11724 Request::builder()
11725 .uri("/v1/messaging/setup/messaging-teams/demo")
11726 .body(Body::empty())
11727 .unwrap(),
11728 )
11729 .await
11730 .expect("state response");
11731 assert_eq!(response.status(), StatusCode::OK);
11732 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11733 let body: Value = serde_json::from_slice(&bytes).unwrap();
11734 assert_eq!(body["ok"], false);
11735 assert_eq!(body["setup_status"]["ok"], false);
11736 assert_eq!(body["setup_status"]["items"].as_array().unwrap().len(), 0);
11737 assert_eq!(
11738 body["setup_status"]["blocked"]["title"],
11739 "Setup backend contract could not be loaded"
11740 );
11741 assert_ne!(body["setup_status"]["last_step"], "complete");
11742 assert_ne!(body["setup_status"]["next"], "Setup complete.");
11743 }
11744
11745 #[tokio::test]
11746 async fn setup_backend_next_names_unsupported_executor_kind() {
11747 let temp = tempfile::tempdir().expect("tempdir");
11748 let providers = temp.path().join("providers/messaging");
11749 std::fs::create_dir_all(&providers).expect("providers");
11750 write_pack_with_unsupported_setup_action(
11751 &providers.join("messaging-unsupported.gtpack"),
11752 "messaging-unsupported",
11753 )
11754 .expect("pack");
11755
11756 let state = test_ui_state(temp.path());
11757 let app = build_router(state.clone());
11758 let response = app
11759 .oneshot(
11760 Request::builder()
11761 .method("POST")
11762 .uri("/v1/messaging/setup/messaging-unsupported/demo/next")
11763 .header("content-type", "application/json")
11764 .body(Body::from("{}"))
11765 .unwrap(),
11766 )
11767 .await
11768 .expect("next response");
11769 assert_eq!(response.status(), StatusCode::OK);
11770 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11771 let body: Value = serde_json::from_slice(&bytes).unwrap();
11772 let result = &body["values"]["last_setup_result"];
11773 assert_eq!(result["ok"], false);
11774 assert_eq!(result["result"]["executor_kind"], "future_executor");
11775 assert_eq!(
11776 result["next"],
11777 "setup backend executor kind is not implemented: future_executor"
11778 );
11779 let events = read_provider_setup_events(
11780 &state,
11781 &super::ProviderSetupEventsQuery {
11782 provider_id: "messaging-unsupported".to_string(),
11783 tenant: Some("demo".to_string()),
11784 team: Some("support".to_string()),
11785 env: Some("dev".to_string()),
11786 limit: Some(10),
11787 },
11788 )
11789 .expect("diagnostic events");
11790 let diagnostic = events
11791 .iter()
11792 .find(|event| event["event_name"] == "greentic-provider-setup-backend-next")
11793 .expect("backend next diagnostic");
11794 assert_eq!(diagnostic["current_step_id"], "custom_step");
11795 assert_eq!(
11796 diagnostic["event_detail"]["selected_executor"]["kind"],
11797 "future_executor"
11798 );
11799 assert_eq!(
11800 diagnostic["event_detail"]["request"]["path"],
11801 "/v1/messaging/setup/messaging-unsupported/demo/next"
11802 );
11803 }
11804
11805 #[tokio::test]
11806 async fn setup_backend_contract_config_ignores_server_owned_browser_fields() {
11807 let temp = tempfile::tempdir().expect("tempdir");
11808 let providers = temp.path().join("providers/messaging");
11809 std::fs::create_dir_all(&providers).expect("providers");
11810 write_pack_with_setup_backend_contract(
11811 &providers.join("messaging-contract.gtpack"),
11812 "messaging-contract",
11813 )
11814 .expect("pack");
11815
11816 let app = build_router(test_ui_state(temp.path()));
11817 let response = app
11818 .oneshot(
11819 Request::builder()
11820 .method("POST")
11821 .uri("/v1/messaging/setup/messaging-contract/demo/config")
11822 .header("content-type", "application/json")
11823 .body(Body::from(
11824 json!({
11825 "config": {
11826 "safe_key": "safe",
11827 "oauth_device_code": "browser-device-code",
11828 "graph_access_token": "browser-token",
11829 "bot_access_token": "browser-bot-token"
11830 }
11831 })
11832 .to_string(),
11833 ))
11834 .unwrap(),
11835 )
11836 .await
11837 .expect("config response");
11838 assert_eq!(response.status(), StatusCode::OK);
11839 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11840 let body: Value = serde_json::from_slice(&bytes).unwrap();
11841 let config = &body["values"]["config"];
11842 assert_eq!(config["safe_key"], "safe");
11843 assert!(config.get("oauth_device_code").is_none());
11844 assert!(config.get("graph_access_token").is_none());
11845 assert!(config.get("bot_access_token").is_none());
11846 }
11847
11848 #[tokio::test]
11849 async fn provider_setup_event_route_writes_and_reads_jsonl() {
11850 let temp = tempfile::tempdir().expect("tempdir");
11851 let state = test_ui_state(temp.path());
11852 let app = build_router(state.clone());
11853 let request = Request::builder()
11854 .method("POST")
11855 .uri("/api/provider-setup-events")
11856 .header("content-type", "application/json")
11857 .body(Body::from(
11858 json!({
11859 "provider_id": "messaging-test",
11860 "event_name": "greentic-provider-setup-result",
11861 "event_detail": {
11862 "providerId": "messaging-test",
11863 "access_token": "secret",
11864 "step": "publish"
11865 },
11866 "tenant": "demo",
11867 "team": "support",
11868 "env": "dev",
11869 "setup_session_id": "browser-session",
11870 "setup_ui_url": "http://127.0.0.1:9999"
11871 })
11872 .to_string(),
11873 ))
11874 .unwrap();
11875 let response = app.oneshot(request).await.expect("response");
11876 assert_eq!(response.status(), StatusCode::OK);
11877
11878 let log_path = temp
11879 .path()
11880 .join("state/logs/setup/dev/demo/support/messaging-test.jsonl");
11881 let log = std::fs::read_to_string(&log_path).expect("log");
11882 assert!(log.contains(r#""event_name":"greentic-provider-setup-result""#));
11883 assert!(log.contains(r#""access_token":"[redacted]""#));
11884 assert!(!log.contains("secret"));
11885
11886 let app = build_router(state);
11887 let response = app
11888 .oneshot(
11889 Request::builder()
11890 .uri("/api/provider-setup-events?provider_id=messaging-test&tenant=demo&team=support&env=dev")
11891 .body(Body::empty())
11892 .unwrap(),
11893 )
11894 .await
11895 .expect("response");
11896 assert_eq!(response.status(), StatusCode::OK);
11897 let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
11898 let body: Value = serde_json::from_slice(&bytes).unwrap();
11899 assert_eq!(body["events"].as_array().unwrap().len(), 1);
11900 assert_eq!(body["events"][0]["event_detail"]["step"], "publish");
11901 assert_eq!(body["events"][0]["current_step_id"], "publish");
11902 assert_eq!(body["events"][0]["http_status"], Value::Null);
11903 }
11904
11905 #[test]
11906 fn provider_setup_event_helpers_persist_under_scoped_log_path() {
11907 let temp = tempfile::tempdir().expect("tempdir");
11908 let state = test_ui_state(temp.path());
11909 let record = persist_provider_setup_event(
11910 &state,
11911 ProviderSetupEventRequest {
11912 provider_id: "messaging-test".to_string(),
11913 event_name: "greentic-provider-setup-state".to_string(),
11914 event_detail: json!({"user_code": "CODE-1234", "status": 200}),
11915 current_step_id: None,
11916 current_progress: None,
11917 action_name: None,
11918 request_method: None,
11919 request_path: None,
11920 http_status: None,
11921 response_body: None,
11922 error: None,
11923 correlation_id: None,
11924 tenant: None,
11925 team: None,
11926 env: None,
11927 setup_session_id: None,
11928 setup_ui_url: None,
11929 },
11930 )
11931 .expect("persist");
11932 assert_eq!(record["tenant"], "demo");
11933 assert_eq!(record["team"], "support");
11934 assert_eq!(record["env"], "dev");
11935 assert_eq!(record["http_status"], 200);
11936
11937 let events = read_provider_setup_events(
11938 &state,
11939 &super::ProviderSetupEventsQuery {
11940 provider_id: "messaging-test".to_string(),
11941 tenant: None,
11942 team: None,
11943 env: None,
11944 limit: None,
11945 },
11946 )
11947 .expect("read");
11948 assert_eq!(events.len(), 1);
11949 assert!(
11950 events[0]["event_detail"]["user_code"]
11951 .as_str()
11952 .unwrap()
11953 .starts_with("[sha256:")
11954 );
11955 }
11956
11957 #[test]
11958 fn setup_ui_asset_forwards_generic_provider_setup_events() {
11959 let js = super::assets::APP_JS;
11960 assert!(js.contains("PROVIDER_SETUP_EVENT_NAMES"));
11961 assert!(js.contains("greentic-provider-setup-action-start"));
11962 assert!(js.contains("greentic-provider-setup-complete"));
11963 assert!(js.contains("postProviderSetupEvent"));
11964 assert!(js.contains("/api/provider-setup-events"));
11965 assert!(js.contains("sanitizeProviderSetupEventDetail"));
11966 assert!(js.contains("__greenticSetupTestHooks"));
11967 }
11968
11969 #[tokio::test]
11970 async fn persist_ui_draft_writes_provider_answers_to_dev_store() {
11971 let temp = tempfile::tempdir().expect("tempdir");
11972 let bundle_root = temp.path();
11973 std::fs::create_dir_all(bundle_root.join("packs")).expect("packs dir");
11974
11975 let pack_path = bundle_root.join("packs").join("weatherapi-pack.gtpack");
11976 write_pack_with_secret_requirements(
11977 &pack_path,
11978 "weatherapi-pack",
11979 r#"[{"key":"auth.param.get_weather.key"}]"#,
11980 )
11981 .expect("pack");
11982
11983 let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
11984 "weatherapi-pack": {
11985 "auth_param_get_weather_key": "test-weather-key"
11986 }
11987 }))
11988 .expect("answers");
11989
11990 let persisted = persist_ui_draft(bundle_root, "dev-tenant", None, "dev", &answers)
11991 .await
11992 .expect("persist draft");
11993 assert_eq!(
11994 persisted.get("weatherapi-pack"),
11995 Some(&json!(["auth_param_get_weather_key"]))
11996 );
11997
11998 let store = open_dev_store(bundle_root).expect("open store");
11999 let base_uri = crate::canonical_secret_uri(
12000 "dev",
12001 "dev-tenant",
12002 None,
12003 "weatherapi-pack",
12004 "auth_param_get_weather_key",
12005 );
12006 let alias_uri = crate::canonical_secret_uri(
12007 "dev",
12008 "dev-tenant",
12009 None,
12010 "weatherapi-pack",
12011 "auth.param.get_weather.key",
12012 );
12013 let base_value =
12014 String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
12015 let alias_value =
12016 String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
12017 assert_eq!(base_value, "test-weather-key");
12018 assert_eq!(alias_value, "test-weather-key");
12019 }
12020
12021 #[test]
12022 fn detects_cloud_deploy_targets_in_prefill_answers() {
12023 let cloud_prefill = serde_json::from_value::<JsonMap<String, Value>>(json!({
12024 "platform_setup": {
12025 "deployment_targets": [
12026 { "target": "runtime" },
12027 { "target": "aws" }
12028 ]
12029 }
12030 }))
12031 .expect("cloud prefill");
12032 assert!(prefill_has_cloud_deployment_targets(Some(&cloud_prefill)));
12033
12034 let local_prefill = serde_json::from_value::<JsonMap<String, Value>>(json!({
12035 "platform_setup": {
12036 "deployment_targets": [
12037 { "target": "runtime" },
12038 { "target": "single-vm" }
12039 ]
12040 }
12041 }))
12042 .expect("local prefill");
12043 assert!(!prefill_has_cloud_deployment_targets(Some(&local_prefill)));
12044 }
12045}