Skip to main content

greentic_setup/
setup_backend_contract.rs

1//! Shared helpers for provider setup backend contracts.
2//!
3//! The UI server currently owns most backend-contract execution, but the
4//! mutation rules here are deliberately UI-independent so CLI and future
5//! state-machine execution can share the same safety behavior.
6
7use std::collections::BTreeSet;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::thread;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use anyhow::{Context, anyhow};
14use serde_json::{Map as JsonMap, Value};
15use url::Url;
16
17const REDACTED_SECRET_MARKER: &str = "[redacted:dev-secret]";
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct DeclaredProviderHttpRoute {
21    pub provider_id: String,
22    pub pack_path: PathBuf,
23    pub methods: Vec<String>,
24    pub target: ProviderHttpRouteTarget,
25    pub segments: Vec<ProviderHttpRouteSegment>,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum ProviderHttpRouteTarget {
30    SetupComponent { component_ref: String, op: String },
31    ProviderIngress { component_ref: String, op: String },
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum ProviderHttpRouteSegment {
36    Literal(String),
37    Tenant,
38    Team,
39    Wildcard,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct ProviderHttpRouteMatch {
44    pub route: DeclaredProviderHttpRoute,
45    pub tenant: String,
46    pub team: String,
47}
48
49pub fn server_owned_config_keys(contract: &Value) -> BTreeSet<String> {
50    contract
51        .get("server_owned_config_keys")
52        .and_then(Value::as_array)
53        .into_iter()
54        .flatten()
55        .filter_map(Value::as_str)
56        .map(str::trim)
57        .filter(|value| !value.is_empty())
58        .map(str::to_string)
59        .collect()
60}
61
62pub fn merge_browser_config_update(
63    stored: &mut JsonMap<String, Value>,
64    incoming: &Value,
65    contract: &Value,
66    default_config: JsonMap<String, Value>,
67) -> anyhow::Result<()> {
68    let Some(incoming) = incoming.as_object() else {
69        return Ok(());
70    };
71    let server_owned = server_owned_config_keys(contract);
72    let config = stored
73        .entry("config".to_string())
74        .or_insert_with(|| Value::Object(default_config))
75        .as_object_mut()
76        .ok_or_else(|| anyhow!("stored config is not an object"))?;
77
78    for (key, value) in incoming {
79        if server_owned.contains(key) {
80            continue;
81        }
82        config.insert(key.clone(), value.clone());
83    }
84    Ok(())
85}
86
87pub fn required_steps(contract: &Value) -> Vec<&str> {
88    contract
89        .get("required_order")
90        .and_then(Value::as_array)
91        .into_iter()
92        .flatten()
93        .filter_map(Value::as_str)
94        .collect()
95}
96
97pub fn action_by_id<'a>(contract: &'a Value, id: &str) -> Option<&'a Value> {
98    contract
99        .get("actions")
100        .and_then(Value::as_array)?
101        .iter()
102        .find(|action| action.get("id").and_then(Value::as_str) == Some(id))
103}
104
105pub fn value_at_path<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
106    path.split('.')
107        .filter(|part| !part.is_empty())
108        .try_fold(root, |current, part| current.get(part))
109}
110
111pub fn completion_met(values: &Value, completion: &Value) -> bool {
112    let Some(path) = completion.get("state_path").and_then(Value::as_str) else {
113        return false;
114    };
115    let Some(value) = value_at_path(values, path) else {
116        return false;
117    };
118    if completion
119        .get("exists")
120        .and_then(Value::as_bool)
121        .unwrap_or(false)
122    {
123        return !value.is_null()
124            && value
125                .as_str()
126                .map(|value| !value.trim().is_empty())
127                .unwrap_or(true);
128    }
129    if let Some(expected) = completion.get("equals") {
130        return value == expected;
131    }
132    value.as_bool().unwrap_or(false)
133}
134
135pub fn render_status(contract: &Value, stored: &JsonMap<String, Value>) -> Value {
136    let values = render_values(stored);
137    let recovery_step = oauth_resume_token(stored)
138        .and_then(|token| oauth_action_by_token_store_key(contract, token))
139        .and_then(|action| action.get("id").and_then(Value::as_str));
140    let items: Vec<Value> = required_steps(contract)
141        .into_iter()
142        .map(|step| {
143            let done = Some(step) != recovery_step
144                && action_by_id(contract, step)
145                    .and_then(|action| action.get("completion"))
146                    .is_some_and(|completion| completion_met(&values, completion));
147            serde_json::json!({
148                "id": step,
149                "label": step.replace('_', " "),
150                "state": if done { "done" } else { "pending" },
151            })
152        })
153        .collect();
154    let ok = !items.is_empty()
155        && items
156            .iter()
157            .all(|item| item.get("state").and_then(Value::as_str) == Some("done"));
158    let next = recovery_step.unwrap_or_else(|| {
159        items
160            .iter()
161            .find(|item| item.get("state").and_then(Value::as_str) != Some("done"))
162            .and_then(|item| item.get("id").and_then(Value::as_str))
163            .unwrap_or("complete")
164    });
165    let blocked = stored
166        .get("last_setup_result")
167        .and_then(blocked_from_result)
168        .unwrap_or(Value::Null);
169    serde_json::json!({
170        "ok": ok,
171        "next": next,
172        "items": items,
173        "blocked": blocked,
174    })
175}
176
177pub fn next_action_id(contract: &Value, stored: &JsonMap<String, Value>) -> Option<String> {
178    let status = render_status(contract, stored);
179    status
180        .get("next")
181        .and_then(Value::as_str)
182        .filter(|step| *step != "complete")
183        .map(str::to_string)
184}
185
186pub fn executor_kind(action: &Value) -> Option<&str> {
187    action
188        .get("executor")
189        .and_then(Value::as_object)
190        .and_then(|executor| executor.get("kind"))
191        .and_then(Value::as_str)
192        .map(str::trim)
193        .filter(|value| !value.is_empty())
194}
195
196pub fn executor(action: &Value) -> anyhow::Result<&Value> {
197    action
198        .get("executor")
199        .ok_or_else(|| anyhow!("setup backend action missing executor"))
200}
201
202pub fn required_executor_str<'a>(executor: &'a Value, key: &str) -> anyhow::Result<&'a str> {
203    executor
204        .get(key)
205        .and_then(Value::as_str)
206        .map(str::trim)
207        .filter(|value| !value.is_empty())
208        .ok_or_else(|| anyhow!("setup backend executor missing {key}"))
209}
210
211pub fn config_mut(
212    stored: &mut JsonMap<String, Value>,
213) -> anyhow::Result<&mut JsonMap<String, Value>> {
214    stored
215        .entry("config".to_string())
216        .or_insert_with(|| Value::Object(JsonMap::new()))
217        .as_object_mut()
218        .ok_or_else(|| anyhow!("stored config is not an object"))
219}
220
221pub fn config_str(config: &JsonMap<String, Value>, key: &str) -> String {
222    config
223        .get(key)
224        .and_then(Value::as_str)
225        .map(str::trim)
226        .unwrap_or_default()
227        .to_string()
228}
229
230pub fn oauth_client_id(
231    executor: &Value,
232    config: &JsonMap<String, Value>,
233) -> anyhow::Result<String> {
234    let client_id_key = required_executor_str(executor, "client_id_config_key")?;
235    let configured_client_id = config_str(config, client_id_key);
236    if !configured_client_id.is_empty() {
237        return Ok(configured_client_id);
238    }
239    Ok(executor
240        .get("client_id_default")
241        .and_then(Value::as_str)
242        .map(str::trim)
243        .filter(|value| !value.is_empty())
244        .unwrap_or_default()
245        .to_string())
246}
247
248pub fn oauth_device_login_next_message() -> &'static str {
249    "open the Microsoft device-code page, enter the code, then click Continue setup"
250}
251
252pub fn public_oauth_response(body: &Value) -> Value {
253    let mut public = JsonMap::new();
254    for key in [
255        "user_code",
256        "verification_uri",
257        "verification_url",
258        "verification_uri_complete",
259        "expires_in",
260        "interval",
261        "message",
262    ] {
263        if let Some(value) = body.get(key) {
264            public.insert(key.to_string(), value.clone());
265        }
266    }
267    Value::Object(public)
268}
269
270pub fn device_login_payload(
271    config: &JsonMap<String, Value>,
272    user_code_key: &str,
273    body: &Value,
274) -> Value {
275    serde_json::json!({
276        "url": config_str(config, "oauth_verification_uri"),
277        "userCode": config_str(config, user_code_key),
278        "user_code": config_str(config, user_code_key),
279        "verification_uri_complete": body.get("verification_uri_complete").cloned().unwrap_or(Value::Null),
280        "message": body.get("message").cloned().unwrap_or(Value::Null),
281    })
282}
283
284pub fn execute_oauth_device_code_start_with_response(
285    stored: &mut JsonMap<String, Value>,
286    action: &Value,
287    body: &Value,
288) -> anyhow::Result<Value> {
289    let executor = executor(action)?;
290    let client_id = {
291        let config = config_mut(stored)?;
292        oauth_client_id(executor, config)?
293    };
294    if client_id.is_empty() {
295        let client_id_key = required_executor_str(executor, "client_id_config_key")?;
296        return Ok(step_result(
297            action,
298            false,
299            &format!("set {client_id_key}, then retry"),
300            serde_json::json!({
301                "ok": false,
302                "missing_config_key": client_id_key,
303            }),
304        ));
305    }
306    let authority_tenant = {
307        let config = config_mut(stored)?;
308        executor
309            .get("authority_tenant_config_key")
310            .and_then(Value::as_str)
311            .map(|key| config_str(config, key))
312            .filter(|value| !value.is_empty())
313            .or_else(|| {
314                executor
315                    .get("authority_tenant_default")
316                    .and_then(Value::as_str)
317                    .map(str::to_string)
318            })
319            .unwrap_or_else(|| "organizations".to_string())
320    };
321    let authority_template = required_executor_str(executor, "authority_url_template")?;
322    let authority = authority_template.replace("{authority_tenant}", &authority_tenant);
323    let token_url = format!("{}/oauth2/v2.0/token", authority.trim_end_matches('/'));
324    let device_code = body
325        .get("device_code")
326        .and_then(Value::as_str)
327        .unwrap_or_default()
328        .trim()
329        .to_string();
330    if device_code.is_empty() {
331        return Ok(step_result(
332            action,
333            false,
334            "OAuth device-code response did not include a device code.",
335            serde_json::json!({ "ok": false, "body": body }),
336        ));
337    }
338    let oauth_kind = executor
339        .get("oauth_kind")
340        .and_then(Value::as_str)
341        .unwrap_or("default");
342    let device_code_key = executor
343        .get("device_code_store_key")
344        .and_then(Value::as_str)
345        .unwrap_or("oauth_device_code");
346    let user_code_key = executor
347        .get("user_code_store_key")
348        .and_then(Value::as_str)
349        .unwrap_or("oauth_user_code");
350
351    let login = {
352        let config = config_mut(stored)?;
353        config.insert(
354            "oauth_kind".to_string(),
355            Value::String(oauth_kind.to_string()),
356        );
357        config.insert(device_code_key.to_string(), Value::String(device_code));
358        if let Some(user_code) = body.get("user_code").and_then(Value::as_str) {
359            config.insert(
360                user_code_key.to_string(),
361                Value::String(user_code.to_string()),
362            );
363        }
364        if let Some(verification_uri) = body
365            .get("verification_uri")
366            .or_else(|| body.get("verification_url"))
367            .and_then(Value::as_str)
368        {
369            config.insert(
370                "oauth_verification_uri".to_string(),
371                Value::String(verification_uri.to_string()),
372            );
373        }
374        config.insert("oauth_token_url".to_string(), Value::String(token_url));
375        config.insert("oauth_client_id".to_string(), Value::String(client_id));
376        device_login_payload(config, user_code_key, body)
377    };
378    stored.insert(
379        "last_oauth".to_string(),
380        serde_json::json!({
381            "kind": oauth_kind,
382            "response": public_oauth_response(body),
383        }),
384    );
385    Ok(step_result(
386        action,
387        false,
388        oauth_device_login_next_message(),
389        serde_json::json!({
390            "ok": false,
391            "pending_device_login": true,
392            "login": login,
393            "body": public_oauth_response(body),
394        }),
395    ))
396}
397
398pub fn execute_oauth_device_code_start(
399    stored: &mut JsonMap<String, Value>,
400    action: &Value,
401) -> anyhow::Result<Value> {
402    let executor = executor(action)?;
403    let config = config_mut(stored)?;
404    let client_id = oauth_client_id(executor, config)?;
405    if client_id.is_empty() {
406        let client_id_key = required_executor_str(executor, "client_id_config_key")?;
407        return Ok(step_result(
408            action,
409            false,
410            &format!("set {client_id_key}, then retry"),
411            serde_json::json!({
412                "ok": false,
413                "missing_config_key": client_id_key,
414            }),
415        ));
416    }
417    let authority_tenant = executor
418        .get("authority_tenant_config_key")
419        .and_then(Value::as_str)
420        .map(|key| config_str(config, key))
421        .filter(|value| !value.is_empty())
422        .or_else(|| {
423            executor
424                .get("authority_tenant_default")
425                .and_then(Value::as_str)
426                .map(str::to_string)
427        })
428        .unwrap_or_else(|| "organizations".to_string());
429    let authority_template = required_executor_str(executor, "authority_url_template")?;
430    let authority = authority_template.replace("{authority_tenant}", &authority_tenant);
431    let scopes = executor
432        .get("scopes")
433        .and_then(Value::as_array)
434        .into_iter()
435        .flatten()
436        .filter_map(Value::as_str)
437        .collect::<Vec<_>>()
438        .join(" ");
439    if scopes.trim().is_empty() {
440        return Ok(step_result(
441            action,
442            false,
443            "OAuth device-code action has no scopes.",
444            serde_json::json!({ "ok": false, "error": "oauth_device_code executor missing scopes" }),
445        ));
446    }
447    let device_url = format!("{}/oauth2/v2.0/devicecode", authority.trim_end_matches('/'));
448    let mut response = ureq::post(&device_url)
449        .send_form([
450            ("client_id", client_id.as_str()),
451            ("scope", scopes.as_str()),
452        ])
453        .context("OAuth device-code request failed")?;
454    let body = response
455        .body_mut()
456        .read_json::<Value>()
457        .context("failed to parse OAuth device-code response")?;
458    execute_oauth_device_code_start_with_response(stored, action, &body)
459}
460
461pub fn oauth_device_login_started(stored: &JsonMap<String, Value>, action: &Value) -> bool {
462    let Some(executor) = action.get("executor") else {
463        return false;
464    };
465    let device_code_key = executor
466        .get("device_code_store_key")
467        .and_then(Value::as_str)
468        .unwrap_or("oauth_device_code");
469    stored
470        .get("config")
471        .and_then(Value::as_object)
472        .map(|config| {
473            !config_str(config, device_code_key).is_empty()
474                && !config_str(config, "oauth_client_id").is_empty()
475                && !config_str(config, "oauth_token_url").is_empty()
476        })
477        .unwrap_or(false)
478}
479
480pub fn execute_oauth_device_code_complete_with_response(
481    stored: &mut JsonMap<String, Value>,
482    action: &Value,
483    status: u16,
484    body: &Value,
485) -> anyhow::Result<Value> {
486    let executor = executor(action)?;
487    let oauth_kind = executor
488        .get("oauth_kind")
489        .and_then(Value::as_str)
490        .unwrap_or("default");
491    let device_code_key = executor
492        .get("device_code_store_key")
493        .and_then(Value::as_str)
494        .unwrap_or("oauth_device_code");
495    let token_store_key = required_executor_str(executor, "token_store_key")?;
496    {
497        let config = config_mut(stored)?;
498        let device_code = config_str(config, device_code_key);
499        let client_id = config_str(config, "oauth_client_id");
500        let token_url = config_str(config, "oauth_token_url");
501        if device_code.is_empty() || client_id.is_empty() || token_url.is_empty() {
502            return Ok(step_result(
503                action,
504                false,
505                "start device login first",
506                serde_json::json!({ "ok": false, "error": "device_login_not_started" }),
507            ));
508        }
509    }
510    if let Some(error) = body.get("error").and_then(Value::as_str)
511        && matches!(error, "authorization_pending" | "slow_down")
512    {
513        return Ok(step_result(
514            action,
515            false,
516            "authorization is still pending",
517            serde_json::json!({ "ok": false, "body": body }),
518        ));
519    }
520    if status >= 400 || body.get("access_token").and_then(Value::as_str).is_none() {
521        return Ok(step_result(
522            action,
523            false,
524            "OAuth token polling failed.",
525            serde_json::json!({ "ok": false, "http_status": status, "body": body }),
526        ));
527    }
528    {
529        let config = config_mut(stored)?;
530        if let Some(token) = body.get("access_token").and_then(Value::as_str) {
531            config.insert(
532                token_store_key.to_string(),
533                Value::String(token.to_string()),
534            );
535        }
536        config.remove(device_code_key);
537        let user_code_key = executor
538            .get("user_code_store_key")
539            .and_then(Value::as_str)
540            .unwrap_or("oauth_user_code");
541        config.remove(user_code_key);
542        config.remove("oauth_kind");
543        config.remove("oauth_client_id");
544        config.remove("oauth_token_url");
545    }
546    clear_oauth_resume_for_token(stored, token_store_key);
547    let oauth = stored
548        .entry("oauth".to_string())
549        .or_insert_with(|| Value::Object(JsonMap::new()))
550        .as_object_mut()
551        .ok_or_else(|| anyhow!("stored oauth state is not an object"))?;
552    oauth.insert(
553        oauth_kind.to_string(),
554        serde_json::json!({
555            "ok": true,
556            "completed_at": current_timestamp_ms(),
557            "token_store_key": token_store_key,
558        }),
559    );
560    Ok(step_result(
561        action,
562        true,
563        "click again to continue setup",
564        serde_json::json!({
565            "ok": true,
566            "persisted_keys": [token_store_key],
567        }),
568    ))
569}
570
571pub fn execute_oauth_device_code_complete(
572    stored: &mut JsonMap<String, Value>,
573    action: &Value,
574) -> anyhow::Result<Value> {
575    let executor = executor(action)?;
576    let device_code_key = executor
577        .get("device_code_store_key")
578        .and_then(Value::as_str)
579        .unwrap_or("oauth_device_code");
580    let (device_code, client_id, token_url) = {
581        let config = config_mut(stored)?;
582        (
583            config_str(config, device_code_key),
584            config_str(config, "oauth_client_id"),
585            config_str(config, "oauth_token_url"),
586        )
587    };
588    if device_code.is_empty() || client_id.is_empty() || token_url.is_empty() {
589        return Ok(step_result(
590            action,
591            false,
592            "start device login first",
593            serde_json::json!({ "ok": false, "error": "device_login_not_started" }),
594        ));
595    }
596    let agent = ureq::Agent::config_builder()
597        .http_status_as_error(false)
598        .build()
599        .new_agent();
600    let mut response = agent
601        .post(&token_url)
602        .send_form([
603            ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
604            ("client_id", client_id.as_str()),
605            ("device_code", device_code.as_str()),
606        ])
607        .context("OAuth device-code token polling failed")?;
608    let status = response.status().as_u16();
609    let body = response
610        .body_mut()
611        .read_json::<Value>()
612        .context("failed to parse OAuth device-code token response")?;
613    execute_oauth_device_code_complete_with_response(stored, action, status, &body)
614}
615
616pub fn expand_template(
617    tenant: &str,
618    team: &str,
619    env: &str,
620    config: &JsonMap<String, Value>,
621    template: &str,
622) -> String {
623    let mut expanded = template
624        .replace("{tenant}", tenant)
625        .replace("{team}", team)
626        .replace("{env}", env);
627    if expanded.contains("{public_base_url}") {
628        let public_base = config_str(config, "public_base_url")
629            .trim_end_matches('/')
630            .to_string();
631        expanded = expanded.replace("{public_base_url}", &public_base);
632    }
633    for (key, value) in config {
634        if let Some(value) = value.as_str() {
635            expanded = expanded.replace(&format!("{{{key}}}"), value);
636        }
637    }
638    expanded
639}
640
641pub fn expand_json_template(
642    tenant: &str,
643    team: &str,
644    env: &str,
645    config: &JsonMap<String, Value>,
646    value: &Value,
647) -> Value {
648    match value {
649        Value::String(template) => {
650            Value::String(expand_template(tenant, team, env, config, template))
651        }
652        Value::Array(items) => Value::Array(
653            items
654                .iter()
655                .map(|item| expand_json_template(tenant, team, env, config, item))
656                .collect(),
657        ),
658        Value::Object(map) => Value::Object(
659            map.iter()
660                .map(|(key, value)| {
661                    (
662                        key.clone(),
663                        expand_json_template(tenant, team, env, config, value),
664                    )
665                })
666                .collect(),
667        ),
668        other => other.clone(),
669    }
670}
671
672pub fn template_unresolved(value: &str) -> bool {
673    value.contains('{') || value.contains('}')
674}
675
676pub fn validate_provider_http_url(url: &str) -> anyhow::Result<()> {
677    let parsed =
678        Url::parse(url).with_context(|| format!("provider_http target is not a URL: {url}"))?;
679    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
680        anyhow::bail!("provider_http target must be an http(s) URL");
681    }
682    Ok(())
683}
684
685pub fn provider_http_url(
686    tenant: &str,
687    team: &str,
688    env: &str,
689    config: &JsonMap<String, Value>,
690    executor: &Value,
691) -> anyhow::Result<String> {
692    let Some(template) = executor
693        .get("url_template")
694        .or_else(|| executor.get("target_url_template"))
695        .and_then(Value::as_str)
696    else {
697        anyhow::bail!("provider_http path_template requires setup UI/provider route dispatch");
698    };
699    let url = expand_template(tenant, team, env, config, template);
700    validate_provider_http_url(&url)?;
701    Ok(url)
702}
703
704pub fn is_safe_same_origin_path(path: &str) -> bool {
705    path.starts_with('/')
706        && !path.starts_with("//")
707        && !path.contains('\\')
708        && Url::parse(path).is_err()
709}
710
711pub fn provider_http_path(
712    tenant: &str,
713    team: &str,
714    env: &str,
715    config: &JsonMap<String, Value>,
716    executor: &Value,
717) -> anyhow::Result<String> {
718    let path_template = executor
719        .get("path_template")
720        .or_else(|| executor.get("target_path_template"))
721        .and_then(Value::as_str)
722        .ok_or_else(|| anyhow!("provider_http executor requires path_template"))?;
723    let path = expand_template(tenant, team, env, config, path_template);
724    if !is_safe_same_origin_path(&path) {
725        anyhow::bail!("provider_http executor path_template must resolve to a safe absolute path");
726    }
727    Ok(path)
728}
729
730pub fn provider_http_payload(
731    _provider_id: &str,
732    tenant: &str,
733    team: &str,
734    env: &str,
735    config: &JsonMap<String, Value>,
736    action: &Value,
737) -> anyhow::Result<Value> {
738    let executor = executor(action)?;
739    if let Some(template) = executor
740        .get("body")
741        .or_else(|| executor.get("body_template"))
742        .or_else(|| executor.get("request_body"))
743    {
744        return Ok(expand_json_template(tenant, team, env, config, template));
745    }
746    anyhow::bail!(
747        "provider_http headless execution requires an explicit body/body_template/request_body"
748    );
749}
750
751fn provider_http_state_key<'a>(action: &'a Value, executor: &'a Value) -> &'a str {
752    executor
753        .get("state_store_key")
754        .and_then(Value::as_str)
755        .or_else(|| action.get("id").and_then(Value::as_str))
756        .unwrap_or("last_provider_http")
757}
758
759fn provider_http_response_ok(response: &Value) -> bool {
760    response
761        .get("body")
762        .and_then(|body| body.get("ok"))
763        .and_then(Value::as_bool)
764        .unwrap_or_else(|| response.get("ok").and_then(Value::as_bool).unwrap_or(false))
765}
766
767pub fn provider_http_result_from_response(
768    stored: &mut JsonMap<String, Value>,
769    action: &Value,
770    target: &str,
771    response: Value,
772) -> anyhow::Result<Value> {
773    let executor = executor(action)?;
774    let ok = provider_http_response_ok(&response);
775    let state_key = provider_http_state_key(action, executor);
776    let result = serde_json::json!({
777        "ok": ok,
778        "target": target,
779        "response": response,
780    });
781    stored.insert(state_key.to_string(), result.clone());
782    Ok(step_result(
783        action,
784        ok,
785        if ok {
786            "click again to continue setup"
787        } else {
788            "fix provider setup endpoint and retry"
789        },
790        result,
791    ))
792}
793
794pub fn execute_runtime_observation(
795    stored: &mut JsonMap<String, Value>,
796    action: &Value,
797) -> anyhow::Result<Value> {
798    let executor = executor(action)?;
799    let state_key = executor
800        .get("state_store_key")
801        .and_then(Value::as_str)
802        .unwrap_or("last_activity");
803    if stored.get(state_key).is_some() {
804        return Ok(step_result(
805            action,
806            true,
807            "runtime observation is present",
808            serde_json::json!({
809                "ok": true,
810                "state_store_key": state_key,
811                "observed": stored.get(state_key).cloned().unwrap_or(Value::Null),
812            }),
813        ));
814    }
815    Ok(step_result(
816        action,
817        false,
818        "runtime observation not present yet",
819        serde_json::json!({
820            "ok": false,
821            "waiting": true,
822            "blocked": true,
823            "error": "runtime observation not present yet",
824            "retryable": true,
825            "source": executor.get("source").cloned().unwrap_or(Value::Null),
826            "event": executor.get("event").cloned().unwrap_or(Value::Null),
827            "state_store_key": state_key,
828        }),
829    ))
830}
831
832pub fn execute_microsoft_graph_application(
833    stored: &mut JsonMap<String, Value>,
834    provider_id: &str,
835    action: &Value,
836) -> anyhow::Result<Value> {
837    let executor = executor(action)?;
838    let config = config_mut(stored)?;
839    let token_key = required_executor_str(executor, "graph_token_store_key")?;
840    let token = config_str(config, token_key);
841    if token.is_empty() {
842        return Ok(step_result(
843            action,
844            false,
845            &format!("complete OAuth for {token_key}, then retry"),
846            serde_json::json!({
847                "ok": false,
848                "blocked": true,
849                "missing_token_store_key": token_key,
850                "retryable": true,
851            }),
852        ));
853    }
854
855    let app_id_key = required_executor_str(executor, "app_id_config_key")?;
856    let secret_key = required_executor_str(executor, "client_secret_config_key")?;
857    let display_name_key = required_executor_str(executor, "display_name_config_key")?;
858    let configured_app_id = config_str(config, app_id_key);
859    let mut display_name = config_str(config, display_name_key);
860    if display_name.is_empty() {
861        display_name = "Greentic Bot".to_string();
862    }
863
864    let graph_base_url = executor
865        .get("graph_base_url")
866        .and_then(Value::as_str)
867        .map(str::trim)
868        .filter(|value| !value.is_empty())
869        .unwrap_or("https://graph.microsoft.com/v1.0")
870        .trim_end_matches('/')
871        .to_string();
872    validate_provider_http_url(&graph_base_url)?;
873    let select = "id,appId,displayName,signInAudience";
874    let filter = if configured_app_id.is_empty() {
875        format!("displayName eq '{}'", odata_string(&display_name))
876    } else {
877        format!("appId eq '{}'", odata_string(&configured_app_id))
878    };
879    let lookup_url = format!(
880        "{graph_base_url}/applications?$filter={}&$select={}",
881        url_encode(&filter),
882        url_encode(select)
883    );
884    let agent = graph_agent();
885    let lookup = graph_json_request(&agent, "GET", &lookup_url, Some(&token), None)?;
886    if !lookup.get("ok").and_then(Value::as_bool).unwrap_or(false) {
887        if let Some(result) =
888            oauth_required_result(action, token_key, "authenticated request failed", &lookup)
889        {
890            return Ok(result);
891        }
892        return Ok(step_result(
893            action,
894            false,
895            "Microsoft Graph application lookup failed.",
896            lookup,
897        ));
898    }
899
900    let items = lookup
901        .get("body")
902        .and_then(|body| body.get("value"))
903        .and_then(Value::as_array)
904        .cloned()
905        .unwrap_or_default();
906    let (app, action_name) = if let Some(app) = items.first() {
907        (
908            app.clone(),
909            if configured_app_id.is_empty() {
910                "reuse"
911            } else {
912                "reuse_by_app_id"
913            },
914        )
915    } else {
916        if !configured_app_id.is_empty() {
917            return Ok(step_result(
918                action,
919                false,
920                "configured app id was not found in Microsoft Graph applications",
921                serde_json::json!({ "ok": false, "configured_app_id": configured_app_id }),
922            ));
923        }
924        let create = graph_json_request(
925            &agent,
926            "POST",
927            &format!("{graph_base_url}/applications"),
928            Some(&token),
929            Some(serde_json::json!({
930                "displayName": display_name,
931                "signInAudience": executor
932                    .get("sign_in_audience")
933                    .and_then(Value::as_str)
934                    .unwrap_or("AzureADMultipleOrgs"),
935            })),
936        )?;
937        if !create.get("ok").and_then(Value::as_bool).unwrap_or(false) {
938            if let Some(result) =
939                oauth_required_result(action, token_key, "authenticated request failed", &create)
940            {
941                return Ok(result);
942            }
943            return Ok(step_result(
944                action,
945                false,
946                "Microsoft Graph application create failed.",
947                create,
948            ));
949        }
950        (create.get("body").cloned().unwrap_or(Value::Null), "create")
951    };
952
953    let object_id = app.get("id").and_then(Value::as_str).unwrap_or_default();
954    let app_id = app.get("appId").and_then(Value::as_str).unwrap_or_default();
955    if !app_id.is_empty() {
956        config.insert(app_id_key.to_string(), Value::String(app_id.to_string()));
957    }
958    config.insert(
959        display_name_key.to_string(),
960        Value::String(display_name.clone()),
961    );
962
963    let mut secret_action = "keep_existing_secret";
964    if config_str(config, secret_key).is_empty() {
965        if object_id.is_empty() {
966            return Ok(step_result(
967                action,
968                false,
969                "app object id missing; cannot add password",
970                serde_json::json!({ "ok": false, "app": app }),
971            ));
972        }
973        let password_display_name = executor
974            .get("password_display_name")
975            .and_then(Value::as_str)
976            .unwrap_or("setup secret");
977        let secret = graph_json_request(
978            &agent,
979            "POST",
980            &format!(
981                "{graph_base_url}/applications/{}/addPassword",
982                url_encode(object_id)
983            ),
984            Some(&token),
985            Some(serde_json::json!({
986                "passwordCredential": { "displayName": password_display_name }
987            })),
988        )?;
989        if !secret.get("ok").and_then(Value::as_bool).unwrap_or(false) {
990            if let Some(result) =
991                oauth_required_result(action, token_key, "authenticated request failed", &secret)
992            {
993                return Ok(result);
994            }
995            return Ok(step_result(
996                action,
997                false,
998                "Microsoft Graph addPassword failed.",
999                secret,
1000            ));
1001        }
1002        if let Some(secret_text) = secret
1003            .get("body")
1004            .and_then(|body| body.get("secretText"))
1005            .and_then(Value::as_str)
1006        {
1007            config.insert(
1008                secret_key.to_string(),
1009                Value::String(secret_text.to_string()),
1010            );
1011            secret_action = "generated_secret";
1012        }
1013    }
1014
1015    let result = serde_json::json!({
1016        "ok": true,
1017        "action": action_name,
1018        "secret_action": secret_action,
1019        "app_id": app_id,
1020        "bot_app_id": app_id,
1021        "app_object_id": object_id,
1022        "display_name": display_name,
1023        "provider_id": provider_id,
1024    });
1025    stored.insert("last_app_registration".to_string(), result.clone());
1026    Ok(step_result(
1027        action,
1028        true,
1029        "click again to continue setup",
1030        result,
1031    ))
1032}
1033
1034pub fn execute_microsoft_graph_teams_app_user_install(
1035    stored: &mut JsonMap<String, Value>,
1036    tenant: &str,
1037    team: &str,
1038    env: &str,
1039    action: &Value,
1040) -> anyhow::Result<Value> {
1041    let executor = executor(action)?;
1042    let config = config_mut(stored)?.clone();
1043    let token_key = required_executor_str(executor, "graph_token_store_key")?;
1044    let token = config_str(&config, token_key);
1045    let state_key = executor
1046        .get("state_store_key")
1047        .and_then(Value::as_str)
1048        .unwrap_or("last_teams_app_install");
1049    let links = expand_executor_links(tenant, team, env, &config, executor);
1050    let publish_state_key = executor
1051        .get("publish_state_store_key")
1052        .and_then(Value::as_str)
1053        .unwrap_or("last_teams_app_publish");
1054    let publish = stored
1055        .get(publish_state_key)
1056        .and_then(Value::as_object)
1057        .cloned()
1058        .unwrap_or_default();
1059    let catalog_app_id = publish
1060        .get("catalog_app_id")
1061        .and_then(Value::as_str)
1062        .unwrap_or_default()
1063        .to_string();
1064    if catalog_app_id.is_empty() {
1065        return Ok(step_result(
1066            action,
1067            false,
1068            "publish the Teams app before installing it for the signed-in user",
1069            serde_json::json!({
1070                "ok": false,
1071                "blocked": true,
1072                "error": "missing_catalog_app_id",
1073                "links": links,
1074            }),
1075        ));
1076    }
1077    if token.is_empty() {
1078        let result = serde_json::json!({
1079            "ok": true,
1080            "action": "manual_unverified",
1081            "catalog_app_id": catalog_app_id,
1082            "warning": format!("{} is unavailable; continuing with manual install links", token_key),
1083            "add_to_teams_url": links.get("add_to_teams_url").cloned().unwrap_or(Value::Null),
1084            "open_bot_chat_url": links.get("open_bot_chat_url").cloned().unwrap_or(Value::Null),
1085        });
1086        stored.insert(state_key.to_string(), result.clone());
1087        return Ok(step_result(
1088            action,
1089            true,
1090            "open the bot chat link and send a message",
1091            result,
1092        ));
1093    }
1094
1095    let graph_base_url = executor
1096        .get("graph_base_url")
1097        .and_then(Value::as_str)
1098        .map(str::trim)
1099        .filter(|value| !value.is_empty())
1100        .unwrap_or("https://graph.microsoft.com/v1.0")
1101        .trim_end_matches('/')
1102        .to_string();
1103    validate_provider_http_url(&graph_base_url)?;
1104    let agent = graph_agent();
1105    let installed = graph_json_request(
1106        &agent,
1107        "GET",
1108        &format!("{graph_base_url}/me/teamwork/installedApps?$expand=teamsApp"),
1109        Some(&token),
1110        None,
1111    )?;
1112    if !installed
1113        .get("ok")
1114        .and_then(Value::as_bool)
1115        .unwrap_or(false)
1116    {
1117        let result = serde_json::json!({
1118            "ok": true,
1119            "action": "manual_unverified",
1120            "catalog_app_id": catalog_app_id,
1121            "warning": "Graph could not verify installed apps; continuing with manual install links",
1122            "previous": installed,
1123            "add_to_teams_url": links.get("add_to_teams_url").cloned().unwrap_or(Value::Null),
1124            "open_bot_chat_url": links.get("open_bot_chat_url").cloned().unwrap_or(Value::Null),
1125        });
1126        stored.insert(state_key.to_string(), result.clone());
1127        return Ok(step_result(
1128            action,
1129            true,
1130            "open the bot chat link and send a message",
1131            result,
1132        ));
1133    }
1134    let items = installed
1135        .get("body")
1136        .and_then(|body| body.get("value"))
1137        .and_then(Value::as_array)
1138        .cloned()
1139        .unwrap_or_default();
1140    let existing = items.iter().find(|item| {
1141        item.get("teamsApp")
1142            .and_then(|teams_app| teams_app.get("id"))
1143            .and_then(Value::as_str)
1144            == Some(catalog_app_id.as_str())
1145    });
1146    let (action_name, installed_app_id) = if let Some(item) = existing {
1147        (
1148            "keep",
1149            item.get("id")
1150                .and_then(Value::as_str)
1151                .unwrap_or_default()
1152                .to_string(),
1153        )
1154    } else {
1155        let created = graph_json_request(
1156            &agent,
1157            "POST",
1158            &format!("{graph_base_url}/me/teamwork/installedApps"),
1159            Some(&token),
1160            Some(serde_json::json!({
1161                "teamsApp@odata.bind": format!("{graph_base_url}/appCatalogs/teamsApps/{catalog_app_id}")
1162            })),
1163        )?;
1164        if !created.get("ok").and_then(Value::as_bool).unwrap_or(false) {
1165            let result = serde_json::json!({
1166                "ok": true,
1167                "action": "manual_unverified",
1168                "catalog_app_id": catalog_app_id,
1169                "warning": "Graph could not install the app; continuing with manual install links",
1170                "previous": created,
1171                "add_to_teams_url": links.get("add_to_teams_url").cloned().unwrap_or(Value::Null),
1172                "open_bot_chat_url": links.get("open_bot_chat_url").cloned().unwrap_or(Value::Null),
1173            });
1174            stored.insert(state_key.to_string(), result.clone());
1175            return Ok(step_result(
1176                action,
1177                true,
1178                "open the bot chat link and send a message",
1179                result,
1180            ));
1181        }
1182        (
1183            "install",
1184            created
1185                .get("body")
1186                .and_then(|body| body.get("id"))
1187                .and_then(Value::as_str)
1188                .unwrap_or_default()
1189                .to_string(),
1190        )
1191    };
1192    let result = serde_json::json!({
1193        "ok": true,
1194        "action": action_name,
1195        "catalog_app_id": catalog_app_id,
1196        "installed_app_id": installed_app_id,
1197        "add_to_teams_url": links.get("add_to_teams_url").cloned().unwrap_or(Value::Null),
1198        "open_bot_chat_url": links.get("open_bot_chat_url").cloned().unwrap_or(Value::Null),
1199    });
1200    stored.insert(state_key.to_string(), result.clone());
1201    Ok(step_result(
1202        action,
1203        true,
1204        "open the bot chat link and send a message",
1205        result,
1206    ))
1207}
1208
1209pub fn execute_microsoft_graph_teams_app_catalog_publish(
1210    provider_pack_path: &Path,
1211    stored: &mut JsonMap<String, Value>,
1212    tenant: &str,
1213    team: &str,
1214    env: &str,
1215    action: &Value,
1216) -> anyhow::Result<Value> {
1217    let executor = executor(action)?;
1218    let config = config_mut(stored)?;
1219    let token_key = required_executor_str(executor, "graph_token_store_key")?;
1220    let token = config_str(config, token_key);
1221    if token.is_empty() {
1222        return Ok(step_result(
1223            action,
1224            false,
1225            &format!("complete OAuth for {token_key}, then retry"),
1226            serde_json::json!({
1227                "ok": false,
1228                "blocked": true,
1229                "missing_token_store_key": token_key,
1230                "retryable": true,
1231            }),
1232        ));
1233    }
1234    let app_id_key = required_executor_str(executor, "teams_app_id_config_key")?;
1235    let version_key = required_executor_str(executor, "teams_app_version_config_key")?;
1236    let bot_app_id_key = required_executor_str(executor, "bot_app_id_config_key")?;
1237    if config_str(config, app_id_key).is_empty() {
1238        let fallback = config_str(config, bot_app_id_key);
1239        if fallback.is_empty() {
1240            return Ok(step_result(
1241                action,
1242                false,
1243                &format!("{app_id_key} or {bot_app_id_key} is required"),
1244                serde_json::json!({
1245                    "ok": false,
1246                    "blocked": true,
1247                    "missing_config_keys": [app_id_key, bot_app_id_key],
1248                }),
1249            ));
1250        }
1251        config.insert(app_id_key.to_string(), Value::String(fallback));
1252    }
1253    if config_str(config, version_key).is_empty() {
1254        config.insert(version_key.to_string(), Value::String("1.0.0".to_string()));
1255    }
1256    let package = build_teams_app_package(provider_pack_path, tenant, team, env, config, executor)?;
1257    let app_id = config_str(config, app_id_key);
1258    let manifest_version = config_str(config, version_key);
1259    let links = expand_executor_links(tenant, team, env, config, executor);
1260    let graph_base_url = executor
1261        .get("graph_base_url")
1262        .and_then(Value::as_str)
1263        .map(str::trim)
1264        .filter(|value| !value.is_empty())
1265        .unwrap_or("https://graph.microsoft.com/v1.0")
1266        .trim_end_matches('/')
1267        .to_string();
1268    validate_provider_http_url(&graph_base_url)?;
1269    let agent = graph_agent();
1270    let filter = format!("externalId eq '{}'", odata_string(&app_id));
1271    let lookup_url = format!(
1272        "{graph_base_url}/appCatalogs/teamsApps?$filter={}&$select={}",
1273        url_encode(&filter),
1274        url_encode("id,externalId,displayName,distributionMethod")
1275    );
1276    let lookup = graph_json_request(&agent, "GET", &lookup_url, Some(&token), None)?;
1277    if !lookup.get("ok").and_then(Value::as_bool).unwrap_or(false) {
1278        if let Some(result) =
1279            oauth_required_result(action, token_key, "authenticated request failed", &lookup)
1280        {
1281            return Ok(result);
1282        }
1283        return Ok(step_result(
1284            action,
1285            false,
1286            "Teams app catalog lookup failed.",
1287            lookup,
1288        ));
1289    }
1290    let items = lookup
1291        .get("body")
1292        .and_then(|body| body.get("value"))
1293        .and_then(Value::as_array)
1294        .cloned()
1295        .unwrap_or_default();
1296    let (url, action_name, catalog_app_id) = if let Some(item) = items.first() {
1297        let catalog_app_id = item.get("id").and_then(Value::as_str).unwrap_or_default();
1298        (
1299            format!(
1300                "{graph_base_url}/appCatalogs/teamsApps/{}/appDefinitions",
1301                url_encode(catalog_app_id)
1302            ),
1303            "update",
1304            catalog_app_id.to_string(),
1305        )
1306    } else {
1307        (
1308            format!("{graph_base_url}/appCatalogs/teamsApps"),
1309            "publish",
1310            String::new(),
1311        )
1312    };
1313    let published = graph_binary_request(&agent, "POST", &url, &token, package)?;
1314    if !published
1315        .get("ok")
1316        .and_then(Value::as_bool)
1317        .unwrap_or(false)
1318    {
1319        if let Some(result) = oauth_required_result(
1320            action,
1321            token_key,
1322            "authenticated request failed",
1323            &published,
1324        ) {
1325            return Ok(result);
1326        }
1327        return Ok(step_result(
1328            action,
1329            false,
1330            "Teams app catalog publish failed.",
1331            published,
1332        ));
1333    }
1334    let body_catalog_id = published
1335        .get("body")
1336        .and_then(|body| body.get("id"))
1337        .and_then(Value::as_str)
1338        .unwrap_or_default();
1339    let catalog_app_id = if catalog_app_id.is_empty() {
1340        body_catalog_id.to_string()
1341    } else {
1342        catalog_app_id
1343    };
1344    let add_to_teams_url = links
1345        .get("add_to_teams_url")
1346        .and_then(Value::as_str)
1347        .map(str::to_string)
1348        .unwrap_or_else(|| {
1349            if catalog_app_id.is_empty() {
1350                String::new()
1351            } else {
1352                format!(
1353                    "https://teams.microsoft.com/l/app/{}?source=app-details-dialog",
1354                    url_encode(&catalog_app_id)
1355                )
1356            }
1357        });
1358    let result = serde_json::json!({
1359        "ok": true,
1360        "action": action_name,
1361        "teams_app_id": app_id,
1362        "catalog_app_id": catalog_app_id,
1363        "manifest_version": manifest_version,
1364        "add_to_teams_url": add_to_teams_url,
1365    });
1366    let state_key = executor
1367        .get("state_store_key")
1368        .and_then(Value::as_str)
1369        .unwrap_or("last_teams_app_publish");
1370    stored.insert(state_key.to_string(), result.clone());
1371    Ok(step_result(
1372        action,
1373        true,
1374        "open the Add to Teams link, install the app, then continue",
1375        result,
1376    ))
1377}
1378
1379fn build_teams_app_package(
1380    provider_pack_path: &Path,
1381    tenant: &str,
1382    team: &str,
1383    env: &str,
1384    config: &JsonMap<String, Value>,
1385    executor: &Value,
1386) -> anyhow::Result<Vec<u8>> {
1387    let manifest_asset = required_executor_str(executor, "manifest_template_asset")?;
1388    let base = executor
1389        .get("package_assets_base")
1390        .and_then(Value::as_str)
1391        .unwrap_or("assets/teams-app");
1392    if !is_safe_pack_relative_path(manifest_asset) || !is_safe_pack_relative_path(base) {
1393        anyhow::bail!("teams app package asset path is not safe");
1394    }
1395    let mut manifest = crate::discovery::read_pack_json_asset(provider_pack_path, manifest_asset)?;
1396    replace_json_templates(tenant, team, env, config, &mut manifest);
1397
1398    let mut out = std::io::Cursor::new(Vec::new());
1399    {
1400        let mut writer = zip::ZipWriter::new(&mut out);
1401        let options: zip::write::FileOptions<'_, ()> =
1402            zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1403        writer.start_file("manifest.json", options)?;
1404        let manifest_text = serde_json::to_vec_pretty(&manifest)?;
1405        std::io::Write::write_all(&mut writer, &manifest_text)?;
1406        for name in ["color.png", "outline.png"] {
1407            let asset = format!("{}/{}", base.trim_end_matches('/'), name);
1408            if let Ok(bytes) = read_pack_binary_asset(provider_pack_path, &asset) {
1409                writer.start_file(name, options)?;
1410                std::io::Write::write_all(&mut writer, &bytes)?;
1411            }
1412        }
1413        writer.finish()?;
1414    }
1415    Ok(out.into_inner())
1416}
1417
1418fn read_pack_binary_asset(pack_path: &Path, entry_name: &str) -> anyhow::Result<Vec<u8>> {
1419    if !is_safe_pack_relative_path(entry_name) {
1420        anyhow::bail!("unsafe pack asset path: {entry_name}");
1421    }
1422    let file = std::fs::File::open(pack_path)
1423        .with_context(|| format!("open provider pack {}", pack_path.display()))?;
1424    let mut archive = zip::ZipArchive::new(file).context("provider pack is not a zip archive")?;
1425    let mut entry = archive
1426        .by_name(entry_name)
1427        .with_context(|| format!("provider pack missing {entry_name}"))?;
1428    let mut bytes = Vec::new();
1429    std::io::Read::read_to_end(&mut entry, &mut bytes)?;
1430    Ok(bytes)
1431}
1432
1433fn is_safe_pack_relative_path(path: &str) -> bool {
1434    let candidate = Path::new(path);
1435    !path.trim().is_empty()
1436        && candidate.is_relative()
1437        && !candidate
1438            .components()
1439            .any(|component| matches!(component, std::path::Component::ParentDir))
1440}
1441
1442fn replace_json_templates(
1443    tenant: &str,
1444    team: &str,
1445    env: &str,
1446    config: &JsonMap<String, Value>,
1447    value: &mut Value,
1448) {
1449    match value {
1450        Value::String(text) => {
1451            *text = expand_template(tenant, team, env, config, text);
1452        }
1453        Value::Array(items) => {
1454            for item in items {
1455                replace_json_templates(tenant, team, env, config, item);
1456            }
1457        }
1458        Value::Object(map) => {
1459            for value in map.values_mut() {
1460                replace_json_templates(tenant, team, env, config, value);
1461            }
1462        }
1463        _ => {}
1464    }
1465}
1466
1467fn expand_executor_links(
1468    tenant: &str,
1469    team: &str,
1470    env: &str,
1471    config: &JsonMap<String, Value>,
1472    executor: &Value,
1473) -> Value {
1474    let mut links = JsonMap::new();
1475    if let Some(raw_links) = executor.get("links").and_then(Value::as_object) {
1476        for (key, value) in raw_links {
1477            if let Some(template) = value.as_str() {
1478                let output_key = key.strip_suffix("_template").unwrap_or(key).to_string();
1479                links.insert(
1480                    output_key,
1481                    Value::String(expand_template(tenant, team, env, config, template)),
1482                );
1483            }
1484        }
1485    }
1486    Value::Object(links)
1487}
1488
1489fn graph_agent() -> ureq::Agent {
1490    ureq::Agent::config_builder()
1491        .http_status_as_error(false)
1492        .build()
1493        .new_agent()
1494}
1495
1496fn graph_json_request(
1497    agent: &ureq::Agent,
1498    method: &str,
1499    url: &str,
1500    bearer: Option<&str>,
1501    body: Option<Value>,
1502) -> anyhow::Result<Value> {
1503    let auth = bearer.map(|token| format!("Bearer {token}"));
1504    let response = match method {
1505        "GET" => {
1506            let mut request = agent.get(url);
1507            if let Some(auth) = auth.as_deref() {
1508                request = request.header("Authorization", auth);
1509            }
1510            request.call()
1511        }
1512        "POST" => {
1513            let mut request = agent.post(url);
1514            if let Some(auth) = auth.as_deref() {
1515                request = request.header("Authorization", auth);
1516            }
1517            if let Some(body) = body {
1518                request.send_json(&body)
1519            } else {
1520                request.send_empty()
1521            }
1522        }
1523        other => anyhow::bail!("unsupported Microsoft Graph method: {other}"),
1524    };
1525    let mut response = response.with_context(|| format!("request failed: {url}"))?;
1526    let status = response.status().as_u16();
1527    let body = response
1528        .body_mut()
1529        .read_json::<Value>()
1530        .unwrap_or(Value::Null);
1531    Ok(serde_json::json!({
1532        "ok": status < 400,
1533        "status": status,
1534        "body": body,
1535    }))
1536}
1537
1538fn graph_binary_request(
1539    agent: &ureq::Agent,
1540    method: &str,
1541    url: &str,
1542    bearer: &str,
1543    payload: Vec<u8>,
1544) -> anyhow::Result<Value> {
1545    let auth = format!("Bearer {bearer}");
1546    let response = match method {
1547        "POST" => agent
1548            .post(url)
1549            .header("Authorization", auth)
1550            .header("Content-Type", "application/zip")
1551            .send(&payload),
1552        other => anyhow::bail!("unsupported Microsoft Graph binary method: {other}"),
1553    };
1554    let mut response = response.with_context(|| format!("request failed: {url}"))?;
1555    let status = response.status().as_u16();
1556    let text = response.body_mut().read_to_string().unwrap_or_default();
1557    let body = serde_json::from_str::<Value>(&text).unwrap_or(Value::String(text));
1558    Ok(serde_json::json!({
1559        "ok": status < 400,
1560        "status": status,
1561        "body": body,
1562    }))
1563}
1564
1565fn oauth_required_result(
1566    action: &Value,
1567    token_key: &str,
1568    reason: &str,
1569    response: &Value,
1570) -> Option<Value> {
1571    if !response_needs_oauth(response) {
1572        return None;
1573    }
1574    Some(step_result(
1575        action,
1576        false,
1577        &format!("complete OAuth for {token_key}, then retry"),
1578        serde_json::json!({
1579            "ok": false,
1580            "blocked": true,
1581            "error": "oauth_required",
1582            "reason": reason,
1583            "token_store_key": token_key,
1584            "resume_step": action.get("id").and_then(Value::as_str).unwrap_or_default(),
1585            "previous": response,
1586        }),
1587    ))
1588}
1589
1590fn response_needs_oauth(response: &Value) -> bool {
1591    let status = response.get("status").and_then(Value::as_u64).unwrap_or(0);
1592    let body = response.get("body").unwrap_or(response);
1593    let text = body.to_string().to_ascii_lowercase();
1594    let says_unauthorized = status == 401
1595        || text.contains("http 401")
1596        || text.contains("status 401")
1597        || text.contains("unauthorized")
1598        || text.contains("invalid token");
1599    let says_expired = text.contains("expired")
1600        || text.contains("expiry")
1601        || text.contains("token exp")
1602        || text.contains("lifetime validation failed")
1603        || text.contains("token is expired");
1604    let says_invalid = text.contains("invalid token")
1605        || text.contains("access token is invalid")
1606        || text.contains("invalid access token");
1607    says_unauthorized && (says_expired || says_invalid)
1608}
1609
1610fn odata_string(value: &str) -> String {
1611    value.replace('\'', "''")
1612}
1613
1614fn url_encode(value: &str) -> String {
1615    url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
1616}
1617
1618pub fn execute_provider_http_external(
1619    stored: &mut JsonMap<String, Value>,
1620    provider_id: &str,
1621    tenant: &str,
1622    team: &str,
1623    env: &str,
1624    action: &Value,
1625) -> anyhow::Result<Value> {
1626    let executor = executor(action)?;
1627    let config = config_mut(stored)?.clone();
1628    let target = match provider_http_url(tenant, team, env, &config, executor) {
1629        Ok(url) => url,
1630        Err(err) => {
1631            return Ok(step_result(
1632                action,
1633                false,
1634                &err.to_string(),
1635                serde_json::json!({
1636                    "ok": false,
1637                    "blocked": true,
1638                    "error": err.to_string(),
1639                }),
1640            ));
1641        }
1642    };
1643    if template_unresolved(&target) || target.trim().is_empty() {
1644        return Ok(step_result(
1645            action,
1646            false,
1647            "provider_http executor target could not be resolved",
1648            serde_json::json!({
1649                "ok": false,
1650                "blocked": true,
1651                "error": "provider_http executor target could not be resolved",
1652                "target": target,
1653            }),
1654        ));
1655    }
1656    let payload = match provider_http_payload(provider_id, tenant, team, env, &config, action) {
1657        Ok(payload) => payload,
1658        Err(err) => {
1659            return Ok(step_result(
1660                action,
1661                false,
1662                &err.to_string(),
1663                serde_json::json!({
1664                    "ok": false,
1665                    "blocked": true,
1666                    "error": err.to_string(),
1667                    "target": target,
1668                }),
1669            ));
1670        }
1671    };
1672    let method = executor
1673        .get("method")
1674        .and_then(Value::as_str)
1675        .map(str::trim)
1676        .filter(|value| !value.is_empty())
1677        .unwrap_or("POST")
1678        .to_ascii_uppercase();
1679    let agent = ureq::Agent::config_builder()
1680        .http_status_as_error(false)
1681        .build()
1682        .new_agent();
1683    let response = match method.as_str() {
1684        "POST" => agent.post(&target).send_json(&payload),
1685        "GET" => agent.get(&target).call(),
1686        _ => {
1687            return Ok(step_result(
1688                action,
1689                false,
1690                "provider_http headless execution supports GET and POST only",
1691                serde_json::json!({
1692                    "ok": false,
1693                    "blocked": true,
1694                    "error": "unsupported_provider_http_method",
1695                    "method": method,
1696                    "target": target,
1697                }),
1698            ));
1699        }
1700    };
1701    let mut response = match response {
1702        Ok(response) => response,
1703        Err(err) => {
1704            return Ok(step_result(
1705                action,
1706                false,
1707                "Provider setup service is not running",
1708                serde_json::json!({
1709                    "ok": false,
1710                    "blocked": true,
1711                    "error": "Provider setup service is not running",
1712                    "target": target,
1713                    "detail": err.to_string(),
1714                }),
1715            ));
1716        }
1717    };
1718    let status = response.status().as_u16();
1719    let body = response
1720        .body_mut()
1721        .read_json::<Value>()
1722        .unwrap_or(Value::Null);
1723    let response = serde_json::json!({
1724        "ok": status < 400,
1725        "status": status,
1726        "body": body,
1727    });
1728    provider_http_result_from_response(stored, action, &target, response)
1729}
1730
1731pub fn load_declared_provider_http_routes(
1732    bundle_path: &Path,
1733) -> anyhow::Result<Vec<DeclaredProviderHttpRoute>> {
1734    let discovered = crate::discovery::discover(bundle_path)?;
1735    let mut routes = Vec::new();
1736    for provider in discovered.providers {
1737        let Some(http_routes_extension) =
1738            crate::discovery::read_pack_extension(&provider.pack_path, "greentic.http-routes.v1")?
1739        else {
1740            continue;
1741        };
1742        let http_routes =
1743            extension_inline(&http_routes_extension).unwrap_or(&http_routes_extension);
1744        let ingress_extension = crate::discovery::read_pack_extension(
1745            &provider.pack_path,
1746            "messaging.provider_ingress.v1",
1747        )?;
1748        let ingress = ingress_extension
1749            .as_ref()
1750            .and_then(|extension| extension_inline(extension).or(Some(extension)));
1751        let Some(records) = http_routes.get("routes").and_then(Value::as_array) else {
1752            continue;
1753        };
1754        for record in records {
1755            let Some(pattern) = record
1756                .get("pattern")
1757                .and_then(Value::as_str)
1758                .map(str::trim)
1759                .filter(|value| !value.is_empty())
1760            else {
1761                continue;
1762            };
1763            let methods = record
1764                .get("methods")
1765                .and_then(Value::as_array)
1766                .into_iter()
1767                .flatten()
1768                .filter_map(Value::as_str)
1769                .map(ToString::to_string)
1770                .collect();
1771            let Some(target) = declared_provider_http_route_target(record, ingress) else {
1772                continue;
1773            };
1774            routes.push(DeclaredProviderHttpRoute {
1775                provider_id: provider.provider_id.clone(),
1776                pack_path: provider.pack_path.clone(),
1777                methods,
1778                target,
1779                segments: parse_provider_http_route_pattern(pattern),
1780            });
1781        }
1782    }
1783    Ok(routes)
1784}
1785
1786pub fn find_declared_provider_http_route(
1787    bundle_path: &Path,
1788    method: &str,
1789    path: &str,
1790    default_tenant: &str,
1791    default_team: &str,
1792) -> anyhow::Result<Option<ProviderHttpRouteMatch>> {
1793    let mut routes = load_declared_provider_http_routes(bundle_path)?;
1794    routes.sort_by(|a, b| {
1795        let a_wild = a
1796            .segments
1797            .iter()
1798            .any(|segment| matches!(segment, ProviderHttpRouteSegment::Wildcard));
1799        let b_wild = b
1800            .segments
1801            .iter()
1802            .any(|segment| matches!(segment, ProviderHttpRouteSegment::Wildcard));
1803        b.segments
1804            .len()
1805            .cmp(&a.segments.len())
1806            .then(a_wild.cmp(&b_wild))
1807    });
1808    let request_segments: Vec<&str> = path
1809        .trim_start_matches('/')
1810        .split('/')
1811        .filter(|segment| !segment.is_empty())
1812        .collect();
1813    for route in routes {
1814        if !route.methods.is_empty()
1815            && !route
1816                .methods
1817                .iter()
1818                .any(|candidate| candidate.eq_ignore_ascii_case(method))
1819        {
1820            continue;
1821        }
1822        if let Some((tenant, team)) =
1823            match_provider_http_route(&route, &request_segments, default_tenant, default_team)
1824        {
1825            return Ok(Some(ProviderHttpRouteMatch {
1826                route,
1827                tenant,
1828                team,
1829            }));
1830        }
1831    }
1832    Ok(None)
1833}
1834
1835fn extension_inline(extension: &Value) -> Option<&Value> {
1836    extension.get("inline").or(Some(extension))
1837}
1838
1839fn declared_provider_http_route_target(
1840    record: &Value,
1841    ingress: Option<&Value>,
1842) -> Option<ProviderHttpRouteTarget> {
1843    let setup_component_ref = record
1844        .get("setup_component_ref")
1845        .or_else(|| record.get("component_ref"))
1846        .and_then(Value::as_str)
1847        .map(str::trim)
1848        .filter(|value| !value.is_empty());
1849    if let Some(component_ref) = setup_component_ref {
1850        let op = record
1851            .get("setup_op")
1852            .or_else(|| record.get("op"))
1853            .or_else(|| record.get("provider_op"))
1854            .and_then(Value::as_str)
1855            .map(str::trim)
1856            .filter(|value| !value.is_empty())
1857            .unwrap_or("handle_http")
1858            .to_string();
1859        return Some(ProviderHttpRouteTarget::SetupComponent {
1860            component_ref: component_ref.to_string(),
1861            op,
1862        });
1863    }
1864
1865    let ingress = ingress?;
1866    let component_ref = ingress
1867        .get("component_ref")
1868        .and_then(Value::as_str)
1869        .map(str::trim)
1870        .filter(|value| !value.is_empty())?;
1871    let op = record
1872        .get("provider_op")
1873        .or_else(|| record.get("op"))
1874        .and_then(Value::as_str)
1875        .map(str::trim)
1876        .filter(|value| !value.is_empty())
1877        .unwrap_or("ingest_http")
1878        .to_string();
1879    Some(ProviderHttpRouteTarget::ProviderIngress {
1880        component_ref: component_ref.to_string(),
1881        op,
1882    })
1883}
1884
1885pub fn parse_provider_http_route_pattern(pattern: &str) -> Vec<ProviderHttpRouteSegment> {
1886    pattern
1887        .trim_start_matches('/')
1888        .split('/')
1889        .filter(|segment| !segment.is_empty())
1890        .map(|segment| {
1891            if segment == "{tenant}" {
1892                ProviderHttpRouteSegment::Tenant
1893            } else if segment == "{team}" {
1894                ProviderHttpRouteSegment::Team
1895            } else if segment.ends_with("*}") || segment == "*" {
1896                ProviderHttpRouteSegment::Wildcard
1897            } else {
1898                ProviderHttpRouteSegment::Literal(segment.to_string())
1899            }
1900        })
1901        .collect()
1902}
1903
1904pub fn match_provider_http_route(
1905    route: &DeclaredProviderHttpRoute,
1906    request_segments: &[&str],
1907    default_tenant: &str,
1908    default_team: &str,
1909) -> Option<(String, String)> {
1910    let mut tenant = default_tenant.to_string();
1911    let mut team = default_team.to_string();
1912    let mut request_index = 0;
1913    for segment in &route.segments {
1914        match segment {
1915            ProviderHttpRouteSegment::Literal(expected) => {
1916                if request_segments.get(request_index)? != expected {
1917                    return None;
1918                }
1919                request_index += 1;
1920            }
1921            ProviderHttpRouteSegment::Tenant => {
1922                tenant = request_segments.get(request_index)?.to_string();
1923                if tenant.is_empty() {
1924                    return None;
1925                }
1926                request_index += 1;
1927            }
1928            ProviderHttpRouteSegment::Team => {
1929                team = request_segments.get(request_index)?.to_string();
1930                if team.is_empty() {
1931                    return None;
1932                }
1933                request_index += 1;
1934            }
1935            ProviderHttpRouteSegment::Wildcard => {
1936                return Some((tenant, team));
1937            }
1938        }
1939    }
1940    if request_index == request_segments.len() {
1941        Some((tenant, team))
1942    } else {
1943        None
1944    }
1945}
1946
1947pub fn execute_provider_http_local_route(
1948    bundle_path: &Path,
1949    stored: &mut JsonMap<String, Value>,
1950    provider_id: &str,
1951    tenant: &str,
1952    team: &str,
1953    env: &str,
1954    action: &Value,
1955) -> anyhow::Result<Value> {
1956    let executor = executor(action)?;
1957    let config = config_mut(stored)?.clone();
1958    let target = match provider_http_path(tenant, team, env, &config, executor) {
1959        Ok(path) => path,
1960        Err(err) => {
1961            return Ok(step_result(
1962                action,
1963                false,
1964                &err.to_string(),
1965                serde_json::json!({
1966                    "ok": false,
1967                    "blocked": true,
1968                    "error": err.to_string(),
1969                }),
1970            ));
1971        }
1972    };
1973    let method = executor
1974        .get("method")
1975        .and_then(Value::as_str)
1976        .unwrap_or("POST");
1977    let Some(route_match) =
1978        find_declared_provider_http_route(bundle_path, method, &target, tenant, team)?
1979    else {
1980        let message = format!(
1981            "provider_http target {} is not declared by pack greentic.http-routes.v1",
1982            target
1983        );
1984        return Ok(step_result(
1985            action,
1986            false,
1987            &message,
1988            serde_json::json!({
1989                "ok": false,
1990                "blocked": true,
1991                "error": message,
1992                "target": target,
1993                "provider_id": provider_id,
1994            }),
1995        ));
1996    };
1997    let ProviderHttpRouteTarget::SetupComponent { component_ref, op } = &route_match.route.target
1998    else {
1999        return Ok(step_result(
2000            action,
2001            false,
2002            "provider setup route must declare setup_component_ref/setup_op",
2003            serde_json::json!({
2004                "ok": false,
2005                "blocked": true,
2006                "error": "provider setup route must declare setup_component_ref/setup_op",
2007                "target": target,
2008                "provider_id": provider_id,
2009            }),
2010        ));
2011    };
2012    let payload = provider_http_payload(provider_id, tenant, team, env, &config, action)?;
2013    let body_json = serde_json::to_string(&payload)?;
2014    let headers_json = serde_json::to_string(&serde_json::json!({
2015        "method": method,
2016        "path": target,
2017        "query": "",
2018    }))?;
2019    let request = serde_json::json!({
2020        "v": 1,
2021        "domain": "messaging",
2022        "provider": route_match.route.provider_id,
2023        "tenant": route_match.tenant,
2024        "team": route_match.team,
2025        "method": method,
2026        "path": target,
2027        "query": Vec::<(String, String)>::new(),
2028        "headers": headers_json,
2029        "body_json": body_json,
2030    });
2031    let setup_config = crate::engine::SetupConfig {
2032        tenant: route_match.tenant.clone(),
2033        team: Some(route_match.team.clone()),
2034        env: env.to_string(),
2035        offline: false,
2036        verbose: false,
2037    };
2038    let output = crate::engine::invoke_setup_component_operation(
2039        bundle_path,
2040        &route_match.route.pack_path,
2041        component_ref,
2042        op,
2043        &request,
2044        &setup_config,
2045    )?;
2046    let ok = output
2047        .get("ok")
2048        .and_then(Value::as_bool)
2049        .or_else(|| {
2050            output
2051                .get("response")
2052                .and_then(|response| response.get("body_json"))
2053                .and_then(|body| body.get("ok"))
2054                .and_then(Value::as_bool)
2055        })
2056        .unwrap_or(true);
2057    let response = serde_json::json!({
2058        "ok": ok,
2059        "response": output,
2060    });
2061    provider_http_result_from_response(stored, action, &target, response)
2062}
2063
2064pub fn step_result(action: &Value, ok: bool, next: &str, result: Value) -> Value {
2065    serde_json::json!({
2066        "ok": ok,
2067        "step": action.get("id").and_then(Value::as_str).unwrap_or_default(),
2068        "next": next,
2069        "result": result,
2070    })
2071}
2072
2073pub fn update_oauth_resume(stored: &mut JsonMap<String, Value>, setup_result: &Value) {
2074    let Some(result) = setup_result.get("result").and_then(Value::as_object) else {
2075        return;
2076    };
2077    if result.get("error").and_then(Value::as_str) != Some("oauth_required") {
2078        return;
2079    }
2080    let Some(token_store_key) = result.get("token_store_key").and_then(Value::as_str) else {
2081        return;
2082    };
2083    let resume_step = result
2084        .get("resume_step")
2085        .and_then(Value::as_str)
2086        .or_else(|| setup_result.get("step").and_then(Value::as_str))
2087        .unwrap_or_default();
2088    stored.insert(
2089        "oauth_resume".to_string(),
2090        serde_json::json!({
2091            "token_store_key": token_store_key,
2092            "resume_step": resume_step,
2093        }),
2094    );
2095}
2096
2097pub fn clear_oauth_resume_for_token(stored: &mut JsonMap<String, Value>, token_store_key: &str) {
2098    let should_clear =
2099        oauth_resume_token(stored).is_some_and(|stored_key| stored_key == token_store_key);
2100    if should_clear {
2101        stored.remove("oauth_resume");
2102    }
2103}
2104
2105pub fn append_backend_event(
2106    bundle_root: &Path,
2107    tenant: &str,
2108    team: &str,
2109    provider_id: &str,
2110    event: Value,
2111) -> anyhow::Result<PathBuf> {
2112    let path = backend_events_path(bundle_root, tenant, team, provider_id)?;
2113    if let Some(parent) = path.parent() {
2114        std::fs::create_dir_all(parent)
2115            .with_context(|| format!("create setup backend event dir {}", parent.display()))?;
2116    }
2117    let mut event = event;
2118    if let Value::Object(object) = &mut event {
2119        object
2120            .entry("timestamp_ms".to_string())
2121            .or_insert_with(|| Value::from(current_timestamp_ms() as u64));
2122    }
2123    let mut file = std::fs::OpenOptions::new()
2124        .create(true)
2125        .append(true)
2126        .open(&path)
2127        .with_context(|| format!("open setup backend event log {}", path.display()))?;
2128    serde_json::to_writer(&mut file, &event)
2129        .with_context(|| format!("write setup backend event {}", path.display()))?;
2130    file.write_all(b"\n")
2131        .with_context(|| format!("write setup backend event newline {}", path.display()))?;
2132    Ok(path)
2133}
2134
2135pub fn record_action_result(
2136    bundle_root: &Path,
2137    tenant: &str,
2138    team: &str,
2139    provider_id: &str,
2140    stored: &mut JsonMap<String, Value>,
2141    action_result: Value,
2142) -> anyhow::Result<PathBuf> {
2143    update_oauth_resume(stored, &action_result);
2144    stored.insert("last_setup_result".to_string(), action_result.clone());
2145    save_backend_state(bundle_root, tenant, team, provider_id, stored)?;
2146    append_backend_event(
2147        bundle_root,
2148        tenant,
2149        team,
2150        provider_id,
2151        serde_json::json!({
2152            "type": "action_result",
2153            "step": action_result.get("step").cloned().unwrap_or(Value::Null),
2154            "ok": action_result.get("ok").cloned().unwrap_or(Value::Null),
2155            "next": action_result.get("next").cloned().unwrap_or(Value::Null),
2156            "result": action_result.get("result").cloned().unwrap_or(Value::Null),
2157        }),
2158    )
2159}
2160
2161pub fn blocked_from_result(setup_result: &Value) -> Option<Value> {
2162    let result = setup_result.get("result")?;
2163    if !result
2164        .get("blocked")
2165        .and_then(Value::as_bool)
2166        .unwrap_or(false)
2167    {
2168        return None;
2169    }
2170    let message = result
2171        .get("error")
2172        .or_else(|| setup_result.get("next"))
2173        .and_then(Value::as_str)
2174        .unwrap_or("Setup action is blocked.");
2175    let mut blocked = JsonMap::new();
2176    blocked.insert(
2177        "title".to_string(),
2178        Value::String("Setup action blocked".to_string()),
2179    );
2180    blocked.insert("summary".to_string(), Value::String(message.to_string()));
2181    blocked.insert("next".to_string(), Value::String(message.to_string()));
2182    if blocked_result_retryable(message, result) {
2183        blocked.insert("retryable".to_string(), Value::Bool(true));
2184    }
2185    if let Some(capability) = result.get("missing_host_capability").cloned() {
2186        blocked.insert("missing_host_capability".to_string(), capability);
2187    }
2188    if let Some(config_key) = result.get("missing_config_key").cloned() {
2189        blocked.insert("missing_config_key".to_string(), config_key);
2190    }
2191    Some(Value::Object(blocked))
2192}
2193
2194pub fn blocked_result_retryable(message: &str, result: &Value) -> bool {
2195    result
2196        .get("retryable")
2197        .and_then(Value::as_bool)
2198        .unwrap_or(false)
2199        || result
2200            .get("waiting")
2201            .and_then(Value::as_bool)
2202            .unwrap_or(false)
2203        || message.eq_ignore_ascii_case("runtime/tunnel not running")
2204        || result
2205            .get("error")
2206            .and_then(Value::as_str)
2207            .is_some_and(|error| {
2208                error.eq_ignore_ascii_case("runtime/tunnel not running")
2209                    || error.eq_ignore_ascii_case("oauth_required")
2210            })
2211}
2212
2213pub fn oauth_action_by_token_store_key<'a>(
2214    contract: &'a Value,
2215    token_store_key: &str,
2216) -> Option<&'a Value> {
2217    contract
2218        .get("actions")
2219        .and_then(Value::as_array)?
2220        .iter()
2221        .find(|action| {
2222            let Some(executor) = action.get("executor").and_then(Value::as_object) else {
2223                return false;
2224            };
2225            executor.get("kind").and_then(Value::as_str) == Some("oauth_device_code")
2226                && executor.get("token_store_key").and_then(Value::as_str) == Some(token_store_key)
2227        })
2228}
2229
2230pub fn oauth_resume_token(stored: &JsonMap<String, Value>) -> Option<&str> {
2231    stored
2232        .get("oauth_resume")?
2233        .get("token_store_key")?
2234        .as_str()
2235        .map(str::trim)
2236        .filter(|value| !value.is_empty())
2237}
2238
2239fn render_values(stored: &JsonMap<String, Value>) -> Value {
2240    let mut values = JsonMap::new();
2241    for (key, value) in stored {
2242        values.insert(key.clone(), value.clone());
2243    }
2244    Value::Object(values)
2245}
2246
2247pub fn backend_state_dir(
2248    bundle_root: &Path,
2249    tenant: &str,
2250    team: &str,
2251    provider_id: &str,
2252) -> anyhow::Result<PathBuf> {
2253    Ok(bundle_root
2254        .join("state")
2255        .join("setup")
2256        .join(validate_path_segment(tenant, "tenant")?)
2257        .join(validate_path_segment(team, "team")?)
2258        .join(validate_path_segment(provider_id, "provider_id")?))
2259}
2260
2261pub fn backend_state_path(
2262    bundle_root: &Path,
2263    tenant: &str,
2264    team: &str,
2265    provider_id: &str,
2266) -> anyhow::Result<PathBuf> {
2267    Ok(backend_state_dir(bundle_root, tenant, team, provider_id)?.join("backend-contract.json"))
2268}
2269
2270pub fn backend_events_path(
2271    bundle_root: &Path,
2272    tenant: &str,
2273    team: &str,
2274    provider_id: &str,
2275) -> anyhow::Result<PathBuf> {
2276    Ok(backend_state_dir(bundle_root, tenant, team, provider_id)?.join("events.jsonl"))
2277}
2278
2279pub fn backend_archive_dir(
2280    bundle_root: &Path,
2281    tenant: &str,
2282    team: &str,
2283    provider_id: &str,
2284) -> anyhow::Result<PathBuf> {
2285    Ok(backend_state_dir(bundle_root, tenant, team, provider_id)?.join("archive"))
2286}
2287
2288pub fn archive_backend_state(
2289    bundle_root: &Path,
2290    tenant: &str,
2291    team: &str,
2292    provider_id: &str,
2293    reason: &str,
2294) -> anyhow::Result<Option<PathBuf>> {
2295    let state_path = backend_state_path(bundle_root, tenant, team, provider_id)?;
2296    if !state_path.is_file() {
2297        return Ok(None);
2298    }
2299    let archive_dir = backend_archive_dir(bundle_root, tenant, team, provider_id)?;
2300    std::fs::create_dir_all(&archive_dir)
2301        .with_context(|| format!("create setup backend archive dir {}", archive_dir.display()))?;
2302    let archive_path = archive_dir.join(format!(
2303        "{}-{}.json",
2304        safe_archive_segment(reason),
2305        current_timestamp_ms()
2306    ));
2307    std::fs::copy(&state_path, &archive_path).with_context(|| {
2308        format!(
2309            "archive setup backend state {} to {}",
2310            state_path.display(),
2311            archive_path.display()
2312        )
2313    })?;
2314    Ok(Some(archive_path))
2315}
2316
2317pub fn archive_backend_file(
2318    bundle_root: &Path,
2319    tenant: &str,
2320    team: &str,
2321    provider_id: &str,
2322    source_path: &Path,
2323    reason: &str,
2324) -> anyhow::Result<Option<PathBuf>> {
2325    if !source_path.is_file() {
2326        return Ok(None);
2327    }
2328    let archive_dir = backend_archive_dir(bundle_root, tenant, team, provider_id)?;
2329    std::fs::create_dir_all(&archive_dir)
2330        .with_context(|| format!("create setup backend archive dir {}", archive_dir.display()))?;
2331    let archive_path = archive_dir.join(format!(
2332        "{}-{}.json",
2333        safe_archive_segment(reason),
2334        current_timestamp_ms()
2335    ));
2336    std::fs::copy(source_path, &archive_path).with_context(|| {
2337        format!(
2338            "archive setup backend file {} to {}",
2339            source_path.display(),
2340            archive_path.display()
2341        )
2342    })?;
2343    Ok(Some(archive_path))
2344}
2345
2346pub fn legacy_backend_state_path(
2347    bundle_root: &Path,
2348    env: &str,
2349    tenant: &str,
2350    team: &str,
2351    provider_id: &str,
2352) -> anyhow::Result<PathBuf> {
2353    Ok(bundle_root
2354        .join("state")
2355        .join("setup-backends")
2356        .join(validate_path_segment(env, "env")?)
2357        .join(validate_path_segment(tenant, "tenant")?)
2358        .join(validate_path_segment(team, "team")?)
2359        .join(format!(
2360            "{}.json",
2361            validate_path_segment(provider_id, "provider_id")?
2362        )))
2363}
2364
2365#[derive(Clone, Debug)]
2366pub struct BackendStateMigration {
2367    pub legacy_path: PathBuf,
2368    pub legacy_setup_actions_path: PathBuf,
2369    pub state_path: PathBuf,
2370    pub archive_path: Option<PathBuf>,
2371    pub setup_actions_archive_path: Option<PathBuf>,
2372    pub event_path: PathBuf,
2373    pub source: String,
2374    pub legacy_removed: bool,
2375    pub setup_actions_removed: bool,
2376    pub empty: bool,
2377}
2378
2379pub fn load_legacy_backend_state(
2380    bundle_root: &Path,
2381    env: &str,
2382    tenant: &str,
2383    team: &str,
2384    provider_id: &str,
2385) -> anyhow::Result<Option<JsonMap<String, Value>>> {
2386    let legacy = legacy_backend_state_path(bundle_root, env, tenant, team, provider_id)?;
2387    read_state_file(&legacy)
2388}
2389
2390pub fn migrate_backend_state(
2391    bundle_root: &Path,
2392    env: &str,
2393    tenant: &str,
2394    team: &str,
2395    provider_id: &str,
2396) -> anyhow::Result<BackendStateMigration> {
2397    let state_path = backend_state_path(bundle_root, tenant, team, provider_id)?;
2398    let legacy_path = legacy_backend_state_path(bundle_root, env, tenant, team, provider_id)?;
2399    let legacy_setup_actions_path =
2400        crate::setup_actions::setup_actions_state_path(bundle_root, tenant, team, provider_id);
2401    let generic = read_state_file(&state_path)?;
2402    let legacy = read_state_file(&legacy_path)?;
2403    let (stored, source) = match (generic, legacy.clone()) {
2404        (Some(stored), _) => (stored, "generic"),
2405        (None, Some(stored)) => (stored, "legacy"),
2406        (None, None) => (JsonMap::new(), "empty"),
2407    };
2408
2409    save_backend_state(bundle_root, tenant, team, provider_id, &stored)?;
2410
2411    let archive_path = if legacy_path.is_file() {
2412        archive_backend_file(
2413            bundle_root,
2414            tenant,
2415            team,
2416            provider_id,
2417            &legacy_path,
2418            "legacy-migration",
2419        )?
2420    } else {
2421        None
2422    };
2423    let legacy_removed = if legacy_path.is_file() {
2424        std::fs::remove_file(&legacy_path)
2425            .with_context(|| format!("remove legacy setup backend {}", legacy_path.display()))?;
2426        true
2427    } else {
2428        false
2429    };
2430    let setup_actions_archive_path = if legacy_setup_actions_path.is_file() {
2431        archive_backend_file(
2432            bundle_root,
2433            tenant,
2434            team,
2435            provider_id,
2436            &legacy_setup_actions_path,
2437            "legacy-setup-actions-migration",
2438        )?
2439    } else {
2440        None
2441    };
2442    let setup_actions_removed = if legacy_setup_actions_path.is_file() {
2443        std::fs::remove_file(&legacy_setup_actions_path).with_context(|| {
2444            format!(
2445                "remove legacy setup actions {}",
2446                legacy_setup_actions_path.display()
2447            )
2448        })?;
2449        true
2450    } else {
2451        false
2452    };
2453    let event_path = append_backend_event(
2454        bundle_root,
2455        tenant,
2456        team,
2457        provider_id,
2458        serde_json::json!({
2459            "type": "state_migrated",
2460            "legacy_path": legacy_path,
2461            "state_path": state_path,
2462            "archive_path": archive_path,
2463            "legacy_setup_actions_path": legacy_setup_actions_path,
2464            "setup_actions_archive_path": setup_actions_archive_path,
2465            "source": source,
2466            "legacy_removed": legacy_removed,
2467            "setup_actions_removed": setup_actions_removed,
2468            "empty": stored.is_empty(),
2469        }),
2470    )?;
2471
2472    Ok(BackendStateMigration {
2473        legacy_path,
2474        legacy_setup_actions_path,
2475        state_path,
2476        archive_path,
2477        setup_actions_archive_path,
2478        event_path,
2479        source: source.to_string(),
2480        legacy_removed,
2481        setup_actions_removed,
2482        empty: stored.is_empty(),
2483    })
2484}
2485
2486pub fn reset_backend_state(
2487    bundle_root: &Path,
2488    tenant: &str,
2489    team: &str,
2490    provider_id: &str,
2491    reason: &str,
2492) -> anyhow::Result<Option<PathBuf>> {
2493    let archive_path = archive_backend_state(bundle_root, tenant, team, provider_id, reason)?;
2494    let state_path = backend_state_path(bundle_root, tenant, team, provider_id)?;
2495    match std::fs::remove_file(&state_path) {
2496        Ok(()) => {}
2497        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
2498        Err(err) => return Err(err).with_context(|| format!("remove {}", state_path.display())),
2499    }
2500    append_backend_event(
2501        bundle_root,
2502        tenant,
2503        team,
2504        provider_id,
2505        serde_json::json!({
2506            "type": "state_reset",
2507            "reason": reason,
2508            "archive_path": archive_path,
2509        }),
2510    )?;
2511    Ok(archive_path)
2512}
2513
2514pub fn load_backend_state(
2515    bundle_root: &Path,
2516    env: &str,
2517    tenant: &str,
2518    team: &str,
2519    provider_id: &str,
2520) -> anyhow::Result<JsonMap<String, Value>> {
2521    let path = backend_state_path(bundle_root, tenant, team, provider_id)?;
2522    let mut stored = read_state_file(&path)?.unwrap_or_default();
2523    hydrate_redacted_backend_config(bundle_root, env, tenant, team, provider_id, &mut stored)?;
2524    repair_redacted_display_codes(&mut stored);
2525    Ok(stored)
2526}
2527
2528pub fn save_backend_state(
2529    bundle_root: &Path,
2530    tenant: &str,
2531    team: &str,
2532    provider_id: &str,
2533    stored: &JsonMap<String, Value>,
2534) -> anyhow::Result<PathBuf> {
2535    let path = backend_state_path(bundle_root, tenant, team, provider_id)?;
2536    if let Some(parent) = path.parent() {
2537        std::fs::create_dir_all(parent)
2538            .with_context(|| format!("create setup backend state dir {}", parent.display()))?;
2539    }
2540    let env = backend_state_env(stored);
2541    persist_sensitive_backend_config(bundle_root, &env, tenant, team, provider_id, stored)?;
2542    let redacted = redact_backend_state_for_disk(stored);
2543    std::fs::write(&path, serde_json::to_vec_pretty(&redacted)?)
2544        .with_context(|| format!("write setup backend state {}", path.display()))?;
2545    Ok(path)
2546}
2547
2548fn backend_state_env(stored: &JsonMap<String, Value>) -> String {
2549    stored
2550        .get("config")
2551        .and_then(Value::as_object)
2552        .and_then(|config| config.get("env"))
2553        .and_then(Value::as_str)
2554        .map(str::trim)
2555        .filter(|value| !value.is_empty())
2556        .unwrap_or("dev")
2557        .to_string()
2558}
2559
2560fn persist_sensitive_backend_config(
2561    bundle_root: &Path,
2562    env: &str,
2563    tenant: &str,
2564    team: &str,
2565    provider_id: &str,
2566    stored: &JsonMap<String, Value>,
2567) -> anyhow::Result<()> {
2568    let Some(config) = stored.get("config").and_then(Value::as_object) else {
2569        return Ok(());
2570    };
2571    let sensitive: JsonMap<String, Value> = config
2572        .iter()
2573        .filter(|(key, value)| {
2574            is_sensitive_backend_state_key(key) && !value_is_redacted_marker(value)
2575        })
2576        .map(|(key, value)| (key.clone(), value.clone()))
2577        .collect();
2578    if sensitive.is_empty() {
2579        return Ok(());
2580    }
2581    let config = Value::Object(sensitive);
2582    let bundle_root = bundle_root.to_path_buf();
2583    let env = env.to_string();
2584    let tenant = tenant.to_string();
2585    let team = team.to_string();
2586    let provider_id = provider_id.to_string();
2587    block_on_backend_secret_task(async move {
2588        crate::qa::persist::persist_all_config_as_secrets(
2589            &bundle_root,
2590            &env,
2591            &tenant,
2592            Some(&team),
2593            &provider_id,
2594            &config,
2595            None,
2596        )
2597        .await
2598    })
2599    .map(|_| ())
2600}
2601
2602fn hydrate_redacted_backend_config(
2603    bundle_root: &Path,
2604    env: &str,
2605    tenant: &str,
2606    team: &str,
2607    provider_id: &str,
2608    stored: &mut JsonMap<String, Value>,
2609) -> anyhow::Result<()> {
2610    let Some(config) = stored.get_mut("config").and_then(Value::as_object_mut) else {
2611        return Ok(());
2612    };
2613    let keys: Vec<String> = config
2614        .iter()
2615        .filter(|(key, value)| {
2616            is_sensitive_backend_state_key(key) && value_is_redacted_marker(value)
2617        })
2618        .map(|(key, _)| key.clone())
2619        .collect();
2620    if keys.is_empty() {
2621        return Ok(());
2622    }
2623    let bundle_root = bundle_root.to_path_buf();
2624    let env = env.to_string();
2625    let tenant = tenant.to_string();
2626    let team = team.to_string();
2627    let provider_id = provider_id.to_string();
2628    let hydrated = block_on_backend_secret_task(async move {
2629        use greentic_secrets_lib::SecretsStore;
2630
2631        let store = crate::secrets::open_dev_store(&bundle_root)?;
2632        let mut values = JsonMap::new();
2633        for key in keys {
2634            let uri = crate::canonical_secret_uri(&env, &tenant, Some(&team), &provider_id, &key);
2635            if let Ok(bytes) = store.get(&uri).await
2636                && let Ok(text) = String::from_utf8(bytes)
2637                && !text.is_empty()
2638            {
2639                values.insert(key, Value::String(text));
2640            }
2641        }
2642        Ok::<_, anyhow::Error>(values)
2643    })?;
2644    for (key, value) in hydrated {
2645        config.insert(key, value);
2646    }
2647    Ok(())
2648}
2649
2650fn redact_backend_state_for_disk(stored: &JsonMap<String, Value>) -> JsonMap<String, Value> {
2651    stored
2652        .iter()
2653        .map(|(key, value)| {
2654            (
2655                key.clone(),
2656                redact_backend_state_value(Some(key.as_str()), value),
2657            )
2658        })
2659        .collect()
2660}
2661
2662fn redact_backend_state_value(key: Option<&str>, value: &Value) -> Value {
2663    if let Some(key) = key
2664        && is_sensitive_backend_state_key(key)
2665        && !value.is_null()
2666    {
2667        return Value::String(REDACTED_SECRET_MARKER.to_string());
2668    }
2669    match value {
2670        Value::Object(object) => Value::Object(
2671            object
2672                .iter()
2673                .map(|(key, value)| {
2674                    (
2675                        key.clone(),
2676                        redact_backend_state_value(Some(key.as_str()), value),
2677                    )
2678                })
2679                .collect(),
2680        ),
2681        Value::Array(items) => Value::Array(
2682            items
2683                .iter()
2684                .map(|item| redact_backend_state_value(None, item))
2685                .collect(),
2686        ),
2687        _ => value.clone(),
2688    }
2689}
2690
2691fn is_sensitive_backend_state_key(key: &str) -> bool {
2692    let normalized = key
2693        .chars()
2694        .filter(|ch| ch.is_ascii_alphanumeric())
2695        .flat_map(char::to_lowercase)
2696        .collect::<String>();
2697    if normalized.ends_with("url") {
2698        return false;
2699    }
2700    normalized.contains("accesstoken")
2701        || normalized.contains("refreshtoken")
2702        || normalized.contains("idtoken")
2703        || normalized.contains("devicecode")
2704        || normalized.contains("password")
2705        || normalized.contains("secret")
2706        || normalized.contains("credential")
2707        || normalized.ends_with("token")
2708}
2709
2710fn value_is_redacted_marker(value: &Value) -> bool {
2711    value.as_str() == Some(REDACTED_SECRET_MARKER)
2712}
2713
2714fn repair_redacted_display_codes(stored: &mut JsonMap<String, Value>) {
2715    for value in stored.values_mut() {
2716        repair_redacted_display_codes_in_value(value);
2717    }
2718}
2719
2720fn repair_redacted_display_codes_in_value(value: &mut Value) {
2721    match value {
2722        Value::Object(object) => {
2723            let fallback_code = object
2724                .values()
2725                .filter_map(Value::as_str)
2726                .filter(|text| *text != REDACTED_SECRET_MARKER)
2727                .find_map(extract_display_code_from_text);
2728            for (key, child) in object.iter_mut() {
2729                if is_display_code_key(key) && value_is_redacted_marker(child) {
2730                    if let Some(code) = fallback_code.as_ref() {
2731                        *child = Value::String(code.clone());
2732                    }
2733                } else {
2734                    repair_redacted_display_codes_in_value(child);
2735                }
2736            }
2737        }
2738        Value::Array(items) => {
2739            for item in items {
2740                repair_redacted_display_codes_in_value(item);
2741            }
2742        }
2743        _ => {}
2744    }
2745}
2746
2747fn is_display_code_key(key: &str) -> bool {
2748    let normalized = key
2749        .chars()
2750        .filter(|ch| ch.is_ascii_alphanumeric())
2751        .flat_map(char::to_lowercase)
2752        .collect::<String>();
2753    normalized == "usercode" || normalized.ends_with("usercode")
2754}
2755
2756fn extract_display_code_from_text(text: &str) -> Option<String> {
2757    let lower = text.to_ascii_lowercase();
2758    let code_at = lower.find("code")?;
2759    let after_code = text.get(code_at + "code".len()..)?;
2760    after_code
2761        .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
2762        .map(str::trim)
2763        .filter(|token| {
2764            let len = token.len();
2765            (4..=32).contains(&len)
2766                && token.chars().any(|ch| ch.is_ascii_alphabetic())
2767                && token.chars().any(|ch| ch.is_ascii_digit())
2768        })
2769        .map(ToString::to_string)
2770        .next()
2771}
2772
2773fn block_on_backend_secret_task<F, T>(future: F) -> anyhow::Result<T>
2774where
2775    F: std::future::Future<Output = anyhow::Result<T>> + Send + 'static,
2776    T: Send + 'static,
2777{
2778    thread::spawn(move || {
2779        tokio::runtime::Builder::new_current_thread()
2780            .enable_all()
2781            .build()
2782            .context("build setup backend secret runtime")?
2783            .block_on(future)
2784    })
2785    .join()
2786    .map_err(|_| anyhow!("setup backend secret task panicked"))?
2787}
2788
2789fn read_state_file(path: &Path) -> anyhow::Result<Option<JsonMap<String, Value>>> {
2790    match std::fs::read_to_string(path) {
2791        Ok(text) => serde_json::from_str::<JsonMap<String, Value>>(&text)
2792            .with_context(|| format!("parse setup backend state {}", path.display()))
2793            .map(Some),
2794        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
2795        Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
2796    }
2797}
2798
2799fn current_timestamp_ms() -> u128 {
2800    SystemTime::now()
2801        .duration_since(UNIX_EPOCH)
2802        .unwrap_or_default()
2803        .as_millis()
2804}
2805
2806fn safe_archive_segment(value: &str) -> String {
2807    let mut out = value
2808        .chars()
2809        .map(|ch| {
2810            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
2811                ch
2812            } else {
2813                '-'
2814            }
2815        })
2816        .collect::<String>();
2817    while out.contains("--") {
2818        out = out.replace("--", "-");
2819    }
2820    let out = out.trim_matches('-');
2821    if out.is_empty() {
2822        "archive".to_string()
2823    } else {
2824        out.to_string()
2825    }
2826}
2827
2828pub fn validate_path_segment<'a>(value: &'a str, name: &str) -> anyhow::Result<&'a str> {
2829    let value = value.trim();
2830    if value.is_empty()
2831        || value == "."
2832        || value == ".."
2833        || value.contains('/')
2834        || value.contains('\\')
2835    {
2836        anyhow::bail!("invalid {name}");
2837    }
2838    Ok(value)
2839}
2840
2841#[cfg(test)]
2842mod tests {
2843    use super::*;
2844    use serde_json::json;
2845    use std::io::Write;
2846    use zip::write::{FileOptions, ZipWriter};
2847
2848    fn write_setup_route_pack(pack_path: &Path) -> anyhow::Result<()> {
2849        let file = std::fs::File::create(pack_path)?;
2850        let mut writer = ZipWriter::new(file);
2851        let options: FileOptions<'_, ()> =
2852            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2853        writer.start_file("pack.manifest.json", options)?;
2854        writer.write_all(
2855            json!({
2856                "pack_id": "messaging-example",
2857                "extensions": {
2858                    "greentic.http-routes.v1": {
2859                        "inline": {
2860                            "routes": [
2861                                {
2862                                    "pattern": "/v1/setup/messaging-example/{tenant}/{team}/register",
2863                                    "methods": ["POST"],
2864                                    "setup_component_ref": "setup-component",
2865                                    "setup_op": "handle_http"
2866                                }
2867                            ]
2868                        }
2869                    }
2870                }
2871            })
2872            .to_string()
2873            .as_bytes(),
2874        )?;
2875        writer.start_file("components/setup-component.json", options)?;
2876        writer.write_all(
2877            json!({
2878                "operations": {
2879                    "handle_http": {
2880                        "echo_request": true
2881                    }
2882                }
2883            })
2884            .to_string()
2885            .as_bytes(),
2886        )?;
2887        writer.finish()?;
2888        Ok(())
2889    }
2890
2891    #[test]
2892    fn merge_browser_config_update_ignores_server_owned_keys() {
2893        let contract = json!({
2894            "server_owned_config_keys": [
2895                "oauth_device_code",
2896                "graph_access_token"
2897            ]
2898        });
2899        let mut stored = JsonMap::new();
2900        merge_browser_config_update(
2901            &mut stored,
2902            &json!({
2903                "public_base_url": "https://example.test",
2904                "oauth_device_code": "browser-device-code",
2905                "graph_access_token": "browser-token"
2906            }),
2907            &contract,
2908            JsonMap::new(),
2909        )
2910        .unwrap();
2911
2912        let config = stored.get("config").and_then(Value::as_object).unwrap();
2913        assert_eq!(config["public_base_url"], "https://example.test");
2914        assert!(config.get("oauth_device_code").is_none());
2915        assert!(config.get("graph_access_token").is_none());
2916    }
2917
2918    #[test]
2919    fn completion_met_supports_exists_equals_and_boolean() {
2920        let values = json!({
2921            "oauth": {"graph": {"ok": true}},
2922            "last_reconcile": {"ok": true},
2923            "last_activity": {"id": "activity-1"}
2924        });
2925
2926        assert!(completion_met(
2927            &values,
2928            &json!({"state_path": "last_activity", "exists": true})
2929        ));
2930        assert!(completion_met(
2931            &values,
2932            &json!({"state_path": "last_reconcile.ok", "equals": true})
2933        ));
2934        assert!(completion_met(
2935            &values,
2936            &json!({"state_path": "oauth.graph.ok"})
2937        ));
2938        assert!(!completion_met(
2939            &values,
2940            &json!({"state_path": "missing.value", "exists": true})
2941        ));
2942    }
2943
2944    #[test]
2945    fn render_status_reports_next_pending_step() {
2946        let contract = json!({
2947            "required_order": ["auth", "register"],
2948            "actions": [
2949                {
2950                    "id": "auth",
2951                    "completion": {"state_path": "oauth.graph.ok", "equals": true}
2952                },
2953                {
2954                    "id": "register",
2955                    "completion": {"state_path": "last_reconcile.ok", "equals": true}
2956                }
2957            ]
2958        });
2959        let mut stored = JsonMap::new();
2960        stored.insert("oauth".to_string(), json!({"graph": {"ok": true}}));
2961
2962        let status = render_status(&contract, &stored);
2963        assert_eq!(status["ok"], false);
2964        assert_eq!(status["next"], "register");
2965        assert_eq!(status["items"][0]["state"], "done");
2966        assert_eq!(status["items"][1]["state"], "pending");
2967    }
2968
2969    #[test]
2970    fn render_status_prioritizes_oauth_resume_recovery() {
2971        let contract = json!({
2972            "required_order": ["auth", "register"],
2973            "actions": [
2974                {
2975                    "id": "auth",
2976                    "executor": {
2977                        "kind": "oauth_device_code",
2978                        "token_store_key": "graph_access_token"
2979                    },
2980                    "completion": {"state_path": "oauth.graph.ok", "equals": true}
2981                },
2982                {
2983                    "id": "register",
2984                    "completion": {"state_path": "last_reconcile.ok", "equals": true}
2985                }
2986            ]
2987        });
2988        let mut stored = JsonMap::new();
2989        stored.insert("oauth".to_string(), json!({"graph": {"ok": true}}));
2990        stored.insert("last_reconcile".to_string(), json!({"ok": true}));
2991        stored.insert(
2992            "oauth_resume".to_string(),
2993            json!({"token_store_key": "graph_access_token"}),
2994        );
2995
2996        let status = render_status(&contract, &stored);
2997        assert_eq!(status["ok"], false);
2998        assert_eq!(status["next"], "auth");
2999        assert_eq!(status["items"][0]["state"], "pending");
3000        assert_eq!(status["items"][1]["state"], "done");
3001    }
3002
3003    #[test]
3004    fn render_status_includes_retryable_blocked_details() {
3005        let contract = json!({
3006            "required_order": ["observe"],
3007            "actions": [{
3008                "id": "observe",
3009                "completion": {"state_path": "last_activity", "exists": true}
3010            }]
3011        });
3012        let mut stored = JsonMap::new();
3013        stored.insert(
3014            "last_setup_result".to_string(),
3015            step_result(
3016                &json!({"id": "observe"}),
3017                false,
3018                "runtime/tunnel not running",
3019                json!({
3020                    "ok": false,
3021                    "blocked": true,
3022                    "error": "runtime/tunnel not running"
3023                }),
3024            ),
3025        );
3026
3027        let status = render_status(&contract, &stored);
3028
3029        assert_eq!(status["ok"], false);
3030        assert_eq!(status["blocked"]["retryable"], true);
3031        assert_eq!(status["blocked"]["summary"], "runtime/tunnel not running");
3032    }
3033
3034    #[test]
3035    fn next_action_id_returns_recovery_or_first_pending_step() {
3036        let contract = json!({
3037            "required_order": ["auth", "register"],
3038            "actions": [
3039                {
3040                    "id": "auth",
3041                    "executor": {
3042                        "kind": "oauth_device_code",
3043                        "token_store_key": "graph_access_token"
3044                    },
3045                    "completion": {"state_path": "oauth.graph.ok", "equals": true}
3046                },
3047                {
3048                    "id": "register",
3049                    "completion": {"state_path": "last_reconcile.ok", "equals": true}
3050                }
3051            ]
3052        });
3053        let mut stored = JsonMap::new();
3054        stored.insert("oauth".to_string(), json!({"graph": {"ok": true}}));
3055        assert_eq!(
3056            next_action_id(&contract, &stored).as_deref(),
3057            Some("register")
3058        );
3059
3060        stored.insert(
3061            "oauth_resume".to_string(),
3062            json!({"token_store_key": "graph_access_token"}),
3063        );
3064        assert_eq!(next_action_id(&contract, &stored).as_deref(), Some("auth"));
3065    }
3066
3067    #[test]
3068    fn record_action_result_saves_state_and_appends_jsonl_event() {
3069        let temp = tempfile::tempdir().unwrap();
3070        let action = json!({"id": "register"});
3071        let result = step_result(
3072            &action,
3073            false,
3074            "provider setup service is not running",
3075            json!({"ok": false, "blocked": true}),
3076        );
3077        let mut stored = JsonMap::new();
3078
3079        let event_path = record_action_result(
3080            temp.path(),
3081            "demo",
3082            "default",
3083            "messaging-teams",
3084            &mut stored,
3085            result,
3086        )
3087        .unwrap();
3088
3089        let state =
3090            load_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams").unwrap();
3091        assert_eq!(state["last_setup_result"]["step"], "register");
3092        let events = std::fs::read_to_string(event_path).unwrap();
3093        assert!(events.contains("\"type\":\"action_result\""));
3094        assert!(events.contains("\"step\":\"register\""));
3095    }
3096
3097    #[test]
3098    fn archive_file_copies_legacy_artifact_into_generic_archive() {
3099        let temp = tempfile::tempdir().unwrap();
3100        let legacy =
3101            legacy_backend_state_path(temp.path(), "dev", "demo", "default", "messaging-teams")
3102                .unwrap();
3103        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
3104        std::fs::write(&legacy, r#"{"config":{"bot_app_id":"legacy"}}"#).unwrap();
3105
3106        let archive = archive_backend_file(
3107            temp.path(),
3108            "demo",
3109            "default",
3110            "messaging-teams",
3111            &legacy,
3112            "legacy migration",
3113        )
3114        .unwrap()
3115        .expect("archive");
3116
3117        assert!(archive.is_file());
3118        assert!(
3119            archive
3120                .file_name()
3121                .unwrap()
3122                .to_string_lossy()
3123                .starts_with("legacy-migration-")
3124        );
3125        assert_eq!(
3126            std::fs::read_to_string(archive).unwrap(),
3127            r#"{"config":{"bot_app_id":"legacy"}}"#
3128        );
3129    }
3130
3131    #[test]
3132    fn archive_and_reset_backend_state_preserve_prior_state() {
3133        let temp = tempfile::tempdir().unwrap();
3134        let mut stored = JsonMap::new();
3135        stored.insert("config".to_string(), json!({"bot_app_id": "old"}));
3136        save_backend_state(temp.path(), "demo", "default", "messaging-teams", &stored).unwrap();
3137
3138        let archive = reset_backend_state(
3139            temp.path(),
3140            "demo",
3141            "default",
3142            "messaging-teams",
3143            "manual reset",
3144        )
3145        .unwrap()
3146        .expect("archive");
3147
3148        assert!(archive.is_file());
3149        assert!(
3150            archive
3151                .file_name()
3152                .unwrap()
3153                .to_string_lossy()
3154                .starts_with("manual-reset-")
3155        );
3156        assert_eq!(
3157            load_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams")
3158                .unwrap()
3159                .len(),
3160            0
3161        );
3162        let events = std::fs::read_to_string(
3163            backend_events_path(temp.path(), "demo", "default", "messaging-teams").unwrap(),
3164        )
3165        .unwrap();
3166        assert!(events.contains("\"type\":\"state_reset\""));
3167    }
3168
3169    #[test]
3170    fn blocked_from_result_marks_runtime_and_oauth_as_retryable() {
3171        let runtime = step_result(
3172            &json!({"id": "observe"}),
3173            false,
3174            "runtime/tunnel not running",
3175            json!({
3176                "ok": false,
3177                "blocked": true,
3178                "error": "runtime/tunnel not running"
3179            }),
3180        );
3181        let blocked = blocked_from_result(&runtime).unwrap();
3182        assert_eq!(blocked["retryable"], true);
3183        assert_eq!(blocked["summary"], "runtime/tunnel not running");
3184
3185        let oauth = step_result(
3186            &json!({"id": "publish"}),
3187            false,
3188            "reauthorize",
3189            json!({
3190                "ok": false,
3191                "blocked": true,
3192                "error": "oauth_required",
3193                "token_store_key": "graph_access_token"
3194            }),
3195        );
3196        assert_eq!(blocked_from_result(&oauth).unwrap()["retryable"], true);
3197    }
3198
3199    #[test]
3200    fn oauth_required_result_treats_invalid_access_token_as_reauth() {
3201        let action = json!({"id": "discover"});
3202        let result = oauth_required_result(
3203            &action,
3204            "access_token",
3205            "authenticated request failed",
3206            &json!({
3207                "ok": false,
3208                "status": 401,
3209                "body": {
3210                    "error": "Azure subscription discovery failed (HTTP 401): The access token is invalid."
3211                }
3212            }),
3213        )
3214        .expect("invalid token should require OAuth");
3215
3216        assert_eq!(result["ok"], false);
3217        assert_eq!(result["result"]["error"], "oauth_required");
3218        assert_eq!(result["result"]["token_store_key"], "access_token");
3219        assert_eq!(blocked_from_result(&result).unwrap()["retryable"], true);
3220    }
3221
3222    #[test]
3223    fn runtime_observation_waits_until_state_store_key_exists() -> anyhow::Result<()> {
3224        let action = json!({
3225            "id": "observe",
3226            "executor": {
3227                "kind": "runtime_observation",
3228                "source": "runtime",
3229                "event": "ready",
3230                "state_store_key": "last_runtime_ready"
3231            }
3232        });
3233        let mut stored = JsonMap::new();
3234
3235        let waiting = execute_runtime_observation(&mut stored, &action)?;
3236
3237        assert_eq!(waiting["ok"], false);
3238        assert_eq!(waiting["result"]["waiting"], true);
3239        assert_eq!(waiting["result"]["retryable"], true);
3240        assert_eq!(waiting["result"]["state_store_key"], "last_runtime_ready");
3241        assert!(blocked_from_result(&waiting).is_some());
3242
3243        stored.insert(
3244            "last_runtime_ready".to_string(),
3245            json!({"ok": true, "runtime_context": {"public_base_url": "https://example.test"}}),
3246        );
3247        let complete = execute_runtime_observation(&mut stored, &action)?;
3248
3249        assert_eq!(complete["ok"], true);
3250        assert_eq!(complete["result"]["state_store_key"], "last_runtime_ready");
3251        assert_eq!(complete["result"]["observed"]["ok"], true);
3252        Ok(())
3253    }
3254
3255    #[test]
3256    fn graph_application_waits_for_oauth_token_without_losing_state() -> anyhow::Result<()> {
3257        let action = json!({
3258            "id": "register_app",
3259            "executor": {
3260                "kind": "microsoft_graph_application",
3261                "graph_token_store_key": "graph_access_token",
3262                "app_id_config_key": "bot_app_id",
3263                "client_secret_config_key": "bot_client_secret",
3264                "display_name_config_key": "bot_display_name"
3265            }
3266        });
3267        let mut stored = JsonMap::new();
3268        stored.insert(
3269            "config".to_string(),
3270            json!({
3271                "bot_display_name": "Demo Bot"
3272            }),
3273        );
3274
3275        let blocked = execute_microsoft_graph_application(&mut stored, "messaging-teams", &action)?;
3276
3277        assert_eq!(blocked["ok"], false);
3278        assert_eq!(
3279            blocked["result"]["missing_token_store_key"],
3280            "graph_access_token"
3281        );
3282        assert_eq!(blocked["result"]["retryable"], true);
3283        assert_eq!(
3284            stored
3285                .get("config")
3286                .and_then(Value::as_object)
3287                .and_then(|config| config.get("bot_display_name"))
3288                .and_then(Value::as_str),
3289            Some("Demo Bot")
3290        );
3291        assert!(stored.get("last_app_registration").is_none());
3292        Ok(())
3293    }
3294
3295    #[test]
3296    fn teams_app_catalog_publish_waits_for_oauth_token_before_packaging() -> anyhow::Result<()> {
3297        let action = json!({
3298            "id": "publish_app",
3299            "executor": {
3300                "kind": "microsoft_graph_teams_app_catalog_publish",
3301                "graph_token_store_key": "graph_access_token",
3302                "teams_app_id_config_key": "teams_app_id",
3303                "teams_app_version_config_key": "teams_app_version",
3304                "bot_app_id_config_key": "bot_app_id",
3305                "manifest_template_asset": "assets/teams-app/manifest.json"
3306            }
3307        });
3308        let mut stored = JsonMap::new();
3309        stored.insert(
3310            "config".to_string(),
3311            json!({
3312                "bot_app_id": "bot-123"
3313            }),
3314        );
3315
3316        let blocked = execute_microsoft_graph_teams_app_catalog_publish(
3317            Path::new("unused.gtpack"),
3318            &mut stored,
3319            "demo",
3320            "support",
3321            "dev",
3322            &action,
3323        )?;
3324
3325        assert_eq!(blocked["ok"], false);
3326        assert_eq!(
3327            blocked["result"]["missing_token_store_key"],
3328            "graph_access_token"
3329        );
3330        assert_eq!(blocked["result"]["retryable"], true);
3331        assert!(stored.get("last_teams_app_publish").is_none());
3332        Ok(())
3333    }
3334
3335    #[test]
3336    fn teams_app_user_install_can_continue_with_manual_links_without_graph_token()
3337    -> anyhow::Result<()> {
3338        let action = json!({
3339            "id": "install_app",
3340            "executor": {
3341                "kind": "microsoft_graph_teams_app_user_install",
3342                "graph_token_store_key": "graph_access_token",
3343                "links": {
3344                    "add_to_teams_url_template": "https://teams.microsoft.com/l/app/{catalog_app_id}?tenant={tenant}",
3345                    "open_bot_chat_url_template": "https://teams.microsoft.com/l/chat/0/0?users=28:{bot_app_id}&team={team}&env={env}"
3346                }
3347            }
3348        });
3349        let mut stored = JsonMap::new();
3350        stored.insert(
3351            "config".to_string(),
3352            json!({
3353                "catalog_app_id": "catalog-123",
3354                "bot_app_id": "bot-123"
3355            }),
3356        );
3357        stored.insert(
3358            "last_teams_app_publish".to_string(),
3359            json!({
3360                "ok": true,
3361                "catalog_app_id": "catalog-123"
3362            }),
3363        );
3364
3365        let result = execute_microsoft_graph_teams_app_user_install(
3366            &mut stored,
3367            "demo",
3368            "support",
3369            "dev",
3370            &action,
3371        )?;
3372
3373        assert_eq!(result["ok"], true);
3374        assert_eq!(result["result"]["action"], "manual_unverified");
3375        assert_eq!(result["result"]["catalog_app_id"], "catalog-123");
3376        assert_eq!(
3377            result["result"]["add_to_teams_url"],
3378            "https://teams.microsoft.com/l/app/catalog-123?tenant=demo"
3379        );
3380        assert_eq!(
3381            result["result"]["open_bot_chat_url"],
3382            "https://teams.microsoft.com/l/chat/0/0?users=28:bot-123&team=support&env=dev"
3383        );
3384        assert_eq!(
3385            stored
3386                .get("last_teams_app_install")
3387                .and_then(|value| value.get("action"))
3388                .and_then(Value::as_str),
3389            Some("manual_unverified")
3390        );
3391        Ok(())
3392    }
3393
3394    #[test]
3395    fn oauth_device_start_with_response_persists_private_state_and_redacts_public_result() {
3396        let action = json!({
3397            "id": "graph_auth",
3398            "executor": {
3399                "kind": "oauth_device_code",
3400                "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
3401                "authority_tenant_default": "organizations",
3402                "client_id_config_key": "graph_setup_client_id",
3403                "client_id_default": "default-client",
3404                "scopes": ["User.Read"],
3405                "oauth_kind": "graph",
3406                "token_store_key": "graph_access_token",
3407                "device_code_store_key": "oauth_device_code",
3408                "user_code_store_key": "oauth_user_code"
3409            }
3410        });
3411        let mut stored = JsonMap::new();
3412        let result = execute_oauth_device_code_start_with_response(
3413            &mut stored,
3414            &action,
3415            &json!({
3416                "device_code": "private-device-code",
3417                "user_code": "ABCD-EFGH",
3418                "verification_uri": "https://microsoft.com/devicelogin",
3419                "verification_uri_complete": "https://microsoft.com/devicelogin?code=ABCD-EFGH",
3420                "expires_in": 900,
3421                "interval": 5
3422            }),
3423        )
3424        .unwrap();
3425
3426        assert_eq!(result["ok"], false);
3427        assert_eq!(result["result"]["pending_device_login"], true);
3428        assert_eq!(stored["config"]["oauth_device_code"], "private-device-code");
3429        assert_eq!(stored["config"]["oauth_user_code"], "ABCD-EFGH");
3430        assert_eq!(
3431            stored["config"]["oauth_token_url"],
3432            "https://login.microsoftonline.com/organizations/oauth2/v2.0/token"
3433        );
3434        assert_eq!(stored["last_oauth"]["kind"], "graph");
3435        assert!(result["result"]["body"].get("device_code").is_none());
3436        assert!(
3437            stored["last_oauth"]["response"]
3438                .get("device_code")
3439                .is_none()
3440        );
3441    }
3442
3443    #[test]
3444    fn oauth_device_complete_with_response_keeps_pending_login_resumable() {
3445        let action = json!({
3446            "id": "graph_auth",
3447            "executor": {
3448                "kind": "oauth_device_code",
3449                "oauth_kind": "graph",
3450                "token_store_key": "graph_access_token",
3451                "device_code_store_key": "oauth_device_code",
3452                "user_code_store_key": "oauth_user_code"
3453            }
3454        });
3455        let mut stored = JsonMap::new();
3456        stored.insert(
3457            "config".to_string(),
3458            json!({
3459                "oauth_device_code": "private-device-code",
3460                "oauth_user_code": "OLD-CODE",
3461                "oauth_client_id": "client-id",
3462                "oauth_token_url": "https://login.example/token"
3463            }),
3464        );
3465
3466        let result = execute_oauth_device_code_complete_with_response(
3467            &mut stored,
3468            &action,
3469            400,
3470            &json!({"error": "authorization_pending"}),
3471        )
3472        .unwrap();
3473
3474        assert_eq!(result["ok"], false);
3475        assert_eq!(result["next"], "authorization is still pending");
3476        assert_eq!(stored["config"]["oauth_device_code"], "private-device-code");
3477        assert!(stored.get("oauth").is_none());
3478    }
3479
3480    #[test]
3481    fn oauth_device_complete_with_response_persists_token_and_clears_resume_state() {
3482        let action = json!({
3483            "id": "graph_auth",
3484            "executor": {
3485                "kind": "oauth_device_code",
3486                "oauth_kind": "graph",
3487                "token_store_key": "graph_access_token",
3488                "device_code_store_key": "oauth_device_code"
3489            }
3490        });
3491        let mut stored = JsonMap::new();
3492        stored.insert(
3493            "config".to_string(),
3494            json!({
3495                "oauth_device_code": "private-device-code",
3496                "oauth_client_id": "client-id",
3497                "oauth_token_url": "https://login.example/token"
3498            }),
3499        );
3500        stored.insert(
3501            "oauth_resume".to_string(),
3502            json!({
3503                "token_store_key": "graph_access_token",
3504                "resume_step": "publish"
3505            }),
3506        );
3507
3508        let result = execute_oauth_device_code_complete_with_response(
3509            &mut stored,
3510            &action,
3511            200,
3512            &json!({"access_token": "private-access-token"}),
3513        )
3514        .unwrap();
3515
3516        assert_eq!(result["ok"], true);
3517        assert_eq!(
3518            stored["config"]["graph_access_token"],
3519            "private-access-token"
3520        );
3521        assert!(stored["config"].get("oauth_device_code").is_none());
3522        assert!(stored["config"].get("oauth_user_code").is_none());
3523        assert!(stored.get("oauth_resume").is_none());
3524        assert_eq!(stored["oauth"]["graph"]["ok"], true);
3525        assert_eq!(
3526            stored["oauth"]["graph"]["token_store_key"],
3527            "graph_access_token"
3528        );
3529    }
3530
3531    #[test]
3532    fn provider_http_external_posts_expanded_body_and_stores_result() {
3533        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3534        let addr = listener.local_addr().unwrap();
3535        let (tx, rx) = std::sync::mpsc::channel();
3536        let server = std::thread::spawn(move || {
3537            let (mut stream, _) = listener.accept().unwrap();
3538            stream
3539                .set_read_timeout(Some(std::time::Duration::from_secs(2)))
3540                .unwrap();
3541            let mut data = Vec::new();
3542            let mut buffer = [0_u8; 1024];
3543            loop {
3544                match std::io::Read::read(&mut stream, &mut buffer) {
3545                    Ok(0) => break,
3546                    Ok(n) => {
3547                        data.extend_from_slice(&buffer[..n]);
3548                        if let Some(header_end) = data.windows(4).position(|w| w == b"\r\n\r\n") {
3549                            let headers = String::from_utf8_lossy(&data[..header_end]);
3550                            let content_length = headers
3551                                .lines()
3552                                .find_map(|line| {
3553                                    line.strip_prefix("Content-Length:")
3554                                        .or_else(|| line.strip_prefix("content-length:"))
3555                                })
3556                                .and_then(|value| value.trim().parse::<usize>().ok())
3557                                .unwrap_or(0);
3558                            if data.len() >= header_end + 4 + content_length {
3559                                break;
3560                            }
3561                        }
3562                    }
3563                    Err(_) => break,
3564                }
3565            }
3566            let request = String::from_utf8_lossy(&data).to_string();
3567            tx.send(request).unwrap();
3568            let response = r#"{"ok":true,"registered":true}"#;
3569            let wire = format!(
3570                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3571                response.len(),
3572                response
3573            );
3574            std::io::Write::write_all(&mut stream, wire.as_bytes()).unwrap();
3575        });
3576        let action = json!({
3577            "id": "register",
3578            "executor": {
3579                "kind": "provider_http",
3580                "url_template": format!("http://{addr}/setup/{{tenant}}/{{team}}"),
3581                "body": {
3582                    "provider_id": "messaging-example",
3583                    "tenant": "{tenant}",
3584                    "team": "{team}",
3585                    "endpoint": "{public_base_url}/ingress"
3586                },
3587                "state_store_key": "last_register"
3588            }
3589        });
3590        let mut stored = JsonMap::new();
3591        stored.insert(
3592            "config".to_string(),
3593            json!({
3594                "public_base_url": "https://runtime.example.com/"
3595            }),
3596        );
3597
3598        let result = execute_provider_http_external(
3599            &mut stored,
3600            "messaging-example",
3601            "demo",
3602            "support",
3603            "dev",
3604            &action,
3605        )
3606        .unwrap();
3607
3608        let request = rx.recv().unwrap();
3609        server.join().unwrap();
3610        assert!(request.starts_with("POST /setup/demo/support HTTP/1.1"));
3611        assert!(request.contains("runtime.example.com"));
3612        assert!(request.contains("ingress"));
3613        assert_eq!(result["ok"], true);
3614        assert_eq!(stored["last_register"]["ok"], true);
3615        assert_eq!(
3616            stored["last_register"]["response"]["body"]["registered"],
3617            true
3618        );
3619    }
3620
3621    #[test]
3622    fn provider_http_local_route_invokes_declared_setup_component_and_stores_result() {
3623        let temp = tempfile::tempdir().unwrap();
3624        let bundle = temp.path().join("bundle");
3625        let providers = bundle.join("providers").join("messaging");
3626        std::fs::create_dir_all(&providers).unwrap();
3627        write_setup_route_pack(&providers.join("messaging-example.gtpack")).unwrap();
3628
3629        let action = json!({
3630            "id": "register",
3631            "executor": {
3632                "kind": "provider_http",
3633                "path_template": "/v1/setup/messaging-example/{tenant}/{team}/register",
3634                "body": {
3635                    "tenant": "{tenant}",
3636                    "team": "{team}",
3637                    "endpoint": "{public_base_url}/ingress"
3638                },
3639                "state_store_key": "last_register"
3640            }
3641        });
3642        let mut stored = JsonMap::new();
3643        stored.insert(
3644            "config".to_string(),
3645            json!({
3646                "public_base_url": "https://runtime.example.com/"
3647            }),
3648        );
3649
3650        let result = execute_provider_http_local_route(
3651            &bundle,
3652            &mut stored,
3653            "messaging-example",
3654            "demo",
3655            "support",
3656            "dev",
3657            &action,
3658        )
3659        .unwrap();
3660
3661        assert_eq!(result["ok"], true);
3662        assert_eq!(stored["last_register"]["ok"], true);
3663        assert_eq!(
3664            stored["last_register"]["target"],
3665            "/v1/setup/messaging-example/demo/support/register"
3666        );
3667        assert_eq!(
3668            stored["last_register"]["response"]["response"]["path"],
3669            "/v1/setup/messaging-example/demo/support/register"
3670        );
3671        assert_eq!(
3672            serde_json::from_str::<Value>(
3673                stored["last_register"]["response"]["response"]["body_json"]
3674                    .as_str()
3675                    .unwrap()
3676            )
3677            .unwrap()["endpoint"],
3678            "https://runtime.example.com/ingress"
3679        );
3680    }
3681
3682    #[test]
3683    fn backend_state_uses_generic_path_without_legacy_fallback() {
3684        let temp = tempfile::tempdir().unwrap();
3685        let legacy =
3686            legacy_backend_state_path(temp.path(), "dev", "demo", "default", "messaging-teams")
3687                .unwrap();
3688        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
3689        std::fs::write(&legacy, r#"{"config":{"bot_app_id":"old"}}"#).unwrap();
3690
3691        let loaded =
3692            load_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams").unwrap();
3693        assert!(loaded.is_empty());
3694        assert_eq!(
3695            load_legacy_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams")
3696                .unwrap()
3697                .unwrap()["config"]["bot_app_id"],
3698            "old"
3699        );
3700
3701        let mut generic = JsonMap::new();
3702        generic.insert("config".to_string(), json!({"bot_app_id": "new"}));
3703        save_backend_state(temp.path(), "demo", "default", "messaging-teams", &generic).unwrap();
3704        let new_path =
3705            backend_state_path(temp.path(), "demo", "default", "messaging-teams").unwrap();
3706        assert!(new_path.is_file());
3707        let loaded =
3708            load_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams").unwrap();
3709        assert_eq!(loaded["config"]["bot_app_id"], "new");
3710        assert_eq!(
3711            new_path.strip_prefix(temp.path()).unwrap(),
3712            Path::new("state/setup/demo/default/messaging-teams/backend-contract.json")
3713        );
3714    }
3715
3716    #[test]
3717    fn backend_state_redacts_secret_config_on_disk_and_hydrates_on_load() {
3718        let temp = tempfile::tempdir().unwrap();
3719        let mut stored = JsonMap::new();
3720        stored.insert(
3721            "config".to_string(),
3722            json!({
3723                "env": "dev",
3724                "service_access_token": "access-secret",
3725                "client_password": "password-secret",
3726                "oauth_token_url": "https://login.example/token",
3727                "public_base_url": "https://runtime.example.com"
3728            }),
3729        );
3730        stored.insert(
3731            "last_oauth".to_string(),
3732            json!({
3733                "response": {
3734                    "user_code": "SECRET-CODE",
3735                    "verification_uri": "https://login.example/device"
3736                }
3737            }),
3738        );
3739
3740        let path = save_backend_state(temp.path(), "demo", "default", "messaging-generic", &stored)
3741            .expect("save state");
3742        let raw = std::fs::read_to_string(&path).expect("read state");
3743
3744        assert!(!raw.contains("access-secret"));
3745        assert!(!raw.contains("password-secret"));
3746        assert!(raw.contains("SECRET-CODE"));
3747        assert!(raw.contains(REDACTED_SECRET_MARKER));
3748        assert!(raw.contains("https://login.example/token"));
3749        assert!(raw.contains("https://runtime.example.com"));
3750
3751        let loaded = load_backend_state(temp.path(), "dev", "demo", "default", "messaging-generic")
3752            .expect("load state");
3753        assert_eq!(loaded["config"]["service_access_token"], "access-secret");
3754        assert_eq!(loaded["config"]["client_password"], "password-secret");
3755        assert_eq!(
3756            loaded["config"]["oauth_token_url"],
3757            "https://login.example/token"
3758        );
3759        assert_eq!(loaded["last_oauth"]["response"]["user_code"], "SECRET-CODE");
3760    }
3761
3762    #[test]
3763    fn backend_state_repairs_redacted_display_code_from_device_login_message() {
3764        let temp = tempfile::tempdir().unwrap();
3765        let path = backend_state_path(temp.path(), "demo", "default", "messaging-generic")
3766            .expect("state path");
3767        std::fs::create_dir_all(path.parent().unwrap()).expect("state dir");
3768        std::fs::write(
3769            &path,
3770            serde_json::to_vec_pretty(&json!({
3771                "config": {
3772                    "env": "dev"
3773                },
3774                "last_oauth": {
3775                    "response": {
3776                        "message": "Open the verification page and enter the code LKZE33H3X to continue.",
3777                        "user_code": REDACTED_SECRET_MARKER,
3778                        "verification_uri": "https://login.example/device"
3779                    }
3780                }
3781            }))
3782            .unwrap(),
3783        )
3784        .expect("write state");
3785
3786        let loaded = load_backend_state(temp.path(), "dev", "demo", "default", "messaging-generic")
3787            .expect("load state");
3788        assert_eq!(loaded["last_oauth"]["response"]["user_code"], "LKZE33H3X");
3789    }
3790
3791    #[test]
3792    fn migrate_backend_state_archives_and_removes_legacy_state() {
3793        let temp = tempfile::tempdir().unwrap();
3794        let legacy =
3795            legacy_backend_state_path(temp.path(), "dev", "demo", "default", "messaging-teams")
3796                .unwrap();
3797        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
3798        std::fs::write(&legacy, r#"{"config":{"bot_app_id":"old"}}"#).unwrap();
3799        let legacy_actions = crate::setup_actions::setup_actions_state_path(
3800            temp.path(),
3801            "demo",
3802            "default",
3803            "messaging-teams",
3804        );
3805        std::fs::create_dir_all(legacy_actions.parent().unwrap()).unwrap();
3806        std::fs::write(
3807            &legacy_actions,
3808            r#"{"provider_id":"messaging-teams","tenant":"demo","team":"default","actions":[]}"#,
3809        )
3810        .unwrap();
3811
3812        let migration =
3813            migrate_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams")
3814                .unwrap();
3815
3816        assert_eq!(migration.source, "legacy");
3817        assert!(migration.legacy_removed);
3818        assert!(migration.setup_actions_removed);
3819        assert!(!legacy.exists());
3820        assert!(!legacy_actions.exists());
3821        assert!(migration.archive_path.unwrap().is_file());
3822        assert!(migration.setup_actions_archive_path.unwrap().is_file());
3823        let loaded =
3824            load_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams").unwrap();
3825        assert_eq!(loaded["config"]["bot_app_id"], "old");
3826
3827        let repeated =
3828            migrate_backend_state(temp.path(), "dev", "demo", "default", "messaging-teams")
3829                .unwrap();
3830        assert_eq!(repeated.source, "generic");
3831        assert!(!repeated.legacy_removed);
3832        assert!(!repeated.setup_actions_removed);
3833        assert!(repeated.archive_path.is_none());
3834        assert!(repeated.setup_actions_archive_path.is_none());
3835    }
3836}