Skip to main content

greentic_setup/ui/
mod.rs

1//! Web-based setup UI server.
2//!
3//! Launches an Axum HTTP server on a random port, opens the browser, and serves
4//! a single-page app that drives the setup wizard through the same FormSpec
5//! infrastructure as the terminal wizard.
6
7mod 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
41// ── Types ──
42
43struct 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    /// Pre-loaded answers from `--answers` file, keyed by provider_id.
52    prefill_answers: Option<JsonMap<String, Value>>,
53    /// Where the on-disk artifact should be written back after a successful
54    /// setup. `Some(Archive)` means re-pack the extracted bundle dir into
55    /// a `.gtbundle`; `Some(Directory)` means copy the dir; `None` means
56    /// the user passed a directory and the working dir IS the artifact, so
57    /// no copy/repack is needed.
58    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    /// Set when a setup tunnel was spawned but never became externally
64    /// reachable (e.g. cloudflared quick tunnels are unroutable on this
65    /// network's DNS path). Acts as a circuit breaker: while set and within
66    /// [`TUNNEL_FAILURE_COOLDOWN`], `ensure_setup_tunnel` returns the
67    /// recorded error immediately instead of spawning yet another tunnel
68    /// that would just fail the same reachability check again.
69    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/// A provider the user can add to the bundle, from the embedded catalog
131/// (`assets/setup-ui/providers-catalog.json`, schema `providers@1`). Mirrors
132/// the `greentic-designer` providers-registry item shape so the two stay
133/// interchangeable.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135struct ProviderCatalogItem {
136    /// Pack id, e.g. `messaging-slack`.
137    id: String,
138    /// Provider domain: `messaging` | `events` | `oauth`.
139    category: String,
140    label: ProviderCatalogLabel,
141    /// OCI/file/local pack reference, e.g.
142    /// `oci://ghcr.io/greenticai/packs/messaging/messaging-slack:stable`.
143    #[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    /// Parse the embedded catalog JSON.
165    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    /// Pre-populated rows for `kind: List` questions, hydrated on wizard
182    /// re-run from the bundle's existing tenant config (e.g. nav_links).
183    /// Each entry is a JSON object keyed by `column.id` whose value matches
184    /// the column kind (string for scalars, locale-keyed object for
185    /// multilingual cells).
186    #[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    /// Link to where the operator can create this credential (provider dev
195    /// portal), surfaced from the component QA spec's `help_url`.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    create_url: Option<String>,
198    /// Column schema for `kind: List` (table) questions. Each entry tells
199    /// the front-end how to render one cell per row. Absent for scalar
200    /// kinds.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    list_columns: Option<Vec<ListColumnInfo>>,
203    /// Minimum row count for a `kind: List` question.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    min_rows: Option<usize>,
206    /// Maximum row count for a `kind: List` question.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    max_rows: Option<usize>,
209}
210
211/// Per-column metadata sent to the front-end so it can render one input
212/// per cell when the question kind is `List` (a.k.a. table).
213#[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    /// When true, the front-end renders a multi-locale cell — operator can
224    /// add per-locale translations via "+ Add language". Persisted as a
225    /// locale-keyed object instead of a plain string.
226    #[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
236/// Extra fields from setup.yaml not in FormSpec.
237struct SetupQuestionExtras {
238    placeholder: Option<String>,
239    group: Option<String>,
240    docs_url: Option<String>,
241    create_url: Option<String>,
242    /// Per-column metadata for `kind: table` questions. Maps column `key`
243    /// → multilingual flag. Used by the UI to render i18n-aware cells.
244    /// Empty for non-table questions.
245    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// ── Public API ──
428
429/// Launch the setup UI server and open in browser.
430///
431/// When `prefill_answers` is provided (from `--answers` file), the values are
432/// injected into the UI as pre-filled form values so the user can review and
433/// edit before executing.
434#[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    // Bind an ephemeral port by default. When GREENTIC_SETUP_BIND_PORT is set,
449    // bind that stable port instead so the setup server is reachable at a fixed
450    // address — required to tunnel the OAuth developer-install callback (paired
451    // with GREENTIC_SETUP_PUBLIC_BASE_URL). Falls back to ephemeral on bad input.
452    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
538// ── Static assets ──
539
540async 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// ── Provider catalog: add additional providers ──
565
566/// One row of the "add providers" list surfaced to the UI.
567#[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
581/// `GET /api/available-providers` — the embedded catalog minus the providers
582/// already present in the bundle, so the UI only offers packs that can still be
583/// added.
584async 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
610/// Catalog items minus the providers already installed in the bundle.
611fn 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
639/// `POST /api/add-provider` — fetch a catalog provider's `.gtpack` and drop it
640/// into the bundle's `providers/<category>/` dir so it joins the toggle list on
641/// the next `GET /api/providers`. This is a bundle-level add (the layer this
642/// page owns); the pack is deployed into an environment later by the deploy path.
643async 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
676/// Resolve a catalog item's pack reference to a local `.gtpack` and copy it into
677/// `<bundle>/providers/<category>/<id>.gtpack`. Handles `oci://`, `file://`, and
678/// local paths (the latter two keep tests hermetic — no network).
679async 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
706// ── API handlers ──
707
708/// Well-known locales with display labels.
709const 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    // Detect tenant from the bundle's tenants/ directory for informational display.
758    let detected_tenant = detect_tenant_from_bundle(bundle_path);
759
760    // The web UI should honor the requested CLI/answers scope. Detected bundle
761    // tenants are informational only; otherwise a scaffold containing both
762    // `demo` and `default` can silently shift setup into the wrong tenant.
763    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
800/// Detect tenant from the bundle's `tenants/` directory.
801fn 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
821/// Scan the bundle for previously configured scopes.
822///
823/// Reads `state/config/*/setup-answers.json` for provider answers and
824/// probes the dev secrets store with detected tenants to reconstruct
825/// existing scope configurations.
826async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
827    let bundle_path = &state.bundle_path;
828
829    // 1. Detect tenants from tenants/ directory
830    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    // 2. Read provider answers from state/config/*/setup-answers.json
854    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    // 3. For each tenant, probe secrets store to see if secrets exist
872    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            // Merge saved secrets with file-based answers
899            let mut merged_answers = JsonMap::new();
900            for (pid, file_ans) in &provider_answers {
901                let mut cloned = file_ans.clone();
902                // Migrate legacy `<id>_json` string answers to their array
903                // equivalent (the new `kind: table` wizard writes the array
904                // form). Without this the legacy ghost dominates the prefill
905                // and silently overrides the user's table edits on the next
906                // sync.
907                // Currently we only know one legacy `_json` string key —
908                // `nav_links_json`. Open-coded rather than looping a single
909                // element. If we add more table questions later, swap to a
910                // const slice + for loop again.
911                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            // Overlay saved secrets into answers
926            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; // found secrets for this tenant, skip other envs
945        }
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    // Use query locale override, fall back to CLI locale
958    let locale = query.locale.as_deref().or(state.locale.as_deref());
959
960    // Load i18n strings for the UI
961    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    // Detect shared questions (saved values injected after secrets are loaded below)
994    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    // Build lookup maps for extra fields (placeholder, group, docs_url) from setup.yaml
1030    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    // Guided create/help links from each pack's component QA spec (help_url).
1060    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    // Load saved secrets from dev store for auto-fill
1072    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    // Build per-provider prefill map from --answers file (overrides saved secrets)
1082    let prefill = &state.prefill_answers;
1083
1084    // Inject saved values into shared questions (pick from first provider that has the value)
1085    // Answers from --answers file take priority over saved secrets.
1086    // Filter out questions that are auto-injected by the operator (e.g. public_base_url).
1087    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            // First try --answers prefill (check all providers for the shared question)
1097            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            // Fall back to saved secrets
1115            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                            // setup.yaml create_url takes precedence over the
1159                            // component QA help_url when both are declared.
1160                            if ext.create_url.is_some() {
1161                                info.create_url = ext.create_url.clone();
1162                            }
1163                            // Overlay per-column multilingual flags onto the
1164                            // table-rendering metadata (qa-spec QuestionSpec
1165                            // has no slot for this hint, so we carry it
1166                            // out-of-band via SetupQuestionExtras).
1167                            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                        // --answers prefill takes priority over saved secrets
1181                        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                        // Hydrate kind: List rows from --answers (if it
1194                        // carries an array) or, for the webchat-gui
1195                        // nav_links table, from the bundle's persisted
1196                        // tenant.json so a wizard re-run pre-populates the
1197                        // pills the operator just configured.
1198                        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
2919/// Track and log repeated executions of the same setup step. Returns the
2920/// attempt number (1-based; resets when `config.public_base_url` changes,
2921/// since a URL change makes a re-run legitimate). A step re-running with an
2922/// unchanged public_base_url is making no progress — from attempt 3 on this
2923/// logs loudly: that pattern is the signature of a staleness/currency check
2924/// comparing the wrong tunnel, not of a genuine retry.
2925fn 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    // Re-adopt AFTER the runtime/tunnel are ensured: the adopt above ran
3601    // before `ensure_setup_runtime`, so on the very attempt that first boots
3602    // the runtime, config still holds the pre-runtime (Setup-UI tunnel) URL
3603    // and the provider would be registered against an endpoint that is about
3604    // to be superseded — costing an extra run per state transition. Adopting
3605    // again here makes the first post-boot attempt register the runtime's
3606    // real ingress URL immediately.
3607    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    // Resolve through the single ingress source of truth (live per-port
4751    // tunnel record first, then the runtime-reported endpoints) so the URL
4752    // adopted into config is the same one every currency check compares
4753    // against.
4754    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(&current) {
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(&current)
4784        || !setup_backend_public_base_url_is_external_https(&current)
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(&current) {
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(&current))
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(&current)
4822        && setup_backend_public_base_url_is_external_https(&current)
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            // A setup-started runtime may persist its local HTTP base as the runtime
4831            // "public" URL when its own tunnel is disabled. Provider webhooks still
4832            // need a setup-owned public tunnel to that runtime.
4833        } 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
4938/// An operator-supplied public base URL (managed tunnel) from the environment.
4939/// When present, provider setup honors it instead of spinning its own ephemeral
4940/// setup tunnel — which otherwise blocks the setup action when that tunnel can't
4941/// come up. Accepts ephemeral hosts (ngrok/trycloudflare) since the operator
4942/// chose them explicitly.
4943fn 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        // `PUBLIC_BASE_URL` is the runtime (`greentic-start`) override; accept it
4948        // here too so a single export drives both setup actions and start,
4949        // instead of a provider action erroring on a URL the operator already set.
4950        .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
4955/// The setup server's own public HTTPS base URL for OAuth *callbacks* (the
4956/// developer app-install flow handled here by `/oauth/callback/<provider>`),
4957/// distinct from the messaging `public_base_url` that targets the runtime for
4958/// webhook ingress. Set `GREENTIC_SETUP_PUBLIC_BASE_URL` to the setup server's
4959/// public tunnel. `None` (unset/non-https) → callers fall back to the messaging
4960/// `public_base_url`.
4961fn 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
4968/// Single source of truth for the OAuth developer-install callback base.
4969///
4970/// The redirect URL must be byte-identical in three places or Slack rejects the
4971/// exchange: the app manifest's `redirect_urls`, the authorize link's
4972/// `redirect_uri`, and the `oauth.v2.access` exchange's `redirect_uri`. The URL
4973/// the browser actually lands on is the *setup server's* public tunnel — not the
4974/// runtime's `public_base_url` — so resolve it here and feed all three seams.
4975///
4976/// An explicit env override wins (named tunnels / prod); otherwise use the live
4977/// setup tunnel fronting this UI.
4978fn 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        // Also honor the runtime override so one `PUBLIC_BASE_URL` export covers
4990        // both setup and start (see injected_setup_public_base_url).
4991        .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
5026/// Public URL of the tunnel fronting `port`: the in-session slot when it
5027/// fronts that exact port, else the machine-wide shared tunnel record.
5028///
5029/// The in-session slot (`state.setup_tunnel`) is last-writer-wins across
5030/// flows that tunnel *different* ports (runtime ingress vs the Setup UI), so
5031/// reading it port-blind reports whichever tunnel was acquired most recently
5032/// — comparing that against a runtime-ingress URL is what made the Teams
5033/// reconcile step permanently "stale". Keying by port restores one identity
5034/// per tunnel. Record read only — no probing here (this runs on every status
5035/// render); liveness is enforced by the acquire paths, and a dead-but-still-
5036/// recorded tunnel self-heals on the next acquire (record changes → one
5037/// legitimate re-register).
5038fn 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
5042/// Testable seam for [`setup_backend_tunnel_public_base_url_for_port`]:
5043/// `record_root` overrides the shared tunnel state root (tests use a temp dir
5044/// instead of mutating `GREENTIC_TUNNEL_STATE_DIR`, which races across
5045/// parallel tests).
5046fn 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
5111/// THE single source of truth for "what public URL fronts the runtime
5112/// ingress right now": the live per-port shared tunnel record, else the
5113/// runtime's own reported public base (`endpoints.json`), else nothing.
5114///
5115/// Every consumer (runtime_context, config adoption, registration bodies)
5116/// must resolve through here — five places used to compose their own chains
5117/// (contract config, in-session slot, per-port records, endpoints.json,
5118/// browser echoes), and every wizard loop so far was two of them
5119/// disagreeing. The port-blind in-session slot deliberately never answers:
5120/// it may hold the Setup-UI tunnel, which must not masquerade as the
5121/// ingress tunnel.
5122fn 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    // Log only a LIVE conflict: a resolved ingress URL that differs from the
5157    // one in config genuinely invalidates dependent steps. "Nothing resolved"
5158    // (runtime and tunnel down, e.g. between setup sessions) is unknown, not
5159    // stale — completed steps must survive it.
5160    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    // The plain probe shares the OS resolver with every other process on this
5251    // machine — and freshly-minted quick-tunnel hostnames (*.trycloudflare.com)
5252    // race DNS propagation: the first A-record query can land before the record
5253    // exists, poisoning the OS resolver's negative cache for up to the zone's
5254    // negative TTL (30 minutes for trycloudflare.com). On IPv4-only machines
5255    // that leaves the host unresolvable locally even though the tunnel is up
5256    // and every REMOTE party (Slack, Teams, ...) resolves it fine. Distinguish
5257    // "tunnel dead" from "our resolver is blind" by re-resolving via public
5258    // DNS-over-HTTPS (an IP-literal URL, so it needs no DNS at all) and
5259    // retrying the probe with the answer pinned.
5260    tunnel_responds_with_pinned_dns(base_url).await
5261}
5262
5263/// Second-opinion tunnel probe that bypasses the OS resolver: resolve the
5264/// host's A record via Cloudflare DoH (`https://1.1.1.1/...` — IP literal),
5265/// then re-issue the probe with reqwest pinned to that address (correct SNI
5266/// and cert validation still apply). Returns false when the host genuinely
5267/// has no public A record or the pinned request fails.
5268async 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
5303/// Resolve `host`'s first IPv4 address via Cloudflare's DNS-over-HTTPS JSON
5304/// API. The endpoint is addressed by IP literal so this works even when the
5305/// local resolver cannot resolve anything under the host's zone.
5306async 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        // type 1 = A record; CNAME chain entries (type 5) also appear here.
5322        .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    // An ephemeral public base is invalidated only by a LIVE conflicting
5335    // tunnel — a resolved ingress URL that differs from the recorded one. An
5336    // absent active tunnel (runtime and tunnel down, e.g. between setup
5337    // sessions) is UNKNOWN, not stale: treating it as stale un-completed
5338    // every runtime-dependent step on restart and re-ran OAuth consents and
5339    // endpoint registrations. When the runtime comes back with a different
5340    // URL, the conflict becomes live and dependent steps re-run exactly once.
5341    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        // Log only the fresh→stale transition, not the idempotent re-mark on
5636        // every render, so a stuck step shows one line per invalidation.
5637        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, &current)
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, &current) {
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        &current,
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    // Use scope from UI request if provided, otherwise fall back to CLI defaults
6360    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    // Persist tunnel config from the UI selection.
6368    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        // Operator supplied a public URL (e.g. a managed ngrok/cloudflared tunnel);
6377        // honor it instead of spinning — and blocking on — our own setup tunnel.
6378        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    // After a successful UI setup, re-pack the extracted bundle dir back
6423    // to its original `.gtbundle` archive (or copy it to a directory
6424    // output) so the on-disk artifact reflects the answers the user just
6425    // saved. Without this the simple-mode CLI did the write-back but the
6426    // UI mode silently dropped it — see bin/greentic_setup.rs:run_ui_mode.
6427    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        // Operator supplied a public URL; honor it instead of spinning our own tunnel.
6653        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    // `oauth_install_button` additionally builds an authorize_url (client_id,
6693    // scopes, redirect_uri) after registration; `open_url` is a plain link
6694    // whose target is resolved entirely from registration output via the
6695    // generic `install-to-workspace`-style deep_link action in the pack's
6696    // `greentic.setup.actions.v1` extension (see setup_final_actions.rs) —
6697    // it just needs registration to run, nothing more, and
6698    // `setup_action_final_url_value` already no-ops gracefully for it.
6699    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    // Resolve ONE callback base for the whole OAuth developer-install flow. The
6723    // same value must land in the app manifest's `redirect_urls` (below), in the
6724    // authorize link's `redirect_uri` (via SetupActionFinalUrlContext), and in the
6725    // exchange's `redirect_uri` (oauth_callback) — Slack rejects the exchange if
6726    // they differ.
6727    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    // Registration derives the manifest's `oauth_config.redirect_urls` from
6735    // `public_base_url`. For an OAuth install that redirect must point at THIS
6736    // setup server's callback (the live setup tunnel), not the runtime. Override
6737    // it on the registration request ONLY — `config` is persisted back as provider
6738    // answers and carries the runtime's public_base_url for webhook ingress, so it
6739    // must stay untouched. (The manifest's event-subscription URL is re-pointed to
6740    // the runtime by `setup_webhook` at `gtc start`.)
6741    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    // Discover packs to identify secret fields for encryption
6921    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    // Single scope → flat format (compatible with --answers)
6964    // Multiple scopes → array format
6965    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    // Exchange with the SAME callback base used for the manifest redirect_urls and
7020    // the authorize link, or Slack rejects with `bad_redirect_uri`.
7021    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('&', "&amp;")
7105        .replace('<', "&lt;")
7106        .replace('>', "&gt;")
7107        .replace('"', "&quot;")
7108        .replace('\'', "&#39;")
7109}
7110
7111async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
7112    let _ = state.shutdown_tx.send(());
7113}
7114
7115// ── Execution ──
7116
7117fn 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
7125/// How long `ensure_setup_tunnel` refuses to spawn another tunnel after one
7126/// failed to become reachable. Without this, a network that can't route the
7127/// tunnel provider's hostnames at all (e.g. cloudflared quick tunnels being
7128/// unroutable on some networks' DNS path) causes every single call to spawn
7129/// yet another doomed tunnel: the reuse-if-alive check always sees the prior
7130/// one as dead (it never became reachable) and replaces it, forever.
7131const 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        // Stale in-session tunnel: fall through and re-acquire. For
7139        // cloudflared, start_setup_tunnel consults the shared record and
7140        // replaces the dead tunnel; bailing here would strand setup on a
7141        // URL that stopped serving.
7142        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        // The slot is last-writer-wins across flows that tunnel different
7187        // ports (runtime ingress vs Setup UI) — make each takeover visible.
7188        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
7220/// Remaining cooldown after a tunnel failed to become reachable, or `None`
7221/// if there was no recent failure (or the cooldown has already elapsed).
7222fn 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
7230/// Persist a [`crate::platform_setup::TunnelHandoff`] for `tunnel`, best
7231/// effort — a write failure here should never fail setup itself, it only
7232/// means `greentic-start` falls back to its own port selection. See
7233/// `cli_helpers::persist_tunnel_handoff` for the CLI-side counterpart.
7234fn 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    // greentic-start binds its gateway to the handoff's local_port (so a
7244    // pre-created tunnel keeps fronting the runtime). The Setup-UI port is
7245    // occupied by this very process — handing it off would make the next
7246    // `greentic-start` try to bind a taken port and point the gateway at the
7247    // wrong tunnel. Only runtime-facing tunnels may be handed off.
7248    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            // When setup runs a cloudflared tunnel, hand the runtime off with
7355            // the tunnel ON: greentic-start adopts the same tunnel via the
7356            // machine-wide shared record (same port -> same URL), so the URL
7357            // configured during setup stays live after setup exits.
7358            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    // Collect manual steps before moving answers into request
7487    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    // Capture plan summary
7525    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
7563// ── Helpers ──
7564
7565/// Load previously saved secret values from the dev store for all providers.
7566async 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
7641/// Extract a non-empty string from a JSON value (handles String, Number, Bool).
7642fn 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    /// Base URL of the setup server's own `/oauth/callback/<provider>` endpoint.
7722    /// Must be the SAME value used for the app manifest's `redirect_urls` and for
7723    /// the token-exchange `redirect_uri`, or Slack rejects the exchange.
7724    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    // For an open_url install action, prefer its url_template (e.g. Slack's
7735    // api.slack.com/apps/{slack_app_id}/install-on-team? link) resolved from the
7736    // returned values, over any app_redirect link the component put in config.
7737    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    // The deep-link key (e.g. slack_app_url -> install-on-team) opens the
7752    // provider dashboard install. It cannot deliver an OAuth code back to our
7753    // callback, but it is the only install path that works in locked-down
7754    // orgs (e.g. Enterprise Grid without org app approval), so it is preferred
7755    // whenever registration returned it; the OAuth authorize URL below is the
7756    // fallback for packs that don't supply a dashboard link.
7757    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        // When the rewrite produced a DIFFERENT url (app_redirect -> dashboard
7766        // install page), return it under its own key so the registration's
7767        // original value survives: `slack_app_url` (app_redirect) is the
7768        // post-setup "open the bot in Slack" deep link and must not be
7769        // clobbered by the one-time install-page URL.
7770        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    // Store the authorize URL under the deep-link key only when registration did
7781    // not already supply that key (legacy packs use it as the button slot). When
7782    // registration returned it (e.g. Slack's slack_app_url -> install-on-team),
7783    // keep that value for the post-setup "open the installed app" link and expose
7784    // the authorize URL under oauth_authorize_url instead.
7785    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
7826/// Fill `{name}` placeholders in a `url_template` from returned config values
7827/// (e.g. `{slack_app_id}` -> `A0…`). Returns `None` if any placeholder is
7828/// missing/empty so the caller can fall back.
7829fn 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
7851/// Slack's `app_redirect` link (`https://[ws.]slack.com/app_redirect?app=<id>`)
7852/// opens the workspace app page, not the install screen. Rewrite it to the
7853/// `api.slack.com/apps/<id>/install-on-team?` link the setup flow expects.
7854/// Any other URL is returned unchanged.
7855fn 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        // The OAuth callback (developer app-install) is served by THIS setup
7921        // server via `/oauth/callback/<provider>` + `complete_oauth_callback`,
7922        // NOT the runtime. Prefer the resolved setup-callback base (live setup
7923        // tunnel or env override) — the same value stamped into the manifest's
7924        // `redirect_urls` and used for the exchange — so all three agree. Fall
7925        // back to the env, then to the messaging `public_base_url` (the runtime).
7926        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    // Resolve title and help from i18n if available
8025    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 is set by the caller via overlay_setup_extras —
8059                    // qa-spec QuestionSpec has no slot for it, so we leave it
8060                    // false here and let the UI loop fix it up from
8061                    // SetupQuestionExtras.column_multilingual.
8062                    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        // A stand-in .gtpack the catalog item points at via a local path — keeps
8178        // the test hermetic (no OCI/network); `install_catalog_provider` only
8179        // resolves + copies, it does not parse the pack.
8180        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        // The embedded SPA must render the scrollable add-list and wire the
8230        // add button — guards against the asset drifting out of sync.
8231        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        // While adding: button loader + page blocker + single-flight guard.
8240        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        // Plant a Setup-UI-port tunnel in the in-session slot (port 12345
8258        // matches test_ui_state's local_base_url).
8259        *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        // Publish a runtime-ingress record for port 8080 at the temp root.
8265        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        // Slot port match → slot URL, even though a record root is supplied.
8270        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        // Different port → the slot must NOT answer; the shared record does.
8280        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        // No slot match, no record → None.
8290        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        // Runtime endpoints on disk: ingress at a port that has no shared
8305        // tunnel record anywhere (odd port keeps the test hermetic), public
8306        // base = the registered ephemeral URL.
8307        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        // The in-session slot holds the SETUP-UI tunnel (different port).
8322        *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        // With the ingress port known, the slot must never answer: the
8335        // runtime-reported public base wins, so the registered URL stays
8336        // current and completed steps stay done across setup restarts.
8337        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        // Runtime and tunnel both down (e.g. between setup sessions): nothing
8352        // resolves, so the context has no active tunnel...
8353        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        // ...and UNKNOWN must not invalidate previously completed steps: only
8374        // a live conflicting URL may. (Treating unknown as stale re-ran OAuth
8375        // consents and endpoint registrations on every setup restart.)
8376        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        // Post-fix shape: the active tunnel resolved for the INGRESS port
8389        // matches the registered public_base_url → the step stays done.
8390        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, &current_ok));
8396        // The pre-fix failure: the port-blind slot reported the Setup-UI
8397        // tunnel instead → permanently stale. The gate itself must still
8398        // reject a genuine mismatch (that is what triggers a re-register).
8399        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            &current_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        // Setup-UI-port tunnel: skipped — greentic-start would try to bind
8419        // its gateway to a port this process already occupies.
8420        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        // Runtime-ingress tunnel: persisted with its own port.
8432        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        // Independent step tracks separately.
8458        assert_eq!(setup_backend_record_step_attempt(&mut stored, "publish"), 1);
8459        // A changed public_base_url makes a re-run legitimate → reset.
8460        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        // Counter round-trips through the stored map.
8466        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        // A failure recorded further in the past than the cooldown window
8491        // has already elapsed, so it should read as no-longer-blocking.
8492        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        // A LIVE conflicting ingress (runtime endpoints report a different
9323        // tunnel URL than the recorded context) is what makes the step stale
9324        // under the unknown-is-not-stale semantics.
9325        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        // A LIVE conflicting ingress (runtime endpoints report a different
10208        // tunnel URL than the recorded context) is what makes the step stale
10209        // under the unknown-is-not-stale semantics.
10210        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        // Slack's "Setup Slack App" action has no client_id to drive an
11382        // oauth_install_button (Slack's apps.manifest.update never returns
11383        // one on reuse), so it uses a plain open_url action instead. The
11384        // registration must still run — the setup-action endpoint used to
11385        // hard-reject any kind other than oauth_install_button.
11386        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        // A provider whose "run registration" setup action is a plain
11565        // `open_url` link (e.g. Slack's install-on-team button, which has no
11566        // client_id to drive an oauth_install_button) must still surface as a
11567        // runnable step — not be silently filtered out, which would skip the
11568        // whole provider-setup-action phase and never invoke registration.
11569        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}