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