1use std::cmp::Ordering;
2use std::collections::BTreeMap;
3use std::env;
4use std::fs;
5use std::path::Path;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use serde_json::{Map, Value};
9use sha2::{Digest, Sha256};
10
11use crate::config::AppRuntime;
12
13use super::connection::NativeTransportType;
14use super::http::HttpRequest;
15use super::json_commands;
16
17pub const DYNAMIC_HOST_PROFILE: &str = "dynamic-app";
29
30pub fn dynamic_runtime_host_profile() -> &'static str {
46 DYNAMIC_HOST_PROFILE
47}
48
49pub fn request_has_session_cookie(cookie_header: &str) -> bool {
69 pharos_runtime_security::request_has_session_cookie(cookie_header)
70}
71
72pub fn mut_json_obj_get_string(object: &Value, key: &str) -> Option<String> {
93 object
94 .as_object()
95 .and_then(|map| map.get(key))
96 .and_then(Value::as_str)
97 .map(ToOwned::to_owned)
98}
99
100pub fn generated_surface_label(field_name: Option<&str>) -> Option<String> {
124 let field_name = field_name.unwrap_or("value");
125 let mut next_word = true;
126 Some(
127 field_name
128 .chars()
129 .map(|ch| {
130 let mut out = if ch == '_' { ' ' } else { ch };
131 if next_word && out.is_ascii_lowercase() {
132 out = out.to_ascii_uppercase();
133 }
134 next_word = out == ' ';
135 out
136 })
137 .collect(),
138 )
139}
140
141#[derive(Debug, Default, Clone, Eq, PartialEq)]
142pub struct RuntimeJsonValueList {
158 pub items: Vec<Value>,
159}
160
161pub fn runtime_json_value_list_free(list: &mut RuntimeJsonValueList) {
181 list.items.clear();
182}
183
184pub fn runtime_json_value_list_append(list: &mut RuntimeJsonValueList, item: &Value) -> bool {
205 list.items.push(item.clone());
206 true
207}
208
209pub fn runtime_text_buffer_append_format(buffer: &mut String, formatted_text: &str) -> bool {
230 buffer.push_str(formatted_text);
231 true
232}
233
234fn html_escape(input: &str) -> String {
236 input
237 .replace('&', "&")
238 .replace('<', "<")
239 .replace('>', ">")
240 .replace('"', """)
241}
242
243pub fn runtime_text_buffer_append_html_escaped(buffer: &mut String, text: Option<&str>) -> bool {
264 buffer.push_str(&html_escape(text.unwrap_or("")));
265 true
266}
267
268pub fn normalized_public_origin(configured_origin: &str) -> Option<String> {
292 pharos_runtime_dynamic::normalized_public_origin(configured_origin)
293}
294
295pub fn request_origin_scheme(
322 request: Option<&HttpRequest>,
323 transport_type: NativeTransportType,
324 direct_client_ip: Option<&str>,
325 trusted_proxy_cidrs: Option<&str>,
326) -> &'static str {
327 let Some(request) = request else {
328 return "http";
329 };
330 let forwarded_https = request.headers.lines().any(|line| {
331 let (name, value) = line.split_once(':').unwrap_or(("", ""));
332 name.trim().eq_ignore_ascii_case("x-forwarded-proto")
333 && value.trim().eq_ignore_ascii_case("https")
334 });
335 let origin_https = request.headers.lines().any(|line| {
336 let (name, value) = line.split_once(':').unwrap_or(("", ""));
337 name.trim().eq_ignore_ascii_case("origin") && value.trim_start().starts_with("https://")
338 });
339 let trusted_forward = match transport_type {
340 NativeTransportType::Unix => forwarded_https,
341 NativeTransportType::Tcp => {
342 forwarded_https
343 && direct_client_ip.is_some()
344 && trusted_proxy_cidrs.is_some_and(|value| !value.trim().is_empty())
345 }
346 NativeTransportType::Unknown => false,
347 };
348 if trusted_forward || origin_https {
349 "https"
350 } else {
351 "http"
352 }
353}
354
355pub fn verification_token_path(app: &AppRuntime, token: &str) -> Option<String> {
380 if token.is_empty()
381 || token.len() > 512
382 || token.bytes().any(|byte| !byte.is_ascii_alphanumeric())
383 {
384 return None;
385 }
386 let base = app
387 .base_path
388 .as_deref()
389 .filter(|value| !value.is_empty() && *value != "/")
390 .unwrap_or("");
391 Some(format!(
392 "{base}/accounts/verify-email/{token}/?app_id={}",
393 app.app_id.as_deref().unwrap_or_default()
394 ))
395}
396
397pub fn verification_url(app: &AppRuntime, request: &HttpRequest, token: &str) -> Option<String> {
423 let path = verification_token_path(app, token)?;
424 let origin = app
425 .runtime_conf_path
426 .as_deref()
427 .and_then(|path| {
428 json_commands::dynamic_config_value(
429 Path::new(app.app_root.as_deref()?),
430 Some(Path::new(path)),
431 "base_url",
432 )
433 })
434 .and_then(|value| normalized_public_origin(&value));
435 let origin = origin.or_else(|| {
436 let host = request.headers.lines().find_map(|line| {
437 let (name, value) = line.split_once(':')?;
438 name.trim()
439 .eq_ignore_ascii_case("host")
440 .then_some(value.trim())
441 })?;
442 let scheme = request_origin_scheme(Some(request), NativeTransportType::Tcp, None, None);
443 normalized_public_origin(&format!("{scheme}://{host}"))
444 })?;
445 Some(format!("{origin}{path}"))
446}
447
448pub fn request_form_value(request: &HttpRequest, field: &str) -> Option<String> {
469 if field.is_empty() {
470 return None;
471 }
472 let body = String::from_utf8_lossy(&request.body);
473 if request.body_file_path.is_none()
474 && request
475 .content_type
476 .as_deref()
477 .is_some_and(|value| value.starts_with("multipart/form-data"))
478 {
479 return request.content_type.as_deref().and_then(|content_type| {
480 json_commands::request_multipart_field_value(content_type, &request.body, field)
481 });
482 }
483 json_commands::request_form_field_value(
484 request.content_type.as_deref(),
485 Some(body.as_ref()),
486 request.body_file_path.as_deref().map(Path::new),
487 field,
488 )
489}
490
491pub fn request_query_value(request: &HttpRequest, field: &str) -> Option<String> {
512 request
513 .target
514 .split_once('?')
515 .and_then(|(_, query)| json_commands::request_query_value(query, field))
516}
517
518fn dynamic_derived_input_value(spec: &Value, request: &HttpRequest) -> Option<String> {
520 let source = spec
521 .get("source")
522 .and_then(Value::as_str)
523 .unwrap_or_default();
524 match source {
525 "literal" => spec
526 .get("value")
527 .map(value_to_text)
528 .filter(|value| !value.is_empty()),
529 "header" => spec
530 .get("header")
531 .and_then(Value::as_str)
532 .and_then(|header| request_header_value(request, header))
533 .filter(|value| !value.is_empty()),
534 "query" => spec
535 .get("param")
536 .or_else(|| spec.get("query_param"))
537 .and_then(Value::as_str)
538 .and_then(|field| request_query_value(request, field))
539 .filter(|value| !value.is_empty()),
540 "form" => spec
541 .get("from_field")
542 .or_else(|| spec.get("input_field"))
543 .and_then(Value::as_str)
544 .and_then(|field| request_form_value(request, field))
545 .filter(|value| !value.is_empty()),
546 "context_method" => (!request.method.is_empty()).then(|| request.method.clone()),
547 "context_path" => {
548 let path = request_path(Some(request));
549 (!path.is_empty()).then_some(path)
550 }
551 _ => None,
552 }
553}
554
555fn dynamic_apply_default_input_hooks(
557 overrides: &mut BTreeMap<String, Option<String>>,
558 action: &Value,
559 request: &HttpRequest,
560) -> Result<(), i32> {
561 let Some(rows) = action
562 .get("hook_contract")
563 .and_then(|value| value.get("default_inputs"))
564 .and_then(Value::as_array)
565 else {
566 return Ok(());
567 };
568 if rows.len() > 32 {
569 return Err(422);
570 }
571 for row in rows {
572 let field = row
573 .get("field")
574 .and_then(Value::as_str)
575 .filter(|value| !value.is_empty() && value.len() <= 128)
576 .ok_or(422)?;
577 if dynamic_action_input_value(request, overrides, field)
578 .is_some_and(|value| !value.is_empty())
579 {
580 continue;
581 }
582 overrides.insert(field.to_owned(), dynamic_derived_input_value(row, request));
583 }
584 Ok(())
585}
586
587fn dynamic_action_input_overrides(
589 action: &Value,
590 request: &HttpRequest,
591) -> Result<BTreeMap<String, Option<String>>, i32> {
592 let mut overrides = BTreeMap::new();
593 let Some(hook_contract) = action.get("hook_contract").and_then(Value::as_object) else {
594 return Ok(overrides);
595 };
596 if let Some(rows) = hook_contract
597 .get("derived_inputs")
598 .and_then(Value::as_array)
599 {
600 if rows.len() > 32 {
601 return Err(422);
602 }
603 for row in rows {
604 let field = row
605 .get("field")
606 .and_then(Value::as_str)
607 .filter(|value| !value.is_empty() && value.len() <= 128)
608 .ok_or(422)?;
609 if row
610 .get("preserve_existing")
611 .and_then(Value::as_bool)
612 .unwrap_or(false)
613 && dynamic_action_input_value(request, &overrides, field)
614 .is_some_and(|value| !value.is_empty())
615 {
616 continue;
617 }
618 overrides.insert(field.to_owned(), dynamic_derived_input_value(row, request));
619 }
620 }
621 dynamic_apply_default_input_hooks(&mut overrides, action, request)?;
622 let Some(hooks) = hook_contract
623 .get("normalize_inputs")
624 .and_then(Value::as_array)
625 else {
626 return Ok(overrides);
627 };
628 for hook in hooks {
629 let kind = hook.get("kind").and_then(Value::as_str).ok_or(422)?;
630 let fields = hook.get("fields").and_then(Value::as_array).ok_or(422)?;
631 for field in fields {
632 let field = field.as_str().ok_or(422)?;
633 if field.is_empty() || field.len() > 128 {
634 return Err(422);
635 }
636 let current = overrides
637 .get(field)
638 .cloned()
639 .unwrap_or_else(|| request_form_value(request, field));
640 let next = match kind {
641 "trim" => current.map(|value| value.trim().to_owned()),
642 "collapse_whitespace" => current.map(|value| {
643 value
644 .split_whitespace()
645 .filter(|segment| !segment.is_empty())
646 .collect::<Vec<_>>()
647 .join(" ")
648 }),
649 "lowercase" => current.map(|value| value.to_ascii_lowercase()),
650 "uppercase" => current.map(|value| value.to_ascii_uppercase()),
651 "empty_to_null" => {
652 current.and_then(|value| (!value.trim().is_empty()).then_some(value))
653 }
654 _ => return Err(422),
655 };
656 overrides.insert(field.to_owned(), next);
657 }
658 }
659 Ok(overrides)
660}
661
662fn dynamic_action_input_value(
664 request: &HttpRequest,
665 overrides: &BTreeMap<String, Option<String>>,
666 field: &str,
667) -> Option<String> {
668 if field.is_empty() {
669 return None;
670 }
671 overrides
672 .get(field)
673 .cloned()
674 .unwrap_or_else(|| request_form_value(request, field))
675}
676
677fn dynamic_action_report_annotations(
679 action: &Value,
680 request: &HttpRequest,
681 input_overrides: &BTreeMap<String, Option<String>>,
682 action_id: &str,
683 entity_id: &str,
684 event_kind: &str,
685 anchor_record_id: i64,
686) -> Result<Map<String, Value>, i32> {
687 dynamic_action_annotations(
688 action,
689 "report_annotations",
690 request,
691 input_overrides,
692 action_id,
693 entity_id,
694 event_kind,
695 anchor_record_id,
696 )
697}
698
699fn dynamic_action_annotations(
701 action: &Value,
702 hook_key: &str,
703 request: &HttpRequest,
704 input_overrides: &BTreeMap<String, Option<String>>,
705 action_id: &str,
706 entity_id: &str,
707 event_kind: &str,
708 anchor_record_id: i64,
709) -> Result<Map<String, Value>, i32> {
710 let mut annotations = Map::new();
711 let Some(rows) = action
712 .get("hook_contract")
713 .and_then(|value| value.get(hook_key))
714 .and_then(Value::as_array)
715 else {
716 return Ok(annotations);
717 };
718 if rows.len() > 16 {
719 return Err(422);
720 }
721 for row in rows {
722 let field = row
723 .get("field")
724 .and_then(Value::as_str)
725 .filter(|value| !value.is_empty() && value.len() <= 128)
726 .ok_or(422)?;
727 let value = dynamic_notification_parameter_value(
728 row.get("value").unwrap_or(&Value::Null),
729 request,
730 input_overrides,
731 action_id,
732 entity_id,
733 event_kind,
734 "",
735 "",
736 anchor_record_id,
737 )
738 .unwrap_or(Value::Null);
739 annotations.insert(field.to_owned(), value);
740 }
741 Ok(annotations)
742}
743
744fn dynamic_action_failure_annotations(
746 action: &Value,
747 request: &HttpRequest,
748 input_overrides: &BTreeMap<String, Option<String>>,
749 action_id: &str,
750 entity_id: &str,
751 event_kind: &str,
752 anchor_record_id: i64,
753) -> Result<Map<String, Value>, i32> {
754 dynamic_action_annotations(
755 action,
756 "failure_annotations",
757 request,
758 input_overrides,
759 action_id,
760 entity_id,
761 event_kind,
762 anchor_record_id,
763 )
764}
765
766fn dynamic_replace_records_from_input_list_primitive(
768 root: &mut Value,
769 request: &HttpRequest,
770 input_overrides: &BTreeMap<String, Option<String>>,
771 primitive: &Value,
772 matched_record_count: &mut i64,
773 created_record_count: &mut i64,
774 updated_record_count: &mut i64,
775 skipped_record_count: &mut i64,
776 removed_record_count: &mut i64,
777) -> Result<(), i32> {
778 let collection = primitive
779 .get("collection")
780 .and_then(Value::as_str)
781 .ok_or(422)?;
782 let scope_field = primitive
783 .get("scope_match")
784 .and_then(Value::as_object)
785 .and_then(|map| map.get("field"))
786 .and_then(Value::as_str)
787 .ok_or(422)?;
788 let scope_input = primitive
789 .get("scope_match")
790 .and_then(Value::as_object)
791 .and_then(|map| map.get("from_input"))
792 .and_then(Value::as_str)
793 .ok_or(422)?;
794 let wanted =
795 dynamic_action_input_value(request, input_overrides, scope_input).unwrap_or_default();
796 let delimiter = primitive
797 .get("list_source")
798 .and_then(Value::as_object)
799 .and_then(|map| map.get("delimiter"))
800 .and_then(Value::as_str)
801 .unwrap_or(",");
802 let source_field = primitive
803 .get("list_source")
804 .and_then(Value::as_object)
805 .and_then(|map| map.get("from_input"))
806 .and_then(Value::as_str)
807 .ok_or(422)?;
808 let raw_input =
809 dynamic_action_input_value(request, input_overrides, source_field).unwrap_or_default();
810 let parsed_items = raw_input
811 .split(delimiter)
812 .map(str::trim)
813 .filter(|value| !value.is_empty())
814 .collect::<Vec<_>>();
815 let max_records = primitive
816 .get("max_records")
817 .and_then(Value::as_i64)
818 .unwrap_or(parsed_items.len() as i64)
819 .max(0) as usize;
820 let overflow_policy = primitive
821 .get("overflow_policy")
822 .and_then(Value::as_str)
823 .unwrap_or("truncate");
824 if parsed_items.len() > max_records && overflow_policy == "fail" {
825 return Err(422);
826 }
827 let items = parsed_items
828 .into_iter()
829 .take(max_records)
830 .collect::<Vec<_>>();
831 *skipped_record_count += raw_input
832 .split(delimiter)
833 .map(str::trim)
834 .filter(|value| !value.is_empty())
835 .count()
836 .saturating_sub(items.len()) as i64;
837 let order_field = primitive
838 .get("order_field")
839 .and_then(Value::as_str)
840 .unwrap_or("id");
841 let updates = primitive
842 .get("updates")
843 .and_then(Value::as_object)
844 .ok_or(422)?;
845 let record_contract = primitive
846 .get("record_contract")
847 .and_then(Value::as_object)
848 .ok_or(422)?;
849 let existing_match_count = root
850 .get(collection)
851 .and_then(Value::as_array)
852 .map(|rows| {
853 rows.iter()
854 .filter(|row| row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted)
855 .count()
856 })
857 .unwrap_or(0);
858 let create_count = items.len().saturating_sub(existing_match_count);
859 let mut reserved_ids = (0..create_count)
860 .map(|_| dynamic_take_next_global_id(root))
861 .collect::<Vec<_>>();
862 reserved_ids.reverse();
863 let Some(rows) = dynamic_state_rows_mut(root, collection) else {
864 return Err(500);
865 };
866 let mut matching_indexes = rows
867 .iter()
868 .enumerate()
869 .filter(|(_, row)| row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted)
870 .map(|(index, _)| index)
871 .collect::<Vec<_>>();
872 matching_indexes.sort_by(|left, right| {
873 dynamic_value_order_cmp(rows[*left].get(order_field), rows[*right].get(order_field))
874 });
875 *matched_record_count += matching_indexes.len() as i64;
876 for (position, item) in items.iter().enumerate() {
877 if let Some(index) = matching_indexes.get(position).copied() {
878 let Some(object) = rows.get_mut(index).and_then(Value::as_object_mut) else {
879 return Err(500);
880 };
881 for (field_name, update_spec) in updates {
882 let source = update_spec
883 .get("source")
884 .and_then(Value::as_str)
885 .unwrap_or_default();
886 let value = match source {
887 "input" => Value::String(
888 dynamic_action_input_value(
889 request,
890 input_overrides,
891 update_spec
892 .get("field")
893 .and_then(Value::as_str)
894 .unwrap_or_default(),
895 )
896 .unwrap_or_default(),
897 ),
898 "literal" => update_spec.get("value").cloned().unwrap_or(Value::Null),
899 "list_item" => Value::String((*item).to_owned()),
900 "list_index" => Value::from((position + 1) as i64),
901 _ => Value::Null,
902 };
903 object.insert(field_name.to_owned(), value);
904 }
905 *updated_record_count += 1;
906 } else {
907 let mut record = Map::new();
908 let mut reserved_next_id = None;
909 for (field_name, field_spec) in record_contract {
910 let source = field_spec
911 .get("source")
912 .and_then(Value::as_str)
913 .unwrap_or_default();
914 let value = match source {
915 "next_id" => {
916 let next_id = *reserved_next_id
917 .get_or_insert_with(|| reserved_ids.pop().unwrap_or(0));
918 Value::from(next_id)
919 }
920 "input" => Value::String(
921 dynamic_action_input_value(
922 request,
923 input_overrides,
924 field_spec
925 .get("field")
926 .and_then(Value::as_str)
927 .unwrap_or_default(),
928 )
929 .unwrap_or_default(),
930 ),
931 "literal" => field_spec.get("value").cloned().unwrap_or(Value::Null),
932 "context_anchor_created_id" => Value::from(0),
933 "list_item" => Value::String((*item).to_owned()),
934 "list_index" => Value::from((position + 1) as i64),
935 _ => Value::Null,
936 };
937 record.insert(field_name.to_owned(), value);
938 }
939 rows.push(Value::Object(record));
940 *created_record_count += 1;
941 }
942 }
943 let mut remove_indexes = matching_indexes
944 .into_iter()
945 .skip(items.len())
946 .collect::<Vec<_>>();
947 remove_indexes.sort_unstable_by(|left, right| right.cmp(left));
948 for index in remove_indexes {
949 rows.remove(index);
950 *removed_record_count += 1;
951 }
952 Ok(())
953}
954
955fn dynamic_append_record_primitive(
957 root: &mut Value,
958 request: &HttpRequest,
959 input_overrides: &BTreeMap<String, Option<String>>,
960 primitive: &Value,
961 anchor_record_id: &mut i64,
962 created_record_count: &mut i64,
963) -> Result<(), i32> {
964 let collection = primitive
965 .get("collection")
966 .and_then(Value::as_str)
967 .ok_or(422)?;
968 let mut record = Map::new();
969 let record_contract = primitive
970 .get("record_contract")
971 .and_then(Value::as_object)
972 .ok_or(422)?;
973 for (field_name, field_spec) in record_contract {
974 let source = field_spec
975 .get("source")
976 .and_then(Value::as_str)
977 .unwrap_or_default();
978 let value = match source {
979 "next_id" => Value::from(dynamic_take_next_global_id(root)),
980 "input" => Value::String(
981 dynamic_action_input_value(
982 request,
983 input_overrides,
984 field_spec
985 .get("field")
986 .and_then(Value::as_str)
987 .unwrap_or_default(),
988 )
989 .unwrap_or_default(),
990 ),
991 "literal" => field_spec.get("value").cloned().unwrap_or(Value::Null),
992 "context_anchor_created_id" => Value::from(*anchor_record_id),
993 _ => Value::Null,
994 };
995 record.insert(field_name.to_owned(), value);
996 }
997 let created_id = record.get("id").and_then(Value::as_i64).unwrap_or(0);
998 dynamic_state_rows_mut(root, collection)
999 .ok_or(500)?
1000 .push(Value::Object(record));
1001 *created_record_count += 1;
1002 if *anchor_record_id <= 0 {
1003 *anchor_record_id = created_id;
1004 }
1005 Ok(())
1006}
1007
1008fn dynamic_set_root_fields_primitive(
1010 app: &AppRuntime,
1011 root: &mut Value,
1012 request: &HttpRequest,
1013 input_overrides: &BTreeMap<String, Option<String>>,
1014 primitive: &Value,
1015) -> Result<(), i32> {
1016 let ref_text = primitive.get("fields").and_then(Value::as_str).ok_or(422)?;
1017 let fields_root = dynamic_resolve_ref_target(app, ref_text).ok_or(422)?;
1018 let fields = fields_root
1019 .as_object()
1020 .or_else(|| fields_root.get("fields").and_then(Value::as_object))
1021 .ok_or(422)?;
1022 let Some(root_object) = root.as_object_mut() else {
1023 return Err(500);
1024 };
1025 for (field_name, field_spec) in fields {
1026 let source = field_spec
1027 .get("source")
1028 .and_then(Value::as_str)
1029 .unwrap_or_default();
1030 let value = match source {
1031 "literal" => field_spec.get("value").cloned().unwrap_or(Value::Null),
1032 "input" => Value::String(
1033 dynamic_action_input_value(
1034 request,
1035 input_overrides,
1036 field_spec
1037 .get("field")
1038 .and_then(Value::as_str)
1039 .unwrap_or_default(),
1040 )
1041 .unwrap_or_default(),
1042 ),
1043 _ => Value::Null,
1044 };
1045 root_object.insert(field_name.to_owned(), value);
1046 }
1047 Ok(())
1048}
1049
1050fn dynamic_update_record_primitive(
1052 root: &mut Value,
1053 request: &HttpRequest,
1054 input_overrides: &BTreeMap<String, Option<String>>,
1055 primitive: &Value,
1056 matched_record_count: &mut i64,
1057 updated_record_count: &mut i64,
1058) -> Result<(), i32> {
1059 let collection = primitive
1060 .get("collection")
1061 .and_then(Value::as_str)
1062 .ok_or(422)?;
1063 let match_field = primitive
1064 .get("match")
1065 .and_then(Value::as_object)
1066 .and_then(|map| map.get("field"))
1067 .and_then(Value::as_str)
1068 .ok_or(422)?;
1069 let match_input = primitive
1070 .get("match")
1071 .and_then(Value::as_object)
1072 .and_then(|map| map.get("from_input"))
1073 .and_then(Value::as_str)
1074 .ok_or(422)?;
1075 let wanted =
1076 dynamic_action_input_value(request, input_overrides, match_input).unwrap_or_default();
1077 let updates = primitive
1078 .get("updates")
1079 .and_then(Value::as_object)
1080 .ok_or(422)?;
1081 let Some(row) = dynamic_state_rows_mut(root, collection).and_then(|rows| {
1082 rows.iter_mut()
1083 .find(|row| row.get(match_field).map(value_to_text).unwrap_or_default() == wanted)
1084 }) else {
1085 return Err(404);
1086 };
1087 let Some(object) = row.as_object_mut() else {
1088 return Err(500);
1089 };
1090 for (field_name, update_spec) in updates {
1091 let source = update_spec
1092 .get("source")
1093 .and_then(Value::as_str)
1094 .unwrap_or_default();
1095 let value = match source {
1096 "literal" => update_spec.get("value").cloned().unwrap_or(Value::Null),
1097 "input" => Value::String(
1098 dynamic_action_input_value(
1099 request,
1100 input_overrides,
1101 update_spec
1102 .get("field")
1103 .and_then(Value::as_str)
1104 .unwrap_or_default(),
1105 )
1106 .unwrap_or_default(),
1107 ),
1108 "toggle_boolean" => Value::Bool(
1109 !object
1110 .get(
1111 update_spec
1112 .get("field")
1113 .and_then(Value::as_str)
1114 .unwrap_or_default(),
1115 )
1116 .and_then(Value::as_bool)
1117 .unwrap_or(false),
1118 ),
1119 _ => Value::Null,
1120 };
1121 object.insert(field_name.to_owned(), value);
1122 }
1123 *updated_record_count += 1;
1124 *matched_record_count += 1;
1125 Ok(())
1126}
1127
1128fn dynamic_append_records_from_input_list_primitive(
1130 root: &mut Value,
1131 request: &HttpRequest,
1132 input_overrides: &BTreeMap<String, Option<String>>,
1133 primitive: &Value,
1134 anchor_record_id: i64,
1135 matched_record_count: i64,
1136 updated_record_count: i64,
1137 skipped_record_count: &mut i64,
1138 created_record_count: &mut i64,
1139) -> Result<(), i32> {
1140 let collection = primitive
1141 .get("collection")
1142 .and_then(Value::as_str)
1143 .ok_or(422)?;
1144 let delimiter = primitive
1145 .get("list_source")
1146 .and_then(Value::as_object)
1147 .and_then(|map| map.get("delimiter"))
1148 .and_then(Value::as_str)
1149 .unwrap_or(",");
1150 let source_field = primitive
1151 .get("list_source")
1152 .and_then(Value::as_object)
1153 .and_then(|map| map.get("from_input"))
1154 .and_then(Value::as_str)
1155 .ok_or(422)?;
1156 let input_value =
1157 dynamic_action_input_value(request, input_overrides, source_field).unwrap_or_default();
1158 let items = input_value
1159 .split(delimiter)
1160 .map(str::trim)
1161 .filter(|value| !value.is_empty())
1162 .collect::<Vec<_>>();
1163 let max_records = primitive
1164 .get("max_records")
1165 .and_then(Value::as_i64)
1166 .unwrap_or(items.len() as i64)
1167 .max(0) as usize;
1168 let overflow_policy = primitive
1169 .get("overflow_policy")
1170 .and_then(Value::as_str)
1171 .unwrap_or("truncate");
1172 if items.len() > max_records && overflow_policy == "fail" {
1173 return Err(422);
1174 }
1175 *skipped_record_count += items.len().saturating_sub(max_records) as i64;
1176 let record_contract = primitive
1177 .get("record_contract")
1178 .and_then(Value::as_object)
1179 .ok_or(422)?;
1180 for (position, item) in items.into_iter().take(max_records).enumerate() {
1181 let mut record = Map::new();
1182 for (field_name, field_spec) in record_contract {
1183 let source = field_spec
1184 .get("source")
1185 .and_then(Value::as_str)
1186 .unwrap_or_default();
1187 let value = match source {
1188 "next_id"
1189 | "input"
1190 | "literal"
1191 | "context_anchor_created_id"
1192 | "list_item"
1193 | "list_index" => dynamic_record_contract_value(
1194 root,
1195 request,
1196 input_overrides,
1197 field_spec,
1198 anchor_record_id,
1199 Some(item),
1200 Some(position),
1201 matched_record_count,
1202 updated_record_count,
1203 *skipped_record_count,
1204 ),
1205 _ => Value::Null,
1206 };
1207 record.insert(field_name.to_owned(), value);
1208 }
1209 dynamic_state_rows_mut(root, collection)
1210 .ok_or(500)?
1211 .push(Value::Object(record));
1212 *created_record_count += 1;
1213 }
1214 Ok(())
1215}
1216
1217fn dynamic_append_records_from_related_collection_primitive(
1219 root: &mut Value,
1220 request: &HttpRequest,
1221 input_overrides: &BTreeMap<String, Option<String>>,
1222 primitive: &Value,
1223 matched_record_count: &mut i64,
1224 skipped_record_count: &mut i64,
1225 created_record_count: &mut i64,
1226) -> Result<(), i32> {
1227 let collection = primitive
1228 .get("collection")
1229 .and_then(Value::as_str)
1230 .ok_or(422)?;
1231 let source_collection = primitive
1232 .get("source_collection")
1233 .and_then(Value::as_str)
1234 .ok_or(422)?;
1235 let scope_field = primitive
1236 .get("scope_match")
1237 .and_then(Value::as_object)
1238 .and_then(|map| map.get("field"))
1239 .and_then(Value::as_str)
1240 .ok_or(422)?;
1241 let scope_input = primitive
1242 .get("scope_match")
1243 .and_then(Value::as_object)
1244 .and_then(|map| map.get("from_input"))
1245 .and_then(Value::as_str)
1246 .ok_or(422)?;
1247 let wanted =
1248 dynamic_action_input_value(request, input_overrides, scope_input).unwrap_or_default();
1249 let source_rows = root
1250 .get(source_collection)
1251 .and_then(Value::as_array)
1252 .cloned()
1253 .unwrap_or_default();
1254 let matched = source_rows
1255 .into_iter()
1256 .filter(|row| row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted)
1257 .collect::<Vec<_>>();
1258 let max_records = primitive
1259 .get("max_records")
1260 .and_then(Value::as_i64)
1261 .unwrap_or(matched.len() as i64)
1262 .max(0) as usize;
1263 *matched_record_count += matched.len() as i64;
1264 *skipped_record_count += matched.len().saturating_sub(max_records) as i64;
1265 let record_contract = primitive
1266 .get("record_contract")
1267 .and_then(Value::as_object)
1268 .ok_or(422)?;
1269 for source_row in matched.into_iter().take(max_records) {
1270 let mut record = Map::new();
1271 for (field_name, field_spec) in record_contract {
1272 let source = field_spec
1273 .get("source")
1274 .and_then(Value::as_str)
1275 .unwrap_or_default();
1276 let value = match source {
1277 "next_id" => Value::from(dynamic_take_next_global_id(root)),
1278 "source_record_field" => source_row
1279 .get(
1280 field_spec
1281 .get("field")
1282 .and_then(Value::as_str)
1283 .unwrap_or_default(),
1284 )
1285 .cloned()
1286 .unwrap_or(Value::Null),
1287 "input" => Value::String(
1288 dynamic_action_input_value(
1289 request,
1290 input_overrides,
1291 field_spec
1292 .get("field")
1293 .and_then(Value::as_str)
1294 .unwrap_or_default(),
1295 )
1296 .unwrap_or_default(),
1297 ),
1298 "literal" => field_spec.get("value").cloned().unwrap_or(Value::Null),
1299 _ => Value::Null,
1300 };
1301 record.insert(field_name.to_owned(), value);
1302 }
1303 dynamic_state_rows_mut(root, collection)
1304 .ok_or(500)?
1305 .push(Value::Object(record));
1306 *created_record_count += 1;
1307 }
1308 Ok(())
1309}
1310
1311fn dynamic_apply_scoped_assignment_or_bulk_update_primitive(
1313 root: &mut Value,
1314 request: &HttpRequest,
1315 input_overrides: &BTreeMap<String, Option<String>>,
1316 primitive: &Value,
1317 primitive_kind: &str,
1318 matched_record_count: &mut i64,
1319 updated_record_count: &mut i64,
1320 skipped_record_count: &mut i64,
1321) -> Result<(), i32> {
1322 let collection = primitive
1323 .get("collection")
1324 .and_then(Value::as_str)
1325 .ok_or(422)?;
1326 let scope_field = primitive
1327 .get("scope_match")
1328 .and_then(Value::as_object)
1329 .and_then(|map| map.get("field"))
1330 .and_then(Value::as_str)
1331 .ok_or(422)?;
1332 let scope_input = primitive
1333 .get("scope_match")
1334 .and_then(Value::as_object)
1335 .and_then(|map| map.get("from_input"))
1336 .and_then(Value::as_str)
1337 .ok_or(422)?;
1338 let wanted =
1339 dynamic_action_input_value(request, input_overrides, scope_input).unwrap_or_default();
1340 let max_records = primitive
1341 .get("max_records")
1342 .and_then(Value::as_i64)
1343 .unwrap_or(i64::MAX)
1344 .max(0) as usize;
1345 let overflow_policy = primitive
1346 .get("overflow_policy")
1347 .and_then(Value::as_str)
1348 .unwrap_or("truncate");
1349 let Some(rows) = dynamic_state_rows_mut(root, collection) else {
1350 return Err(500);
1351 };
1352 let matching_indexes = rows
1353 .iter()
1354 .enumerate()
1355 .filter(|(_, row)| row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted)
1356 .map(|(index, _)| index)
1357 .collect::<Vec<_>>();
1358 *matched_record_count += matching_indexes.len() as i64;
1359 if matching_indexes.len() > max_records && overflow_policy == "fail" {
1360 return Err(422);
1361 }
1362 *skipped_record_count += matching_indexes.len().saturating_sub(max_records) as i64;
1363 for index in matching_indexes.into_iter().take(max_records) {
1364 let Some(object) = rows.get_mut(index).and_then(Value::as_object_mut) else {
1365 return Err(500);
1366 };
1367 if primitive_kind == "scoped_reassignment" {
1368 let target_field = primitive
1369 .get("target_field")
1370 .and_then(Value::as_str)
1371 .ok_or(422)?;
1372 let value = dynamic_action_input_value(
1373 request,
1374 input_overrides,
1375 primitive
1376 .get("value_contract")
1377 .and_then(Value::as_object)
1378 .and_then(|map| map.get("field"))
1379 .and_then(Value::as_str)
1380 .unwrap_or_default(),
1381 )
1382 .unwrap_or_default();
1383 object.insert(target_field.to_owned(), Value::String(value));
1384 } else {
1385 let updates = primitive
1386 .get("updates")
1387 .and_then(Value::as_object)
1388 .ok_or(422)?;
1389 for (field_name, update_spec) in updates {
1390 let source = update_spec
1391 .get("source")
1392 .and_then(Value::as_str)
1393 .unwrap_or_default();
1394 let value = match source {
1395 "literal" => update_spec.get("value").cloned().unwrap_or(Value::Null),
1396 _ => Value::Null,
1397 };
1398 object.insert(field_name.to_owned(), value);
1399 }
1400 }
1401 *updated_record_count += 1;
1402 }
1403 Ok(())
1404}
1405
1406fn dynamic_apply_scoped_status_transition_primitive(
1408 root: &mut Value,
1409 request: &HttpRequest,
1410 input_overrides: &BTreeMap<String, Option<String>>,
1411 primitive: &Value,
1412 matched_record_count: &mut i64,
1413 updated_record_count: &mut i64,
1414 skipped_record_count: &mut i64,
1415) -> Result<(), i32> {
1416 let collection = primitive
1417 .get("collection")
1418 .and_then(Value::as_str)
1419 .ok_or(422)?;
1420 let scope_field = primitive
1421 .get("scope_match")
1422 .and_then(Value::as_object)
1423 .and_then(|map| map.get("field"))
1424 .and_then(Value::as_str)
1425 .ok_or(422)?;
1426 let scope_input = primitive
1427 .get("scope_match")
1428 .and_then(Value::as_object)
1429 .and_then(|map| map.get("from_input"))
1430 .and_then(Value::as_str)
1431 .ok_or(422)?;
1432 let wanted =
1433 dynamic_action_input_value(request, input_overrides, scope_input).unwrap_or_default();
1434 let status_field = primitive
1435 .get("status_field")
1436 .and_then(Value::as_str)
1437 .filter(|value| !value.is_empty())
1438 .unwrap_or("status");
1439 let workflow_state_field = primitive
1440 .get("workflow_state_field")
1441 .and_then(Value::as_str)
1442 .filter(|value| !value.is_empty())
1443 .unwrap_or("workflow_state");
1444 let to_status = primitive
1445 .get("to_status")
1446 .and_then(Value::as_str)
1447 .filter(|value| !value.is_empty())
1448 .ok_or(422)?;
1449 let to_workflow_state = primitive
1450 .get("to_workflow_state")
1451 .and_then(Value::as_str)
1452 .filter(|value| !value.is_empty())
1453 .unwrap_or(to_status);
1454 let allowed_from_statuses = primitive
1455 .get("from_statuses")
1456 .and_then(Value::as_array)
1457 .map(|rows| {
1458 rows.iter()
1459 .filter_map(Value::as_str)
1460 .filter(|value| !value.is_empty())
1461 .collect::<Vec<_>>()
1462 })
1463 .unwrap_or_default();
1464 let max_records = primitive
1465 .get("max_records")
1466 .and_then(Value::as_i64)
1467 .unwrap_or(i64::MAX)
1468 .max(0) as usize;
1469 let overflow_policy = primitive
1470 .get("overflow_policy")
1471 .and_then(Value::as_str)
1472 .unwrap_or("truncate");
1473 let now = now_iso_utc();
1474 let actor_value = primitive
1475 .get("actor_input")
1476 .and_then(Value::as_str)
1477 .and_then(|field| dynamic_action_input_value(request, input_overrides, field));
1478 let Some(rows) = dynamic_state_rows_mut(root, collection) else {
1479 return Err(500);
1480 };
1481 let matching_indexes = rows
1482 .iter()
1483 .enumerate()
1484 .filter(|(_, row)| row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted)
1485 .map(|(index, _)| index)
1486 .collect::<Vec<_>>();
1487 *matched_record_count += matching_indexes.len() as i64;
1488 if matching_indexes.len() > max_records && overflow_policy == "fail" {
1489 return Err(422);
1490 }
1491 *skipped_record_count += matching_indexes.len().saturating_sub(max_records) as i64;
1492 for index in matching_indexes.into_iter().take(max_records) {
1493 let Some(object) = rows.get_mut(index).and_then(Value::as_object_mut) else {
1494 return Err(500);
1495 };
1496 let current_status = object
1497 .get(status_field)
1498 .and_then(Value::as_str)
1499 .or_else(|| object.get(workflow_state_field).and_then(Value::as_str))
1500 .unwrap_or_default();
1501 if !allowed_from_statuses.is_empty() && !allowed_from_statuses.contains(¤t_status) {
1502 *skipped_record_count += 1;
1503 continue;
1504 }
1505 object.insert(status_field.to_owned(), Value::String(to_status.to_owned()));
1506 object.insert(
1507 workflow_state_field.to_owned(),
1508 Value::String(to_workflow_state.to_owned()),
1509 );
1510 if let Some(field) = primitive
1511 .get("timestamp_field")
1512 .and_then(Value::as_str)
1513 .filter(|value| !value.is_empty())
1514 {
1515 object.insert(field.to_owned(), Value::String(now.clone()));
1516 }
1517 if let Some(field) = primitive
1518 .get("actor_field")
1519 .and_then(Value::as_str)
1520 .filter(|value| !value.is_empty())
1521 {
1522 object.insert(
1523 field.to_owned(),
1524 Value::String(actor_value.clone().unwrap_or_default()),
1525 );
1526 }
1527 if let Some(updates) = primitive.get("updates").and_then(Value::as_object) {
1528 for (field_name, update_spec) in updates {
1529 let source = update_spec
1530 .get("source")
1531 .and_then(Value::as_str)
1532 .unwrap_or_default();
1533 let value = match source {
1534 "literal" => update_spec.get("value").cloned().unwrap_or(Value::Null),
1535 "input" => Value::String(
1536 dynamic_action_input_value(
1537 request,
1538 input_overrides,
1539 update_spec
1540 .get("field")
1541 .and_then(Value::as_str)
1542 .unwrap_or_default(),
1543 )
1544 .unwrap_or_default(),
1545 ),
1546 _ => Value::Null,
1547 };
1548 object.insert(field_name.to_owned(), value);
1549 }
1550 }
1551 *updated_record_count += 1;
1552 }
1553 Ok(())
1554}
1555
1556fn dynamic_upsert_record_primitive(
1558 root: &mut Value,
1559 request: &HttpRequest,
1560 input_overrides: &BTreeMap<String, Option<String>>,
1561 primitive: &Value,
1562 anchor_record_id: i64,
1563 matched_record_count: &mut i64,
1564 updated_record_count: &mut i64,
1565 skipped_record_count: i64,
1566 created_record_count: &mut i64,
1567) -> Result<(), i32> {
1568 let collection = primitive
1569 .get("collection")
1570 .and_then(Value::as_str)
1571 .ok_or(422)?;
1572 let match_field = primitive
1573 .get("match")
1574 .and_then(Value::as_object)
1575 .and_then(|map| map.get("field"))
1576 .and_then(Value::as_str)
1577 .ok_or(422)?;
1578 let wanted = if let Some(input_name) = primitive
1579 .get("match")
1580 .and_then(Value::as_object)
1581 .and_then(|map| map.get("from_input"))
1582 .and_then(Value::as_str)
1583 {
1584 dynamic_action_input_value(request, input_overrides, input_name).unwrap_or_default()
1585 } else if let Some(value) = primitive
1586 .get("match")
1587 .and_then(Value::as_object)
1588 .and_then(|map| map.get("value"))
1589 {
1590 value_to_text(value)
1591 } else {
1592 return Err(422);
1593 };
1594 let updates = primitive
1595 .get("updates")
1596 .and_then(Value::as_object)
1597 .ok_or(422)?;
1598 let record_contract = primitive
1599 .get("record_contract")
1600 .and_then(Value::as_object)
1601 .ok_or(422)?;
1602 let existing_index = root
1603 .get(collection)
1604 .and_then(Value::as_array)
1605 .and_then(|rows| {
1606 rows.iter().position(|row| {
1607 row.get(match_field).map(value_to_text).unwrap_or_default() == wanted
1608 })
1609 });
1610 if let Some(index) = existing_index {
1611 *matched_record_count += 1;
1612 let resolved_updates = updates
1613 .iter()
1614 .map(|(field_name, update_spec)| {
1615 (
1616 field_name.to_owned(),
1617 dynamic_record_contract_value(
1618 root,
1619 request,
1620 input_overrides,
1621 update_spec,
1622 anchor_record_id,
1623 None,
1624 None,
1625 *matched_record_count,
1626 *updated_record_count,
1627 skipped_record_count,
1628 ),
1629 )
1630 })
1631 .collect::<Vec<_>>();
1632 let Some(object) = dynamic_state_rows_mut(root, collection)
1633 .and_then(|rows| rows.get_mut(index))
1634 .and_then(Value::as_object_mut)
1635 else {
1636 return Err(500);
1637 };
1638 for (field_name, value) in resolved_updates {
1639 object.insert(field_name.to_owned(), value);
1640 }
1641 *updated_record_count += 1;
1642 } else {
1643 let mut record = Map::new();
1644 for (field_name, field_spec) in record_contract {
1645 let value = dynamic_record_contract_value(
1646 root,
1647 request,
1648 input_overrides,
1649 field_spec,
1650 anchor_record_id,
1651 None,
1652 None,
1653 *matched_record_count,
1654 *updated_record_count,
1655 skipped_record_count,
1656 );
1657 record.insert(field_name.to_owned(), value);
1658 }
1659 dynamic_state_rows_mut(root, collection)
1660 .ok_or(500)?
1661 .push(Value::Object(record));
1662 *created_record_count += 1;
1663 }
1664 Ok(())
1665}
1666
1667fn dynamic_action_resource_id(
1669 action: &Value,
1670 request: &HttpRequest,
1671 input_overrides: &BTreeMap<String, Option<String>>,
1672 anchor_record_id: i64,
1673) -> String {
1674 let entity_id = action
1675 .get("entity_id")
1676 .and_then(Value::as_str)
1677 .unwrap_or("record");
1678 if anchor_record_id > 0 {
1679 return format!("{entity_id}:{anchor_record_id}");
1680 }
1681 if let Some(input_name) = action
1682 .get("mutation_primitives")
1683 .and_then(Value::as_array)
1684 .and_then(|rows| {
1685 rows.iter().find_map(|primitive| {
1686 primitive
1687 .get("match")
1688 .and_then(Value::as_object)
1689 .and_then(|map| map.get("from_input"))
1690 .and_then(Value::as_str)
1691 .or_else(|| {
1692 primitive
1693 .get("scope_match")
1694 .and_then(Value::as_object)
1695 .and_then(|map| map.get("from_input"))
1696 .and_then(Value::as_str)
1697 })
1698 })
1699 })
1700 .filter(|value| !value.is_empty())
1701 && let Some(value) = dynamic_action_input_value(request, input_overrides, input_name)
1702 .filter(|value| !value.is_empty())
1703 {
1704 return format!("{entity_id}:{value}");
1705 }
1706 if let Some(value) =
1707 dynamic_action_input_value(request, input_overrides, "id").filter(|value| !value.is_empty())
1708 {
1709 return format!("{entity_id}:{value}");
1710 }
1711 format!("{entity_id}:0")
1712}
1713
1714fn prepare_file_record(
1716 integrations_root: &Value,
1717 action: &Value,
1718 file_record: &Value,
1719 request: &HttpRequest,
1720 input_overrides: &BTreeMap<String, Option<String>>,
1721 action_id: &str,
1722 entity_id: &str,
1723 event_kind: &str,
1724 anchor_record_id: i64,
1725) -> Option<(String, String, Value)> {
1726 let adapter_id = file_record
1727 .get("adapter_id")
1728 .and_then(Value::as_str)
1729 .filter(|value| !value.is_empty())
1730 .unwrap_or_default();
1731 let adapter = dynamic_find_integration_adapter(integrations_root, adapter_id)?;
1732 let adapter_kind = adapter
1733 .get("adapter_kind")
1734 .and_then(Value::as_str)
1735 .unwrap_or_default();
1736 if !dynamic_integration_file_adapter_kind(adapter_kind) {
1737 return None;
1738 }
1739 let relative_path = adapter
1740 .get("relative_path")
1741 .and_then(Value::as_str)
1742 .filter(|value| !value.is_empty())
1743 .unwrap_or_default();
1744 if relative_path.is_empty()
1745 || Path::new(relative_path)
1746 .components()
1747 .any(|part| !matches!(part, std::path::Component::Normal(_)))
1748 {
1749 return None;
1750 }
1751 let mut record = serde_json::Map::from_iter([
1752 (
1753 "adapter_id".to_owned(),
1754 Value::String(adapter_id.to_owned()),
1755 ),
1756 (
1757 "adapter_kind".to_owned(),
1758 Value::String(adapter_kind.to_owned()),
1759 ),
1760 ("action_id".to_owned(), Value::String(action_id.to_owned())),
1761 ("entity_id".to_owned(), Value::String(entity_id.to_owned())),
1762 (
1763 "event_kind".to_owned(),
1764 Value::String(event_kind.to_owned()),
1765 ),
1766 (
1767 "resource_id".to_owned(),
1768 Value::String(dynamic_action_resource_id(
1769 action,
1770 request,
1771 input_overrides,
1772 anchor_record_id,
1773 )),
1774 ),
1775 ("created_at".to_owned(), Value::String(now_iso_utc())),
1776 ]);
1777 if let Some(fields) = file_record.get("record").and_then(Value::as_object) {
1778 if fields.len() > 32 {
1779 return None;
1780 }
1781 for (field_name, field_spec) in fields {
1782 if field_name.is_empty() || field_name.len() > 128 {
1783 return None;
1784 }
1785 let value = dynamic_notification_parameter_value(
1786 field_spec,
1787 request,
1788 input_overrides,
1789 action_id,
1790 entity_id,
1791 event_kind,
1792 "",
1793 "",
1794 anchor_record_id,
1795 )
1796 .unwrap_or(Value::Null);
1797 record.insert(field_name.to_owned(), value);
1798 }
1799 }
1800 Some((
1801 adapter_kind.to_owned(),
1802 relative_path.to_owned(),
1803 Value::Object(record),
1804 ))
1805}
1806
1807fn enqueue_file_record(root: &mut Value, prepared: (String, String, Value)) -> bool {
1809 let (kind, path, record) = prepared;
1810 let persisted = match root.get("_local_file_delivery_sequence") {
1811 None => 0,
1812 Some(value) => match value.as_i64().filter(|value| *value >= 0) {
1813 Some(value) => value,
1814 None => return false,
1815 },
1816 };
1817 let retained_maximum = root
1818 .get("local_file_deliveries")
1819 .and_then(Value::as_array)
1820 .into_iter()
1821 .flatten()
1822 .filter_map(|row| row.get("id").and_then(Value::as_i64))
1823 .max()
1824 .unwrap_or(0);
1825 let Some(id) = persisted.max(retained_maximum).checked_add(1) else {
1826 return false;
1827 };
1828
1829 let Ok(encoded) = serde_json::to_vec(&record) else {
1830 return false;
1831 };
1832 let key = pharos_runtime_database::operations::bytes_fingerprint(&encoded);
1833 let Some(rows) = root.as_object_mut().and_then(|object| {
1834 object
1835 .entry("local_file_deliveries")
1836 .or_insert_with(|| Value::Array(Vec::new()))
1837 .as_array_mut()
1838 }) else {
1839 return false;
1840 };
1841 let mut terminal_count = rows
1842 .iter()
1843 .filter(|row| {
1844 matches!(
1845 row.get("delivery_status").and_then(Value::as_str),
1846 Some("delivered" | "failed")
1847 )
1848 })
1849 .count();
1850 rows.retain(|row| {
1851 if terminal_count > 64
1852 && matches!(
1853 row.get("delivery_status").and_then(Value::as_str),
1854 Some("delivered" | "failed")
1855 )
1856 {
1857 terminal_count -= 1;
1858 false
1859 } else {
1860 true
1861 }
1862 });
1863 if rows.len() >= 1024 {
1864 return false;
1865 }
1866
1867 rows.push(serde_json::json!({"id": id, "adapter_kind": kind, "relative_path": path, "record": record, "delivery_key": format!("{id}:{key}"), "delivery_status": "queued", "attempt_count": 0, "next_attempt_at": ""}));
1868 let Some(object) = root.as_object_mut() else {
1869 return false;
1870 };
1871 object.insert("_local_file_delivery_sequence".to_owned(), Value::from(id));
1872
1873 true
1874}
1875
1876pub(super) fn process_one_due_file_delivery(app: &AppRuntime, root: &mut Value) -> bool {
1879 let now = current_epoch_seconds();
1880 let Some(rows) = root
1881 .get_mut("local_file_deliveries")
1882 .and_then(Value::as_array_mut)
1883 else {
1884 return false;
1885 };
1886 let mut pending_paths = std::collections::BTreeSet::new();
1887 let Some(row) = rows.iter_mut().find(|row| {
1888 let status = row.get("delivery_status").and_then(Value::as_str);
1889 if !matches!(status, Some("queued" | "retryable")) {
1890 return false;
1891 }
1892 let Some(path) = row.get("relative_path").and_then(Value::as_str) else {
1893 return false;
1894 };
1895 if !pending_paths.insert(path.to_owned()) {
1896 return false;
1897 }
1898 status == Some("queued")
1899 || (row.get("delivery_status").and_then(Value::as_str) == Some("retryable")
1900 && row
1901 .get("next_attempt_at")
1902 .and_then(Value::as_str)
1903 .and_then(|text| text.parse::<u64>().ok())
1904 .is_some_and(|due| due <= now))
1905 }) else {
1906 return false;
1907 };
1908 let state_root = app
1909 .state_path
1910 .as_deref()
1911 .and_then(|path| Path::new(path).parent());
1912 let delivered = state_root.is_some_and(|state_root| deliver_file_record(state_root, row));
1913 let attempts = row
1914 .get("attempt_count")
1915 .and_then(Value::as_u64)
1916 .unwrap_or(0)
1917 .saturating_add(1);
1918 row["attempt_count"] = Value::from(attempts);
1919 row["delivery_status"] = Value::String(
1920 if delivered {
1921 "delivered"
1922 } else if attempts >= 5 {
1923 "failed"
1924 } else {
1925 "retryable"
1926 }
1927 .to_owned(),
1928 );
1929 row["next_attempt_at"] = Value::String(if delivered || attempts >= 5 {
1930 String::new()
1931 } else {
1932 now.saturating_add(30 * attempts).to_string()
1933 });
1934 true
1935}
1936
1937fn deliver_file_record(state_root: &Path, envelope: &Value) -> bool {
1939 let Some(relative) = envelope.get("relative_path").and_then(Value::as_str) else {
1940 return false;
1941 };
1942 if relative.is_empty()
1943 || Path::new(relative)
1944 .components()
1945 .any(|part| !matches!(part, std::path::Component::Normal(_)))
1946 {
1947 return false;
1948 }
1949 let Some(record) = envelope.get("record") else {
1950 return false;
1951 };
1952 let output_path = state_root.join(relative);
1953 match envelope.get("adapter_kind").and_then(Value::as_str) {
1954 Some("append_jsonl_local") => envelope
1955 .get("delivery_key")
1956 .and_then(Value::as_str)
1957 .is_some_and(|key| {
1958 pharos_runtime_core::fs::append_json_record_once(
1959 state_root,
1960 &output_path,
1961 key,
1962 record,
1963 0o640,
1964 )
1965 .is_ok()
1966 }),
1967 Some("write_json_local") => serde_json::to_vec_pretty(record).ok().is_some_and(|bytes| {
1968 pharos_runtime_core::fs::write_file_within(state_root, &output_path, &bytes, 0o640)
1969 .is_ok()
1970 }),
1971 _ => false,
1972 }
1973}
1974
1975#[cfg(test)]
1977fn dynamic_append_file_record(
1978 state_path: &Path,
1979 integrations_root: &Value,
1980 action: &Value,
1981 file_record: &Value,
1982 request: &HttpRequest,
1983 input_overrides: &BTreeMap<String, Option<String>>,
1984 action_id: &str,
1985 entity_id: &str,
1986 event_kind: &str,
1987 anchor_record_id: i64,
1988) -> bool {
1989 let Some(prepared) = prepare_file_record(
1990 integrations_root,
1991 action,
1992 file_record,
1993 request,
1994 input_overrides,
1995 action_id,
1996 entity_id,
1997 event_kind,
1998 anchor_record_id,
1999 ) else {
2000 return false;
2001 };
2002 let mut root = serde_json::json!({});
2003 if !enqueue_file_record(&mut root, prepared) {
2004 return false;
2005 }
2006 state_path
2007 .parent()
2008 .is_some_and(|parent| deliver_file_record(parent, &root["local_file_deliveries"][0]))
2009}
2010
2011fn dynamic_apply_approval_transition(
2013 object: &mut Map<String, Value>,
2014 primitive: &Value,
2015 request: &HttpRequest,
2016 input_overrides: &BTreeMap<String, Option<String>>,
2017) -> Result<(), i32> {
2018 let current_status = object
2019 .get("status")
2020 .and_then(Value::as_str)
2021 .or_else(|| object.get("workflow_state").and_then(Value::as_str))
2022 .unwrap_or_default();
2023 let transition_kind = primitive
2024 .get("transition_kind")
2025 .and_then(Value::as_str)
2026 .unwrap_or_default();
2027 let now = now_iso_utc();
2028 match transition_kind {
2029 "submit" => {
2030 if current_status != "draft" {
2031 return Err(422);
2032 }
2033 object.insert("status".to_owned(), Value::String("submitted".to_owned()));
2034 object.insert(
2035 "workflow_state".to_owned(),
2036 Value::String("submitted".to_owned()),
2037 );
2038 if let Some(field) = primitive
2039 .get("submitted_at_field")
2040 .and_then(Value::as_str)
2041 .filter(|value| !value.is_empty())
2042 {
2043 object.insert(field.to_owned(), Value::String(now));
2044 }
2045 }
2046 "approve" => {
2047 if current_status != "submitted" {
2048 return Err(422);
2049 }
2050 let approver = dynamic_action_input_value(
2051 request,
2052 input_overrides,
2053 primitive
2054 .get("approver_input")
2055 .and_then(Value::as_str)
2056 .unwrap_or("approver"),
2057 )
2058 .unwrap_or_default();
2059 if approver.trim().is_empty() {
2060 return Err(422);
2061 }
2062 let approved_by_field = primitive
2063 .get("approved_by_field")
2064 .and_then(Value::as_str)
2065 .filter(|value| !value.is_empty())
2066 .unwrap_or("approved_by");
2067 object.insert(approved_by_field.to_owned(), Value::String(approver));
2068 if let Some(field) = primitive
2069 .get("approved_at_field")
2070 .and_then(Value::as_str)
2071 .filter(|value| !value.is_empty())
2072 {
2073 object.insert(field.to_owned(), Value::String(now.clone()));
2074 }
2075 object.insert("status".to_owned(), Value::String("approved".to_owned()));
2076 object.insert(
2077 "workflow_state".to_owned(),
2078 Value::String("approved".to_owned()),
2079 );
2080 let rejected_by_field = primitive
2081 .get("rejected_by_field")
2082 .and_then(Value::as_str)
2083 .filter(|value| !value.is_empty())
2084 .unwrap_or("rejected_by");
2085 let rejection_reason_field = primitive
2086 .get("rejection_reason_field")
2087 .and_then(Value::as_str)
2088 .filter(|value| !value.is_empty())
2089 .unwrap_or("rejection_reason");
2090 object.insert(rejected_by_field.to_owned(), Value::String(String::new()));
2091 object.insert(
2092 rejection_reason_field.to_owned(),
2093 Value::String(String::new()),
2094 );
2095 if let Some(field) = primitive
2096 .get("rejected_at_field")
2097 .and_then(Value::as_str)
2098 .filter(|value| !value.is_empty())
2099 {
2100 object.insert(field.to_owned(), Value::String(String::new()));
2101 }
2102 }
2103 "reject" => {
2104 if current_status != "submitted" {
2105 return Err(422);
2106 }
2107 let approver = dynamic_action_input_value(
2108 request,
2109 input_overrides,
2110 primitive
2111 .get("approver_input")
2112 .and_then(Value::as_str)
2113 .unwrap_or("approver"),
2114 )
2115 .unwrap_or_default();
2116 let reason = dynamic_action_input_value(
2117 request,
2118 input_overrides,
2119 primitive
2120 .get("reason_input")
2121 .and_then(Value::as_str)
2122 .unwrap_or("reason"),
2123 )
2124 .unwrap_or_default();
2125 if approver.trim().is_empty() || reason.trim().is_empty() {
2126 return Err(422);
2127 }
2128 let rejected_by_field = primitive
2129 .get("rejected_by_field")
2130 .and_then(Value::as_str)
2131 .filter(|value| !value.is_empty())
2132 .unwrap_or("approved_by");
2133 let rejection_reason_field = primitive
2134 .get("rejection_reason_field")
2135 .and_then(Value::as_str)
2136 .filter(|value| !value.is_empty())
2137 .unwrap_or("rejection_reason");
2138 let approved_by_field = primitive
2139 .get("approved_by_field")
2140 .and_then(Value::as_str)
2141 .filter(|value| !value.is_empty())
2142 .unwrap_or("approved_by");
2143 object.insert(rejected_by_field.to_owned(), Value::String(approver));
2144 object.insert(rejection_reason_field.to_owned(), Value::String(reason));
2145 object.insert(approved_by_field.to_owned(), Value::String(String::new()));
2146 if let Some(field) = primitive
2147 .get("approved_at_field")
2148 .and_then(Value::as_str)
2149 .filter(|value| !value.is_empty())
2150 {
2151 object.insert(field.to_owned(), Value::String(String::new()));
2152 }
2153 if let Some(field) = primitive
2154 .get("rejected_at_field")
2155 .and_then(Value::as_str)
2156 .filter(|value| !value.is_empty())
2157 {
2158 object.insert(field.to_owned(), Value::String(now));
2159 }
2160 object.insert("status".to_owned(), Value::String("rejected".to_owned()));
2161 object.insert(
2162 "workflow_state".to_owned(),
2163 Value::String("rejected".to_owned()),
2164 );
2165 }
2166 _ => return Err(422),
2167 }
2168 Ok(())
2169}
2170
2171fn dynamic_append_audit_record(
2173 root: &mut Value,
2174 integrations_root: &Value,
2175 action: &Value,
2176 audit: &Value,
2177 request: &HttpRequest,
2178 input_overrides: &BTreeMap<String, Option<String>>,
2179 action_id: &str,
2180 entity_id: &str,
2181 event_kind: &str,
2182 anchor_record_id: i64,
2183) -> bool {
2184 let adapter_id = audit
2185 .get("adapter_id")
2186 .and_then(Value::as_str)
2187 .filter(|value| !value.is_empty())
2188 .unwrap_or_default();
2189 let Some(adapter) = dynamic_find_integration_adapter(integrations_root, adapter_id) else {
2190 return false;
2191 };
2192 let adapter_kind = adapter
2193 .get("adapter_kind")
2194 .and_then(Value::as_str)
2195 .unwrap_or_default();
2196 if !dynamic_integration_audit_adapter_kind(adapter_kind) {
2197 return false;
2198 }
2199 let event = dynamic_notification_parameter_value(
2200 audit.get("event").unwrap_or(&Value::Null),
2201 request,
2202 input_overrides,
2203 action_id,
2204 entity_id,
2205 event_kind,
2206 "",
2207 "",
2208 anchor_record_id,
2209 )
2210 .map(|value| value_to_text(&value))
2211 .filter(|value| !value.is_empty())
2212 .unwrap_or_else(|| event_kind.to_owned());
2213 let level = dynamic_notification_parameter_value(
2214 audit.get("level").unwrap_or(&Value::Null),
2215 request,
2216 input_overrides,
2217 action_id,
2218 entity_id,
2219 event_kind,
2220 "",
2221 "",
2222 anchor_record_id,
2223 )
2224 .map(|value| value_to_text(&value))
2225 .filter(|value| !value.is_empty())
2226 .unwrap_or_else(|| "info".to_owned());
2227 let message = dynamic_notification_parameter_value(
2228 audit.get("message").unwrap_or(&Value::Null),
2229 request,
2230 input_overrides,
2231 action_id,
2232 entity_id,
2233 event_kind,
2234 "",
2235 "",
2236 anchor_record_id,
2237 )
2238 .map(|value| value_to_text(&value))
2239 .unwrap_or_default();
2240 let mut annotations = Map::new();
2241 if let Some(rows) = audit.get("annotations").and_then(Value::as_array) {
2242 if rows.len() > 16 {
2243 return false;
2244 }
2245 for row in rows {
2246 let Some(field) = row
2247 .get("field")
2248 .and_then(Value::as_str)
2249 .filter(|value| !value.is_empty() && value.len() <= 128)
2250 else {
2251 return false;
2252 };
2253 let value = dynamic_notification_parameter_value(
2254 row.get("value").unwrap_or(&Value::Null),
2255 request,
2256 input_overrides,
2257 action_id,
2258 entity_id,
2259 event_kind,
2260 "",
2261 "",
2262 anchor_record_id,
2263 )
2264 .unwrap_or(Value::Null);
2265 annotations.insert(field.to_owned(), value);
2266 }
2267 }
2268 let audit_id = dynamic_take_next_section_id(root, "audit_log");
2269 let resource_id =
2270 dynamic_action_resource_id(action, request, input_overrides, anchor_record_id);
2271 let Some(rows) = root
2272 .as_object_mut()
2273 .map(|object| {
2274 object
2275 .entry("audit_log".to_owned())
2276 .or_insert_with(|| Value::Array(Vec::new()))
2277 })
2278 .and_then(Value::as_array_mut)
2279 else {
2280 return false;
2281 };
2282 let mut record = serde_json::Map::from_iter([
2283 ("id".to_owned(), Value::from(audit_id)),
2284 (
2285 "adapter_id".to_owned(),
2286 Value::String(adapter_id.to_owned()),
2287 ),
2288 (
2289 "adapter_kind".to_owned(),
2290 Value::String(adapter_kind.to_owned()),
2291 ),
2292 ("event".to_owned(), Value::String(event)),
2293 ("level".to_owned(), Value::String(level)),
2294 ("message".to_owned(), Value::String(message)),
2295 ("action_id".to_owned(), Value::String(action_id.to_owned())),
2296 ("entity_id".to_owned(), Value::String(entity_id.to_owned())),
2297 (
2298 "event_kind".to_owned(),
2299 Value::String(event_kind.to_owned()),
2300 ),
2301 ("resource_id".to_owned(), Value::String(resource_id)),
2302 ("created_at".to_owned(), Value::String(now_iso_utc())),
2303 ]);
2304 if !annotations.is_empty() {
2305 record.insert("annotations".to_owned(), Value::Object(annotations));
2306 }
2307 rows.push(Value::Object(record));
2308 true
2309}
2310
2311fn dynamic_action_failure_code(status: i32) -> &'static str {
2313 match status {
2314 400 => "bad_request",
2315 403 => "forbidden",
2316 404 => "not_found",
2317 409 => "conflict",
2318 415 => "unsupported_media_type",
2319 422 => "validation_failed",
2320 500 => "internal_error",
2321 _ => "action_failed",
2322 }
2323}
2324
2325pub fn request_path(request: Option<&HttpRequest>) -> String {
2345 request
2346 .and_then(|request| super::http::request_path_without_query(&request.target))
2347 .unwrap_or_else(|| "/".to_owned())
2348}
2349
2350pub fn request_is_hx(request: Option<&HttpRequest>) -> bool {
2374 request.is_some_and(|request| {
2375 request.headers.lines().any(|line| {
2376 let (name, value) = line.split_once(':').unwrap_or(("", ""));
2377 name.trim().eq_ignore_ascii_case("hx-request")
2378 && value.trim().eq_ignore_ascii_case("true")
2379 })
2380 })
2381}
2382
2383fn cookie_value(cookie_header: Option<&str>, name: &str) -> Option<String> {
2385 pharos_runtime_security::cookie_value(cookie_header, name)
2386}
2387
2388fn constant_time_equal(left: &[u8], right: &[u8]) -> bool {
2390 pharos_runtime_security::constant_time_equal(left, right)
2391}
2392
2393pub(crate) fn hex_digest(bytes: &[u8]) -> String {
2413 pharos_runtime_dynamic::hex_digest(bytes)
2414}
2415
2416pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
2437 pharos_runtime_dynamic::hmac_sha256(key, data)
2438}
2439
2440pub fn verify_password_hash(stored_hash: &str, password: &str, username: Option<&str>) -> bool {
2462 pharos_runtime_dynamic::verify_password_hash(stored_hash, password, username)
2463}
2464
2465pub fn random_secret_hex(byte_count: usize) -> Result<String, i32> {
2489 pharos_runtime_dynamic::random_secret_hex(byte_count).map_err(|_| 69)
2490}
2491
2492pub fn hash_password(password: &str) -> Result<String, i32> {
2516 pharos_runtime_dynamic::hash_password(password).map_err(|_| 69)
2517}
2518
2519pub fn active_user_id_from_cookie(state_path: &Path, cookie_header: Option<&str>) -> i64 {
2544 let Some(token) = cookie_value(cookie_header, pharos_runtime_security::SESSION_COOKIE_NAME)
2545 .filter(|value| !value.is_empty())
2546 else {
2547 return 0;
2548 };
2549 let token_hash = hex_digest(&Sha256::digest(token.as_bytes()));
2550 let Some(root) = fs::read_to_string(state_path)
2551 .ok()
2552 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2553 else {
2554 return 0;
2555 };
2556 let now_epoch = current_epoch_seconds();
2557 let now_timestamp = super::current_timestamp_utc();
2558 root.get("sessions")
2559 .and_then(Value::as_array)
2560 .and_then(|rows| {
2561 rows.iter().find(|row| {
2562 row.get("session_token_hash").and_then(Value::as_str) == Some(token_hash.as_str())
2563 && row
2564 .get("revoked_at")
2565 .is_none_or(|value| value.is_null() || value.as_str() == Some(""))
2566 && row
2567 .get("expires_at")
2568 .and_then(Value::as_str)
2569 .is_some_and(|value| {
2570 value
2571 .parse::<u64>()
2572 .map_or(value > now_timestamp.as_str(), |expires| {
2573 expires > now_epoch
2574 })
2575 })
2576 })
2577 })
2578 .and_then(|row| row.get("user_id"))
2579 .and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()))
2580 .unwrap_or(0)
2581}
2582
2583fn current_epoch_seconds() -> u64 {
2585 SystemTime::now()
2586 .duration_since(UNIX_EPOCH)
2587 .map(|value| value.as_secs())
2588 .unwrap_or(0)
2589}
2590
2591fn now_iso_utc() -> String {
2593 current_epoch_seconds().to_string()
2594}
2595
2596pub fn csrf_request_valid(request: &HttpRequest, form_body: Option<&str>) -> bool {
2621 let cookie = cookie_value(request.cookie.as_deref(), "pharos_csrf");
2622 let form = request_form_value(request, "csrf_token").or_else(|| {
2623 form_body.and_then(|body| json_commands::request_form_value(body, "csrf_token"))
2624 });
2625 let header = request.headers.lines().find_map(|line| {
2626 let (name, value) = line.split_once(':')?;
2627 name.trim()
2628 .eq_ignore_ascii_case("x-csrf-token")
2629 .then_some(value.trim().to_owned())
2630 });
2631 let request_token = form.or(header);
2632 cookie
2633 .as_deref()
2634 .zip(request_token.as_deref())
2635 .is_some_and(|(left, right)| constant_time_equal(left.as_bytes(), right.as_bytes()))
2636}
2637
2638pub fn session_cookie_header(
2661 app: &AppRuntime,
2662 request: Option<&HttpRequest>,
2663 token: Option<&str>,
2664 clear: bool,
2665) -> String {
2666 let path = app
2667 .base_path
2668 .as_deref()
2669 .filter(|value| !value.is_empty())
2670 .unwrap_or("/");
2671 let secure = request_origin_scheme(request, NativeTransportType::Tcp, None, None) == "https";
2672 pharos_runtime_security::session_cookie_header(path, token, clear, secure)
2673}
2674
2675pub fn page_title(page_spec: &Value, body_params: &Value) -> String {
2696 page_spec
2697 .get("title")
2698 .and_then(Value::as_str)
2699 .filter(|value| !value.is_empty())
2700 .map(ToOwned::to_owned)
2701 .or_else(|| {
2702 page_spec
2703 .get("title_mode")
2704 .and_then(Value::as_str)
2705 .and_then(|key| body_params.get(key))
2706 .map(value_to_text)
2707 })
2708 .unwrap_or_default()
2709}
2710
2711fn value_to_text(value: &Value) -> String {
2713 match value {
2714 Value::Null => String::new(),
2715 Value::String(value) => value.clone(),
2716 Value::Bool(value) => value.to_string(),
2717 Value::Number(value) => value.to_string(),
2718 _ => serde_json::to_string(value).unwrap_or_default(),
2719 }
2720}
2721
2722fn dynamic_value_order_cmp(left: Option<&Value>, right: Option<&Value>) -> Ordering {
2724 match (
2725 left.and_then(Value::as_i64),
2726 right.and_then(Value::as_i64),
2727 left.map(value_to_text),
2728 right.map(value_to_text),
2729 ) {
2730 (Some(left_num), Some(right_num), _, _) => left_num.cmp(&right_num),
2731 (_, _, Some(left_text), Some(right_text)) => left_text.cmp(&right_text),
2732 (Some(_), None, _, _) => Ordering::Less,
2733 (None, Some(_), _, _) => Ordering::Greater,
2734 _ => Ordering::Equal,
2735 }
2736}
2737
2738fn dynamic_record_contract_value(
2740 root: &mut Value,
2741 request: &HttpRequest,
2742 input_overrides: &BTreeMap<String, Option<String>>,
2743 field_spec: &Value,
2744 anchor_record_id: i64,
2745 list_item: Option<&str>,
2746 list_index: Option<usize>,
2747 matched_record_count: i64,
2748 updated_record_count: i64,
2749 skipped_record_count: i64,
2750) -> Value {
2751 let source = field_spec
2752 .get("source")
2753 .and_then(Value::as_str)
2754 .unwrap_or_default();
2755 match source {
2756 "next_id" => Value::from(dynamic_take_next_global_id(root)),
2757 "input" => Value::String(
2758 dynamic_action_input_value(
2759 request,
2760 input_overrides,
2761 field_spec
2762 .get("field")
2763 .and_then(Value::as_str)
2764 .unwrap_or_default(),
2765 )
2766 .unwrap_or_default(),
2767 ),
2768 "literal" => field_spec.get("value").cloned().unwrap_or(Value::Null),
2769 "context_anchor_created_id" => Value::from(anchor_record_id),
2770 "list_item" => Value::String(list_item.unwrap_or_default().to_owned()),
2771 "list_index" => Value::from(list_index.unwrap_or(0).saturating_add(1) as i64),
2772 "context_matched_count" => Value::from(matched_record_count.max(0)),
2773 "context_updated_count" => Value::from(updated_record_count.max(0)),
2774 "context_skipped_count" => Value::from(skipped_record_count.max(0)),
2775 "now" => Value::String(now_iso_utc()),
2776 _ => Value::Null,
2777 }
2778}
2779
2780pub fn persist_app_state_text(app: &AppRuntime, state_json: &str) -> i32 {
2805 let Some(path) = app.state_path.as_deref().filter(|value| !value.is_empty()) else {
2806 return 69;
2807 };
2808 if state_json.len() > 16 * 1024 * 1024 || serde_json::from_str::<Value>(state_json).is_err() {
2809 return 64;
2810 }
2811 super::fs::write_text_file(Path::new(path), state_json)
2812}
2813
2814pub fn append_rate_limit_event_runtime_local(
2841 app: &AppRuntime,
2842 policy_key: &str,
2843 bucket_key: &str,
2844 outcome: &str,
2845) -> i32 {
2846 if policy_key.is_empty() || bucket_key.len() > 512 || outcome.len() > 128 {
2847 return 64;
2848 }
2849 let Some(path) = app.state_path.as_deref() else {
2850 return 69;
2851 };
2852 let Ok(mut root) = fs::read_to_string(path)
2853 .ok()
2854 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2855 .ok_or(66)
2856 else {
2857 return 66;
2858 };
2859 let id = root
2860 .get("rate_limit_events")
2861 .and_then(Value::as_array)
2862 .into_iter()
2863 .flatten()
2864 .filter_map(|row| row.get("id").and_then(Value::as_i64))
2865 .max()
2866 .unwrap_or(0)
2867 + 1;
2868 let events = root.as_object_mut().and_then(|object| {
2869 let value = object
2870 .entry("rate_limit_events".to_owned())
2871 .or_insert_with(|| Value::Array(Vec::new()));
2872 if !value.is_array() {
2873 *value = Value::Array(Vec::new());
2874 }
2875 value.as_array_mut()
2876 });
2877 let Some(events) = events else {
2878 return 69;
2879 };
2880 let created_epoch = SystemTime::now()
2881 .duration_since(UNIX_EPOCH)
2882 .map(|value| value.as_secs())
2883 .unwrap_or(0);
2884 events.push(serde_json::json!({"id": id, "policy_key": policy_key, "bucket_key": bucket_key, "outcome": outcome, "created_at": now_iso_utc(), "created_epoch": created_epoch}));
2885 persist_app_state_text(app, &serde_json::to_string(&root).unwrap_or_default())
2886}
2887
2888pub fn admit_rate_limited_request_runtime_local(
2920 app: &AppRuntime,
2921 policy_key: &str,
2922 bucket_key: &str,
2923 maximum: usize,
2924 window_seconds: u64,
2925) -> Result<bool, i32> {
2926 if policy_key.is_empty()
2927 || policy_key.len() > 128
2928 || bucket_key.is_empty()
2929 || bucket_key.len() > 512
2930 || maximum == 0
2931 || maximum > 100_000
2932 || window_seconds == 0
2933 || window_seconds > 86_400
2934 {
2935 return Err(64);
2936 }
2937 let path = app
2938 .state_path
2939 .as_deref()
2940 .filter(|value| !value.is_empty())
2941 .map(Path::new)
2942 .ok_or(69)?;
2943 let mut root = fs::read_to_string(path)
2944 .ok()
2945 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
2946 .filter(Value::is_object)
2947 .ok_or(66)?;
2948 let now = SystemTime::now()
2949 .duration_since(UNIX_EPOCH)
2950 .map_err(|_| 70)?
2951 .as_secs();
2952 let cutoff = now.saturating_sub(window_seconds);
2953 let events = root
2954 .as_object_mut()
2955 .ok_or(66)?
2956 .entry("rate_limit_events".to_owned())
2957 .or_insert_with(|| Value::Array(Vec::new()));
2958 if !events.is_array() {
2959 *events = Value::Array(Vec::new());
2960 }
2961 let events = events.as_array_mut().ok_or(66)?;
2962 events.retain(|event| {
2963 event
2964 .get("created_epoch")
2965 .and_then(Value::as_u64)
2966 .is_some_and(|created| created >= cutoff)
2967 });
2968 let observed = events
2969 .iter()
2970 .filter(|event| {
2971 event.get("policy_key").and_then(Value::as_str) == Some(policy_key)
2972 && event.get("bucket_key").and_then(Value::as_str) == Some(bucket_key)
2973 && event
2974 .get("created_epoch")
2975 .and_then(Value::as_u64)
2976 .is_some_and(|created| created >= cutoff)
2977 })
2978 .count();
2979 if observed >= maximum {
2980 return Ok(false);
2981 }
2982 if events.len() >= app.resource_budgets.max_state_section_items {
2983 return Err(69);
2984 }
2985 let next_id = events
2986 .iter()
2987 .filter_map(|event| event.get("id").and_then(Value::as_i64))
2988 .max()
2989 .unwrap_or(0)
2990 .saturating_add(1);
2991 events.push(serde_json::json!({
2992 "id": next_id,
2993 "policy_key": policy_key,
2994 "bucket_key": bucket_key,
2995 "outcome": "admitted",
2996 "created_at": now_iso_utc(),
2997 "created_epoch": now,
2998 }));
2999 let encoded = serde_json::to_string(&root).map_err(|_| 70)?;
3000 (super::fs::write_text_file(path, &encoded) == 0)
3001 .then_some(true)
3002 .ok_or(69)
3003}
3004
3005pub fn update_notification_delivery_status(
3032 app: &AppRuntime,
3033 delivery_id: i64,
3034 status: &str,
3035 delivered_at: Option<&str>,
3036) -> i32 {
3037 if delivery_id <= 0 || status.is_empty() || status.len() > 64 {
3038 return 64;
3039 }
3040 let Some(path) = app.state_path.as_deref() else {
3041 return 69;
3042 };
3043 let Ok(mut root) = fs::read_to_string(path)
3044 .ok()
3045 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
3046 .ok_or(66)
3047 else {
3048 return 66;
3049 };
3050 let Some(row) = root
3051 .get_mut("notification_deliveries")
3052 .and_then(Value::as_array_mut)
3053 .and_then(|rows| {
3054 rows.iter_mut()
3055 .find(|row| row.get("id").and_then(Value::as_i64) == Some(delivery_id))
3056 })
3057 else {
3058 return 66;
3059 };
3060 let Some(object) = row.as_object_mut() else {
3061 return 69;
3062 };
3063 object.insert(
3064 "delivery_status".to_owned(),
3065 Value::String(status.to_owned()),
3066 );
3067 object.insert("updated_at".to_owned(), Value::String(now_iso_utc()));
3068 if let Some(delivered_at) = delivered_at {
3069 object.insert(
3070 "delivered_at".to_owned(),
3071 Value::String(delivered_at.to_owned()),
3072 );
3073 }
3074 persist_app_state_text(app, &serde_json::to_string(&root).unwrap_or_default())
3075}
3076
3077pub fn write_json_error(status: u16, error_type: &str, message: &str) -> String {
3099 let code = pharos_runtime_core::error::ErrorCode::from_http_status(status);
3100 let mut envelope = pharos_runtime_core::error::envelope(code, message, None);
3101 if let Some(error) = envelope.get_mut("error").and_then(Value::as_object_mut) {
3102 error.insert("context".to_owned(), Value::String(error_type.to_owned()));
3103 }
3104 let body = serde_json::to_string(&envelope).unwrap_or_else(|_| {
3105 "{\"schema\":\"pharos.error.v1\",\"error\":{\"code\":\"internal_failure\",\"message\":\"response encoding failed\",\"retryable\":false,\"correlation_id\":\"\"}}".to_owned()
3106 });
3107 format!(
3108 "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}",
3109 super::http::http_status_text(status),
3110 body.len(),
3111 body
3112 )
3113}
3114
3115pub fn write_hx_fragment_response_with_csrf(
3137 app: &AppRuntime,
3138 request: Option<&HttpRequest>,
3139 fragment_html: &str,
3140) -> String {
3141 let scoped = json_commands::template_apply_base_path(fragment_html, app.base_path.as_deref())
3142 .unwrap_or_default();
3143 let token = cookie_value(
3144 request.and_then(|request| request.cookie.as_deref()),
3145 "pharos_csrf",
3146 )
3147 .unwrap_or_default();
3148 format!(
3149 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nSet-Cookie: pharos_csrf={}; Path=/; SameSite=Lax\r\nConnection: close\r\n\r\n{}",
3150 scoped.len(),
3151 token,
3152 scoped
3153 )
3154}
3155
3156pub fn render_declared_page_response_with_headers(
3187 app_root: &Path,
3188 page_id: &str,
3189 body_params_json: &str,
3190 extra_headers: Option<&str>,
3191) -> Result<String, i32> {
3192 let page = json_commands::template_declared_page_contract(app_root, page_id).ok_or(404)?;
3193 let page_spec: Value = serde_json::from_str(&page).map_err(|_| 500)?;
3194 let template_path = page_spec
3195 .get("template_path")
3196 .and_then(Value::as_str)
3197 .ok_or(500)?;
3198 let params: Value = serde_json::from_str(body_params_json).map_err(|_| 400)?;
3199 let html = json_commands::render_file_template(Path::new(template_path), ¶ms).ok_or(500)?;
3200 let headers = extra_headers.unwrap_or("").replace(['\r', '\n'], " ");
3201 Ok(format!(
3202 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n{}\r\nConnection: close\r\n\r\n{}",
3203 html.len(),
3204 headers,
3205 html
3206 ))
3207}
3208
3209pub fn render_declared_page_response(
3235 app_root: &Path,
3236 page_id: &str,
3237 body_params_json: &str,
3238) -> Result<String, i32> {
3239 render_declared_page_response_with_headers(app_root, page_id, body_params_json, None)
3240}
3241
3242pub fn resolve_declared_page_params_json(page_id: &str, request_path: &str) -> String {
3263 serde_json::to_string(&serde_json::json!({"page_id": page_id, "request_path": request_path, "error_html": "", "next_input": ""})).unwrap_or_else(|_| "{}".to_owned())
3264}
3265
3266pub fn handle_register_account_action(
3295 state_path: &Path,
3296 request: &HttpRequest,
3297) -> Result<String, i32> {
3298 let username = request_form_value(request, "username").ok_or(422)?;
3299 let email = request_form_value(request, "email").ok_or(422)?;
3300 let password = request_form_value(request, "password").ok_or(422)?;
3301 let confirmation = request_form_value(request, "password_confirm").unwrap_or_default();
3302 if username.is_empty()
3303 || !json_commands::generated_surface_text_is_email(&email)
3304 || password.len() < 8
3305 || password != confirmation
3306 {
3307 return Err(422);
3308 }
3309 let mut root: Value =
3310 serde_json::from_str(&fs::read_to_string(state_path).map_err(|_| 66)?).map_err(|_| 66)?;
3311 let users = root
3312 .as_object_mut()
3313 .ok_or(66)?
3314 .entry("users".to_owned())
3315 .or_insert_with(|| Value::Array(Vec::new()));
3316 let rows = users.as_array_mut().ok_or(69)?;
3317 if rows.iter().any(|row| {
3318 row.get("username").and_then(Value::as_str) == Some(username.as_str())
3319 || row
3320 .get("email")
3321 .and_then(Value::as_str)
3322 .is_some_and(|value| value.eq_ignore_ascii_case(&email))
3323 }) {
3324 return Err(409);
3325 }
3326 let id = rows
3327 .iter()
3328 .filter_map(|row| row.get("id").and_then(Value::as_i64))
3329 .max()
3330 .unwrap_or(0)
3331 + 1;
3332 let password_hash = hash_password(&password)?;
3333 rows.push(serde_json::json!({"id": id, "username": username, "email": email, "password_hash": password_hash, "is_active": true, "email_verified": false}));
3334 super::fs::write_text_file(state_path, &serde_json::to_string(&root).map_err(|_| 75)?)
3335 .eq(&0)
3336 .then_some(format!("{{\"user_id\":{id}}}"))
3337 .ok_or(69)
3338}
3339
3340pub fn handle_verify_email_action(state_path: &Path, token_hash: &str, now: &str) -> i32 {
3366 let Ok(mut root) = fs::read_to_string(state_path)
3367 .ok()
3368 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
3369 .ok_or(66)
3370 else {
3371 return 66;
3372 };
3373 let Some(reset) = root
3374 .get("email_verifications")
3375 .and_then(Value::as_array)
3376 .and_then(|rows| {
3377 rows.iter().find(|row| {
3378 row.get("token_hash").and_then(Value::as_str) == Some(token_hash)
3379 && row
3380 .get("claimed_at")
3381 .is_none_or(|value| value.is_null() || value.as_str() == Some(""))
3382 && row
3383 .get("expires_at")
3384 .and_then(Value::as_str)
3385 .is_some_and(|value| value > now)
3386 })
3387 })
3388 .cloned()
3389 else {
3390 return 404;
3391 };
3392 let user_id = reset.get("user_id").and_then(Value::as_i64).unwrap_or(0);
3393 let Some(object) = root.as_object_mut() else {
3394 return 66;
3395 };
3396 if let Some(row) = object
3397 .get_mut("email_verifications")
3398 .and_then(Value::as_array_mut)
3399 .and_then(|rows| {
3400 rows.iter_mut()
3401 .find(|row| row.get("token_hash").and_then(Value::as_str) == Some(token_hash))
3402 })
3403 {
3404 row.as_object_mut()
3405 .map(|row| row.insert("claimed_at".to_owned(), Value::String(now.to_owned())));
3406 }
3407 if let Some(row) = object
3408 .get_mut("users")
3409 .and_then(Value::as_array_mut)
3410 .and_then(|rows| {
3411 rows.iter_mut()
3412 .find(|row| row.get("id").and_then(Value::as_i64) == Some(user_id))
3413 })
3414 {
3415 row.as_object_mut()
3416 .map(|row| row.insert("email_verified".to_owned(), Value::Bool(true)));
3417 }
3418 super::fs::write_text_file(
3419 state_path,
3420 &serde_json::to_string(&root).unwrap_or_default(),
3421 )
3422}
3423
3424pub fn handle_password_reset_request_action(
3449 _state_path: &Path,
3450 email: &str,
3451) -> Result<String, i32> {
3452 if !json_commands::generated_surface_text_is_email(email) {
3453 return Err(422);
3454 }
3455 random_secret_hex(32)
3456}
3457
3458pub fn handle_password_reset_complete_action(
3485 state_path: &Path,
3486 presented_token: &str,
3487 password: &str,
3488 now: &str,
3489) -> i32 {
3490 if password.len() < 8 || presented_token.is_empty() {
3491 return 422;
3492 }
3493 let token_hash = hex_digest(&Sha256::digest(presented_token.as_bytes()));
3494 let Ok(mut root) = fs::read_to_string(state_path)
3495 .ok()
3496 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
3497 .ok_or(66)
3498 else {
3499 return 66;
3500 };
3501 let user_id = root
3502 .get("password_resets")
3503 .and_then(Value::as_array)
3504 .and_then(|rows| {
3505 rows.iter().find(|row| {
3506 row.get("token_hash").and_then(Value::as_str) == Some(token_hash.as_str())
3507 && row
3508 .get("claimed_at")
3509 .is_none_or(|value| value.is_null() || value.as_str() == Some(""))
3510 && row
3511 .get("expires_at")
3512 .and_then(Value::as_str)
3513 .is_some_and(|value| value > now)
3514 })
3515 })
3516 .and_then(|row| row.get("user_id"))
3517 .and_then(Value::as_i64)
3518 .unwrap_or(0);
3519 if user_id <= 0 {
3520 return 404;
3521 }
3522 let Ok(password_hash) = hash_password(password) else {
3523 return 69;
3524 };
3525 if let Some(reset) = root
3526 .get_mut("password_resets")
3527 .and_then(Value::as_array_mut)
3528 .and_then(|rows| {
3529 rows.iter_mut().find(|row| {
3530 row.get("token_hash").and_then(Value::as_str) == Some(token_hash.as_str())
3531 })
3532 })
3533 {
3534 reset["claimed_at"] = Value::String(now.to_owned());
3535 }
3536 if let Some(row) = root
3537 .get_mut("users")
3538 .and_then(Value::as_array_mut)
3539 .and_then(|rows| {
3540 rows.iter_mut()
3541 .find(|row| row.get("id").and_then(Value::as_i64) == Some(user_id))
3542 })
3543 {
3544 row.as_object_mut()
3545 .map(|row| row.insert("password_hash".to_owned(), Value::String(password_hash)));
3546 } else {
3547 return 404;
3548 }
3549 if let Some(rows) = root.get_mut("sessions").and_then(Value::as_array_mut) {
3550 for row in rows {
3551 if row.get("user_id").and_then(Value::as_i64) == Some(user_id)
3552 && row
3553 .get("revoked_at")
3554 .is_none_or(|value| value.is_null() || value.as_str() == Some(""))
3555 && let Some(object) = row.as_object_mut()
3556 {
3557 object.insert("revoked_at".to_owned(), Value::String(now.to_owned()));
3558 }
3559 }
3560 }
3561 super::fs::write_text_file(
3562 state_path,
3563 &serde_json::to_string(&root).unwrap_or_default(),
3564 )
3565}
3566
3567pub fn resource_numeric_id_jobs_logic_local(resource_id: &str) -> i64 {
3591 resource_id
3592 .rsplit([':', '/', '-'])
3593 .next()
3594 .and_then(|value| value.parse::<i64>().ok())
3595 .filter(|value| *value > 0)
3596 .unwrap_or(0)
3597}
3598
3599pub fn dynamic_job_cancel_report_json(app: &AppRuntime, job_id: i64) -> Option<String> {
3620 (job_id > 0).then(|| serde_json::json!({"report_id":"pharos-job-cancel-v1","app_id":app.app_id,"job_id":job_id,"status":"cancel_requested"}).to_string())
3621}
3622
3623pub fn dynamic_job_retry_report_json(app: &AppRuntime, job_id: i64) -> Option<String> {
3644 (job_id > 0).then(|| serde_json::json!({"report_id":"pharos-job-retry-v1","app_id":app.app_id,"job_id":job_id,"status":"retry_requested"}).to_string())
3645}
3646
3647pub(super) fn dynamic_contract_doc(app: &AppRuntime, contract_key: &str) -> Option<Value> {
3649 let app_root = Path::new(app.app_root.as_deref()?);
3650 let manifest_path = if app_root.join("pharos.app.json").is_file() {
3651 app_root.join("pharos.app.json")
3652 } else {
3653 app_root.join("pharos.app.yml")
3654 };
3655 let manifest = json_commands::json_doc_load_file(&manifest_path)?;
3656 let relative = manifest
3657 .get("contracts")
3658 .and_then(Value::as_object)?
3659 .get(contract_key)
3660 .and_then(Value::as_str)?;
3661 if relative.is_empty() || relative.contains("..") || Path::new(relative).is_absolute() {
3662 return None;
3663 }
3664 json_commands::json_doc_load_file(&app_root.join(relative))
3665}
3666
3667pub fn integration_contract_doc(app: &AppRuntime) -> Option<Value> {
3687 dynamic_contract_doc(app, "integration_contract")
3688}
3689
3690fn dynamic_find_action_contract<'a>(actions_root: &'a Value, action_id: &str) -> Option<&'a Value> {
3692 actions_root
3693 .get("actions")
3694 .and_then(Value::as_array)?
3695 .iter()
3696 .find(|action| action.get("action_id").and_then(Value::as_str) == Some(action_id))
3697}
3698
3699fn dynamic_find_workflow_transition<'a>(
3701 workflow_root: &'a Value,
3702 action_id: &str,
3703) -> Option<&'a Value> {
3704 workflow_root
3705 .get("workflows")
3706 .and_then(Value::as_array)?
3707 .iter()
3708 .find_map(|workflow| {
3709 workflow
3710 .get("transitions")
3711 .and_then(Value::as_array)?
3712 .iter()
3713 .find(|transition| {
3714 transition.get("action_id").and_then(Value::as_str) == Some(action_id)
3715 })
3716 })
3717}
3718
3719fn dynamic_state_rows_mut<'a>(root: &'a mut Value, section: &str) -> Option<&'a mut Vec<Value>> {
3721 root.as_object_mut()?
3722 .entry(section.to_owned())
3723 .or_insert_with(|| Value::Array(Vec::new()))
3724 .as_array_mut()
3725}
3726
3727fn dynamic_take_next_global_id(root: &mut Value) -> i64 {
3729 let next = root
3730 .get("next_id")
3731 .and_then(Value::as_i64)
3732 .filter(|value| *value > 0)
3733 .unwrap_or(1);
3734 if let Some(object) = root.as_object_mut() {
3735 object.insert("next_id".to_owned(), Value::from(next.saturating_add(1)));
3736 }
3737 next
3738}
3739
3740fn dynamic_take_next_section_id(root: &mut Value, section: &str) -> i64 {
3742 root.get(section)
3743 .and_then(Value::as_array)
3744 .into_iter()
3745 .flatten()
3746 .filter_map(|row| row.get("id").and_then(Value::as_i64))
3747 .max()
3748 .unwrap_or(0)
3749 .saturating_add(1)
3750}
3751
3752fn dynamic_presentation_text(node: &Value, key: &str) -> String {
3754 node.get("presentation")
3755 .and_then(Value::as_object)
3756 .and_then(|presentation| presentation.get(key))
3757 .and_then(Value::as_str)
3758 .unwrap_or_default()
3759 .to_owned()
3760}
3761
3762fn dynamic_transition_presentation_text(node: Option<&Value>, key: &str) -> String {
3764 node.and_then(|transition| transition.get("presentation"))
3765 .and_then(Value::as_object)
3766 .and_then(|presentation| presentation.get(key))
3767 .and_then(Value::as_str)
3768 .unwrap_or_default()
3769 .to_owned()
3770}
3771
3772fn dynamic_resolve_ref_target(app: &AppRuntime, ref_text: &str) -> Option<Value> {
3774 let app_root = Path::new(app.app_root.as_deref()?);
3775 super::declarative::declarative_source_load_ref_target(app_root, ref_text)
3776}
3777
3778fn dynamic_url_encode_component(value: &str) -> String {
3780 const HEX: &[u8; 16] = b"0123456789ABCDEF";
3781 let mut encoded = String::with_capacity(value.len());
3782 for byte in value.bytes() {
3783 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
3784 encoded.push(char::from(byte));
3785 } else {
3786 encoded.push('%');
3787 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
3788 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
3789 }
3790 }
3791 encoded
3792}
3793
3794fn dynamic_webhook_payload_json(adapter_id: &str, action_id: &str, resource_id: &str) -> String {
3796 serde_json::json!({
3797 "adapter_id": adapter_id,
3798 "action_id": action_id,
3799 "resource_id": resource_id,
3800 })
3801 .to_string()
3802}
3803
3804fn dynamic_http_form_payload(payload_json: &str) -> Option<String> {
3806 let payload = serde_json::from_str::<Value>(payload_json).ok()?;
3807 let object = payload.as_object()?;
3808 let mut encoded = String::new();
3809 for (index, (key, value)) in object.iter().enumerate() {
3810 if key.is_empty() {
3811 return None;
3812 }
3813 let value_text = match value {
3814 Value::Null => String::new(),
3815 Value::Bool(boolean) => boolean.to_string(),
3816 Value::Number(number) => number.to_string(),
3817 Value::String(text) => text.clone(),
3818 _ => return None,
3819 };
3820 if index > 0 {
3821 encoded.push('&');
3822 }
3823 encoded.push_str(&dynamic_url_encode_component(key));
3824 encoded.push('=');
3825 encoded.push_str(&dynamic_url_encode_component(&value_text));
3826 }
3827 Some(encoded)
3828}
3829
3830fn dynamic_delivery_payload_body(adapter_kind: &str, payload_json: &str) -> Option<String> {
3832 match adapter_kind {
3833 "webhook" | "http_json" => Some(payload_json.to_owned()),
3834 "http_form" => dynamic_http_form_payload(payload_json),
3835 _ => None,
3836 }
3837}
3838
3839fn dynamic_http_post_status(
3841 url: &str,
3842 content_type: &str,
3843 payload_body: &str,
3844 extra_headers: &[(&str, String)],
3845 timeout_seconds: u64,
3846 max_response_bytes: usize,
3847 allow_plaintext_local: bool,
3848) -> Option<u16> {
3849 let timeout = std::time::Duration::from_secs(timeout_seconds.clamp(1, 300));
3850 pharos_runtime_integrations::webhook::post_status(
3851 url,
3852 content_type,
3853 payload_body,
3854 extra_headers,
3855 pharos_runtime_integrations::webhook::WebhookLimits {
3856 connect_timeout: timeout.min(std::time::Duration::from_secs(30)),
3857 operation_timeout: timeout,
3858 max_response_bytes: max_response_bytes.clamp(64, 16 * 1024 * 1024),
3859 allow_plaintext_local,
3860 },
3861 )
3862}
3863
3864fn dynamic_append_workflow_execution(
3866 root: &mut Value,
3867 action_id: &str,
3868 transition: Option<&Value>,
3869) -> Option<i64> {
3870 let execution_id = dynamic_take_next_section_id(root, "workflow_executions");
3871 let workflow_id = transition
3872 .and_then(|value| value.get("action_id"))
3873 .and_then(Value::as_str)
3874 .unwrap_or(action_id)
3875 .to_owned();
3876 dynamic_state_rows_mut(root, "workflow_executions")?.push(serde_json::json!({
3877 "id": execution_id,
3878 "workflow_id": workflow_id,
3879 "transition_id": transition.and_then(|value| value.get("transition_id")).and_then(Value::as_str).unwrap_or_default(),
3880 "from_state": dynamic_transition_presentation_text(transition, "from_label"),
3881 "to_state": transition.and_then(|value| value.get("to_state")).and_then(Value::as_str).unwrap_or_default(),
3882 "execution_status": "applied",
3883 "queued_job_count": transition.and_then(|value| value.get("scheduled_jobs")).and_then(Value::as_array).map(|rows| rows.len()).unwrap_or(0),
3884 "created_at": now_iso_utc(),
3885 "completed_at": now_iso_utc(),
3886 }));
3887 Some(execution_id)
3888}
3889
3890fn dynamic_append_workflow_events(
3892 root: &mut Value,
3893 workflow_execution_id: i64,
3894 transition: Option<&Value>,
3895) -> bool {
3896 let first_id = dynamic_take_next_section_id(root, "workflow_execution_events");
3897 let second_id = first_id.saturating_add(1);
3898 let Some(rows) = dynamic_state_rows_mut(root, "workflow_execution_events") else {
3899 return false;
3900 };
3901 rows.push(serde_json::json!({
3902 "id": first_id,
3903 "workflow_execution_id": workflow_execution_id,
3904 "event_kind": "action_applied",
3905 "event_key": transition.and_then(|value| value.get("action_id")).and_then(Value::as_str).unwrap_or_default(),
3906 "created_at": now_iso_utc(),
3907 }));
3908 rows.push(serde_json::json!({
3909 "id": second_id,
3910 "workflow_execution_id": workflow_execution_id,
3911 "event_kind": transition.and_then(|value| value.get("emits_event")).and_then(Value::as_str).unwrap_or("action_event"),
3912 "event_key": transition.and_then(|value| value.get("transition_id")).and_then(Value::as_str).unwrap_or_default(),
3913 "created_at": now_iso_utc(),
3914 }));
3915 true
3916}
3917
3918fn dynamic_append_workflow_execution_event(
3920 root: &mut Value,
3921 workflow_execution_id: i64,
3922 event_kind: &str,
3923 event_key: &str,
3924 payload: Value,
3925) -> bool {
3926 if workflow_execution_id <= 0
3927 || event_kind.is_empty()
3928 || event_kind.len() > 128
3929 || event_key.len() > 128
3930 {
3931 return false;
3932 }
3933 let event_id = dynamic_take_next_section_id(root, "workflow_execution_events");
3934 let Some(rows) = dynamic_state_rows_mut(root, "workflow_execution_events") else {
3935 return false;
3936 };
3937 rows.push(serde_json::json!({
3938 "id": event_id,
3939 "workflow_execution_id": workflow_execution_id,
3940 "event_kind": event_kind,
3941 "event_key": event_key,
3942 "payload_json": payload.to_string(),
3943 "created_at": now_iso_utc(),
3944 }));
3945 true
3946}
3947
3948fn dynamic_append_scheduled_job(
3950 root: &mut Value,
3951 workflow_execution_id: i64,
3952 job_id: &str,
3953 queue_name: &str,
3954 delay_seconds: i64,
3955 max_attempts: i64,
3956 backoff_seconds: i64,
3957 lease_seconds: i64,
3958) -> bool {
3959 if job_id.is_empty() || queue_name.is_empty() || queue_name.len() > 128 {
3960 return false;
3961 }
3962 let now_epoch = current_epoch_seconds();
3963 let scheduled_epoch = now_epoch.saturating_add(delay_seconds.max(0) as u64);
3964 let execution_id = dynamic_take_next_section_id(root, "job_executions");
3965 let Some(job_rows) = dynamic_state_rows_mut(root, "job_executions") else {
3966 return false;
3967 };
3968 job_rows.push(serde_json::json!({
3969 "id": execution_id,
3970 "workflow_execution_id": workflow_execution_id,
3971 "queue_name": queue_name,
3972 "job_id": job_id,
3973 "handler_id": job_id,
3974 "execution_status": "queued",
3975 "attempt_count": 0,
3976 "max_attempts": max_attempts.clamp(1, 100),
3977 "backoff_seconds": backoff_seconds.max(0),
3978 "lease_seconds": lease_seconds.clamp(1, 3600),
3979 "created_at": now_epoch.to_string(),
3980 "scheduled_for": scheduled_epoch.to_string(),
3981 "available_at": scheduled_epoch.to_string(),
3982 "idempotency_key": format!("{workflow_execution_id}:{job_id}"),
3983 }));
3984 let _ = dynamic_append_workflow_execution_event(
3985 root,
3986 workflow_execution_id,
3987 "job_queued",
3988 job_id,
3989 serde_json::json!({
3990 "job_id": job_id,
3991 "queue_name": queue_name,
3992 "delay_seconds": delay_seconds.max(0),
3993 "available_at": scheduled_epoch.to_string(),
3994 }),
3995 );
3996 true
3997}
3998
3999fn dynamic_append_notification(
4001 root: &mut Value,
4002 integrations_root: &Value,
4003 adapter_id: &str,
4004 channel: &str,
4005 recipient: &str,
4006 template_id: &str,
4007 template_params: &Value,
4008) -> bool {
4009 let adapter = integrations_root
4010 .get("adapters")
4011 .and_then(Value::as_array)
4012 .into_iter()
4013 .flatten()
4014 .find(|row| row.get("adapter_id").and_then(Value::as_str) == Some(adapter_id));
4015 let adapter_kind = adapter
4016 .and_then(|row| row.get("adapter_kind").and_then(Value::as_str))
4017 .unwrap_or_default();
4018 if adapter_id.is_empty()
4019 || recipient.is_empty()
4020 || template_id.is_empty()
4021 || !template_params.is_object()
4022 || !(adapter_kind == "notification_local" || adapter_kind == "notification_email")
4023 {
4024 return false;
4025 }
4026 let notification_id = dynamic_take_next_section_id(root, "notification_deliveries");
4027 let Some(rows) = dynamic_state_rows_mut(root, "notification_deliveries") else {
4028 return false;
4029 };
4030 rows.push(serde_json::json!({
4031 "id": notification_id,
4032 "adapter_id": adapter_id,
4033 "adapter_kind": adapter_kind,
4034 "delivery_kind": "action_notification",
4035 "channel": if channel.is_empty() { "email" } else { channel },
4036 "recipient": recipient,
4037 "template_id": template_id,
4038 "template_params": template_params,
4039 "delivery_status": "queued",
4040 "attempt_count": 0,
4041 "max_attempts": adapter.and_then(|row| row.get("max_attempts").and_then(Value::as_i64)).unwrap_or(if adapter_kind == "notification_email" { 2 } else { 1 }).clamp(1, 100),
4042 "backoff_seconds": adapter.and_then(|row| row.get("backoff_seconds").and_then(Value::as_i64)).unwrap_or(0).max(0),
4043 "relay": adapter.and_then(|row| row.get("relay").and_then(Value::as_str)).unwrap_or("primary"),
4044 "last_smtp_code": 0,
4045 "last_error": "",
4046 "next_attempt_at": "",
4047 "created_at": now_iso_utc(),
4048 "updated_at": now_iso_utc(),
4049 "completed_at": "",
4050 }));
4051 true
4052}
4053
4054fn dynamic_integration_http_adapter_kind(adapter_kind: &str) -> bool {
4056 matches!(adapter_kind, "webhook" | "http_json" | "http_form")
4057}
4058
4059fn dynamic_integration_audit_adapter_kind(adapter_kind: &str) -> bool {
4061 adapter_kind == "audit_local"
4062}
4063
4064fn dynamic_integration_file_adapter_kind(adapter_kind: &str) -> bool {
4066 adapter_kind == "append_jsonl_local" || adapter_kind == "write_json_local"
4067}
4068
4069fn dynamic_delete_records_by_scope_primitive(
4071 root: &mut Value,
4072 request: &HttpRequest,
4073 input_overrides: &BTreeMap<String, Option<String>>,
4074 primitive: &Value,
4075 matched_record_count: &mut i64,
4076 skipped_record_count: &mut i64,
4077 removed_record_count: &mut i64,
4078) -> Result<(), i32> {
4079 let collection = primitive
4080 .get("collection")
4081 .and_then(Value::as_str)
4082 .ok_or(422)?;
4083 let scope_field = primitive
4084 .get("scope_match")
4085 .and_then(Value::as_object)
4086 .and_then(|map| map.get("field"))
4087 .and_then(Value::as_str)
4088 .ok_or(422)?;
4089 let scope_input = primitive
4090 .get("scope_match")
4091 .and_then(Value::as_object)
4092 .and_then(|map| map.get("from_input"))
4093 .and_then(Value::as_str)
4094 .ok_or(422)?;
4095 let wanted =
4096 dynamic_action_input_value(request, input_overrides, scope_input).unwrap_or_default();
4097 let max_records = primitive
4098 .get("max_records")
4099 .and_then(Value::as_i64)
4100 .unwrap_or(i64::MAX)
4101 .max(0) as usize;
4102 let overflow_policy = primitive
4103 .get("overflow_policy")
4104 .and_then(Value::as_str)
4105 .unwrap_or("truncate");
4106 let Some(rows) = dynamic_state_rows_mut(root, collection) else {
4107 return Err(500);
4108 };
4109 let matching_indexes = rows
4110 .iter()
4111 .enumerate()
4112 .filter(|(_, row)| row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted)
4113 .map(|(index, _)| index)
4114 .collect::<Vec<_>>();
4115 *matched_record_count += matching_indexes.len() as i64;
4116 if matching_indexes.len() > max_records && overflow_policy == "fail" {
4117 return Err(422);
4118 }
4119 *skipped_record_count += matching_indexes.len().saturating_sub(max_records) as i64;
4120 let mut removed = 0usize;
4121 let mut keep = Vec::with_capacity(rows.len());
4122 for (index, row) in rows.iter().cloned().enumerate() {
4123 let selected = matching_indexes
4124 .iter()
4125 .take(max_records)
4126 .any(|candidate| *candidate == index);
4127 if selected {
4128 removed += 1;
4129 } else {
4130 keep.push(row);
4131 }
4132 }
4133 *rows = keep;
4134 *removed_record_count += removed as i64;
4135 Ok(())
4136}
4137
4138fn dynamic_find_integration_adapter<'a>(
4140 integrations_root: &'a Value,
4141 adapter_id: &str,
4142) -> Option<&'a Value> {
4143 integrations_root
4144 .get("adapters")
4145 .and_then(Value::as_array)?
4146 .iter()
4147 .find(|row| row.get("adapter_id").and_then(Value::as_str) == Some(adapter_id))
4148}
4149
4150fn dynamic_state_rows<'a>(root: &'a Value, section: &str) -> &'a [Value] {
4152 root.get(section)
4153 .and_then(Value::as_array)
4154 .map(Vec::as_slice)
4155 .unwrap_or(&[])
4156}
4157
4158fn dynamic_row_i64(row: &Value, field: &str) -> i64 {
4160 row.get(field)
4161 .and_then(Value::as_i64)
4162 .or_else(|| {
4163 row.get(field)
4164 .and_then(Value::as_str)
4165 .and_then(|value| value.parse::<i64>().ok())
4166 })
4167 .unwrap_or(0)
4168}
4169
4170fn dynamic_notification_parameter_value(
4172 spec: &Value,
4173 request: &HttpRequest,
4174 input_overrides: &BTreeMap<String, Option<String>>,
4175 action_id: &str,
4176 entity_id: &str,
4177 event_kind: &str,
4178 recipient: &str,
4179 template_id: &str,
4180 anchor_record_id: i64,
4181) -> Option<Value> {
4182 let source = spec
4183 .get("source")
4184 .and_then(Value::as_str)
4185 .unwrap_or("literal");
4186 match source {
4187 "literal" => spec.get("value").cloned(),
4188 "input" => spec.get("field").and_then(Value::as_str).map(|field| {
4189 Value::String(
4190 dynamic_action_input_value(request, input_overrides, field).unwrap_or_default(),
4191 )
4192 }),
4193 "context_anchor_id" => Some(Value::from(anchor_record_id.max(0))),
4194 "action_id" => Some(Value::String(action_id.to_owned())),
4195 "entity_id" => Some(Value::String(entity_id.to_owned())),
4196 "event_kind" => Some(Value::String(event_kind.to_owned())),
4197 "recipient" => Some(Value::String(recipient.to_owned())),
4198 "template_id" => Some(Value::String(template_id.to_owned())),
4199 _ => None,
4200 }
4201}
4202
4203fn dynamic_notification_template_params(
4205 notification: &Value,
4206 request: &HttpRequest,
4207 input_overrides: &BTreeMap<String, Option<String>>,
4208 action_id: &str,
4209 entity_id: &str,
4210 event_kind: &str,
4211 recipient: &str,
4212 template_id: &str,
4213 anchor_record_id: i64,
4214) -> Value {
4215 let mut params = Map::new();
4216 params.insert("action_id".to_owned(), Value::String(action_id.to_owned()));
4217 params.insert("entity_id".to_owned(), Value::String(entity_id.to_owned()));
4218 params.insert(
4219 "event_kind".to_owned(),
4220 Value::String(event_kind.to_owned()),
4221 );
4222 params.insert("recipient".to_owned(), Value::String(recipient.to_owned()));
4223 params.insert(
4224 "template_id".to_owned(),
4225 Value::String(template_id.to_owned()),
4226 );
4227 params.insert(
4228 "anchor_record_id".to_owned(),
4229 Value::from(anchor_record_id.max(0)),
4230 );
4231 if let Some(explicit) = notification.get("params").and_then(Value::as_object) {
4232 for (key, spec) in explicit {
4233 if key.is_empty() || key.len() > 128 {
4234 continue;
4235 }
4236 if let Some(value) = dynamic_notification_parameter_value(
4237 spec,
4238 request,
4239 input_overrides,
4240 action_id,
4241 entity_id,
4242 event_kind,
4243 recipient,
4244 template_id,
4245 anchor_record_id,
4246 ) {
4247 params.insert(key.to_owned(), value);
4248 }
4249 }
4250 }
4251 Value::Object(params)
4252}
4253
4254fn dynamic_notification_template_id_from_row(row: &Value) -> Option<&str> {
4256 if let Some(template_id) = row.get("template_id").and_then(Value::as_str) {
4257 return (!template_id.is_empty()).then_some(template_id);
4258 }
4259 match row
4260 .get("delivery_kind")
4261 .and_then(Value::as_str)
4262 .unwrap_or_default()
4263 {
4264 "business_invite" => Some("business_invite"),
4265 "password_reset" => Some("password_reset"),
4266 _ => None,
4267 }
4268}
4269
4270fn dynamic_notification_origin(app: &AppRuntime) -> Option<String> {
4272 let app_root = Path::new(app.app_root.as_deref()?);
4273 let runtime_conf = app.runtime_conf_path.as_deref().map(Path::new);
4274 json_commands::dynamic_config_value(app_root, runtime_conf, "base_url")
4275 .and_then(|value| normalized_public_origin(&value))
4276}
4277
4278fn dynamic_notification_absolute_url(app: &AppRuntime, relative_path: &str) -> String {
4280 let scoped_relative = format!(
4281 "{}{}",
4282 app.base_path
4283 .as_deref()
4284 .filter(|value| !value.is_empty() && *value != "/")
4285 .unwrap_or(""),
4286 relative_path
4287 );
4288 dynamic_notification_origin(app)
4289 .map(|origin| format!("{origin}{scoped_relative}"))
4290 .unwrap_or(scoped_relative)
4291}
4292
4293fn dynamic_framework_notification_params(app: &AppRuntime, root: &Value, row: &Value) -> Value {
4295 let mut params = Map::new();
4296 params.insert(
4297 "app_id".to_owned(),
4298 Value::String(app.app_id.clone().unwrap_or_default()),
4299 );
4300 let recipient = row
4301 .get("recipient")
4302 .and_then(Value::as_str)
4303 .unwrap_or_default();
4304 params.insert("recipient".to_owned(), Value::String(recipient.to_owned()));
4305 match row
4306 .get("delivery_kind")
4307 .and_then(Value::as_str)
4308 .unwrap_or_default()
4309 {
4310 "business_invite" => {
4311 let secret = row
4312 .get("secret")
4313 .and_then(Value::as_str)
4314 .unwrap_or_default();
4315 params.insert(
4316 "invite_url".to_owned(),
4317 Value::String(dynamic_notification_absolute_url(
4318 app,
4319 &format!("/accounts/invite/{secret}/"),
4320 )),
4321 );
4322 let token_hash = hex_digest(&Sha256::digest(secret.as_bytes()));
4323 if let Some(invite) =
4324 dynamic_state_rows(root, "business_invites")
4325 .iter()
4326 .find(|invite| {
4327 invite.get("token_hash").and_then(Value::as_str)
4328 == Some(token_hash.as_str())
4329 })
4330 {
4331 let business_id = dynamic_row_i64(invite, "business_id");
4332 let business_name = dynamic_state_rows(root, "businesses")
4333 .iter()
4334 .find(|business| dynamic_row_i64(business, "id") == business_id)
4335 .and_then(|business| business.get("name").and_then(Value::as_str))
4336 .unwrap_or("the business");
4337 let account_type = invite
4338 .get("account_type")
4339 .and_then(Value::as_str)
4340 .map(super::business::dynamic_invite_account_type_label)
4341 .unwrap_or("member");
4342 params.insert(
4343 "business_name".to_owned(),
4344 Value::String(business_name.to_owned()),
4345 );
4346 params.insert(
4347 "account_type".to_owned(),
4348 Value::String(account_type.to_owned()),
4349 );
4350 }
4351 }
4352 "password_reset" => {
4353 let secret = row
4354 .get("secret")
4355 .and_then(Value::as_str)
4356 .unwrap_or_default();
4357 let token_hash = hex_digest(&Sha256::digest(secret.as_bytes()));
4358 let password_reset_user_id = dynamic_state_rows(root, "password_resets")
4359 .iter()
4360 .find(|reset| {
4361 reset.get("token_hash").and_then(Value::as_str) == Some(token_hash.as_str())
4362 })
4363 .map(|reset| dynamic_row_i64(reset, "user_id"))
4364 .unwrap_or(0);
4365 params.insert(
4366 "reset_url".to_owned(),
4367 Value::String(dynamic_notification_absolute_url(
4368 app,
4369 &format!("/accounts/password-reset/complete/{secret}/"),
4370 )),
4371 );
4372 let full_name = dynamic_state_rows(root, "users")
4373 .iter()
4374 .find(|user| {
4375 user.get("email").and_then(Value::as_str) == Some(recipient)
4376 || dynamic_row_i64(user, "id") == password_reset_user_id
4377 })
4378 .and_then(|user| {
4379 user.get("display_name")
4380 .and_then(Value::as_str)
4381 .filter(|value| !value.is_empty())
4382 .or_else(|| user.get("username").and_then(Value::as_str))
4383 })
4384 .unwrap_or("there");
4385 params.insert("full_name".to_owned(), Value::String(full_name.to_owned()));
4386 }
4387 _ => {}
4388 }
4389 Value::Object(params)
4390}
4391
4392fn dynamic_update_notification_delivery(
4394 delivery: &mut Value,
4395 attempt_number: i64,
4396 status: &str,
4397 last_error: &str,
4398 next_attempt_at: Option<u64>,
4399 last_smtp_code: i64,
4400 completed: bool,
4401 rendered_subject: Option<&str>,
4402 rendered_body: Option<&str>,
4403) {
4404 let now = now_iso_utc();
4405 if let Some(object) = delivery.as_object_mut() {
4406 object.insert(
4407 "attempt_count".to_owned(),
4408 Value::from(attempt_number.max(0)),
4409 );
4410 object.insert(
4411 "delivery_status".to_owned(),
4412 Value::String(status.to_owned()),
4413 );
4414 object.insert("updated_at".to_owned(), Value::String(now.clone()));
4415 object.insert(
4416 "last_error".to_owned(),
4417 Value::String(last_error.to_owned()),
4418 );
4419 object.insert(
4420 "last_smtp_code".to_owned(),
4421 Value::from(last_smtp_code.max(0)),
4422 );
4423 object.insert(
4424 "next_attempt_at".to_owned(),
4425 Value::String(
4426 next_attempt_at
4427 .map(|value| value.to_string())
4428 .unwrap_or_default(),
4429 ),
4430 );
4431 if completed {
4432 object.insert("completed_at".to_owned(), Value::String(now));
4433 }
4434 if let Some(subject) = rendered_subject {
4435 object.insert(
4436 "rendered_subject".to_owned(),
4437 Value::String(subject.to_owned()),
4438 );
4439 }
4440 if let Some(body) = rendered_body {
4441 object.insert("rendered_body".to_owned(), Value::String(body.to_owned()));
4442 }
4443 }
4444}
4445
4446pub fn process_one_due_notification_delivery(
4472 app: &AppRuntime,
4473 root: &mut Value,
4474 integrations_root: &Value,
4475) -> bool {
4476 let now = current_epoch_seconds();
4477 let delivery_id = root
4478 .get("notification_deliveries")
4479 .and_then(Value::as_array)
4480 .and_then(|rows| {
4481 rows.iter().find(|row| {
4482 row.get("delivery_status").and_then(Value::as_str) == Some("queued")
4483 || (row.get("delivery_status").and_then(Value::as_str) == Some("retryable")
4484 && row
4485 .get("next_attempt_at")
4486 .and_then(Value::as_str)
4487 .and_then(|value| value.parse::<u64>().ok())
4488 .is_some_and(|available| available <= now))
4489 })
4490 })
4491 .and_then(|row| row.get("id").and_then(Value::as_i64));
4492 let Some(delivery_id) = delivery_id else {
4493 return false;
4494 };
4495 let delivery_snapshot = root
4496 .get("notification_deliveries")
4497 .and_then(Value::as_array)
4498 .and_then(|rows| {
4499 rows.iter()
4500 .find(|row| row.get("id").and_then(Value::as_i64) == Some(delivery_id))
4501 })
4502 .cloned();
4503 let framework_params = delivery_snapshot
4504 .as_ref()
4505 .map(|row| dynamic_framework_notification_params(app, root, row));
4506 let Some(rows) = root
4507 .get_mut("notification_deliveries")
4508 .and_then(Value::as_array_mut)
4509 else {
4510 return false;
4511 };
4512 let Some(delivery) = rows
4513 .iter_mut()
4514 .find(|row| row.get("id").and_then(Value::as_i64) == Some(delivery_id))
4515 else {
4516 return false;
4517 };
4518 let adapter_id = delivery
4519 .get("adapter_id")
4520 .and_then(Value::as_str)
4521 .unwrap_or_default();
4522 let adapter_kind = delivery
4523 .get("adapter_kind")
4524 .and_then(Value::as_str)
4525 .filter(|value| !value.is_empty())
4526 .map(ToOwned::to_owned)
4527 .or_else(|| {
4528 dynamic_find_integration_adapter(integrations_root, adapter_id)
4529 .and_then(|adapter| adapter.get("adapter_kind").and_then(Value::as_str))
4530 .map(ToOwned::to_owned)
4531 })
4532 .unwrap_or_else(|| {
4533 if delivery
4534 .get("delivery_kind")
4535 .and_then(Value::as_str)
4536 .is_some()
4537 {
4538 "notification_email".to_owned()
4539 } else {
4540 "notification_local".to_owned()
4541 }
4542 });
4543 let recipient = delivery
4544 .get("recipient")
4545 .and_then(Value::as_str)
4546 .unwrap_or_default()
4547 .to_owned();
4548 let template_id = dynamic_notification_template_id_from_row(delivery)
4549 .unwrap_or_default()
4550 .to_owned();
4551 let params = delivery
4552 .get("template_params")
4553 .filter(|value| value.is_object())
4554 .cloned()
4555 .or(framework_params)
4556 .unwrap_or_else(|| serde_json::json!({}));
4557 let attempt_number = delivery
4558 .get("attempt_count")
4559 .and_then(Value::as_i64)
4560 .unwrap_or(0)
4561 .saturating_add(1);
4562 let max_attempts = delivery
4563 .get("max_attempts")
4564 .and_then(Value::as_i64)
4565 .unwrap_or(if adapter_kind == "notification_email" {
4566 2
4567 } else {
4568 1
4569 })
4570 .max(1);
4571 let backoff_seconds = delivery
4572 .get("backoff_seconds")
4573 .and_then(Value::as_i64)
4574 .unwrap_or(0)
4575 .max(0) as u64;
4576 if recipient.is_empty() || template_id.is_empty() {
4577 dynamic_update_notification_delivery(
4578 delivery,
4579 attempt_number,
4580 "failed",
4581 "notification delivery is incomplete",
4582 None,
4583 0,
4584 true,
4585 None,
4586 None,
4587 );
4588 return true;
4589 }
4590 let Some(message) =
4591 super::business::load_notification_email_message(app, &recipient, &template_id, ¶ms)
4592 else {
4593 dynamic_update_notification_delivery(
4594 delivery,
4595 attempt_number,
4596 "failed",
4597 "notification template is unavailable",
4598 None,
4599 0,
4600 true,
4601 None,
4602 None,
4603 );
4604 return true;
4605 };
4606 match adapter_kind.as_str() {
4607 "notification_local" => {
4608 dynamic_update_notification_delivery(
4609 delivery,
4610 attempt_number,
4611 "succeeded",
4612 "",
4613 None,
4614 0,
4615 true,
4616 Some(&message.subject),
4617 Some(&message.text_body),
4618 );
4619 true
4620 }
4621 "notification_email" => {
4622 let relay_prefix = if delivery.get("relay").and_then(Value::as_str) == Some("secondary")
4623 {
4624 Some("smtp2_")
4625 } else {
4626 Some("smtp_")
4627 };
4628 let config = super::business::email_load_relay_config(
4629 Path::new(app.app_root.as_deref().unwrap_or_default()),
4630 app.runtime_conf_path.as_deref().map(Path::new),
4631 relay_prefix,
4632 );
4633 let Ok(config) = config else {
4634 dynamic_update_notification_delivery(
4635 delivery,
4636 attempt_number,
4637 "skipped",
4638 "smtp configuration unavailable",
4639 None,
4640 0,
4641 true,
4642 Some(&message.subject),
4643 Some(&message.text_body),
4644 );
4645 return true;
4646 };
4647 let mut result = super::business::EmailResult::default();
4648 let _ = super::business::email_send_text(&config, &message, &mut result);
4649 if result.delivered {
4650 dynamic_update_notification_delivery(
4651 delivery,
4652 attempt_number,
4653 "succeeded",
4654 "",
4655 None,
4656 result.smtp_code,
4657 true,
4658 Some(&message.subject),
4659 Some(&message.text_body),
4660 );
4661 } else if result.retryable && attempt_number < max_attempts {
4662 dynamic_update_notification_delivery(
4663 delivery,
4664 attempt_number,
4665 "retryable",
4666 result
4667 .error_message
4668 .as_deref()
4669 .unwrap_or("smtp delivery failed"),
4670 Some(now.saturating_add(backoff_seconds.max(1))),
4671 result.smtp_code,
4672 false,
4673 Some(&message.subject),
4674 Some(&message.text_body),
4675 );
4676 } else {
4677 let status = if result.skipped { "skipped" } else { "failed" };
4678 dynamic_update_notification_delivery(
4679 delivery,
4680 attempt_number,
4681 status,
4682 result
4683 .error_message
4684 .as_deref()
4685 .unwrap_or("smtp delivery failed"),
4686 None,
4687 result.smtp_code,
4688 true,
4689 Some(&message.subject),
4690 Some(&message.text_body),
4691 );
4692 }
4693 true
4694 }
4695 _ => {
4696 dynamic_update_notification_delivery(
4697 delivery,
4698 attempt_number,
4699 "failed",
4700 "unsupported notification adapter kind",
4701 None,
4702 0,
4703 true,
4704 None,
4705 None,
4706 );
4707 true
4708 }
4709 }
4710}
4711
4712fn dynamic_append_webhook_delivery(
4714 root: &mut Value,
4715 integrations_root: &Value,
4716 adapter_id: &str,
4717 action_id: &str,
4718 resource_id: &str,
4719) -> Option<()> {
4720 let adapter = integrations_root
4721 .get("adapters")
4722 .and_then(Value::as_array)?
4723 .iter()
4724 .find(|row| row.get("adapter_id").and_then(Value::as_str) == Some(adapter_id))?;
4725 let adapter_kind = adapter
4726 .get("adapter_kind")
4727 .and_then(Value::as_str)
4728 .unwrap_or_default();
4729 if !dynamic_integration_http_adapter_kind(adapter_kind) {
4730 return None;
4731 }
4732 let target_url = adapter
4733 .get("target_url")
4734 .and_then(Value::as_str)
4735 .filter(|value| !value.is_empty())
4736 .map(ToOwned::to_owned)
4737 .or_else(|| {
4738 adapter
4739 .get("target_url_env")
4740 .and_then(Value::as_str)
4741 .and_then(|name| env::var(name).ok())
4742 .filter(|value| !value.is_empty())
4743 })
4744 .unwrap_or_default();
4745 let content_type = adapter
4746 .get("content_type")
4747 .and_then(Value::as_str)
4748 .unwrap_or("application/json");
4749 let payload_json = dynamic_webhook_payload_json(adapter_id, action_id, resource_id);
4750 let delivery_id = dynamic_take_next_section_id(root, "integration_deliveries");
4751 dynamic_state_rows_mut(root, "integration_deliveries")?.push(serde_json::json!({
4752 "id": delivery_id,
4753 "adapter_id": adapter_id,
4754 "adapter_kind": adapter_kind,
4755 "event_kind": action_id,
4756 "resource_id": resource_id,
4757 "subject_id": "",
4758 "tenant_id": "",
4759 "target_url": target_url,
4760 "content_type": content_type,
4761 "payload_json": payload_json,
4762 "event_header": adapter.get("event_header").and_then(Value::as_str).unwrap_or_default(),
4763 "auth_header": adapter.get("auth_header").and_then(Value::as_str).unwrap_or_default(),
4764 "auth_scheme": adapter.get("auth_scheme").and_then(Value::as_str).unwrap_or_default(),
4765 "auth_token_env": adapter.get("auth_token_env").and_then(Value::as_str).unwrap_or_default(),
4766 "delivery_status": "queued",
4767 "attempt_count": 0,
4768 "max_attempts": adapter.get("max_attempts").and_then(Value::as_i64).unwrap_or(1),
4769 "backoff_seconds": adapter.get("backoff_seconds").and_then(Value::as_i64).unwrap_or(0),
4770 "timeout_seconds": adapter.get("timeout_seconds").and_then(Value::as_i64).unwrap_or(0),
4771 "max_response_bytes": adapter.get("max_response_bytes").and_then(Value::as_i64).unwrap_or(8192),
4772 "allow_plaintext_local": adapter.get("allow_plaintext_local").and_then(Value::as_bool).unwrap_or(false),
4773 "last_http_status": 0,
4774 "last_error": "",
4775 "next_attempt_at": "",
4776 "completed_at": "",
4777 "created_at": now_iso_utc(),
4778 "updated_at": now_iso_utc(),
4779 }));
4780 Some(())
4781}
4782
4783fn dynamic_process_webhook_delivery(root: &mut Value, delivery_id: i64) -> bool {
4785 let Some(delivery) = dynamic_state_rows_mut(root, "integration_deliveries").and_then(|rows| {
4786 rows.iter_mut()
4787 .find(|row| row.get("id").and_then(Value::as_i64) == Some(delivery_id))
4788 }) else {
4789 return false;
4790 };
4791 let target_url = delivery
4792 .get("target_url")
4793 .and_then(Value::as_str)
4794 .unwrap_or_default()
4795 .to_owned();
4796 let content_type = delivery
4797 .get("content_type")
4798 .and_then(Value::as_str)
4799 .unwrap_or("application/json")
4800 .to_owned();
4801 let event_kind = delivery
4802 .get("event_kind")
4803 .and_then(Value::as_str)
4804 .unwrap_or_default()
4805 .to_owned();
4806 let adapter_kind = delivery
4807 .get("adapter_kind")
4808 .and_then(Value::as_str)
4809 .unwrap_or("webhook")
4810 .to_owned();
4811 let payload_json = delivery
4812 .get("payload_json")
4813 .map(|payload| {
4814 payload.as_str().map(ToOwned::to_owned).unwrap_or_else(|| {
4815 serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_owned())
4816 })
4817 })
4818 .unwrap_or_else(|| "{}".to_owned());
4819 let attempt_number = delivery
4820 .get("attempt_count")
4821 .and_then(Value::as_i64)
4822 .unwrap_or(0)
4823 .saturating_add(1);
4824 let max_attempts = delivery
4825 .get("max_attempts")
4826 .and_then(Value::as_i64)
4827 .unwrap_or(1)
4828 .max(1);
4829 let backoff_seconds = delivery
4830 .get("backoff_seconds")
4831 .and_then(Value::as_i64)
4832 .unwrap_or(0)
4833 .max(0);
4834 let now = now_iso_utc();
4835 let timestamp = now_iso_utc();
4836 let delivery_id_text = delivery_id.to_string();
4837 let signature = delivery
4838 .get("signature_secret_env")
4839 .and_then(Value::as_str)
4840 .and_then(|secret_env| {
4841 webhook_signature_header_value_dup_logic_local(secret_env, ×tamp, &payload_json)
4842 });
4843 let signature_header = delivery
4844 .get("signature_header")
4845 .and_then(Value::as_str)
4846 .unwrap_or("X-Pharos-Signature")
4847 .to_owned();
4848 let timestamp_header = delivery
4849 .get("timestamp_header")
4850 .and_then(Value::as_str)
4851 .unwrap_or("X-Pharos-Timestamp")
4852 .to_owned();
4853 let mut headers = vec![
4854 ("X-Pharos-Delivery-Id", delivery_id_text),
4855 (timestamp_header.as_str(), timestamp.clone()),
4856 ];
4857 let event_header = delivery
4858 .get("event_header")
4859 .and_then(Value::as_str)
4860 .unwrap_or_default()
4861 .to_owned();
4862 if !event_header.is_empty() && !event_kind.is_empty() {
4863 headers.push((event_header.as_str(), event_kind.clone()));
4864 }
4865 let auth_header = delivery
4866 .get("auth_header")
4867 .and_then(Value::as_str)
4868 .filter(|value| !value.is_empty())
4869 .unwrap_or("Authorization")
4870 .to_owned();
4871 let auth_token_env = delivery
4872 .get("auth_token_env")
4873 .and_then(Value::as_str)
4874 .unwrap_or_default()
4875 .to_owned();
4876 let auth_scheme = delivery
4877 .get("auth_scheme")
4878 .and_then(Value::as_str)
4879 .unwrap_or("Bearer")
4880 .to_owned();
4881 let auth_required = !auth_token_env.is_empty();
4882 let auth_header_value = (!auth_token_env.is_empty())
4883 .then(|| env::var(&auth_token_env).ok())
4884 .flatten()
4885 .filter(|value| !value.is_empty())
4886 .map(|token| {
4887 if auth_scheme.is_empty() {
4888 token
4889 } else {
4890 format!("{auth_scheme} {token}")
4891 }
4892 });
4893 if let Some(auth_value) = auth_header_value.as_ref() {
4894 headers.push((auth_header.as_str(), auth_value.clone()));
4895 }
4896 if let Some(signature_value) = signature.as_ref() {
4897 headers.push((signature_header.as_str(), signature_value.clone()));
4898 }
4899 let signature_required = delivery
4900 .get("signature_secret_env")
4901 .and_then(Value::as_str)
4902 .is_some_and(|value| !value.is_empty());
4903 let payload_body = dynamic_delivery_payload_body(&adapter_kind, &payload_json);
4904 let delivery_error = if !dynamic_integration_http_adapter_kind(&adapter_kind) {
4905 "unsupported integration adapter kind"
4906 } else if payload_body.is_none() {
4907 "integration adapter payload is incompatible"
4908 } else if auth_required && auth_header_value.is_none() {
4909 "integration adapter auth unavailable"
4910 } else if signature_required && signature.is_none() {
4911 "webhook signature secret unavailable"
4912 } else {
4913 ""
4914 };
4915 let status_code = if !delivery_error.is_empty() {
4916 None
4917 } else {
4918 dynamic_http_post_status(
4919 &target_url,
4920 &content_type,
4921 payload_body.as_deref().unwrap_or_default(),
4922 &headers,
4923 delivery
4924 .get("timeout_seconds")
4925 .and_then(Value::as_u64)
4926 .unwrap_or(15),
4927 delivery
4928 .get("max_response_bytes")
4929 .and_then(Value::as_u64)
4930 .unwrap_or(8 * 1024) as usize,
4931 delivery
4932 .get("allow_plaintext_local")
4933 .and_then(Value::as_bool)
4934 .unwrap_or(false),
4935 )
4936 };
4937 let success = status_code.is_some_and(|status| (200..300).contains(&status));
4938 if let Some(object) = delivery.as_object_mut() {
4939 object.insert("attempt_count".to_owned(), Value::from(attempt_number));
4940 object.insert("updated_at".to_owned(), Value::String(now.clone()));
4941 object.insert(
4942 "last_http_status".to_owned(),
4943 Value::from(status_code.unwrap_or(0) as i64),
4944 );
4945 if success {
4946 object.insert(
4947 "delivery_status".to_owned(),
4948 Value::String("succeeded".to_owned()),
4949 );
4950 object.insert("completed_at".to_owned(), Value::String(now.clone()));
4951 object.insert("next_attempt_at".to_owned(), Value::String(String::new()));
4952 object.insert("last_error".to_owned(), Value::String(String::new()));
4953 } else if attempt_number < max_attempts {
4954 object.insert(
4955 "delivery_status".to_owned(),
4956 Value::String("retryable".to_owned()),
4957 );
4958 object.insert(
4959 "next_attempt_at".to_owned(),
4960 Value::String((now.parse::<i64>().unwrap_or(0) + backoff_seconds).to_string()),
4961 );
4962 object.insert(
4963 "last_error".to_owned(),
4964 Value::String(if delivery_error.is_empty() {
4965 "outbound webhook transport unavailable".to_owned()
4966 } else {
4967 delivery_error.to_owned()
4968 }),
4969 );
4970 } else {
4971 object.insert(
4972 "delivery_status".to_owned(),
4973 Value::String("failed".to_owned()),
4974 );
4975 object.insert("next_attempt_at".to_owned(), Value::String(String::new()));
4976 object.insert(
4977 "last_error".to_owned(),
4978 Value::String(if delivery_error.is_empty() {
4979 "outbound webhook transport unavailable".to_owned()
4980 } else {
4981 delivery_error.to_owned()
4982 }),
4983 );
4984 }
4985 }
4986 let attempt_id = dynamic_take_next_section_id(root, "integration_delivery_attempts");
4987 if let Some(rows) = dynamic_state_rows_mut(root, "integration_delivery_attempts") {
4988 rows.push(serde_json::json!({
4989 "id": attempt_id,
4990 "delivery_id": delivery_id,
4991 "attempt_status": if success { "succeeded" } else { "failed" },
4992 "http_status": status_code.unwrap_or(0),
4993 "error_message": if success {
4994 ""
4995 } else if delivery_error.is_empty() {
4996 "outbound webhook transport unavailable"
4997 } else {
4998 delivery_error
4999 },
5000 "started_at": now,
5001 "finished_at": now_iso_utc(),
5002 }));
5003 }
5004 success
5005}
5006
5007pub fn deliver_integration_webhook(
5038 root: &mut Value,
5039 integrations_root: &Value,
5040 adapter_id: &str,
5041 event_kind: Option<&str>,
5042 resource_id: Option<&str>,
5043 tenant_id: Option<&str>,
5044 subject_id: Option<&str>,
5045 payload_json: &str,
5046) -> Option<i64> {
5047 if adapter_id.is_empty()
5048 || payload_json.len() > 256 * 1024
5049 || !serde_json::from_str::<Value>(payload_json)
5050 .ok()?
5051 .is_object()
5052 {
5053 return None;
5054 }
5055 let adapter = integrations_root
5056 .get("adapters")
5057 .and_then(Value::as_array)?
5058 .iter()
5059 .find(|row| row.get("adapter_id").and_then(Value::as_str) == Some(adapter_id))?;
5060 let adapter_kind = adapter
5061 .get("adapter_kind")
5062 .and_then(Value::as_str)
5063 .unwrap_or_default();
5064 if !dynamic_integration_http_adapter_kind(adapter_kind) {
5065 return None;
5066 }
5067 let target_url = adapter
5068 .get("target_url")
5069 .and_then(Value::as_str)
5070 .filter(|value| !value.is_empty())
5071 .map(ToOwned::to_owned)
5072 .or_else(|| {
5073 adapter
5074 .get("target_url_env")
5075 .and_then(Value::as_str)
5076 .and_then(|name| env::var(name).ok())
5077 .filter(|value| !value.is_empty())
5078 })?;
5079 let signature = adapter.get("signature").and_then(Value::as_object);
5080 let delivery_id = dynamic_take_next_section_id(root, "integration_deliveries");
5081 dynamic_state_rows_mut(root, "integration_deliveries")?.push(serde_json::json!({
5082 "id": delivery_id,
5083 "adapter_id": adapter_id,
5084 "adapter_kind": adapter_kind,
5085 "event_kind": event_kind.filter(|value| !value.is_empty()).unwrap_or("integration_event"),
5086 "resource_id": resource_id.unwrap_or_default(),
5087 "tenant_id": tenant_id.unwrap_or_default(),
5088 "subject_id": subject_id.unwrap_or_default(),
5089 "target_url": target_url,
5090 "content_type": adapter.get("content_type").and_then(Value::as_str).unwrap_or("application/json"),
5091 "payload_json": payload_json,
5092 "event_header": adapter.get("event_header").and_then(Value::as_str).unwrap_or_default(),
5093 "auth_header": adapter.get("auth_header").and_then(Value::as_str).unwrap_or_default(),
5094 "auth_scheme": adapter.get("auth_scheme").and_then(Value::as_str).unwrap_or_default(),
5095 "auth_token_env": adapter.get("auth_token_env").and_then(Value::as_str).unwrap_or_default(),
5096 "signature_header": signature.and_then(|value| value.get("header")).and_then(Value::as_str).unwrap_or("X-Pharos-Signature"),
5097 "timestamp_header": signature.and_then(|value| value.get("timestamp_header")).and_then(Value::as_str).unwrap_or("X-Pharos-Timestamp"),
5098 "signature_secret_env": signature.and_then(|value| value.get("secret_env")).and_then(Value::as_str).unwrap_or_default(),
5099 "delivery_status": "queued",
5100 "attempt_count": 0,
5101 "max_attempts": adapter.get("max_attempts").and_then(Value::as_i64).unwrap_or(1).max(1),
5102 "backoff_seconds": adapter.get("backoff_seconds").and_then(Value::as_i64).unwrap_or(0).max(0),
5103 "timeout_seconds": adapter.get("timeout_seconds").and_then(Value::as_i64).unwrap_or(0).max(0),
5104 "max_response_bytes": adapter.get("max_response_bytes").and_then(Value::as_u64).unwrap_or(8 * 1024).clamp(64, 16 * 1024 * 1024),
5105 "allow_plaintext_local": adapter.get("allow_plaintext_local").and_then(Value::as_bool).unwrap_or(false),
5106 "last_http_status": 0,
5107 "last_error": "",
5108 "next_attempt_at": "",
5109 "completed_at": "",
5110 "created_at": now_iso_utc(),
5111 "updated_at": now_iso_utc(),
5112 }));
5113 Some(delivery_id)
5114}
5115
5116pub fn process_one_due_integration_delivery(root: &mut Value) -> bool {
5140 let now = current_epoch_seconds();
5141 let delivery_id = root
5142 .get("integration_deliveries")
5143 .and_then(Value::as_array)
5144 .and_then(|rows| {
5145 rows.iter().find(|row| {
5146 row.get("delivery_status").and_then(Value::as_str) == Some("queued")
5147 || (row.get("delivery_status").and_then(Value::as_str) == Some("retryable")
5148 && row
5149 .get("next_attempt_at")
5150 .and_then(Value::as_str)
5151 .and_then(|value| value.parse::<u64>().ok())
5152 .is_some_and(|available| available <= now))
5153 })
5154 })
5155 .and_then(|row| row.get("id").and_then(Value::as_i64));
5156 delivery_id.is_some_and(|delivery_id| {
5157 let _ = dynamic_process_webhook_delivery(root, delivery_id);
5158 true
5159 })
5160}
5161
5162fn dynamic_store_last_action_report(root: &mut Value, mut report: Value) -> bool {
5164 let report_id = dynamic_take_next_section_id(root, "action_reports");
5165 if let Some(object) = report.as_object_mut() {
5166 object.insert("id".to_owned(), Value::from(report_id));
5167 }
5168 let Some(object) = root.as_object_mut() else {
5169 return false;
5170 };
5171 object.insert("last_action_report".to_owned(), report.clone());
5172 object.insert(
5173 "last_action".to_owned(),
5174 report
5175 .get("event_kind")
5176 .cloned()
5177 .unwrap_or(Value::String(String::new())),
5178 );
5179 dynamic_state_rows_mut(root, "action_reports").is_some_and(|rows| {
5180 rows.push(report);
5181 true
5182 })
5183}
5184
5185fn dynamic_action_failure_report_root(
5187 mut root: Value,
5188 action: &Value,
5189 request: &HttpRequest,
5190 input_overrides: &BTreeMap<String, Option<String>>,
5191 action_id: &str,
5192 entity_id: &str,
5193 transition: Option<&Value>,
5194 anchor_record_id: i64,
5195 status: i32,
5196) -> Value {
5197 let event_kind = format!("{action_id}_failed");
5198 let mut report = serde_json::json!({
5199 "action_id": action_id,
5200 "event_kind": event_kind,
5201 "execution_status": "failed",
5202 "failure_status": status,
5203 "failure_code": dynamic_action_failure_code(status),
5204 "created_record_count": 0,
5205 "updated_record_count": 0,
5206 "matched_record_count": 0,
5207 "skipped_record_count": 0,
5208 "removed_record_count": 0,
5209 "queued_notification_count": 0,
5210 "queued_webhook_count": 0,
5211 "emitted_audit_count": 0,
5212 "emitted_file_record_count": 0,
5213 "delivered_webhook_count": 0,
5214 "retryable_webhook_count": 0,
5215 "failed_webhook_count": 0,
5216 "retried_delivery_count": 0,
5217 "anchor_record_id": anchor_record_id,
5218 "operator_message": dynamic_presentation_text(action, "failure_message"),
5219 "operator_summary": dynamic_presentation_text(action, "operator_summary"),
5220 "transition_label": dynamic_transition_presentation_text(transition, "transition_label"),
5221 "to_state": transition.and_then(|value| value.get("to_state")).and_then(Value::as_str).unwrap_or_default(),
5222 "overflow_policy": action
5223 .get("mutation_primitives")
5224 .and_then(Value::as_array)
5225 .and_then(|rows| rows.iter().find_map(|row| row.get("overflow_policy").and_then(Value::as_str)))
5226 .unwrap_or_default(),
5227 });
5228 let annotations = dynamic_action_failure_annotations(
5229 action,
5230 request,
5231 input_overrides,
5232 action_id,
5233 entity_id,
5234 &event_kind,
5235 anchor_record_id,
5236 )
5237 .unwrap_or_default();
5238 if let Some(object) = report.as_object_mut()
5239 && !annotations.is_empty()
5240 {
5241 object.insert("annotations".to_owned(), Value::Object(annotations));
5242 }
5243 let _ = dynamic_store_last_action_report(&mut root, report);
5244 root
5245}
5246
5247pub enum DeclarativeActionResponse {
5249 Redirect(String),
5250 Json(u16, Value),
5251}
5252
5253pub fn dynamic_execute_declarative_action(
5284 app: &AppRuntime,
5285 state_path: Option<&Path>,
5286 request: &HttpRequest,
5287 action_id: &str,
5288) -> Result<DeclarativeActionResponse, i32> {
5289 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
5290 for attempt in 0..32 {
5291 let mut rejected_write = false;
5292 let result = dynamic_execute_declarative_action_attempt(
5293 app,
5294 state_path,
5295 request,
5296 action_id,
5297 &mut rejected_write,
5298 );
5299 if !rejected_write || attempt == 31 || std::time::Instant::now() >= deadline {
5300 return result;
5301 }
5302 std::thread::sleep(std::time::Duration::from_millis(25 + attempt * 5));
5305 }
5306 Err(409)
5307}
5308
5309fn dynamic_execute_declarative_action_attempt(
5311 app: &AppRuntime,
5312 state_path: Option<&Path>,
5313 request: &HttpRequest,
5314 action_id: &str,
5315 rejected_write: &mut bool,
5316) -> Result<DeclarativeActionResponse, i32> {
5317 if action_id.is_empty() || app.app_id.as_deref().is_none_or(str::is_empty) {
5318 return Err(400);
5319 }
5320 let actions_root = dynamic_contract_doc(app, "action_contract").ok_or(404)?;
5321 let workflows_root = dynamic_contract_doc(app, "workflow_contract").ok_or(404)?;
5322 let integrations_root = dynamic_contract_doc(app, "integration_contract")
5323 .unwrap_or_else(|| serde_json::json!({"adapters": []}));
5324 let action = dynamic_find_action_contract(&actions_root, action_id).ok_or(404)?;
5325 let input_overrides = dynamic_action_input_overrides(action, request)?;
5326 let response = action.get("response_contract").ok_or(422)?;
5327 let response_kind = response
5328 .get("response_kind")
5329 .and_then(Value::as_str)
5330 .unwrap_or("redirect");
5331 if !matches!(response_kind, "redirect" | "json") {
5332 return Err(422);
5333 }
5334 let response_status = if response_kind == "json" {
5335 response
5336 .get("status")
5337 .and_then(Value::as_u64)
5338 .and_then(|status| u16::try_from(status).ok())
5339 .filter(|status| (200..=299).contains(status))
5340 .ok_or(422)?
5341 } else {
5342 303
5343 };
5344
5345 let transition = dynamic_find_workflow_transition(&workflows_root, action_id);
5346 let compiled_effects = app
5347 .application_ir
5348 .as_ref()
5349 .and_then(|ir| {
5350 ir.actions
5351 .values()
5352 .find(|candidate| candidate.id.as_str() == action_id)
5353 })
5354 .map(|action| &action.effects)
5355 .ok_or(403)?;
5356 let executable_effects =
5357 pharos_runtime_core::application_ir::compile_action_effects_for_runtime(action, transition)
5358 .map_err(|_| 422)?;
5359 if compiled_effects != &executable_effects {
5360 return Err(403);
5361 }
5362 let document_store = if super::app_uses_file_backed_runtime_state(app) {
5363 None
5364 } else {
5365 let store = super::database::runtime_logical_app_store(app).map_err(|_| 503)?;
5366 let (entity, field, identity) = declared_document_storage(app).ok_or(422)?;
5367 let snapshot = pharos_runtime_database::document::read_document(
5368 store.adapter.as_ref(),
5369 &store.config,
5370 &store.schema,
5371 &entity,
5372 identity,
5373 &field,
5374 )
5375 .map_err(|error| match error {
5376 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
5377 pharos_runtime_database::operations::DatabaseError::Conflict => 409,
5378 _ => 503,
5379 })?;
5380 Some((store, snapshot))
5381 };
5382 let mut root = match &document_store {
5383 Some((_, snapshot)) => snapshot.value.clone(),
5384 None => fs::read_to_string(state_path.ok_or(503)?)
5385 .ok()
5386 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
5387 .ok_or(404)?,
5388 };
5389 let original_root = root.clone();
5390 let result = (|| {
5391 let retried_delivery_count = 0;
5392 let failed_retried_delivery_count = 0;
5393 let mut created_record_count = 0_i64;
5394 let mut updated_record_count = 0_i64;
5395 let mut matched_record_count = 0_i64;
5396 let mut skipped_record_count = 0_i64;
5397 let mut removed_record_count = 0_i64;
5398 let mut anchor_record_id = 0_i64;
5399 for primitive in action
5400 .get("mutation_primitives")
5401 .and_then(Value::as_array)
5402 .into_iter()
5403 .flatten()
5404 {
5405 let primitive_kind = primitive
5406 .get("primitive_kind")
5407 .and_then(Value::as_str)
5408 .unwrap_or_default();
5409 match primitive_kind {
5410 "append_record" => {
5411 dynamic_append_record_primitive(
5412 &mut root,
5413 request,
5414 &input_overrides,
5415 primitive,
5416 &mut anchor_record_id,
5417 &mut created_record_count,
5418 )?;
5419 }
5420 "set_root_fields" => {
5421 dynamic_set_root_fields_primitive(
5422 app,
5423 &mut root,
5424 request,
5425 &input_overrides,
5426 primitive,
5427 )?;
5428 }
5429 "update_record" => {
5430 dynamic_update_record_primitive(
5431 &mut root,
5432 request,
5433 &input_overrides,
5434 primitive,
5435 &mut matched_record_count,
5436 &mut updated_record_count,
5437 )?;
5438 }
5439 "approval_transition" => {
5440 let collection = primitive
5441 .get("collection")
5442 .and_then(Value::as_str)
5443 .unwrap_or("approval_requests");
5444 let match_field = primitive
5445 .get("match")
5446 .and_then(Value::as_object)
5447 .and_then(|map| map.get("field"))
5448 .and_then(Value::as_str)
5449 .unwrap_or("id");
5450 let match_input = primitive
5451 .get("match")
5452 .and_then(Value::as_object)
5453 .and_then(|map| map.get("from_input"))
5454 .and_then(Value::as_str)
5455 .unwrap_or("id");
5456 let wanted = dynamic_action_input_value(request, &input_overrides, match_input)
5457 .unwrap_or_default();
5458 let transition_kind = primitive
5459 .get("transition_kind")
5460 .and_then(Value::as_str)
5461 .unwrap_or_default();
5462 let Some(row) =
5463 dynamic_state_rows_mut(&mut root, collection).and_then(|rows| {
5464 rows.iter_mut().find(|row| {
5465 row.get(match_field).map(value_to_text).unwrap_or_default()
5466 == wanted
5467 })
5468 })
5469 else {
5470 return Err(404);
5471 };
5472 let Some(object) = row.as_object_mut() else {
5473 return Err(500);
5474 };
5475 if transition_kind.is_empty() {
5476 return Err(422);
5477 }
5478 dynamic_apply_approval_transition(
5479 object,
5480 primitive,
5481 request,
5482 &input_overrides,
5483 )?;
5484 updated_record_count += 1;
5485 matched_record_count += 1;
5486 }
5487 "reservation_transition" => {
5488 let collection = primitive
5489 .get("collection")
5490 .and_then(Value::as_str)
5491 .ok_or(422)?;
5492 let scope_field = primitive
5493 .get("scope_match")
5494 .and_then(Value::as_object)
5495 .and_then(|map| map.get("field"))
5496 .and_then(Value::as_str)
5497 .ok_or(422)?;
5498 let scope_input = primitive
5499 .get("scope_match")
5500 .and_then(Value::as_object)
5501 .and_then(|map| map.get("from_input"))
5502 .and_then(Value::as_str)
5503 .ok_or(422)?;
5504 let wanted = dynamic_action_input_value(request, &input_overrides, scope_input)
5505 .unwrap_or_default();
5506 let reservation_state_field = primitive
5507 .get("reservation_state_field")
5508 .and_then(Value::as_str)
5509 .filter(|value| !value.is_empty())
5510 .unwrap_or("reservation_state");
5511 let reserved_by_field = primitive
5512 .get("reserved_by_field")
5513 .and_then(Value::as_str)
5514 .filter(|value| !value.is_empty())
5515 .unwrap_or("reserved_by");
5516 let reservation_key_field = primitive
5517 .get("reservation_key_field")
5518 .and_then(Value::as_str)
5519 .filter(|value| !value.is_empty())
5520 .unwrap_or("reservation_key");
5521 let reserved_at_field = primitive
5522 .get("reserved_at_field")
5523 .and_then(Value::as_str)
5524 .filter(|value| !value.is_empty())
5525 .unwrap_or("reserved_at");
5526 let released_at_field = primitive
5527 .get("released_at_field")
5528 .and_then(Value::as_str)
5529 .filter(|value| !value.is_empty())
5530 .unwrap_or("released_at");
5531 let released_by_field = primitive
5532 .get("released_by_field")
5533 .and_then(Value::as_str)
5534 .filter(|value| !value.is_empty());
5535 let transition_kind = primitive
5536 .get("transition_kind")
5537 .and_then(Value::as_str)
5538 .unwrap_or_default();
5539 let max_records = primitive
5540 .get("max_records")
5541 .and_then(Value::as_i64)
5542 .unwrap_or(i64::MAX)
5543 .max(0) as usize;
5544 let overflow_policy = primitive
5545 .get("overflow_policy")
5546 .and_then(Value::as_str)
5547 .unwrap_or("truncate");
5548 let actor_value = primitive
5549 .get("actor_input")
5550 .and_then(Value::as_str)
5551 .and_then(|field| {
5552 dynamic_action_input_value(request, &input_overrides, field)
5553 })
5554 .unwrap_or_default();
5555 let reservation_key = primitive
5556 .get("key_input")
5557 .and_then(Value::as_str)
5558 .and_then(|field| {
5559 dynamic_action_input_value(request, &input_overrides, field)
5560 })
5561 .unwrap_or_default();
5562 let released_state = primitive
5563 .get("released_state")
5564 .and_then(Value::as_str)
5565 .filter(|value| !value.is_empty())
5566 .unwrap_or("available");
5567 let Some(rows) = dynamic_state_rows_mut(&mut root, collection) else {
5568 return Err(500);
5569 };
5570 let matching_indexes = rows
5571 .iter()
5572 .enumerate()
5573 .filter(|(_, row)| {
5574 row.get(scope_field).map(value_to_text).unwrap_or_default() == wanted
5575 })
5576 .map(|(index, _)| index)
5577 .collect::<Vec<_>>();
5578 matched_record_count += matching_indexes.len() as i64;
5579 if matching_indexes.len() > max_records && overflow_policy == "fail" {
5580 return Err(422);
5581 }
5582 skipped_record_count +=
5583 matching_indexes.len().saturating_sub(max_records) as i64;
5584 if transition_kind == "reserve" && actor_value.trim().is_empty() {
5585 return Err(422);
5586 }
5587 let now = now_iso_utc();
5588 for index in matching_indexes.into_iter().take(max_records) {
5589 let Some(object) = rows.get_mut(index).and_then(Value::as_object_mut)
5590 else {
5591 return Err(500);
5592 };
5593 let current_state = object
5594 .get(reservation_state_field)
5595 .and_then(Value::as_str)
5596 .unwrap_or_default();
5597 let current_key = object
5598 .get(reservation_key_field)
5599 .map(value_to_text)
5600 .unwrap_or_default();
5601 match transition_kind {
5602 "reserve" => {
5603 if current_state == "reserved"
5604 && !current_key.is_empty()
5605 && current_key != reservation_key
5606 {
5607 return Err(409);
5608 }
5609 object.insert(
5610 reservation_state_field.to_owned(),
5611 Value::String("reserved".to_owned()),
5612 );
5613 object.insert(
5614 reserved_by_field.to_owned(),
5615 Value::String(actor_value.clone()),
5616 );
5617 object.insert(
5618 reservation_key_field.to_owned(),
5619 Value::String(reservation_key.clone()),
5620 );
5621 object.insert(
5622 reserved_at_field.to_owned(),
5623 Value::String(now.clone()),
5624 );
5625 object.insert(
5626 released_at_field.to_owned(),
5627 Value::String(String::new()),
5628 );
5629 if let Some(field) = released_by_field {
5630 object.insert(field.to_owned(), Value::String(String::new()));
5631 }
5632 }
5633 "release" => {
5634 if current_state == "reserved"
5635 && !reservation_key.is_empty()
5636 && !current_key.is_empty()
5637 && current_key != reservation_key
5638 {
5639 return Err(409);
5640 }
5641 object.insert(
5642 reservation_state_field.to_owned(),
5643 Value::String(released_state.to_owned()),
5644 );
5645 object.insert(
5646 reserved_by_field.to_owned(),
5647 Value::String(String::new()),
5648 );
5649 object.insert(
5650 reservation_key_field.to_owned(),
5651 Value::String(String::new()),
5652 );
5653 object.insert(
5654 released_at_field.to_owned(),
5655 Value::String(now.clone()),
5656 );
5657 if let Some(field) = released_by_field {
5658 object.insert(
5659 field.to_owned(),
5660 Value::String(actor_value.clone()),
5661 );
5662 }
5663 }
5664 _ => return Err(422),
5665 }
5666 updated_record_count += 1;
5667 }
5668 }
5669 "scoped_status_transition" => {
5670 dynamic_apply_scoped_status_transition_primitive(
5671 &mut root,
5672 request,
5673 &input_overrides,
5674 primitive,
5675 &mut matched_record_count,
5676 &mut updated_record_count,
5677 &mut skipped_record_count,
5678 )?;
5679 }
5680 "append_records_from_input_list" => {
5681 dynamic_append_records_from_input_list_primitive(
5682 &mut root,
5683 request,
5684 &input_overrides,
5685 primitive,
5686 anchor_record_id,
5687 matched_record_count,
5688 updated_record_count,
5689 &mut skipped_record_count,
5690 &mut created_record_count,
5691 )?;
5692 }
5693 "replace_records_from_input_list" => {
5694 dynamic_replace_records_from_input_list_primitive(
5695 &mut root,
5696 request,
5697 &input_overrides,
5698 primitive,
5699 &mut matched_record_count,
5700 &mut created_record_count,
5701 &mut updated_record_count,
5702 &mut skipped_record_count,
5703 &mut removed_record_count,
5704 )?;
5705 }
5706 "delete_records_by_scope" => {
5707 dynamic_delete_records_by_scope_primitive(
5708 &mut root,
5709 request,
5710 &input_overrides,
5711 primitive,
5712 &mut matched_record_count,
5713 &mut skipped_record_count,
5714 &mut removed_record_count,
5715 )?;
5716 }
5717 "append_records_from_related_collection" => {
5718 dynamic_append_records_from_related_collection_primitive(
5719 &mut root,
5720 request,
5721 &input_overrides,
5722 primitive,
5723 &mut matched_record_count,
5724 &mut skipped_record_count,
5725 &mut created_record_count,
5726 )?;
5727 }
5728 "scoped_reassignment" | "bulk_update" => {
5729 dynamic_apply_scoped_assignment_or_bulk_update_primitive(
5730 &mut root,
5731 request,
5732 &input_overrides,
5733 primitive,
5734 primitive_kind,
5735 &mut matched_record_count,
5736 &mut updated_record_count,
5737 &mut skipped_record_count,
5738 )?;
5739 }
5740 "upsert_record" => {
5741 dynamic_upsert_record_primitive(
5742 &mut root,
5743 request,
5744 &input_overrides,
5745 primitive,
5746 anchor_record_id,
5747 &mut matched_record_count,
5748 &mut updated_record_count,
5749 skipped_record_count,
5750 &mut created_record_count,
5751 )?;
5752 }
5753 _ => return Err(422),
5754 }
5755 }
5756 let (
5757 event_kind,
5758 queued_notification_count,
5759 queued_webhook_count,
5760 emitted_audit_count,
5761 queued_file_record_count,
5762 failed_effect_count,
5763 ) = prepare_action_effects(
5764 app,
5765 &mut root,
5766 action,
5767 transition,
5768 &integrations_root,
5769 request,
5770 &input_overrides,
5771 action_id,
5772 anchor_record_id,
5773 )?;
5774 let entity_id = action
5775 .get("entity_id")
5776 .and_then(Value::as_str)
5777 .unwrap_or("record");
5778 let delivered_webhook_count = 0_i64;
5779 let retryable_webhook_count = 0_i64;
5780 let failed_webhook_count = failed_retried_delivery_count + failed_effect_count;
5781 let report = serde_json::json!({
5782 "action_id": action_id,
5783 "event_kind": event_kind,
5784 "created_record_count": created_record_count,
5785 "updated_record_count": updated_record_count,
5786 "matched_record_count": matched_record_count,
5787 "skipped_record_count": skipped_record_count,
5788 "removed_record_count": removed_record_count,
5789 "queued_notification_count": queued_notification_count,
5790 "queued_webhook_count": queued_webhook_count,
5791 "emitted_audit_count": emitted_audit_count,
5792 "emitted_file_record_count": 0,
5793 "queued_file_record_count": queued_file_record_count,
5794 "delivered_webhook_count": delivered_webhook_count,
5795 "retryable_webhook_count": retryable_webhook_count,
5796 "failed_webhook_count": failed_webhook_count,
5797 "retried_delivery_count": retried_delivery_count,
5798 "anchor_record_id": anchor_record_id,
5799 "operator_message": dynamic_presentation_text(action, "success_message"),
5800 "operator_summary": dynamic_presentation_text(action, "operator_summary"),
5801 "transition_label": dynamic_transition_presentation_text(transition, "transition_label"),
5802 "to_state": transition.and_then(|value| value.get("to_state")).and_then(Value::as_str).unwrap_or_default(),
5803 "overflow_policy": action
5804 .get("mutation_primitives")
5805 .and_then(Value::as_array)
5806 .and_then(|rows| rows.iter().find_map(|row| row.get("overflow_policy").and_then(Value::as_str)))
5807 .unwrap_or_default(),
5808 });
5809 let report_annotations = dynamic_action_report_annotations(
5810 action,
5811 request,
5812 &input_overrides,
5813 action_id,
5814 entity_id,
5815 &event_kind,
5816 anchor_record_id,
5817 )?;
5818 let mut report = report;
5819 if let Some(object) = report.as_object_mut()
5820 && !report_annotations.is_empty()
5821 {
5822 object.insert("annotations".to_owned(), Value::Object(report_annotations));
5823 }
5824 if !dynamic_store_last_action_report(&mut root, report) {
5825 return Err(500);
5826 }
5827 if let Some((store, snapshot)) = &document_store {
5828 pharos_runtime_database::document::write_document(
5829 store.adapter.as_ref(),
5830 &store.config,
5831 &store.schema,
5832 snapshot,
5833 &root,
5834 )
5835 .map_err(|error| match error {
5836 pharos_runtime_database::operations::DatabaseError::Conflict => {
5837 *rejected_write = true;
5838 409
5839 }
5840 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
5841 _ => 503,
5842 })?;
5843 }
5844 if document_store.is_none() {
5845 let encoded = serde_json::to_string(&root).map_err(|_| 500)?;
5846 if super::fs::write_text_file(state_path.ok_or(503)?, &encoded) != 0 {
5847 return Err(500);
5848 }
5849 }
5850 if response_kind == "json" {
5851 let mut body = serde_json::json!({
5852 "schema": "pharos.action.result.v1",
5853 "action_id": action_id,
5854 "report": root.get("last_action_report").cloned().unwrap_or(Value::Null),
5855 });
5856 if response.get("include_state").and_then(Value::as_bool) == Some(true) {
5857 if let Some(object) = root.as_object_mut() {
5858 object.remove("_database_worker_lease");
5859 }
5860 body["state"] = root;
5861 }
5862 Ok(DeclarativeActionResponse::Json(response_status, body))
5863 } else {
5864 Ok(DeclarativeActionResponse::Redirect(
5865 response
5866 .get("location")
5867 .and_then(Value::as_str)
5868 .unwrap_or("/")
5869 .to_owned(),
5870 ))
5871 }
5872 })();
5873 if let Err(status) = result
5874 && !*rejected_write
5875 {
5876 let entity_id = action
5877 .get("entity_id")
5878 .and_then(Value::as_str)
5879 .unwrap_or("record");
5880 let failure_root = dynamic_action_failure_report_root(
5881 original_root,
5882 action,
5883 request,
5884 &input_overrides,
5885 action_id,
5886 entity_id,
5887 transition,
5888 0,
5889 status,
5890 );
5891 if let Some((store, snapshot)) = &document_store {
5892 match pharos_runtime_database::document::write_document(
5895 store.adapter.as_ref(),
5896 &store.config,
5897 &store.schema,
5898 snapshot,
5899 &failure_root,
5900 ) {
5901 Ok(()) => {}
5902 Err(pharos_runtime_database::operations::DatabaseError::Conflict) => {
5903 *rejected_write = true;
5904 }
5905 Err(_) => return Err(503),
5906 }
5907 } else if let Some(state_path) = state_path
5908 && let Ok(encoded) = serde_json::to_string(&failure_root)
5909 {
5910 let _ = super::fs::write_text_file(state_path, &encoded);
5911 }
5912 }
5913 result
5914}
5915
5916fn prepare_action_effects(
5919 app: &AppRuntime,
5920 root: &mut Value,
5921 action: &Value,
5922 transition: Option<&Value>,
5923 integrations_root: &Value,
5924 request: &HttpRequest,
5925 input_overrides: &BTreeMap<String, Option<String>>,
5926 action_id: &str,
5927 anchor_record_id: i64,
5928) -> Result<(String, i64, i64, i64, i64, i64), i32> {
5929 let workflow_execution_id =
5930 dynamic_append_workflow_execution(root, action_id, transition).ok_or(500)?;
5931 if !dynamic_append_workflow_events(root, workflow_execution_id, transition) {
5932 return Err(422);
5933 }
5934 let scheduled_jobs = transition
5935 .and_then(|value| value.get("scheduled_jobs"))
5936 .and_then(Value::as_array)
5937 .cloned()
5938 .unwrap_or_default();
5939 for job in &scheduled_jobs {
5940 let job_id = job
5941 .as_str()
5942 .or_else(|| job.get("job_id").and_then(Value::as_str))
5943 .unwrap_or_default();
5944 let queue_name = job
5945 .get("queue_name")
5946 .and_then(Value::as_str)
5947 .unwrap_or("default");
5948 let delay_seconds = job
5949 .get("delay_seconds")
5950 .and_then(Value::as_i64)
5951 .unwrap_or(0);
5952 let max_attempts = job
5953 .get("max_attempts")
5954 .and_then(Value::as_i64)
5955 .unwrap_or(app.queue_max_attempts.max(1));
5956 let backoff_seconds = job
5957 .get("backoff_seconds")
5958 .and_then(Value::as_i64)
5959 .unwrap_or(app.queue_retry_seconds.max(0));
5960 let lease_seconds = job
5961 .get("lease_seconds")
5962 .and_then(Value::as_i64)
5963 .unwrap_or(30);
5964 if !dynamic_append_scheduled_job(
5965 root,
5966 workflow_execution_id,
5967 job_id,
5968 queue_name,
5969 delay_seconds,
5970 max_attempts,
5971 backoff_seconds,
5972 lease_seconds,
5973 ) {
5974 return Err(422);
5975 }
5976 }
5977 let mut queued_notification_count = 0_i64;
5978 let mut queued_webhook_count = 0_i64;
5979 let mut emitted_audit_count = 0_i64;
5980 let mut queued_file_record_count = 0_i64;
5981
5982 let mut failed_webhook_count = 0_i64;
5983 let event_kind = transition
5984 .and_then(|value| value.get("emits_event"))
5985 .and_then(Value::as_str)
5986 .map(ToOwned::to_owned)
5987 .or_else(|| {
5988 root.get("last_action")
5989 .and_then(Value::as_str)
5990 .filter(|value| !value.is_empty())
5991 .map(ToOwned::to_owned)
5992 })
5993 .unwrap_or_else(|| action_id.to_owned());
5994 let entity_id = action
5995 .get("entity_id")
5996 .and_then(Value::as_str)
5997 .unwrap_or("record");
5998 for notification in action
5999 .get("effect_hooks")
6000 .and_then(Value::as_object)
6001 .and_then(|hooks| hooks.get("notifications"))
6002 .and_then(Value::as_array)
6003 .into_iter()
6004 .flatten()
6005 {
6006 let adapter_id = notification
6007 .get("adapter_id")
6008 .and_then(Value::as_str)
6009 .unwrap_or("local-mail");
6010 let channel = notification
6011 .get("channel")
6012 .and_then(Value::as_str)
6013 .unwrap_or("email");
6014 let recipient = notification
6015 .get("recipient")
6016 .and_then(Value::as_object)
6017 .and_then(|spec| {
6018 dynamic_notification_parameter_value(
6019 &Value::Object(spec.clone()),
6020 request,
6021 input_overrides,
6022 action_id,
6023 entity_id,
6024 &event_kind,
6025 "",
6026 "",
6027 anchor_record_id,
6028 )
6029 })
6030 .and_then(|value| value.as_str().map(ToOwned::to_owned))
6031 .unwrap_or_default();
6032 let template_id = notification
6033 .get("template_id")
6034 .and_then(Value::as_object)
6035 .and_then(|spec| {
6036 dynamic_notification_parameter_value(
6037 &Value::Object(spec.clone()),
6038 request,
6039 input_overrides,
6040 action_id,
6041 entity_id,
6042 &event_kind,
6043 &recipient,
6044 "",
6045 anchor_record_id,
6046 )
6047 })
6048 .and_then(|value| value.as_str().map(ToOwned::to_owned))
6049 .unwrap_or_default();
6050 let template_params = dynamic_notification_template_params(
6051 notification,
6052 request,
6053 input_overrides,
6054 action_id,
6055 entity_id,
6056 &event_kind,
6057 &recipient,
6058 &template_id,
6059 anchor_record_id,
6060 );
6061 if dynamic_append_notification(
6062 root,
6063 integrations_root,
6064 adapter_id,
6065 channel,
6066 &recipient,
6067 &template_id,
6068 &template_params,
6069 ) {
6070 queued_notification_count += 1;
6071 } else {
6072 return Err(422);
6073 }
6074 }
6075 let resource_id =
6076 dynamic_action_resource_id(action, request, input_overrides, anchor_record_id);
6077 for adapter_id in action
6078 .get("effect_hooks")
6079 .and_then(Value::as_object)
6080 .and_then(|hooks| hooks.get("webhook_dispatch"))
6081 .and_then(Value::as_array)
6082 .into_iter()
6083 .flatten()
6084 .filter_map(Value::as_str)
6085 {
6086 if dynamic_append_webhook_delivery(
6087 root,
6088 integrations_root,
6089 adapter_id,
6090 action_id,
6091 &resource_id,
6092 )
6093 .is_some()
6094 {
6095 queued_webhook_count += 1;
6096 } else {
6097 failed_webhook_count += 1;
6098 }
6099 }
6100 for audit in action
6101 .get("effect_hooks")
6102 .and_then(Value::as_object)
6103 .and_then(|hooks| hooks.get("audit_records"))
6104 .and_then(Value::as_array)
6105 .into_iter()
6106 .flatten()
6107 {
6108 if dynamic_append_audit_record(
6109 root,
6110 integrations_root,
6111 action,
6112 audit,
6113 request,
6114 input_overrides,
6115 action_id,
6116 entity_id,
6117 &event_kind,
6118 anchor_record_id,
6119 ) {
6120 emitted_audit_count += 1;
6121 } else {
6122 return Err(422);
6123 }
6124 }
6125 for file_record in action
6126 .get("effect_hooks")
6127 .and_then(Value::as_object)
6128 .and_then(|hooks| hooks.get("file_records"))
6129 .and_then(Value::as_array)
6130 .into_iter()
6131 .flatten()
6132 {
6133 let prepared = prepare_file_record(
6134 integrations_root,
6135 action,
6136 file_record,
6137 request,
6138 input_overrides,
6139 action_id,
6140 entity_id,
6141 &event_kind,
6142 anchor_record_id,
6143 )
6144 .ok_or(422)?;
6145 if !enqueue_file_record(root, prepared) {
6146 return Err(422);
6147 }
6148 queued_file_record_count += 1;
6149 }
6150 Ok((
6151 event_kind,
6152 queued_notification_count,
6153 queued_webhook_count,
6154 emitted_audit_count,
6155 queued_file_record_count,
6156 failed_webhook_count,
6157 ))
6158}
6159
6160pub(super) fn action_resource_binding(
6162 app: &AppRuntime,
6163 schema: &pharos_runtime_database::operations::AppSchema,
6164 action_id: &str,
6165) -> Option<pharos_runtime_database::effects::ResourceBinding> {
6166 let actions = dynamic_contract_doc(app, "action_contract")?;
6167 let action = dynamic_find_action_contract(&actions, action_id)?;
6168 let label = action.get("entity_id")?.as_str()?;
6169 let entities = dynamic_contract_doc(app, "entity_contract");
6170 let name = entities
6171 .as_ref()
6172 .and_then(|root| root.get("entities"))
6173 .and_then(Value::as_array)
6174 .and_then(|rows| {
6175 rows.iter()
6176 .find(|row| row.get("entity_id").and_then(Value::as_str) == Some(label))
6177 })
6178 .and_then(|row| row.get("table"))
6179 .and_then(Value::as_str)
6180 .unwrap_or(label);
6181 schema.entities.iter().find(|entity| entity.name == name)?;
6182 Some(pharos_runtime_database::effects::ResourceBinding {
6183 entity: name.to_owned(),
6184 prefix: format!("{label}:"),
6185 })
6186}
6187
6188pub(super) fn declared_effect_steps(
6191 app: &AppRuntime,
6192 schema: &pharos_runtime_database::operations::AppSchema,
6193 request: &HttpRequest,
6194 action_id: &str,
6195 input_overrides: &BTreeMap<String, Option<String>>,
6196 anchor: i64,
6197 actor_id: i64,
6198) -> Result<Vec<pharos_runtime_database::operations::Mutation>, i32> {
6199 use pharos_runtime_database::operations::{FieldType, Mutation, WriteValue};
6200 let Some(actions) = dynamic_contract_doc(app, "action_contract") else {
6201 return Ok(Vec::new());
6202 };
6203 let Some(action) = dynamic_find_action_contract(&actions, action_id) else {
6204 return Ok(Vec::new());
6205 };
6206 let workflows = dynamic_contract_doc(app, "workflow_contract");
6207 let transition = workflows
6208 .as_ref()
6209 .and_then(|root| dynamic_find_workflow_transition(root, action_id));
6210 if transition.is_none() && action.get("effect_hooks").is_none() {
6211 return Ok(Vec::new());
6212 }
6213 let integrations =
6214 integration_contract_doc(app).unwrap_or_else(|| serde_json::json!({"adapters": []}));
6215 let mut prepared = serde_json::json!({});
6216 let (_, _, _, _, _, failures) = prepare_action_effects(
6217 app,
6218 &mut prepared,
6219 action,
6220 transition,
6221 &integrations,
6222 request,
6223 input_overrides,
6224 action_id,
6225 anchor,
6226 )?;
6227 if failures != 0 {
6228 return Err(422);
6229 }
6230 static EFFECT_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
6231 let sequence = EFFECT_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6232 let token = hex_digest(&Sha256::digest(
6233 format!(
6234 "{}:{}:{}:{}",
6235 action_id,
6236 std::process::id(),
6237 std::time::SystemTime::now()
6238 .duration_since(std::time::UNIX_EPOCH)
6239 .map_err(|_| 503)?
6240 .as_nanos(),
6241 sequence
6242 )
6243 .as_bytes(),
6244 ));
6245 let mut steps = Vec::new();
6246 let mut workflow_step = None;
6247 for (section, entity_name) in [
6248 ("workflow_executions", "workflow_execution"),
6249 ("workflow_execution_events", "workflow_execution_event"),
6250 ("job_executions", "job_execution"),
6251 ("notification_deliveries", "notification_delivery"),
6252 ("integration_deliveries", "integration_delivery"),
6253 ("audit_log", "audit_event"),
6254 ("local_file_deliveries", "local_file_delivery"),
6255 ] {
6256 let Some(rows) = prepared.get(section).and_then(Value::as_array) else {
6257 continue;
6258 };
6259 if rows.is_empty() {
6260 continue;
6261 }
6262 let entity = schema
6263 .entities
6264 .iter()
6265 .find(|entity| entity.name == entity_name)
6266 .ok_or(422)?;
6267 for row in rows {
6268 let mut row = row.as_object().cloned().ok_or(422)?;
6269 if entity_name == "workflow_execution" {
6270 row.insert("idempotency_key".to_owned(), Value::String(token.clone()));
6271 row.insert("replay_trace_id".to_owned(), Value::String(token.clone()));
6272 }
6273 if entity_name == "audit_event" {
6274 row.insert("payload_json".to_owned(), Value::Object(row.clone()));
6275 row.insert(
6276 "entity_type".to_owned(),
6277 action.get("entity_id").cloned().unwrap_or(Value::Null),
6278 );
6279 row.insert(
6280 "entity_id".to_owned(),
6281 if anchor > 0 {
6282 Value::from(anchor)
6283 } else {
6284 Value::Null
6285 },
6286 );
6287 row.insert(
6288 "subject_user_id".to_owned(),
6289 if actor_id > 0 {
6290 Value::from(actor_id)
6291 } else {
6292 Value::Null
6293 },
6294 );
6295 }
6296 if entity_name == "workflow_execution" {
6297 row.insert(
6298 "resource_id".to_owned(),
6299 Value::String(dynamic_action_resource_id(
6300 action,
6301 request,
6302 input_overrides,
6303 anchor,
6304 )),
6305 );
6306 row.insert(
6307 "executed_by_user_id".to_owned(),
6308 if actor_id > 0 {
6309 Value::from(actor_id)
6310 } else {
6311 Value::Null
6312 },
6313 );
6314 }
6315 if entity_name == "integration_delivery"
6316 && let Some(header) = row.get("timestamp_header").cloned()
6317 {
6318 row.insert("signature_timestamp_header".to_owned(), header);
6319 }
6320 if entity_name == "job_execution" {
6321 row.insert(
6322 "idempotency_key".to_owned(),
6323 Value::String(format!("{}:{}", token, steps.len())),
6324 );
6325 row.insert("replay_trace_id".to_owned(), Value::String(token.clone()));
6326 }
6327 let mut values = BTreeMap::new();
6328 for field in &entity.fields {
6329 if entity.identity.generated_field() == Some(field.name.as_str()) {
6330 continue;
6331 }
6332 let Some(mut value) = row.get(&field.name).cloned() else {
6333 continue;
6334 };
6335 if field.name == "workflow_execution_id" {
6336 values.insert(
6337 field.name.clone(),
6338 WriteValue::GeneratedId(workflow_step.ok_or(422)?),
6339 );
6340 continue;
6341 }
6342 if field.nullable && value.as_str() == Some("") {
6343 value = Value::Null;
6344 }
6345 if field.kind == FieldType::Timestamp
6346 && let Some(text) = value.as_str()
6347 && let Ok(epoch) = text.parse::<i64>()
6348 {
6349 let seconds = u64::try_from(epoch)
6350 .ok()
6351 .filter(|seconds| *seconds <= 253_402_300_799)
6352 .ok_or(422)?;
6353 value = Value::String(pharos_runtime_core::time::timestamp_from_epoch_seconds(
6354 seconds,
6355 ));
6356 }
6357 if field.kind == FieldType::Json
6358 && let Some(text) = value.as_str()
6359 {
6360 value = serde_json::from_str(text).map_err(|_| 422)?;
6361 }
6362 values.insert(field.name.clone(), WriteValue::Literal(value));
6363 }
6364 if entity_name == "workflow_execution" {
6365 workflow_step = Some(steps.len());
6366 }
6367 steps.push(Mutation::Insert {
6368 entity: entity_name.to_owned(),
6369 values,
6370 });
6371 }
6372 }
6373 Ok(steps)
6374}
6375
6376pub fn is_declarative_document_action(app: &AppRuntime, action_id: &str) -> bool {
6378 if !super::app_uses_file_backed_runtime_state(app) && declared_document_storage(app).is_none() {
6379 return false;
6380 }
6381 dynamic_contract_doc(app, "action_contract")
6382 .and_then(|document| dynamic_find_action_contract(&document, action_id).cloned())
6383 .is_some_and(|action| {
6384 action
6385 .get("mutation_primitives")
6386 .and_then(Value::as_array)
6387 .is_some_and(|steps| !steps.is_empty())
6388 })
6389}
6390
6391pub(super) fn declared_document_storage(
6393 app: &AppRuntime,
6394) -> Option<(
6395 String,
6396 String,
6397 pharos_runtime_database::operations::Predicate,
6398)> {
6399 let root = app.app_root.as_deref().map(Path::new)?;
6400 let manifest = super::declarative::declarative_source_load_fixture(root, "pharos.app.yml")?;
6401 let declaration = manifest.get("database")?.get("document_storage")?;
6402 let entity = declaration.get("entity")?.as_str()?;
6403 let field = declaration.get("field")?.as_str()?;
6404 let identity = declaration.get("identity")?.as_object()?;
6405 if entity.is_empty() || field.is_empty() || identity.is_empty() || identity.len() > 16 {
6406 return None;
6407 }
6408 let predicates = identity
6409 .iter()
6410 .map(|(name, value)| {
6411 if name.is_empty() || value.is_null() || value.is_array() || value.is_object() {
6412 return None;
6413 }
6414 Some(pharos_runtime_database::operations::Predicate::Equal(
6415 name.clone(),
6416 value.clone(),
6417 ))
6418 })
6419 .collect::<Option<Vec<_>>>()?;
6420 Some((
6421 entity.to_owned(),
6422 field.to_owned(),
6423 pharos_runtime_database::operations::Predicate::All(predicates),
6424 ))
6425}
6426
6427pub fn pattern_matches(pattern: &str, path: &str) -> bool {
6452 if pattern.is_empty() || path.is_empty() {
6453 return false;
6454 }
6455 let pattern = pattern.trim_matches('/').split('/').collect::<Vec<_>>();
6456 let path = path
6457 .split('?')
6458 .next()
6459 .unwrap_or(path)
6460 .trim_matches('/')
6461 .split('/')
6462 .collect::<Vec<_>>();
6463 pattern.len() == path.len()
6464 && pattern.iter().zip(path).all(|(expected, actual)| {
6465 expected.starts_with(':') || *expected == "*" || *expected == actual
6466 })
6467}
6468
6469pub fn route_pattern_specificity_score(pattern: &str) -> usize {
6493 pattern
6494 .trim_matches('/')
6495 .split('/')
6496 .filter(|segment| !segment.is_empty() && !segment.starts_with(':') && *segment != "*")
6497 .count()
6498}
6499
6500pub fn resolve_route_fixture_path(artifact_dir: &Path, app_id: &str) -> Option<String> {
6525 if artifact_dir.as_os_str().is_empty() || app_id.is_empty() || app_id.contains(['/', '\\']) {
6526 return None;
6527 }
6528 let candidates = [
6529 artifact_dir.join("routes.json"),
6530 artifact_dir.join("data/contracts/routes.json"),
6531 artifact_dir.join("apps").join(app_id).join("routes.json"),
6532 ];
6533 candidates
6534 .into_iter()
6535 .find(|path| path.is_file())
6536 .map(|path| path.to_string_lossy().into_owned())
6537}
6538
6539pub fn dynamic_dispatch_free_match<T>(_value: Option<T>) {}
6559
6560pub fn framework_webhook_endpoint_id(path: &str) -> Option<String> {
6584 path.strip_prefix("/_pharos/webhooks/")
6585 .map(|rest| rest.strip_suffix('/').unwrap_or(rest))
6586 .filter(|value| {
6587 !value.is_empty()
6588 && value.len() <= 128
6589 && !value.contains('/')
6590 && value
6591 .bytes()
6592 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
6593 })
6594 .map(ToOwned::to_owned)
6595}
6596
6597pub fn integration_fixture_path(app: &AppRuntime) -> Option<String> {
6621 let root = Path::new(app.app_root.as_deref()?);
6622 let value = fs::read_to_string(root.join("pharos.app.json"))
6623 .ok()
6624 .and_then(|text| serde_json::from_str::<Value>(&text).ok())?;
6625 let relative = value
6626 .get("contracts")
6627 .and_then(Value::as_object)?
6628 .get("integration_contract")
6629 .and_then(Value::as_str)?;
6630 if relative.is_empty()
6631 || Path::new(relative).is_absolute()
6632 || relative.split('/').any(|segment| segment == "..")
6633 {
6634 return None;
6635 }
6636 Some(root.join(relative).to_string_lossy().into_owned())
6637}
6638
6639pub fn generated_surface_user_has_capability(
6667 state_root: &Value,
6668 _app: &AppRuntime,
6669 user_id: i64,
6670 tenant_id: i64,
6671 capability: &str,
6672) -> bool {
6673 user_id > 0
6674 && tenant_id > 0
6675 && !capability.is_empty()
6676 && state_root
6677 .get("capabilities")
6678 .and_then(Value::as_array)
6679 .is_some_and(|rows| {
6680 rows.iter().any(|row| {
6681 row.get("user_id").and_then(Value::as_i64) == Some(user_id)
6682 && row.get("tenant_id").and_then(Value::as_i64) == Some(tenant_id)
6683 && row.get("capability").and_then(Value::as_str) == Some(capability)
6684 })
6685 })
6686}
6687
6688pub fn write_error_envelope(
6714 status: u16,
6715 status_text: &str,
6716 error_type: &str,
6717 error_code: &str,
6718 message: &str,
6719 actor_scope: Option<&str>,
6720 tenant_scope: Option<&str>,
6721) -> String {
6722 serde_json::json!({"status":status,"status_text":status_text,"error_type":error_type,"error_code":error_code,"message":message,"actor_scope":actor_scope.unwrap_or_default(),"tenant_scope":tenant_scope.unwrap_or_default()}).to_string()
6723}
6724
6725pub fn write_generated_surface_api_schema_response(surface_root: &Value) -> Option<String> {
6745 serde_json::to_string(surface_root).ok()
6746}
6747
6748pub fn write_generated_surface_api_item_response(
6771 status: u16,
6772 status_text: &str,
6773 surface_root: &Value,
6774 item_root: &Value,
6775) -> Option<String> {
6776 serde_json::to_string(&serde_json::json!({"status":status,"status_text":status_text,"surface":surface_root,"item":item_root})).ok()
6777}
6778
6779pub fn write_generated_surface_api_list_response(
6804 items: &[Value],
6805 raw_query: Option<&str>,
6806) -> Option<String> {
6807 let query = raw_query.unwrap_or_default().to_ascii_lowercase();
6808 let filtered = items
6809 .iter()
6810 .filter(|item| query.is_empty() || item.to_string().to_ascii_lowercase().contains(&query))
6811 .cloned()
6812 .collect::<Vec<_>>();
6813 serde_json::to_string(&serde_json::json!({"items":filtered,"count":filtered.len()})).ok()
6814}
6815
6816pub fn mut_json_obj_get_long_default(object: &Value, key: &str, default_value: i64) -> i64 {
6838 object
6839 .get(key)
6840 .and_then(Value::as_i64)
6841 .or_else(|| {
6842 object
6843 .get(key)
6844 .and_then(Value::as_str)
6845 .and_then(|value| value.parse().ok())
6846 })
6847 .unwrap_or(default_value)
6848}
6849
6850pub fn value_long_equals_runtime_local(value: &Value, expected: i64) -> bool {
6871 mut_json_obj_get_long_default(value, "value", i64::MIN) == expected
6872 || value.as_i64() == Some(expected)
6873}
6874
6875pub fn revoke_session_by_cookie(state_path: &Path, cookie: &str) -> i32 {
6900 if cookie.is_empty() {
6901 return 64;
6902 }
6903 let Ok(mut root) = fs::read_to_string(state_path)
6904 .ok()
6905 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
6906 .ok_or(66)
6907 else {
6908 return 66;
6909 };
6910 let Some(rows) = root.get_mut("sessions").and_then(Value::as_array_mut) else {
6911 return 69;
6912 };
6913 for row in rows {
6914 if row.get("cookie").and_then(Value::as_str) == Some(cookie)
6915 && let Some(object) = row.as_object_mut()
6916 {
6917 object.insert("revoked_at".to_owned(), Value::String(now_iso_utc()));
6918 }
6919 }
6920 super::fs::write_text_file(
6921 state_path,
6922 &serde_json::to_string(&root).unwrap_or_default(),
6923 )
6924}
6925
6926pub fn dispatch_response_file_for_request(root: &Path, method: &str, path: &str) -> Option<String> {
6952 if !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD") {
6953 return None;
6954 }
6955 let relative = path.trim_start_matches('/');
6956 if relative.is_empty() || relative.split('/').any(|segment| segment == "..") {
6957 return None;
6958 }
6959 let target = root.join(relative);
6960 let text = fs::read_to_string(target).ok()?;
6961 (text.len() <= 16 * 1024 * 1024).then_some(text)
6962}
6963
6964pub fn find_webhook_endpoint<'a>(
6989 integration_root: &'a Value,
6990 endpoint_id: &str,
6991) -> Option<&'a Value> {
6992 if endpoint_id.is_empty() {
6993 return None;
6994 }
6995 integration_root
6996 .get("webhook_endpoints")
6997 .and_then(Value::as_array)
6998 .and_then(|rows| {
6999 rows.iter()
7000 .find(|row| row.get("endpoint_id").and_then(Value::as_str) == Some(endpoint_id))
7001 })
7002}
7003
7004pub fn webhook_request_signature_valid(
7026 endpoint: &Value,
7027 request: &HttpRequest,
7028 payload_json: &str,
7029) -> bool {
7030 let Some(signature) = endpoint.get("signature").and_then(Value::as_object) else {
7031 return false;
7032 };
7033 if signature.get("mode").and_then(Value::as_str) != Some("hmac_sha256") {
7034 return false;
7035 }
7036 let Some(signature_header) = signature.get("header").and_then(Value::as_str) else {
7037 return false;
7038 };
7039 let Some(timestamp_header) = signature.get("timestamp_header").and_then(Value::as_str) else {
7040 return false;
7041 };
7042 let Some(secret_env) = signature.get("secret_env").and_then(Value::as_str) else {
7043 return false;
7044 };
7045 let max_age = signature
7046 .get("max_age_seconds")
7047 .and_then(Value::as_u64)
7048 .filter(|value| *value > 0)
7049 .unwrap_or(300);
7050 let Some(timestamp_text) = request_header_value(request, timestamp_header) else {
7051 return false;
7052 };
7053 let Some(timestamp) = timestamp_text.parse::<u64>().ok() else {
7054 return false;
7055 };
7056 let now = SystemTime::now()
7057 .duration_since(UNIX_EPOCH)
7058 .map(|value| value.as_secs())
7059 .unwrap_or(0);
7060 if now.abs_diff(timestamp) > max_age {
7061 return false;
7062 }
7063 let Some(provided) = request_header_value(request, signature_header) else {
7064 return false;
7065 };
7066 let Some(secret) = env::var(secret_env).ok().filter(|value| !value.is_empty()) else {
7067 return false;
7068 };
7069 let message = format!("{timestamp_text}.{payload_json}");
7070 let expected = format!(
7071 "sha256={}",
7072 hex_digest(&hmac_sha256(secret.as_bytes(), message.as_bytes()))
7073 );
7074 constant_time_equal(provided.as_bytes(), expected.as_bytes())
7075}
7076
7077pub fn append_webhook_receipt(
7104 state_path: &Path,
7105 endpoint_id: &str,
7106 verification_status: &str,
7107 payload_json: &str,
7108) -> i32 {
7109 if endpoint_id.is_empty() || verification_status.is_empty() || payload_json.len() > 1_048_576 {
7110 return 64;
7111 }
7112 let Ok(mut root) = fs::read_to_string(state_path)
7113 .ok()
7114 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
7115 .ok_or(66)
7116 else {
7117 return 66;
7118 };
7119 let Some(rows) = root
7120 .as_object_mut()
7121 .map(|object| {
7122 object
7123 .entry("webhook_receipts".to_owned())
7124 .or_insert_with(|| Value::Array(Vec::new()))
7125 })
7126 .and_then(Value::as_array_mut)
7127 else {
7128 return 69;
7129 };
7130 rows.push(serde_json::json!({"endpoint_id":endpoint_id,"verification_status":verification_status,"payload":payload_json,"created_at":now_iso_utc()}));
7131 super::fs::write_text_file(
7132 state_path,
7133 &serde_json::to_string(&root).unwrap_or_default(),
7134 )
7135}
7136
7137pub fn webhook_signature_header_value_dup_logic_local(
7159 secret_env: &str,
7160 timestamp_text: &str,
7161 payload_json: &str,
7162) -> Option<String> {
7163 if secret_env.is_empty() || timestamp_text.is_empty() || payload_json.is_empty() {
7164 return None;
7165 }
7166 let secret = env::var(secret_env)
7167 .ok()
7168 .filter(|value| !value.is_empty())?;
7169 let message = format!("{timestamp_text}.{payload_json}");
7170 Some(format!(
7171 "sha256={}",
7172 hex_digest(&hmac_sha256(secret.as_bytes(), message.as_bytes()))
7173 ))
7174}
7175
7176pub fn append_bytes_logic_local(output: &mut Vec<u8>, bytes: &[u8]) -> bool {
7197 if output.len().saturating_add(bytes.len()) > 16 * 1024 * 1024 {
7198 return false;
7199 }
7200 output.extend_from_slice(bytes);
7201 true
7202}
7203
7204pub fn input_field_value_dup_logic_local(object: &Value, field_name: &str) -> Option<String> {
7225 object
7226 .get(field_name)
7227 .map(value_to_text)
7228 .filter(|value| !value.is_empty())
7229}
7230
7231pub fn mut_object_field_logic_local(
7253 object: &mut Map<String, Value>,
7254 field_name: &str,
7255 value: Value,
7256) -> bool {
7257 if field_name.is_empty() || field_name.len() > 256 || field_name.contains('\0') {
7258 return false;
7259 }
7260 object.insert(field_name.to_owned(), value);
7261 true
7262}
7263
7264pub fn dynamic_generated_admin_context_binding_mut(
7285 object: &mut Map<String, Value>,
7286 binding: &Value,
7287) -> bool {
7288 let Some(binding) = binding.as_object() else {
7289 return false;
7290 };
7291 if binding.get("param").and_then(Value::as_str).is_none() {
7292 return false;
7293 }
7294 object.insert("context_binding".to_owned(), Value::Object(binding.clone()));
7295 true
7296}
7297
7298pub fn now_unix_epoch_text_logic_local() -> String {
7314 SystemTime::now()
7315 .duration_since(UNIX_EPOCH)
7316 .map(|value| value.as_secs().to_string())
7317 .unwrap_or_else(|_| "0".to_owned())
7318}
7319
7320pub fn parse_http_url_logic_local(url: &str) -> Option<(String, String, Option<u16>)> {
7346 let (scheme, rest) = url.split_once("://")?;
7347 if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https")
7348 || rest.is_empty()
7349 || rest.contains(['/', '?', '#', '\\', '\0'])
7350 {
7351 return None;
7352 }
7353 let (host, port) = if let Some((host, port)) = rest.split_once(':') {
7354 (host, Some(port.parse::<u16>().ok()?))
7355 } else {
7356 (rest, None)
7357 };
7358 if host.is_empty() || host.bytes().any(|byte| byte < 0x20 || byte == 0x7f) {
7359 return None;
7360 }
7361 Some((scheme.to_ascii_lowercase(), host.to_owned(), port))
7362}
7363
7364pub fn record_field_matches_text_value_logic_local(
7386 record: &Value,
7387 field_name: &str,
7388 expected: &str,
7389) -> bool {
7390 !field_name.is_empty() && record.get(field_name).map(value_to_text).as_deref() == Some(expected)
7391}
7392
7393pub fn streq_casefold_management_local(left: &str, right: &str) -> bool {
7414 left.eq_ignore_ascii_case(right)
7415}
7416
7417pub fn ensure_media_subtree(app_root: &Path, business_slug: &str) -> Option<String> {
7438 if app_root.as_os_str().is_empty()
7439 || business_slug.is_empty()
7440 || business_slug.contains(['/', '\\', '\0'])
7441 {
7442 return None;
7443 }
7444 let path = super::config::app_media_root(app_root)?
7445 .join("businesses")
7446 .join(business_slug);
7447 super::fs::ensure_directory(&path)
7448 .eq(&0)
7449 .then(|| path.to_string_lossy().into_owned())
7450}
7451
7452pub fn media_disk_path(app_root: &Path, business_slug: &str, media_url: &str) -> Option<String> {
7478 if app_root.as_os_str().is_empty() || business_slug.is_empty() || media_url.is_empty() {
7479 return None;
7480 }
7481 let relative = media_url
7482 .strip_prefix("/media/")
7483 .or_else(|| media_url.strip_prefix("media/"))?;
7484 if relative.is_empty()
7485 || relative
7486 .split('/')
7487 .any(|segment| segment.is_empty() || segment == "..")
7488 {
7489 return None;
7490 }
7491 Some(
7492 super::config::app_media_root(app_root)?
7493 .join(business_slug)
7494 .join(relative)
7495 .to_string_lossy()
7496 .into_owned(),
7497 )
7498}
7499
7500pub fn safe_upload_filename(filename: &str) -> Option<String> {
7524 let basename = filename.rsplit(['/', '\\']).next()?.trim();
7525 if basename.is_empty()
7526 || basename.len() > 255
7527 || basename == "."
7528 || basename == ".."
7529 || basename
7530 .bytes()
7531 .any(|byte| byte < 0x20 || byte == 0x7f || byte == 0)
7532 {
7533 return None;
7534 }
7535 Some(
7536 basename
7537 .chars()
7538 .filter(|character| {
7539 character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
7540 })
7541 .collect(),
7542 )
7543}
7544
7545pub fn dynamic_form_value(request: &HttpRequest, field_name: &str) -> Option<String> {
7566 request_form_value(request, field_name)
7567}
7568
7569pub fn dynamic_cookie_value(cookie_header: &str, name: &str) -> Option<String> {
7590 cookie_value(Some(cookie_header), name)
7591}
7592
7593pub fn csrf_token_for_response(cookie_header: Option<&str>) -> String {
7613 pharos_runtime_security::csrf_token_for_response(cookie_header)
7614}
7615
7616pub fn csrf_generate_random_hex(byte_count: usize) -> String {
7636 pharos_runtime_security::csrf_generate_random_hex(byte_count)
7637}
7638
7639pub fn csrf_token_from_cookie(cookie_header: Option<&str>) -> Option<String> {
7659 pharos_runtime_security::csrf_token_from_cookie(cookie_header)
7660}
7661
7662pub fn extract_ip_from_peer_info_cookie(cookie_header: &str) -> Option<String> {
7682 cookie_value(Some(cookie_header), "pharos_peer_ip")
7683 .filter(|value| value.parse::<std::net::IpAddr>().is_ok())
7684}
7685
7686pub fn request_body_bytes(request: &HttpRequest) -> Option<Vec<u8>> {
7706 (request.body.len() <= super::http::PHAROS_HTTP_MAX_MULTIPART_BODY_BYTES)
7707 .then(|| request.body.clone())
7708}
7709
7710pub fn request_body_text(request: &HttpRequest) -> Option<String> {
7730 String::from_utf8(request_body_bytes(request)?).ok()
7731}
7732
7733pub fn request_header_value(request: &HttpRequest, header_name: &str) -> Option<String> {
7758 request.headers.lines().find_map(|line| {
7759 let (name, value) = line.split_once(':')?;
7760 name.trim()
7761 .eq_ignore_ascii_case(header_name)
7762 .then(|| value.trim().to_owned())
7763 })
7764}
7765
7766pub fn write_app_state_text_file_runtime_local(app: &AppRuntime, state_text: &str) -> i32 {
7791 let path = app
7792 .state_path
7793 .as_deref()
7794 .filter(|value| !value.is_empty())
7795 .map(Path::new);
7796 let Some(path) = path else {
7797 return 69;
7798 };
7799 if state_text.len() > 16 * 1024 * 1024 || serde_json::from_str::<Value>(state_text).is_err() {
7800 return 64;
7801 }
7802 super::fs::write_text_file(path, state_text)
7803}
7804
7805pub fn write_html_response(
7828 status: u16,
7829 status_text: &str,
7830 html: &str,
7831 csrf_token: Option<&str>,
7832) -> String {
7833 let status_text = if status_text.is_empty() {
7834 super::http::http_status_text(status)
7835 } else {
7836 status_text
7837 };
7838 let cookie = csrf_token
7839 .map(|token| format!("Set-Cookie: pharos_csrf={token}; Path=/; SameSite=Lax\r\n"))
7840 .unwrap_or_default();
7841 format!(
7842 "HTTP/1.1 {status} {status_text}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n{cookie}\r\n{html}",
7843 html.len()
7844 )
7845}
7846
7847pub fn csrf_inject_hidden_inputs(_app_root: &Path, html: &str, csrf_token: &str) -> String {
7869 let escaped_token = super::json_commands::html_escape_text(csrf_token);
7870 pharos_runtime_dynamic::inject_csrf_hidden_inputs(html, &escaped_token)
7871}
7872
7873pub fn write_html_response_with_headers(
7897 status: u16,
7898 status_text: &str,
7899 html: &str,
7900 csrf_token: Option<&str>,
7901 extra_headers: &str,
7902) -> String {
7903 let safe_headers = extra_headers.replace(['\r', '\n'], " ");
7904 let mut response = write_html_response(status, status_text, html, csrf_token);
7905 if !safe_headers.is_empty() {
7906 response = response.replacen("\r\n\r\n", &format!("\r\n{safe_headers}\r\n\r\n"), 1);
7907 }
7908 response
7909}
7910
7911#[cfg(test)]
7912mod tests {
7913 #[test]
7915 fn local_outbox_retention_preserves_delivery_sequence() {
7916 let mut root = json!({});
7917 for index in 0..1100 {
7918 assert!(super::enqueue_file_record(
7919 &mut root,
7920 (
7921 "write_json_local".to_owned(),
7922 "test.json".to_owned(),
7923 json!({"revision": index})
7924 )
7925 ));
7926 let last = root["local_file_deliveries"]
7927 .as_array_mut()
7928 .expect("deliveries")
7929 .last_mut()
7930 .expect("new delivery");
7931 last["delivery_status"] = json!("delivered");
7932 }
7933 assert_eq!(root["_local_file_delivery_sequence"], json!(1100));
7934 assert!(
7935 root["local_file_deliveries"]
7936 .as_array()
7937 .expect("history")
7938 .len()
7939 <= 65
7940 );
7941 assert!(super::enqueue_file_record(
7942 &mut root,
7943 (
7944 "write_json_local".to_owned(),
7945 "test.json".to_owned(),
7946 json!({"revision": 1100})
7947 )
7948 ));
7949 assert_eq!(
7950 root["local_file_deliveries"]
7951 .as_array()
7952 .expect("history")
7953 .last()
7954 .expect("new delivery")["id"],
7955 json!(1101)
7956 );
7957 root["_local_file_delivery_sequence"] = json!("invalid");
7958 assert!(!super::enqueue_file_record(
7959 &mut root,
7960 (
7961 "write_json_local".to_owned(),
7962 "test.json".to_owned(),
7963 json!({})
7964 )
7965 ));
7966 }
7967
7968 #[test]
7971 fn local_outbox_replacements_do_not_overtake_retries() {
7972 let mut root = json!({"local_file_deliveries": [
7973 {"id": 1, "relative_path": "same.json", "delivery_status": "retryable", "next_attempt_at": "99999999999"},
7974 {"id": 2, "relative_path": "same.json", "delivery_status": "queued"}
7975 ]});
7976 assert!(!super::process_one_due_file_delivery(
7977 &AppRuntime::default(),
7978 &mut root
7979 ));
7980 assert_eq!(
7981 root["local_file_deliveries"][1]["delivery_status"],
7982 json!("queued")
7983 );
7984 }
7985
7986 use super::{
7987 append_webhook_receipt, deliver_integration_webhook, dynamic_action_failure_annotations,
7988 dynamic_action_failure_report_root, dynamic_action_input_overrides,
7989 dynamic_action_report_annotations, dynamic_append_file_record, dynamic_append_notification,
7990 dynamic_append_record_primitive, dynamic_append_records_from_input_list_primitive,
7991 dynamic_append_records_from_related_collection_primitive,
7992 dynamic_apply_approval_transition,
7993 dynamic_apply_scoped_assignment_or_bulk_update_primitive,
7994 dynamic_apply_scoped_status_transition_primitive,
7995 dynamic_delete_records_by_scope_primitive,
7996 dynamic_replace_records_from_input_list_primitive, dynamic_set_root_fields_primitive,
7997 dynamic_store_last_action_report, dynamic_update_record_primitive,
7998 dynamic_upsert_record_primitive, process_one_due_notification_delivery,
7999 webhook_request_signature_valid, webhook_signature_header_value_dup_logic_local,
8000 };
8001 use crate::config::AppRuntime;
8002 use crate::runtime::http::HttpRequest;
8003 use serde_json::json;
8004 use std::collections::BTreeMap;
8005 use std::fs;
8006 use std::path::PathBuf;
8007 use std::time::{SystemTime, UNIX_EPOCH};
8008
8009 fn unique_test_dir(label: &str) -> PathBuf {
8011 let stamp = SystemTime::now()
8012 .duration_since(UNIX_EPOCH)
8013 .expect("test clock")
8014 .as_nanos();
8015 std::env::temp_dir().join(format!("pharos-{label}-{stamp}"))
8016 }
8017
8018 fn request_with_form(headers: &str, body: &str) -> HttpRequest {
8020 HttpRequest {
8021 method: "POST".to_owned(),
8022 target: "/actions/test".to_owned(),
8023 cookie: None,
8024 content_type: Some("application/x-www-form-urlencoded".to_owned()),
8025 headers: headers.to_owned(),
8026 body: body.as_bytes().to_vec(),
8027 body_file_path: None,
8028 body_len: body.len(),
8029 is_multipart: false,
8030 }
8031 }
8032
8033 #[test]
8034 fn webhook_request_signature_validation_accepts_valid_current_signature_and_rejects_bad_one() {
8036 let payload = r#"{"event":"local_echo"}"#;
8037 let timestamp = SystemTime::now()
8038 .duration_since(UNIX_EPOCH)
8039 .expect("clock")
8040 .as_secs()
8041 .to_string();
8042 let secret_env = "PATH";
8043 assert!(
8044 std::env::var(secret_env)
8045 .ok()
8046 .is_some_and(|value| !value.is_empty())
8047 );
8048 let signature =
8049 webhook_signature_header_value_dup_logic_local(secret_env, ×tamp, payload)
8050 .expect("signature");
8051 let endpoint = json!({
8052 "endpoint_id": "local-echo",
8053 "signature": {
8054 "mode": "hmac_sha256",
8055 "header": "X-Pharos-Signature",
8056 "timestamp_header": "X-Pharos-Timestamp",
8057 "secret_env": secret_env,
8058 "max_age_seconds": 300
8059 }
8060 });
8061 let valid_request = HttpRequest {
8062 method: "POST".to_owned(),
8063 target: "/_pharos/webhooks/local-echo/".to_owned(),
8064 cookie: None,
8065 content_type: Some("application/json".to_owned()),
8066 headers: format!(
8067 "X-Pharos-Timestamp: {timestamp}\r\nX-Pharos-Signature: {signature}\r\n"
8068 ),
8069 body: payload.as_bytes().to_vec(),
8070 body_file_path: None,
8071 body_len: payload.len(),
8072 is_multipart: false,
8073 };
8074 assert!(webhook_request_signature_valid(
8075 &endpoint,
8076 &valid_request,
8077 payload
8078 ));
8079 let invalid_request = HttpRequest {
8080 headers: format!(
8081 "X-Pharos-Timestamp: {timestamp}\r\nX-Pharos-Signature: sha256=deadbeef\r\n"
8082 ),
8083 ..valid_request
8084 };
8085 assert!(!webhook_request_signature_valid(
8086 &endpoint,
8087 &invalid_request,
8088 payload
8089 ));
8090 }
8091
8092 #[test]
8093 fn append_webhook_receipt_persists_verified_payload() {
8095 let root = unique_test_dir("dynamic-webhook-receipt");
8096 fs::create_dir_all(&root).expect("create receipt root");
8097 let state_path = root.join("runtime-state.json");
8098 fs::write(&state_path, r#"{"webhook_receipts":[]}"#).expect("write state");
8099 assert_eq!(
8100 append_webhook_receipt(&state_path, "local-echo", "verified", r#"{"ok":true}"#),
8101 0
8102 );
8103 let persisted: serde_json::Value =
8104 serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
8105 .expect("parse state");
8106 let receipts = persisted["webhook_receipts"].as_array().expect("receipts");
8107 assert_eq!(receipts.len(), 1);
8108 assert_eq!(receipts[0]["endpoint_id"], "local-echo");
8109 assert_eq!(receipts[0]["verification_status"], "verified");
8110 assert_eq!(receipts[0]["payload"], r#"{"ok":true}"#);
8111 assert!(
8112 receipts[0]["created_at"]
8113 .as_str()
8114 .is_some_and(|value| !value.is_empty())
8115 );
8116 fs::remove_dir_all(root).expect("remove receipt root");
8117 }
8118
8119 #[test]
8120 fn notification_email_delivery_marks_missing_smtp_config_as_skipped() {
8122 let root = unique_test_dir("dynamic-notification-email");
8123 fs::create_dir_all(root.join("templates/email")).expect("create template dir");
8124 fs::write(
8125 root.join("templates/email/account-proof.yml"),
8126 "subject: Account proof for {{recipient}}\nbody: Hello {{recipient}}, this is a proof email.\n",
8127 )
8128 .expect("write email template");
8129 let app = AppRuntime {
8130 app_root: Some(root.to_string_lossy().into_owned()),
8131 app_id: Some("ucal".to_owned()),
8132 ..AppRuntime::default()
8133 };
8134 let integrations = json!({
8135 "adapters": [{
8136 "adapter_id": "account-mail",
8137 "adapter_kind": "notification_email",
8138 "relay": "primary",
8139 "max_attempts": 2,
8140 "backoff_seconds": 1
8141 }]
8142 });
8143 let mut state = json!({"notification_deliveries": []});
8144 assert!(dynamic_append_notification(
8145 &mut state,
8146 &integrations,
8147 "account-mail",
8148 "email",
8149 "ops@llight.io",
8150 "account-proof",
8151 &json!({"recipient":"ops@llight.io"})
8152 ));
8153 assert!(process_one_due_notification_delivery(
8154 &app,
8155 &mut state,
8156 &integrations
8157 ));
8158 let delivery = &state["notification_deliveries"][0];
8159 assert_eq!(delivery["adapter_kind"], "notification_email");
8160 assert_eq!(delivery["delivery_status"], "skipped");
8161 assert_eq!(delivery["attempt_count"], 1);
8162 assert_eq!(delivery["last_error"], "email disabled");
8163 assert_eq!(
8164 delivery["rendered_subject"],
8165 "Account proof for ops@llight.io"
8166 );
8167 assert_eq!(
8168 delivery["rendered_body"],
8169 "Hello ops@llight.io, this is a proof email."
8170 );
8171 assert!(
8172 delivery["completed_at"]
8173 .as_str()
8174 .is_some_and(|value| !value.is_empty())
8175 );
8176 fs::remove_dir_all(root).expect("remove notification root");
8177 }
8178
8179 #[test]
8180 fn default_inputs_fill_missing_values_without_overwriting_submitted_ones() {
8182 let action = json!({
8183 "hook_contract": {
8184 "default_inputs": [
8185 { "field": "snapshot_label", "source": "literal", "value": "initial-proof" },
8186 { "field": "actor", "source": "header", "header": "X-Pharos-Actor" }
8187 ]
8188 }
8189 });
8190 let request_missing = request_with_form("X-Pharos-Actor: casey", "board_id=4");
8191 let overrides_missing =
8192 dynamic_action_input_overrides(&action, &request_missing).expect("default overrides");
8193 assert_eq!(
8194 super::dynamic_action_input_value(
8195 &request_missing,
8196 &overrides_missing,
8197 "snapshot_label"
8198 )
8199 .as_deref(),
8200 Some("initial-proof")
8201 );
8202 assert_eq!(
8203 super::dynamic_action_input_value(&request_missing, &overrides_missing, "actor")
8204 .as_deref(),
8205 Some("casey")
8206 );
8207
8208 let request_present = request_with_form("X-Pharos-Actor: casey", "snapshot_label=manual");
8209 let overrides_present =
8210 dynamic_action_input_overrides(&action, &request_present).expect("submitted value");
8211 assert_eq!(
8212 super::dynamic_action_input_value(
8213 &request_present,
8214 &overrides_present,
8215 "snapshot_label"
8216 )
8217 .as_deref(),
8218 Some("manual")
8219 );
8220 }
8221
8222 #[test]
8223 fn delete_records_by_scope_removes_matching_rows_and_counts_skips() {
8225 let mut root = json!({
8226 "board_task_snapshots": [
8227 { "id": 8, "board_id": 4, "snapshot_label": "initial-proof" },
8228 { "id": 9, "board_id": 4, "snapshot_label": "initial-proof" },
8229 { "id": 10, "board_id": 4, "snapshot_label": "initial-proof" },
8230 { "id": 11, "board_id": 8, "snapshot_label": "keep" }
8231 ]
8232 });
8233 let primitive = json!({
8234 "collection": "board_task_snapshots",
8235 "scope_match": { "field": "board_id", "from_input": "board_id" },
8236 "max_records": 2,
8237 "overflow_policy": "truncate"
8238 });
8239 let request = request_with_form("", "board_id=4");
8240 let mut matched = 0;
8241 let mut skipped = 0;
8242 let mut removed = 0;
8243 dynamic_delete_records_by_scope_primitive(
8244 &mut root,
8245 &request,
8246 &BTreeMap::new(),
8247 &primitive,
8248 &mut matched,
8249 &mut skipped,
8250 &mut removed,
8251 )
8252 .expect("delete primitive");
8253 let rows = root["board_task_snapshots"]
8254 .as_array()
8255 .expect("snapshot rows");
8256 assert_eq!(matched, 3);
8257 assert_eq!(skipped, 1);
8258 assert_eq!(removed, 2);
8259 assert_eq!(rows.len(), 2);
8260 assert!(rows.iter().any(|row| row["board_id"] == 8));
8261 }
8262
8263 #[test]
8264 fn write_json_local_overwrites_one_bounded_snapshot_file() {
8266 let root = unique_test_dir("dynamic-json-file-record");
8267 let state_root = root.join("state");
8268 fs::create_dir_all(state_root.join("exports")).expect("create exports");
8269 let state_path = state_root.join("runtime-state.json");
8270 fs::write(&state_path, "{}").expect("write state");
8271 let integrations = json!({
8272 "adapters": [{
8273 "adapter_id": "local-board-snapshot",
8274 "adapter_kind": "write_json_local",
8275 "relative_path": "exports/board-snapshot.json"
8276 }]
8277 });
8278 let action = json!({ "entity_id": "board_task_snapshot" });
8279 let file_record = json!({
8280 "adapter_id": "local-board-snapshot",
8281 "record": {
8282 "event": { "source": "literal", "value": "board_task_snapshots_purged" },
8283 "board_id": { "source": "input", "field": "board_id" }
8284 }
8285 });
8286 let request = request_with_form("", "board_id=4");
8287 assert!(dynamic_append_file_record(
8288 &state_path,
8289 &integrations,
8290 &action,
8291 &file_record,
8292 &request,
8293 &BTreeMap::new(),
8294 "purge_board_task_snapshots",
8295 "board_task_snapshot",
8296 "board_task_snapshots_purged",
8297 0,
8298 ));
8299 let output = fs::read_to_string(state_root.join("exports/board-snapshot.json"))
8300 .expect("read snapshot json");
8301 let persisted: serde_json::Value =
8302 serde_json::from_str(&output).expect("parse snapshot json");
8303 assert_eq!(persisted["adapter_kind"], "write_json_local");
8304 assert_eq!(persisted["board_id"], "4");
8305 assert_eq!(persisted["event"], "board_task_snapshots_purged");
8306 fs::remove_dir_all(root).expect("remove test root");
8307 }
8308
8309 #[test]
8311 fn webhook_delivery_carries_bounded_transport_policy() {
8312 let mut state = json!({"integration_deliveries": []});
8313 let integrations = json!({
8314 "adapters": [{
8315 "adapter_id": "local_webhook",
8316 "adapter_kind": "webhook",
8317 "target_url": "http://127.0.0.1:18781/capture",
8318 "allow_plaintext_local": true,
8319 "max_response_bytes": 4096
8320 }]
8321 });
8322
8323 assert_eq!(
8324 deliver_integration_webhook(
8325 &mut state,
8326 &integrations,
8327 "local_webhook",
8328 Some("test"),
8329 Some("appointment:1"),
8330 None,
8331 None,
8332 r#"{"event":"probe"}"#,
8333 ),
8334 Some(1)
8335 );
8336 let delivery = &state["integration_deliveries"][0];
8337 assert_eq!(delivery["allow_plaintext_local"], true);
8338 assert_eq!(delivery["max_response_bytes"], 4096);
8339 }
8340
8341 #[test]
8343 fn webhook_delivery_defaults_to_secure_transport_policy() {
8344 let mut state = json!({"integration_deliveries": []});
8345 let integrations = json!({
8346 "adapters": [{
8347 "adapter_id": "secure_webhook",
8348 "adapter_kind": "webhook",
8349 "target_url": "https://example.test/capture"
8350 }]
8351 });
8352
8353 assert_eq!(
8354 deliver_integration_webhook(
8355 &mut state,
8356 &integrations,
8357 "secure_webhook",
8358 None,
8359 None,
8360 None,
8361 None,
8362 r#"{"event":"probe"}"#,
8363 ),
8364 Some(1)
8365 );
8366 let delivery = &state["integration_deliveries"][0];
8367 assert_eq!(delivery["allow_plaintext_local"], false);
8368 assert_eq!(delivery["max_response_bytes"], 8 * 1024);
8369 }
8370
8371 #[test]
8373 fn cookie_lookup_prefers_first_duplicate_name() {
8374 let cookie_header =
8375 "pharos_session=scoped-token; other=value; pharos_session=stale-root-token";
8376 assert_eq!(
8377 super::dynamic_cookie_value(cookie_header, "pharos_session").as_deref(),
8378 Some("scoped-token")
8379 );
8380 }
8381
8382 #[test]
8384 fn http_json_delivery_carries_auth_and_event_header_policy() {
8385 let mut state = json!({"integration_deliveries": []});
8386 let integrations = json!({
8387 "adapters": [{
8388 "adapter_id": "board_api",
8389 "adapter_kind": "http_json",
8390 "target_url": "http://127.0.0.1:18782/capture",
8391 "auth_header": "Authorization",
8392 "auth_scheme": "Bearer",
8393 "auth_token_env": "PHAROS_BOARD_API_TOKEN",
8394 "event_header": "X-Pharos-Event",
8395 "allow_plaintext_local": true
8396 }]
8397 });
8398
8399 assert_eq!(
8400 deliver_integration_webhook(
8401 &mut state,
8402 &integrations,
8403 "board_api",
8404 Some("board_created"),
8405 Some("board:4"),
8406 None,
8407 None,
8408 r#"{"event":"probe"}"#,
8409 ),
8410 Some(1)
8411 );
8412 let delivery = &state["integration_deliveries"][0];
8413 assert_eq!(delivery["adapter_kind"], "http_json");
8414 assert_eq!(delivery["auth_header"], "Authorization");
8415 assert_eq!(delivery["auth_scheme"], "Bearer");
8416 assert_eq!(delivery["auth_token_env"], "PHAROS_BOARD_API_TOKEN");
8417 assert_eq!(delivery["event_header"], "X-Pharos-Event");
8418 }
8419
8420 #[test]
8422 fn http_form_delivery_carries_form_adapter_policy() {
8423 let mut state = json!({"integration_deliveries": []});
8424 let integrations = json!({
8425 "adapters": [{
8426 "adapter_id": "board_form",
8427 "adapter_kind": "http_form",
8428 "target_url": "http://127.0.0.1:18783/capture",
8429 "content_type": "application/x-www-form-urlencoded",
8430 "auth_header": "X-Api-Key",
8431 "auth_scheme": "",
8432 "auth_token_env": "PHAROS_BOARD_FORM_KEY",
8433 "event_header": "X-Pharos-Event",
8434 "allow_plaintext_local": true
8435 }]
8436 });
8437
8438 assert_eq!(
8439 deliver_integration_webhook(
8440 &mut state,
8441 &integrations,
8442 "board_form",
8443 Some("board_completed"),
8444 Some("board:4"),
8445 None,
8446 None,
8447 r#"{"event":"probe","attempt":1}"#,
8448 ),
8449 Some(1)
8450 );
8451 let delivery = &state["integration_deliveries"][0];
8452 assert_eq!(delivery["adapter_kind"], "http_form");
8453 assert_eq!(
8454 delivery["content_type"],
8455 "application/x-www-form-urlencoded"
8456 );
8457 assert_eq!(delivery["auth_header"], "X-Api-Key");
8458 assert_eq!(delivery["auth_scheme"], "");
8459 assert_eq!(delivery["auth_token_env"], "PHAROS_BOARD_FORM_KEY");
8460 assert_eq!(delivery["event_header"], "X-Pharos-Event");
8461 }
8462
8463 #[test]
8464 fn action_report_annotations_are_persisted_in_last_action_report() {
8466 let action = json!({
8467 "hook_contract": {
8468 "normalize_inputs": [
8469 { "kind": "trim", "fields": ["title"] },
8470 { "kind": "collapse_whitespace", "fields": ["title"] }
8471 ],
8472 "report_annotations": [
8473 { "field": "normalized_title", "value": { "source": "input", "field": "title" } },
8474 { "field": "request_transport", "value": { "source": "literal", "value": "form" } }
8475 ]
8476 }
8477 });
8478 let request = request_with_form("", "title=%20%20Native%20%20Proof%20Task%20%20");
8479 let overrides = dynamic_action_input_overrides(&action, &request).expect("input overrides");
8480 let annotations = dynamic_action_report_annotations(
8481 &action,
8482 &request,
8483 &overrides,
8484 "create_task",
8485 "task",
8486 "create_task_succeeded",
8487 41,
8488 )
8489 .expect("report annotations");
8490 let mut root = json!({"action_reports": []});
8491 assert!(dynamic_store_last_action_report(
8492 &mut root,
8493 json!({
8494 "action_id": "create_task",
8495 "event_kind": "create_task_succeeded",
8496 "annotations": annotations
8497 }),
8498 ));
8499 assert_eq!(
8500 root["last_action_report"]["annotations"]["normalized_title"],
8501 "Native Proof Task"
8502 );
8503 assert_eq!(
8504 root["last_action_report"]["annotations"]["request_transport"],
8505 "form"
8506 );
8507 assert_eq!(
8508 root["action_reports"]
8509 .as_array()
8510 .expect("action reports")
8511 .len(),
8512 1
8513 );
8514 }
8515
8516 #[test]
8517 fn action_failure_annotations_are_preserved_in_failure_report() {
8519 let action = json!({
8520 "hook_contract": {
8521 "failure_annotations": [
8522 { "field": "attempted_transition", "value": { "source": "literal", "value": "reject" } },
8523 { "field": "approval_request_id", "value": { "source": "input", "field": "id" } },
8524 { "field": "approver", "value": { "source": "input", "field": "approver" } }
8525 ]
8526 }
8527 });
8528 let request = request_with_form("", "id=17&approver=owner1");
8529 let overrides = dynamic_action_input_overrides(&action, &request).expect("input overrides");
8530 let annotations = dynamic_action_failure_annotations(
8531 &action,
8532 &request,
8533 &overrides,
8534 "reject_approval",
8535 "approval_request",
8536 "reject_approval_failed",
8537 17,
8538 )
8539 .expect("failure annotations");
8540 assert_eq!(annotations["attempted_transition"], "reject");
8541 let persisted = dynamic_action_failure_report_root(
8542 json!({"action_reports": []}),
8543 &action,
8544 &request,
8545 &overrides,
8546 "reject_approval",
8547 "approval_request",
8548 None,
8549 17,
8550 422,
8551 );
8552 assert_eq!(
8553 persisted["last_action_report"]["annotations"]["attempted_transition"],
8554 "reject"
8555 );
8556 assert_eq!(
8557 persisted["last_action_report"]["annotations"]["approval_request_id"],
8558 "17"
8559 );
8560 assert_eq!(
8561 persisted["last_action_report"]["annotations"]["approver"],
8562 "owner1"
8563 );
8564 }
8565
8566 #[test]
8567 fn append_record_creates_anchor_row() {
8569 let mut root = json!({
8570 "next_id": 7,
8571 "boards": []
8572 });
8573 let primitive = json!({
8574 "collection": "boards",
8575 "record_contract": {
8576 "id": { "source": "next_id" },
8577 "name": { "source": "input", "field": "board_name" },
8578 "workflow_state": { "source": "literal", "value": "created" }
8579 }
8580 });
8581 let request = request_with_form("", "board_name=Roadmap");
8582 let mut anchor_id = 0;
8583 let mut created = 0;
8584 dynamic_append_record_primitive(
8585 &mut root,
8586 &request,
8587 &BTreeMap::new(),
8588 &primitive,
8589 &mut anchor_id,
8590 &mut created,
8591 )
8592 .expect("append record");
8593 assert_eq!(created, 1);
8594 assert_eq!(anchor_id, 7);
8595 assert_eq!(root["boards"][0]["id"], 7);
8596 assert_eq!(root["boards"][0]["name"], "Roadmap");
8597 assert_eq!(root["boards"][0]["workflow_state"], "created");
8598 }
8599
8600 #[test]
8601 fn set_root_fields_applies_declared_literal_and_input_values() {
8603 let root_dir = unique_test_dir("dynamic-set-root-fields");
8604 fs::create_dir_all(root_dir.join("data/contracts")).expect("create contract dir");
8605 fs::write(
8606 root_dir.join("data/contracts/test-fields.json"),
8607 r#"{
8608 "fields": {
8609 "last_action": { "source": "literal", "value": "board_created" },
8610 "last_board_name": { "source": "input", "field": "board_name" }
8611 }
8612 }"#,
8613 )
8614 .expect("write field contract");
8615 let app = AppRuntime {
8616 app_root: Some(root_dir.to_string_lossy().into_owned()),
8617 ..AppRuntime::default()
8618 };
8619 let primitive = json!({
8620 "fields": "test-fields.json#fields"
8621 });
8622 let request = request_with_form("", "board_name=Roadmap");
8623 let mut root = json!({});
8624 dynamic_set_root_fields_primitive(&app, &mut root, &request, &BTreeMap::new(), &primitive)
8625 .expect("set root fields");
8626 assert_eq!(root["last_action"], "board_created");
8627 assert_eq!(root["last_board_name"], "Roadmap");
8628 fs::remove_dir_all(root_dir).expect("remove test root");
8629 }
8630
8631 #[test]
8632 fn update_record_updates_matching_row_and_missing_match_fails_closed() {
8634 let primitive = json!({
8635 "collection": "tasks",
8636 "match": { "field": "id", "from_input": "id" },
8637 "updates": {
8638 "done": { "source": "toggle_boolean", "field": "done" },
8639 "title": { "source": "input", "field": "title" }
8640 }
8641 });
8642 let request = request_with_form("", "id=2&title=Retitled");
8643 let mut root = json!({
8644 "tasks": [
8645 { "id": "1", "title": "One", "done": false },
8646 { "id": "2", "title": "Two", "done": false }
8647 ]
8648 });
8649 let mut matched = 0;
8650 let mut updated = 0;
8651 dynamic_update_record_primitive(
8652 &mut root,
8653 &request,
8654 &BTreeMap::new(),
8655 &primitive,
8656 &mut matched,
8657 &mut updated,
8658 )
8659 .expect("update matching row");
8660 assert_eq!(matched, 1);
8661 assert_eq!(updated, 1);
8662 assert_eq!(root["tasks"][1]["title"], "Retitled");
8663 assert_eq!(root["tasks"][1]["done"], true);
8664 let missing_request = request_with_form("", "id=99&title=Missing");
8665 let missing = dynamic_update_record_primitive(
8666 &mut root,
8667 &missing_request,
8668 &BTreeMap::new(),
8669 &primitive,
8670 &mut matched,
8671 &mut updated,
8672 );
8673 assert_eq!(missing, Err(404));
8674 }
8675
8676 #[test]
8677 fn append_records_from_input_list_creates_rows_with_anchor_and_skip_counts() {
8679 let mut root = json!({
8680 "next_id": 9,
8681 "board_tasks": []
8682 });
8683 let primitive = json!({
8684 "collection": "board_tasks",
8685 "list_source": { "from_input": "task_titles", "delimiter": "," },
8686 "max_records": 2,
8687 "overflow_policy": "truncate",
8688 "record_contract": {
8689 "id": { "source": "next_id" },
8690 "board_id": { "source": "context_anchor_created_id" },
8691 "title": { "source": "list_item" },
8692 "sort_order": { "source": "list_index" },
8693 "workflow_state": { "source": "literal", "value": "created" }
8694 }
8695 });
8696 let request = request_with_form("", "task_titles=One,Two,Three");
8697 let mut skipped = 0;
8698 let mut created = 0;
8699 dynamic_append_records_from_input_list_primitive(
8700 &mut root,
8701 &request,
8702 &BTreeMap::new(),
8703 &primitive,
8704 44,
8705 0,
8706 0,
8707 &mut skipped,
8708 &mut created,
8709 )
8710 .expect("append records from input list");
8711 let rows = root["board_tasks"].as_array().expect("board tasks array");
8712 assert_eq!(created, 2);
8713 assert_eq!(skipped, 1);
8714 assert_eq!(rows.len(), 2);
8715 assert_eq!(rows[0]["id"], 9);
8716 assert_eq!(rows[0]["board_id"], 44);
8717 assert_eq!(rows[0]["title"], "One");
8718 assert_eq!(rows[0]["sort_order"], 1);
8719 assert_eq!(rows[1]["id"], 10);
8720 assert_eq!(rows[1]["title"], "Two");
8721 assert_eq!(rows[1]["sort_order"], 2);
8722 }
8723
8724 #[test]
8725 fn append_records_from_related_collection_copies_matched_rows_and_tracks_overflow() {
8727 let mut root = json!({
8728 "next_id": 20,
8729 "board_tasks": [
8730 { "id": 1, "board_id": 4, "title": "One", "assignee": "sam", "status": "pending" },
8731 { "id": 2, "board_id": 4, "title": "Two", "assignee": "lee", "status": "pending" },
8732 { "id": 3, "board_id": 8, "title": "Other", "assignee": "ivy", "status": "done" }
8733 ],
8734 "board_task_snapshots": []
8735 });
8736 let primitive = json!({
8737 "collection": "board_task_snapshots",
8738 "source_collection": "board_tasks",
8739 "scope_match": { "field": "board_id", "from_input": "board_id" },
8740 "max_records": 1,
8741 "record_contract": {
8742 "id": { "source": "next_id" },
8743 "board_id": { "source": "source_record_field", "field": "board_id" },
8744 "source_task_id": { "source": "source_record_field", "field": "id" },
8745 "title": { "source": "source_record_field", "field": "title" },
8746 "snapshot_label": { "source": "input", "field": "snapshot_label" }
8747 }
8748 });
8749 let request = request_with_form("", "board_id=4&snapshot_label=Nightly");
8750 let mut matched = 0;
8751 let mut skipped = 0;
8752 let mut created = 0;
8753 dynamic_append_records_from_related_collection_primitive(
8754 &mut root,
8755 &request,
8756 &BTreeMap::new(),
8757 &primitive,
8758 &mut matched,
8759 &mut skipped,
8760 &mut created,
8761 )
8762 .expect("append records from related collection");
8763 let rows = root["board_task_snapshots"]
8764 .as_array()
8765 .expect("snapshot rows array");
8766 assert_eq!(matched, 2);
8767 assert_eq!(skipped, 1);
8768 assert_eq!(created, 1);
8769 assert_eq!(rows.len(), 1);
8770 assert_eq!(rows[0]["id"], 20);
8771 assert_eq!(rows[0]["board_id"], 4);
8772 assert_eq!(rows[0]["source_task_id"], 1);
8773 assert_eq!(rows[0]["title"], "One");
8774 assert_eq!(rows[0]["snapshot_label"], "Nightly");
8775 }
8776
8777 #[test]
8778 fn scoped_reassignment_updates_matching_rows_and_preserves_others() {
8780 let mut root = json!({
8781 "board_tasks": [
8782 { "id": 1, "board_id": 4, "assignee": "sam" },
8783 { "id": 2, "board_id": 4, "assignee": "lee" },
8784 { "id": 3, "board_id": 8, "assignee": "ivy" }
8785 ]
8786 });
8787 let primitive = json!({
8788 "collection": "board_tasks",
8789 "scope_match": { "field": "board_id", "from_input": "board_id" },
8790 "target_field": "assignee",
8791 "value_contract": { "source": "input", "field": "new_assignee" },
8792 "max_records": 1,
8793 "overflow_policy": "truncate"
8794 });
8795 let request = request_with_form("", "board_id=4&new_assignee=alex");
8796 let mut matched = 0;
8797 let mut updated = 0;
8798 let mut skipped = 0;
8799 dynamic_apply_scoped_assignment_or_bulk_update_primitive(
8800 &mut root,
8801 &request,
8802 &BTreeMap::new(),
8803 &primitive,
8804 "scoped_reassignment",
8805 &mut matched,
8806 &mut updated,
8807 &mut skipped,
8808 )
8809 .expect("scoped reassignment");
8810 let rows = root["board_tasks"].as_array().expect("board tasks");
8811 assert_eq!(matched, 2);
8812 assert_eq!(updated, 1);
8813 assert_eq!(skipped, 1);
8814 assert_eq!(rows[0]["assignee"], "alex");
8815 assert_eq!(rows[1]["assignee"], "lee");
8816 assert_eq!(rows[2]["assignee"], "ivy");
8817 }
8818
8819 #[test]
8820 fn bulk_update_fails_closed_when_scope_exceeds_strict_bound() {
8822 let mut root = json!({
8823 "board_tasks": [
8824 { "id": 1, "board_id": 4, "done": false, "status": "pending" },
8825 { "id": 2, "board_id": 4, "done": false, "status": "pending" }
8826 ]
8827 });
8828 let primitive = json!({
8829 "collection": "board_tasks",
8830 "scope_match": { "field": "board_id", "from_input": "board_id" },
8831 "updates": {
8832 "done": { "source": "literal", "value": true },
8833 "status": { "source": "literal", "value": "completed" }
8834 },
8835 "max_records": 1,
8836 "overflow_policy": "fail"
8837 });
8838 let request = request_with_form("", "board_id=4");
8839 let mut matched = 0;
8840 let mut updated = 0;
8841 let mut skipped = 0;
8842 let result = dynamic_apply_scoped_assignment_or_bulk_update_primitive(
8843 &mut root,
8844 &request,
8845 &BTreeMap::new(),
8846 &primitive,
8847 "bulk_update",
8848 &mut matched,
8849 &mut updated,
8850 &mut skipped,
8851 );
8852 assert_eq!(result, Err(422));
8853 assert_eq!(matched, 2);
8854 assert_eq!(updated, 0);
8855 assert_eq!(skipped, 0);
8856 assert_eq!(root["board_tasks"][0]["done"], false);
8857 assert_eq!(root["board_tasks"][1]["status"], "pending");
8858 }
8859
8860 #[test]
8861 fn scoped_status_transition_updates_allowed_rows_and_reports_skips() {
8863 let mut root = json!({
8864 "board_tasks": [
8865 { "id": 1, "board_id": 4, "status": "pending", "workflow_state": "created", "done": false },
8866 { "id": 2, "board_id": 4, "status": "assigned", "workflow_state": "assigned", "done": false },
8867 { "id": 3, "board_id": 4, "status": "completed", "workflow_state": "done", "done": true },
8868 { "id": 4, "board_id": 8, "status": "pending", "workflow_state": "created", "done": false }
8869 ]
8870 });
8871 let primitive = json!({
8872 "collection": "board_tasks",
8873 "scope_match": { "field": "board_id", "from_input": "board_id" },
8874 "from_statuses": ["pending", "assigned"],
8875 "to_status": "completed",
8876 "to_workflow_state": "done",
8877 "timestamp_field": "completed_at",
8878 "actor_field": "completed_by",
8879 "actor_input": "actor",
8880 "updates": {
8881 "done": { "source": "literal", "value": true }
8882 }
8883 });
8884 let request = request_with_form("", "board_id=4&actor=ops-user");
8885 let mut matched = 0;
8886 let mut updated = 0;
8887 let mut skipped = 0;
8888 dynamic_apply_scoped_status_transition_primitive(
8889 &mut root,
8890 &request,
8891 &BTreeMap::new(),
8892 &primitive,
8893 &mut matched,
8894 &mut updated,
8895 &mut skipped,
8896 )
8897 .expect("scoped status transition");
8898 let rows = root["board_tasks"].as_array().expect("board tasks");
8899 assert_eq!(matched, 3);
8900 assert_eq!(updated, 2);
8901 assert_eq!(skipped, 1);
8902 assert_eq!(rows[0]["status"], "completed");
8903 assert_eq!(rows[0]["workflow_state"], "done");
8904 assert_eq!(rows[0]["done"], true);
8905 assert_eq!(rows[0]["completed_by"], "ops-user");
8906 assert!(
8907 rows[0]["completed_at"]
8908 .as_str()
8909 .is_some_and(|value| !value.is_empty())
8910 );
8911 assert_eq!(rows[2]["status"], "completed");
8912 assert_eq!(rows[3]["status"], "pending");
8913 }
8914
8915 #[test]
8916 fn upsert_record_updates_existing_row_and_creates_missing_row() {
8918 let primitive = json!({
8919 "collection": "board_status_summaries",
8920 "match": { "field": "board_id", "from_input": "board_id" },
8921 "updates": {
8922 "board_id": { "source": "input", "field": "board_id" },
8923 "board_status": { "source": "literal", "value": "completed" },
8924 "completed_task_count": { "source": "context_updated_count" },
8925 "updated_at": { "source": "now" }
8926 },
8927 "record_contract": {
8928 "id": { "source": "next_id" },
8929 "board_id": { "source": "input", "field": "board_id" },
8930 "board_status": { "source": "literal", "value": "completed" },
8931 "completed_task_count": { "source": "context_updated_count" },
8932 "created_at": { "source": "now" },
8933 "updated_at": { "source": "now" }
8934 }
8935 });
8936
8937 let mut existing_root = json!({
8938 "next_id": 12,
8939 "board_status_summaries": [
8940 { "id": 3, "board_id": "4", "board_status": "pending", "completed_task_count": 1, "updated_at": "stale" }
8941 ]
8942 });
8943 let request = request_with_form("", "board_id=4");
8944 let mut matched = 0;
8945 let mut updated = 2;
8946 let mut created = 0;
8947 dynamic_upsert_record_primitive(
8948 &mut existing_root,
8949 &request,
8950 &BTreeMap::new(),
8951 &primitive,
8952 0,
8953 &mut matched,
8954 &mut updated,
8955 0,
8956 &mut created,
8957 )
8958 .expect("upsert existing row");
8959 let existing = &existing_root["board_status_summaries"][0];
8960 assert_eq!(matched, 1);
8961 assert_eq!(updated, 3);
8962 assert_eq!(created, 0);
8963 assert_eq!(existing["board_status"], "completed");
8964 assert_eq!(existing["completed_task_count"], 2);
8965 assert!(
8966 existing["updated_at"]
8967 .as_str()
8968 .is_some_and(|value| !value.is_empty() && value != "stale")
8969 );
8970
8971 let mut missing_root = json!({
8972 "next_id": 20,
8973 "board_status_summaries": []
8974 });
8975 let mut matched_missing = 0;
8976 let mut updated_missing = 4;
8977 let mut created_missing = 0;
8978 dynamic_upsert_record_primitive(
8979 &mut missing_root,
8980 &request,
8981 &BTreeMap::new(),
8982 &primitive,
8983 0,
8984 &mut matched_missing,
8985 &mut updated_missing,
8986 1,
8987 &mut created_missing,
8988 )
8989 .expect("create missing upsert row");
8990 let created_row = &missing_root["board_status_summaries"][0];
8991 assert_eq!(matched_missing, 0);
8992 assert_eq!(updated_missing, 4);
8993 assert_eq!(created_missing, 1);
8994 assert_eq!(created_row["id"], 20);
8995 assert_eq!(created_row["board_id"], "4");
8996 assert_eq!(created_row["board_status"], "completed");
8997 assert_eq!(created_row["completed_task_count"], 4);
8998 assert!(
8999 created_row["created_at"]
9000 .as_str()
9001 .is_some_and(|value| !value.is_empty())
9002 );
9003 }
9004
9005 #[test]
9006 fn replace_records_from_input_list_replaces_updates_and_removes_in_order() {
9008 let mut root = json!({
9009 "next_id": 8,
9010 "board_tasks": [
9011 { "id": 3, "board_id": 4, "title": "Legacy 2", "sort_order": 2, "status": "old", "workflow_state": "old" },
9012 { "id": 1, "board_id": 4, "title": "Legacy 1", "sort_order": 1, "status": "old", "workflow_state": "old" },
9013 { "id": 9, "board_id": 8, "title": "Other Board", "sort_order": 1, "status": "keep", "workflow_state": "keep" },
9014 { "id": 2, "board_id": 4, "title": "Legacy 3", "sort_order": 3, "status": "old", "workflow_state": "old" }
9015 ]
9016 });
9017 let primitive = json!({
9018 "collection": "board_tasks",
9019 "scope_match": { "field": "board_id", "from_input": "board_id" },
9020 "list_source": { "from_input": "task_titles", "delimiter": "," },
9021 "order_field": "sort_order",
9022 "updates": {
9023 "title": { "source": "list_item" },
9024 "sort_order": { "source": "list_index" },
9025 "status": { "source": "literal", "value": "pending" },
9026 "workflow_state": { "source": "literal", "value": "created" }
9027 },
9028 "record_contract": {
9029 "id": { "source": "next_id" },
9030 "board_id": { "source": "input", "field": "board_id" },
9031 "title": { "source": "list_item" },
9032 "sort_order": { "source": "list_index" },
9033 "status": { "source": "literal", "value": "pending" },
9034 "workflow_state": { "source": "literal", "value": "created" }
9035 }
9036 });
9037 let request = request_with_form("", "board_id=4&task_titles=Replaced+One,Replaced+Two");
9038 let mut matched = 0;
9039 let mut created = 0;
9040 let mut updated = 0;
9041 let mut skipped = 0;
9042 let mut removed = 0;
9043 dynamic_replace_records_from_input_list_primitive(
9044 &mut root,
9045 &request,
9046 &BTreeMap::new(),
9047 &primitive,
9048 &mut matched,
9049 &mut created,
9050 &mut updated,
9051 &mut skipped,
9052 &mut removed,
9053 )
9054 .expect("replace primitive should succeed");
9055 let board_rows = root["board_tasks"]
9056 .as_array()
9057 .expect("board tasks should remain an array")
9058 .iter()
9059 .filter(|row| row["board_id"] == json!(4))
9060 .cloned()
9061 .collect::<Vec<_>>();
9062 assert_eq!(matched, 3);
9063 assert_eq!(created, 0);
9064 assert_eq!(updated, 2);
9065 assert_eq!(skipped, 0);
9066 assert_eq!(removed, 1);
9067 assert_eq!(board_rows.len(), 2);
9068 assert_eq!(board_rows[0]["id"], 3);
9069 assert_eq!(board_rows[0]["title"], "Replaced Two");
9070 assert_eq!(board_rows[0]["sort_order"], 2);
9071 assert_eq!(board_rows[1]["id"], 1);
9072 assert_eq!(board_rows[1]["title"], "Replaced One");
9073 assert_eq!(board_rows[1]["sort_order"], 1);
9074 assert!(
9075 root["board_tasks"]
9076 .as_array()
9077 .expect("board tasks array")
9078 .iter()
9079 .any(|row| row["board_id"] == json!(8) && row["title"] == "Other Board")
9080 );
9081 }
9082
9083 #[test]
9084 fn replace_records_from_input_list_counts_truncated_items_as_skipped() {
9086 let mut root = json!({
9087 "next_id": 4,
9088 "board_tasks": [
9089 { "id": 1, "board_id": 4, "title": "Legacy 1", "sort_order": 1 },
9090 { "id": 2, "board_id": 4, "title": "Legacy 2", "sort_order": 2 }
9091 ]
9092 });
9093 let primitive = json!({
9094 "collection": "board_tasks",
9095 "scope_match": { "field": "board_id", "from_input": "board_id" },
9096 "list_source": { "from_input": "task_titles", "delimiter": "," },
9097 "order_field": "sort_order",
9098 "max_records": 2,
9099 "overflow_policy": "truncate",
9100 "updates": {
9101 "title": { "source": "list_item" },
9102 "sort_order": { "source": "list_index" }
9103 },
9104 "record_contract": {
9105 "id": { "source": "next_id" },
9106 "board_id": { "source": "input", "field": "board_id" },
9107 "title": { "source": "list_item" },
9108 "sort_order": { "source": "list_index" }
9109 }
9110 });
9111 let request = request_with_form("", "board_id=4&task_titles=One,Two,Three,Four");
9112 let mut matched = 0;
9113 let mut created = 0;
9114 let mut updated = 0;
9115 let mut skipped = 0;
9116 let mut removed = 0;
9117 dynamic_replace_records_from_input_list_primitive(
9118 &mut root,
9119 &request,
9120 &BTreeMap::new(),
9121 &primitive,
9122 &mut matched,
9123 &mut created,
9124 &mut updated,
9125 &mut skipped,
9126 &mut removed,
9127 )
9128 .expect("replace primitive should truncate within bounds");
9129 assert_eq!(matched, 2);
9130 assert_eq!(created, 0);
9131 assert_eq!(updated, 2);
9132 assert_eq!(skipped, 2);
9133 assert_eq!(removed, 0);
9134 }
9135
9136 #[test]
9137 fn approval_transition_sets_expected_actor_reason_and_timestamp_fields() {
9139 let mut approval = serde_json::Map::from_iter([
9140 ("id".to_owned(), json!(17)),
9141 ("status".to_owned(), json!("submitted")),
9142 ("workflow_state".to_owned(), json!("submitted")),
9143 ("approved_by".to_owned(), json!("")),
9144 ("approved_at".to_owned(), json!("")),
9145 ("rejected_by".to_owned(), json!("stale")),
9146 ("rejected_at".to_owned(), json!("stale")),
9147 ("rejection_reason".to_owned(), json!("stale")),
9148 ]);
9149 let approve_primitive = json!({
9150 "transition_kind": "approve",
9151 "approver_input": "approver",
9152 "approved_by_field": "approved_by",
9153 "approved_at_field": "approved_at",
9154 "rejected_by_field": "rejected_by",
9155 "rejected_at_field": "rejected_at",
9156 "rejection_reason_field": "rejection_reason"
9157 });
9158 let approve_request = request_with_form("", "approver=owner1");
9159 dynamic_apply_approval_transition(
9160 &mut approval,
9161 &approve_primitive,
9162 &approve_request,
9163 &BTreeMap::new(),
9164 )
9165 .expect("approve transition");
9166 assert_eq!(approval.get("status"), Some(&json!("approved")));
9167 assert_eq!(approval.get("workflow_state"), Some(&json!("approved")));
9168 assert_eq!(approval.get("approved_by"), Some(&json!("owner1")));
9169 assert_eq!(approval.get("rejected_by"), Some(&json!("")));
9170 assert_eq!(approval.get("rejected_at"), Some(&json!("")));
9171 assert_eq!(approval.get("rejection_reason"), Some(&json!("")));
9172 assert!(
9173 approval
9174 .get("approved_at")
9175 .and_then(|value| value.as_str())
9176 .is_some_and(|value| !value.is_empty())
9177 );
9178
9179 let mut rejection = serde_json::Map::from_iter([
9180 ("id".to_owned(), json!(18)),
9181 ("status".to_owned(), json!("submitted")),
9182 ("workflow_state".to_owned(), json!("submitted")),
9183 ("approved_by".to_owned(), json!("owner1")),
9184 ("approved_at".to_owned(), json!("stale")),
9185 ("rejected_by".to_owned(), json!("")),
9186 ("rejected_at".to_owned(), json!("")),
9187 ("rejection_reason".to_owned(), json!("")),
9188 ]);
9189 let reject_primitive = json!({
9190 "transition_kind": "reject",
9191 "approver_input": "approver",
9192 "reason_input": "reason",
9193 "approved_by_field": "approved_by",
9194 "approved_at_field": "approved_at",
9195 "rejected_by_field": "rejected_by",
9196 "rejected_at_field": "rejected_at",
9197 "rejection_reason_field": "rejection_reason"
9198 });
9199 let reject_request = request_with_form("", "approver=owner2&reason=missing+context");
9200 dynamic_apply_approval_transition(
9201 &mut rejection,
9202 &reject_primitive,
9203 &reject_request,
9204 &BTreeMap::new(),
9205 )
9206 .expect("reject transition");
9207 assert_eq!(rejection.get("status"), Some(&json!("rejected")));
9208 assert_eq!(rejection.get("workflow_state"), Some(&json!("rejected")));
9209 assert_eq!(rejection.get("approved_by"), Some(&json!("")));
9210 assert_eq!(rejection.get("rejected_by"), Some(&json!("owner2")));
9211 assert_eq!(
9212 rejection.get("rejection_reason"),
9213 Some(&json!("missing context"))
9214 );
9215 assert!(
9216 rejection
9217 .get("rejected_at")
9218 .and_then(|value| value.as_str())
9219 .is_some_and(|value| !value.is_empty())
9220 );
9221 }
9222
9223 #[test]
9224 fn local_file_record_adapter_returns_false_when_append_path_is_not_writable_file() {
9226 let root = unique_test_dir("dynamic-file-record");
9227 let state_root = root.join("state");
9228 fs::create_dir_all(state_root.join("exports")).expect("create exports directory");
9229 let state_path = state_root.join("runtime-state.json");
9230 fs::write(&state_path, "{}").expect("write state");
9231 let integrations = json!({
9232 "adapters": [{
9233 "adapter_id": "local-board-history",
9234 "adapter_kind": "append_jsonl_local",
9235 "relative_path": "exports"
9236 }]
9237 });
9238 let action = json!({ "entity_id": "board_task" });
9239 let file_record = json!({
9240 "adapter_id": "local-board-history",
9241 "record": {
9242 "board_id": { "source": "literal", "value": 4 }
9243 }
9244 });
9245 let request = request_with_form("", "");
9246 assert!(!dynamic_append_file_record(
9247 &state_path,
9248 &integrations,
9249 &action,
9250 &file_record,
9251 &request,
9252 &BTreeMap::new(),
9253 "append_history",
9254 "board_task",
9255 "append_history_succeeded",
9256 0,
9257 ));
9258 fs::remove_dir_all(root).expect("remove test root");
9259 }
9260}