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