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