1#![allow(clippy::too_many_arguments)]
2
3#[cfg(test)]
4use pharos_runtime_docs::rustdoc::docs_cargo_executable_candidates;
5use pharos_runtime_docs::rustdoc::docs_run_cargo_doc;
6
7pub mod agent_architect;
9pub mod agent_bridge;
21pub mod agent_ingest;
23#[cfg(not(target_os = "wasi"))]
24pub mod agent_media;
26pub mod agent_vault;
28mod architect_chatgpt_http;
29mod architect_http;
30mod architect_import;
31mod architect_preview_accounts;
32mod architect_project_identity;
33mod event_discovery;
34mod outbound_url;
35#[cfg(not(target_os = "wasi"))]
36mod social_connections;
37use architect_chatgpt_http::*;
38use architect_http::*;
39#[cfg(not(target_os = "wasi"))]
41pub mod backup;
53pub mod business;
65pub mod cache;
77pub mod cache_key;
89pub mod connection;
101pub mod database;
113pub mod declarative;
125pub mod docs;
137pub mod dynamic;
149pub mod feature_runtime;
161pub mod features;
173pub mod fs;
185pub mod generated;
197pub mod http;
209pub mod job;
221pub mod json_commands;
233pub mod json_ops;
245#[cfg(not(target_os = "wasi"))]
246pub mod listener;
258pub mod ops_bridge;
270pub mod payment_provider;
272pub mod ui;
284
285pub mod reference;
297pub mod runtime_reports;
309pub mod runtime_stack;
321pub mod state_store;
333pub mod support;
345#[cfg(not(target_os = "wasi"))]
346pub mod worker_pool;
358
359use std::collections::{BTreeMap, BTreeSet};
360use std::fs as stdfs;
361use std::io::{Read, Seek, SeekFrom, Write};
362use std::net::{Shutdown, TcpListener, TcpStream};
363#[cfg(unix)]
364use std::os::unix::net::{UnixListener, UnixStream};
365use std::path::{Path, PathBuf};
366use std::sync::atomic::{AtomicBool, Ordering};
367use std::sync::{Arc, OnceLock, RwLock};
368use std::thread;
369use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
370
371use pharos_runtime_architect::preview_style::{
372 architect_preview_append_style_override, architect_preview_css_selector,
373 architect_preview_merge_property_override, architect_preview_property_override,
374 architect_preview_style_declarations, architect_preview_style_error_message,
375};
376use serde_json::{Map, Value};
377use sha2::{Digest, Sha256};
378
379use crate::artifact_server;
380use crate::config::{self, AppRuntime, ServeConfig};
381
382pub type RuntimeStack = pharos_runtime_core::stack::RuntimeStack;
383pub type RuntimeAppLoadFailure = pharos_runtime_core::stack::RuntimeAppLoadFailure;
384
385pub const MODULE_STATUS: &str = "COMPLETE";
397pub const PHAROS_RUNTIME_STACK_VERSION: &str = env!("CARGO_PKG_VERSION");
409pub const PHAROS_VERSION: &str = env!("CARGO_PKG_VERSION");
421pub const PHAROS_TARGET_ID: &str = "native-host-target";
433
434pub type FrameworkBuiltinResponse = pharos_runtime_core::builtin::FrameworkBuiltinResponse;
435
436#[derive(Debug, Clone, Copy, Eq, PartialEq)]
437pub enum PostgresTlsMode {
453 RustlsRequired,
454}
455
456#[derive(Debug, Clone, Copy, Eq, PartialEq)]
457pub enum CapabilityState {
475 Available,
476 Pending,
477 UnsupportedOnWasi,
478}
479
480#[derive(Debug, Clone, Copy, Eq, PartialEq)]
481pub struct RuntimeScaffold {
500 pub sqlite: CapabilityState,
501 pub postgresql: CapabilityState,
502 pub smtp: CapabilityState,
503 pub tls_mode: PostgresTlsMode,
504}
505
506pub const RUNTIME_CAPABILITY_STATUS: RuntimeScaffold = RuntimeScaffold {
518 sqlite: if cfg!(target_os = "wasi") {
519 CapabilityState::UnsupportedOnWasi
520 } else {
521 CapabilityState::Available
522 },
523 postgresql: if cfg!(target_os = "wasi") {
524 CapabilityState::UnsupportedOnWasi
525 } else {
526 CapabilityState::Available
527 },
528 smtp: if cfg!(target_os = "wasi") {
529 CapabilityState::UnsupportedOnWasi
530 } else {
531 CapabilityState::Available
532 },
533 tls_mode: PostgresTlsMode::RustlsRequired,
534};
535
536pub fn runtime_capability_status() -> &'static RuntimeScaffold {
552 &RUNTIME_CAPABILITY_STATUS
553}
554
555pub fn runtime_verb_supported(verb: &str) -> bool {
575 matches!(
576 verb,
577 "build" | "status" | "verify" | "run" | "probe" | "smoke" | "doctor"
578 )
579}
580
581pub fn runtime_report_id_for_verb(verb: &str) -> &'static str {
601 match verb {
602 "build" | "verify" => "pharos-local-app-build-v1",
603 "status" => "pharos-status-v1",
604 "run" | "probe" | "smoke" => "pharos-local-app-runtime-v1",
605 "doctor" => "pharos-doctor-v1",
606 _ => "pharos-local-app-runtime-v1",
607 }
608}
609
610fn read_manifest_json(path: &Path) -> Result<Value, &'static str> {
612 let text = stdfs::read_to_string(path).map_err(|_| "manifest missing")?;
613 serde_json::from_str(&text).map_err(|_| "manifest invalid")
614}
615
616fn read_source_manifest_value(app_root: &Path) -> Result<Value, &'static str> {
618 let yaml_path = app_root.join("pharos.app.yml");
619 if yaml_path.exists() {
620 let yaml_text = stdfs::read_to_string(&yaml_path).map_err(|_| "manifest missing")?;
621 return declarative::yaml_compile_to_json(&yaml_text).ok_or("manifest invalid");
622 }
623
624 read_manifest_json(&app_root.join("pharos.app.json"))
625}
626
627fn json_string(value: &Value, key: &str) -> Option<String> {
629 value
630 .get(key)
631 .and_then(Value::as_str)
632 .map(ToOwned::to_owned)
633}
634
635fn json_runtime_string(value: &Value, key: &str) -> Option<String> {
637 value
638 .get("runtime")
639 .and_then(Value::as_object)
640 .and_then(|runtime| runtime.get(key))
641 .and_then(Value::as_str)
642 .map(ToOwned::to_owned)
643}
644
645fn json_artifact_string(value: &Value, key: &str) -> Option<String> {
647 value
648 .get("artifact")
649 .and_then(Value::as_object)
650 .and_then(|artifact| artifact.get(key))
651 .and_then(Value::as_str)
652 .map(ToOwned::to_owned)
653}
654
655fn path_last_segment(path: &Path) -> Option<String> {
657 pharos_runtime_core::stack::path_last_segment(path)
658}
659
660fn runtime_framework_webhook_endpoint_id(path: &str) -> Option<String> {
662 dynamic::framework_webhook_endpoint_id(path)
663}
664
665fn runtime_integration_fixture_path(app: &AppRuntime) -> Option<PathBuf> {
667 let app_root = Path::new(app.app_root.as_deref()?.trim());
668 if app_root.as_os_str().is_empty() {
669 return None;
670 }
671 let manifest = read_manifest_json(&app_root.join("pharos.app.json")).ok()?;
672 let relative = manifest
673 .get("contracts")
674 .and_then(Value::as_object)
675 .and_then(|contracts| contracts.get("integration_contract"))
676 .and_then(Value::as_str)
677 .filter(|value| !value.is_empty())?;
678 let path = Path::new(relative);
679 Some(if path.is_absolute() {
680 path.to_path_buf()
681 } else {
682 app_root.join(path)
683 })
684}
685
686fn runtime_app_declares_framework_webhook_endpoint(app: &AppRuntime, endpoint_id: &str) -> bool {
688 if endpoint_id.is_empty() || app.host_profile.as_deref() != Some("dynamic-app") {
689 return false;
690 }
691 let Some(fixture_path) = runtime_integration_fixture_path(app) else {
692 return false;
693 };
694 let Ok(text) = stdfs::read_to_string(fixture_path) else {
695 return false;
696 };
697 let Ok(value) = serde_json::from_str::<Value>(&text) else {
698 return false;
699 };
700 dynamic::find_webhook_endpoint(&value, endpoint_id).is_some()
701}
702
703fn runtime_framework_webhook_response(
705 app: &AppRuntime,
706 request: &http::HttpRequest,
707 endpoint_id: &str,
708) -> Vec<u8> {
709 if request.method != "POST" {
710 return http_text_response(405, "Method Not Allowed", "method not allowed\n");
711 }
712 let Some(payload) = dynamic::request_body_text(request)
713 .filter(|body| body.len() <= 1_048_576 && serde_json::from_str::<Value>(body).is_ok())
714 else {
715 return http_json_response(
716 400,
717 "Bad Request",
718 r#"{"error":"invalid JSON payload"}"#,
719 &request.method,
720 );
721 };
722 let Some(integration_root) = runtime_integration_fixture_path(app)
723 .and_then(|path| stdfs::read_to_string(path).ok())
724 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
725 else {
726 return http_json_response(
727 500,
728 "Internal Server Error",
729 r#"{"error":"integration contract unavailable"}"#,
730 &request.method,
731 );
732 };
733 let Some(endpoint) = dynamic::find_webhook_endpoint(&integration_root, endpoint_id) else {
734 return http_text_response(404, "Not Found", "not found\n");
735 };
736 let verified = dynamic::webhook_request_signature_valid(endpoint, request, &payload);
737 let persisted = if app_uses_file_backed_runtime_state(app) {
738 app.state_path.as_deref().is_some_and(|state_path| {
739 let state_path = Path::new(state_path);
740 let Ok(_guard) = state_store::StateMutationGuard::acquire(state_path) else {
741 return false;
742 };
743 dynamic::append_webhook_receipt(
744 state_path,
745 endpoint_id,
746 if verified { "verified" } else { "rejected" },
747 &payload,
748 ) == 0
749 })
750 } else {
751 (|| {
752 let store = database::runtime_logical_app_store(app).ok()?;
753 pharos_runtime_database::webhook::append_webhook_receipt(
754 store.adapter.as_ref(),
755 &store.config,
756 &store.schema,
757 endpoint_id,
758 verified,
759 &payload,
760 )
761 .ok()
762 })()
763 .is_some()
764 };
765 if !persisted {
766 return http_json_response(
767 503,
768 "Service Unavailable",
769 r#"{"error":"receipt persistence unavailable"}"#,
770 &request.method,
771 );
772 }
773 if verified {
774 http_json_response(202, "Accepted", r#"{"status":"accepted"}"#, &request.method)
775 } else {
776 http_json_response(
777 401,
778 "Unauthorized",
779 r#"{"error":"signature verification failed"}"#,
780 &request.method,
781 )
782 }
783}
784
785fn infer_repo_root_from_app_root(app_root: &Path) -> Option<PathBuf> {
787 let apps_dir = app_root.parent()?;
788 if apps_dir.file_name().and_then(|value| value.to_str()) != Some("apps") {
789 return None;
790 }
791 apps_dir.parent().map(Path::to_path_buf)
792}
793
794fn resolve_build_dir_for_runtime_loading(
796 app_root: &Path,
797 build_dir: &str,
798 repo_root: Option<&Path>,
799) -> PathBuf {
800 let path = Path::new(build_dir);
801 if path.is_absolute() {
802 return path.to_path_buf();
803 }
804 if let Some(repo_root) = repo_root {
805 return repo_root.join(path);
806 }
807 if let Some(inferred_repo_root) = infer_repo_root_from_app_root(app_root) {
808 return inferred_repo_root.join(path);
809 }
810 path.to_path_buf()
811}
812
813pub fn load_app_runtime_from_artifact(artifact_dir: &Path) -> Result<AppRuntime, &'static str> {
837 let manifest_path = artifact_dir.join("pharos.app.json");
838 let manifest = read_manifest_json(&manifest_path)?;
839 if manifest
840 .get("artifact")
841 .and_then(|value| value.get("ownership_manifest"))
842 .and_then(Value::as_str)
843 .is_some()
844 && !verify_generated_ownership_manifest(artifact_dir)
845 {
846 return Err("generated artifact ownership verification failed");
847 }
848 let application_ir = match pharos_runtime_core::application_ir::compile_application_ir(
849 artifact_dir,
850 &manifest,
851 ) {
852 Ok(application_ir) => application_ir,
853 Err(error) => {
854 eprintln!("application contract compilation failed: {error:?}");
855 return Err("invalid application contract");
856 }
857 };
858 let mut app = AppRuntime {
859 application_ir: Some(std::sync::Arc::new(application_ir)),
860 artifact_dir: Some(artifact_dir.to_string_lossy().into_owned()),
861 app_id: json_string(&manifest, "app_id"),
862 app_root: Some(".".to_owned()),
863 build_dir: artifact_dir
864 .parent()
865 .map(|path| path.to_string_lossy().into_owned()),
866 host_profile: json_runtime_string(&manifest, "host_profile"),
867 state_path: json_runtime_string(&manifest, "state_path"),
868 base_path: json_runtime_string(&manifest, "base_path").or_else(|| Some(String::new())),
869 log_path: json_runtime_string(&manifest, "log_path"),
870 log_format: json_runtime_string(&manifest, "log_format")
871 .or_else(|| Some("service".to_owned())),
872 error_report_path: json_runtime_string(&manifest, "error_report_path")
873 .or_else(|| Some(String::new())),
874 log_level: json_runtime_string(&manifest, "log_level").or_else(|| Some("WARN".to_owned())),
875 trusted_proxy_cidrs: json_runtime_string(&manifest, "trusted_proxy_cidrs"),
876 ..AppRuntime::default()
877 };
878
879 if let Some(app_root) = app.app_root.as_deref()
880 && !app_root.is_empty()
881 && !Path::new(app_root).is_absolute()
882 {
883 app.app_root = Some(artifact_dir.join(app_root).to_string_lossy().into_owned());
884 }
885
886 if app.app_id.is_none()
887 && let Some(app_root) = app.app_root.as_deref()
888 {
889 let app_manifest_path = Path::new(app_root).join("pharos.app.json");
890 if let Ok(app_manifest) = read_manifest_json(&app_manifest_path) {
891 app.app_id = json_string(&app_manifest, "app_id");
892 }
893 }
894
895 if app.app_id.is_none() {
896 app.app_id = path_last_segment(artifact_dir);
897 }
898
899 let host_profile = app
900 .host_profile
901 .as_deref()
902 .filter(|value| !value.is_empty())
903 .ok_or("missing host_profile")?;
904 let app_id_present = app
905 .app_id
906 .as_deref()
907 .filter(|value| !value.is_empty())
908 .is_some();
909 let build_dir_present = app
910 .build_dir
911 .as_deref()
912 .filter(|value| !value.is_empty())
913 .is_some();
914 if !app_id_present || !build_dir_present {
915 return Err("missing runtime identity");
916 }
917 if !matches!(host_profile, "static-pages" | "dynamic-app") {
918 return Err("unsupported host_profile");
919 }
920
921 Ok(app)
922}
923
924pub fn inspect_app_artifact_json(artifact_dir: &Path) -> Result<String, &'static str> {
952 let app = load_app_runtime_from_artifact(artifact_dir)?;
953 let ir = app
954 .application_ir
955 .as_ref()
956 .ok_or("application IR missing")?;
957 let routes = ir
958 .routes
959 .values()
960 .map(|route| {
961 serde_json::json!({
962 "route_id": route.id.as_str(),
963 "path": route.path,
964 "kind": route.kind,
965 "auth_gate": route.auth_gate,
966 "policy_gate": route.policy_id.as_ref().map(|id| id.as_str()).unwrap_or_default(),
967 "security": {
968 "csrf_mode": route.security.csrf_mode,
969 "trusted_origin_policy": route.security.trusted_origin_policy,
970 "tenant_scope": route.security.tenant_scope,
971 "rate_limit_policy": route.security.rate_limit_policy,
972 "capability_policy": route.security.capability_policy,
973 },
974 "source": {
975 "path": route.source.path,
976 "pointer": route.source.pointer,
977 "line": route.source.line,
978 "column": route.source.column,
979 },
980 })
981 })
982 .collect::<Vec<_>>();
983 let actions = ir
984 .actions
985 .values()
986 .map(|action| {
987 serde_json::json!({
988 "action_id": action.id.as_str(),
989 "effects": {
990 "reads": action.effects.reads,
991 "writes": action.effects.writes,
992 "emits": action.effects.emits,
993 "enqueues": action.effects.enqueues,
994 "external_calls": action.effects.external_calls,
995 "cross_tenant": action.effects.cross_tenant,
996 },
997 "source": {
998 "path": action.source.path,
999 "pointer": action.source.pointer,
1000 "line": action.source.line,
1001 "column": action.source.column,
1002 },
1003 })
1004 })
1005 .collect::<Vec<_>>();
1006 serde_json::to_string_pretty(&serde_json::json!({
1007 "schema": "pharos.contract-inspection.v1",
1008 "application_ir_schema": ir.version,
1009 "contract_version": pharos_runtime_core::application_ir::CONTRACT_VERSION,
1010 "app_id": ir.app_id.as_str(),
1011 "contract_sha256": ir.contract_digest,
1012 "routes": routes,
1013 "actions": actions,
1014 "resource_budgets": {
1015 "max_response_bytes": app.resource_budgets.max_response_bytes,
1016 "max_query_result_bytes": app.resource_budgets.max_query_result_bytes,
1017 "max_report_bytes": app.resource_budgets.max_report_bytes,
1018 "max_state_section_items": app.resource_budgets.max_state_section_items,
1019 "max_workflow_effects": app.resource_budgets.max_workflow_effects,
1020 "max_jobs_per_poll": app.resource_budgets.max_jobs_per_poll,
1021 },
1022 "troubleshooting": {
1023 "contract": "inspect the source path and pointer reported for the failing declaration",
1024 "security": "compare the effective route security posture with the owning routes contract",
1025 "runtime": "inspect the effective resource budgets and app runtime configuration",
1026 },
1027 }))
1028 .map_err(|_| "inspection encode failed")
1029}
1030
1031pub fn inspect_app_performance_json(artifact_dir: &Path) -> Result<String, &'static str> {
1059 const WARMUP_ITERATIONS: usize = 2_000;
1060 const SAMPLE_ITERATIONS: usize = 20_000;
1061 const SAMPLE_COUNT: usize = 5;
1062 const MAX_NANOSECONDS_PER_LOOKUP: u128 = 250_000;
1063
1064 let app = load_app_runtime_from_artifact(artifact_dir)?;
1065 let routes = app
1066 .application_ir
1067 .as_ref()
1068 .map(|ir| {
1069 ir.routes
1070 .values()
1071 .map(|route| {
1072 let method = if route.kind == "page" { "GET" } else { "POST" };
1073 let path = route
1074 .path
1075 .split('/')
1076 .map(|segment| {
1077 if segment.starts_with(':')
1078 || (segment.starts_with('{') && segment.ends_with('}'))
1079 {
1080 "benchmark"
1081 } else {
1082 segment
1083 }
1084 })
1085 .collect::<Vec<_>>()
1086 .join("/");
1087 (method, path)
1088 })
1089 .collect::<Vec<_>>()
1090 })
1091 .filter(|routes| !routes.is_empty())
1092 .ok_or("performance inspection requires compiled routes")?;
1093
1094 for index in 0..WARMUP_ITERATIONS {
1095 let (method, path) = &routes[index % routes.len()];
1096 std::hint::black_box(matched_app_route(&app, method, path));
1097 }
1098
1099 let mut samples = Vec::with_capacity(SAMPLE_COUNT);
1100 let mut matched = 0usize;
1101 for sample in 0..SAMPLE_COUNT {
1102 let started = Instant::now();
1103 for index in 0..SAMPLE_ITERATIONS {
1104 let (method, path) = &routes[(sample * SAMPLE_ITERATIONS + index) % routes.len()];
1105 matched = matched.saturating_add(
1106 std::hint::black_box(matched_app_route(&app, method, path)).is_some() as usize,
1107 );
1108 }
1109 samples.push(started.elapsed().as_nanos());
1110 }
1111 if matched != SAMPLE_COUNT * SAMPLE_ITERATIONS {
1112 return Err("performance route corpus did not resolve");
1113 }
1114 let total_nanoseconds = samples.iter().copied().sum::<u128>();
1115 let lookup_count = (SAMPLE_COUNT * SAMPLE_ITERATIONS) as u128;
1116 let mean_nanoseconds = total_nanoseconds / lookup_count.max(1);
1117 let maximum_sample_nanoseconds = samples.iter().copied().max().unwrap_or(0);
1118 let maximum_sample_nanoseconds_per_lookup =
1119 maximum_sample_nanoseconds / SAMPLE_ITERATIONS as u128;
1120 if maximum_sample_nanoseconds_per_lookup > MAX_NANOSECONDS_PER_LOOKUP {
1121 return Err("route matching performance budget exceeded");
1122 }
1123 let throughput_per_second = lookup_count
1124 .saturating_mul(1_000_000_000)
1125 .checked_div(total_nanoseconds)
1126 .unwrap_or(u128::MAX);
1127 let configured_working_set_budget_bytes = app
1128 .resource_budgets
1129 .max_response_bytes
1130 .saturating_add(app.resource_budgets.max_query_result_bytes)
1131 .saturating_add(app.resource_budgets.max_report_bytes)
1132 .saturating_add(
1133 app.resource_budgets
1134 .max_state_section_items
1135 .saturating_mul(4_096),
1136 );
1137 serde_json::to_string_pretty(&serde_json::json!({
1138 "schema": "pharos.performance-evidence.v1",
1139 "status": "within_budget",
1140 "environment": {
1141 "os": std::env::consts::OS,
1142 "architecture": std::env::consts::ARCH,
1143 },
1144 "runtime_identity": runtime_build_identity_value(&app),
1145 "app_contract_sha256": app.application_ir.as_ref().map(|ir| ir.contract_digest.as_str()).unwrap_or_default(),
1146 "database_mode": "not_exercised_route_matcher",
1147 "warmup_iterations": WARMUP_ITERATIONS,
1148 "sample_count": SAMPLE_COUNT,
1149 "iterations_per_sample": SAMPLE_ITERATIONS,
1150 "sample_elapsed_nanoseconds": samples,
1151 "mean_nanoseconds_per_lookup": mean_nanoseconds,
1152 "maximum_sample_nanoseconds_per_lookup": maximum_sample_nanoseconds_per_lookup,
1153 "maximum_nanoseconds_per_lookup_budget": MAX_NANOSECONDS_PER_LOOKUP,
1154 "throughput_lookups_per_second": throughput_per_second,
1155 "configured_working_set_budget_bytes": configured_working_set_budget_bytes,
1156 }))
1157 .map_err(|_| "performance evidence encode failed")
1158}
1159
1160fn initialize_missing_app_runtime_state(app: &AppRuntime) -> Result<(), &'static str> {
1166 const MAX_INITIAL_STATE_BYTES: u64 = 8 * 1024 * 1024;
1167
1168 if app_uses_relational_runtime_authority(app) {
1169 return Ok(());
1170 }
1171
1172 let artifact_dir = app
1173 .artifact_dir
1174 .as_deref()
1175 .filter(|value| !value.is_empty())
1176 .ok_or("missing artifact_dir")?;
1177 let seed_path = Path::new(artifact_dir).join("data/live/runtime-state.json");
1178 let seed_metadata = match stdfs::symlink_metadata(&seed_path) {
1179 Ok(metadata) => metadata,
1180 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1181 Err(_) => return Err("runtime state seed metadata unreadable"),
1182 };
1183 if !seed_metadata.is_file()
1184 || seed_metadata.file_type().is_symlink()
1185 || seed_metadata.len() > MAX_INITIAL_STATE_BYTES
1186 {
1187 return Err("runtime state seed must be a bounded regular file");
1188 }
1189 let seed = stdfs::read(&seed_path).map_err(|_| "runtime state seed unreadable")?;
1190 let seed_root = serde_json::from_slice::<Value>(&seed)
1191 .map_err(|_| "runtime state seed must be valid JSON")?;
1192 if seed_root
1193 .as_object()
1194 .and_then(|object| object.get("schema"))
1195 .and_then(Value::as_str)
1196 .is_none_or(str::is_empty)
1197 {
1198 return Err("runtime state seed must declare a schema");
1199 }
1200
1201 let state_path = app
1202 .state_path
1203 .as_deref()
1204 .filter(|value| !value.is_empty())
1205 .map(Path::new)
1206 .ok_or("runtime state seed requires state_path")?;
1207 if !state_path.is_absolute() {
1208 return Err("runtime state path must be absolute after finalization");
1209 }
1210 match stdfs::symlink_metadata(state_path) {
1211 Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => return Ok(()),
1212 Ok(_) => return Err("runtime state destination must be a regular file"),
1213 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1214 Err(_) => return Err("runtime state destination metadata unreadable"),
1215 }
1216
1217 let parent = state_path
1218 .parent()
1219 .filter(|path| !path.as_os_str().is_empty())
1220 .ok_or("runtime state destination parent missing")?;
1221 stdfs::create_dir_all(parent).map_err(|_| "runtime state destination parent unavailable")?;
1222 let parent_metadata = stdfs::symlink_metadata(parent)
1223 .map_err(|_| "runtime state destination parent metadata unreadable")?;
1224 if !parent_metadata.is_dir() || parent_metadata.file_type().is_symlink() {
1225 return Err("runtime state destination parent must be a directory");
1226 }
1227
1228 let mut open_options = stdfs::OpenOptions::new();
1229 open_options.write(true).create_new(true);
1230 #[cfg(unix)]
1231 {
1232 use std::os::unix::fs::OpenOptionsExt;
1233 open_options.mode(0o640);
1234 }
1235 let mut state_file = match open_options.open(state_path) {
1236 Ok(file) => file,
1237 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1238 let metadata = stdfs::symlink_metadata(state_path)
1239 .map_err(|_| "runtime state destination metadata unreadable")?;
1240 return if metadata.is_file() && !metadata.file_type().is_symlink() {
1241 Ok(())
1242 } else {
1243 Err("runtime state destination must be a regular file")
1244 };
1245 }
1246 Err(_) => return Err("runtime state destination could not be created"),
1247 };
1248 let initialization_result = (|| -> std::io::Result<()> {
1249 state_file.write_all(&seed)?;
1250 #[cfg(unix)]
1251 {
1252 use std::os::unix::fs::PermissionsExt;
1253 stdfs::set_permissions(state_path, stdfs::Permissions::from_mode(0o640))?;
1254 }
1255 state_file.sync_all()
1256 })();
1257 if initialization_result.is_err() {
1258 drop(state_file);
1259 let _ = stdfs::remove_file(state_path);
1260 return Err("runtime state seed initialization failed");
1261 }
1262 Ok(())
1263}
1264
1265pub fn packaged_artifact_root_matches(app_root: &Path, target_id: &str) -> bool {
1286 if app_root.as_os_str().is_empty() || target_id.is_empty() {
1287 return false;
1288 }
1289 let manifest_path = app_root.join("pharos.app.json");
1290 let Ok(manifest) = read_manifest_json(&manifest_path) else {
1291 return false;
1292 };
1293
1294 if app_root.join("app.conf").is_file() && app_root.join("start.sh").is_file() {
1298 return false;
1299 }
1300
1301 let is_artifact = json_string(&manifest, "app_id").as_deref() == Some(target_id)
1302 && json_runtime_string(&manifest, "build_artifact_dir").as_deref() == Some(".")
1303 && json_artifact_string(&manifest, "schema").as_deref() == Some("pharos.app.artifact.v1")
1304 && json_artifact_string(&manifest, "app_root").as_deref() == Some(".")
1305 && json_artifact_string(&manifest, "artifact_dir").as_deref() == Some(".");
1306 if is_artifact {
1307 return true;
1308 }
1309
1310 false
1311}
1312
1313pub fn app_runtime_conf_required_present(app_root: &Path) -> bool {
1333 config::app_local_conf_path(app_root).is_some()
1334}
1335
1336fn app_uses_relational_runtime_authority(app: &AppRuntime) -> bool {
1337 if app.host_profile.as_deref() == Some("dynamic-app") {
1338 return true;
1339 }
1340 [app.app_root.as_deref(), app.artifact_dir.as_deref()]
1341 .into_iter()
1342 .flatten()
1343 .map(Path::new)
1344 .any(database::relational_runtime_authority_enabled)
1345}
1346
1347fn app_uses_file_backed_runtime_state(app: &AppRuntime) -> bool {
1348 app.state_path
1349 .as_deref()
1350 .is_some_and(|value| !value.is_empty())
1351 && !app_uses_relational_runtime_authority(app)
1352}
1353
1354fn append_optional_file_fingerprint(digest: &mut Sha256, path: Option<&Path>) {
1355 let Some(path) = path else {
1356 digest.update(b"missing");
1357 digest.update([0]);
1358 return;
1359 };
1360 digest.update(path.to_string_lossy().as_bytes());
1361 digest.update([0]);
1362 if let Some(fingerprint) = metadata_fingerprint_for_path(path) {
1363 digest.update(fingerprint.as_bytes());
1364 } else {
1365 digest.update(b"missing");
1366 }
1367 digest.update([0]);
1368}
1369
1370fn verify_app_migration_state_cache_key(
1372 app: &AppRuntime,
1373 app_root: &Path,
1374 runtime_conf: Option<&Path>,
1375) -> Option<String> {
1376 let app_id = app.app_id.as_deref()?;
1377 let contract_root = app
1378 .artifact_dir
1379 .as_deref()
1380 .map(Path::new)
1381 .unwrap_or(app_root);
1382 let contract = database::selected_logical_contract(contract_root, app_id).ok()??;
1383 let config = database::load_cli_backend_config(app_root, runtime_conf, Some(app_id))?;
1384 let adapter = database::operations::resolve(&config).ok()?;
1385 let ledger = adapter.migration_ledger(&config, app_id).ok()?;
1386 let source = serde_json::json!({
1387 "schema": "pharos.logical-migration-startup-cache.v2",
1388 "contract": contract,
1389 "ledger": ledger,
1390 "backend": config.backend,
1391 "host": config.host,
1392 "port": config.port,
1393 "database": config.database,
1394 "user": config.user,
1395 "local_path": config.sqlite_path,
1396 "socket_path": config.socket_path,
1397 "options": config.options,
1398 });
1399 let mut digest = Sha256::new();
1400 digest.update(serde_json::to_vec(&source).ok()?);
1401 append_optional_file_fingerprint(&mut digest, runtime_conf);
1402 append_optional_file_fingerprint(
1403 &mut digest,
1404 config::app_local_conf_path(app_root).as_deref(),
1405 );
1406 Some(
1407 digest
1408 .finalize()
1409 .iter()
1410 .map(|byte| format!("{byte:02x}"))
1411 .collect(),
1412 )
1413}
1414
1415fn app_state_cache_sibling_path(app: &AppRuntime, suffix: &str) -> Option<PathBuf> {
1416 let state_path = app
1417 .state_path
1418 .as_deref()
1419 .filter(|value| !value.is_empty())
1420 .map(Path::new)?;
1421 let app_id = app
1422 .app_id
1423 .as_deref()
1424 .filter(|value| !value.is_empty())
1425 .unwrap_or("app");
1426 Some(
1427 state_path
1428 .parent()?
1429 .join(format!(".pharos-{app_id}-{suffix}.json")),
1430 )
1431}
1432
1433fn migration_state_cache_path(app: &AppRuntime) -> Option<PathBuf> {
1434 app_state_cache_sibling_path(app, "migration-startup-cache")
1435}
1436
1437fn cached_verified_migration_state(app: &AppRuntime, cache_key: &str) -> bool {
1438 let Some(path) = migration_state_cache_path(app) else {
1439 return false;
1440 };
1441 stdfs::read_to_string(path)
1442 .ok()
1443 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
1444 .is_some_and(|root| {
1445 root.get("schema").and_then(Value::as_str) == Some("pharos.migration-startup-cache.v1")
1446 && root.get("status").and_then(Value::as_str) == Some("verified")
1447 && root.get("cache_key").and_then(Value::as_str) == Some(cache_key)
1448 })
1449}
1450
1451fn write_verified_migration_state_cache(app: &AppRuntime, cache_key: &str) {
1452 let Some(path) = migration_state_cache_path(app) else {
1453 return;
1454 };
1455 let Some(parent) = path.parent() else {
1456 return;
1457 };
1458 if stdfs::create_dir_all(parent).is_err() {
1459 return;
1460 }
1461 let now = SystemTime::now()
1462 .duration_since(UNIX_EPOCH)
1463 .map(|value| value.as_secs())
1464 .unwrap_or_default();
1465 let document = serde_json::json!({
1466 "schema": "pharos.migration-startup-cache.v1",
1467 "status": "verified",
1468 "cache_key": cache_key,
1469 "verified_at_unix": now,
1470 });
1471 if let Ok(encoded) = serde_json::to_vec_pretty(&document) {
1472 let _ = stdfs::write(path, encoded);
1473 }
1474}
1475
1476fn verify_app_migration_state(app: &AppRuntime) -> Result<(), &'static str> {
1478 let Some(app_root) = app.app_root.as_deref().map(Path::new) else {
1479 return Ok(());
1480 };
1481 if json_commands::manifest_database_contract_path(app_root).is_none() {
1482 return Ok(());
1483 }
1484 let runtime_conf = app.runtime_conf_path.as_deref().map(Path::new);
1485 let app_id = app
1486 .app_id
1487 .as_deref()
1488 .ok_or("database app identity unavailable")?;
1489 let contract_root = app
1490 .artifact_dir
1491 .as_deref()
1492 .map(Path::new)
1493 .unwrap_or(app_root);
1494 let contract = database::selected_logical_contract(contract_root, app_id)
1495 .map_err(|_| "logical database contract invalid")?
1496 .ok_or("logical database contract required")?;
1497 let config = database::load_cli_backend_config(app_root, runtime_conf, Some(app_id))
1498 .ok_or("database configuration unavailable")?;
1499 let adapter =
1500 database::operations::resolve(&config).map_err(|_| "database backend unavailable")?;
1501 let cache_key = verify_app_migration_state_cache_key(app, app_root, runtime_conf);
1502 if cache_key
1503 .as_deref()
1504 .is_some_and(|key| cached_verified_migration_state(app, key))
1505 {
1506 adapter
1507 .ready(&config, &contract.schema)
1508 .map_err(|_| "logical database schema unavailable")?;
1509 return Ok(());
1510 }
1511 adapter
1512 .verify_contract(&config, &contract)
1513 .map_err(|error| {
1514 eprintln!("logical database verification failed for {app_id}: {error:?}");
1515 "logical database contract verification failed"
1516 })?;
1517 if let Some(key) = cache_key.as_deref() {
1518 write_verified_migration_state_cache(app, key);
1519 }
1520 Ok(())
1521}
1522
1523pub fn load_packaged_artifact(
1548 config_record: &ServeConfig,
1549 artifact_root: &Path,
1550) -> Result<(AppRuntime, String), &'static str> {
1551 pharos_runtime_core::stack::load_packaged_artifact_with_service(
1552 &RootRuntimeAppLoadService,
1553 config_record,
1554 artifact_root,
1555 )
1556}
1557
1558pub fn runtime_build_dir_override(config: &ServeConfig, app_root: &Path) -> Option<String> {
1579 if let Some(value) = config
1580 .build_dir
1581 .as_deref()
1582 .filter(|value| !value.is_empty())
1583 {
1584 return config::effective_app_build_dir(app_root, Some(value));
1585 }
1586 let repo_dist = app_root
1587 .parent()
1588 .filter(|parent| parent.file_name().and_then(|name| name.to_str()) == Some("apps"))
1589 .and_then(Path::parent)
1590 .map(|root| root.join("dist"));
1591 repo_dist
1592 .or_else(|| config::effective_app_build_dir(app_root, None).map(PathBuf::from))
1593 .map(|path| path.to_string_lossy().into_owned())
1594}
1595
1596pub fn prepare_app_artifact(
1622 app_root: &Path,
1623 app_id: &str,
1624 build_dir_override: Option<&str>,
1625) -> Result<String, i32> {
1626 if app_root.as_os_str().is_empty() || app_id.is_empty() {
1627 return Err(64);
1628 }
1629 if !config::app_local_conf_path(app_root).is_some() {
1630 return Err(66);
1631 }
1632 let build_dir = config::effective_app_build_dir(app_root, build_dir_override).ok_or(69)?;
1633 let artifact_dir =
1634 config::effective_app_artifact_dir(app_root, Path::new(&build_dir), app_id).ok_or(69)?;
1635 Path::new(&artifact_dir)
1636 .is_dir()
1637 .then_some(artifact_dir)
1638 .ok_or(66)
1639}
1640
1641pub fn log_runtime_app_config_missing(
1667 config: &ServeConfig,
1668 app_root: &Path,
1669 app_id_hint: Option<&str>,
1670) {
1671 let Some(log_path) = config.log_path.as_deref().filter(|value| !value.is_empty()) else {
1672 return;
1673 };
1674 let message = format!(
1675 "app configuration missing: app_id={} app_root={}\n",
1676 app_id_hint.unwrap_or("unknown"),
1677 app_root.display()
1678 );
1679 if message.len() > 4096 {
1680 return;
1681 }
1682 if let Some(parent) = Path::new(log_path).parent()
1683 && stdfs::create_dir_all(parent).is_err()
1684 {
1685 eprintln!(
1686 "runtime diagnostic log directory unavailable: {}",
1687 parent.display()
1688 );
1689 return;
1690 }
1691 let mut existing = stdfs::read_to_string(log_path).unwrap_or_default();
1692 if existing.len().saturating_add(message.len()) <= 64 * 1024 {
1693 existing.push_str(&message);
1694 if fs::write_binary_file(Path::new(log_path), existing.as_bytes(), 0o640).is_err() {
1695 eprintln!("runtime diagnostic log unavailable: {log_path}");
1696 }
1697 }
1698}
1699
1700pub fn load_app_runtime_for_single_app_direct(
1726 config_record: &ServeConfig,
1727 repo_root: Option<&Path>,
1728 binary_dir: Option<&Path>,
1729) -> Result<AppRuntime, &'static str> {
1730 pharos_runtime_core::stack::load_app_runtime_for_single_app_direct_with_service(
1731 &RootRuntimeAppLoadService,
1732 config_record,
1733 repo_root,
1734 binary_dir,
1735 )
1736}
1737
1738pub fn runtime_apps_dir(conf_path: Option<&Path>) -> Option<String> {
1758 let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1759 pharos_runtime_core::stack::runtime_apps_dir(conf_path, ¤t_dir)
1760}
1761
1762fn runtime_loaded_app_context(app: &AppRuntime) -> String {
1764 let mount = app.base_path.as_deref().unwrap_or_default();
1765 let context = mount.strip_prefix('/').unwrap_or(mount);
1766 if context.is_empty() {
1767 app.app_id.clone().unwrap_or_default()
1768 } else {
1769 context.to_owned()
1770 }
1771}
1772
1773pub fn runtime_log_banner_lines(
1800 stack: &RuntimeStack,
1801 config_record: &ServeConfig,
1802 load_ms: i64,
1803 timestamp: &str,
1804) -> Vec<String> {
1805 let loaded_apps = stack
1806 .apps
1807 .iter()
1808 .map(runtime_loaded_app_context)
1809 .filter(|context| !context.is_empty())
1810 .collect::<Vec<_>>()
1811 .join(",");
1812 let listening_on = config_record
1813 .socket_path
1814 .as_deref()
1815 .filter(|value| !value.is_empty())
1816 .or_else(|| {
1817 stack
1818 .socket_path
1819 .as_deref()
1820 .filter(|value| !value.is_empty())
1821 })
1822 .or_else(|| {
1823 config_record
1824 .host
1825 .as_deref()
1826 .filter(|value| !value.is_empty())
1827 })
1828 .or_else(|| stack.host.as_deref().filter(|value| !value.is_empty()))
1829 .unwrap_or("127.0.0.1");
1830 let contract_identities = stack
1831 .apps
1832 .iter()
1833 .map(|app| {
1834 format!(
1835 "{}:{}",
1836 app.app_id.as_deref().unwrap_or("app"),
1837 app.application_ir
1838 .as_ref()
1839 .map(|ir| ir.contract_digest.as_str())
1840 .unwrap_or("unrecorded")
1841 )
1842 })
1843 .collect::<Vec<_>>()
1844 .join(",");
1845
1846 let mut lines = vec![
1847 format!("{timestamp} INFO Pharos {PHAROS_RUNTIME_STACK_VERSION} threaded: true"),
1848 format!(
1849 "{timestamp} INFO build_identity source_revision={} cargo_lock_sha256={} app_contracts={contract_identities}",
1850 option_env!("PHAROS_SOURCE_REVISION").unwrap_or("unrecorded"),
1851 option_env!("PHAROS_CARGO_LOCK_SHA256").unwrap_or("unrecorded"),
1852 ),
1853 format!("{timestamp} INFO app_count: {}", stack.apps.len()),
1854 format!("{timestamp} INFO loaded_apps: {loaded_apps}"),
1855 format!("{timestamp} STATUS listening_on: {listening_on} started in: {load_ms}ms"),
1856 ];
1857 if !stack.failed_apps.is_empty() {
1858 let failed_apps = stack
1859 .failed_apps
1860 .iter()
1861 .map(|app| {
1862 format!(
1863 "{}:{}",
1864 if app.app_id.is_empty() {
1865 app.app_root.as_str()
1866 } else {
1867 app.app_id.as_str()
1868 },
1869 app.error
1870 )
1871 })
1872 .collect::<Vec<_>>()
1873 .join(",");
1874 lines.push(format!(
1875 "{timestamp} WARN failed_app_count: {} quarantined_apps: {failed_apps}",
1876 stack.failed_apps.len()
1877 ));
1878 }
1879 lines
1880}
1881
1882fn runtime_app_summary_value(app: &AppRuntime) -> Value {
1884 serde_json::json!({
1885 "app_id": app.app_id.clone().unwrap_or_default(),
1886 "mount_path": app.base_path.clone().unwrap_or_default(),
1887 "app_root": app.app_root.clone().unwrap_or_default(),
1888 "build_dir": app.build_dir.clone().unwrap_or_default(),
1889 "state_path": app.state_path.clone().unwrap_or_default(),
1890 "log_path": app.log_path.clone().unwrap_or_default(),
1891 "log_format": app.log_format.clone().unwrap_or_else(|| "service".to_owned()),
1892 "error_report_path": app.error_report_path.clone().unwrap_or_default(),
1893 "log_level": app.log_level.clone().unwrap_or_else(|| "WARN".to_owned()),
1894 "host_profile": app.host_profile.clone().unwrap_or_else(|| "dynamic-app".to_owned()),
1895 "runtime_conf_path": app.runtime_conf_path.clone().unwrap_or_default(),
1896 "backend": app_runtime_backend_summary_value(app),
1897 "runtime_identity": runtime_build_identity_value(app),
1898 })
1899}
1900
1901fn runtime_failed_app_summary_value(app: &RuntimeAppLoadFailure) -> Value {
1903 serde_json::json!({
1904 "app_id": app.app_id,
1905 "mount_path": app.mount_path,
1906 "app_root": app.app_root,
1907 "artifact_dir": app.artifact_dir,
1908 "error": app.error,
1909 })
1910}
1911
1912fn sha256_hex(bytes: &[u8]) -> String {
1914 pharos_runtime_core::support::sha256_hex(bytes)
1915}
1916
1917fn runtime_config_fingerprint(app: &AppRuntime) -> String {
1919 let value = serde_json::json!({
1920 "app_id": app.app_id.as_deref().unwrap_or_default(),
1921 "mount": app.base_path.as_deref().unwrap_or_default(),
1922 "host_profile": app.host_profile.as_deref().unwrap_or_default(),
1923 "queue_enabled": app.queue_enabled,
1924 "queue_poll_interval_ms": app.queue_poll_interval_ms,
1925 "queue_max_attempts": app.queue_max_attempts,
1926 "queue_retry_seconds": app.queue_retry_seconds,
1927 "resource_budgets": {
1928 "max_response_bytes": app.resource_budgets.max_response_bytes,
1929 "max_query_result_bytes": app.resource_budgets.max_query_result_bytes,
1930 "max_report_bytes": app.resource_budgets.max_report_bytes,
1931 "max_state_section_items": app.resource_budgets.max_state_section_items,
1932 "max_workflow_effects": app.resource_budgets.max_workflow_effects,
1933 "max_jobs_per_poll": app.resource_budgets.max_jobs_per_poll,
1934 "integration_connect_timeout_ms": app.resource_budgets.integration_connect_timeout_ms,
1935 "integration_operation_timeout_ms": app.resource_budgets.integration_operation_timeout_ms,
1936 "integration_max_response_bytes": app.resource_budgets.integration_max_response_bytes,
1937 },
1938 });
1939 sha256_hex(value.to_string().as_bytes())
1940}
1941
1942fn runtime_build_identity_value(app: &AppRuntime) -> Value {
1944 serde_json::json!({
1945 "schema": "pharos.runtime-identity.v1",
1946 "runtime_version": env!("CARGO_PKG_VERSION"),
1947 "source_revision": option_env!("PHAROS_SOURCE_REVISION").unwrap_or("unrecorded"),
1948 "cargo_lock_sha256": option_env!("PHAROS_CARGO_LOCK_SHA256").unwrap_or("unrecorded"),
1949 "configuration_sha256": runtime_config_fingerprint(app),
1950 "application_ir_schema": pharos_runtime_core::application_ir::APPLICATION_IR_VERSION,
1951 "app_contract_sha256": app.application_ir.as_ref().map(|ir| ir.contract_digest.as_str()).unwrap_or("unrecorded"),
1952 })
1953}
1954
1955fn runtime_default_environment(app: &AppRuntime) -> String {
1957 let Some(app_root) = app.app_root.as_deref().filter(|value| !value.is_empty()) else {
1958 return "development".to_owned();
1959 };
1960 cache::dynamic_config_allowlist_value(
1961 Path::new(app_root),
1962 app.runtime_conf_path.as_deref().map(Path::new),
1963 "environment",
1964 )
1965 .filter(|value| !value.is_empty())
1966 .unwrap_or_else(|| "development".to_owned())
1967}
1968
1969fn warm_runtime_config_allowlist(app: &AppRuntime) {
1971 let Some(app_root) = app.app_root.as_deref().filter(|value| !value.is_empty()) else {
1972 return;
1973 };
1974 let app_root = Path::new(app_root);
1975 let runtime_conf = app.runtime_conf_path.as_deref().map(Path::new);
1976 for key in ["environment", "base_path", "base_url"] {
1977 let _ = cache::dynamic_config_allowlist_value(app_root, runtime_conf, key);
1978 }
1979}
1980
1981fn runtime_default_backend(app: &AppRuntime) -> String {
1983 let Some(app_root) = app.app_root.as_deref().filter(|value| !value.is_empty()) else {
1984 return "sqlite".to_owned();
1985 };
1986 cache::dynamic_config_allowlist_value(
1987 Path::new(app_root),
1988 app.runtime_conf_path.as_deref().map(Path::new),
1989 "db_backend",
1990 )
1991 .filter(|value| !value.is_empty())
1992 .unwrap_or_else(|| "sqlite".to_owned())
1993}
1994
1995pub(super) fn app_runtime_backend_summary_value(app: &AppRuntime) -> Value {
1997 let Some(_) = app.app_root.as_deref().filter(|value| !value.is_empty()) else {
1998 return serde_json::json!({
1999 "db_backend": "",
2000 "db_ready": false,
2001 });
2002 };
2003 let backend = runtime_default_backend(app);
2004 let ready = database::runtime_database_ready(app).unwrap_or(false);
2005 serde_json::json!({
2006 "db_backend": backend,
2007 "db_ready": ready,
2008 })
2009}
2010
2011fn environment_valid(environment: &str) -> bool {
2013 matches!(
2014 environment,
2015 "development" | "staging" | "production" | "test"
2016 )
2017}
2018
2019fn output_path_check(path: Option<&str>, field: &str, allow_not_configured: bool) -> Value {
2021 let status = match path {
2022 Some("") | None if allow_not_configured => "not_configured",
2023 Some("") | None => "missing",
2024 Some(path) => {
2025 let parent_exists = Path::new(path)
2026 .parent()
2027 .is_some_and(|parent| parent.exists() || parent.as_os_str().is_empty());
2028 if parent_exists {
2029 "ok"
2030 } else {
2031 "missing_parent_dir"
2032 }
2033 }
2034 };
2035 serde_json::json!({
2036 "field": field,
2037 "path": path.unwrap_or_default(),
2038 "status": status,
2039 })
2040}
2041
2042fn db_check_json(app: &AppRuntime, backend: &str) -> Value {
2044 let backend_config = app
2045 .app_root
2046 .as_deref()
2047 .filter(|value| !value.is_empty())
2048 .and_then(|app_root| {
2049 database::load_cli_backend_config(
2050 Path::new(app_root),
2051 app.runtime_conf_path.as_deref().map(Path::new),
2052 app.app_id.as_deref(),
2053 )
2054 });
2055 let db_path = backend_config
2056 .as_ref()
2057 .and_then(|config| config.sqlite_path.as_ref())
2058 .map(|path| path.to_string_lossy().into_owned())
2059 .unwrap_or_default();
2060 let status = match backend_config.as_ref() {
2061 Some(config)
2062 if config.backend == backend && database::operations::resolve(config).is_ok() =>
2063 {
2064 match config.sqlite_path.as_deref() {
2065 Some(path) if path.as_os_str().is_empty() => "incomplete",
2066 Some(path)
2067 if path.parent().is_none_or(|parent| {
2068 !parent.exists() && !parent.as_os_str().is_empty()
2069 }) =>
2070 {
2071 "missing_parent_dir"
2072 }
2073 _ => "ok",
2074 }
2075 }
2076 _ => "invalid",
2077 };
2078 serde_json::json!({
2079 "backend": backend,
2080 "db_path": db_path,
2081 "status": status,
2082 })
2083}
2084
2085fn build_app_config_report_local_json(app: &AppRuntime) -> Value {
2087 let environment = runtime_default_environment(app);
2088 let backend = runtime_default_backend(app);
2089 let db_checks = db_check_json(app, &backend);
2090 let log_check = output_path_check(app.log_path.as_deref(), "app_log_path", false);
2091 let error_check = output_path_check(
2092 app.error_report_path.as_deref(),
2093 "app_error_report_path",
2094 true,
2095 );
2096 let db_ok = db_checks
2097 .get("status")
2098 .and_then(Value::as_str)
2099 .is_some_and(|status| status == "ok");
2100 let log_ok = log_check
2101 .get("status")
2102 .and_then(Value::as_str)
2103 .is_some_and(|status| status == "ok");
2104 let error_ok = error_check
2105 .get("status")
2106 .and_then(Value::as_str)
2107 .is_some_and(|status| matches!(status, "ok" | "not_configured"));
2108 let status = if environment_valid(&environment) && db_ok && log_ok && error_ok {
2109 "ok"
2110 } else {
2111 "invalid"
2112 };
2113
2114 serde_json::json!({
2115 "environment": environment,
2116 "backend": backend,
2117 "environment_valid": environment_valid(&runtime_default_environment(app)),
2118 "log_path": app.log_path.clone().unwrap_or_default(),
2119 "error_report_path": app.error_report_path.clone().unwrap_or_default(),
2120 "queue_enabled": app.queue_enabled,
2121 "queue_poll_interval_ms": app.queue_poll_interval_ms,
2122 "queue_max_attempts": app.queue_max_attempts,
2123 "queue_retry_seconds": app.queue_retry_seconds,
2124 "required_env_refs": [],
2125 "secret_env_refs": [],
2126 "required_env_checks": [],
2127 "secret_env_checks": [],
2128 "db_checks": db_checks,
2129 "log_checks": [log_check, error_check],
2130 "issues": [],
2131 "status": status,
2132 })
2133}
2134
2135fn build_app_doctor_report_json(
2137 artifact_dir: &str,
2138 app: &AppRuntime,
2139) -> Result<String, &'static str> {
2140 let config_report = build_app_config_report_local_json(app);
2141 let build_verify_report = serde_json::from_str::<Value>(&app_runtime_report_json(
2142 "pharos-local-app-build-v1",
2143 "verify",
2144 artifact_dir,
2145 app,
2146 )?)
2147 .map_err(|_| "report encode failed")?;
2148 let backend = runtime_default_backend(app);
2149 let migrate_ready = verify_app_migration_state(app).is_ok()
2150 && database::runtime_database_ready(app).unwrap_or(false);
2151 let migrate_verify_status = if migrate_ready {
2152 "verified"
2153 } else {
2154 "verification_failed"
2155 };
2156 let migrate_verify_error = if migrate_ready {
2157 Value::Null
2158 } else {
2159 Value::String("database readiness verification failed".to_owned())
2160 };
2161 let overall_status = if config_report
2162 .get("status")
2163 .and_then(Value::as_str)
2164 .is_some_and(|status| status == "ok")
2165 && migrate_verify_status == "verified"
2166 {
2167 "ok"
2168 } else {
2169 "needs_attention"
2170 };
2171
2172 let report = serde_json::to_string(&serde_json::json!({
2173 "report_id": "pharos-doctor-v1",
2174 "target_kind": "app",
2175 "target_id": app.app_id.clone().unwrap_or_default(),
2176 "backend": backend,
2177 "status": overall_status,
2178 "runtime_summary": runtime_app_summary_value(app),
2179 "config": config_report,
2180 "migrate_verify_status": migrate_verify_status,
2181 "build_verify_status": "ok",
2182 "migrate_verify_error": migrate_verify_error,
2183 "build_verify_error": Value::Null,
2184 "migrate_verify_report": Value::Null,
2185 "build_verify_report": build_verify_report,
2186 }))
2187 .map_err(|_| "report encode failed")?;
2188 (report.len() <= app.resource_budgets.max_report_bytes)
2189 .then_some(report)
2190 .ok_or("report resource budget exceeded")
2191}
2192
2193fn write_report_file(path: &Path, content: &str) -> Result<(), &'static str> {
2195 if let Some(parent) = path.parent() {
2196 stdfs::create_dir_all(parent).map_err(|_| "report write failed")?;
2197 }
2198 fs::write_binary_file(path, content.as_bytes(), 0o640).map_err(|_| "report write failed")
2199}
2200
2201fn website_report_path(build_dir: &Path, verb: &str, target_id: &str) -> PathBuf {
2203 build_dir
2204 .join("reports")
2205 .join(format!("{verb}-website-{target_id}.json"))
2206}
2207
2208fn path_has_runtime_component(path: &Path) -> bool {
2210 path.components()
2211 .any(|component| component.as_os_str() == "runtime")
2212}
2213
2214fn runtime_fingerprint_target(path: &Path) -> bool {
2216 path.file_name()
2217 .and_then(|value| value.to_str())
2218 .is_some_and(|name| matches!(name, "pharos.app.json" | "app.conf"))
2219 || path_has_runtime_component(path)
2220}
2221
2222fn collect_runtime_fingerprint_files(
2224 root: &Path,
2225 depth: usize,
2226 max_depth: usize,
2227 files: &mut Vec<PathBuf>,
2228) -> Result<(), &'static str> {
2229 let directory = match stdfs::read_dir(root) {
2230 Ok(directory) => directory,
2231 Err(_) => return Ok(()),
2232 };
2233 for entry in directory.flatten() {
2234 let path = entry.path();
2235 if path.is_dir() {
2236 if depth < max_depth {
2237 collect_runtime_fingerprint_files(&path, depth + 1, max_depth, files)?;
2238 }
2239 } else if runtime_fingerprint_target(&path) {
2240 files.push(path);
2241 }
2242 }
2243 Ok(())
2244}
2245
2246pub fn runtime_apps_fingerprint(apps_dir: &Path) -> Result<Option<String>, &'static str> {
2274 if apps_dir.as_os_str().is_empty() {
2275 return Ok(None);
2276 }
2277 let mut files = Vec::new();
2278 collect_runtime_fingerprint_files(apps_dir, 0, 2, &mut files)?;
2279 if files.is_empty() {
2280 return Ok(None);
2281 }
2282 files.sort();
2283 let mut hasher = Sha256::new();
2284 for path in files {
2285 let content = stdfs::read_to_string(&path).map_err(|_| "apps_dir unreadable")?;
2286 hasher.update(content.as_bytes());
2287 }
2288 let digest = hasher.finalize();
2289 let hex = digest
2290 .iter()
2291 .map(|byte| format!("{byte:02x}"))
2292 .collect::<String>();
2293 Ok(Some(hex))
2294}
2295
2296pub fn runtime_stack_refresh_required(
2321 apps_dir: &Path,
2322 previous_fingerprint: Option<&str>,
2323) -> Result<bool, &'static str> {
2324 let Some(next_fingerprint) = runtime_apps_fingerprint(apps_dir)? else {
2325 return Ok(false);
2326 };
2327 Ok(previous_fingerprint.is_none_or(|previous| previous != next_fingerprint))
2328}
2329
2330fn mounted_route_pattern(mount: &str, route: &str) -> String {
2333 pharos_runtime_core::route::mounted_route_pattern(mount, route)
2334}
2335
2336fn validate_runtime_stack_ownership(apps: &[AppRuntime]) -> Result<(), &'static str> {
2338 let mut identifiers = BTreeMap::new();
2339 let mut owned_paths: Vec<(PathBuf, &str)> = Vec::new();
2340 let mut routes = BTreeMap::new();
2341 let mut mounts: Vec<(&str, &str)> = Vec::new();
2342 for app in apps {
2343 let app_id = app.app_id.as_deref().ok_or("runtime app id missing")?;
2344 if identifiers.insert(app_id, app_id).is_some() {
2345 return Err("runtime app id collision");
2346 }
2347 let mount = app.base_path.as_deref().unwrap_or_default();
2348 for (other_mount, _) in &mounts {
2349 let first = mount.trim_end_matches('/');
2350 let second = other_mount.trim_end_matches('/');
2351 if first == second
2352 || (!second.is_empty()
2353 && first
2354 .strip_prefix(second)
2355 .is_some_and(|tail| tail.starts_with('/')))
2356 || (!first.is_empty()
2357 && second
2358 .strip_prefix(first)
2359 .is_some_and(|tail| tail.starts_with('/')))
2360 {
2361 return Err("runtime base path collision");
2362 }
2363 }
2364 mounts.push((mount, app_id));
2365 let db_path = app
2366 .app_root
2367 .as_deref()
2368 .map(|app_root| {
2369 database::app_local_database_artifact_path(
2370 Path::new(app_root),
2371 app.runtime_conf_path.as_deref().map(Path::new),
2372 app_id,
2373 )
2374 })
2375 .transpose()
2376 .map_err(|_| "runtime database artifact ownership unavailable")?
2377 .flatten();
2378 for candidate in [
2379 app.artifact_dir.as_deref(),
2380 app.state_path.as_deref(),
2381 app.log_path.as_deref(),
2382 ]
2383 .into_iter()
2384 .flatten()
2385 .filter(|value| !value.is_empty())
2386 .map(PathBuf::from)
2387 .chain(db_path)
2388 {
2389 for (owned, owner) in &owned_paths {
2390 if owner != &app_id
2391 && (candidate == *owned
2392 || candidate.starts_with(owned)
2393 || owned.starts_with(&candidate))
2394 {
2395 return Err("runtime filesystem ownership collision");
2396 }
2397 }
2398 owned_paths.push((candidate, app_id));
2399 }
2400 let application_ir = app
2401 .application_ir
2402 .as_ref()
2403 .ok_or("runtime application IR missing")?;
2404 for route in application_ir.routes.values() {
2405 let pattern = mounted_route_pattern(mount, &route.path);
2406 let key = format!("{} {pattern}", route.kind);
2407 if routes.insert(key, app_id).is_some() {
2408 return Err("runtime route ownership collision");
2409 }
2410 }
2411 }
2412 Ok(())
2413}
2414
2415fn startup_timing_enabled() -> bool {
2416 std::env::var("PHAROS_STARTUP_TIMING").ok().as_deref() == Some("1")
2417}
2418
2419fn log_startup_app_timing(
2420 app_id: &str,
2421 artifact_dir: &Path,
2422 load_ms: u128,
2423 finalize_ms: u128,
2424 state_ms: u128,
2425 verify_ms: u128,
2426 total_ms: u128,
2427) {
2428 if startup_timing_enabled() {
2429 eprintln!(
2430 "startup_timing app={} artifact={} load_ms={} finalize_ms={} state_ms={} verify_ms={} total_ms={}",
2431 app_id,
2432 artifact_dir.display(),
2433 load_ms,
2434 finalize_ms,
2435 state_ms,
2436 verify_ms,
2437 total_ms
2438 );
2439 }
2440}
2441
2442#[derive(Clone, Copy)]
2443struct RootRuntimeAppLoadService;
2444
2445impl pharos_runtime_core::stack::RuntimeAppLoadService for RootRuntimeAppLoadService {
2446 fn read_source_manifest_value(&self, app_root: &Path) -> Result<Value, &'static str> {
2447 read_source_manifest_value(app_root)
2448 }
2449
2450 fn packaged_artifact_root_matches(&self, app_root: &Path, target_id: &str) -> bool {
2451 packaged_artifact_root_matches(app_root, target_id)
2452 }
2453
2454 fn app_runtime_conf_required_present(&self, app_root: &Path) -> bool {
2455 app_runtime_conf_required_present(app_root)
2456 }
2457
2458 fn resolve_build_dir_for_runtime_loading(
2459 &self,
2460 app_root: &Path,
2461 build_dir: &str,
2462 repo_root: Option<&Path>,
2463 ) -> PathBuf {
2464 resolve_build_dir_for_runtime_loading(app_root, build_dir, repo_root)
2465 }
2466
2467 fn load_app_runtime_from_artifact(
2468 &self,
2469 artifact_dir: &Path,
2470 ) -> Result<AppRuntime, &'static str> {
2471 load_app_runtime_from_artifact(artifact_dir)
2472 }
2473
2474 fn initialize_missing_app_runtime_state(&self, app: &AppRuntime) -> Result<(), &'static str> {
2475 initialize_missing_app_runtime_state(app)
2476 }
2477
2478 fn verify_app_migration_state(&self, app: &AppRuntime) -> Result<(), &'static str> {
2479 verify_app_migration_state(app)
2480 }
2481
2482 fn log_startup_app_timing(&self, timing: pharos_runtime_core::stack::RuntimeAppStartupTiming) {
2483 log_startup_app_timing(
2484 &timing.app_id,
2485 &timing.artifact_dir,
2486 timing.load_ms,
2487 timing.finalize_ms,
2488 timing.state_ms,
2489 timing.verify_ms,
2490 timing.total_ms,
2491 );
2492 }
2493}
2494
2495pub fn load_runtime_stack_direct(
2523 config_record: &ServeConfig,
2524) -> Result<RuntimeStack, &'static str> {
2525 let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2526 let load_config =
2527 pharos_runtime_core::stack::resolve_runtime_stack_load_config(config_record, ¤t_dir)?;
2528 pharos_runtime_core::stack::load_runtime_stack_from_apps_dir_with_service(
2529 &load_config.apps_dir_path,
2530 load_config.host,
2531 load_config.port,
2532 load_config.socket_path,
2533 load_config.effective_config,
2534 RootRuntimeAppLoadService,
2535 validate_runtime_stack_ownership,
2536 )
2537}
2538
2539fn app_runtime_report_json(
2541 report_id: &str,
2542 verb: &str,
2543 artifact_dir: &str,
2544 app: &AppRuntime,
2545) -> Result<String, &'static str> {
2546 let summary = runtime_app_summary_value(app);
2547 let mut payload = serde_json::Map::new();
2548 payload.insert("report_id".to_owned(), Value::String(report_id.to_owned()));
2549 payload.insert("verb".to_owned(), Value::String(verb.to_owned()));
2550 payload.insert("target_kind".to_owned(), Value::String("app".to_owned()));
2551 payload.insert(
2552 "target_id".to_owned(),
2553 Value::String(app.app_id.clone().unwrap_or_else(|| "app".to_owned())),
2554 );
2555 payload.insert(
2556 "status".to_owned(),
2557 Value::String(if verb == "build" { "built" } else { "ok" }.to_owned()),
2558 );
2559 payload.insert(
2560 "artifact_dir".to_owned(),
2561 Value::String(artifact_dir.to_owned()),
2562 );
2563 payload.insert(
2564 "state_path".to_owned(),
2565 Value::String(app.state_path.clone().unwrap_or_default()),
2566 );
2567 payload.insert(
2568 "base_path".to_owned(),
2569 Value::String(app.base_path.clone().unwrap_or_default()),
2570 );
2571 payload.insert(
2572 "log_path".to_owned(),
2573 Value::String(app.log_path.clone().unwrap_or_default()),
2574 );
2575 payload.insert(
2576 "log_format".to_owned(),
2577 Value::String(
2578 app.log_format
2579 .clone()
2580 .unwrap_or_else(|| "service".to_owned()),
2581 ),
2582 );
2583 payload.insert(
2584 "error_report_path".to_owned(),
2585 Value::String(app.error_report_path.clone().unwrap_or_default()),
2586 );
2587 payload.insert(
2588 "log_level".to_owned(),
2589 Value::String(app.log_level.clone().unwrap_or_else(|| "WARN".to_owned())),
2590 );
2591 payload.insert(
2592 "host_profile".to_owned(),
2593 Value::String(app.host_profile.clone().unwrap_or_default()),
2594 );
2595 payload.insert(
2596 "runtime_conf_path".to_owned(),
2597 Value::String(app.runtime_conf_path.clone().unwrap_or_default()),
2598 );
2599 payload.insert(
2600 "runtime_launcher".to_owned(),
2601 Value::Bool(matches!(verb, "run" | "probe" | "smoke")),
2602 );
2603 payload.insert(
2604 "host_target".to_owned(),
2605 Value::String("native-host-target".to_owned()),
2606 );
2607 payload.insert(
2608 "host_mode".to_owned(),
2609 Value::String("native-host-target".to_owned()),
2610 );
2611 payload.insert(
2612 "generated_by".to_owned(),
2613 Value::String("pharos-native-runtime".to_owned()),
2614 );
2615 payload.insert("runtime_summary".to_owned(), summary);
2616 let report =
2617 serde_json::to_string(&Value::Object(payload)).map_err(|_| "report encode failed")?;
2618 (report.len() <= app.resource_budgets.max_report_bytes)
2619 .then_some(report)
2620 .ok_or("report resource budget exceeded")
2621}
2622
2623pub fn build_runtime_build_report_json(
2650 target_id: &str,
2651 conf_path: Option<&str>,
2652 apps_dir: &str,
2653 app_count: usize,
2654) -> Result<String, &'static str> {
2655 serde_json::to_string(&serde_json::json!({
2656 "report_id": "pharos-runtime-build-v1",
2657 "target_kind": "runtime",
2658 "target_id": target_id,
2659 "status": "built",
2660 "app_count": app_count,
2661 "config_path": conf_path.unwrap_or_default(),
2662 "apps_dir": apps_dir,
2663 }))
2664 .map_err(|_| "report encode failed")
2665}
2666
2667struct RootRuntimeRequestService;
2692
2693impl pharos_runtime_core::request::RuntimeRequestService for RootRuntimeRequestService {
2694 fn route_match_lookup(&self, target: &str) -> Option<String> {
2695 cache::route_match_lookup(target)
2696 }
2697
2698 fn route_match_store(&self, target: &str, app_id: &str) {
2699 cache::route_match_store(target, app_id);
2700 }
2701
2702 fn framework_webhook_endpoint_id(&self, target: &str) -> Option<String> {
2703 runtime_framework_webhook_endpoint_id(target)
2704 }
2705
2706 fn app_declares_framework_webhook_endpoint(&self, app: &AppRuntime, endpoint_id: &str) -> bool {
2707 runtime_app_declares_framework_webhook_endpoint(app, endpoint_id)
2708 }
2709}
2710
2711pub fn runtime_match_app<'a>(stack: &'a RuntimeStack, path: &str) -> Option<&'a AppRuntime> {
2712 pharos_runtime_core::request::runtime_match_app(stack, path, &RootRuntimeRequestService)
2713}
2714
2715fn runtime_match_failed_app<'a>(
2717 stack: &'a RuntimeStack,
2718 path: &str,
2719) -> Option<&'a RuntimeAppLoadFailure> {
2720 pharos_runtime_core::route::matched_failed_app(stack, |candidates| {
2721 pharos_runtime_core::best_mount_index(path, candidates)
2722 })
2723}
2724
2725fn runtime_failed_app_response(app: &RuntimeAppLoadFailure, method: &str) -> Vec<u8> {
2727 http_json_response(
2728 503,
2729 "Service Unavailable",
2730 &serde_json::json!({
2731 "schema": "pharos.error.v1",
2732 "error": {
2733 "code": "dependency_unavailable",
2734 "message": "app bundle unavailable",
2735 "retryable": true,
2736 "correlation_id": "",
2737 "details": {
2738 "app_id": app.app_id,
2739 "mount_path": app.mount_path,
2740 "app_root": app.app_root,
2741 "artifact_dir": app.artifact_dir,
2742 "reason": app.error,
2743 }
2744 }
2745 })
2746 .to_string(),
2747 method,
2748 )
2749}
2750
2751pub fn app_runtime_probe_ready(app: &AppRuntime) -> bool {
2771 pharos_runtime_core::readiness::app_runtime_probe_ready(
2772 app,
2773 app_uses_relational_runtime_authority(app),
2774 )
2775}
2776
2777pub fn runtime_stack_probe_ready(stack: &RuntimeStack) -> bool {
2801 pharos_runtime_core::readiness::runtime_stack_probe_ready(
2802 stack.apps.iter(),
2803 stack.failed_apps.len(),
2804 app_runtime_probe_ready,
2805 )
2806}
2807
2808pub fn framework_builtin_response(
2835 method: &str,
2836 target: &str,
2837 app: Option<&AppRuntime>,
2838 stack: Option<&RuntimeStack>,
2839) -> Option<FrameworkBuiltinResponse> {
2840 pharos_runtime_core::builtin::framework_builtin_response(
2841 method,
2842 target,
2843 app,
2844 stack,
2845 PHAROS_VERSION,
2846 PHAROS_TARGET_ID,
2847 app_runtime_probe_ready,
2848 runtime_stack_probe_ready,
2849 )
2850}
2851
2852fn http_status_line(status_code: u16, status_text: &str) -> String {
2854 format!("HTTP/1.1 {status_code} {status_text}\r\n")
2855}
2856
2857fn http_reason_phrase(status_code: u16) -> &'static str {
2858 match status_code {
2859 200 => "OK",
2860 201 => "Created",
2861 202 => "Accepted",
2862 204 => "No Content",
2863 302 => "Found",
2864 303 => "See Other",
2865 400 => "Bad Request",
2866 401 => "Unauthorized",
2867 403 => "Forbidden",
2868 404 => "Not Found",
2869 405 => "Method Not Allowed",
2870 409 => "Conflict",
2871 413 => "Payload Too Large",
2872 422 => "Unprocessable Entity",
2873 424 => "Failed Dependency",
2874 429 => "Too Many Requests",
2875 500 => "Internal Server Error",
2876 502 => "Bad Gateway",
2877 503 => "Service Unavailable",
2878 504 => "Gateway Timeout",
2879 _ => "Internal Server Error",
2880 }
2881}
2882
2883fn http_text_response(status_code: u16, status_text: &str, body: &str) -> Vec<u8> {
2885 if status_code >= 400 {
2886 let code = pharos_runtime_core::error::ErrorCode::from_http_status(status_code);
2887 let envelope = pharos_runtime_core::error::envelope(code, body.trim(), None).to_string();
2888 return http_json_response(status_code, status_text, &envelope, "GET");
2889 }
2890 let mut response = String::new();
2891 response.push_str(&http_status_line(status_code, status_text));
2892 response.push_str("Content-Type: text/plain; charset=utf-8\r\n");
2893 response.push_str(&format!("Content-Length: {}\r\n", body.len()));
2894 response.push_str("Connection: close\r\n\r\n");
2895 response.push_str(body);
2896 response.into_bytes()
2897}
2898
2899fn public_http_validation_details(parsed: &Value) -> Option<Value> {
2901 if parsed.get("schema").and_then(Value::as_str) != Some("pharos.validation.report.v1") {
2902 return None;
2903 }
2904 let errors = parsed
2905 .get("errors")
2906 .and_then(Value::as_array)
2907 .into_iter()
2908 .flatten()
2909 .filter_map(Value::as_str)
2910 .take(32)
2911 .map(|error| error.chars().take(128).collect::<String>())
2912 .map(Value::String)
2913 .collect::<Vec<_>>();
2914 let mut details = serde_json::Map::from_iter([(
2915 "schema".to_owned(),
2916 Value::String("pharos.validation.report.v1".to_owned()),
2917 )]);
2918 if !errors.is_empty() {
2919 details.insert("errors".to_owned(), Value::Array(errors));
2920 }
2921 for array_name in ["field_errors", "form_errors"] {
2922 if let Some(entries) = parsed.get(array_name).and_then(Value::as_array) {
2923 let filtered = entries
2924 .iter()
2925 .filter_map(Value::as_object)
2926 .take(16)
2927 .map(|entry| {
2928 let mut filtered = serde_json::Map::new();
2929 for key in ["field_id", "rule_id", "code", "message"] {
2930 if let Some(value) = entry
2931 .get(key)
2932 .and_then(Value::as_str)
2933 .filter(|value| value.len() <= 256)
2934 {
2935 filtered.insert(key.to_owned(), Value::String(value.to_owned()));
2936 }
2937 }
2938 Value::Object(filtered)
2939 })
2940 .collect::<Vec<_>>();
2941 if !filtered.is_empty() {
2942 details.insert(array_name.to_owned(), Value::Array(filtered));
2943 }
2944 }
2945 }
2946 for key in ["action_id", "report_id", "surface_id", "form_id"] {
2947 if let Some(value) = parsed
2948 .get(key)
2949 .and_then(Value::as_str)
2950 .filter(|value| value.len() <= 128)
2951 {
2952 details.insert(key.to_owned(), Value::String(value.to_owned()));
2953 }
2954 }
2955 Some(Value::Object(details))
2956}
2957
2958fn route_request_validation_report(route: &Value, code: &str, message: &str) -> String {
2960 let mut root = serde_json::Map::new();
2961 root.insert(
2962 "schema".to_owned(),
2963 Value::String("pharos.validation.report.v1".to_owned()),
2964 );
2965 root.insert(
2966 "report_id".to_owned(),
2967 Value::String("pharos.validation.report.v1".to_owned()),
2968 );
2969 if let Some(action_id) = route.get("action_id").and_then(Value::as_str) {
2970 root.insert("action_id".to_owned(), Value::String(action_id.to_owned()));
2971 }
2972 root.insert(
2973 "errors".to_owned(),
2974 Value::Array(vec![Value::String(code.to_owned())]),
2975 );
2976 root.insert(
2977 "form_errors".to_owned(),
2978 Value::Array(vec![Value::Object(serde_json::Map::from_iter([
2979 ("rule_id".to_owned(), Value::String(code.to_owned())),
2980 ("code".to_owned(), Value::String(code.to_owned())),
2981 ("message".to_owned(), Value::String(message.to_owned())),
2982 ]))]),
2983 );
2984 root.insert("field_errors".to_owned(), Value::Array(Vec::new()));
2985 Value::Object(root).to_string()
2986}
2987
2988fn route_request_hook_failure(
2990 route: &Value,
2991 request: &http::HttpRequest,
2992) -> Option<(u16, &'static str, String)> {
2993 let hooks = route.get("request_hooks").and_then(Value::as_array)?;
2994 for hook in hooks {
2995 let hook_id = hook
2996 .as_str()
2997 .or_else(|| hook.get("hook_id").and_then(Value::as_str))?;
2998 match hook_id {
2999 "json_body_required"
3000 if !request
3001 .content_type
3002 .as_deref()
3003 .is_some_and(|value| value.starts_with("application/json")) =>
3004 {
3005 return Some((
3006 415,
3007 "Unsupported Media Type",
3008 route_request_validation_report(
3009 route,
3010 "json_content_type_required",
3011 "This route requires an application/json request body.",
3012 ),
3013 ));
3014 }
3015 "form_body_required"
3016 if !request.content_type.as_deref().is_some_and(|value| {
3017 value.starts_with("application/x-www-form-urlencoded")
3018 }) =>
3019 {
3020 return Some((
3021 415,
3022 "Unsupported Media Type",
3023 route_request_validation_report(
3024 route,
3025 "form_content_type_required",
3026 "This route requires an application/x-www-form-urlencoded request body.",
3027 ),
3028 ));
3029 }
3030 _ => {}
3031 }
3032 }
3033 None
3034}
3035
3036fn route_request_middleware_failure(
3038 route: &Value,
3039 request: &http::HttpRequest,
3040) -> Option<(u16, &'static str, String)> {
3041 let middlewares = route.get("middleware").and_then(Value::as_array)?;
3042 for middleware in middlewares {
3043 if middleware.get("phase").and_then(Value::as_str) != Some("request") {
3044 continue;
3045 }
3046 let kind = middleware
3047 .get("kind")
3048 .and_then(Value::as_str)
3049 .unwrap_or_default();
3050 let (actual_value, default_status, default_code, default_message) = match kind {
3051 "require_header" => (
3052 middleware
3053 .get("header")
3054 .and_then(Value::as_str)
3055 .filter(|value| !value.is_empty())
3056 .and_then(|header_name| dynamic::request_header_value(request, header_name)),
3057 428_u16,
3058 "middleware_header_required",
3059 "A required request header is missing or invalid.",
3060 ),
3061 "require_query_param" => (
3062 middleware
3063 .get("param")
3064 .or_else(|| middleware.get("field"))
3065 .and_then(Value::as_str)
3066 .filter(|value| !value.is_empty())
3067 .and_then(|param| dynamic::request_query_value(request, param)),
3068 422_u16,
3069 "middleware_query_param_required",
3070 "A required query parameter is missing or invalid.",
3071 ),
3072 "require_form_field" => (
3073 middleware
3074 .get("field")
3075 .and_then(Value::as_str)
3076 .filter(|value| !value.is_empty())
3077 .and_then(|field| dynamic::request_form_value(request, field)),
3078 422_u16,
3079 "middleware_form_field_required",
3080 "A required form field is missing or invalid.",
3081 ),
3082 _ => continue,
3083 };
3084 let required_value = middleware
3085 .get("equals")
3086 .and_then(Value::as_str)
3087 .filter(|value| !value.is_empty());
3088 let satisfied = match (actual_value.as_deref(), required_value) {
3089 (Some(actual), Some(expected)) => actual == expected,
3090 (Some(_), None) => true,
3091 _ => false,
3092 };
3093 if !satisfied {
3094 let status = middleware
3095 .get("status_code")
3096 .and_then(Value::as_u64)
3097 .unwrap_or(default_status as u64) as u16;
3098 let status_text = http::http_status_text(status);
3099 let code = middleware
3100 .get("error_code")
3101 .and_then(Value::as_str)
3102 .filter(|value| !value.is_empty())
3103 .unwrap_or(default_code);
3104 let message = middleware
3105 .get("message")
3106 .and_then(Value::as_str)
3107 .filter(|value| !value.is_empty())
3108 .unwrap_or(default_message);
3109 return Some((
3110 status,
3111 status_text,
3112 route_request_validation_report(route, code, message),
3113 ));
3114 }
3115 }
3116 None
3117}
3118
3119fn http_response_insert_header(mut response: Vec<u8>, name: &str, value: &str) -> Vec<u8> {
3121 if name.is_empty() || value.contains(['\r', '\n']) || name.contains(['\r', '\n', ':']) {
3122 return response;
3123 }
3124 let Some(separator) = response.windows(2).position(|window| window == b"\r\n") else {
3125 return response;
3126 };
3127 let mut observed =
3128 Vec::with_capacity(response.len().saturating_add(name.len() + value.len() + 4));
3129 observed.extend_from_slice(&response[..separator + 2]);
3130 observed.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
3131 observed.extend_from_slice(&response[separator + 2..]);
3132 response.clear();
3133 observed
3134}
3135
3136fn apply_route_response_middleware(
3138 route: &Value,
3139 request: &http::HttpRequest,
3140 response: Vec<u8>,
3141) -> Vec<u8> {
3142 let mut response = response;
3143 let Some(middlewares) = route.get("middleware").and_then(Value::as_array) else {
3144 return response;
3145 };
3146 for middleware in middlewares {
3147 if middleware.get("phase").and_then(Value::as_str) != Some("response") {
3148 continue;
3149 }
3150 let kind = middleware
3151 .get("kind")
3152 .and_then(Value::as_str)
3153 .unwrap_or_default();
3154 match kind {
3155 "set_header" => {
3156 let Some(name) = middleware
3157 .get("name")
3158 .and_then(Value::as_str)
3159 .filter(|value| !value.is_empty())
3160 else {
3161 continue;
3162 };
3163 let value = middleware
3164 .get("value")
3165 .and_then(Value::as_str)
3166 .unwrap_or_default();
3167 response = http_response_insert_header(response, name, value);
3168 }
3169 "mirror_request_header" => {
3170 let Some(name) = middleware
3171 .get("name")
3172 .and_then(Value::as_str)
3173 .filter(|value| !value.is_empty())
3174 else {
3175 continue;
3176 };
3177 let source_header = middleware
3178 .get("from_request_header")
3179 .and_then(Value::as_str)
3180 .filter(|value| !value.is_empty())
3181 .unwrap_or(name);
3182 let value =
3183 dynamic::request_header_value(request, source_header).unwrap_or_else(|| {
3184 middleware
3185 .get("default_value")
3186 .and_then(Value::as_str)
3187 .unwrap_or_default()
3188 .to_owned()
3189 });
3190 response = http_response_insert_header(response, name, &value);
3191 }
3192 "mirror_query_param" => {
3193 let Some(name) = middleware
3194 .get("name")
3195 .and_then(Value::as_str)
3196 .filter(|value| !value.is_empty())
3197 else {
3198 continue;
3199 };
3200 let source_param = middleware
3201 .get("from_query_param")
3202 .or_else(|| middleware.get("param"))
3203 .and_then(Value::as_str)
3204 .filter(|value| !value.is_empty());
3205 let value = source_param
3206 .and_then(|param| dynamic::request_query_value(request, param))
3207 .unwrap_or_else(|| {
3208 middleware
3209 .get("default_value")
3210 .and_then(Value::as_str)
3211 .unwrap_or_default()
3212 .to_owned()
3213 });
3214 response = http_response_insert_header(response, name, &value);
3215 }
3216 _ => {}
3217 }
3218 }
3219 response
3220}
3221
3222fn json_content_type_required_response(action_id: &str, method: &str) -> Vec<u8> {
3224 http_json_response(
3225 415,
3226 "Unsupported Media Type",
3227 &serde_json::json!({
3228 "schema": "pharos.validation.report.v1",
3229 "report_id": "pharos.validation.report.v1",
3230 "action_id": action_id,
3231 "errors": ["json_content_type_required"],
3232 "field_errors": [],
3233 "form_errors": [{
3234 "rule_id": "json_content_type_required",
3235 "code": "json_content_type_required",
3236 "message": "This route requires an application/json request body."
3237 }]
3238 })
3239 .to_string(),
3240 method,
3241 )
3242}
3243
3244fn public_http_validation_message(details: &Value) -> Option<String> {
3246 let entries = details
3247 .get("field_errors")
3248 .and_then(Value::as_array)
3249 .into_iter()
3250 .flatten()
3251 .chain(
3252 details
3253 .get("form_errors")
3254 .and_then(Value::as_array)
3255 .into_iter()
3256 .flatten(),
3257 );
3258 let messages = entries
3259 .filter_map(|entry| {
3260 let message = entry.get("message")?.as_str()?.trim();
3261 if message.is_empty() {
3262 return None;
3263 }
3264 let field = entry
3265 .get("field_id")
3266 .and_then(Value::as_str)
3267 .unwrap_or("")
3268 .trim();
3269 Some(if field.is_empty() {
3270 message.to_owned()
3271 } else {
3272 format!("{field}: {message}")
3273 })
3274 })
3275 .take(8)
3276 .collect::<Vec<_>>();
3277 (!messages.is_empty()).then(|| messages.join("; "))
3278}
3279
3280fn canonical_http_error_body(status_code: u16, status_text: &str, body: &str) -> String {
3282 let parsed = serde_json::from_str::<Value>(body).ok();
3283 let validation_details = parsed.as_ref().and_then(public_http_validation_details);
3284 let existing_error = parsed.as_ref().and_then(|value| value.get("error"));
3285 let message = existing_error
3286 .and_then(|error| error.get("message"))
3287 .and_then(Value::as_str)
3288 .map(ToOwned::to_owned)
3289 .or_else(|| {
3290 parsed
3291 .as_ref()
3292 .and_then(|value| value.get("message"))
3293 .and_then(Value::as_str)
3294 .map(ToOwned::to_owned)
3295 })
3296 .or_else(|| {
3297 existing_error
3298 .and_then(Value::as_str)
3299 .map(ToOwned::to_owned)
3300 })
3301 .or_else(|| {
3302 validation_details
3303 .as_ref()
3304 .and_then(public_http_validation_message)
3305 })
3306 .or_else(|| {
3307 parsed
3308 .as_ref()
3309 .and_then(|value| value.get("errors"))
3310 .and_then(Value::as_array)
3311 .map(|errors| {
3312 errors
3313 .iter()
3314 .filter_map(Value::as_str)
3315 .collect::<Vec<_>>()
3316 .join(", ")
3317 })
3318 .filter(|message| !message.is_empty())
3319 })
3320 .filter(|message| !message.is_empty())
3321 .unwrap_or_else(|| status_text.to_owned())
3322 .chars()
3323 .take(1_024)
3324 .collect::<String>();
3325 let code = pharos_runtime_core::error::ErrorCode::from_http_status(status_code);
3326 let mut envelope = pharos_runtime_core::error::envelope(code, &message, None);
3327 if let (Some(details), Some(error)) = (
3328 validation_details,
3329 envelope.get_mut("error").and_then(Value::as_object_mut),
3330 ) {
3331 error.insert("details".to_owned(), details);
3332 }
3333 envelope.to_string()
3334}
3335
3336fn http_json_response(status_code: u16, status_text: &str, body: &str, method: &str) -> Vec<u8> {
3338 let canonical_body;
3339 let body = if status_code >= 400 {
3340 canonical_body = canonical_http_error_body(status_code, status_text, body);
3341 canonical_body.as_str()
3342 } else {
3343 body
3344 };
3345 let mut response = String::new();
3346 response.push_str(&http_status_line(status_code, status_text));
3347 response.push_str("Content-Type: application/json; charset=utf-8\r\n");
3348 response.push_str(&format!("Content-Length: {}\r\n", body.len()));
3349 response.push_str("Connection: close\r\n\r\n");
3350 if method != "HEAD" {
3351 response.push_str(body);
3352 }
3353 response.into_bytes()
3354}
3355
3356fn http_html_response(status_code: u16, status_text: &str, body: &str, method: &str) -> Vec<u8> {
3358 let response = dynamic::write_html_response(status_code, status_text, body, None);
3359 if method == "HEAD"
3360 && let Some((headers, _)) = response.split_once("\r\n\r\n")
3361 {
3362 return format!("{headers}\r\n\r\n").into_bytes();
3363 }
3364 response.into_bytes()
3365}
3366
3367fn http_html_response_with_headers(
3368 status_code: u16,
3369 status_text: &str,
3370 body: &str,
3371 method: &str,
3372 extra_headers: &str,
3373) -> Vec<u8> {
3374 let response = dynamic::write_html_response_with_headers(
3375 status_code,
3376 status_text,
3377 body,
3378 None,
3379 extra_headers,
3380 );
3381 if method == "HEAD"
3382 && let Some((headers, _)) = response.split_once("\r\n\r\n")
3383 {
3384 return format!("{headers}\r\n\r\n").into_bytes();
3385 }
3386 response.into_bytes()
3387}
3388
3389fn http_download_response(
3390 body: &[u8],
3391 content_type: &str,
3392 filename: &str,
3393 method: &str,
3394) -> Vec<u8> {
3395 let safe_filename = filename
3396 .chars()
3397 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_'))
3398 .take(180)
3399 .collect::<String>();
3400 let filename = if safe_filename.is_empty() {
3401 "download.bin"
3402 } else {
3403 safe_filename.as_str()
3404 };
3405 let headers = format!(
3406 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nContent-Disposition: attachment; filename=\"{filename}\"\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
3407 body.len()
3408 );
3409 if method == "HEAD" {
3410 return headers.into_bytes();
3411 }
3412 let mut response = headers.into_bytes();
3413 response.extend_from_slice(body);
3414 response
3415}
3416
3417fn http_html_response_with_csrf(
3419 status_code: u16,
3420 status_text: &str,
3421 body: &str,
3422 method: &str,
3423 csrf_token: Option<&str>,
3424) -> Vec<u8> {
3425 let response = dynamic::write_html_response(status_code, status_text, body, csrf_token);
3426 if method == "HEAD"
3427 && let Some((headers, _)) = response.split_once("\r\n\r\n")
3428 {
3429 return format!("{headers}\r\n\r\n").into_bytes();
3430 }
3431 response.into_bytes()
3432}
3433
3434fn http_route_redirect(location: &str) -> Vec<u8> {
3436 let safe_location = location.replace(['\r', '\n', '"'], "");
3437 let mut response = String::new();
3438 response.push_str(&http_status_line(303, "See Other"));
3439 response.push_str(&format!("Location: {safe_location}\r\n"));
3440 response.push_str("Content-Length: 0\r\nConnection: close\r\n\r\n");
3441 response.into_bytes()
3442}
3443
3444fn http_hx_redirect(location: &str) -> Vec<u8> {
3446 let safe_location = location.replace(['\r', '\n', '"'], "");
3447 let mut response = String::new();
3448 response.push_str(&http_status_line(200, "OK"));
3449 response.push_str(&format!("HX-Redirect: {safe_location}\r\n"));
3450 response.push_str("Content-Length: 0\r\nConnection: close\r\n\r\n");
3451 response.into_bytes()
3452}
3453
3454fn http_route_redirect_for_request(request: &http::HttpRequest, location: &str) -> Vec<u8> {
3456 if dynamic::request_is_hx(Some(request)) {
3457 http_hx_redirect(location)
3458 } else {
3459 http_route_redirect(location)
3460 }
3461}
3462
3463fn app_fragment_response(
3465 app: &AppRuntime,
3466 relative_path: &str,
3467 params: Value,
3468 method: &str,
3469) -> Vec<u8> {
3470 let body = app
3471 .artifact_dir
3472 .as_deref()
3473 .and_then(|root| json_commands::render_app_fragment_from_doc(root, relative_path, ¶ms));
3474 match body {
3475 Some(body) => http_html_response(200, "OK", &body, method),
3476 None => http_text_response(
3477 500,
3478 "Internal Server Error",
3479 "declared app response failed to render\n",
3480 ),
3481 }
3482}
3483
3484fn payment_provider_feedback_response(app: &AppRuntime, message_key: &str) -> Vec<u8> {
3485 let Some(app_root) = app.app_root.as_deref().map(Path::new) else {
3486 return http_text_response(
3487 500,
3488 "Internal Server Error",
3489 "payment provider feedback unavailable\n",
3490 );
3491 };
3492 let message = payment_provider::payment_provider_copy_value(app_root, message_key)
3493 .unwrap_or_else(|| message_key.to_owned());
3494 payment_provider_feedback_message_response(app, &message)
3495}
3496
3497fn payment_provider_feedback_message_response(app: &AppRuntime, message: &str) -> Vec<u8> {
3498 let params = serde_json::json!({
3499 "payment_provider_feedback_message": message,
3500 });
3501 app_fragment_response(
3502 app,
3503 "templates/fragments/payment_provider_feedback.html",
3504 params,
3505 "GET",
3506 )
3507}
3508
3509fn http_route_redirect_with_session(
3511 app: &AppRuntime,
3512 location: &str,
3513 request_target: &str,
3514 request: &http::HttpRequest,
3515 session_token: &str,
3516) -> Vec<u8> {
3517 let safe_location = location.replace(['\r', '\n', '"'], "");
3518 let request_path = request_target.split('?').next().unwrap_or("/");
3519 let inferred_base = request_path
3520 .strip_suffix(&safe_location)
3521 .filter(|prefix| !prefix.is_empty())
3522 .map(|prefix| prefix.trim_end_matches('/'));
3523 let scoped_location = app
3524 .base_path
3525 .as_deref()
3526 .filter(|base| !base.is_empty() && *base != "/")
3527 .map(|base| base.trim_end_matches('/').to_owned())
3528 .or_else(|| inferred_base.map(ToOwned::to_owned))
3529 .map(|base| {
3530 if safe_location == base || safe_location.starts_with(&format!("{base}/")) {
3531 safe_location.clone()
3532 } else {
3533 format!("{base}{safe_location}")
3534 }
3535 })
3536 .unwrap_or(safe_location);
3537 format!(
3538 "{}Location: {scoped_location}\r\n{}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3539 http_status_line(303, "See Other"),
3540 dynamic::session_cookie_header(app, Some(request), Some(session_token), false)
3541 )
3542 .into_bytes()
3543}
3544
3545fn app_login_location(app: &AppRuntime) -> String {
3547 app.base_path
3548 .as_deref()
3549 .filter(|value| !value.is_empty() && *value != "/")
3550 .map(|base| format!("{}/accounts/login/", base.trim_end_matches('/')))
3551 .unwrap_or_else(|| "/accounts/login/".to_owned())
3552}
3553
3554fn app_scope_path(app: &AppRuntime, path: &str) -> String {
3556 match app
3557 .base_path
3558 .as_deref()
3559 .filter(|base| !base.is_empty() && *base != "/")
3560 {
3561 Some(base) if path == "/" => base.trim_end_matches('/').to_owned(),
3562 Some(base) if !path.starts_with(base) => format!("{}{}", base.trim_end_matches('/'), path),
3563 _ => path.to_owned(),
3564 }
3565}
3566
3567fn app_safe_next_path(app: &AppRuntime, candidate: &str, fallback: &str) -> String {
3569 let trimmed = candidate.trim();
3570 if trimmed.is_empty()
3571 || !trimmed.starts_with('/')
3572 || trimmed.starts_with("//")
3573 || trimmed.contains(['\r', '\n'])
3574 {
3575 return fallback.to_owned();
3576 }
3577 let path_only = trimmed.split('?').next().unwrap_or(trimmed);
3578 match app
3579 .base_path
3580 .as_deref()
3581 .filter(|base| !base.is_empty() && *base != "/")
3582 {
3583 Some(base) => {
3584 let scoped = base.trim_end_matches('/');
3585 if path_only == scoped || path_only.starts_with(&format!("{scoped}/")) {
3586 trimmed.to_owned()
3587 } else {
3588 fallback.to_owned()
3589 }
3590 }
3591 None => trimmed.to_owned(),
3592 }
3593}
3594
3595fn runtime_parse_yaml_file(path: &Path) -> Option<Value> {
3596 pharos_runtime_core::yaml::parse_yaml_file(path)
3597}
3598
3599fn runtime_load_yaml_or_json_value(base_dir: &Path, basename: &str) -> Option<Value> {
3600 if basename.is_empty() {
3601 return None;
3602 }
3603 let yaml_path = base_dir.join(format!("{basename}.yml"));
3604 if yaml_path.exists() {
3605 return runtime_parse_yaml_file(&yaml_path);
3606 }
3607 let json_path = base_dir.join(format!("{basename}.json"));
3608 serde_json::from_str(&stdfs::read_to_string(json_path).ok()?).ok()
3609}
3610
3611fn app_manifest_value(app: &AppRuntime) -> Option<Value> {
3612 app.artifact_dir
3613 .as_deref()
3614 .and_then(|path| runtime_load_yaml_or_json_value(Path::new(path), "pharos.app"))
3615 .or_else(|| {
3616 app.app_root
3617 .as_deref()
3618 .and_then(|path| runtime_load_yaml_or_json_value(Path::new(path), "pharos.app"))
3619 })
3620}
3621
3622fn declared_login_path_is_safe(path: &str) -> bool {
3623 let trimmed = path.trim();
3624 !trimmed.is_empty()
3625 && trimmed.len() <= 512
3626 && trimmed.starts_with('/')
3627 && !trimmed.starts_with("//")
3628 && !trimmed.contains(['\r', '\n'])
3629}
3630
3631fn declared_post_login_role_landing(manifest: &Value, roles: &[String]) -> Option<(String, bool)> {
3632 manifest
3633 .get("auth")?
3634 .get("post_login_redirects")?
3635 .as_array()?
3636 .iter()
3637 .find_map(|entry| {
3638 let role = entry.get("role")?.as_str()?;
3639 let path = entry.get("path")?.as_str()?;
3640 (roles.iter().any(|candidate| candidate == role) && declared_login_path_is_safe(path))
3641 .then(|| {
3642 (
3643 path.trim().to_owned(),
3644 entry
3645 .get("override_next")
3646 .and_then(Value::as_bool)
3647 .unwrap_or(false),
3648 )
3649 })
3650 })
3651}
3652
3653fn app_post_login_role_landing_path(
3654 app: &AppRuntime,
3655 actor_id: i64,
3656 fallback: &str,
3657) -> Result<(String, bool), pharos_runtime_database::operations::DatabaseError> {
3658 let roles = feature_runtime::actor_roles(app, actor_id)?;
3659 Ok(app_manifest_value(app)
3660 .and_then(|manifest| declared_post_login_role_landing(&manifest, &roles))
3661 .map(|(path, override_next)| (app_scope_path(app, &path), override_next))
3662 .unwrap_or_else(|| (fallback.to_owned(), false)))
3663}
3664
3665#[cfg(test)]
3666mod owner_login_landing_tests {
3667 use super::*;
3668
3669 #[test]
3670 fn forced_owner_landing_is_explicit_and_role_scoped() {
3671 let manifest = serde_json::json!({"auth":{"post_login_redirects":[{"role":"store_owner","path":"/owner","override_next":true}]}});
3672 assert_eq!(
3673 declared_post_login_role_landing(&manifest, &["store_owner".to_owned()]),
3674 Some(("/owner".to_owned(), true))
3675 );
3676 assert_eq!(
3677 declared_post_login_role_landing(&manifest, &["customer".to_owned()]),
3678 None
3679 );
3680 let legacy = serde_json::json!({"auth":{"post_login_redirects":[{"role":"store_owner","path":"/owner"}]}});
3681 assert_eq!(
3682 declared_post_login_role_landing(&legacy, &["store_owner".to_owned()]),
3683 Some(("/owner".to_owned(), false))
3684 );
3685 for path in [
3686 "//example.com",
3687 "https://example.com",
3688 "/owner\r\nLocation:bad",
3689 ] {
3690 let invalid = serde_json::json!({"auth":{"post_login_redirects":[{"role":"store_owner","path":path,"override_next":true}]}});
3691 assert_eq!(
3692 declared_post_login_role_landing(&invalid, &["store_owner".to_owned()]),
3693 None
3694 );
3695 }
3696 }
3697}
3698
3699fn app_login_redirect(app: &AppRuntime, next_path: &str) -> Vec<u8> {
3701 let safe_next = next_path.replace(['\r', '\n', '"'], "");
3702 http_route_redirect(&format!("{}?next={safe_next}", app_login_location(app)))
3703}
3704
3705fn http_route_redirect_clear_session(
3707 app: &AppRuntime,
3708 request: &http::HttpRequest,
3709 location: &str,
3710) -> Vec<u8> {
3711 let safe_location = location.replace(['\r', '\n', '"'], "");
3712 format!(
3713 "{}Location: {safe_location}\r\n{}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3714 http_status_line(303, "See Other"),
3715 dynamic::session_cookie_header(app, Some(request), None, true)
3716 )
3717 .into_bytes()
3718}
3719
3720fn database_error_response(error: pharos_runtime_database::operations::DatabaseError) -> Vec<u8> {
3722 use pharos_runtime_database::operations::DatabaseError;
3723 let (status, message) = match error {
3724 DatabaseError::InvalidInput => (422, "invalid database input\n"),
3725 DatabaseError::InvalidContract => (422, "invalid database contract\n"),
3726 DatabaseError::MissingRecord => (404, "database record not found\n"),
3727 DatabaseError::AccessDenied => (403, "database access denied\n"),
3728 DatabaseError::Conflict => (409, "database conflict\n"),
3729 DatabaseError::Busy => (503, "database temporarily busy\n"),
3730 DatabaseError::CapacityExceeded => (503, "database capacity exceeded\n"),
3731 DatabaseError::Unsupported => (503, "database capability unavailable\n"),
3732 DatabaseError::Unavailable => (503, "database unavailable\n"),
3733 DatabaseError::StorageFailure => (503, "database operation failed\n"),
3734 };
3735 http_text_response(status, http::http_status_text(status), message)
3736}
3737
3738fn native_session_token_hash(app: &AppRuntime, token: &str) -> Option<String> {
3740 pharos_runtime_security::native_session_token_hash(
3741 app.app_id.as_deref().unwrap_or_default(),
3742 token,
3743 )
3744}
3745
3746fn app_mount_prefix(app: &AppRuntime, request_target: &str, relative_path: &str) -> String {
3748 if let Some(base) = app
3749 .base_path
3750 .as_deref()
3751 .filter(|value| !value.is_empty() && *value != "/")
3752 {
3753 return base.trim_end_matches('/').to_owned();
3754 }
3755 let request_path = request_target.split('?').next().unwrap_or("/");
3756 request_path
3757 .strip_suffix(relative_path)
3758 .filter(|prefix| !prefix.is_empty())
3759 .map(|prefix| prefix.trim_end_matches('/').to_owned())
3760 .unwrap_or_default()
3761}
3762
3763fn app_security_cache_scope(
3766 app: &AppRuntime,
3767) -> Result<String, pharos_runtime_database::operations::DatabaseError> {
3768 let app_id = app
3769 .app_id
3770 .as_deref()
3771 .ok_or(pharos_runtime_database::operations::DatabaseError::InvalidContract)?;
3772 let authority = if app_uses_file_backed_runtime_state(app) {
3773 serde_json::json!({"document": app.state_path.as_deref().ok_or(pharos_runtime_database::operations::DatabaseError::InvalidContract)?})
3774 } else {
3775 let config = database::checked_backend_config(
3776 Path::new(
3777 app.app_root
3778 .as_deref()
3779 .ok_or(pharos_runtime_database::operations::DatabaseError::InvalidContract)?,
3780 ),
3781 app.runtime_conf_path.as_deref().map(Path::new),
3782 Some(app_id),
3783 )?;
3784 serde_json::to_value(
3785 config
3786 .settings()
3787 .map_err(|_| pharos_runtime_database::operations::DatabaseError::InvalidContract)?,
3788 )
3789 .map_err(|_| pharos_runtime_database::operations::DatabaseError::InvalidContract)?
3790 };
3791 let bytes = serde_json::to_vec(&serde_json::json!({"app_id": app_id, "authority": authority}))
3792 .map_err(|_| pharos_runtime_database::operations::DatabaseError::InvalidContract)?;
3793 Ok(dynamic::hex_digest(&Sha256::digest(bytes)))
3794}
3795
3796fn active_session_user_id(
3798 app: &AppRuntime,
3799 cookie: Option<&str>,
3800) -> Result<i64, pharos_runtime_database::operations::DatabaseError> {
3801 let Some(token) =
3802 cookie.and_then(|value| dynamic::dynamic_cookie_value(value, "pharos_session"))
3803 else {
3804 return Ok(0);
3805 };
3806 let Some(token_hash) = native_session_token_hash(app, &token) else {
3807 return Ok(0);
3808 };
3809 let session_store = if app_uses_file_backed_runtime_state(app) {
3810 None
3811 } else {
3812 if !app_uses_relational_runtime_authority(app) {
3813 return Ok(0);
3814 }
3815 let store = checked_app_database_operations(app)?;
3816 if !store
3817 .schema
3818 .entities
3819 .iter()
3820 .any(|entity| entity.name == "auth_session")
3821 {
3822 return Ok(0);
3823 }
3824 Some(store)
3825 };
3826 let cache_scope = app_security_cache_scope(app)?;
3827 let app_id = cache_scope.as_str();
3828 let now = SystemTime::now()
3829 .duration_since(UNIX_EPOCH)
3830 .map(|value| value.as_secs())
3831 .unwrap_or(u64::MAX);
3832 if let Some(user_id) =
3833 pharos_runtime_security::local_session_cache_lookup(app_id, &token_hash, now)
3834 {
3835 return Ok(user_id);
3836 }
3837 if let Some(record) = cache::session_cache_lookup(app_id, &token_hash, now) {
3838 pharos_runtime_security::local_session_cache_store(
3839 app_id,
3840 record.user_id,
3841 &token_hash,
3842 record.expires_at_epoch,
3843 now,
3844 );
3845 return Ok(record.user_id);
3846 }
3847 let now_timestamp = current_timestamp_utc();
3848 if let Some(store) = session_store {
3849 let Some(record) =
3850 store
3851 .adapter
3852 .active_session(&store.config, &store.schema, &token_hash, now)?
3853 else {
3854 return Ok(0);
3855 };
3856 pharos_runtime_security::local_session_cache_store(
3857 app_id,
3858 record.user_id,
3859 &token_hash,
3860 record.expires_at_epoch,
3861 now,
3862 );
3863 cache::session_cache_store(app_id, record.user_id, &token_hash, record.expires_at_epoch);
3864 return Ok(record.user_id);
3865 }
3866 Ok(app
3867 .state_path
3868 .as_deref()
3869 .filter(|_| app_uses_file_backed_runtime_state(app))
3870 .and_then(|state_path| {
3871 let root = stdfs::read_to_string(state_path).ok()?;
3872 let root = serde_json::from_str::<Value>(&root).ok()?;
3873 root.get("sessions")?.as_array()?.iter().find_map(|row| {
3874 (row.get("session_token_hash").and_then(Value::as_str) == Some(token_hash.as_str())
3875 && row.get("app_id").and_then(Value::as_str) == app.app_id.as_deref()
3876 && row.get("revoked_at").is_none_or(|value| value.is_null())
3877 && row
3878 .get("expires_at")
3879 .and_then(Value::as_str)
3880 .is_some_and(|expires| {
3881 expires
3882 .parse::<u64>()
3883 .map_or(expires > now_timestamp.as_str(), |expires| expires > now)
3884 }))
3885 .then(|| row.get("user_id").and_then(Value::as_i64))
3886 .flatten()
3887 })
3888 })
3889 .unwrap_or(0))
3890}
3891
3892fn login_error_response_with_status(
3894 app: &AppRuntime,
3895 request: &http::HttpRequest,
3896 message: &str,
3897 status_code: u16,
3898 status_text: &str,
3899) -> Vec<u8> {
3900 let target = request.target.as_str();
3901 let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
3902 let relative_target = app_relative_target(app, target);
3903 let relative_path = relative_target.split('?').next().unwrap_or("/");
3904 let html = matched_app_route(app, "GET", relative_path)
3905 .filter(|route| route.get("page_id").and_then(Value::as_str) == Some("login"))
3906 .and_then(|route| {
3907 if route.get("feature_id").and_then(Value::as_str).is_some() {
3908 feature_runtime::render_feature_page_html_with_error(
3909 app,
3910 &route,
3911 &relative_target,
3912 0,
3913 false,
3914 &csrf_token,
3915 Some(message),
3916 )
3917 .ok()
3918 } else {
3919 app_route_page_html(
3920 app,
3921 &route,
3922 &relative_target,
3923 0,
3924 false,
3925 0,
3926 Some(message),
3927 &csrf_token,
3928 )
3929 }
3930 })
3931 .or_else(|| {
3932 let route = serde_json::json!({"page_id":"login"});
3933 app_route_page_html(
3934 app,
3935 &route,
3936 &relative_target,
3937 0,
3938 false,
3939 0,
3940 Some(message),
3941 &csrf_token,
3942 )
3943 });
3944 let Some(html) = html else {
3945 return http_text_response(
3946 500,
3947 "Internal Server Error",
3948 "declared login route failed to render\n",
3949 );
3950 };
3951 http_html_response_with_csrf(
3952 status_code,
3953 status_text,
3954 &html,
3955 request.method.as_str(),
3956 Some(&csrf_token),
3957 )
3958}
3959
3960fn login_error_response(app: &AppRuntime, request: &http::HttpRequest, message: &str) -> Vec<u8> {
3962 login_error_response_with_status(app, request, message, 422, "Unprocessable Entity")
3963}
3964
3965fn http_static_response(response: artifact_server::StaticResponse, method: &str) -> Vec<u8> {
3967 let mut headers = String::new();
3968 headers.push_str(&http_status_line(
3969 response.status_code,
3970 response.status_text,
3971 ));
3972 headers.push_str("Content-Type: ");
3973 headers.push_str(response.content_type);
3974 headers.push_str("\r\n");
3975 headers.push_str(&format!("Content-Length: {}\r\n", response.content_length));
3976 headers.push_str("Connection: close\r\n\r\n");
3977 if method == "HEAD" {
3978 return headers.into_bytes();
3979 }
3980 let mut bytes = headers.into_bytes();
3981 bytes.extend_from_slice(&response.body);
3982 bytes
3983}
3984
3985fn read_http_request<R: Read>(stream: &mut R) -> Result<http::HttpRequest, &'static str> {
3987 let mut buffer = Vec::with_capacity(1024);
3988 let mut chunk = [0u8; 1024];
3989 let header_end;
3990 loop {
3991 let read = match stream.read(&mut chunk) {
3992 Ok(read) => read,
3993 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
3994 Err(error)
3995 if buffer.is_empty()
3996 && matches!(
3997 error.kind(),
3998 std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
3999 ) =>
4000 {
4001 return Err("request connection closed");
4004 }
4005 Err(_) => return Err("request parse failed"),
4006 };
4007 if read == 0 {
4008 return Err("request connection closed");
4009 }
4010 buffer.extend_from_slice(&chunk[..read]);
4011 if let Some(end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
4012 header_end = end + 4;
4013 break;
4014 }
4015 if let Some(end) = buffer.windows(2).position(|window| window == b"\n\n") {
4016 header_end = end + 2;
4017 break;
4018 }
4019 if buffer.len() > http::PHAROS_HTTP_MAX_HEADER_BYTES {
4020 return Err("request parse failed");
4021 }
4022 }
4023 let parsed = http::parse_http_request(&buffer[..header_end])?;
4024 let mut request = http::HttpRequest {
4025 method: parsed.method,
4026 target: parsed.target,
4027 headers: parsed
4028 .headers
4029 .iter()
4030 .map(|(name, value)| format!("{name}: {value}"))
4031 .collect::<Vec<_>>()
4032 .join("\r\n"),
4033 ..Default::default()
4034 };
4035 let mut content_length = 0i64;
4036 let mut chunked = false;
4037 for (name, value) in &parsed.headers {
4038 http::apply_known_header(&mut request, name, value, &mut content_length, &mut chunked);
4039 }
4040 if content_length < 0 {
4041 return Err("invalid request body length");
4042 }
4043 let body_limit = if request
4044 .content_type
4045 .as_deref()
4046 .is_some_and(|value| value.to_ascii_lowercase().starts_with("multipart/"))
4047 {
4048 http::PHAROS_HTTP_MAX_MULTIPART_BODY_BYTES
4049 } else {
4050 http::PHAROS_HTTP_MAX_BODY_BYTES
4051 };
4052 if chunked {
4053 let mut cursor = header_end;
4054 let mut body = Vec::new();
4055 loop {
4056 while buffer[cursor..].windows(2).all(|window| window != b"\r\n") {
4057 let read = stream
4058 .read(&mut chunk)
4059 .map_err(|_| "chunked body read failed")?;
4060 if read == 0 {
4061 return Err("chunked body truncated");
4062 }
4063 buffer.extend_from_slice(&chunk[..read]);
4064 }
4065 let line_end = buffer[cursor..]
4066 .windows(2)
4067 .position(|window| window == b"\r\n")
4068 .ok_or("chunk size missing")?
4069 + cursor;
4070 let size_text =
4071 std::str::from_utf8(&buffer[cursor..line_end]).map_err(|_| "chunk size invalid")?;
4072 let size =
4073 usize::from_str_radix(size_text.trim(), 16).map_err(|_| "chunk size invalid")?;
4074 cursor = line_end + 2;
4075 if size == 0 {
4076 request.body = body;
4077 request.body_len = request.body.len();
4078 request.is_multipart = request
4079 .content_type
4080 .as_deref()
4081 .is_some_and(|value| value.to_ascii_lowercase().starts_with("multipart/"));
4082 return Ok(request);
4083 }
4084 if body.len().saturating_add(size) > body_limit {
4085 return Err("chunked body exceeds its limit");
4086 }
4087 while buffer.len() < cursor.saturating_add(size).saturating_add(2) {
4088 let read = stream
4089 .read(&mut chunk)
4090 .map_err(|_| "chunked body read failed")?;
4091 if read == 0 {
4092 return Err("chunked body truncated");
4093 }
4094 buffer.extend_from_slice(&chunk[..read]);
4095 }
4096 body.extend_from_slice(&buffer[cursor..cursor + size]);
4097 if &buffer[cursor + size..cursor + size + 2] != b"\r\n" {
4098 return Err("chunk terminator missing");
4099 }
4100 cursor += size + 2;
4101 }
4102 }
4103 let body_len = usize::try_from(content_length).map_err(|_| "request body length invalid")?;
4104 if body_len > body_limit {
4105 return Err("request body exceeds its limit");
4106 }
4107 while buffer.len() < header_end + body_len {
4108 let read = stream
4109 .read(&mut chunk)
4110 .map_err(|_| "request body read failed")?;
4111 if read == 0 {
4112 return Err("request body truncated");
4113 }
4114 buffer.extend_from_slice(&chunk[..read]);
4115 }
4116 request.body = buffer[header_end..header_end + body_len].to_vec();
4117 request.body_len = body_len;
4118 request.is_multipart = request
4119 .content_type
4120 .as_deref()
4121 .is_some_and(|value| value.to_ascii_lowercase().starts_with("multipart/"));
4122 Ok(request)
4123}
4124
4125fn current_timestamp_utc() -> String {
4127 pharos_runtime_core::time::current_timestamp_utc()
4128}
4129
4130fn timestamp_after_seconds_utc(additional_seconds: u64) -> String {
4132 pharos_runtime_core::time::timestamp_after_seconds_utc(additional_seconds)
4133}
4134
4135fn chrono_like_timestamp(seconds: u64) -> String {
4137 pharos_runtime_core::time::timestamp_from_epoch_seconds(seconds)
4138}
4139
4140fn runtime_timestamp_seconds(timestamp_text: &str) -> Option<u64> {
4142 let text = timestamp_text.trim();
4143 if text.is_empty() {
4144 return None;
4145 }
4146 if text.bytes().all(|byte| byte.is_ascii_digit()) {
4147 return text.parse::<u64>().ok();
4148 }
4149 runtime_timestamp_seconds_utc_text(text)
4150}
4151
4152fn runtime_timestamp_seconds_utc_text(text: &str) -> Option<u64> {
4154 let (date_text, time_text, offset_seconds) =
4155 if let Some((date_text, time_text)) = text.split_once('T') {
4156 let (time_text, offset_seconds) = if let Some(time_text) = time_text.strip_suffix('Z') {
4157 (time_text, 0_i64)
4158 } else {
4159 let sign_index = time_text.rfind(['+', '-'])?;
4160 let (time_text, offset_text) = time_text.split_at(sign_index);
4161 (time_text, runtime_timestamp_offset_seconds(offset_text)?)
4162 };
4163 (date_text, time_text, offset_seconds)
4164 } else if let Some((date_text, time_text)) = text.split_once(' ') {
4165 (date_text, time_text, 0_i64)
4166 } else {
4167 return None;
4168 };
4169 let (year, month, day) = runtime_timestamp_parse_date(date_text)?;
4170 let (hour, minute, second) = runtime_timestamp_parse_time(time_text)?;
4171 let days = runtime_days_from_civil(year, month, day)?;
4172 let seconds = days
4173 .checked_mul(86_400)?
4174 .checked_add(i64::from(hour) * 3_600)?
4175 .checked_add(i64::from(minute) * 60)?
4176 .checked_add(i64::from(second))?
4177 .checked_sub(offset_seconds)?;
4178 u64::try_from(seconds).ok()
4179}
4180
4181fn runtime_timestamp_parse_date(text: &str) -> Option<(i64, u32, u32)> {
4183 let mut parts = text.split('-');
4184 let year = parts.next()?.parse::<i64>().ok()?;
4185 let month = parts.next()?.parse::<u32>().ok()?;
4186 let day = parts.next()?.parse::<u32>().ok()?;
4187 if parts.next().is_some() {
4188 return None;
4189 }
4190 (1..=12).contains(&month).then_some((year, month, day))
4191}
4192
4193fn runtime_timestamp_parse_time(text: &str) -> Option<(u32, u32, u32)> {
4195 let mut parts = text.split(':');
4196 let hour = parts.next()?.parse::<u32>().ok()?;
4197 let minute = parts.next()?.parse::<u32>().ok()?;
4198 let second_text = parts.next()?;
4199 if parts.next().is_some() {
4200 return None;
4201 }
4202 let second_text = second_text
4203 .split_once('.')
4204 .map(|(value, _)| value)
4205 .unwrap_or(second_text);
4206 let second = second_text.parse::<u32>().ok()?;
4207 (hour < 24 && minute < 60 && second < 60).then_some((hour, minute, second))
4208}
4209
4210fn runtime_timestamp_offset_seconds(text: &str) -> Option<i64> {
4212 if text.len() != 6 {
4213 return None;
4214 }
4215 let sign = match text.as_bytes().first().copied()? {
4216 b'+' => 1_i64,
4217 b'-' => -1_i64,
4218 _ => return None,
4219 };
4220 if text.as_bytes().get(3).copied()? != b':' {
4221 return None;
4222 }
4223 let hour = text.get(1..3)?.parse::<i64>().ok()?;
4224 let minute = text.get(4..6)?.parse::<i64>().ok()?;
4225 if hour >= 24 || minute >= 60 {
4226 return None;
4227 }
4228 Some(sign * (hour * 3_600 + minute * 60))
4229}
4230
4231fn runtime_days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
4233 let max_day = match month {
4234 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4235 4 | 6 | 9 | 11 => 30,
4236 2 if runtime_is_leap_year(year) => 29,
4237 2 => 28,
4238 _ => return None,
4239 };
4240 if day == 0 || day > max_day {
4241 return None;
4242 }
4243 let month = i64::from(month);
4244 let day = i64::from(day);
4245 let year = year - i64::from(month <= 2);
4246 let era = if year >= 0 { year } else { year - 399 } / 400;
4247 let yoe = year - era * 400;
4248 let mp = month + if month > 2 { -3 } else { 9 };
4249 let doy = (153 * mp + 2) / 5 + day - 1;
4250 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
4251 Some(era * 146_097 + doe - 719_468)
4252}
4253
4254fn runtime_is_leap_year(year: i64) -> bool {
4256 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
4257}
4258
4259fn emit_runtime_log_banner(stack: &RuntimeStack, config_record: &ServeConfig, load_ms: i64) {
4261 let timestamp = current_timestamp_utc();
4262 for line in runtime_log_banner_lines(stack, config_record, load_ms, ×tamp) {
4263 println!("{line}");
4264 }
4265}
4266
4267enum RuntimeListener {
4268 Tcp(TcpListener),
4269 #[cfg(unix)]
4270 Unix(UnixListener, PathBuf),
4271}
4272
4273impl RuntimeListener {
4274 fn accept(&self) -> std::io::Result<AcceptedConnection> {
4276 match self {
4277 Self::Tcp(listener) => listener
4278 .accept()
4279 .map(|(stream, _)| AcceptedConnection::Tcp(stream)),
4280 #[cfg(unix)]
4281 Self::Unix(listener, _) => listener
4282 .accept()
4283 .map(|(stream, _)| AcceptedConnection::Unix(stream)),
4284 }
4285 }
4286
4287 fn local_endpoint(&self) -> String {
4289 match self {
4290 Self::Tcp(listener) => listener
4291 .local_addr()
4292 .map(|addr| addr.to_string())
4293 .unwrap_or_else(|_| "127.0.0.1:7323".to_owned()),
4294 #[cfg(unix)]
4295 Self::Unix(_, path) => path.to_string_lossy().into_owned(),
4296 }
4297 }
4298}
4299
4300impl Drop for RuntimeListener {
4301 fn drop(&mut self) {
4303 #[cfg(unix)]
4304 if let Self::Unix(_, path) = self {
4305 let _ = stdfs::remove_file(path);
4306 }
4307 }
4308}
4309
4310enum AcceptedConnection {
4311 Tcp(TcpStream),
4312 #[cfg(unix)]
4313 Unix(UnixStream),
4314}
4315
4316impl AcceptedConnection {
4317 fn set_timeouts(&self) {
4319 let timeout = Some(Duration::from_secs(10));
4323 match self {
4324 Self::Tcp(stream) => {
4325 let _ = stream.set_nonblocking(false);
4326 let _ = stream.set_read_timeout(timeout);
4327 let _ = stream.set_write_timeout(timeout);
4328 }
4329 #[cfg(unix)]
4330 Self::Unix(stream) => {
4331 let _ = stream.set_nonblocking(false);
4332 let _ = stream.set_read_timeout(timeout);
4333 let _ = stream.set_write_timeout(timeout);
4334 }
4335 }
4336 }
4337
4338 fn shutdown(&self) {
4340 match self {
4341 Self::Tcp(stream) => {
4342 let _ = stream.shutdown(Shutdown::Both);
4343 }
4344 #[cfg(unix)]
4345 Self::Unix(stream) => {
4346 let _ = stream.shutdown(Shutdown::Both);
4347 }
4348 }
4349 }
4350
4351 fn read_request(&mut self) -> Result<http::HttpRequest, &'static str> {
4353 match self {
4354 Self::Tcp(stream) => read_http_request(stream),
4355 #[cfg(unix)]
4356 Self::Unix(stream) => read_http_request(stream),
4357 }
4358 }
4359
4360 fn write_all(&mut self, response: &[u8]) {
4362 match self {
4363 Self::Tcp(stream) => {
4364 let _ = stream.write_all(response);
4365 let _ = stream.flush();
4366 }
4367 #[cfg(unix)]
4368 Self::Unix(stream) => {
4369 let _ = stream.write_all(response);
4370 let _ = stream.flush();
4371 }
4372 }
4373 }
4374
4375 fn write_stream(&mut self, response: &artifact_server::StreamResponse, method: &str) {
4377 match self {
4378 Self::Tcp(stream) => write_stream_response(stream, response, method),
4379 #[cfg(unix)]
4380 Self::Unix(stream) => write_stream_response(stream, response, method),
4381 }
4382 }
4383}
4384
4385fn write_stream_response<W: Write>(
4387 stream: &mut W,
4388 response: &artifact_server::StreamResponse,
4389 method: &str,
4390) {
4391 let content_range = if response.status_code == 206 {
4392 format!(
4393 "Content-Range: bytes {}-{}/{}\r\n",
4394 response.offset,
4395 response.offset + response.content_length.saturating_sub(1),
4396 response.complete_length
4397 )
4398 } else {
4399 String::new()
4400 };
4401 let headers = format!(
4402 "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\nETag: {}\r\nCache-Control: {}\r\nX-Content-Type-Options: nosniff\r\n{}{}Connection: close\r\n\r\n",
4403 response.status_code,
4404 response.status_text,
4405 response.content_type,
4406 response.content_length,
4407 response.etag,
4408 response.cache_control,
4409 content_range,
4410 response
4411 .content_disposition
4412 .as_ref()
4413 .map(|value| format!("Content-Disposition: {value}\r\n"))
4414 .unwrap_or_default(),
4415 );
4416 if stream.write_all(headers.as_bytes()).is_err() || method == "HEAD" {
4417 return;
4418 }
4419 let Ok(mut file) = stdfs::File::open(&response.file_path) else {
4420 return;
4421 };
4422 if file.seek(SeekFrom::Start(response.offset)).is_err() {
4423 return;
4424 }
4425 let mut remaining = response.content_length;
4426 let mut buffer = [0u8; 64 * 1024];
4427 while remaining > 0 {
4428 let count = usize::try_from(remaining.min(buffer.len() as u64)).unwrap_or(buffer.len());
4429 let Ok(read) = file.read(&mut buffer[..count]) else {
4430 return;
4431 };
4432 if read == 0 || stream.write_all(&buffer[..read]).is_err() {
4433 return;
4434 }
4435 remaining = remaining.saturating_sub(read as u64);
4436 }
4437 let _ = stream.flush();
4438}
4439
4440fn http_stream_error(error: artifact_server::StreamError, method: &str) -> Vec<u8> {
4442 let mut response = http_static_response(error.response, method);
4443 if let Some(length) = error.unsatisfied_length
4444 && let Some(separator) = response.windows(2).position(|window| window == b"\r\n")
4445 {
4446 let header = format!("Content-Range: bytes */{length}\r\nAccept-Ranges: bytes\r\n");
4447 response.splice(separator + 2..separator + 2, header.bytes());
4448 }
4449 response
4450}
4451
4452fn open_runtime_listener(
4454 config_record: &ServeConfig,
4455 stack: &RuntimeStack,
4456) -> Result<RuntimeListener, &'static str> {
4457 if let Some(socket_path) = config_record
4458 .socket_path
4459 .as_deref()
4460 .filter(|value| !value.is_empty())
4461 .or_else(|| {
4462 stack
4463 .socket_path
4464 .as_deref()
4465 .filter(|value| !value.is_empty())
4466 })
4467 {
4468 #[cfg(unix)]
4469 {
4470 let path = PathBuf::from(socket_path);
4471 if let Some(parent) = path.parent() {
4472 stdfs::create_dir_all(parent).map_err(|_| "failed to open listener")?;
4473 }
4474 if path.exists() {
4475 stdfs::remove_file(&path).map_err(|_| "failed to open listener")?;
4476 }
4477 let listener = UnixListener::bind(&path).map_err(|_| "failed to open listener")?;
4478 listener
4479 .set_nonblocking(true)
4480 .map_err(|_| "failed to open listener")?;
4481 return Ok(RuntimeListener::Unix(listener, path));
4482 }
4483 #[cfg(not(unix))]
4484 {
4485 let _ = socket_path;
4486 return Err("unix socket runtime serve not supported on this target");
4487 }
4488 }
4489
4490 let host = config_record
4491 .host
4492 .as_deref()
4493 .filter(|value| !value.is_empty())
4494 .or_else(|| stack.host.as_deref().filter(|value| !value.is_empty()))
4495 .unwrap_or("127.0.0.1");
4496 let port = config_record
4497 .port
4498 .as_deref()
4499 .filter(|value| !value.is_empty())
4500 .or_else(|| stack.port.as_deref().filter(|value| !value.is_empty()))
4501 .unwrap_or("7323");
4502 let listener =
4503 TcpListener::bind(format!("{host}:{port}")).map_err(|_| "failed to open listener")?;
4504 listener
4505 .set_nonblocking(true)
4506 .map_err(|_| "failed to open listener")?;
4507 Ok(RuntimeListener::Tcp(listener))
4508}
4509
4510fn is_business_availability_create_path(path: &str) -> bool {
4512 path.starts_with("/businesses/")
4513 && !path.contains("/technicians/")
4514 && path.ends_with("/availability/new/")
4515}
4516
4517fn matched_app_route(app: &AppRuntime, method: &str, path: &str) -> Option<Value> {
4519 pharos_runtime_core::route::matched_app_route_value(app, method, path)
4520}
4521
4522fn trusted_browser_origin(request: &http::HttpRequest) -> bool {
4524 let origin = request.headers.lines().find_map(|line| {
4525 let (name, value) = line.split_once(':')?;
4526 name.trim()
4527 .eq_ignore_ascii_case("origin")
4528 .then_some(value.trim())
4529 });
4530 let Some(origin) = origin else {
4531 return true;
4532 };
4533 let host = request.headers.lines().find_map(|line| {
4534 let (name, value) = line.split_once(':')?;
4535 name.trim()
4536 .eq_ignore_ascii_case("host")
4537 .then_some(value.trim())
4538 });
4539 host.is_some_and(|host| {
4540 origin
4541 .strip_prefix("https://")
4542 .or_else(|| origin.strip_prefix("http://"))
4543 .and_then(|authority| authority.split('/').next())
4544 .is_some_and(|authority| authority.eq_ignore_ascii_case(host))
4545 })
4546}
4547
4548fn actor_owns_mutation_resource(
4551 app: &AppRuntime,
4552 path: &str,
4553 actor_id: i64,
4554) -> Result<bool, pharos_runtime_database::operations::DatabaseError> {
4555 let resource_path = path.strip_prefix("/api").unwrap_or(path);
4556 if !app_uses_file_backed_runtime_state(app) {
4557 if let Some(slug) = resource_path
4558 .strip_prefix("/businesses/")
4559 .and_then(|value| value.split('/').next())
4560 {
4561 if slug == "new" {
4562 return Ok(actor_id > 0);
4563 }
4564 let store = checked_app_database_operations(app)?;
4565 return pharos_runtime_business::actor_manages_business(
4566 store.adapter.as_ref(),
4567 &store.config,
4568 &store.schema,
4569 actor_id,
4570 slug,
4571 );
4572 }
4573 if let Some(appointment_id) = resource_path
4574 .strip_prefix("/appointments/")
4575 .and_then(|value| value.split('/').next())
4576 .and_then(|value| value.parse::<i64>().ok())
4577 {
4578 let store = checked_app_database_operations(app)?;
4579 return pharos_runtime_business::actor_owns_appointment(
4580 store.adapter.as_ref(),
4581 &store.config,
4582 &store.schema,
4583 actor_id,
4584 appointment_id,
4585 );
4586 }
4587 if resource_path.starts_with("/appointments/") {
4588 return Ok(false);
4589 }
4590 return Ok(true);
4591 }
4592 let Some(state_path) = app.state_path.as_deref() else {
4593 return Ok(true);
4594 };
4595 let Some(root) = stdfs::read_to_string(state_path)
4596 .ok()
4597 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
4598 else {
4599 return Ok(false);
4600 };
4601 if app
4602 .app_root
4603 .as_deref()
4604 .and_then(|app_root| {
4605 payment_provider::payment_provider_owner_route_value(Path::new(app_root), resource_path)
4606 })
4607 .is_some()
4608 {
4609 return Ok(app
4610 .app_root
4611 .as_deref()
4612 .and_then(|app_root| {
4613 payment_provider::actor_owns_declared_payment_provider_route(
4614 &root,
4615 Path::new(app_root),
4616 resource_path,
4617 actor_id,
4618 )
4619 .ok()
4620 })
4621 .unwrap_or(false));
4622 }
4623 if let Some(slug) = resource_path
4624 .strip_prefix("/businesses/")
4625 .and_then(|value| value.split('/').next())
4626 {
4627 if slug == "new" {
4628 return Ok(true);
4629 }
4630 let business_id = root
4631 .get("businesses")
4632 .and_then(Value::as_array)
4633 .and_then(|rows| {
4634 rows.iter().find(|row| {
4635 row.get("slug").and_then(Value::as_str) == Some(slug)
4636 || row.get("id").and_then(Value::as_i64) == slug.parse::<i64>().ok()
4637 })
4638 })
4639 .and_then(|row| row.get("id").and_then(Value::as_i64));
4640 return Ok(business_id
4641 .is_some_and(|id| business::dynamic_user_manages_business(&root, actor_id, id)));
4642 }
4643 if let Some(appointment_id) = resource_path
4644 .strip_prefix("/appointments/")
4645 .and_then(|value| value.split('/').next())
4646 .and_then(|value| value.parse::<i64>().ok())
4647 {
4648 return Ok(root
4649 .get("appointments")
4650 .and_then(Value::as_array)
4651 .and_then(|rows| {
4652 rows.iter()
4653 .find(|row| row.get("id").and_then(Value::as_i64) == Some(appointment_id))
4654 })
4655 .is_some_and(|row| {
4656 row.get("user_id").and_then(Value::as_i64) == Some(actor_id)
4657 || row
4658 .get("business_id")
4659 .and_then(Value::as_i64)
4660 .is_some_and(|business_id| {
4661 business::dynamic_user_manages_business(&root, actor_id, business_id)
4662 })
4663 }));
4664 }
4665 Ok(true)
4666}
4667
4668fn actor_has_route_policy(
4670 app: &AppRuntime,
4671 actor_id: i64,
4672 capability: &str,
4673) -> Result<bool, pharos_runtime_database::operations::DatabaseError> {
4674 if capability == "public"
4675 && app
4676 .application_ir
4677 .as_ref()
4678 .is_some_and(|ir| ir.policies.len() == 1)
4679 {
4680 return Ok(true);
4681 }
4682 let Some(application_ir) = app.application_ir.as_ref() else {
4683 return Ok(false);
4684 };
4685 let mut roles = vec!["anonymous".to_owned()];
4686 if actor_id > 0 {
4687 roles.push("authenticated_user".to_owned());
4688 }
4689 if actor_id > 0
4690 && app_uses_file_backed_runtime_state(app)
4691 && let Some(state_path) = app.state_path.as_deref()
4692 && let Some(root) = stdfs::read_to_string(state_path)
4693 .ok()
4694 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
4695 {
4696 if let Some(account_type) = root
4697 .get("users")
4698 .and_then(Value::as_array)
4699 .and_then(|rows| {
4700 rows.iter()
4701 .find(|row| row.get("id").and_then(Value::as_i64) == Some(actor_id))
4702 })
4703 .and_then(|row| row.get("account_type").and_then(Value::as_str))
4704 {
4705 roles.push(account_type.to_owned());
4706 }
4707 roles.extend(
4708 root.get("tenant_memberships")
4709 .and_then(Value::as_array)
4710 .into_iter()
4711 .flatten()
4712 .filter(|row| {
4713 row.get("user_id").and_then(Value::as_i64) == Some(actor_id)
4714 && row.get("membership_status").and_then(Value::as_str) == Some("active")
4715 })
4716 .filter_map(|row| {
4717 row.get("role_id")
4718 .and_then(Value::as_str)
4719 .map(ToOwned::to_owned)
4720 }),
4721 );
4722 }
4723 if actor_id > 0 {
4724 let now = SystemTime::now()
4725 .duration_since(UNIX_EPOCH)
4726 .map(|value| value.as_secs())
4727 .unwrap_or(u64::MAX);
4728 let cache_scope = app_security_cache_scope(app)?;
4729 let app_id = cache_scope.as_str();
4730 let backend_roles =
4731 match pharos_runtime_security::local_role_cache_lookup(app_id, actor_id, now) {
4732 Some(roles) => roles,
4733 None => {
4734 let mut loaded = feature_runtime::actor_roles(app, actor_id)?;
4735 if !app_uses_file_backed_runtime_state(app) {
4736 let store = checked_app_database_operations(app)?;
4737 if store
4738 .schema
4739 .entities
4740 .iter()
4741 .any(|entity| entity.name == "tenant_membership")
4742 {
4743 loaded.extend(pharos_runtime_business::actor_tenant_roles(
4744 store.adapter.as_ref(),
4745 &store.config,
4746 &store.schema,
4747 actor_id,
4748 )?);
4749 }
4750 }
4751 loaded.sort();
4752 loaded.dedup();
4753 pharos_runtime_security::local_role_cache_store(app_id, actor_id, &loaded, now);
4754 loaded
4755 }
4756 };
4757 roles.extend(backend_roles);
4758 roles.sort();
4759 roles.dedup();
4760 }
4761 Ok(application_ir
4762 .role_capability_edges
4763 .iter()
4764 .any(|(role, granted)| {
4765 granted.as_str() == capability
4766 && roles.iter().any(|actor_role| actor_role == role.as_str())
4767 }))
4768}
4769
4770fn normalized_booking_time(direct: &str, hour: &str, minute: &str, period: &str) -> Option<String> {
4772 if !direct.trim().is_empty() {
4773 let (hour, minute) = direct.trim().split_once(':')?;
4774 let hour = hour.parse::<u8>().ok()?;
4775 let minute = minute.parse::<u8>().ok()?;
4776 return (hour < 24 && minute < 60).then(|| format!("{hour:02}:{minute:02}"));
4777 }
4778 let hour = hour.trim().parse::<u8>().ok()?;
4779 let minute = minute.trim().parse::<u8>().ok()?;
4780 if !(1..=12).contains(&hour) || minute >= 60 {
4781 return None;
4782 }
4783 let hour = match period.trim().to_ascii_uppercase().as_str() {
4784 "AM" => hour % 12,
4785 "PM" => hour % 12 + 12,
4786 _ => return None,
4787 };
4788 Some(format!("{hour:02}:{minute:02}"))
4789}
4790
4791fn authorize_app_mutation(
4793 app: &AppRuntime,
4794 request: &http::HttpRequest,
4795 path: &str,
4796) -> Result<(), Vec<u8>> {
4797 let Some(route) = matched_app_route(app, &request.method, path) else {
4798 return Err(http_text_response(404, "Not Found", "undeclared action\n"));
4799 };
4800 let security = route
4801 .get("effective_security")
4802 .and_then(Value::as_object)
4803 .ok_or_else(|| http_text_response(403, "Forbidden", "route security unavailable\n"))?;
4804 let csrf_required = security
4805 .get("csrf_mode")
4806 .and_then(Value::as_str)
4807 .is_some_and(|mode| matches!(mode, "required" | "mutation_required"));
4808 let trusted_origin_required = security
4809 .get("trusted_origin_policy")
4810 .and_then(Value::as_str)
4811 .is_some_and(|mode| matches!(mode, "required" | "mutation_required"));
4812 if (trusted_origin_required && !trusted_browser_origin(request))
4813 || (csrf_required
4814 && !dynamic::csrf_request_valid(
4815 request,
4816 dynamic::request_body_text(request).as_deref(),
4817 ))
4818 {
4819 return Err(http_text_response(
4820 403,
4821 "Forbidden",
4822 "Security verification failed\n",
4823 ));
4824 }
4825 let auth_gate = route
4826 .get("auth_gate")
4827 .and_then(Value::as_str)
4828 .unwrap_or_default();
4829 let policy_gate = route
4830 .get("policy_gate")
4831 .and_then(Value::as_str)
4832 .unwrap_or_default();
4833 let capability_policy = security
4834 .get("capability_policy")
4835 .and_then(Value::as_str)
4836 .unwrap_or_default();
4837 if capability_policy == "policy_gate" && policy_gate.is_empty() {
4838 return Err(http_text_response(403, "Forbidden", "policy required\n"));
4839 }
4840 let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
4841 Ok(value) => value,
4842 Err(error) => return Err(database_error_response(error)),
4843 };
4844 if auth_gate == "session_required" && actor_id <= 0 {
4845 let next_path = route
4846 .get("action_id")
4847 .and_then(Value::as_str)
4848 .and_then(|action_id| feature_runtime::action_fallback_redirect(app, action_id).ok())
4849 .map(|redirect| app_scope_path(app, &redirect))
4850 .unwrap_or_else(|| path.to_owned());
4851 return Err(app_login_redirect(app, &next_path));
4852 }
4853 if capability_policy == "policy_gate"
4854 && !match actor_has_route_policy(app, actor_id, policy_gate) {
4855 Ok(value) => value,
4856 Err(error) => return Err(database_error_response(error)),
4857 }
4858 {
4859 return Err(http_text_response(403, "Forbidden", "capability denied\n"));
4860 }
4861 let tenant_scope = security
4862 .get("tenant_scope")
4863 .and_then(Value::as_str)
4864 .unwrap_or_default();
4865 if tenant_scope == "policy_bound"
4866 && actor_id > 0
4867 && !match actor_owns_mutation_resource(app, path, actor_id) {
4868 Ok(value) => value,
4869 Err(error) => return Err(database_error_response(error)),
4870 }
4871 {
4872 return Err(http_text_response(
4873 403,
4874 "Forbidden",
4875 "resource access denied\n",
4876 ));
4877 }
4878 let rate_limit_policy = security
4879 .get("rate_limit_policy")
4880 .and_then(Value::as_str)
4881 .unwrap_or_default();
4882 let (maximum, window_seconds) = match rate_limit_policy {
4883 "runtime_default" => {
4884 if route.get("action_id").and_then(Value::as_str) == Some("login") {
4885 (10, 300)
4886 } else {
4887 (120, 60)
4888 }
4889 }
4890 _ => {
4891 return Err(http_text_response(
4892 503,
4893 "Service Unavailable",
4894 "rate-limit policy unavailable\n",
4895 ));
4896 }
4897 };
4898 let bucket_key = if route.get("action_id").and_then(Value::as_str) == Some("login") {
4899 dynamic::request_form_value(request, "username")
4900 .map(|value| value.trim().to_ascii_lowercase())
4901 .filter(|value| !value.is_empty())
4902 .unwrap_or_else(|| "anonymous".to_owned())
4903 } else if actor_id > 0 {
4904 format!("actor:{actor_id}")
4905 } else {
4906 "anonymous".to_owned()
4907 };
4908 let policy_key = format!(
4909 "{}:{}",
4910 rate_limit_policy,
4911 route
4912 .get("route_id")
4913 .and_then(Value::as_str)
4914 .unwrap_or("unknown")
4915 );
4916 if !app_uses_file_backed_runtime_state(app) {
4917 let store = checked_app_database_operations(app).map_err(database_error_response)?;
4918 return match store.adapter.admit_rate_limited_request(
4919 &store.config,
4920 &policy_key,
4921 &bucket_key,
4922 maximum,
4923 window_seconds,
4924 ) {
4925 Ok(true) => Ok(()),
4926 Ok(false) => Err(http_text_response(
4927 429,
4928 "Too Many Requests",
4929 "rate limit exceeded\n",
4930 )),
4931 Err(error) => Err(database_error_response(error)),
4932 };
4933 }
4934 match dynamic::admit_rate_limited_request_runtime_local(
4935 app,
4936 &policy_key,
4937 &bucket_key,
4938 maximum,
4939 window_seconds,
4940 ) {
4941 Ok(true) => {}
4942 Ok(false) => {
4943 return Err(http_text_response(
4944 429,
4945 "Too Many Requests",
4946 "rate limit exceeded\n",
4947 ));
4948 }
4949 Err(_) => {
4950 return Err(http_text_response(
4951 503,
4952 "Service Unavailable",
4953 "rate-limit state unavailable\n",
4954 ));
4955 }
4956 }
4957 Ok(())
4958}
4959
4960fn app_static_fragment(artifact_root: &Path, relative_path: &str) -> Option<String> {
4962 json_commands::render_app_fragment_from_doc(
4963 artifact_root.to_string_lossy().as_ref(),
4964 relative_path,
4965 &Value::Object(serde_json::Map::new()),
4966 )
4967}
4968
4969fn app_select_option(
4971 artifact_root: &Path,
4972 value: &str,
4973 label: &str,
4974 selected: bool,
4975) -> Option<String> {
4976 json_commands::render_app_fragment_from_doc(
4977 artifact_root.to_string_lossy().as_ref(),
4978 "templates/shared/fragments/select_option.html",
4979 &serde_json::json!({
4980 "option_value": value,
4981 "option_label": label,
4982 "selected_attr": if selected { " selected" } else { "" },
4983 }),
4984 )
4985}
4986
4987fn app_ui_fragment(artifact_root: &Path, fragment_name: &str, params: Value) -> Option<String> {
4989 json_commands::render_app_fragment_from_doc(
4990 artifact_root.to_string_lossy().as_ref(),
4991 fragment_name,
4992 ¶ms,
4993 )
4994}
4995
4996fn app_ui_input(
4998 artifact_root: &Path,
4999 id: &str,
5000 input_type: &str,
5001 name: &str,
5002 label: &str,
5003 label_class: &str,
5004 class_name: &str,
5005 value: &str,
5006 attributes_html: &str,
5007) -> Option<String> {
5008 app_ui_fragment(
5009 artifact_root,
5010 "templates/shared/fragments/ui_input.html",
5011 serde_json::json!({
5012 "id": id,
5013 "input_type": input_type,
5014 "name": name,
5015 "label": label,
5016 "label_class": label_class,
5017 "class_name": class_name,
5018 "value": value,
5019 "attributes_html": attributes_html,
5020 "help_html": "",
5021 }),
5022 )
5023}
5024
5025fn app_ui_button(
5027 artifact_root: &Path,
5028 id: &str,
5029 button_type: &str,
5030 class_name: &str,
5031 attributes_html: &str,
5032 content_html: &str,
5033) -> Option<String> {
5034 app_ui_fragment(
5035 artifact_root,
5036 "templates/shared/fragments/ui_button.html",
5037 serde_json::json!({
5038 "id": id,
5039 "button_type": button_type,
5040 "class_name": class_name,
5041 "attributes_html": attributes_html,
5042 "content_html": content_html,
5043 }),
5044 )
5045}
5046
5047fn app_ui_select(
5049 artifact_root: &Path,
5050 id: &str,
5051 name: &str,
5052 label: &str,
5053 label_class: &str,
5054 class_name: &str,
5055 attributes_html: &str,
5056 options_html: &str,
5057) -> Option<String> {
5058 app_ui_fragment(
5059 artifact_root,
5060 "templates/shared/fragments/ui_select.html",
5061 serde_json::json!({
5062 "id": id,
5063 "name": name,
5064 "label": label,
5065 "label_class": label_class,
5066 "class_name": class_name,
5067 "attributes_html": attributes_html,
5068 "options_html": options_html,
5069 "help_html": "",
5070 }),
5071 )
5072}
5073
5074fn app_ui_hidden_input(
5076 artifact_root: &Path,
5077 id: &str,
5078 name: &str,
5079 value: &str,
5080 attributes_html: &str,
5081) -> Option<String> {
5082 app_ui_fragment(
5083 artifact_root,
5084 "templates/shared/fragments/ui_hidden_input.html",
5085 serde_json::json!({
5086 "id": id,
5087 "name": name,
5088 "value": value,
5089 "attributes_html": attributes_html,
5090 }),
5091 )
5092}
5093
5094fn app_ui_textarea(
5096 artifact_root: &Path,
5097 id: &str,
5098 name: &str,
5099 label: &str,
5100 label_class: &str,
5101 class_name: &str,
5102 value: &str,
5103 attributes_html: &str,
5104) -> Option<String> {
5105 app_ui_fragment(
5106 artifact_root,
5107 "templates/shared/fragments/ui_textarea.html",
5108 serde_json::json!({
5109 "id": id,
5110 "name": name,
5111 "label": label,
5112 "label_class": label_class,
5113 "class_name": class_name,
5114 "value": value,
5115 "attributes_html": attributes_html,
5116 "help_html": "",
5117 }),
5118 )
5119}
5120
5121fn populate_static_page_ui_params(
5123 artifact_root: &Path,
5124 page_id: &str,
5125 params: &mut serde_json::Map<String, Value>,
5126) -> Option<()> {
5127 let value = |name: &str| {
5128 params
5129 .get(name)
5130 .and_then(Value::as_str)
5131 .unwrap_or_default()
5132 .to_owned()
5133 };
5134 let service_name = value("service_name");
5135 let service_cost = value("service_cost");
5136 let service_duration = value("service_duration");
5137 let service_description = value("service_description");
5138 let service_primary_color = value("service_primary_color");
5139 let service_sort_options_html = value("service_sort_options_html");
5140 let booking_option_count = value("booking_option_count");
5141 let day_options = value("day_options");
5142 let start_time = value("start_time");
5143 let end_time = value("end_time");
5144 let delete_path = value("delete_path");
5145 let job_status_filter = value("status_filter");
5146 let job_search_query = value("search_query");
5147 let profile_full_name = value("full_name");
5148 let profile_phone_number = value("phone_number");
5149 let profile_address = value("address");
5150 let selected_payment_preference = value("selected_payment_preference");
5151 let mut insert = |name: &str, html: Option<String>| -> Option<()> {
5152 params.insert(name.to_owned(), Value::String(html?));
5153 Some(())
5154 };
5155 match page_id {
5156 "login" => {
5157 insert(
5158 "login_username_input_ui_html",
5159 app_ui_input(
5160 artifact_root,
5161 "login_username",
5162 "text",
5163 "username",
5164 "Username",
5165 "",
5166 "form-control",
5167 "",
5168 " autocomplete=\"username\" required",
5169 ),
5170 )?;
5171 insert(
5172 "login_password_input_ui_html",
5173 app_ui_input(
5174 artifact_root,
5175 "login_password",
5176 "password",
5177 "password",
5178 "Password",
5179 "",
5180 "form-control",
5181 "",
5182 " autocomplete=\"current-password\" required",
5183 ),
5184 )?;
5185 insert(
5186 "login_submit_button_ui_html",
5187 app_ui_button(
5188 artifact_root,
5189 "login-submit",
5190 "submit",
5191 "btn btn-primary",
5192 " style=\"width:100%\"",
5193 "Log In",
5194 ),
5195 )?;
5196 }
5197 "password_reset_request" => {
5198 insert(
5199 "id_reset_email_ui_html",
5200 app_ui_input(
5201 artifact_root,
5202 "reset_email",
5203 "email",
5204 "email",
5205 "Email",
5206 "",
5207 "form-control",
5208 "",
5209 " autocomplete=\"email\" required",
5210 ),
5211 )?;
5212 insert(
5213 "password_reset_request_button_ui_html",
5214 app_ui_button(
5215 artifact_root,
5216 "password-reset-submit",
5217 "submit",
5218 "btn btn-primary",
5219 " style=\"width:100%\"",
5220 "Send reset link",
5221 ),
5222 )?;
5223 }
5224 "password_reset_complete" => {
5225 insert(
5226 "id_reset_password_ui_html",
5227 app_ui_input(
5228 artifact_root,
5229 "reset_password",
5230 "password",
5231 "password",
5232 "New password",
5233 "",
5234 "form-control",
5235 "",
5236 " autocomplete=\"new-password\" required",
5237 ),
5238 )?;
5239 insert(
5240 "id_reset_password_confirm_ui_html",
5241 app_ui_input(
5242 artifact_root,
5243 "reset_password_confirm",
5244 "password",
5245 "password_confirm",
5246 "Confirm password",
5247 "",
5248 "form-control",
5249 "",
5250 " autocomplete=\"new-password\" required",
5251 ),
5252 )?;
5253 insert(
5254 "password_reset_complete_button_ui_html",
5255 app_ui_button(
5256 artifact_root,
5257 "password-reset-complete-submit",
5258 "submit",
5259 "btn btn-primary",
5260 " style=\"width:100%\"",
5261 "Update password",
5262 ),
5263 )?;
5264 }
5265 "register" => {
5266 let controls = [
5267 (
5268 "id_username_ui_html",
5269 "register_username",
5270 "text",
5271 "username",
5272 "Username",
5273 " autocomplete=\"username\" required",
5274 ),
5275 (
5276 "id_email_ui_html",
5277 "register_email",
5278 "email",
5279 "email",
5280 "Email",
5281 " autocomplete=\"email\" required",
5282 ),
5283 (
5284 "id_full_name_ui_html",
5285 "register_full_name",
5286 "text",
5287 "full_name",
5288 "Full name",
5289 " autocomplete=\"name\" required",
5290 ),
5291 (
5292 "id_phone_number_ui_html",
5293 "register_phone_number",
5294 "text",
5295 "phone_number",
5296 "Phone",
5297 " autocomplete=\"tel\"",
5298 ),
5299 (
5300 "id_password_ui_html",
5301 "register_password",
5302 "password",
5303 "password",
5304 "Password",
5305 " autocomplete=\"new-password\" required",
5306 ),
5307 (
5308 "id_password_confirm_ui_html",
5309 "register_password_confirm",
5310 "password",
5311 "password_confirm",
5312 "Confirm password",
5313 " autocomplete=\"new-password\" required",
5314 ),
5315 ];
5316 for (param, id, input_type, name, label, attributes) in controls {
5317 insert(
5318 param,
5319 app_ui_input(
5320 artifact_root,
5321 id,
5322 input_type,
5323 name,
5324 label,
5325 "",
5326 "form-control",
5327 "",
5328 attributes,
5329 ),
5330 )?;
5331 }
5332 let options = [
5333 app_select_option(artifact_root, "business_customer", "Customer", true)?,
5334 app_select_option(artifact_root, "business_owner", "Business owner", false)?,
5335 ]
5336 .join("");
5337 insert(
5338 "id_account_type_ui_html",
5339 app_ui_select(
5340 artifact_root,
5341 "register_account_type",
5342 "account_type",
5343 "Account type",
5344 "",
5345 "form-control",
5346 "",
5347 &options,
5348 ),
5349 )?;
5350 insert(
5351 "register_submit_button_ui_html",
5352 app_ui_button(
5353 artifact_root,
5354 "register-submit",
5355 "submit",
5356 "btn btn-primary",
5357 " style=\"width:100%\"",
5358 "Create Account",
5359 ),
5360 )?;
5361 }
5362 "job_queue_list" => {
5363 let options_html = [
5364 ("", "All statuses"),
5365 ("queued", "queued"),
5366 ("running", "running"),
5367 ("succeeded", "succeeded"),
5368 ("failed", "failed"),
5369 ("cancelled", "cancelled"),
5370 ]
5371 .iter()
5372 .map(|(option_value, label)| {
5373 app_select_option(
5374 artifact_root,
5375 option_value,
5376 label,
5377 job_status_filter == *option_value,
5378 )
5379 })
5380 .collect::<Option<Vec<_>>>()?
5381 .join("");
5382 insert(
5383 "job_status_filter_select_ui_html",
5384 app_ui_select(
5385 artifact_root,
5386 "job-status-filter",
5387 "status",
5388 "Status",
5389 "sr-only",
5390 "form-control",
5391 "",
5392 &options_html,
5393 ),
5394 )?;
5395 insert(
5396 "job_search_input_ui_html",
5397 app_ui_input(
5398 artifact_root,
5399 "job-search",
5400 "search",
5401 "q",
5402 "Search jobs",
5403 "sr-only",
5404 "form-control",
5405 &job_search_query,
5406 " placeholder=\"Search job or handler id\" autocomplete=\"off\" maxlength=\"160\"",
5407 ),
5408 )?;
5409 insert(
5410 "job_filter_button_ui_html",
5411 app_ui_button(
5412 artifact_root,
5413 "job-filter",
5414 "submit",
5415 "btn btn-primary",
5416 "",
5417 "Filter",
5418 ),
5419 )?;
5420 }
5421 "booking_form" => {
5422 insert(
5423 "booking_title_input_ui_html",
5424 app_ui_input(
5425 artifact_root,
5426 "booking_title",
5427 "text",
5428 "title",
5429 "Title",
5430 "",
5431 "",
5432 "",
5433 " required",
5434 ),
5435 )?;
5436 insert(
5437 "booking_date_input_ui_html",
5438 app_ui_input(
5439 artifact_root,
5440 "booking_date",
5441 "date",
5442 "date",
5443 "Date",
5444 "",
5445 "",
5446 "",
5447 " required",
5448 ),
5449 )?;
5450 insert(
5451 "booking_time_input_ui_html",
5452 app_ui_input(
5453 artifact_root,
5454 "booking_time",
5455 "time",
5456 "time",
5457 "Time",
5458 "",
5459 "",
5460 "",
5461 " required",
5462 ),
5463 )?;
5464 insert(
5465 "booking_create_button_ui_html",
5466 app_ui_button(
5467 artifact_root,
5468 "booking-create",
5469 "submit",
5470 "",
5471 "",
5472 "Create Booking",
5473 ),
5474 )?;
5475 }
5476 "qr_upload" => {
5477 insert(
5478 "qr_image_input_ui_html",
5479 app_ui_input(
5480 artifact_root,
5481 "qr_image",
5482 "file",
5483 "qr_image",
5484 "Upload QR image",
5485 "sr-only",
5486 "sr-only business-edit-hidden-input",
5487 "",
5488 " accept=\"image/*\" data-file-input-label=\"qr-upload-file-name\"",
5489 ),
5490 )?;
5491 insert(
5492 "qr_image_path_input_ui_html",
5493 app_ui_input(
5494 artifact_root,
5495 "qr_image_path",
5496 "text",
5497 "qr_image_path",
5498 "Local image path",
5499 "",
5500 "form-control",
5501 "",
5502 " placeholder=\"/absolute/path/to/qr.png\"",
5503 ),
5504 )?;
5505 insert(
5506 "qr_payload_input_ui_html",
5507 app_ui_input(
5508 artifact_root,
5509 "qr_payload",
5510 "text",
5511 "qr_payload",
5512 "Fallback QR payload",
5513 "",
5514 "form-control",
5515 "",
5516 " placeholder=\"lotus-studio\"",
5517 ),
5518 )?;
5519 insert(
5520 "qr_upload_button_ui_html",
5521 app_ui_button(
5522 artifact_root,
5523 "qr-upload",
5524 "submit",
5525 "btn btn-primary btn-icon btn-icon-lg",
5526 " title=\"Upload and Scan\" aria-label=\"Upload and Scan\"",
5527 "Upload and Scan",
5528 ),
5529 )?;
5530 insert(
5531 "qr_clear_button_ui_html",
5532 app_ui_button(
5533 artifact_root,
5534 "qr-clear",
5535 "reset",
5536 "btn btn-secondary btn-icon btn-icon-lg",
5537 " title=\"Clear\" aria-label=\"Clear\"",
5538 "Clear",
5539 ),
5540 )?;
5541 }
5542 "profile" => {
5543 for (param, id, name, label, current_value) in [
5544 (
5545 "profile_full_name_input_ui_html",
5546 "profile-full-name",
5547 "full_name",
5548 "Full name",
5549 profile_full_name.as_str(),
5550 ),
5551 (
5552 "profile_phone_number_input_ui_html",
5553 "profile-phone",
5554 "phone_number",
5555 "Phone number",
5556 profile_phone_number.as_str(),
5557 ),
5558 (
5559 "profile_address_input_ui_html",
5560 "profile-address",
5561 "address",
5562 "Address",
5563 profile_address.as_str(),
5564 ),
5565 ] {
5566 insert(
5567 param,
5568 app_ui_input(
5569 artifact_root,
5570 id,
5571 "text",
5572 name,
5573 label,
5574 "",
5575 "form-control",
5576 current_value,
5577 "",
5578 ),
5579 )?;
5580 }
5581 let selected_payment_preference = if selected_payment_preference.is_empty() {
5582 "pay_on_site"
5583 } else {
5584 selected_payment_preference.as_str()
5585 };
5586 let payment_options = [
5587 ("pay_on_site", "Pay on site"),
5588 ("credit_card", "Credit card"),
5589 ]
5590 .into_iter()
5591 .map(|(value, label)| {
5592 app_select_option(
5593 artifact_root,
5594 value,
5595 label,
5596 value == selected_payment_preference,
5597 )
5598 })
5599 .collect::<Option<Vec<_>>>()?
5600 .join("");
5601 insert(
5602 "profile_payment_preference_select_ui_html",
5603 app_ui_select(
5604 artifact_root,
5605 "profile-payment-preference",
5606 "payment_preference",
5607 "Payment preference",
5608 "",
5609 "form-control",
5610 "",
5611 &payment_options,
5612 ),
5613 )?;
5614 insert(
5615 "profile_save_button_ui_html",
5616 app_ui_button(
5617 artifact_root,
5618 "profile-save",
5619 "submit",
5620 "",
5621 "",
5622 "Save Profile",
5623 ),
5624 )?;
5625 insert(
5626 "profile_google_mode_input_ui_html",
5627 app_ui_hidden_input(artifact_root, "profile-google-mode", "mode", "user", ""),
5628 )?;
5629 insert(
5630 "profile_google_next_input_ui_html",
5631 app_ui_hidden_input(
5632 artifact_root,
5633 "profile-google-next",
5634 "next",
5635 "/profile/",
5636 "",
5637 ),
5638 )?;
5639 insert(
5640 "profile_google_connect_button_ui_html",
5641 app_ui_button(
5642 artifact_root,
5643 "profile-google-connect",
5644 "submit",
5645 "",
5646 "",
5647 "Connect Google Calendar",
5648 ),
5649 )?;
5650 }
5651 "business_create" => {
5652 insert(
5653 "business_create_name_input_ui_html",
5654 app_ui_input(
5655 artifact_root,
5656 "business_create_name_input",
5657 "text",
5658 "name",
5659 "Name",
5660 "",
5661 "form-control",
5662 "",
5663 " required autocomplete=\"organization\"",
5664 ),
5665 )?;
5666 insert(
5667 "business_create_address_input_ui_html",
5668 app_ui_input(
5669 artifact_root,
5670 "business_create_address_input",
5671 "text",
5672 "address",
5673 "Address",
5674 "",
5675 "form-control",
5676 "",
5677 " autocomplete=\"street-address\"",
5678 ),
5679 )?;
5680 insert(
5681 "business_create_description_textarea_ui_html",
5682 app_ui_textarea(
5683 artifact_root,
5684 "business_create_description_textarea",
5685 "description",
5686 "Description",
5687 "",
5688 "form-control",
5689 "",
5690 " rows=\"4\"",
5691 ),
5692 )?;
5693 let public_hidden = app_ui_hidden_input(
5694 artifact_root,
5695 "business-create-public-hidden",
5696 "is_public",
5697 "0",
5698 "",
5699 )?;
5700 let public_checkbox = app_ui_fragment(
5701 artifact_root,
5702 "templates/shared/fragments/ui_checkbox.html",
5703 serde_json::json!({"id":"business_create_public_toggle","name":"is_public","class_name":"","value":"1","label_class":"business-create-toggle-option","attributes_html":" checked","label_html":"Public","help_html":""}),
5704 )?;
5705 insert(
5706 "business_create_is_public_input_ui_html",
5707 Some(format!("{public_hidden}{public_checkbox}")),
5708 )?;
5709 let time_options = [
5710 app_select_option(artifact_root, "am_pm", "12-hour AM/PM", true)?,
5711 app_select_option(artifact_root, "24_hour", "24 hour", false)?,
5712 ]
5713 .join("");
5714 insert(
5715 "business_create_time_format_select_ui_html",
5716 app_ui_select(
5717 artifact_root,
5718 "business_create_time_format_select",
5719 "time_format",
5720 "Time Format",
5721 "sr-only",
5722 "form-control",
5723 "",
5724 &time_options,
5725 ),
5726 )?;
5727 insert(
5728 "business_create_secondary_color_input_ui_html",
5729 app_ui_input(
5730 artifact_root,
5731 "business_create_secondary_color_input",
5732 "color",
5733 "secondary_color",
5734 "Banner Color",
5735 "sr-only",
5736 "color-swatch-input",
5737 "#2563eb",
5738 " data-color-hex-target=\"business-create-secondary-color-hex\"",
5739 ),
5740 )?;
5741 insert(
5742 "business_create_secondary_color_hex_input_ui_html",
5743 app_ui_input(
5744 artifact_root,
5745 "business-create-secondary-color-hex",
5746 "text",
5747 "business_create_secondary_color_hex",
5748 "Banner Color Hex",
5749 "sr-only",
5750 "form-control form-control-compact color-hex-input",
5751 "#2563eb",
5752 " inputmode=\"text\" spellcheck=\"false\" autocapitalize=\"characters\" data-color-hex-source=\"secondary_color\"",
5753 ),
5754 )?;
5755 insert(
5756 "business_create_logo_input_ui_html",
5757 app_ui_input(
5758 artifact_root,
5759 "business-create-logo-input",
5760 "file",
5761 "logo",
5762 "Logo",
5763 "sr-only",
5764 "sr-only business-edit-hidden-input",
5765 "",
5766 " accept=\"image/*\" data-file-input-label=\"business-create-logo-file-name\"",
5767 ),
5768 )?;
5769 insert(
5770 "business_create_submit_button_ui_html",
5771 app_ui_button(
5772 artifact_root,
5773 "business_create_submit_button",
5774 "submit",
5775 "btn btn-success btn-icon btn-icon-lg btn-create-business",
5776 " title=\"Create Business\" aria-label=\"Create Business\"",
5777 &app_static_fragment(
5778 artifact_root,
5779 "templates/shared/fragments/ui_content_create_business.html",
5780 )?,
5781 ),
5782 )?;
5783 }
5784 "business_edit" => {}
5785 "business_availability_edit" => {
5786 insert(
5787 "availability_day_select_ui_html",
5788 app_ui_select(
5789 artifact_root,
5790 "availability-day",
5791 "day_of_week",
5792 "Day",
5793 "",
5794 "form-control",
5795 " required",
5796 &day_options,
5797 ),
5798 )?;
5799 insert(
5800 "availability_start_time_input_ui_html",
5801 app_ui_input(
5802 artifact_root,
5803 "availability-start",
5804 "time",
5805 "start_time",
5806 "Start time",
5807 "",
5808 "form-control",
5809 &start_time,
5810 " required",
5811 ),
5812 )?;
5813 insert(
5814 "availability_end_time_input_ui_html",
5815 app_ui_input(
5816 artifact_root,
5817 "availability-end",
5818 "time",
5819 "end_time",
5820 "End time",
5821 "",
5822 "form-control",
5823 &end_time,
5824 " required",
5825 ),
5826 )?;
5827 insert(
5828 "availability_save_button_ui_html",
5829 app_ui_button(
5830 artifact_root,
5831 "availability-save",
5832 "submit",
5833 "btn btn-primary",
5834 "",
5835 "Save",
5836 ),
5837 )?;
5838 insert(
5839 "availability_delete_button_ui_html",
5840 app_ui_button(
5841 artifact_root,
5842 "availability-delete",
5843 "submit",
5844 "btn btn-danger",
5845 &format!(
5846 " formaction=\"{}\"",
5847 json_commands::html_escape_text(&delete_path)
5848 ),
5849 "Delete",
5850 ),
5851 )?;
5852 }
5853 "service_edit" => {
5854 insert(
5855 "service_edit_image_input_ui_html",
5856 app_ui_input(
5857 artifact_root,
5858 "service-edit-image",
5859 "file",
5860 "service_image",
5861 "Choose Service Image",
5862 "sr-only",
5863 "sr-only business-edit-hidden-input",
5864 "",
5865 " accept=\"image/*\" data-file-input-label=\"service-edit-image-file-name\"",
5866 ),
5867 )?;
5868 insert(
5869 "service_edit_remove_image_input_ui_html",
5870 app_ui_fragment(
5871 artifact_root,
5872 "templates/shared/fragments/ui_checkbox.html",
5873 serde_json::json!({"id":"service-edit-remove-image","name":"remove_image","class_name":"sr-only business-edit-hidden-input business-edit-toggle-input","value":"1","label_class":"sr-only","attributes_html":"","label_html":"Remove Service Image","help_html":""}),
5874 ),
5875 )?;
5876 insert(
5877 "service_edit_name_input_ui_html",
5878 app_ui_input(
5879 artifact_root,
5880 "service_edit_name_input",
5881 "text",
5882 "name",
5883 "Name",
5884 "sr-only",
5885 "form-control form-control-compact",
5886 &service_name,
5887 " placeholder=\"Name\" autocomplete=\"off\" aria-label=\"Name\" required",
5888 ),
5889 )?;
5890 insert(
5891 "service_edit_cost_input_ui_html",
5892 app_ui_input(
5893 artifact_root,
5894 "service_edit_cost_input",
5895 "text",
5896 "cost",
5897 "Cost",
5898 "sr-only",
5899 "form-control form-control-compact",
5900 &service_cost,
5901 " placeholder=\"Cost\" inputmode=\"decimal\" required",
5902 ),
5903 )?;
5904 insert(
5905 "service_edit_duration_input_ui_html",
5906 app_ui_input(
5907 artifact_root,
5908 "service_edit_duration_input",
5909 "number",
5910 "duration_minutes",
5911 "Duration",
5912 "sr-only",
5913 "form-control form-control-compact",
5914 &service_duration,
5915 " min=\"1\" placeholder=\"Minutes\" required",
5916 ),
5917 )?;
5918 insert(
5919 "service_edit_sort_order_select_ui_html",
5920 app_ui_select(
5921 artifact_root,
5922 "service_edit_sort_order_select",
5923 "sort_order",
5924 "Display Order",
5925 "sr-only",
5926 "form-control form-control-compact",
5927 " required",
5928 &service_sort_options_html,
5929 ),
5930 )?;
5931 insert(
5932 "service_edit_description_textarea_ui_html",
5933 app_ui_textarea(
5934 artifact_root,
5935 "service_edit_description_textarea",
5936 "description",
5937 "Description",
5938 "sr-only",
5939 "form-control",
5940 &service_description,
5941 "",
5942 ),
5943 )?;
5944 insert(
5945 "service_edit_primary_color_input_ui_html",
5946 app_ui_input(
5947 artifact_root,
5948 "service-edit-primary-color",
5949 "color",
5950 "primary_color",
5951 "Service Color",
5952 "sr-only",
5953 "color-swatch-input",
5954 &service_primary_color,
5955 " data-color-hex-target=\"service-edit-primary-color-hex\"",
5956 ),
5957 )?;
5958 insert(
5959 "service_edit_primary_color_hex_input_ui_html",
5960 app_ui_input(
5961 artifact_root,
5962 "service-edit-primary-color-hex",
5963 "text",
5964 "service_edit_primary_color_hex",
5965 "Service Color Hex Code",
5966 "sr-only",
5967 "form-control form-control-compact color-hex-input",
5968 &service_primary_color,
5969 " inputmode=\"text\" spellcheck=\"false\" autocapitalize=\"characters\" data-color-hex-source=\"primary_color\"",
5970 ),
5971 )?;
5972 for (param, id, kind, class_name, attrs, content) in [
5973 (
5974 "service_edit_save_button_ui_html",
5975 "service_edit_save_button",
5976 "submit",
5977 "btn btn-primary btn-icon btn-icon-lg",
5978 " title=\"Save Service\" aria-label=\"Save Service\"",
5979 "Save Service",
5980 ),
5981 (
5982 "service_edit_delete_dialog_open_button_ui_html",
5983 "service_edit_delete_dialog_open_button",
5984 "button",
5985 "btn btn-danger btn-icon btn-icon-lg",
5986 " data-dialog-open=\"service-delete-dialog\"",
5987 "Delete Service",
5988 ),
5989 (
5990 "booking_options_add_button_ui_html",
5991 "booking_options_add_button",
5992 "button",
5993 "btn btn-secondary btn-icon btn-icon-sm",
5994 " onclick=\"pharosAddBookingOptionRow(this)\"",
5995 "Add Booking Option",
5996 ),
5997 (
5998 "service_edit_delete_confirm_button_ui_html",
5999 "service_edit_delete_confirm_button",
6000 "submit",
6001 "btn btn-danger",
6002 "",
6003 "Delete Service",
6004 ),
6005 (
6006 "service_edit_delete_cancel_button_ui_html",
6007 "service_edit_delete_cancel_button",
6008 "button",
6009 "btn btn-secondary",
6010 " data-dialog-close=\"true\"",
6011 "Cancel",
6012 ),
6013 ] {
6014 insert(
6015 param,
6016 app_ui_button(artifact_root, id, kind, class_name, attrs, content),
6017 )?;
6018 }
6019 insert(
6020 "booking_options_count_input_ui_html",
6021 app_ui_hidden_input(
6022 artifact_root,
6023 "booking-options-count",
6024 "booking_option_count",
6025 &booking_option_count,
6026 " data-booking-option-count=\"true\"",
6027 ),
6028 )?;
6029 for (param, id, name, input_type, label, attrs) in [
6030 (
6031 "booking_options_template_label_input_ui_html",
6032 "booking-option-template-label",
6033 "booking_option_label_template",
6034 "text",
6035 "Service Name",
6036 " placeholder=\"Service Name\" data-booking-option-field=\"label\"",
6037 ),
6038 (
6039 "booking_options_template_duration_input_ui_html",
6040 "booking-option-template-duration",
6041 "booking_option_duration_template",
6042 "number",
6043 "Time in minutes",
6044 " min=\"1\" placeholder=\"Minutes\" data-booking-option-field=\"duration\"",
6045 ),
6046 (
6047 "booking_options_template_cost_input_ui_html",
6048 "booking-option-template-cost",
6049 "booking_option_cost_template",
6050 "text",
6051 "Cost",
6052 " placeholder=\"Cost\" inputmode=\"decimal\" data-booking-option-field=\"cost\"",
6053 ),
6054 ] {
6055 insert(
6056 param,
6057 app_ui_input(
6058 artifact_root,
6059 id,
6060 input_type,
6061 name,
6062 label,
6063 "sr-only",
6064 "form-control form-control-compact booking-option-input",
6065 "",
6066 attrs,
6067 ),
6068 )?;
6069 }
6070 }
6071 _ => {}
6072 }
6073 Some(())
6074}
6075
6076fn app_route_page_html(
6078 app: &AppRuntime,
6079 route: &Value,
6080 target: &str,
6081 actor_id: i64,
6082 has_user: bool,
6083 managed_business_count: i64,
6084 error_message: Option<&str>,
6085 csrf_token: &str,
6086) -> Option<String> {
6087 app_route_page_html_result(
6088 app,
6089 route,
6090 target,
6091 actor_id,
6092 has_user,
6093 managed_business_count,
6094 error_message,
6095 csrf_token,
6096 )
6097 .ok()
6098}
6099
6100fn app_route_page_html_result(
6102 app: &AppRuntime,
6103 route: &Value,
6104 target: &str,
6105 actor_id: i64,
6106 has_user: bool,
6107 managed_business_count: i64,
6108 error_message: Option<&str>,
6109 csrf_token: &str,
6110) -> Result<String, u16> {
6111 let page_id = route
6112 .get("page_id")
6113 .and_then(Value::as_str)
6114 .unwrap_or_default();
6115 let detail = if matches!(page_id, "booking_detail" | "appointment_detail")
6116 && route.get("feature_id").and_then(Value::as_str).is_none()
6117 {
6118 let prefix = if page_id == "booking_detail" {
6119 "/bookings/"
6120 } else {
6121 "/appointments/"
6122 };
6123 let id = target
6124 .split('?')
6125 .next()
6126 .and_then(|path| path.strip_prefix(prefix))
6127 .and_then(|path| path.split('/').next())
6128 .and_then(|value| value.parse::<i64>().ok())
6129 .filter(|id| *id > 0)
6130 .ok_or(404u16)?;
6131 let store = app_database_operations(app).ok_or(503u16)?;
6132 let result = if page_id == "booking_detail" {
6133 business::personal_booking_view(
6134 store.adapter.as_ref(),
6135 &store.config,
6136 &store.schema,
6137 actor_id,
6138 Some(id),
6139 )
6140 } else {
6141 business::appointment_view(
6142 store.adapter.as_ref(),
6143 &store.config,
6144 &store.schema,
6145 actor_id,
6146 Some(id),
6147 )
6148 };
6149 Some(result.map_err(database_operation_http_status)?)
6150 } else if page_id == "job_queue_detail" {
6151 let execution_id = target
6152 .split('?')
6153 .next()
6154 .and_then(|path| path.strip_prefix("/admin/jobs/"))
6155 .and_then(|path| path.trim_end_matches('/').parse::<i64>().ok())
6156 .filter(|id| *id > 0)
6157 .ok_or(404_u16)?;
6158 let store = app_database_operations(app).ok_or(503_u16)?;
6159 Some(
6160 pharos_runtime_database::queue::job_detail_view(
6161 store.adapter.as_ref(),
6162 &store.config,
6163 &store.schema,
6164 execution_id,
6165 )
6166 .map_err(database_operation_http_status)?,
6167 )
6168 } else {
6169 None
6170 };
6171 app_route_page_html_from_detail(
6172 app,
6173 route,
6174 target,
6175 actor_id,
6176 has_user,
6177 managed_business_count,
6178 error_message,
6179 csrf_token,
6180 detail.as_ref(),
6181 )
6182 .ok_or(500)
6183}
6184
6185fn app_route_page_html_from_detail(
6187 app: &AppRuntime,
6188 route: &Value,
6189 target: &str,
6190 actor_id: i64,
6191 has_user: bool,
6192 managed_business_count: i64,
6193 error_message: Option<&str>,
6194 csrf_token: &str,
6195 detail: Option<&Value>,
6196) -> Option<String> {
6197 let is_job_queue_request = target.split('?').next() == Some("/admin/jobs/");
6198 let artifact_dir = app.artifact_dir.as_deref().or_else(|| {
6199 if is_job_queue_request {
6200 eprintln!("job queue list render failed: artifact directory unavailable");
6201 }
6202 None
6203 })?;
6204 let artifact_root = Path::new(artifact_dir.trim());
6205 if route.get("feature_id").and_then(Value::as_str).is_some() {
6206 let mut last_error = None;
6207 let max_attempts = 10;
6208 for attempt in 0..max_attempts {
6209 let is_architect_settings = app.app_id.as_deref() == Some("architect")
6210 && target
6211 .split('?')
6212 .next()
6213 .unwrap_or_default()
6214 .trim_end_matches('/')
6215 == "/settings";
6216 if is_architect_settings {
6217 eprintln!(
6218 "architect settings full-page render attempt {} start",
6219 attempt + 1
6220 );
6221 }
6222 match feature_runtime::render_feature_page_html(
6223 app, route, target, actor_id, has_user, csrf_token,
6224 ) {
6225 Ok(html) => {
6226 if is_architect_settings {
6227 eprintln!(
6228 "architect settings full-page render attempt {} ok",
6229 attempt + 1
6230 );
6231 }
6232 return Some(html);
6233 }
6234 Err(error) => {
6235 if is_architect_settings {
6236 eprintln!(
6237 "architect settings full-page render attempt {} failed: {error:?}",
6238 attempt + 1
6239 );
6240 }
6241 last_error = Some(error);
6242 if attempt + 1 < max_attempts {
6243 std::thread::sleep(Duration::from_millis(50));
6244 }
6245 }
6246 }
6247 }
6248 if let Some(error) = last_error {
6249 eprintln!("feature page render failed: {error:?}");
6250 }
6251 return None;
6252 }
6253 let page_id = route
6254 .get("page_id")
6255 .and_then(Value::as_str)
6256 .filter(|value| !value.is_empty())
6257 .or_else(|| {
6258 if is_job_queue_request {
6259 eprintln!("job queue list render failed: page id unavailable");
6260 }
6261 None
6262 })?;
6263 let page_relative = json_commands::declared_page_relative_path(artifact_root, page_id)
6264 .or_else(|| match page_id {
6265 "generated_admin_manage" => {
6266 Some("templates/generated_admin/manage.page.json".to_owned())
6267 }
6268 "generated_admin_edit" => Some("templates/generated_admin/edit.page.json".to_owned()),
6269 _ => None,
6270 })
6271 .or_else(|| {
6272 if is_job_queue_request {
6273 eprintln!(
6274 "job queue list render failed: page declaration unavailable for {page_id}"
6275 );
6276 }
6277 None
6278 })?;
6279 let page_spec_path = artifact_root.join(&page_relative);
6280 let page_spec = json_commands::json_doc_load_file(&page_spec_path).or_else(|| {
6281 if is_job_queue_request {
6282 eprintln!("job queue list render failed: page specification unreadable");
6283 }
6284 None
6285 })?;
6286 let template_relative = page_spec
6287 .get("template_file")
6288 .and_then(Value::as_str)
6289 .filter(|value| !value.is_empty())
6290 .or_else(|| {
6291 if is_job_queue_request {
6292 eprintln!("job queue list render failed: body template undeclared");
6293 }
6294 None
6295 })?;
6296 let template_path = artifact_root.join(template_relative);
6297 let mut params = serde_json::Map::new();
6298 if page_id == "generated_admin_manage" {
6299 let business_slug = target
6300 .split('?')
6301 .next()
6302 .and_then(|path| path.strip_prefix("/businesses/"))
6303 .and_then(|path| path.split('/').next())
6304 .filter(|slug| !slug.is_empty())?;
6305 let state_root = business_catalog_for_page(app, business_slug, actor_id, true)?;
6306 params.insert(
6307 "manage_sections_html".to_owned(),
6308 Value::String(json_commands::dynamic_generated_admin_manage_sections_html(
6309 &state_root,
6310 business_slug,
6311 artifact_root,
6312 target,
6313 )?),
6314 );
6315 } else if page_id == "generated_admin_edit" {
6316 let service_name_input_ui_html = app_ui_input(
6317 artifact_root,
6318 "generated-admin-service-name",
6319 "text",
6320 "name",
6321 "Service",
6322 "",
6323 "form-control",
6324 "",
6325 " required",
6326 )?;
6327 let save_button_ui_html = app_ui_button(
6328 artifact_root,
6329 "generated-admin-save",
6330 "submit",
6331 "",
6332 "",
6333 "Save Service",
6334 )?;
6335 let delete_button_ui_html = app_ui_button(
6336 artifact_root,
6337 "generated-admin-delete",
6338 "submit",
6339 "",
6340 "",
6341 "Delete",
6342 )?;
6343 params = serde_json::Map::from_iter([
6344 ("title".to_owned(), Value::String("Edit Record".to_owned())),
6345 (
6346 "manage_path".to_owned(),
6347 Value::String("/businesses/lotus-studio/services/".to_owned()),
6348 ),
6349 ("update_path".to_owned(), Value::String(String::new())),
6350 (
6351 "form_fields_html".to_owned(),
6352 Value::String(app_ui_fragment(
6353 artifact_root,
6354 "templates/generated_admin/fragments/edit_form_fields.html",
6355 serde_json::json!({"service_name_input_ui_html": service_name_input_ui_html}),
6356 )?),
6357 ),
6358 (
6359 "generated_admin_save_button_ui_html".to_owned(),
6360 Value::String(app_ui_fragment(
6361 artifact_root,
6362 "templates/generated_admin/fragments/edit_save_button.html",
6363 serde_json::json!({"save_button_ui_html": save_button_ui_html}),
6364 )?),
6365 ),
6366 (
6367 "delete_form_html".to_owned(),
6368 Value::String(app_ui_fragment(
6369 artifact_root,
6370 "templates/generated_admin/fragments/edit_delete_form.html",
6371 serde_json::json!({"delete_button_ui_html": delete_button_ui_html}),
6372 )?),
6373 ),
6374 ]);
6375 } else if page_id == "business_calendar" {
6376 let business_slug = target
6377 .split('?')
6378 .next()
6379 .and_then(|path| path.strip_prefix("/business/"))
6380 .and_then(|path| path.strip_suffix("/calendar/"))
6381 .filter(|slug| !slug.is_empty())?;
6382 let year = target
6383 .split_once('?')
6384 .and_then(|(_, query)| json_commands::request_query_value(query, "year"));
6385 let month = target
6386 .split_once('?')
6387 .and_then(|(_, query)| json_commands::request_query_value(query, "month"));
6388 let date = target
6389 .split_once('?')
6390 .and_then(|(_, query)| json_commands::request_query_value(query, "date"));
6391 let store = app_database_operations(app)?;
6392 let state_root = pharos_runtime_business::business_calendar_view(
6393 store.adapter.as_ref(),
6394 &store.config,
6395 &store.schema,
6396 business_slug,
6397 actor_id,
6398 pharos_runtime_business::CalendarSelection {
6399 year: year.as_deref(),
6400 month: month.as_deref(),
6401 date: date.as_deref(),
6402 },
6403 )
6404 .ok()?;
6405 let params_json = json_commands::dynamic_business_calendar_template_params_from_root_json(
6406 &state_root,
6407 business_slug,
6408 artifact_root,
6409 state_root.get("_calendar_year")?.as_str(),
6410 state_root.get("_calendar_month")?.as_str(),
6411 state_root.get("_calendar_date")?.as_str(),
6412 actor_id,
6413 csrf_token,
6414 )?;
6415 params = serde_json::from_str(¶ms_json).ok()?;
6416 } else if page_id == "diagnostics" {
6417 use pharos_runtime_database::operations::{
6418 Aggregate, AggregateQuery, Predicate, Query, SortKey,
6419 };
6420 let store = app_database_operations(app)?;
6421 let count = |entity: &str, filter: Option<Predicate>| -> Option<u64> {
6422 store
6423 .adapter
6424 .aggregate(
6425 &store.config,
6426 &store.schema,
6427 &AggregateQuery {
6428 entity: entity.to_owned(),
6429 filter,
6430 aggregate: Aggregate::Count,
6431 },
6432 )
6433 .ok()?
6434 .as_u64()
6435 };
6436 let appointment_count = count("appointment", None)?;
6437 let booking_count = count("booking", None)?;
6438 let subscription_count = count("subscription", None)?;
6439 let rating_count = count("rating", None)?;
6440 let connection_count = count("google_calendar_connection", None)?;
6441 let unassigned_count = count(
6442 "appointment",
6443 Some(Predicate::Any(vec![
6444 Predicate::IsNull("technician_id".to_owned()),
6445 Predicate::AtMost("technician_id".to_owned(), Value::from(0)),
6446 ])),
6447 )?;
6448 let cancel_requested_count = count(
6449 "appointment",
6450 Some(Predicate::Equal(
6451 "status".to_owned(),
6452 Value::String("cancel_requested".to_owned()),
6453 )),
6454 )?;
6455 let mut status_counts = BTreeMap::new();
6456 for status in [
6457 "pending",
6458 "payment_pending",
6459 "confirmed",
6460 "cancel_requested",
6461 "reschedule_requested",
6462 "completed",
6463 "cancelled",
6464 "deleted",
6465 ] {
6466 let total = count(
6467 "appointment",
6468 Some(Predicate::Equal(
6469 "status".to_owned(),
6470 Value::String(status.to_owned()),
6471 )),
6472 )?;
6473 if total > 0 {
6474 status_counts.insert(status.to_owned(), total);
6475 }
6476 }
6477 let audit = store
6478 .adapter
6479 .query(
6480 &store.config,
6481 &store.schema,
6482 &Query {
6483 entity: "audit_event".to_owned(),
6484 fields: vec!["event_kind".to_owned()],
6485 filter: None,
6486 order: vec![SortKey {
6487 field: "id".to_owned(),
6488 descending: true,
6489 }],
6490 limit: 5,
6491 offset: 0,
6492 },
6493 )
6494 .ok()?;
6495 let state_root = serde_json::json!({"audit_log": audit.into_iter().map(|row| serde_json::json!({"event": row.get("event_kind")})).collect::<Vec<_>>()});
6496 let appointment_statuses = if status_counts.is_empty() {
6497 app_static_fragment(
6498 artifact_root,
6499 "templates/appointments/fragments/diagnostic_status_empty.html",
6500 )?
6501 } else {
6502 status_counts
6503 .into_iter()
6504 .map(|(status, count)| {
6505 json_commands::render_app_fragment_from_doc(
6506 artifact_root.to_string_lossy().as_ref(),
6507 "templates/appointments/fragments/diagnostic_status_row.html",
6508 &serde_json::json!({"status": status, "count": count}),
6509 )
6510 })
6511 .collect::<Option<Vec<_>>>()?
6512 .join("")
6513 };
6514 let recent_audit = state_root
6515 .get("audit_log")
6516 .and_then(Value::as_array)
6517 .and_then(|rows| {
6518 let mut items = rows
6519 .iter()
6520 .take(5)
6521 .filter_map(|row| {
6522 let event = row
6523 .get("event")
6524 .and_then(Value::as_str)
6525 .filter(|value| !value.is_empty())
6526 .unwrap_or("audit_event");
6527 json_commands::render_app_fragment_from_doc(
6528 artifact_root.to_string_lossy().as_ref(),
6529 "templates/appointments/fragments/diagnostic_audit_row.html",
6530 &serde_json::json!({"event": event}),
6531 )
6532 })
6533 .collect::<Vec<_>>();
6534 if items.is_empty() {
6535 items.push(app_static_fragment(
6536 artifact_root,
6537 "templates/appointments/fragments/diagnostic_audit_empty.html",
6538 )?);
6539 }
6540 Some(items.join(""))
6541 })
6542 .or_else(|| {
6543 app_static_fragment(
6544 artifact_root,
6545 "templates/appointments/fragments/diagnostic_audit_empty.html",
6546 )
6547 })?;
6548 let maintenance_options_html = [
6549 (
6550 "dedupe_appointments",
6551 "Dedupe appointments",
6552 "pharos maintenance app ucal dedupe_appointments",
6553 ),
6554 (
6555 "reassign_appointments_to_technicians",
6556 "Reassign appointments to technicians",
6557 "pharos maintenance app ucal reassign_appointments_to_technicians",
6558 ),
6559 (
6560 "reconcile_future_appointments",
6561 "Reconcile future appointments",
6562 "pharos maintenance app ucal reconcile_future_appointments",
6563 ),
6564 ]
6565 .into_iter()
6566 .map(|(name, title, command)| {
6567 let content_html = app_ui_fragment(
6568 artifact_root,
6569 "templates/appointments/fragments/maintenance_option_content.html",
6570 serde_json::json!({"title": title, "command": command}),
6571 )?;
6572 let checkbox_html = app_ui_fragment(
6573 artifact_root,
6574 "templates/shared/fragments/ui_checkbox.html",
6575 serde_json::json!({
6576 "id": format!("maintenance-{name}"),
6577 "name": name,
6578 "class_name": "",
6579 "value": "1",
6580 "label_class": "appointments-diagnostics-maintenance-option",
6581 "attributes_html": "",
6582 "label_html": content_html,
6583 "help_html": "",
6584 }),
6585 )?;
6586 app_ui_fragment(
6587 artifact_root,
6588 "templates/appointments/fragments/maintenance_option.html",
6589 serde_json::json!({"maintenance_checkbox_ui_html": checkbox_html}),
6590 )
6591 })
6592 .collect::<Option<Vec<_>>>()?
6593 .join("");
6594 params = serde_json::Map::from_iter([
6595 (
6596 "appointment_count".to_owned(),
6597 Value::String(appointment_count.to_string()),
6598 ),
6599 (
6600 "booking_count".to_owned(),
6601 Value::String(booking_count.to_string()),
6602 ),
6603 (
6604 "subscription_count".to_owned(),
6605 Value::String(subscription_count.to_string()),
6606 ),
6607 (
6608 "rating_count".to_owned(),
6609 Value::String(rating_count.to_string()),
6610 ),
6611 (
6612 "unassigned_count".to_owned(),
6613 Value::String(unassigned_count.to_string()),
6614 ),
6615 (
6616 "cancel_requested_count".to_owned(),
6617 Value::String(cancel_requested_count.to_string()),
6618 ),
6619 (
6620 "connection_count".to_owned(),
6621 Value::String(connection_count.to_string()),
6622 ),
6623 (
6624 "appointment_statuses".to_owned(),
6625 Value::String(appointment_statuses),
6626 ),
6627 ("recent_audit".to_owned(), Value::String(recent_audit)),
6628 (
6629 "maintenance_options_html".to_owned(),
6630 Value::String(maintenance_options_html),
6631 ),
6632 (
6633 "run_maintenance_button_ui_html".to_owned(),
6634 Value::String(app_ui_fragment(
6635 artifact_root,
6636 "templates/appointments/fragments/maintenance_run_button.html",
6637 serde_json::json!({
6638 "run_maintenance_button_ui_html": app_ui_button(
6639 artifact_root,
6640 "run-maintenance",
6641 "submit",
6642 "btn btn-primary btn-icon btn-icon-lg",
6643 " title=\"Run Maintenance\" aria-label=\"Run Maintenance\"",
6644 &app_static_fragment(artifact_root, "templates/appointments/fragments/maintenance_button_content.html")?,
6645 )?,
6646 }),
6647 )?),
6648 ),
6649 (
6650 "maintenance_result_html".to_owned(),
6651 Value::String(String::new()),
6652 ),
6653 ]);
6654 } else if page_id == "job_queue_detail" {
6655 let execution_id = target
6656 .split('?')
6657 .next()?
6658 .trim_end_matches('/')
6659 .rsplit('/')
6660 .next()?
6661 .parse::<i64>()
6662 .ok()?;
6663 let view = detail?;
6664 params = serde_json::from_str(&json_commands::dynamic_job_queue_detail_page_params_json(
6665 view,
6666 execution_id,
6667 artifact_root,
6668 )?)
6669 .ok()?;
6670 } else if page_id == "job_queue_list" {
6671 let query_string = target
6672 .split_once('?')
6673 .map(|(_, query)| query)
6674 .unwrap_or_default();
6675 let status = json_commands::request_query_value(query_string, "status").unwrap_or_default();
6676 let search = json_commands::request_query_value(query_string, "q").unwrap_or_default();
6677 let page = json_commands::request_query_value(query_string, "page")
6678 .and_then(|value| value.parse::<usize>().ok())
6679 .unwrap_or(1);
6680 let store = app_database_operations(app)?;
6681 let state_root = pharos_runtime_database::queue::job_queue_page(
6682 store.adapter.as_ref(),
6683 &store.config,
6684 &store.schema,
6685 &status,
6686 &search,
6687 page,
6688 )
6689 .ok()?;
6690 let params_json = json_commands::dynamic_job_queue_list_page_params_json(
6691 &state_root,
6692 artifact_root,
6693 query_string,
6694 )
6695 .or_else(|| {
6696 eprintln!("job queue list render failed: page parameters unavailable");
6697 None
6698 })?;
6699 params = serde_json::from_str::<Value>(¶ms_json)
6700 .ok()
6701 .and_then(|value| value.as_object().cloned())
6702 .or_else(|| {
6703 eprintln!("job queue list render failed: page parameters invalid");
6704 None
6705 })?;
6706 } else if page_id == "explore" {
6707 let store = app_database_operations(app)?;
6708 let state_root = Some(
6709 pharos_runtime_business::public_businesses(
6710 store.adapter.as_ref(),
6711 &store.config,
6712 &store.schema,
6713 )
6714 .ok()?,
6715 );
6716 let mut cards = String::new();
6717 if let Some(root) = state_root.as_ref()
6718 && let Some(businesses) = root.get("businesses").and_then(Value::as_array)
6719 {
6720 for business in businesses
6721 .iter()
6722 .filter(|row| row.get("is_public").and_then(Value::as_bool) == Some(true))
6723 {
6724 let Some(slug) = business.get("slug").and_then(Value::as_str) else {
6725 continue;
6726 };
6727 let description = business
6728 .get("description")
6729 .and_then(Value::as_str)
6730 .unwrap_or_default();
6731 let description = if description == "Business description" {
6732 artifact_root
6733 .join("data/fixtures/synthetic-public-state.json")
6734 .as_path()
6735 .to_str()
6736 .and_then(|path| stdfs::read_to_string(path).ok())
6737 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
6738 .and_then(|fixture| {
6739 fixture.get("businesses").and_then(Value::as_array).cloned()
6740 })
6741 .and_then(|businesses| {
6742 businesses
6743 .into_iter()
6744 .find(|row| row.get("slug").and_then(Value::as_str) == Some(slug))
6745 })
6746 .and_then(|row| {
6747 row.get("description")
6748 .and_then(Value::as_str)
6749 .map(str::to_owned)
6750 })
6751 .unwrap_or_else(|| description.to_owned())
6752 } else {
6753 description.to_owned()
6754 };
6755 let card_params = serde_json::Map::from_iter([
6756 ("business_slug".to_owned(), Value::String(slug.to_owned())),
6757 (
6758 "business_name".to_owned(),
6759 Value::String(
6760 business
6761 .get("name")
6762 .and_then(Value::as_str)
6763 .unwrap_or_default()
6764 .to_owned(),
6765 ),
6766 ),
6767 (
6768 "business_description".to_owned(),
6769 Value::String(description.clone()),
6770 ),
6771 (
6772 "business_image_url".to_owned(),
6773 Value::String(
6774 business
6775 .get("image_url")
6776 .and_then(Value::as_str)
6777 .unwrap_or_default()
6778 .to_owned(),
6779 ),
6780 ),
6781 ]);
6782 let rendered_card = json_commands::render_app_fragment_from_doc(
6783 artifact_root.to_string_lossy().as_ref(),
6784 "templates/explore/fragments/business_card.html",
6785 &Value::Object(card_params),
6786 );
6787 let card = rendered_card.filter(|card| {
6788 description.is_empty()
6789 || card.contains(&json_commands::html_escape_text(&description))
6790 });
6791 if let Some(card) = card {
6792 cards.push_str(&card);
6793 }
6794 }
6795 }
6796 if cards.is_empty() {
6797 cards = json_commands::render_app_fragment_from_doc(
6798 artifact_root.to_string_lossy().as_ref(),
6799 "templates/explore/fragments/businesses_empty.html",
6800 &Value::Object(serde_json::Map::from_iter([(
6801 "message".to_owned(),
6802 Value::String("No businesses available yet.".to_owned()),
6803 )])),
6804 )?;
6805 }
6806 params.insert("business_cards_html".to_owned(), Value::String(cards));
6807 } else if page_id == "business_landing" {
6808 let business_slug = target
6809 .split('?')
6810 .next()
6811 .and_then(|path| path.strip_prefix("/b/"))
6812 .map(|path| path.trim_matches('/'))
6813 .filter(|slug| !slug.is_empty())?;
6814 let state_root = business_catalog_for_page(app, business_slug, actor_id, false)?;
6815 let landing_params = json_commands::dynamic_business_landing_page_params_json(
6816 &state_root,
6817 business_slug,
6818 artifact_root,
6819 )?;
6820 params = serde_json::from_str(&landing_params).ok()?;
6821 if has_user && actor_id > 0 {
6822 params.insert("cta_html".to_owned(), Value::String(String::new()));
6823 }
6824 } else if page_id == "business_rate" {
6825 let business_slug = target
6826 .split('?')
6827 .next()
6828 .and_then(|path| path.strip_prefix("/rate/"))
6829 .map(|path| path.trim_matches('/'))
6830 .filter(|slug| !slug.is_empty())?;
6831 let state_root = business_catalog_for_page(app, business_slug, actor_id, false)?;
6832 let business = state_root
6833 .get("businesses")
6834 .and_then(Value::as_array)
6835 .and_then(|rows| {
6836 rows.iter()
6837 .find(|row| row.get("slug").and_then(Value::as_str) == Some(business_slug))
6838 })?;
6839 let business_name = business
6840 .get("name")
6841 .and_then(Value::as_str)
6842 .filter(|value| !value.is_empty())?;
6843 let rating_options_ui_html = [
6844 ("50", "5.0", "5 stars", "business-rating-star-full", true),
6845 ("45", "4.5", "4.5 stars", "business-rating-star-half", false),
6846 ("40", "4.0", "4 stars", "business-rating-star-full", false),
6847 ("35", "3.5", "3.5 stars", "business-rating-star-half", false),
6848 ("30", "3.0", "3 stars", "business-rating-star-full", false),
6849 ("25", "2.5", "2.5 stars", "business-rating-star-half", false),
6850 ("20", "2.0", "2 stars", "business-rating-star-full", false),
6851 ("15", "1.5", "1.5 stars", "business-rating-star-half", false),
6852 ("10", "1.0", "1 star", "business-rating-star-full", false),
6853 ("05", "0.5", "0.5 stars", "business-rating-star-half", false),
6854 ]
6855 .into_iter()
6856 .map(|(suffix, value, label, star_class, checked)| {
6857 app_ui_fragment(
6858 artifact_root,
6859 "templates/shared/fragments/ui_radio_option.html",
6860 serde_json::json!({
6861 "id": format!("rating-{suffix}"),
6862 "name": "score",
6863 "class_name": "",
6864 "value": value,
6865 "attributes_html": if checked { " checked" } else { "" },
6866 "label_class": format!("business-rating-star {star_class}"),
6867 "label_attributes_html": format!(" title=\"{label}\""),
6868 "label_html": app_ui_fragment(
6869 artifact_root,
6870 "templates/businesses/fragments/rating_option_content.html",
6871 serde_json::json!({"label": label}),
6872 )?,
6873 }),
6874 )
6875 })
6876 .collect::<Option<Vec<_>>>()?
6877 .join("");
6878 let rating_score_ui_html = app_ui_fragment(
6879 artifact_root,
6880 "templates/businesses/fragments/rating_form_controls.html",
6881 serde_json::json!({"rating_options_ui_html": rating_options_ui_html}),
6882 )?;
6883 let rating_comment_textarea_ui_html = app_ui_fragment(
6884 artifact_root,
6885 "templates/businesses/fragments/rating_comment.html",
6886 serde_json::json!({
6887 "rating_comment_textarea_ui_html": app_ui_fragment(
6888 artifact_root,
6889 "templates/shared/fragments/ui_textarea.html",
6890 serde_json::json!({
6891 "id": "rating-comment",
6892 "name": "comment",
6893 "label": "Comment",
6894 "label_class": "",
6895 "class_name": "form-control",
6896 "attributes_html": " rows=\"4\" placeholder=\"Share what stood out.\"",
6897 "value": "",
6898 "help_html": "",
6899 }),
6900 )?,
6901 }),
6902 )?;
6903 let rating_submit_button_ui_html = app_ui_fragment(
6904 artifact_root,
6905 "templates/businesses/fragments/rating_submit.html",
6906 serde_json::json!({
6907 "rating_submit_button_ui_html": app_ui_button(
6908 artifact_root,
6909 "rating-submit",
6910 "submit",
6911 "btn btn-primary",
6912 "",
6913 "Submit Rating",
6914 )?,
6915 }),
6916 )?;
6917 params = serde_json::Map::from_iter([
6918 (
6919 "business_slug".to_owned(),
6920 Value::String(business_slug.to_owned()),
6921 ),
6922 (
6923 "business_name".to_owned(),
6924 Value::String(business_name.to_owned()),
6925 ),
6926 (
6927 "rating_score_ui_html".to_owned(),
6928 Value::String(rating_score_ui_html.to_owned()),
6929 ),
6930 (
6931 "rating_comment_textarea_ui_html".to_owned(),
6932 Value::String(rating_comment_textarea_ui_html.to_owned()),
6933 ),
6934 (
6935 "rating_submit_button_ui_html".to_owned(),
6936 Value::String(rating_submit_button_ui_html.to_owned()),
6937 ),
6938 ("error_html".to_owned(), Value::String(String::new())),
6939 ]);
6940 } else if page_id == "service_book_form" {
6941 let path = target.split('?').next().unwrap_or_default();
6942 let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
6943 let business_slug = segments.get(1).copied().unwrap_or_default();
6944 let service_id = path
6945 .trim_end_matches('/')
6946 .rsplit('/')
6947 .next()
6948 .and_then(|value| value.parse::<i64>().ok())?;
6949 let state_root = business_catalog_for_page(app, business_slug, actor_id, false)?;
6950 let business = state_root
6951 .get("businesses")
6952 .and_then(Value::as_array)
6953 .and_then(|businesses| {
6954 businesses.iter().find(|business| {
6955 business.get("slug").and_then(Value::as_str) == Some(business_slug)
6956 })
6957 })?;
6958 let business_id = business.get("id").and_then(Value::as_i64)?;
6959 let service = state_root
6960 .get("services")
6961 .and_then(Value::as_array)
6962 .and_then(|services| {
6963 services.iter().find(|service| {
6964 service.get("id").and_then(Value::as_i64) == Some(service_id)
6965 && service.get("business_id").and_then(Value::as_i64) == Some(business_id)
6966 })
6967 })?;
6968 let service_name = service
6969 .get("name")
6970 .and_then(Value::as_str)
6971 .unwrap_or("Book service");
6972 let service_description = service
6973 .get("description")
6974 .and_then(Value::as_str)
6975 .unwrap_or_default()
6976 .to_owned();
6977 let service_image_url = service
6978 .get("image_url")
6979 .or_else(|| service.get("image"))
6980 .and_then(Value::as_str)
6981 .unwrap_or_default()
6982 .to_owned();
6983 let service_primary_color = service
6984 .get("primary_color")
6985 .and_then(Value::as_str)
6986 .filter(|value| !value.is_empty())
6987 .unwrap_or("#475569")
6988 .to_owned();
6989 let default_date =
6990 business::dynamic_next_day_ymd().unwrap_or_else(|| "2026-08-13".to_owned());
6991 let date_input_html = json_commands::render_app_fragment_from_doc(
6992 artifact_root.to_string_lossy().as_ref(),
6993 "templates/shared/fragments/ui_input.html",
6994 &Value::Object(serde_json::Map::from_iter([
6995 (
6996 "id".to_owned(),
6997 Value::String("service_booking_date_input".to_owned()),
6998 ),
6999 ("input_type".to_owned(), Value::String("date".to_owned())),
7000 ("name".to_owned(), Value::String("date".to_owned())),
7001 ("label".to_owned(), Value::String("Date".to_owned())),
7002 (
7003 "label_class".to_owned(),
7004 Value::String("sr-only".to_owned()),
7005 ),
7006 (
7007 "class_name".to_owned(),
7008 Value::String("form-control".to_owned()),
7009 ),
7010 ("value".to_owned(), Value::String(default_date.clone())),
7011 (
7012 "attributes_html".to_owned(),
7013 Value::String(" required".to_owned()),
7014 ),
7015 ("help_html".to_owned(), Value::String(String::new())),
7016 ])),
7017 )?;
7018 let hour_options = (1..=12)
7019 .map(|hour| {
7020 app_select_option(
7021 artifact_root,
7022 &hour.to_string(),
7023 &hour.to_string(),
7024 hour == 12,
7025 )
7026 })
7027 .collect::<Option<Vec<_>>>()?
7028 .join("");
7029 let minute_options = [
7030 "00", "05", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55",
7031 ]
7032 .into_iter()
7033 .map(|minute| app_select_option(artifact_root, minute, minute, minute == "00"))
7034 .collect::<Option<Vec<_>>>()?
7035 .join("");
7036 let period_options = [("AM", false), ("PM", true)]
7037 .into_iter()
7038 .map(|(value, selected)| app_select_option(artifact_root, value, value, selected))
7039 .collect::<Option<Vec<_>>>()?
7040 .join("");
7041 let service_booking_submit_button_ui_html = json_commands::render_app_fragment_from_doc(
7042 artifact_root.to_string_lossy().as_ref(),
7043 "templates/shared/fragments/ui_button.html",
7044 &Value::Object(serde_json::Map::from_iter([
7045 ("id".to_owned(), Value::String("service_booking_submit_button".to_owned())),
7046 ("button_type".to_owned(), Value::String("submit".to_owned())),
7047 ("class_name".to_owned(), Value::String("btn btn-primary btn-icon".to_owned())),
7048 ("attributes_html".to_owned(), Value::String(" title=\"Create Appointment\" aria-label=\"Create Appointment\" style=\"width:2.75rem;min-width:2.75rem;height:2.75rem;border-radius:10px;\"".to_owned())),
7049 (
7050 "content_html".to_owned(),
7051 Value::String(
7052 json_commands::render_app_fragment_from_doc(
7053 artifact_root.to_string_lossy().as_ref(),
7054 "templates/shared/fragments/ui_content_create_appointment.html",
7055 &Value::Object(serde_json::Map::new()),
7056 )?,
7057 ),
7058 ),
7059 ])),
7060 )?;
7061 let service_booking_notes_textarea_ui_html = json_commands::render_app_fragment_from_doc(
7062 artifact_root.to_string_lossy().as_ref(),
7063 "templates/shared/fragments/ui_textarea.html",
7064 &Value::Object(serde_json::Map::from_iter([
7065 (
7066 "id".to_owned(),
7067 Value::String("service_booking_notes_textarea".to_owned()),
7068 ),
7069 ("name".to_owned(), Value::String("notes".to_owned())),
7070 ("label".to_owned(), Value::String("Notes".to_owned())),
7071 (
7072 "label_class".to_owned(),
7073 Value::String("sr-only".to_owned()),
7074 ),
7075 (
7076 "class_name".to_owned(),
7077 Value::String("form-control".to_owned()),
7078 ),
7079 ("value".to_owned(), Value::String(String::new())),
7080 (
7081 "attributes_html".to_owned(),
7082 Value::String(
7083 " rows=\"3\" placeholder=\"Notes\" aria-label=\"Notes\"".to_owned(),
7084 ),
7085 ),
7086 ("help_html".to_owned(), Value::String(String::new())),
7087 ])),
7088 )?;
7089 params = serde_json::Map::from_iter([
7090 (
7091 "service_name".to_owned(),
7092 Value::String(service_name.to_owned()),
7093 ),
7094 (
7095 "service_duration".to_owned(),
7096 Value::String(
7097 service
7098 .get("duration_minutes")
7099 .and_then(Value::as_i64)
7100 .unwrap_or(45)
7101 .to_string(),
7102 ),
7103 ),
7104 (
7105 "service_image_style".to_owned(),
7106 Value::String(if service_image_url.is_empty() {
7107 "display:none;".to_owned()
7108 } else {
7109 String::new()
7110 }),
7111 ),
7112 (
7113 "service_image_url".to_owned(),
7114 Value::String(service_image_url),
7115 ),
7116 (
7117 "service_image_alt".to_owned(),
7118 Value::String(service_name.to_owned()),
7119 ),
7120 (
7121 "service_badge_style".to_owned(),
7122 Value::String(
7123 if service
7124 .get("image_url")
7125 .and_then(Value::as_str)
7126 .filter(|value| !value.is_empty())
7127 .is_some()
7128 {
7129 "display:none;".to_owned()
7130 } else {
7131 String::new()
7132 },
7133 ),
7134 ),
7135 (
7136 "service_badge_color".to_owned(),
7137 Value::String(service_primary_color),
7138 ),
7139 (
7140 "service_badge_initials".to_owned(),
7141 Value::String(json_commands::dynamic_service_abbreviation(service_name)),
7142 ),
7143 (
7144 "service_description".to_owned(),
7145 Value::String(service_description),
7146 ),
7147 (
7148 "error_style".to_owned(),
7149 Value::String("display:none;".to_owned()),
7150 ),
7151 ("error_message".to_owned(), Value::String(String::new())),
7152 (
7153 "booking_option_select_style".to_owned(),
7154 Value::String("display:none;".to_owned()),
7155 ),
7156 (
7157 "time_period_style".to_owned(),
7158 Value::String("display:block;".to_owned()),
7159 ),
7160 (
7161 "booking-option-index_ui_html".to_owned(),
7162 Value::String(json_commands::render_app_fragment_from_doc(
7163 artifact_root.to_string_lossy().as_ref(),
7164 "templates/shared/fragments/ui_select.html",
7165 &Value::Object(serde_json::Map::from_iter([
7166 (
7167 "id".to_owned(),
7168 Value::String("booking-option-index".to_owned()),
7169 ),
7170 (
7171 "name".to_owned(),
7172 Value::String("booking_option_index".to_owned()),
7173 ),
7174 (
7175 "label".to_owned(),
7176 Value::String("Booking option".to_owned()),
7177 ),
7178 (
7179 "label_class".to_owned(),
7180 Value::String("sr-only".to_owned()),
7181 ),
7182 (
7183 "class_name".to_owned(),
7184 Value::String("form-control".to_owned()),
7185 ),
7186 (
7187 "attributes_html".to_owned(),
7188 Value::String(" required".to_owned()),
7189 ),
7190 ("options_html".to_owned(), Value::String(String::new())),
7191 ("help_html".to_owned(), Value::String(String::new())),
7192 ])),
7193 )?),
7194 ),
7195 (
7196 "service_booking_date_input_ui_html".to_owned(),
7197 Value::String(date_input_html),
7198 ),
7199 (
7200 "service_booking_hour_select_ui_html".to_owned(),
7201 Value::String(json_commands::render_app_fragment_from_doc(
7202 artifact_root.to_string_lossy().as_ref(),
7203 "templates/shared/fragments/ui_select.html",
7204 &serde_json::json!({"id":"service_booking_hour_select","name":"time_hour","label":"Hour","label_class":"sr-only","class_name":"form-control","attributes_html":" style=\"width:4.5rem;min-width:4.5rem;\" required","options_html":hour_options,"help_html":""}),
7205 )?),
7206 ),
7207 (
7208 "service_booking_minute_select_ui_html".to_owned(),
7209 Value::String(json_commands::render_app_fragment_from_doc(
7210 artifact_root.to_string_lossy().as_ref(),
7211 "templates/shared/fragments/ui_select.html",
7212 &serde_json::json!({"id":"service_booking_minute_select","name":"time_minute","label":"Minute","label_class":"sr-only","class_name":"form-control","attributes_html":" style=\"width:4.5rem;min-width:4.5rem;\" required","options_html":minute_options,"help_html":""}),
7213 )?),
7214 ),
7215 (
7216 "time_period_select_ui_html".to_owned(),
7217 Value::String(json_commands::render_app_fragment_from_doc(
7218 artifact_root.to_string_lossy().as_ref(),
7219 "templates/shared/fragments/ui_select.html",
7220 &serde_json::json!({"id":"time_period_select","name":"time_period","label":"Time period","label_class":"sr-only","class_name":"form-control","attributes_html":" style=\"width:4.5rem;min-width:4.5rem;\" required","options_html":period_options,"help_html":""}),
7221 )?),
7222 ),
7223 (
7224 "service_booking_submit_button_ui_html".to_owned(),
7225 Value::String(service_booking_submit_button_ui_html),
7226 ),
7227 (
7228 "service_booking_notes_textarea_ui_html".to_owned(),
7229 Value::String(service_booking_notes_textarea_ui_html),
7230 ),
7231 (
7232 "pharos_idempotency_key".to_owned(),
7233 Value::String(
7234 dynamic::random_secret_hex(16)
7235 .unwrap_or_else(|_| "pharos-booking-idempotency".to_owned()),
7236 ),
7237 ),
7238 ]);
7239 } else if page_id == "business_detail_admin" {
7240 let business_slug = target
7241 .split('?')
7242 .next()
7243 .and_then(|path| path.strip_prefix("/businesses/"))
7244 .and_then(|path| path.trim_matches('/').split('/').next())
7245 .filter(|slug| !slug.is_empty())?;
7246 let state_root = business_catalog_for_page(app, business_slug, actor_id, true)?;
7247 let params_json = json_commands::dynamic_business_detail_admin_page_params_from_root_json(
7248 &state_root,
7249 business_slug,
7250 artifact_root,
7251 )?;
7252 params = serde_json::from_str(¶ms_json).ok()?;
7253 } else if page_id == "payment_provider_setup" {
7254 let business_slug = target
7255 .split('?')
7256 .next()
7257 .and_then(|request_path| request_path.strip_prefix("/businesses/"))
7258 .and_then(|request_path| request_path.trim_matches('/').split('/').next())
7259 .filter(|slug| !slug.is_empty())?;
7260 let state_root = business_payment_for_page(app, artifact_root, business_slug, actor_id)?;
7261 let params_json = json_commands::dynamic_payment_provider_setup_page_params_from_root_json(
7262 &state_root,
7263 business_slug,
7264 artifact_root,
7265 )?;
7266 params = serde_json::from_str(¶ms_json).ok()?;
7267 } else if page_id == "business_delete_confirm" {
7268 let slug = target
7269 .split('?')
7270 .next()?
7271 .strip_prefix("/businesses/")?
7272 .split('/')
7273 .next()
7274 .filter(|slug| !slug.is_empty())?;
7275 let catalog = business_catalog_for_page(app, slug, actor_id, true)?;
7276 let business = catalog.get("businesses")?.as_array()?.first()?;
7277 params.insert("business_name".to_owned(), business.get("name")?.clone());
7278 params.insert("error_html".to_owned(), Value::String(String::new()));
7279 params.insert(
7280 "delete_path".to_owned(),
7281 Value::String(format!("/businesses/{slug}/delete/")),
7282 );
7283 params.insert(
7284 "cancel_path".to_owned(),
7285 Value::String(format!("/businesses/{slug}/")),
7286 );
7287 let ledger = dynamic::dynamic_contract_doc(app, "ui_ledger")?;
7288 let element = ui::ui_find_element(&ledger, "business_delete_confirm_button")?;
7289 let button_params = Value::Object(params.clone());
7290 let content = app_ui_fragment(
7291 artifact_root,
7292 element.get("content_fragment")?.as_str()?,
7293 button_params.clone(),
7294 )?;
7295 let mut attributes = ui::ui_declaration_attributes(element, &button_params)?;
7296 if !ui::ui_append_ledger_attribute(
7297 &mut attributes,
7298 element,
7299 "title",
7300 "title",
7301 &button_params,
7302 false,
7303 ) {
7304 return None;
7305 }
7306 let button = app_ui_button(
7307 artifact_root,
7308 element.get("id")?.as_str()?,
7309 element.get("button_type")?.as_str()?,
7310 element.get("class_name")?.as_str()?,
7311 &attributes,
7312 &content,
7313 )?;
7314 params.insert(
7315 "business_delete_confirm_button_ui_html".to_owned(),
7316 Value::String(button),
7317 );
7318 } else if page_id == "business_create" {
7319 params = serde_json::Map::new();
7320 } else if page_id == "business_edit" {
7321 let edit_path = target.split('?').next().unwrap_or_default();
7322 let business_slug = edit_path
7323 .strip_prefix("/businesses/")
7324 .and_then(|value| value.split('/').next())
7325 .filter(|value| !value.is_empty())?;
7326 let state_root = business_payment_for_page(app, artifact_root, business_slug, actor_id)?;
7327 let business = state_root
7328 .get("businesses")
7329 .and_then(Value::as_array)
7330 .and_then(|rows| {
7331 rows.iter()
7332 .find(|row| row.get("slug").and_then(Value::as_str) == Some(business_slug))
7333 })?;
7334 let business_name = business
7335 .get("name")
7336 .and_then(Value::as_str)
7337 .unwrap_or("Business");
7338 let description = business
7339 .get("description")
7340 .and_then(Value::as_str)
7341 .unwrap_or_default();
7342 let address = business
7343 .get("address")
7344 .and_then(Value::as_str)
7345 .unwrap_or_default();
7346 let logo_url = business
7347 .get("image_url")
7348 .and_then(Value::as_str)
7349 .unwrap_or_default()
7350 .to_owned();
7351 let time_format = business
7352 .get("time_format")
7353 .and_then(Value::as_str)
7354 .unwrap_or("am_pm");
7355 let secondary_color = business
7356 .get("secondary_color")
7357 .and_then(Value::as_str)
7358 .filter(|v| !v.is_empty())
7359 .unwrap_or("#e5e7eb");
7360 let external_integrations_html =
7361 json_commands::dynamic_business_edit_external_integrations_html(
7362 &state_root,
7363 business_slug,
7364 artifact_root,
7365 )
7366 .unwrap_or_default();
7367 params = serde_json::Map::from_iter([
7368 (
7369 "current_logo_image_url".to_owned(),
7370 Value::String(logo_url.clone()),
7371 ),
7372 (
7373 "current_logo_image_alt".to_owned(),
7374 Value::String(format!("{} logo", business_name)),
7375 ),
7376 (
7377 "current_logo_image_style".to_owned(),
7378 Value::String(if logo_url.is_empty() {
7379 "display:none".to_owned()
7380 } else {
7381 String::new()
7382 }),
7383 ),
7384 (
7385 "current_logo_empty_style".to_owned(),
7386 Value::String(if logo_url.is_empty() {
7387 String::new()
7388 } else {
7389 "display:none".to_owned()
7390 }),
7391 ),
7392 (
7393 "business_name".to_owned(),
7394 Value::String(business_name.to_owned()),
7395 ),
7396 (
7397 "business_description".to_owned(),
7398 Value::String(description.to_owned()),
7399 ),
7400 (
7401 "business_address".to_owned(),
7402 Value::String(address.to_owned()),
7403 ),
7404 (
7405 "business_edit_external_integrations_html".to_owned(),
7406 Value::String(external_integrations_html),
7407 ),
7408 (
7409 "am_pm_selected".to_owned(),
7410 Value::String(
7411 if time_format == "am_pm" {
7412 " selected"
7413 } else {
7414 ""
7415 }
7416 .to_owned(),
7417 ),
7418 ),
7419 (
7420 "hour_24_selected".to_owned(),
7421 Value::String(
7422 if time_format == "24_hour" {
7423 " selected"
7424 } else {
7425 ""
7426 }
7427 .to_owned(),
7428 ),
7429 ),
7430 (
7431 "secondary_color".to_owned(),
7432 Value::String(secondary_color.to_owned()),
7433 ),
7434 ]);
7435 } else if page_id == "service_edit" {
7436 let edit_path = target.split('?').next().unwrap_or_default();
7437 let business_slug = edit_path
7438 .strip_prefix("/businesses/")
7439 .and_then(|value| value.split('/').next())
7440 .filter(|value| !value.is_empty())?;
7441 let service_id = edit_path
7442 .trim_end_matches('/')
7443 .rsplit('/')
7444 .nth(1)
7445 .and_then(|value| value.parse::<i64>().ok())?;
7446 let state_root = business_catalog_for_page(app, business_slug, actor_id, true)?;
7447 let business = state_root
7448 .get("businesses")
7449 .and_then(Value::as_array)
7450 .and_then(|businesses| {
7451 businesses.iter().find(|business| {
7452 business.get("slug").and_then(Value::as_str) == Some(business_slug)
7453 })
7454 })?;
7455 let business_id = business.get("id").and_then(Value::as_i64)?;
7456 let business_name = business
7457 .get("name")
7458 .and_then(Value::as_str)
7459 .unwrap_or("Business")
7460 .to_owned();
7461 let service = state_root
7462 .get("services")
7463 .and_then(Value::as_array)
7464 .and_then(|services| {
7465 services.iter().find(|service| {
7466 service.get("id").and_then(Value::as_i64) == Some(service_id)
7467 && service.get("business_id").and_then(Value::as_i64) == Some(business_id)
7468 })
7469 })?;
7470 let service_name = service
7471 .get("name")
7472 .and_then(Value::as_str)
7473 .unwrap_or("Service");
7474 let service_image_url = service
7475 .get("image_url")
7476 .or_else(|| service.get("image"))
7477 .and_then(Value::as_str)
7478 .unwrap_or_default()
7479 .to_owned();
7480 let service_primary_color = service
7481 .get("primary_color")
7482 .and_then(Value::as_str)
7483 .filter(|value| !value.is_empty())
7484 .unwrap_or("#2563eb")
7485 .to_owned();
7486 let service_sort_order = service
7487 .get("sort_order")
7488 .and_then(Value::as_i64)
7489 .unwrap_or(1);
7490 let booking_options = service
7491 .get("booking_options")
7492 .cloned()
7493 .unwrap_or(Value::Array(Vec::new()));
7494 let booking_option_rows_html = booking_options
7495 .as_array()
7496 .and_then(|rows| {
7497 rows.iter()
7498 .enumerate()
7499 .map(|(index, option)| {
7500 let option_index = index + 1;
7501 let label_value = option
7502 .get("label")
7503 .map(|value| match value {
7504 Value::String(text) => text.clone(),
7505 other => serde_json::to_string(other)
7506 .unwrap_or_default()
7507 .trim_matches('"')
7508 .to_owned(),
7509 })
7510 .unwrap_or_default();
7511 let duration_value = option
7512 .get("duration_minutes")
7513 .map(|value| match value {
7514 Value::String(text) => text.clone(),
7515 Value::Number(number) => number.to_string(),
7516 other => serde_json::to_string(other)
7517 .unwrap_or_default()
7518 .trim_matches('"')
7519 .to_owned(),
7520 })
7521 .unwrap_or_else(|| "45".to_owned());
7522 let cost_value = option
7523 .get("cost")
7524 .map(|value| match value {
7525 Value::String(text) => text.clone(),
7526 Value::Number(number) => number.to_string(),
7527 other => serde_json::to_string(other)
7528 .unwrap_or_default()
7529 .trim_matches('"')
7530 .to_owned(),
7531 })
7532 .unwrap_or_default();
7533 let label_input_ui_html = app_ui_input(
7534 artifact_root,
7535 &format!("booking-option-label-{option_index}"),
7536 "text",
7537 &format!("booking_option_label_{option_index}"),
7538 "Service Name",
7539 "sr-only",
7540 "form-control form-control-compact booking-option-input booking-option-input-label",
7541 &label_value,
7542 " placeholder=\"Service Name\" autocomplete=\"off\" aria-label=\"Service Name\"",
7543 )?;
7544 let duration_input_ui_html = app_ui_input(
7545 artifact_root,
7546 &format!("booking-option-duration-{option_index}"),
7547 "number",
7548 &format!("booking_option_duration_{option_index}"),
7549 "Time in minutes",
7550 "sr-only",
7551 "form-control form-control-compact booking-option-input booking-option-input-duration",
7552 &duration_value,
7553 " min=\"1\" placeholder=\"Minutes\" aria-label=\"Time in minutes\"",
7554 )?;
7555 let cost_input_ui_html = app_ui_input(
7556 artifact_root,
7557 &format!("booking-option-cost-{option_index}"),
7558 "text",
7559 &format!("booking_option_cost_{option_index}"),
7560 "Cost",
7561 "sr-only",
7562 "form-control form-control-compact booking-option-input booking-option-input-cost",
7563 &cost_value,
7564 " placeholder=\"Cost\" aria-label=\"Cost\" inputmode=\"decimal\" autocomplete=\"off\"",
7565 )?;
7566 app_ui_fragment(
7567 artifact_root,
7568 "templates/businesses/fragments/service_edit_booking_option_row.html",
7569 serde_json::json!({
7570 "label_input_ui_html": label_input_ui_html,
7571 "duration_input_ui_html": duration_input_ui_html,
7572 "cost_input_ui_html": cost_input_ui_html,
7573 }),
7574 )
7575 })
7576 .collect::<Option<Vec<_>>>()
7577 .map(|items| items.join(""))
7578 })
7579 .unwrap_or_default();
7580 let technician_rows_html = json_commands::technician_checkboxes_html(
7581 artifact_root,
7582 &state_root,
7583 business_id,
7584 service_id,
7585 "No technicians assigned.",
7586 false,
7587 )
7588 .unwrap_or_default();
7589 params = serde_json::Map::from_iter([
7590 (
7591 "save_path".to_owned(),
7592 Value::String(format!(
7593 "/businesses/{business_slug}/services/{service_id}/edit/"
7594 )),
7595 ),
7596 (
7597 "cancel_path".to_owned(),
7598 Value::String(format!("/businesses/{business_slug}/")),
7599 ),
7600 (
7601 "delete_path".to_owned(),
7602 Value::String(format!(
7603 "/businesses/{business_slug}/services/{service_id}/delete/"
7604 )),
7605 ),
7606 (
7607 "service_name".to_owned(),
7608 Value::String(service_name.to_owned()),
7609 ),
7610 ("business_name".to_owned(), Value::String(business_name)),
7611 (
7612 "service_image_style".to_owned(),
7613 Value::String(if service_image_url.is_empty() {
7614 "display:none;".to_owned()
7615 } else {
7616 "display:block;".to_owned()
7617 }),
7618 ),
7619 (
7620 "service_image_url".to_owned(),
7621 Value::String(service_image_url.clone()),
7622 ),
7623 (
7624 "service_image_alt".to_owned(),
7625 Value::String(service_name.to_owned()),
7626 ),
7627 (
7628 "service_badge_style".to_owned(),
7629 Value::String(if service_image_url.is_empty() {
7630 String::new()
7631 } else {
7632 "display:none;".to_owned()
7633 }),
7634 ),
7635 (
7636 "service_badge_color".to_owned(),
7637 Value::String(service_primary_color.clone()),
7638 ),
7639 (
7640 "service_badge_initials".to_owned(),
7641 Value::String(json_commands::dynamic_service_abbreviation(service_name)),
7642 ),
7643 (
7644 "service_cost".to_owned(),
7645 Value::String(
7646 service
7647 .get("cost")
7648 .map(|value| value.to_string().trim_matches('"').to_owned())
7649 .unwrap_or_default(),
7650 ),
7651 ),
7652 (
7653 "service_duration".to_owned(),
7654 Value::String(
7655 service
7656 .get("duration_minutes")
7657 .and_then(Value::as_i64)
7658 .unwrap_or(45)
7659 .to_string(),
7660 ),
7661 ),
7662 (
7663 "service_sort_options_html".to_owned(),
7664 Value::String(
7665 json_commands::service_sort_order_options_html(
7666 artifact_root,
7667 &state_root,
7668 business_id,
7669 service_sort_order,
7670 false,
7671 )
7672 .unwrap_or_default(),
7673 ),
7674 ),
7675 (
7676 "service_description".to_owned(),
7677 Value::String(
7678 service
7679 .get("description")
7680 .and_then(Value::as_str)
7681 .unwrap_or_default()
7682 .to_owned(),
7683 ),
7684 ),
7685 (
7686 "service_primary_color".to_owned(),
7687 Value::String(service_primary_color.clone()),
7688 ),
7689 (
7690 "booking_option_count".to_owned(),
7691 Value::String(
7692 booking_options
7693 .as_array()
7694 .map(|rows| rows.len())
7695 .unwrap_or(0)
7696 .to_string(),
7697 ),
7698 ),
7699 (
7700 "service_booking_option_rows_html".to_owned(),
7701 Value::String(booking_option_rows_html),
7702 ),
7703 (
7704 "service_edit_technicians_empty_style".to_owned(),
7705 Value::String(if technician_rows_html.is_empty() {
7706 String::new()
7707 } else {
7708 "display:none;".to_owned()
7709 }),
7710 ),
7711 (
7712 "service_edit_technicians_empty_message".to_owned(),
7713 Value::String("No technicians assigned.".to_owned()),
7714 ),
7715 (
7716 "service_edit_technician_assignment_rows_html".to_owned(),
7717 Value::String(technician_rows_html),
7718 ),
7719 ]);
7720 } else if page_id == "business_availability_edit" {
7721 let edit_path = target.split('?').next().unwrap_or_default();
7722 let business_slug = edit_path
7723 .strip_prefix("/businesses/")
7724 .and_then(|value| value.split('/').next())
7725 .filter(|value| !value.is_empty())?;
7726 let availability_id = edit_path
7727 .split('/')
7728 .filter_map(|value| value.parse::<i64>().ok())
7729 .next_back()
7730 .filter(|value| *value > 0)?;
7731 let state_root = business_catalog_for_page(app, business_slug, actor_id, true)?;
7732 let business_id = state_root
7733 .get("businesses")
7734 .and_then(Value::as_array)
7735 .and_then(|rows| {
7736 rows.iter()
7737 .find(|row| row.get("slug").and_then(Value::as_str) == Some(business_slug))
7738 })
7739 .and_then(|row| row.get("id").and_then(Value::as_i64))?;
7740 let availability = state_root
7741 .get("business_availability")
7742 .and_then(Value::as_array)
7743 .and_then(|rows| {
7744 rows.iter().find(|row| {
7745 row.get("id").and_then(Value::as_i64) == Some(availability_id)
7746 && row.get("business_id").and_then(Value::as_i64) == Some(business_id)
7747 })
7748 })?;
7749 let day = availability
7750 .get("day_of_week")
7751 .and_then(Value::as_str)
7752 .unwrap_or("Mon");
7753 let start = availability
7754 .get("start_time")
7755 .and_then(Value::as_str)
7756 .unwrap_or("09:00");
7757 let end = availability
7758 .get("end_time")
7759 .and_then(Value::as_str)
7760 .unwrap_or("17:00");
7761 let day_options = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
7762 .into_iter()
7763 .map(|candidate| {
7764 app_select_option(artifact_root, candidate, candidate, candidate == day)
7765 })
7766 .collect::<Option<Vec<_>>>()?
7767 .join("");
7768 params = serde_json::Map::from_iter([
7769 (
7770 "business_path".to_owned(),
7771 Value::String(format!("/businesses/{business_slug}/")),
7772 ),
7773 ("save_path".to_owned(), Value::String(edit_path.to_owned())),
7774 (
7775 "delete_path".to_owned(),
7776 Value::String(format!(
7777 "/businesses/{business_slug}/availability/{availability_id}/delete/"
7778 )),
7779 ),
7780 ("error_html".to_owned(), Value::String(String::new())),
7781 ("day_options".to_owned(), Value::String(day_options)),
7782 ("start_time".to_owned(), Value::String(start.to_owned())),
7783 ("end_time".to_owned(), Value::String(end.to_owned())),
7784 ]);
7785 } else if page_id == "business_dashboard" {
7786 let header = app_static_fragment(
7787 artifact_root,
7788 "templates/businesses/fragments/business_dashboard_header.html",
7789 )?;
7790 let store = database::runtime_logical_app_store(app).ok()?;
7791 let managed_businesses = pharos_runtime_business::managed_businesses(
7792 store.adapter.as_ref(),
7793 &store.config,
7794 &store.schema,
7795 actor_id,
7796 64,
7797 )
7798 .ok()?;
7799 let managed_businesses = serde_json::to_value(managed_businesses)
7800 .ok()?
7801 .as_array()?
7802 .clone();
7803 let mut cards = String::new();
7804 for business in managed_businesses.iter() {
7805 let slug = business
7806 .get("slug")
7807 .and_then(Value::as_str)
7808 .filter(|value| !value.is_empty())?;
7809 let name = business
7810 .get("name")
7811 .and_then(Value::as_str)
7812 .filter(|value| !value.is_empty())?;
7813 let description = business
7814 .get("description")
7815 .and_then(Value::as_str)
7816 .unwrap_or_default();
7817 cards.push_str(&json_commands::render_app_fragment_from_doc(
7818 artifact_root.to_string_lossy().as_ref(),
7819 "templates/businesses/fragments/business_dashboard_card.html",
7820 &serde_json::json!({
7821 "business_name": name,
7822 "business_description": description,
7823 "business_dashboard_actions_html": json_commands::render_app_fragment_from_doc(
7824 artifact_root.to_string_lossy().as_ref(),
7825 "templates/businesses/fragments/business_dashboard_actions.html",
7826 &serde_json::json!({"business_slug": slug}),
7827 )?,
7828 }),
7829 )?);
7830 }
7831 params = serde_json::Map::from_iter([
7832 (
7833 "business_dashboard_header_html".to_owned(),
7834 Value::String(header),
7835 ),
7836 ("business_cards_html".to_owned(), Value::String(cards)),
7837 ]);
7838 } else if page_id == "qr_upload" {
7839 params.insert("scan_html".to_owned(), Value::String(String::new()));
7840 } else if page_id == "booking_form" {
7841 params = serde_json::Map::from_iter([(
7842 "save_path".to_owned(),
7843 Value::String("/bookings/new/".to_owned()),
7844 )]);
7845 } else if page_id == "profile" {
7846 let store = database::runtime_logical_app_store(app).ok()?;
7847 let state_root = pharos_runtime_database::account_tokens::account_profile_view(
7848 store.adapter.as_ref(),
7849 &store.config,
7850 &store.schema,
7851 actor_id,
7852 )
7853 .ok()?;
7854 let params_json =
7855 json_commands::dynamic_profile_page_params_json(&state_root, actor_id, artifact_root)?;
7856 params = serde_json::from_str(¶ms_json).ok()?;
7857 } else if page_id == "bookings" {
7858 let store = database::runtime_logical_app_store(app).ok()?;
7859 let state_root = business::personal_booking_view(
7860 store.adapter.as_ref(),
7861 &store.config,
7862 &store.schema,
7863 actor_id,
7864 None,
7865 )
7866 .ok()?;
7867 let params_json = json_commands::dynamic_user_bookings_page_params_from_root_json(
7868 &state_root,
7869 actor_id,
7870 artifact_root,
7871 )?;
7872 params = serde_json::from_str(¶ms_json).ok()?;
7873 } else if page_id == "booking_detail" {
7874 let booking_id = target
7875 .split('?')
7876 .next()
7877 .and_then(|path| path.strip_prefix("/bookings/"))
7878 .and_then(|path| path.split('/').next())
7879 .and_then(|value| value.parse::<i64>().ok())?;
7880 let state_root = detail?;
7881 let params_json = json_commands::dynamic_booking_detail_page_params_from_root_json(
7882 state_root, actor_id, booking_id,
7883 )?;
7884 params = serde_json::from_str(¶ms_json).ok()?;
7885 } else if page_id == "appointments" {
7886 let store = database::runtime_logical_app_store(app).ok()?;
7887 let state_root = business::appointment_view(
7888 store.adapter.as_ref(),
7889 &store.config,
7890 &store.schema,
7891 actor_id,
7892 None,
7893 )
7894 .ok()?;
7895 let params_json = json_commands::dynamic_appointments_page_params_from_root_json(
7896 &state_root,
7897 actor_id,
7898 artifact_root,
7899 )?;
7900 params = serde_json::from_str(¶ms_json).ok()?;
7901 } else if page_id == "appointment_detail" {
7902 let appointment_id = target
7903 .split('?')
7904 .next()
7905 .and_then(|path| path.strip_prefix("/appointments/"))
7906 .and_then(|path| path.split('/').next())
7907 .and_then(|value| value.parse::<i64>().ok())?;
7908 let state_root = detail?;
7909 let params_json = json_commands::dynamic_appointment_detail_page_params_from_root_json(
7910 state_root,
7911 actor_id,
7912 appointment_id,
7913 artifact_root,
7914 )?;
7915 params = serde_json::from_str(¶ms_json).ok()?;
7916 } else if page_id == "login" {
7917 params = serde_json::Map::from_iter([
7918 ("error_html".to_owned(), Value::String(String::new())),
7919 ("next_input".to_owned(), Value::String(String::new())),
7920 ]);
7921 } else if page_id == "password_reset_request" {
7922 params =
7923 serde_json::Map::from_iter([("error_html".to_owned(), Value::String(String::new()))]);
7924 } else if page_id == "password_reset_complete" {
7925 params = serde_json::Map::from_iter([
7926 ("error_html".to_owned(), Value::String(String::new())),
7927 (
7928 "reset_post_path".to_owned(),
7929 Value::String(target.to_owned()),
7930 ),
7931 ]);
7932 } else if page_id == "register" {
7933 params = serde_json::Map::from_iter([
7934 ("error_html".to_owned(), Value::String(String::new())),
7935 ("next_input".to_owned(), Value::String(String::new())),
7936 ]);
7937 }
7938 let populated_ui_params = populate_static_page_ui_params(artifact_root, page_id, &mut params);
7939 if populated_ui_params.is_none() && page_id != "login" {
7940 return None.or_else(|| {
7941 if page_id == "job_queue_list" {
7942 eprintln!("job queue list render failed: UI parameters unavailable");
7943 }
7944 None
7945 });
7946 }
7947 if page_id.ends_with("_edit")
7948 && let Some(ledger) = dynamic::dynamic_contract_doc(app, "ui_ledger")
7949 {
7950 let declarations = ledger.get("elements")?.as_array()?;
7951 let values = Value::Object(params.clone());
7952 for element in declarations {
7953 if element.get("element_type").and_then(Value::as_str) != Some("button") {
7954 continue;
7955 }
7956 let id = element.get("id")?.as_str()?;
7957 let key = format!("{id}_ui_html");
7958 if !params.contains_key(&key) {
7959 continue;
7960 }
7961 let content = app_ui_fragment(
7962 artifact_root,
7963 element.get("content_fragment")?.as_str()?,
7964 values.clone(),
7965 )?;
7966 let mut attributes = ui::ui_declaration_attributes(element, &values)?;
7967 for name in [
7968 "title",
7969 "onclick",
7970 "formaction",
7971 "formmethod",
7972 "formtarget",
7973 "formenctype",
7974 ] {
7975 if !ui::ui_append_ledger_attribute(
7976 &mut attributes,
7977 element,
7978 name,
7979 name,
7980 &values,
7981 false,
7982 ) {
7983 return None;
7984 }
7985 }
7986 for group in ["data", "hx"] {
7987 if !ui::ui_append_structured_attributes(&mut attributes, element, group, &values) {
7988 return None;
7989 }
7990 }
7991 let button = app_ui_button(
7992 artifact_root,
7993 id,
7994 element.get("button_type")?.as_str()?,
7995 element.get("class_name")?.as_str()?,
7996 &attributes,
7997 &content,
7998 )?;
7999 params.insert(key, Value::String(button));
8000 }
8001 }
8002 if page_id == "login"
8003 && let Some(message) = error_message.filter(|value| !value.is_empty())
8004 {
8005 let error_html = json_commands::template_error_alert_html(artifact_root, message)
8006 .unwrap_or_else(|| json_commands::html_escape_text(message));
8007 params.insert("error_html".to_owned(), Value::String(error_html));
8008 }
8009 if matches!(
8010 page_id,
8011 "login" | "password_reset_request" | "password_reset_complete"
8012 ) {
8013 let csrf_token_input_ui_html = app_ui_fragment(
8014 artifact_root,
8015 "templates/shared/fragments/csrf_hidden_input.html",
8016 serde_json::json!({"csrf_token": csrf_token}),
8017 )?;
8018 params.insert(
8019 "csrf_token_input_ui_html".to_owned(),
8020 Value::String(csrf_token_input_ui_html),
8021 );
8022 }
8023 if page_id == "login" {
8024 params.insert(
8025 "login_action_path".to_owned(),
8026 Value::String(app_login_location(app)),
8027 );
8028 let next_target = target
8029 .split_once('?')
8030 .and_then(|(_, query)| json_commands::request_query_value(query, "next"))
8031 .unwrap_or_default();
8032 let next_input_html =
8033 app_ui_hidden_input(artifact_root, "login-next", "next", &next_target, "")?;
8034 params.insert("next_input".to_owned(), Value::String(next_input_html));
8035 }
8036 let title = page_spec
8037 .get("title")
8038 .and_then(Value::as_str)
8039 .filter(|value| !value.is_empty())
8040 .map(ToOwned::to_owned)
8041 .or_else(|| {
8042 page_spec
8043 .get("title_mode")
8044 .and_then(Value::as_str)
8045 .and_then(|key| params.get(key))
8046 .and_then(Value::as_str)
8047 .filter(|value| !value.is_empty())
8048 .map(ToOwned::to_owned)
8049 })
8050 .or_else(|| {
8051 params
8052 .get("business_name")
8053 .or_else(|| params.get("service_name"))
8054 .or_else(|| params.get("surface_title"))
8055 .and_then(Value::as_str)
8056 .filter(|value| !value.is_empty())
8057 .map(ToOwned::to_owned)
8058 })
8059 .unwrap_or_else(|| page_id.to_owned());
8060 let params = Value::Object(params);
8061 let body = json_commands::template_render_file(&template_path, ¶ms).or_else(|| {
8062 if page_id == "job_queue_list" {
8063 eprintln!("job queue list render failed: body template unavailable");
8064 }
8065 None
8066 })?;
8067 let layout_id = route
8068 .get("layout_id")
8069 .and_then(Value::as_str)
8070 .or_else(|| page_spec.get("layout_id").and_then(Value::as_str));
8071 let rendered = json_commands::template_layout_page_html(
8072 artifact_root,
8073 app.base_path.as_deref(),
8074 layout_id,
8075 Some("en"),
8076 &title,
8077 &body,
8078 Some(target),
8079 has_user,
8080 managed_business_count,
8081 )
8082 .or_else(|| {
8083 if page_id == "job_queue_list" {
8084 eprintln!("job queue list render failed: layout unavailable");
8085 }
8086 None
8087 })?;
8088 Some(dynamic::csrf_inject_hidden_inputs(
8089 artifact_root,
8090 &rendered,
8091 csrf_token,
8092 ))
8093}
8094
8095fn app_relative_target(app: &AppRuntime, target: &str) -> String {
8097 pharos_runtime_core::relative_target(app.base_path.as_deref(), target)
8098}
8099
8100fn app_render_request(
8102 app: &AppRuntime,
8103 request: &http::HttpRequest,
8104) -> Result<
8105 pharos_runtime_core::render::RuntimeRenderRequest,
8106 pharos_runtime_database::operations::DatabaseError,
8107> {
8108 Ok(
8109 pharos_runtime_core::render::RuntimeRenderRequest::from_request(
8110 request,
8111 app.base_path.as_deref(),
8112 dynamic::csrf_token_for_response(request.cookie.as_deref()),
8113 active_session_user_id(app, request.cookie.as_deref())?,
8114 ),
8115 )
8116}
8117
8118fn app_declarative_route_response(
8120 app: &AppRuntime,
8121 request: &http::HttpRequest,
8122) -> Option<Vec<u8>> {
8123 let render_request = match app_render_request(app, request) {
8124 Ok(value) => value,
8125 Err(error) => return Some(database_error_response(error)),
8126 };
8127 let method = render_request.method.as_str();
8128 let target = render_request.relative_target.as_str();
8129 let path = render_request.relative_path.as_str();
8130 let route = matched_app_route(app, method, path)?;
8131 let kind = route.get("kind").and_then(Value::as_str)?;
8132 if app.app_id.as_deref() == Some("ucal")
8133 && kind == "page"
8134 && route.get("route_id").and_then(Value::as_str) == Some("business_detail")
8135 {
8136 if render_request.actor_id <= 0 {
8137 return Some(app_login_redirect(app, path));
8138 }
8139 let slug = path.strip_prefix("/businesses/")?.trim_end_matches('/');
8140 match ucal_business_api_actor_manages_business(app, target, slug, request.cookie.as_deref())
8141 {
8142 Ok(Some(true)) => {}
8143 Ok(Some(false) | None) => {
8144 return Some(http_text_response(404, "Not Found", "Not found\n"));
8145 }
8146 Err(error) => {
8147 eprintln!("ucal business detail access check failed: {error:?}");
8148 return Some(http_text_response(
8149 503,
8150 "Service Unavailable",
8151 "Unavailable\n",
8152 ));
8153 }
8154 }
8155 }
8156 let auth_gate = route.get("auth_gate").and_then(Value::as_str).unwrap_or("");
8157 if kind == "page"
8158 && auth_gate == "session_required"
8159 && route.get("feature_id").and_then(Value::as_str).is_some()
8160 {
8161 if render_request.actor_id <= 0 {
8162 return Some(app_login_redirect(app, path));
8163 }
8164 if let Some(policy) = route.get("policy_gate").and_then(Value::as_str)
8165 && policy != "public"
8166 && !match actor_has_route_policy(app, render_request.actor_id, policy) {
8167 Ok(value) => value,
8168 Err(error) => return Some(database_error_response(error)),
8169 }
8170 {
8171 if app.app_id.as_deref() == Some("architect")
8172 && path.trim_end_matches('/') == "/settings"
8173 && policy == "manage_llm_vendors"
8174 {
8175 return Some(architect_settings_access_denied_response(
8176 app, request, method, target,
8177 ));
8178 }
8179 return Some(http_text_response(403, "Forbidden", "capability denied\n"));
8180 }
8181 }
8182 if kind == "page"
8183 && app.app_id.as_deref() == Some("architect")
8184 && path.trim_end_matches('/') == "/settings"
8185 {
8186 return Some(architect_settings_review_response(app, request, method));
8187 }
8188 if kind == "page"
8189 && app.app_id.as_deref() == Some("architect")
8190 && matches!(path.trim_end_matches('/'), "" | "/editor")
8191 && !dynamic::request_is_hx(Some(request))
8192 {
8193 let Some(html) = app_route_page_html(
8194 app,
8195 &route,
8196 target,
8197 render_request.actor_id,
8198 render_request.has_user,
8199 0,
8200 None,
8201 &render_request.csrf_token,
8202 ) else {
8203 return Some(http_text_response(
8204 500,
8205 "Internal Server Error",
8206 "declared app route failed to render\n",
8207 ));
8208 };
8209 let html = architect_guard_preview_actions(app, &html, false);
8211 return Some(http_html_response_with_csrf(
8212 200,
8213 "OK",
8214 &html,
8215 method,
8216 Some(&render_request.csrf_token),
8217 ));
8218 }
8219 if kind == "page"
8220 && route.get("feature_id").and_then(Value::as_str).is_some()
8221 && dynamic::request_is_hx(Some(request))
8222 {
8223 let mut last_error = None;
8224 for attempt in 0..10 {
8225 match feature_runtime::render_feature_page_body(
8226 app,
8227 &route,
8228 target,
8229 render_request.actor_id,
8230 &render_request.csrf_token,
8231 ) {
8232 Ok(body) => {
8233 let body = if app.app_id.as_deref() == Some("architect") {
8234 let prepare_previews = path == "/tasks/status";
8235 architect_guard_preview_actions(app, &body, prepare_previews)
8236 } else {
8237 body
8238 };
8239 return Some(http_html_response(200, "OK", &body, method));
8240 }
8241 Err(error) => {
8242 last_error = Some(error);
8243 if attempt < 9 {
8244 std::thread::sleep(Duration::from_millis(50));
8245 }
8246 }
8247 }
8248 }
8249 if let Some(error) = last_error {
8250 eprintln!("feature page fragment render failed: {error:?}");
8251 }
8252 return Some(http_text_response(
8253 500,
8254 "Internal Server Error",
8255 "feature page unavailable\n",
8256 ));
8257 }
8258 if let Some((status, status_text, body)) = route_request_middleware_failure(&route, request) {
8259 return Some(http_json_response(status, status_text, &body, method));
8260 }
8261 if kind == "action" {
8262 if let Some((status, status_text, body)) = route_request_hook_failure(&route, request) {
8263 return Some(http_json_response(status, status_text, &body, method));
8264 }
8265 let action_id = route
8266 .get("action_id")
8267 .and_then(Value::as_str)
8268 .unwrap_or_default();
8269 let action_is_payment_provider_checkout_webhook = app
8270 .app_root
8271 .as_deref()
8272 .and_then(|app_root| {
8273 payment_provider::payment_provider_checkout_webhook_action_declared(
8274 Path::new(app_root),
8275 action_id,
8276 )
8277 })
8278 .or_else(|| {
8279 app.artifact_dir.as_deref().and_then(|artifact_dir| {
8280 payment_provider::payment_provider_checkout_webhook_action_declared(
8281 Path::new(artifact_dir),
8282 action_id,
8283 )
8284 })
8285 })
8286 .is_some();
8287 if method != "GET"
8288 && method != "HEAD"
8289 && !action_is_payment_provider_checkout_webhook
8290 && !dynamic::csrf_request_valid(request, dynamic::request_body_text(request).as_deref())
8291 {
8292 if action_id == "login" {
8293 return Some(login_error_response(
8294 app,
8295 request,
8296 "Security verification failed",
8297 ));
8298 }
8299 return Some(http_text_response(
8300 403,
8301 "Forbidden",
8302 "Security verification failed\n",
8303 ));
8304 }
8305 if let Some(provider_id) = payment_provider_checkout_provider_for_action(app, action_id)
8306 && let Some(response) =
8307 payment_provider_checkout_action_response(app, request, action_id, &provider_id)
8308 {
8309 return Some(response);
8310 }
8311 if let Some(architect_action) =
8312 pharos_runtime_architect::architect_http_action_from_id(action_id)
8313 {
8314 use pharos_runtime_architect::ArchitectHttpAction;
8315 if app.app_id.as_deref() == Some("architect")
8316 && matches!(
8317 architect_action,
8318 ArchitectHttpAction::AutoBuildTask
8319 | ArchitectHttpAction::ApproveApplyPlan
8320 | ArchitectHttpAction::DeleteTask
8321 | ArchitectHttpAction::UploadAttachment
8322 | ArchitectHttpAction::UploadTranscript
8323 )
8324 && let Err(response) = architect_task_actor_allowed_response(app, request)
8325 {
8326 return Some(response);
8327 }
8328 return Some(match architect_action {
8329 ArchitectHttpAction::ChatGptConnect
8330 | ArchitectHttpAction::ChatGptStatus
8331 | ArchitectHttpAction::ChatGptTest
8332 | ArchitectHttpAction::ChatGptDisconnect
8333 | ArchitectHttpAction::ChatGptCancel => {
8334 architect_chatgpt_response(app, request, method, architect_action)
8335 }
8336 ArchitectHttpAction::UploadAttachment => {
8337 architect_upload_attachment_response(app, request, method)
8338 }
8339 ArchitectHttpAction::ReadImageAsset => {
8340 architect_read_image_asset_response(app, request, method)
8341 }
8342 ArchitectHttpAction::UploadTranscript => {
8343 architect_upload_transcript_response(app, request, method)
8344 }
8345 ArchitectHttpAction::SubmitBuiltinTask => {
8346 architect_submit_builtin_task_response(app, request, method)
8347 }
8348 ArchitectHttpAction::AutoBuildTask => {
8349 architect_auto_build_task_response(app, request, method)
8350 }
8351 ArchitectHttpAction::ApproveApplyPlan => {
8352 architect_approve_apply_plan_response(app, request, method)
8353 }
8354 ArchitectHttpAction::DeleteTask => {
8355 architect_delete_task_response(app, request, method)
8356 }
8357 ArchitectHttpAction::ConfigureLlmVendorConnection => {
8358 architect_configure_llm_vendor_connection_response(app, request, method)
8359 }
8360 ArchitectHttpAction::RotateLlmVendorSecret => {
8361 architect_rotate_llm_vendor_secret_response(app, request, method)
8362 }
8363 ArchitectHttpAction::TestLlmVendorConnection => {
8364 architect_test_llm_vendor_connection_response(app, request, method)
8365 }
8366 ArchitectHttpAction::RevokeLlmVendorConnection => {
8367 architect_revoke_llm_vendor_connection_response(app, request, method)
8368 }
8369 ArchitectHttpAction::CreateUser => {
8370 architect_create_user_response(app, request, method)
8371 }
8372 ArchitectHttpAction::UpdateUser => {
8373 architect_update_user_response(app, request, method)
8374 }
8375 ArchitectHttpAction::DeleteUser => {
8376 architect_delete_user_response(app, request, method)
8377 }
8378 ArchitectHttpAction::SetUserPassword => {
8379 architect_set_user_password_response(app, request, method)
8380 }
8381 ArchitectHttpAction::ImportSourceAppProject => {
8382 architect_import_source_app_project_response(app, request, method)
8383 }
8384 ArchitectHttpAction::RenameEditorProject => {
8385 architect_rename_editor_project_response(app, request, method)
8386 }
8387 ArchitectHttpAction::DuplicateEditorProject => {
8388 architect_duplicate_editor_project_response(app, request, method)
8389 }
8390 ArchitectHttpAction::DeleteEditorProject => {
8391 architect_delete_editor_project_response(app, request, method)
8392 }
8393 ArchitectHttpAction::RollbackEditorVersion => {
8394 architect_rollback_editor_version_response(app, request, method)
8395 }
8396 ArchitectHttpAction::ViewEditorSourceFile => {
8397 architect_view_editor_source_file_response(app, request, method)
8398 }
8399 ArchitectHttpAction::PrepareEditorSourceEdit => {
8400 architect_prepare_editor_source_edit_response(app, request, method)
8401 }
8402 ArchitectHttpAction::PrepareEditorStructuredEdit => {
8403 architect_prepare_editor_structured_edit_response(app, request, method)
8404 }
8405 ArchitectHttpAction::ApplyEditorSourceEdit => {
8406 architect_apply_editor_source_edit_response(app, request, method)
8407 }
8408 ArchitectHttpAction::DownloadSource => {
8409 architect_download_source_export_response(app, request, method)
8410 }
8411 ArchitectHttpAction::ViewGeneratedApp => {
8412 architect_generated_app_preview_response(app, request, method)
8413 }
8414 ArchitectHttpAction::UploadPreviewImage => {
8415 architect_upload_preview_image_response(app, request, method)
8416 }
8417 ArchitectHttpAction::ApplyPreviewEdit => {
8418 architect_apply_preview_edit_response(app, request, method)
8419 }
8420 });
8421 }
8422 if action_id == "issue_business_invite" {
8423 return Some(ucal_issue_business_invite_response(
8424 app, request, method, path,
8425 ));
8426 }
8427 if action_id == "accept_invite" {
8428 return Some(ucal_accept_business_invite_response(
8429 app, request, method, path,
8430 ));
8431 }
8432 if route.get("feature_id").and_then(Value::as_str).is_some() {
8433 let actor_id = render_request.actor_id;
8434 if route
8435 .get("auth_gate")
8436 .and_then(Value::as_str)
8437 .is_some_and(|gate| gate == "session_required")
8438 && actor_id <= 0
8439 {
8440 let next_path = route
8441 .get("action_id")
8442 .and_then(Value::as_str)
8443 .and_then(|action_id| {
8444 feature_runtime::action_fallback_redirect(app, action_id).ok()
8445 })
8446 .map(|redirect| app_scope_path(app, &redirect))
8447 .unwrap_or_else(|| path.to_owned());
8448 return Some(app_login_redirect(app, &next_path));
8449 }
8450 let outcome =
8451 match feature_runtime::execute_feature_action(app, &route, request, actor_id) {
8452 Ok(outcome) => outcome,
8453 Err(feature_runtime::FeatureRuntimeError::Database(error)) => {
8454 return Some(database_error_response(error));
8455 }
8456 Err(feature_runtime::FeatureRuntimeError::DatabaseUnavailable) => {
8457 return Some(database_error_response(
8458 pharos_runtime_database::operations::DatabaseError::Unavailable,
8459 ));
8460 }
8461 Err(feature_runtime::FeatureRuntimeError::ValidationReport(report)) => {
8462 return Some(http_json_response(
8463 422,
8464 "Unprocessable Entity",
8465 &report,
8466 method,
8467 ));
8468 }
8469 Err(feature_runtime::FeatureRuntimeError::InvalidInput(field)) => {
8470 eprintln!(
8471 "feature action invalid input: action_id={action_id} field={field}"
8472 );
8473 return Some(http_text_response(
8474 422,
8475 "Unprocessable Entity",
8476 &format!("missing or invalid field: {field}\n"),
8477 ));
8478 }
8479 Err(feature_runtime::FeatureRuntimeError::MissingRecord(_)) => {
8480 return Some(http_text_response(
8481 409,
8482 "Conflict",
8483 "required record unavailable\n",
8484 ));
8485 }
8486 Err(feature_runtime::FeatureRuntimeError::Conflict(_)) => {
8487 return Some(http_text_response(
8488 409,
8489 "Conflict",
8490 "transaction conflict\n",
8491 ));
8492 }
8493 Err(error) => {
8494 eprintln!("feature action failed: {error:?}");
8495 return Some(http_text_response(
8496 500,
8497 "Internal Server Error",
8498 "feature action failed\n",
8499 ));
8500 }
8501 };
8502 if outcome.redirect_only {
8503 let redirect = app_scope_path(app, &outcome.redirect);
8504 return Some(http_route_redirect_for_request(request, &redirect));
8505 }
8506 if dynamic::request_is_hx(Some(request)) {
8507 let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
8508 return Some(http_text_response(
8509 500,
8510 "Internal Server Error",
8511 "declared app response failed to render\n",
8512 ));
8513 };
8514 let fragment_html = dynamic::csrf_inject_hidden_inputs(
8515 artifact_root,
8516 &outcome.fragment_html,
8517 &render_request.csrf_token,
8518 );
8519 return Some(http_html_response(
8520 outcome.status,
8521 http::http_status_text(outcome.status),
8522 &fragment_html,
8523 method,
8524 ));
8525 }
8526 let redirect = app
8527 .base_path
8528 .as_deref()
8529 .filter(|base| !base.is_empty() && *base != "/")
8530 .map(|base| {
8531 format!(
8532 "{}{}",
8533 base.trim_end_matches('/'),
8534 outcome.redirect.as_str()
8535 )
8536 })
8537 .unwrap_or(outcome.redirect);
8538 return Some(http_route_redirect(&redirect));
8539 }
8540 let action_id = route
8541 .get("action_id")
8542 .and_then(Value::as_str)
8543 .unwrap_or_default();
8544 if matches!(
8545 action_id,
8546 "complete_appointment"
8547 | "cancel_appointment"
8548 | "request_cancel_appointment"
8549 | "delete_appointment"
8550 | "assign_appointment_technician"
8551 ) {
8552 let appointment_id = path
8553 .strip_prefix("/appointments/")
8554 .and_then(|value| value.split('/').next())
8555 .and_then(|value| value.parse::<i64>().ok());
8556 if appointment_id.is_none_or(|id| id <= 0 || id > 1_000_000) {
8557 return Some(http_text_response(
8558 422,
8559 "Unprocessable Entity",
8560 "invalid request\n",
8561 ));
8562 }
8563 }
8564 if route.get("action_id").and_then(Value::as_str) == Some("login") {
8565 let username = dynamic::request_form_value(request, "username").unwrap_or_default();
8566 let password = dynamic::request_form_value(request, "password").unwrap_or_default();
8567 let relational_user_id = if app_uses_relational_runtime_authority(app) {
8568 let app_root = match app.app_root.as_deref() {
8569 Some(root) => root,
8570 None => {
8571 return Some(database_error_response(
8572 pharos_runtime_database::operations::DatabaseError::InvalidContract,
8573 ));
8574 }
8575 };
8576 match database::dynamic_relational_authenticate_user(
8577 Path::new(app_root),
8578 app.runtime_conf_path.as_deref().map(Path::new),
8579 app.app_id.as_deref(),
8580 &username,
8581 &password,
8582 ) {
8583 Ok(value) => value,
8584 Err(error) => return Some(database_error_response(error)),
8585 }
8586 } else {
8587 None
8588 };
8589 let json_user_id = app
8590 .state_path
8591 .as_deref()
8592 .filter(|_| app_uses_file_backed_runtime_state(app))
8593 .and_then(|state_path| stdfs::read_to_string(state_path).ok())
8594 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
8595 .and_then(|root| {
8596 root.get("users")
8597 .and_then(Value::as_array)
8598 .and_then(|users| {
8599 users.iter().find_map(|user| {
8600 (user.get("username").and_then(Value::as_str)
8601 == Some(username.as_str())
8602 && user
8603 .get("is_active")
8604 .and_then(Value::as_bool)
8605 .unwrap_or(true)
8606 && dynamic::verify_password_hash(
8607 user.get("password_hash")
8608 .and_then(Value::as_str)
8609 .unwrap_or_default(),
8610 &password,
8611 Some(&username),
8612 ))
8613 .then(|| user.get("id").and_then(Value::as_i64))
8614 .flatten()
8615 })
8616 })
8617 });
8618 let authenticated_user_id = if app_uses_relational_runtime_authority(app) {
8619 relational_user_id
8620 } else {
8621 json_user_id
8622 };
8623 let valid = authenticated_user_id.is_some();
8624 if !valid {
8625 if app_uses_relational_runtime_authority(app) {
8626 let store = match checked_app_database_operations(app) {
8627 Ok(value) => value,
8628 Err(error) => return Some(database_error_response(error)),
8629 };
8630 match store.adapter.append_rate_limit_event(
8631 &store.config,
8632 "auth.login",
8633 &username,
8634 "invalid_credentials",
8635 ) {
8636 Ok(value) => value,
8637 Err(error) => return Some(database_error_response(error)),
8638 };
8639 }
8640 if !app_uses_relational_runtime_authority(app) {
8641 let audit_status = dynamic::append_rate_limit_event_runtime_local(
8642 app,
8643 "auth.login",
8644 &username,
8645 "invalid_credentials",
8646 );
8647 if audit_status != 0 {
8648 return Some(http_text_response(
8649 503,
8650 "Service Unavailable",
8651 "security audit unavailable\n",
8652 ));
8653 }
8654 }
8655 return Some(login_error_response(
8656 app,
8657 request,
8658 "Invalid username or password",
8659 ));
8660 }
8661 let session_token = match dynamic::random_secret_hex(32) {
8662 Ok(token) => token,
8663 Err(_) => {
8664 return Some(http_text_response(
8665 500,
8666 "Internal Server Error",
8667 "session unavailable\n",
8668 ));
8669 }
8670 };
8671 let Some(session_hash) = native_session_token_hash(app, &session_token) else {
8672 return Some(http_text_response(
8673 500,
8674 "Internal Server Error",
8675 "session unavailable\n",
8676 ));
8677 };
8678 let expires_at = timestamp_after_seconds_utc(604_800);
8679 let session_persisted = if app_uses_relational_runtime_authority(app) {
8680 let store = match checked_app_database_operations(app) {
8681 Ok(value) => value,
8682 Err(error) => return Some(database_error_response(error)),
8683 };
8684 let Some(user_id) = authenticated_user_id else {
8685 return Some(database_error_response(
8686 pharos_runtime_database::operations::DatabaseError::AccessDenied,
8687 ));
8688 };
8689 match store.adapter.issue_session(
8690 &store.config,
8691 user_id,
8692 &session_hash,
8693 &expires_at,
8694 ) {
8695 Ok(value) => value,
8696 Err(error) => return Some(database_error_response(error)),
8697 };
8698 true
8699 } else if app_uses_file_backed_runtime_state(app)
8700 && let Some(state_path) = app.state_path.as_deref()
8701 && let Ok(text) = stdfs::read_to_string(state_path)
8702 && let Ok(mut root) = serde_json::from_str::<Value>(&text)
8703 && let Some(object) = root.as_object_mut()
8704 {
8705 let Some(user_id) = authenticated_user_id else {
8706 return Some(http_text_response(
8707 500,
8708 "Internal Server Error",
8709 "session unavailable\n",
8710 ));
8711 };
8712 let sessions = object
8713 .entry("sessions")
8714 .or_insert_with(|| Value::Array(Vec::new()));
8715 if let Some(rows) = sessions.as_array_mut()
8716 && !rows.iter().any(|row| {
8717 row.get("session_token_hash").and_then(Value::as_str)
8718 == Some(session_hash.as_str())
8719 })
8720 {
8721 rows.push(serde_json::json!({
8722 "session_token_hash": session_hash,
8723 "app_id": app.app_id,
8724 "user_id": user_id,
8725 "expires_at": expires_at,
8726 "revoked_at": null
8727 }));
8728 } else if let Some(row) = sessions.as_array_mut().and_then(|rows| {
8729 rows.iter_mut().find(|row| {
8730 row.get("session_token_hash").and_then(Value::as_str)
8731 == Some(session_hash.as_str())
8732 })
8733 }) {
8734 row["user_id"] = Value::from(user_id);
8735 row["revoked_at"] = Value::Null;
8736 }
8737 serde_json::to_string(&root).ok().is_some_and(|encoded| {
8738 fs::write_text_file(Path::new(state_path), &encoded) == 0
8739 })
8740 } else {
8741 false
8742 };
8743 if !session_persisted {
8744 return Some(http_text_response(
8745 500,
8746 "Internal Server Error",
8747 "session unavailable\n",
8748 ));
8749 }
8750 if app_uses_relational_runtime_authority(app)
8751 && let Some(user_id) = authenticated_user_id
8752 {
8753 let fallback_now = SystemTime::now()
8754 .duration_since(UNIX_EPOCH)
8755 .map(|value| value.as_secs())
8756 .unwrap_or(0);
8757 let expires_at_epoch = runtime_timestamp_seconds(&expires_at)
8758 .unwrap_or(fallback_now.saturating_add(604_800));
8759 if let Ok(cache_scope) = app_security_cache_scope(app) {
8760 cache::session_cache_store(
8761 &cache_scope,
8762 user_id,
8763 &session_hash,
8764 expires_at_epoch,
8765 );
8766 }
8767 }
8768 let default_landing_path = format!(
8769 "{}/",
8770 app_mount_prefix(app, &request.target, path).trim_end_matches('/')
8771 );
8772 let default_landing_path = if default_landing_path == "/" {
8773 "/".to_owned()
8774 } else {
8775 default_landing_path
8776 };
8777 let next_candidate =
8778 dynamic::request_form_value(request, "next").unwrap_or_else(|| {
8779 feature_runtime::query_parameters(&request.target)
8780 .ok()
8781 .and_then(|params| {
8782 params
8783 .get("next")
8784 .and_then(Value::as_str)
8785 .map(ToOwned::to_owned)
8786 })
8787 .unwrap_or_default()
8788 });
8789 let (role_landing_path, override_next) = match authenticated_user_id {
8790 Some(actor_id) => {
8791 match app_post_login_role_landing_path(app, actor_id, &default_landing_path) {
8792 Ok(value) => value,
8793 Err(error) => return Some(database_error_response(error)),
8794 }
8795 }
8796 None => (default_landing_path.clone(), false),
8797 };
8798 let landing_path = if override_next {
8799 role_landing_path
8800 } else {
8801 app_safe_next_path(app, &next_candidate, &role_landing_path)
8802 };
8803 return Some(http_route_redirect_with_session(
8804 app,
8805 &landing_path,
8806 &request.target,
8807 request,
8808 &session_token,
8809 ));
8810 }
8811 if route.get("action_id").and_then(Value::as_str) == Some("password_reset_request")
8812 && !app_uses_file_backed_runtime_state(app)
8813 {
8814 let email = dynamic::request_form_value(request, "email").unwrap_or_default();
8815 let token = match dynamic::random_secret_hex(32) {
8816 Ok(token) => token,
8817 Err(_) => {
8818 return Some(http_text_response(
8819 500,
8820 "Internal Server Error",
8821 "token unavailable\n",
8822 ));
8823 }
8824 };
8825 let token_hash = dynamic::hex_digest(&Sha256::digest(token.as_bytes()));
8826 let expires_at = timestamp_after_seconds_utc(3600);
8827
8828 let store = match database::runtime_logical_app_store(app) {
8829 Ok(store) => store,
8830 Err(_) => {
8831 return Some(http_text_response(
8832 503,
8833 "Service Unavailable",
8834 "database unavailable\n",
8835 ));
8836 }
8837 };
8838 let _issued = match pharos_runtime_database::account_tokens::issue_password_reset(
8839 store.adapter.as_ref(),
8840 &store.config,
8841 &store.schema,
8842 &email,
8843 &token_hash,
8844 &token,
8845 &expires_at,
8846 ) {
8847 Ok(issued) => issued,
8848 Err(pharos_runtime_database::operations::DatabaseError::InvalidInput) => {
8849 return Some(http_text_response(
8850 422,
8851 "Unprocessable Entity",
8852 "invalid request\n",
8853 ));
8854 }
8855 Err(_) => {
8856 return Some(http_text_response(
8857 503,
8858 "Service Unavailable",
8859 "database unavailable\n",
8860 ));
8861 }
8862 };
8863 let params = json_commands::template_password_reset_sent_page_json(
8864 Path::new(app.artifact_dir.as_deref()?),
8865 None,
8866 )?;
8867 let response = dynamic::render_declared_page_response(
8868 Path::new(app.artifact_dir.as_deref()?),
8869 "password_reset_sent",
8870 ¶ms,
8871 )
8872 .ok()?;
8873 return Some(response.into_bytes());
8874 }
8875 if route.get("action_id").and_then(Value::as_str) == Some("logout") {
8876 let session_token = request
8877 .cookie
8878 .as_deref()
8879 .and_then(|cookie| dynamic::dynamic_cookie_value(cookie, "pharos_session"));
8880 let session_hash = session_token
8881 .as_deref()
8882 .and_then(|token| native_session_token_hash(app, token));
8883 let session_revoked = if session_hash.is_none() {
8884 true
8885 } else if !app_uses_file_backed_runtime_state(app) {
8886 app_database_operations(app).is_some_and(|store| {
8887 store
8888 .adapter
8889 .revoke_session(&store.config, session_hash.as_deref().unwrap_or_default())
8890 .is_ok()
8891 })
8892 } else if app_uses_file_backed_runtime_state(app)
8893 && let Some(state_path) = app.state_path.as_deref()
8894 && let Ok(text) = stdfs::read_to_string(state_path)
8895 && let Ok(mut root) = serde_json::from_str::<Value>(&text)
8896 && let Some(rows) = root.get_mut("sessions").and_then(Value::as_array_mut)
8897 {
8898 for row in rows {
8899 if row.get("session_token_hash").and_then(Value::as_str)
8900 == session_hash.as_deref()
8901 && let Some(object) = row.as_object_mut()
8902 {
8903 object.insert("revoked_at".to_owned(), Value::String("1".to_owned()));
8904 }
8905 }
8906 serde_json::to_string(&root).ok().is_some_and(|encoded| {
8907 fs::write_text_file(Path::new(state_path), &encoded) == 0
8908 })
8909 } else {
8910 false
8911 };
8912 if !session_revoked {
8913 return Some(http_text_response(
8914 500,
8915 "Internal Server Error",
8916 "session revocation failed\n",
8917 ));
8918 }
8919 if let Some(session_hash) = session_hash.as_deref()
8920 && let Ok(cache_scope) = app_security_cache_scope(app)
8921 {
8922 pharos_runtime_security::local_session_cache_revoke(&cache_scope, session_hash);
8923 cache::session_cache_revoke_token(&cache_scope, session_hash);
8924 }
8925 return Some(http_route_redirect_clear_session(
8926 app,
8927 request,
8928 &app_login_location(app),
8929 ));
8930 }
8931 let action_id = route
8932 .get("action_id")
8933 .and_then(Value::as_str)
8934 .unwrap_or_default();
8935 let missing = |field: &str| {
8936 dynamic::request_form_value(request, field).is_none_or(|value| value.trim().is_empty())
8937 };
8938 let invalid_without_required_fields = match action_id {
8939 "qr_upload_submit" => {
8940 missing("qr_image") && missing("qr_image_path") && missing("qr_payload")
8941 }
8942 "update_profile" => missing("full_name"),
8943 "create_booking" => missing("title") || missing("date") || missing("time"),
8944 "create_business" => missing("name"),
8945 "create_appointment" | "create_internal_appointment" => {
8946 app_uses_file_backed_runtime_state(app)
8947 && (missing("service_id")
8948 || missing("date")
8949 || (missing("time") && missing("time_hour")))
8950 }
8951 "create_service" => missing("name"),
8952 "create_business_availability" | "update_business_availability" => {
8953 missing("start_time") || missing("end_time")
8954 }
8955 "create_technician" => missing("name") && missing("email"),
8956 "create_technician_availability" => missing("start_time") || missing("end_time"),
8957 _ => false,
8958 };
8959 let invalid_appointment_action = matches!(
8960 action_id,
8961 "complete_appointment"
8962 | "cancel_appointment"
8963 | "request_cancel_appointment"
8964 | "delete_appointment"
8965 | "assign_appointment_technician"
8966 ) && path
8967 .strip_prefix("/appointments/")
8968 .and_then(|value| value.split('/').next())
8969 .and_then(|value| value.parse::<i64>().ok())
8970 .is_none_or(|appointment_id| appointment_id <= 0 || appointment_id > 1_000_000);
8971 if invalid_without_required_fields
8972 || (app_uses_file_backed_runtime_state(app) && invalid_appointment_action)
8973 {
8974 return Some(http_text_response(
8975 422,
8976 "Unprocessable Entity",
8977 "invalid request\n",
8978 ));
8979 }
8980 if let Some((provider_id, provider_action)) = app
8981 .app_root
8982 .as_deref()
8983 .and_then(|app_root| {
8984 payment_provider::payment_provider_lifecycle_action_declared(
8985 Path::new(app_root),
8986 action_id,
8987 )
8988 })
8989 .or_else(|| {
8990 app.artifact_dir.as_deref().and_then(|artifact_dir| {
8991 payment_provider::payment_provider_lifecycle_action_declared(
8992 Path::new(artifact_dir),
8993 action_id,
8994 )
8995 })
8996 })
8997 {
8998 let Some(app_root) = app.app_root.as_deref() else {
8999 return Some(http_text_response(
9000 500,
9001 "Internal Server Error",
9002 "missing app root\n",
9003 ));
9004 };
9005 let owner_route_value =
9006 payment_provider::payment_provider_owner_route_value(Path::new(app_root), path)
9007 .unwrap_or_default();
9008 let logical_store = if app_uses_file_backed_runtime_state(app) {
9009 None
9010 } else {
9011 match database::runtime_logical_app_store(app) {
9012 Ok(store) => Some(store),
9013 Err(_) => {
9014 return Some(http_text_response(
9015 503,
9016 "Service Unavailable",
9017 "database unavailable\n",
9018 ));
9019 }
9020 }
9021 };
9022 let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
9023 Ok(value) => value,
9024 Err(error) => return Some(database_error_response(error)),
9025 };
9026 let mut provider_root = if let Some(store) = &logical_store {
9027 let Some(management) =
9028 pharos_runtime_payment::payment_provider_manifest_config(Path::new(app_root))
9029 else {
9030 return Some(http_text_response(
9031 422,
9032 "Unprocessable Entity",
9033 "payment provider declaration unavailable\n",
9034 ));
9035 };
9036 match pharos_runtime_database::payment::payment_lifecycle_state(
9037 store.adapter.as_ref(),
9038 &store.config,
9039 &store.schema,
9040 &management,
9041 &owner_route_value,
9042 &provider_id,
9043 actor_id,
9044 ) {
9045 Ok(root) => root,
9046 Err(_) => {
9047 return Some(http_text_response(
9048 503,
9049 "Service Unavailable",
9050 "payment provider state unavailable\n",
9051 ));
9052 }
9053 }
9054 } else {
9055 let Some(state_path) = app.state_path.as_deref().map(Path::new) else {
9056 return Some(http_text_response(
9057 503,
9058 "Service Unavailable",
9059 "state storage unavailable\n",
9060 ));
9061 };
9062 let Some(root) = json_commands::json_doc_load_file(state_path) else {
9063 return Some(http_text_response(
9064 404,
9065 "Not Found",
9066 "payment provider state unavailable\n",
9067 ));
9068 };
9069 root
9070 };
9071 let provider_before = provider_root.clone();
9072 let mut provider_feedback_key: Option<&str> = None;
9073 let mut provider_feedback_message: Option<String> = None;
9074 let mut provider_redirect: Option<String> = None;
9075 let provider_result: Result<(), i32> = (|| {
9076 let root = &mut provider_root;
9077 match provider_action {
9078 payment_provider::PaymentProviderLifecycleAction::Connect => {
9079 let provider_account_label =
9080 dynamic::request_form_value(request, "provider_account_label");
9081 let connect_request = payment_provider::PaymentProviderConnectRequest {
9082 provider_account_label: provider_account_label.as_deref(),
9083 };
9084 let redirect =
9085 payment_provider::payment_provider_connect_start_with_request(
9086 root,
9087 Path::new(app_root),
9088 app.runtime_conf_path.as_deref().map(Path::new),
9089 &owner_route_value,
9090 &provider_id,
9091 actor_id,
9092 connect_request,
9093 )?;
9094 provider_redirect = Some(redirect);
9095 Ok(())
9096 }
9097 payment_provider::PaymentProviderLifecycleAction::TestCredentials => {
9098 let test_request = payment_provider::PaymentProviderConnectRequest {
9099 provider_account_label: None,
9100 };
9101 match payment_provider::payment_provider_test_connection(
9102 root,
9103 Path::new(app_root),
9104 app.runtime_conf_path.as_deref().map(Path::new),
9105 &owner_route_value,
9106 &provider_id,
9107 actor_id,
9108 test_request,
9109 ) {
9110 Ok(()) => {
9111 provider_feedback_key =
9112 Some("owner_payment_provider_test_success_feedback");
9113 }
9114 Err((reason_code, reason_detail, _status)) => {
9115 let prefix = payment_provider::payment_provider_copy_value(
9116 Path::new(app_root),
9117 "owner_payment_provider_test_failed_feedback",
9118 )
9119 .unwrap_or_else(|| "Connection test failed".to_owned());
9120 provider_feedback_message =
9121 Some(format!("{prefix}: {reason_code} - {reason_detail}"));
9122 }
9123 }
9124 Ok(())
9125 }
9126 payment_provider::PaymentProviderLifecycleAction::Callback => {
9127 payment_provider::payment_provider_callback_complete(
9128 root,
9129 Path::new(app_root),
9130 app.runtime_conf_path.as_deref().map(Path::new),
9131 &owner_route_value,
9132 &provider_id,
9133 &dynamic::request_query_value(request, "state").unwrap_or_default(),
9134 &dynamic::request_query_value(request, "code").unwrap_or_default(),
9135 actor_id,
9136 )?;
9137 Ok(())
9138 }
9139 payment_provider::PaymentProviderLifecycleAction::Refresh => {
9140 match payment_provider::payment_provider_refresh(
9141 root,
9142 Path::new(app_root),
9143 app.runtime_conf_path.as_deref().map(Path::new),
9144 &owner_route_value,
9145 &provider_id,
9146 actor_id,
9147 ) {
9148 Ok(()) => {
9149 provider_feedback_key =
9150 Some("owner_payment_provider_refresh_success_feedback");
9151 Ok(())
9152 }
9153 Err(_status) => {
9154 provider_feedback_message = Some(
9155 payment_provider::payment_provider_copy_value(
9156 Path::new(app_root),
9157 "owner_payment_provider_refresh_failed_feedback",
9158 )
9159 .unwrap_or_else(|| {
9160 "No connected provider account is available to refresh"
9161 .to_owned()
9162 }),
9163 );
9164 Ok(())
9165 }
9166 }
9167 }
9168 payment_provider::PaymentProviderLifecycleAction::Disconnect => {
9169 payment_provider::payment_provider_disconnect(
9170 root,
9171 Path::new(app_root),
9172 app.runtime_conf_path.as_deref().map(Path::new),
9173 &owner_route_value,
9174 &provider_id,
9175 actor_id,
9176 )?;
9177 provider_feedback_key =
9178 Some("owner_payment_provider_disconnected_feedback");
9179 Ok(())
9180 }
9181 payment_provider::PaymentProviderLifecycleAction::ConfigureCredentials => {
9182 let fields = payment_provider::payment_provider_declared_credential_fields(
9183 Path::new(app_root),
9184 &provider_id,
9185 )
9186 .into_iter()
9187 .filter_map(|field| {
9188 dynamic::request_form_value(request, &field.name)
9189 .map(|value| (field.name, value))
9190 })
9191 .collect();
9192 payment_provider::payment_provider_configure_credentials(
9193 root,
9194 Path::new(app_root),
9195 app.runtime_conf_path.as_deref().map(Path::new),
9196 &owner_route_value,
9197 &provider_id,
9198 actor_id,
9199 payment_provider::PaymentProviderCredentialSubmission { fields },
9200 )?;
9201 provider_feedback_key =
9202 Some("owner_payment_provider_credentials_saved_feedback");
9203 Ok(())
9204 }
9205 payment_provider::PaymentProviderLifecycleAction::RevokeCredentials => {
9206 payment_provider::payment_provider_revoke_credentials(
9207 root,
9208 Path::new(app_root),
9209 app.runtime_conf_path.as_deref().map(Path::new),
9210 &owner_route_value,
9211 &provider_id,
9212 actor_id,
9213 )?;
9214 provider_feedback_key =
9215 Some("owner_payment_provider_credentials_deleted_feedback");
9216 Ok(())
9217 }
9218 payment_provider::PaymentProviderLifecycleAction::SetCheckoutMode => {
9219 payment_provider::payment_provider_set_checkout_mode(
9220 root,
9221 Path::new(app_root),
9222 &owner_route_value,
9223 &provider_id,
9224 actor_id,
9225 &dynamic::request_form_value(request, "checkout_mode")
9226 .unwrap_or_default(),
9227 )?;
9228 provider_feedback_key =
9229 Some("owner_payment_provider_action_completed_feedback");
9230 Ok(())
9231 }
9232 }
9233 })();
9234 if let Err(status) = provider_result {
9235 return Some(http_text_response(
9236 status as u16,
9237 http::http_status_text(status as u16),
9238 "invalid request\n",
9239 ));
9240 }
9241 if let Some(store) = &logical_store {
9242 let Some(management) =
9243 pharos_runtime_payment::payment_provider_manifest_config(Path::new(app_root))
9244 else {
9245 return Some(http_text_response(
9246 422,
9247 "Unprocessable Entity",
9248 "payment provider declaration unavailable\n",
9249 ));
9250 };
9251 let committed_store = match with_action_effects(
9252 app,
9253 request,
9254 action_id,
9255 database::LogicalAppStore {
9256 adapter: store.adapter.clone(),
9257 config: store.config.clone(),
9258 schema: store.schema.clone(),
9259 },
9260 ) {
9261 Ok(store) => store,
9262 Err(status) => {
9263 return Some(http_text_response(
9264 status,
9265 http::http_status_text(status),
9266 "payment effects unavailable\n",
9267 ));
9268 }
9269 };
9270 if pharos_runtime_database::payment::apply_payment_lifecycle_delta(
9271 committed_store.adapter.as_ref(),
9272 &committed_store.config,
9273 &committed_store.schema,
9274 &management,
9275 actor_id,
9276 &provider_before,
9277 &provider_root,
9278 )
9279 .is_err()
9280 {
9281 return Some(http_text_response(
9282 409,
9283 "Conflict",
9284 "payment provider update conflict\n",
9285 ));
9286 }
9287 } else if app
9288 .state_path
9289 .as_deref()
9290 .map(Path::new)
9291 .is_none_or(|state_path| {
9292 payment_provider::persist_payment_provider_state_or_500(
9293 &provider_root,
9294 state_path,
9295 )
9296 .is_err()
9297 })
9298 {
9299 return Some(http_text_response(
9300 503,
9301 "Service Unavailable",
9302 "payment provider state unavailable\n",
9303 ));
9304 }
9305 if let Some(redirect) = provider_redirect {
9306 return Some(http_route_redirect_for_request(request, &redirect));
9307 }
9308 if dynamic::request_is_hx(Some(request)) {
9309 if matches!(
9310 provider_action,
9311 payment_provider::PaymentProviderLifecycleAction::TestCredentials
9312 ) {
9313 let feedback = provider_feedback_message.unwrap_or_else(|| {
9314 provider_feedback_key
9315 .and_then(|key| {
9316 payment_provider::payment_provider_copy_value(
9317 Path::new(app_root),
9318 key,
9319 )
9320 })
9321 .unwrap_or_else(|| "Payment provider action completed".to_owned())
9322 });
9323 return Some(payment_provider_feedback_message_response(app, &feedback));
9324 }
9325 if let Some(setup_path) = payment_provider::payment_provider_setup_path(
9326 Path::new(app_root),
9327 &owner_route_value,
9328 &provider_id,
9329 ) && let Some(setup_route) = matched_app_route(app, "GET", &setup_path)
9330 {
9331 let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
9332 Ok(value) => value,
9333 Err(error) => return Some(database_error_response(error)),
9334 };
9335 let feedback = provider_feedback_message.or_else(|| {
9336 provider_feedback_key.and_then(|key| {
9337 payment_provider::payment_provider_copy_value(Path::new(app_root), key)
9338 })
9339 });
9340 let mut extra_params = serde_json::Map::new();
9341 if let Some(feedback) = feedback {
9342 extra_params.insert(
9343 "payment_provider_feedback_message".to_owned(),
9344 Value::String(feedback),
9345 );
9346 }
9347 if setup_route
9348 .get("feature_id")
9349 .and_then(Value::as_str)
9350 .is_none()
9351 && setup_route.get("page_id").and_then(Value::as_str)
9352 == Some("payment_provider_setup")
9353 {
9354 let body = (|| {
9355 let artifact_root = app.artifact_dir.as_deref().map(Path::new)?;
9356 let page_relative = json_commands::declared_page_relative_path(
9357 artifact_root,
9358 "payment_provider_setup",
9359 )?;
9360 let page_spec = json_commands::json_doc_load_file(
9361 &artifact_root.join(page_relative),
9362 )?;
9363 let template_relative =
9364 page_spec.get("template_file").and_then(Value::as_str)?;
9365 let template_path = artifact_root.join(template_relative);
9366 let state_root = &provider_root;
9367 let params_json =
9368 json_commands::dynamic_payment_provider_setup_page_params_from_root_json(
9369 state_root,
9370 &owner_route_value,
9371 artifact_root,
9372 )?;
9373 let mut params = serde_json::from_str::<Value>(¶ms_json)
9374 .ok()?
9375 .as_object()?
9376 .clone();
9377 for (key, value) in extra_params.clone() {
9378 params.insert(key, value);
9379 }
9380 let body = json_commands::template_render_file(
9381 &template_path,
9382 &Value::Object(params),
9383 )?;
9384 let scoped_body = json_commands::template_apply_base_path(
9385 &body,
9386 app.base_path.as_deref(),
9387 )?;
9388 Some(dynamic::csrf_inject_hidden_inputs(
9389 artifact_root,
9390 &scoped_body,
9391 &render_request.csrf_token,
9392 ))
9393 })();
9394 return Some(match body {
9395 Some(body) => http_html_response(200, "OK", &body, method),
9396 None => http_text_response(
9397 500,
9398 "Internal Server Error",
9399 "payment provider setup unavailable\n",
9400 ),
9401 });
9402 }
9403 return Some(
9404 match feature_runtime::render_feature_page_body_with_error_and_params(
9405 app,
9406 &setup_route,
9407 &setup_path,
9408 actor_id,
9409 &render_request.csrf_token,
9410 None,
9411 extra_params,
9412 ) {
9413 Ok(body) => http_html_response(200, "OK", &body, method),
9414 Err(error) => {
9415 eprintln!(
9416 "payment provider setup fragment render failed: {error:?}"
9417 );
9418 http_text_response(
9419 500,
9420 "Internal Server Error",
9421 "payment provider setup unavailable\n",
9422 )
9423 }
9424 },
9425 );
9426 }
9427 return Some(payment_provider_feedback_response(
9428 app,
9429 provider_feedback_key
9430 .unwrap_or("owner_payment_provider_action_completed_feedback"),
9431 ));
9432 }
9433 let redirect = payment_provider::payment_provider_return_path(
9434 Path::new(app_root),
9435 &owner_route_value,
9436 )
9437 .unwrap_or_else(|| "/".to_owned());
9438 return Some(http_route_redirect(&app_scope_path(app, &redirect)));
9439 }
9440 if let Some(provider_id) = app
9441 .app_root
9442 .as_deref()
9443 .and_then(|app_root| {
9444 payment_provider::payment_provider_checkout_webhook_action_declared(
9445 Path::new(app_root),
9446 action_id,
9447 )
9448 })
9449 .or_else(|| {
9450 app.artifact_dir.as_deref().and_then(|artifact_dir| {
9451 payment_provider::payment_provider_checkout_webhook_action_declared(
9452 Path::new(artifact_dir),
9453 action_id,
9454 )
9455 })
9456 })
9457 {
9458 let Some(app_root) = app.app_root.as_deref() else {
9459 return Some(http_text_response(
9460 500,
9461 "Internal Server Error",
9462 "missing app root\n",
9463 ));
9464 };
9465 if !app_uses_file_backed_runtime_state(app) {
9466 let result = app_database_operations(app)
9467 .ok_or(503)
9468 .and_then(|store| {
9469 with_action_effects(app, request, action_id, store)
9470 .map_err(|status| status as i32)
9471 })
9472 .and_then(|store| {
9473 payment_provider::database_checkout_webhook_complete(
9474 Path::new(app_root),
9475 app.runtime_conf_path.as_deref().map(Path::new),
9476 &store,
9477 &provider_id,
9478 &request.headers,
9479 &request.body,
9480 )
9481 });
9482 return Some(match result {
9483 Ok(()) => http_json_response(200, "OK", r#"{"received":true}"#, method),
9484 Err(status) => http_text_response(
9485 status as u16,
9486 http::http_status_text(status as u16),
9487 "payment event unavailable\n",
9488 ),
9489 });
9490 }
9491 let Some(state_path) = app.state_path.as_deref() else {
9492 return Some(http_text_response(
9493 500,
9494 "Internal Server Error",
9495 "missing state path\n",
9496 ));
9497 };
9498 let result: Result<(), i32> = (|| {
9499 let _guard = state_store::StateMutationGuard::acquire(Path::new(state_path))
9500 .map_err(|_| 503)?;
9501 let mut root =
9502 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
9503 payment_provider::payment_provider_checkout_webhook_complete(
9504 &mut root,
9505 Path::new(app_root),
9506 app.runtime_conf_path.as_deref().map(Path::new),
9507 &provider_id,
9508 &request.headers,
9509 &request.body,
9510 )?;
9511 payment_provider::persist_payment_provider_state_or_500(
9512 &root,
9513 Path::new(state_path),
9514 )
9515 })();
9516 return Some(match result {
9517 Ok(()) => http_json_response(200, "OK", r#"{"received":true}"#, method),
9518 Err(status) => http_text_response(
9519 status as u16,
9520 http::http_status_text(status as u16),
9521 "invalid request\n",
9522 ),
9523 });
9524 }
9525 if let Some(provider_id) = payment_provider_checkout_provider_for_action(app, action_id)
9526 && let Some(response) =
9527 payment_provider_checkout_action_response(app, request, action_id, &provider_id)
9528 {
9529 return Some(response);
9530 }
9531 if matches!(
9532 action_id,
9533 "create_appointment" | "create_internal_appointment"
9534 ) && !app_uses_file_backed_runtime_state(app)
9535 {
9536 let result = (|| {
9537 let store = database::runtime_logical_app_store(app).map_err(|_| 503)?;
9538 let store = with_action_effects(app, request, action_id, store)?;
9539 let slug = path
9540 .strip_prefix("/business/")
9541 .or_else(|| path.strip_prefix("/businesses/"))
9542 .and_then(|value| value.split('/').next())
9543 .ok_or(422)?;
9544 let service_id = dynamic::request_form_value(request, "service_id")
9545 .and_then(|value| value.parse::<i64>().ok())
9546 .or_else(|| {
9547 path.split("/book/")
9548 .nth(1)
9549 .and_then(|value| value.trim_matches('/').parse::<i64>().ok())
9550 })
9551 .ok_or(422)?;
9552 let date = dynamic::request_form_value(request, "date").unwrap_or_default();
9553 let direct_time = dynamic::request_form_value(request, "time").unwrap_or_default();
9554 let hour = dynamic::request_form_value(request, "time_hour").unwrap_or_default();
9555 let minute =
9556 dynamic::request_form_value(request, "time_minute").unwrap_or_default();
9557 let period =
9558 dynamic::request_form_value(request, "time_period").unwrap_or_default();
9559 let time =
9560 normalized_booking_time(&direct_time, &hour, &minute, &period).ok_or(422)?;
9561 let notes = dynamic::request_form_value(request, "notes").unwrap_or_default();
9562 let technician =
9563 dynamic::request_form_value(request, "technician_id").unwrap_or_default();
9564 let technician_id = match technician.as_str() {
9565 "" | "0" => None,
9566 value => Some(value.parse::<i64>().map_err(|_| 422)?),
9567 };
9568 let option = dynamic::request_form_value(request, "booking_option_index")
9569 .unwrap_or_default();
9570 let booking_option_index = if option.is_empty() {
9571 None
9572 } else {
9573 Some(option.parse::<usize>().map_err(|_| 422)?)
9574 };
9575 let key = dynamic::request_form_value(request, "idempotency_key")
9576 .filter(|value| !value.is_empty());
9577 business::create_appointment(
9578 store.adapter.as_ref(),
9579 &store.config,
9580 &store.schema,
9581 &business::AppointmentCreate {
9582 business_slug: slug,
9583 service_id,
9584 actor_user_id: (active_session_user_id(app, request.cookie.as_deref()))
9585 .map_err(database_operation_http_status)?,
9586 technician_id,
9587 booking_option_index,
9588 date: &date,
9589 time: &time,
9590 notes: ¬es,
9591 internal: action_id == "create_internal_appointment",
9592 idempotency_key: key.as_deref(),
9593 },
9594 )
9595 .map_err(|error| match error {
9596 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
9597 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
9598 pharos_runtime_database::operations::DatabaseError::AccessDenied => 403,
9599 pharos_runtime_database::operations::DatabaseError::CapacityExceeded
9600 | pharos_runtime_database::operations::DatabaseError::Conflict => 409,
9601 _ => 503,
9602 })
9603 })();
9604 return Some(match result {
9605 Ok(_) => {
9606 let redirect = dynamic::request_form_value(request, "return_to")
9607 .filter(|value| value.starts_with('/') && !value.starts_with("//"))
9608 .unwrap_or_else(|| "/appointments/".to_owned());
9609 http_route_redirect_for_request(request, &app_scope_path(app, &redirect))
9610 }
9611 Err(status) => http_text_response(
9612 status as u16,
9613 http::http_status_text(status as u16),
9614 "booking unavailable\n",
9615 ),
9616 });
9617 }
9618 if matches!(
9619 action_id,
9620 "complete_appointment"
9621 | "cancel_appointment"
9622 | "request_cancel_appointment"
9623 | "delete_appointment"
9624 ) && !app_uses_file_backed_runtime_state(app)
9625 {
9626 let result = (|| {
9627 let store = database::runtime_logical_app_store(app).map_err(|_| 503)?;
9628 let store = with_action_effects(app, request, action_id, store)?;
9629 let appointment_id = path
9630 .strip_prefix("/appointments/")
9631 .and_then(|value| value.split('/').next())
9632 .and_then(|value| value.parse::<i64>().ok())
9633 .ok_or(422)?;
9634 let change = match action_id {
9635 "complete_appointment" => business::AppointmentChange::Complete,
9636 "cancel_appointment" => business::AppointmentChange::Cancel,
9637 "request_cancel_appointment" => business::AppointmentChange::RequestCancel,
9638 "delete_appointment" => business::AppointmentChange::Delete,
9639 _ => return Err(422),
9640 };
9641 business::change_appointment(
9642 store.adapter.as_ref(),
9643 &store.config,
9644 &store.schema,
9645 (active_session_user_id(app, request.cookie.as_deref()))
9646 .map_err(database_operation_http_status)?,
9647 appointment_id,
9648 change,
9649 )
9650 .map_err(|error| match error {
9651 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
9652 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
9653 pharos_runtime_database::operations::DatabaseError::AccessDenied => 403,
9654 pharos_runtime_database::operations::DatabaseError::Conflict => 409,
9655 _ => 503,
9656 })
9657 })();
9658 return Some(match result {
9659 Ok(()) => {
9660 http_route_redirect_for_request(request, &app_scope_path(app, "/appointments/"))
9661 }
9662 Err(status) => http_text_response(
9663 status as u16,
9664 http::http_status_text(status as u16),
9665 "appointment update unavailable\n",
9666 ),
9667 });
9668 }
9669 if matches!(
9670 action_id,
9671 "assign_appointment_technician" | "reschedule_appointment"
9672 ) && !app_uses_file_backed_runtime_state(app)
9673 {
9674 let result = (|| {
9675 let store = with_action_effects(
9676 app,
9677 request,
9678 action_id,
9679 app_database_operations(app).ok_or(503)?,
9680 )?;
9681 let appointment_id = dynamic::request_form_value(request, "appointment_id")
9682 .and_then(|value| value.parse::<i64>().ok())
9683 .or_else(|| {
9684 path.strip_prefix("/appointments/")
9685 .and_then(|value| value.split('/').next())
9686 .and_then(|value| value.parse::<i64>().ok())
9687 })
9688 .ok_or(422)?;
9689 let technician_id = dynamic::request_form_value(request, "technician_id")
9690 .map(|value| value.parse::<i64>().map_err(|_| 422))
9691 .transpose()?;
9692 let timestamp = dynamic::request_form_value(request, "start_time")
9693 .map(|value| business::appointment_timestamp_parts(&value).map_err(|_| 422))
9694 .transpose()?;
9695 let date = dynamic::request_form_value(request, "date")
9696 .or_else(|| timestamp.as_ref().map(|value| value.0.clone()));
9697 let time = dynamic::request_form_value(request, "time")
9698 .or_else(|| timestamp.as_ref().map(|value| value.1.clone()));
9699 let notes = dynamic::request_form_value(request, "notes");
9700 if action_id == "assign_appointment_technician" && technician_id.is_none()
9701 || action_id == "reschedule_appointment" && (date.is_none() || time.is_none())
9702 {
9703 return Err(422);
9704 }
9705 business::move_appointment(
9706 store.adapter.as_ref(),
9707 &store.config,
9708 &store.schema,
9709 business::AppointmentMove {
9710 actor_id: (active_session_user_id(app, request.cookie.as_deref()))
9711 .map_err(database_operation_http_status)?,
9712 appointment_id,
9713 technician_id,
9714 date: date.as_deref(),
9715 time: time.as_deref(),
9716 notes: notes.as_deref(),
9717 },
9718 )
9719 .map_err(|error| match error {
9720 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
9721 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
9722 pharos_runtime_database::operations::DatabaseError::AccessDenied => 403,
9723 pharos_runtime_database::operations::DatabaseError::Conflict
9724 | pharos_runtime_database::operations::DatabaseError::CapacityExceeded => 409,
9725 _ => 503,
9726 })
9727 })();
9728 return Some(match result {
9729 Ok(()) => {
9730 http_route_redirect_for_request(request, &app_scope_path(app, "/appointments/"))
9731 }
9732 Err(status) => http_text_response(
9733 status as u16,
9734 http::http_status_text(status as u16),
9735 "appointment move unavailable\n",
9736 ),
9737 });
9738 }
9739 if matches!(action_id, "update_booking" | "delete_booking")
9740 && !app_uses_file_backed_runtime_state(app)
9741 {
9742 let result = (|| {
9743 let store = with_action_effects(
9744 app,
9745 request,
9746 action_id,
9747 app_database_operations(app).ok_or(503)?,
9748 )?;
9749 let booking_id = dynamic::request_form_value(request, "booking_id")
9750 .and_then(|value| value.parse::<i64>().ok())
9751 .or_else(|| {
9752 path.strip_prefix("/bookings/")
9753 .and_then(|value| value.split('/').next())
9754 .and_then(|value| value.parse::<i64>().ok())
9755 })
9756 .ok_or(422)?;
9757 let input: Map<String, Value> = [
9758 "title",
9759 "description",
9760 "location",
9761 "start_time",
9762 "end_time",
9763 "status",
9764 ]
9765 .into_iter()
9766 .filter_map(|field| {
9767 dynamic::request_form_value(request, field)
9768 .map(|value| (field.to_owned(), Value::String(value)))
9769 })
9770 .collect();
9771 business::change_personal_booking(
9772 store.adapter.as_ref(),
9773 &store.config,
9774 &store.schema,
9775 (active_session_user_id(app, request.cookie.as_deref()))
9776 .map_err(database_operation_http_status)?,
9777 booking_id,
9778 (action_id == "update_booking").then_some(&input),
9779 )
9780 .map_err(|error| match error {
9781 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
9782 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
9783 pharos_runtime_database::operations::DatabaseError::AccessDenied => 403,
9784 pharos_runtime_database::operations::DatabaseError::Conflict => 409,
9785 _ => 503,
9786 })
9787 })();
9788 return Some(match result {
9789 Ok(()) => {
9790 http_route_redirect_for_request(request, &app_scope_path(app, "/bookings/"))
9791 }
9792 Err(status) => http_text_response(
9793 status as u16,
9794 http::http_status_text(status as u16),
9795 "booking change unavailable\n",
9796 ),
9797 });
9798 }
9799 if action_id == "create_booking" && !app_uses_file_backed_runtime_state(app) {
9800 let result = (|| {
9801 let store = database::runtime_logical_app_store(app).map_err(|_| 503)?;
9802 let store = with_action_effects(app, request, action_id, store)?;
9803 let title = dynamic::request_form_value(request, "title").unwrap_or_default();
9804 let date = dynamic::request_form_value(request, "date").unwrap_or_default();
9805 let time = dynamic::request_form_value(request, "time").unwrap_or_default();
9806 business::create_personal_booking(
9807 store.adapter.as_ref(),
9808 &store.config,
9809 &store.schema,
9810 (active_session_user_id(app, request.cookie.as_deref()))
9811 .map_err(database_operation_http_status)?,
9812 &title,
9813 &date,
9814 &time,
9815 )
9816 .map_err(|error| match error {
9817 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
9818 pharos_runtime_database::operations::DatabaseError::Conflict => 409,
9819 _ => 503,
9820 })
9821 })();
9822 return Some(match result {
9823 Ok(_) => {
9824 http_route_redirect_for_request(request, &app_scope_path(app, "/bookings/"))
9825 }
9826 Err(status) => http_text_response(
9827 status as u16,
9828 http::http_status_text(status as u16),
9829 "booking unavailable\n",
9830 ),
9831 });
9832 }
9833 if action_id == "run_selected_appointment_maintenance"
9834 && !app_uses_file_backed_runtime_state(app)
9835 {
9836 let result = (|| {
9837 let store = with_action_effects(
9838 app,
9839 request,
9840 action_id,
9841 app_database_operations(app).ok_or(503u16)?,
9842 )?;
9843 let chosen = |field| {
9844 dynamic::request_form_value(request, field)
9845 .is_some_and(|value| !value.trim().is_empty())
9846 };
9847 let results = business::maintain_appointments(
9848 store.adapter.as_ref(),
9849 &store.config,
9850 &store.schema,
9851 (active_session_user_id(app, request.cookie.as_deref()))
9852 .map_err(database_operation_http_status)?,
9853 chosen("dedupe_appointments"),
9854 chosen("reassign_appointments_to_technicians"),
9855 chosen("reconcile_future_appointments"),
9856 )
9857 .map_err(database_operation_http_status)?;
9858 if dynamic::request_is_hx(Some(request)) {
9859 let root = app.app_root.as_deref().map(Path::new).ok_or(503u16)?;
9860 let html = business::appointment_maintenance_result_html(root, &results)
9861 .ok_or(500u16)?;
9862 Ok(http_html_response(200, "OK", &html, method))
9863 } else {
9864 Ok(http_route_redirect_for_request(
9865 request,
9866 &app_scope_path(app, "/appointments/diagnose/"),
9867 ))
9868 }
9869 })();
9870 return Some(match result {
9871 Ok(response) => response,
9872 Err(status) => http_text_response(
9873 status,
9874 http::http_status_text(status),
9875 "appointment maintenance unavailable\n",
9876 ),
9877 });
9878 }
9879 if action_id == "update_profile" && !app_uses_file_backed_runtime_state(app) {
9880 let result = (|| {
9881 let store = database::runtime_logical_app_store(app).map_err(|_| 503)?;
9882 let store = with_action_effects(app, request, action_id, store)?;
9883 let mut input = Map::new();
9884 for name in ["full_name", "phone_number", "address", "payment_preference"] {
9885 if let Some(value) = dynamic::request_form_value(request, name) {
9886 input.insert(name.to_owned(), Value::String(value));
9887 }
9888 }
9889 if let Some(age) = dynamic::request_form_value(request, "age") {
9890 input.insert(
9891 "age".to_owned(),
9892 if age.is_empty() {
9893 Value::Null
9894 } else {
9895 Value::from(age.parse::<i64>().map_err(|_| 422)?)
9896 },
9897 );
9898 }
9899 pharos_runtime_database::account_tokens::update_account_profile(
9900 store.adapter.as_ref(),
9901 &store.config,
9902 &store.schema,
9903 (active_session_user_id(app, request.cookie.as_deref()))
9904 .map_err(|error| database_operation_http_status(error) as i32)?,
9905 &input,
9906 )
9907 .map_err(|error| match error {
9908 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
9909 pharos_runtime_database::operations::DatabaseError::Conflict => 409,
9910 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
9911 _ => 503,
9912 })
9913 })();
9914 return Some(match result {
9915 Ok(()) => {
9916 http_route_redirect_for_request(request, &app_scope_path(app, "/profile/"))
9917 }
9918 Err(status) => http_text_response(
9919 status as u16,
9920 http::http_status_text(status as u16),
9921 "profile update unavailable\n",
9922 ),
9923 });
9924 }
9925 if !app_uses_file_backed_runtime_state(app)
9926 && pharos_runtime_business::is_catalog_action(action_id)
9927 {
9928 return Some(business_catalog_action_response(
9929 app, request, path, action_id,
9930 ));
9931 }
9932 if !app_uses_file_backed_runtime_state(app)
9933 && pharos_runtime_business::is_business_identity_action(action_id)
9934 {
9935 return Some(business_identity_action_response(
9936 app, request, path, action_id,
9937 ));
9938 }
9939 if action_id == "password_reset_complete" && !app_uses_file_backed_runtime_state(app) {
9940 let token = path
9941 .strip_prefix("/accounts/password-reset/complete/")
9942 .and_then(|value| value.split('/').next())
9943 .unwrap_or_default();
9944 let password = dynamic::request_form_value(request, "password1")
9945 .or_else(|| dynamic::request_form_value(request, "password"))
9946 .unwrap_or_default();
9947 let confirmation = dynamic::request_form_value(request, "password2")
9948 .or_else(|| dynamic::request_form_value(request, "password_confirm"))
9949 .unwrap_or_default();
9950 if password != confirmation || password.len() < 8 {
9951 return Some(http_text_response(
9952 422,
9953 "Unprocessable Entity",
9954 "invalid request\n",
9955 ));
9956 }
9957 let password_hash = match dynamic::hash_password(&password) {
9958 Ok(value) => value,
9959 Err(_) => {
9960 return Some(http_text_response(
9961 500,
9962 "Internal Server Error",
9963 "credential unavailable\n",
9964 ));
9965 }
9966 };
9967
9968 let store = match database::runtime_logical_app_store(app) {
9969 Ok(value) => value,
9970 Err(_) => {
9971 return Some(http_text_response(
9972 503,
9973 "Service Unavailable",
9974 "database unavailable\n",
9975 ));
9976 }
9977 };
9978 let token_hash = dynamic::hex_digest(&Sha256::digest(token.as_bytes()));
9979 let claimed = pharos_runtime_database::account_tokens::complete_password_reset(
9980 store.adapter.as_ref(),
9981 &store.config,
9982 &store.schema,
9983 &token_hash,
9984 &password_hash,
9985 ¤t_timestamp_utc(),
9986 );
9987 match claimed {
9988 Ok(Some(user_id)) => {
9989 if let Ok(cache_scope) = app_security_cache_scope(app) {
9990 cache::session_cache_revoke_user(&cache_scope, user_id);
9991 pharos_runtime_security::local_session_cache_revoke_user(
9992 &cache_scope,
9993 user_id,
9994 );
9995 }
9996 return Some(app_fragment_response(
9997 app,
9998 "templates/registration/password_reset_complete_done.body.html",
9999 serde_json::json!({}),
10000 method,
10001 ));
10002 }
10003 Ok(None) => {
10004 return Some(http_text_response(
10005 404,
10006 "Not Found",
10007 "reset token unavailable\n",
10008 ));
10009 }
10010 Err(_) => {
10011 return Some(http_text_response(
10012 503,
10013 "Service Unavailable",
10014 "database unavailable\n",
10015 ));
10016 }
10017 }
10018 }
10019 if action_id == "verify_email" && !app_uses_file_backed_runtime_state(app) {
10020 let token = path
10021 .strip_prefix("/accounts/verify-email/")
10022 .and_then(|value| value.split('/').next())
10023 .unwrap_or_default();
10024
10025 let store = match database::runtime_logical_app_store(app) {
10026 Ok(value) => value,
10027 Err(_) => {
10028 return Some(http_text_response(
10029 503,
10030 "Service Unavailable",
10031 "database unavailable\n",
10032 ));
10033 }
10034 };
10035 let token_hash = dynamic::hex_digest(&Sha256::digest(token.as_bytes()));
10036 match pharos_runtime_database::account_tokens::complete_email_verification(
10037 store.adapter.as_ref(),
10038 &store.config,
10039 &store.schema,
10040 &token_hash,
10041 ¤t_timestamp_utc(),
10042 ) {
10043 Ok(Some(_)) => {
10044 return Some(http_route_redirect_for_request(
10045 request,
10046 &app_scope_path(app, path),
10047 ));
10048 }
10049 Ok(None) => {
10050 return Some(http_text_response(
10051 404,
10052 "Not Found",
10053 "verification token unavailable\n",
10054 ));
10055 }
10056 Err(_) => {
10057 return Some(http_text_response(
10058 503,
10059 "Service Unavailable",
10060 "database unavailable\n",
10061 ));
10062 }
10063 }
10064 }
10065 if action_id == "register_account" && !app_uses_file_backed_runtime_state(app) {
10066 let username = dynamic::request_form_value(request, "username").unwrap_or_default();
10067 let email = dynamic::request_form_value(request, "email").unwrap_or_default();
10068 let password = dynamic::request_form_value(request, "password").unwrap_or_default();
10069 let confirmation =
10070 dynamic::request_form_value(request, "password_confirm").unwrap_or_default();
10071 if password.len() < 8
10072 || password != confirmation
10073 || !json_commands::generated_surface_text_is_email(&email)
10074 {
10075 return Some(http_text_response(
10076 422,
10077 "Unprocessable Entity",
10078 "invalid request\n",
10079 ));
10080 }
10081 let password_hash = match dynamic::hash_password(&password) {
10082 Ok(value) => value,
10083 Err(_) => {
10084 return Some(http_text_response(
10085 500,
10086 "Internal Server Error",
10087 "credential unavailable\n",
10088 ));
10089 }
10090 };
10091
10092 let store = match database::runtime_logical_app_store(app) {
10093 Ok(value) => value,
10094 Err(_) => {
10095 return Some(http_text_response(
10096 503,
10097 "Service Unavailable",
10098 "database unavailable\n",
10099 ));
10100 }
10101 };
10102 match pharos_runtime_database::account_tokens::register_account(
10103 store.adapter.as_ref(),
10104 &store.config,
10105 &store.schema,
10106 &username,
10107 &email,
10108 &password_hash,
10109 ) {
10110 Ok(_) => {
10111 return Some(http_route_redirect_for_request(
10112 request,
10113 &app_scope_path(app, path),
10114 ));
10115 }
10116 Err(pharos_runtime_database::operations::DatabaseError::InvalidInput) => {
10117 return Some(http_text_response(
10118 422,
10119 "Unprocessable Entity",
10120 "invalid request\n",
10121 ));
10122 }
10123 Err(pharos_runtime_database::operations::DatabaseError::Conflict) => {
10124 return Some(http_text_response(
10125 409,
10126 "Conflict",
10127 "account already exists\n",
10128 ));
10129 }
10130 Err(_) => {
10131 return Some(http_text_response(
10132 503,
10133 "Service Unavailable",
10134 "database unavailable\n",
10135 ));
10136 }
10137 }
10138 }
10139 if !app_uses_file_backed_runtime_state(app)
10140 && dynamic::is_declarative_document_action(app, action_id)
10141 {
10142 return Some(
10143 match dynamic::dynamic_execute_declarative_action(
10144 app,
10145 app.state_path.as_deref().map(Path::new),
10146 request,
10147 action_id,
10148 ) {
10149 Ok(dynamic::DeclarativeActionResponse::Redirect(location)) => {
10150 http_route_redirect_for_request(request, &app_scope_path(app, &location))
10151 }
10152 Ok(dynamic::DeclarativeActionResponse::Json(status, body)) => {
10153 http_json_response(
10154 status,
10155 http_reason_phrase(status),
10156 &body.to_string(),
10157 method,
10158 )
10159 }
10160 Err(status) => http_text_response(
10161 status as u16,
10162 http::http_status_text(status as u16),
10163 "invalid request\n",
10164 ),
10165 },
10166 );
10167 }
10168 if app_uses_file_backed_runtime_state(app)
10169 && let (Some(action_id), Some(state_path)) = (
10170 route.get("action_id").and_then(Value::as_str),
10171 app.state_path.as_deref(),
10172 )
10173 {
10174 let mut maintenance_results: Option<Vec<String>> = None;
10175 let result = match action_id {
10176 "register_account" => {
10177 dynamic::handle_register_account_action(Path::new(state_path), request)
10178 .map(|_| ())
10179 }
10180 "create_booking" => (|| {
10181 let title = dynamic::request_form_value(request, "title")
10182 .map(|value| value.trim().to_owned())
10183 .filter(|value| !value.is_empty())
10184 .ok_or(422)?;
10185 let date = dynamic::request_form_value(request, "date")
10186 .map(|value| value.trim().to_owned())
10187 .filter(|value| json_commands::generated_surface_text_is_date(value))
10188 .ok_or(422)?;
10189 let time = dynamic::request_form_value(request, "time")
10190 .map(|value| value.trim().to_owned())
10191 .filter(|value| json_commands::generated_surface_text_is_time(value))
10192 .map(|value| value.get(0..5).unwrap_or("").to_owned())
10193 .filter(|value| !value.is_empty())
10194 .ok_or(422)?;
10195 let mut root =
10196 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10197 let booking_id = root
10198 .get("bookings")
10199 .and_then(Value::as_array)
10200 .and_then(|rows| {
10201 rows.iter()
10202 .filter_map(|row| row.get("id").and_then(Value::as_i64))
10203 .max()
10204 })
10205 .unwrap_or(0)
10206 + 1;
10207 let rows = root
10208 .as_object_mut()
10209 .and_then(|object| {
10210 object
10211 .entry("bookings".to_owned())
10212 .or_insert_with(|| Value::Array(Vec::new()))
10213 .as_array_mut()
10214 })
10215 .ok_or(500)?;
10216 rows.push(serde_json::json!({
10217 "id": booking_id,
10218 "user_id": (active_session_user_id(app, request.cookie.as_deref())).map_err(|error| database_operation_http_status(error) as i32)?,
10219 "title": title,
10220 "date": date,
10221 "time": time,
10222 "status": "confirmed"
10223 }));
10224 business::persist_management_state_or_500_local(&root, Path::new(state_path))
10225 })(),
10226 "accept_invite" => {
10227 let token = path
10228 .strip_prefix("/accounts/invite/")
10229 .and_then(|value| value.split('/').next())
10230 .unwrap_or_default();
10231 (|| {
10232 let mut form = serde_json::Map::new();
10233 form.insert("token_hash".to_owned(), Value::String(token.to_owned()));
10234 form.insert("user_id".to_owned(), Value::from(1_i64));
10235 let mut root =
10236 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10237 let result = business::dynamic_handle_business_identity_action(
10238 &mut root,
10239 "accept_invite",
10240 &form,
10241 )
10242 .ok_or(404)?;
10243 result.and_then(|_| {
10244 business::persist_management_state_or_500_local(
10245 &root,
10246 Path::new(state_path),
10247 )
10248 })
10249 })()
10250 }
10251 "run_selected_appointment_maintenance" => {
10252 let dedupe = dynamic::request_form_value(request, "dedupe_appointments")
10253 .is_some_and(|value| !value.trim().is_empty());
10254 let reassign = dynamic::request_form_value(
10255 request,
10256 "reassign_appointments_to_technicians",
10257 )
10258 .is_some_and(|value| !value.trim().is_empty());
10259 let reconcile =
10260 dynamic::request_form_value(request, "reconcile_future_appointments")
10261 .is_some_and(|value| !value.trim().is_empty());
10262 (|| {
10263 let mut root =
10264 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10265 let results = business::run_selected_appointment_maintenance(
10266 &mut root, dedupe, reassign, reconcile,
10267 );
10268 maintenance_results = Some(results);
10269 business::persist_management_state_or_500_local(
10270 &root,
10271 Path::new(state_path),
10272 )
10273 })()
10274 }
10275 "create_appointment" | "create_internal_appointment" => (|| {
10276 let mut form = serde_json::Map::new();
10277 for field in [
10278 "csrf_token",
10279 "business_id",
10280 "service_id",
10281 "booking_option_index",
10282 "technician_id",
10283 "date",
10284 "time",
10285 "notes",
10286 ] {
10287 if let Some(value) = dynamic::request_form_value(request, field) {
10288 form.insert(field.to_owned(), Value::String(value));
10289 }
10290 }
10291 if !form.contains_key("business_id") {
10292 let slug = path
10293 .strip_prefix("/business/")
10294 .and_then(|value| value.split('/').next())
10295 .or_else(|| {
10296 path.strip_prefix("/businesses/")
10297 .and_then(|value| value.split('/').next())
10298 })
10299 .unwrap_or_default();
10300 let root =
10301 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10302 let business_id = root
10303 .get("businesses")
10304 .and_then(Value::as_array)
10305 .and_then(|rows| {
10306 rows.iter().find(|row| {
10307 row.get("slug").and_then(Value::as_str) == Some(slug)
10308 })
10309 })
10310 .and_then(|row| row.get("id").and_then(Value::as_i64))
10311 .unwrap_or(0);
10312 form.insert("business_id".to_owned(), Value::from(business_id));
10313 }
10314 form.insert(
10315 "user_id".to_owned(),
10316 Value::from(
10317 (active_session_user_id(app, request.cookie.as_deref()))
10318 .map_err(|error| database_operation_http_status(error) as i32)?,
10319 ),
10320 );
10321 let mut root =
10322 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10323 let result = business::dynamic_handle_business_booking_action(
10324 &mut root, action_id, &form,
10325 )
10326 .ok_or(404)?;
10327 result.and_then(|_| {
10328 business::persist_management_state_or_500_local(
10329 &root,
10330 Path::new(state_path),
10331 )
10332 })
10333 })(),
10334 "assign_appointment_technician" => (|| {
10335 let mut form = serde_json::Map::new();
10336 for field in ["csrf_token", "appointment_id", "technician_id"] {
10337 if let Some(value) = dynamic::request_form_value(request, field) {
10338 form.insert(field.to_owned(), Value::String(value));
10339 }
10340 }
10341 if !form.contains_key("appointment_id")
10342 && let Some(id) = path
10343 .strip_prefix("/appointments/")
10344 .and_then(|value| value.split('/').next())
10345 .and_then(|value| value.parse::<i64>().ok())
10346 {
10347 form.insert("appointment_id".to_owned(), Value::from(id));
10348 }
10349 let mut root =
10350 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10351 let result = business::dynamic_handle_business_booking_action(
10352 &mut root, action_id, &form,
10353 )
10354 .ok_or(404)?;
10355 result.and_then(|_| {
10356 business::persist_management_state_or_500_local(
10357 &root,
10358 Path::new(state_path),
10359 )
10360 })
10361 })(),
10362 "create_business"
10363 | "update_business"
10364 | "toggle_business_public"
10365 | "delete_business"
10366 | "subscribe_business"
10367 | "unsubscribe_business"
10368 | "rate_business"
10369 | "create_service"
10370 | "update_service"
10371 | "delete_service"
10372 | "restore_service"
10373 | "create_technician"
10374 | "update_technician"
10375 | "delete_technician"
10376 | "restore_technician"
10377 | "create_business_availability"
10378 | "update_business_availability"
10379 | "delete_business_availability"
10380 | "create_technician_availability"
10381 | "update_technician_availability"
10382 | "delete_technician_availability" => Ok(()),
10383 _ => dynamic::dynamic_execute_declarative_action(
10384 app,
10385 Some(Path::new(state_path)),
10386 request,
10387 action_id,
10388 )
10389 .map(|_| ()),
10390 };
10391 if let Err(status) = result {
10392 return Some(http_text_response(
10393 status as u16,
10394 http::http_status_text(status as u16),
10395 "invalid request\n",
10396 ));
10397 }
10398 if action_id == "create_booking" {
10399 return Some(http_route_redirect_for_request(
10400 request,
10401 &app_scope_path(app, "/bookings/"),
10402 ));
10403 }
10404 if action_id == "password_reset_complete" {
10405 return Some(app_fragment_response(
10406 app,
10407 "templates/registration/password_reset_complete_done.body.html",
10408 serde_json::json!({}),
10409 method,
10410 ));
10411 }
10412 if action_id == "run_selected_appointment_maintenance" {
10413 let Some(app_root) = app.app_root.as_deref() else {
10414 return Some(http_text_response(
10415 500,
10416 "Internal Server Error",
10417 "missing app root\n",
10418 ));
10419 };
10420 let Some(html) = business::appointment_maintenance_result_html(
10421 Path::new(app_root),
10422 maintenance_results.as_deref().unwrap_or(&[]),
10423 ) else {
10424 return Some(http_text_response(
10425 500,
10426 "Internal Server Error",
10427 "missing maintenance template\n",
10428 ));
10429 };
10430 if dynamic::request_is_hx(Some(request)) {
10431 return Some(http_html_response(200, "OK", &html, method));
10432 }
10433 let diagnostics_path = app
10434 .base_path
10435 .as_deref()
10436 .filter(|value| !value.is_empty() && *value != "/")
10437 .map(|base| format!("{}/appointments/diagnose/", base.trim_end_matches('/')))
10438 .unwrap_or_else(|| "/appointments/diagnose/".to_owned());
10439 return Some(http_route_redirect(&diagnostics_path));
10440 }
10441 if action_id == "assign_appointment_technician" {
10442 let redirect = dynamic::request_form_value(request, "return_to")
10443 .filter(|value| value.starts_with('/') && !value.starts_with("//"))
10444 .unwrap_or_else(|| "/appointments/".to_owned());
10445 let redirect = app
10446 .base_path
10447 .as_deref()
10448 .filter(|base| !base.is_empty() && *base != "/")
10449 .filter(|base| redirect.starts_with(base.trim_end_matches('/')))
10450 .map(|_| redirect.clone())
10451 .unwrap_or_else(|| app_scope_path(app, &redirect));
10452 return Some(http_route_redirect_for_request(request, &redirect));
10453 }
10454 if matches!(
10455 action_id,
10456 "create_appointment" | "create_internal_appointment"
10457 ) {
10458 let redirect = dynamic::request_form_value(request, "return_to")
10459 .filter(|value| value.starts_with('/') && !value.starts_with("//"))
10460 .unwrap_or_else(|| "/appointments/".to_owned());
10461 let redirect = app
10462 .base_path
10463 .as_deref()
10464 .filter(|base| !base.is_empty() && *base != "/")
10465 .filter(|base| redirect.starts_with(base.trim_end_matches('/')))
10466 .map(|_| redirect.clone())
10467 .unwrap_or_else(|| app_scope_path(app, &redirect));
10468 return Some(http_route_redirect_for_request(request, &redirect));
10469 }
10470 if matches!(
10471 action_id,
10472 "create_business"
10473 | "update_business"
10474 | "toggle_business_public"
10475 | "delete_business"
10476 | "subscribe_business"
10477 | "unsubscribe_business"
10478 | "rate_business"
10479 ) {
10480 let invite_token = (action_id == "issue_business_invite")
10481 .then(|| dynamic::random_secret_hex(32).ok())
10482 .flatten();
10483 if action_id == "issue_business_invite" && invite_token.is_none() {
10484 return Some(http_text_response(
10485 500,
10486 "Internal Server Error",
10487 "token unavailable\n",
10488 ));
10489 }
10490 let action_result: Result<(), i32> = (|| {
10491 let mut form = serde_json::Map::new();
10492 for field in [
10493 "csrf_token",
10494 "user_id",
10495 "business_id",
10496 "name",
10497 "address",
10498 "description",
10499 "is_public",
10500 "time_format",
10501 "secondary_color",
10502 "business_edit_secondary_color_hex",
10503 "image_url",
10504 "remove_logo",
10505 "email",
10506 "message",
10507 "score",
10508 "comment",
10509 "token_hash",
10510 ] {
10511 if let Some(value) = dynamic::request_form_value(request, field) {
10512 form.insert(field.to_owned(), Value::String(value));
10513 }
10514 }
10515 let mut root =
10516 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10517 form.insert(
10518 "user_id".to_owned(),
10519 Value::from(
10520 (active_session_user_id(app, request.cookie.as_deref()))
10521 .map_err(|error| database_operation_http_status(error) as i32)?,
10522 ),
10523 );
10524 if matches!(action_id, "create_business" | "update_business")
10525 && let Some(content_type) = request.content_type.as_deref()
10526 {
10527 let fallback_logo_filename =
10528 dynamic::request_form_value(request, "business_slug")
10529 .or_else(|| dynamic::request_form_value(request, "name"));
10530 for upload_field in ["logo", "business_logo"] {
10531 let uploaded_media_url =
10532 if let Some(body_path) = request.body_file_path.as_deref() {
10533 json_commands::dynamic_store_uploaded_media(
10534 Path::new(app.artifact_dir.as_deref().unwrap_or_default()),
10535 content_type,
10536 Path::new(body_path),
10537 upload_field,
10538 "/media/businesses",
10539 fallback_logo_filename.as_deref(),
10540 )
10541 } else {
10542 json_commands::dynamic_store_uploaded_media_from_body(
10543 Path::new(app.artifact_dir.as_deref().unwrap_or_default()),
10544 content_type,
10545 &request.body,
10546 upload_field,
10547 "/media/businesses",
10548 fallback_logo_filename.as_deref(),
10549 )
10550 };
10551 if let Some(uploaded_media_url) = uploaded_media_url {
10552 form.insert(
10553 "image_url".to_owned(),
10554 Value::String(uploaded_media_url),
10555 );
10556 break;
10557 }
10558 }
10559 }
10560 if let Some(token) = invite_token.as_deref() {
10561 form.insert(
10562 "token_hash".to_owned(),
10563 Value::String(dynamic::hex_digest(&Sha256::digest(token.as_bytes()))),
10564 );
10565 }
10566 if !form.contains_key("business_id") {
10567 let slug = path
10568 .strip_prefix("/businesses/")
10569 .and_then(|value| value.split('/').next())
10570 .or_else(|| {
10571 path.strip_prefix("/rate/")
10572 .and_then(|value| value.split('/').next())
10573 })
10574 .unwrap_or_default();
10575 if !slug.is_empty() {
10576 form.insert("business_slug".to_owned(), Value::String(slug.to_owned()));
10577 }
10578 let business_id = root
10579 .get("businesses")
10580 .and_then(Value::as_array)
10581 .and_then(|rows| {
10582 rows.iter().find(|row| {
10583 row.get("slug").and_then(Value::as_str) == Some(slug)
10584 })
10585 })
10586 .and_then(|row| row.get("id").and_then(Value::as_i64))
10587 .unwrap_or(0);
10588 if business_id > 0 {
10589 form.insert("business_id".to_owned(), Value::from(business_id));
10590 }
10591 }
10592 let result = business::dynamic_handle_business_identity_action(
10593 &mut root, action_id, &form,
10594 )
10595 .ok_or(404)?;
10596 result.and_then(|_| {
10597 if let Some(token) = invite_token.as_deref() {
10598 root.as_object_mut()
10599 .and_then(|object| {
10600 object
10601 .entry("notification_deliveries")
10602 .or_insert_with(|| Value::Array(Vec::new()))
10603 .as_array_mut()
10604 })
10605 .ok_or(500)?
10606 .push(serde_json::json!({
10607 "delivery_kind": "business_invite",
10608 "recipient": dynamic::request_form_value(request, "email").unwrap_or_default(),
10609 "secret": token,
10610 "delivery_status": "queued"
10611 }));
10612 }
10613 business::persist_management_state_or_500_local(
10614 &root,
10615 Path::new(state_path),
10616 )
10617 })
10618 })();
10619 if let Err(status) = action_result {
10620 return Some(http_text_response(
10621 status as u16,
10622 http::http_status_text(status as u16),
10623 if action_id.contains("availability") {
10624 "Validation failed\n"
10625 } else {
10626 "invalid request\n"
10627 },
10628 ));
10629 }
10630 if invite_token.is_some() {
10631 let invite_page_json = business::dynamic_invite_created_page_params_json(
10632 "The invite was created and queued for delivery.",
10633 "",
10634 &app_scope_path(app, path),
10635 )?;
10636 return dynamic::render_declared_page_response(
10637 Path::new(app.artifact_dir.as_deref()?),
10638 "invite_created",
10639 &invite_page_json,
10640 )
10641 .ok()
10642 .map(String::into_bytes);
10643 }
10644 if action_id == "create_business"
10645 && let Some(name) = dynamic::request_form_value(request, "name")
10646 && let Some(slug) = business::business_slug_from_name_identity_local(&name)
10647 {
10648 let redirect = app_scope_path(app, &format!("/businesses/{slug}/"));
10649 return Some(http_route_redirect_for_request(request, &redirect));
10650 }
10651 if action_id == "rate_business"
10652 && let Some(slug) = path
10653 .strip_prefix("/rate/")
10654 .and_then(|value| value.split('/').next())
10655 .filter(|value| !value.is_empty())
10656 {
10657 let redirect = app_scope_path(app, &format!("/b/{slug}/"));
10658 return Some(http_route_redirect_for_request(request, &redirect));
10659 }
10660 }
10661 if matches!(
10662 action_id,
10663 "create_service"
10664 | "update_service"
10665 | "delete_service"
10666 | "restore_service"
10667 | "create_technician"
10668 | "update_technician"
10669 | "delete_technician"
10670 | "restore_technician"
10671 ) {
10672 let slug = path
10673 .strip_prefix("/businesses/")
10674 .and_then(|value| value.split('/').next())
10675 .unwrap_or_default();
10676 let management_result: Result<(), i32> = (|| {
10677 let mut form = serde_json::Map::new();
10678 for field in [
10679 "csrf_token",
10680 "business_id",
10681 "service_id",
10682 "technician_id",
10683 "name",
10684 "email",
10685 "description",
10686 "cost",
10687 "duration_minutes",
10688 "sort_order",
10689 "image_url",
10690 "remove_image",
10691 "primary_color",
10692 "booking_option_count",
10693 ] {
10694 if let Some(value) = dynamic::request_form_value(request, field) {
10695 form.insert(field.to_owned(), Value::String(value));
10696 }
10697 }
10698 let mut root =
10699 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10700 if !form.contains_key("business_id") {
10701 let business_id = root
10702 .get("businesses")
10703 .and_then(Value::as_array)
10704 .and_then(|rows| {
10705 rows.iter().find(|row| {
10706 row.get("slug").and_then(Value::as_str) == Some(slug)
10707 })
10708 })
10709 .and_then(|row| row.get("id").and_then(Value::as_i64))
10710 .unwrap_or(0);
10711 form.insert("business_id".to_owned(), Value::from(business_id));
10712 }
10713 let business_id = form.get("business_id").and_then(Value::as_i64).unwrap_or(0);
10714 if !form.contains_key("service_id")
10715 && let Some(id) = path
10716 .split('/')
10717 .filter_map(|value| value.parse::<i64>().ok())
10718 .next_back()
10719 {
10720 form.insert("service_id".to_owned(), Value::from(id));
10721 }
10722 if let Some(option_count) = form
10723 .get("booking_option_count")
10724 .and_then(Value::as_str)
10725 .and_then(|value| value.parse::<usize>().ok())
10726 {
10727 for index in 1..=option_count.min(256) {
10728 for field_name in [
10729 format!("booking_option_label_{index}"),
10730 format!("booking_option_duration_{index}"),
10731 format!("booking_option_cost_{index}"),
10732 ] {
10733 if let Some(value) =
10734 dynamic::request_form_value(request, &field_name)
10735 {
10736 form.insert(field_name, Value::String(value));
10737 }
10738 }
10739 }
10740 }
10741 if business_id > 0
10742 && let Some(rows) = root.get("technicians").and_then(Value::as_array)
10743 {
10744 for technician_id in rows.iter().filter_map(|row| {
10745 (row.get("business_id").and_then(Value::as_i64) == Some(business_id))
10746 .then(|| row.get("id").and_then(Value::as_i64))
10747 .flatten()
10748 }) {
10749 let field_name = format!("technician_{technician_id}");
10750 if let Some(value) = dynamic::request_form_value(request, &field_name) {
10751 form.insert(field_name, Value::String(value));
10752 }
10753 }
10754 }
10755 if !form.contains_key("technician_id")
10756 && let Some(id) = path
10757 .split("/technicians/")
10758 .nth(1)
10759 .and_then(|value| value.split('/').next())
10760 .and_then(|value| value.parse::<i64>().ok())
10761 {
10762 form.insert("technician_id".to_owned(), Value::from(id));
10763 }
10764 let result = business::dynamic_handle_business_management_action(
10765 &mut root, action_id, &form,
10766 )
10767 .ok_or(404)?;
10768 result.and_then(|_| {
10769 business::persist_management_state_or_500_local(
10770 &root,
10771 Path::new(state_path),
10772 )
10773 })
10774 })();
10775 if let Err(status) = management_result {
10776 return Some(http_text_response(
10777 status as u16,
10778 http::http_status_text(status as u16),
10779 if action_id.contains("availability") {
10780 "Validation failed\n"
10781 } else {
10782 "invalid request\n"
10783 },
10784 ));
10785 }
10786 if action_id == "create_technician"
10787 && dynamic::request_is_hx(Some(request))
10788 && let Some(app_root) = app.app_root.as_deref()
10789 && let Some(state_path) = app.state_path.as_deref()
10790 && let Some(rows_html) = business::dynamic_business_admin_technician_rows_html(
10791 Path::new(state_path),
10792 slug,
10793 Path::new(app_root),
10794 )
10795 {
10796 return Some(http_html_response(200, "OK", &rows_html, method));
10797 }
10798 let redirect = if matches!(action_id, "create_service" | "create_technician") {
10799 format!("/businesses/{slug}/")
10800 } else {
10801 path.to_owned()
10802 };
10803 return Some(http_route_redirect(&redirect));
10804 }
10805 if matches!(
10806 action_id,
10807 "create_appointment"
10808 | "create_internal_appointment"
10809 | "create_business_availability"
10810 | "update_business_availability"
10811 | "delete_business_availability"
10812 | "create_technician_availability"
10813 | "update_technician_availability"
10814 | "delete_technician_availability"
10815 ) {
10816 let action_result: Result<(), i32> = (|| {
10817 let mut form = serde_json::Map::new();
10818 for field in [
10819 "day_of_week",
10820 "start_time",
10821 "end_time",
10822 "csrf_token",
10823 "availability_id",
10824 "technician_id",
10825 "business_id",
10826 "service_id",
10827 "booking_option_index",
10828 "user_id",
10829 "name",
10830 "email",
10831 "title",
10832 "date",
10833 "time",
10834 "notes",
10835 ] {
10836 if let Some(value) = dynamic::request_form_value(request, field) {
10837 form.insert(field.to_owned(), Value::String(value));
10838 }
10839 }
10840 let root =
10841 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
10842 if !form.contains_key("business_id") {
10843 let slug = path
10844 .strip_prefix("/businesses/")
10845 .and_then(|value| value.split('/').next())
10846 .or_else(|| {
10847 path.strip_prefix("/business/")
10848 .and_then(|value| value.split('/').next())
10849 })
10850 .unwrap_or_default();
10851 let business_id = root
10852 .get("businesses")
10853 .and_then(Value::as_array)
10854 .and_then(|rows| {
10855 rows.iter().find(|row| {
10856 row.get("slug").and_then(Value::as_str) == Some(slug)
10857 })
10858 })
10859 .and_then(|row| row.get("id").and_then(Value::as_i64))
10860 .unwrap_or(0);
10861 form.insert("business_id".to_owned(), Value::from(business_id));
10862 }
10863 if !form.contains_key("availability_id")
10864 && let Some(id) = path
10865 .split('/')
10866 .filter_map(|value| value.parse::<i64>().ok())
10867 .next_back()
10868 {
10869 form.insert("availability_id".to_owned(), Value::from(id));
10870 }
10871 if matches!(
10872 action_id,
10873 "create_appointment" | "create_internal_appointment"
10874 ) {
10875 form.insert(
10876 "user_id".to_owned(),
10877 Value::from(
10878 (active_session_user_id(app, request.cookie.as_deref())).map_err(
10879 |error| database_operation_http_status(error) as i32,
10880 )?,
10881 ),
10882 );
10883 }
10884 if !form.contains_key("technician_id")
10885 && let Some(id) = path
10886 .split("/technicians/")
10887 .nth(1)
10888 .and_then(|value| value.split('/').next())
10889 .and_then(|value| value.parse::<i64>().ok())
10890 {
10891 form.insert("technician_id".to_owned(), Value::from(id));
10892 }
10893 let mut root = root;
10894 let result = business::dynamic_handle_business_booking_action(
10895 &mut root, action_id, &form,
10896 )
10897 .ok_or(404)?;
10898 result.and_then(|_| {
10899 business::persist_management_state_or_500_local(
10900 &root,
10901 Path::new(state_path),
10902 )
10903 })
10904 })();
10905 if let Err(status) = action_result {
10906 return Some(http_text_response(
10907 status as u16,
10908 http::http_status_text(status as u16),
10909 if action_id.contains("availability") {
10910 "Validation failed\n"
10911 } else {
10912 "invalid request\n"
10913 },
10914 ));
10915 }
10916 if matches!(
10917 action_id,
10918 "create_technician_availability"
10919 | "update_technician_availability"
10920 | "delete_technician_availability"
10921 ) && dynamic::request_is_hx(Some(request))
10922 && let Some(app_root) = app.app_root.as_deref()
10923 {
10924 let slug = path
10925 .strip_prefix("/businesses/")
10926 .and_then(|value| value.split('/').next())
10927 .unwrap_or_default();
10928 if let Some(rows_html) = business::dynamic_business_admin_technician_rows_html(
10929 Path::new(state_path),
10930 slug,
10931 Path::new(app_root),
10932 ) {
10933 return Some(http_html_response(200, "OK", &rows_html, method));
10934 }
10935 }
10936 }
10937 }
10938 let redirect = app_scope_path(app, path);
10939 return Some(http_route_redirect_for_request(request, &redirect));
10940 }
10941 if route
10942 .get("auth_gate")
10943 .and_then(Value::as_str)
10944 .is_some_and(|gate| gate == "session_required")
10945 && render_request.actor_id <= 0
10946 {
10947 return Some(app_login_redirect(app, path));
10948 }
10949 if route.get("feature_id").and_then(Value::as_str).is_some()
10950 && let Some(policy) = route.get("policy_gate").and_then(Value::as_str)
10951 && policy != "public"
10952 && !match actor_has_route_policy(app, render_request.actor_id, policy) {
10953 Ok(value) => value,
10954 Err(error) => return Some(database_error_response(error)),
10955 }
10956 {
10957 return Some(http_text_response(403, "Forbidden", "capability denied\n"));
10958 }
10959 if route.get("route_id").and_then(Value::as_str) == Some("service_manage")
10960 && let Some(business_path) = path.strip_suffix("services/")
10961 {
10962 return Some(http_route_redirect_for_request(
10963 request,
10964 &app_scope_path(app, business_path),
10965 ));
10966 }
10967 if app.host_profile.as_deref() == Some("static-pages")
10968 && route
10969 .get("page_id")
10970 .and_then(Value::as_str)
10971 .is_none_or(str::is_empty)
10972 {
10973 let response = artifact_server::serve_app_request(app, method, path);
10974 return Some(http_static_response(response, method));
10975 }
10976 let actor_id = render_request.actor_id;
10977 let has_user = actor_id > 0;
10978 let managed_business_count = if has_user && actor_id > 0 {
10979 if app_uses_file_backed_runtime_state(app) {
10980 app.state_path
10981 .as_deref()
10982 .map(|state_path| {
10983 business::dynamic_managed_business_count_for_user(
10984 Path::new(state_path),
10985 actor_id,
10986 )
10987 })
10988 .unwrap_or(0)
10989 } else {
10990 let Some(store) = app_database_operations(app) else {
10991 return Some(http_text_response(
10992 503,
10993 "Service Unavailable",
10994 "app storage unavailable\n",
10995 ));
10996 };
10997 if ["business", "tenant", "tenant_membership"]
10998 .iter()
10999 .all(|name| {
11000 store
11001 .schema
11002 .entities
11003 .iter()
11004 .any(|entity| entity.name == *name)
11005 })
11006 {
11007 match pharos_runtime_business::managed_business_count(
11008 store.adapter.as_ref(),
11009 &store.config,
11010 &store.schema,
11011 actor_id,
11012 ) {
11013 Ok(count) => count,
11014 Err(error) => {
11015 let status = database_operation_http_status(error);
11016 return Some(http_text_response(
11017 status,
11018 http::http_status_text(status),
11019 "business scope unavailable\n",
11020 ));
11021 }
11022 }
11023 } else {
11024 0
11025 }
11026 }
11027 } else {
11028 0
11029 };
11030 let html = match app_route_page_html_result(
11031 app,
11032 &route,
11033 target,
11034 actor_id,
11035 has_user,
11036 managed_business_count,
11037 None,
11038 &render_request.csrf_token,
11039 ) {
11040 Ok(html) => html,
11041 Err(status) => {
11042 return Some(http_text_response(
11043 status,
11044 http::http_status_text(status),
11045 "declared app route unavailable\n",
11046 ));
11047 }
11048 };
11049 Some(http_html_response_with_csrf(
11050 200,
11051 "OK",
11052 &html,
11053 method,
11054 Some(&render_request.csrf_token),
11055 ))
11056}
11057
11058pub(crate) fn app_route_response(app: &AppRuntime, method: &str, target: &str) -> Vec<u8> {
11080 app_route_response_with_cookie(app, method, target, None)
11081}
11082
11083fn app_route_response_with_cookie(
11085 app: &AppRuntime,
11086 method: &str,
11087 target: &str,
11088 cookie: Option<&str>,
11089) -> Vec<u8> {
11090 let request = http::HttpRequest {
11091 method: method.to_owned(),
11092 target: target.to_owned(),
11093 cookie: cookie.map(ToOwned::to_owned),
11094 ..Default::default()
11095 };
11096 app_route_response_with_cookie_core(app, &request)
11097}
11098
11099fn app_route_response_with_request(app: &AppRuntime, request: &http::HttpRequest) -> Vec<u8> {
11101 if let Some(response) = pharos_runtime_static::navigation::mount_redirect(
11102 request,
11103 app.base_path.as_deref().unwrap_or_default(),
11104 ) {
11105 return response;
11106 }
11107 let path = app_relative_target(app, &request.target);
11108 let route = matched_app_route(app, &request.method, path.split('?').next().unwrap_or("/"));
11109 let response = app_route_response_with_cookie_core(app, request);
11110 let identity = sha256_hex(runtime_build_identity_value(app).to_string().as_bytes());
11111 let mut response = http_response_insert_header(response, "X-Pharos-Build-Identity", &identity);
11112 if let Some(end) = response.windows(4).position(|bytes| bytes == b"\r\n\r\n")
11113 && let Ok(head) = std::str::from_utf8(&response[..end])
11114 {
11115 let secure = dynamic::request_origin_scheme(
11116 Some(request),
11117 connection::NativeTransportType::Tcp,
11118 None,
11119 None,
11120 ) == "https";
11121 for (name, value) in pharos_runtime_static::browser_cache::deployment_headers(
11122 request,
11123 head,
11124 app.app_id.as_deref().unwrap_or_default(),
11125 app.base_path.as_deref().unwrap_or("/"),
11126 &identity,
11127 secure,
11128 ) {
11129 response = http_response_insert_header(response, name, &value);
11130 }
11131 }
11132 route.map_or(response.clone(), |route| {
11133 apply_route_response_middleware(&route, request, response)
11134 })
11135}
11136
11137fn ucal_issue_business_invite_response(
11138 app: &AppRuntime,
11139 request: &http::HttpRequest,
11140 _method: &str,
11141 path: &str,
11142) -> Vec<u8> {
11143 if let Err(response) = authorize_app_mutation(app, request, path) {
11144 return response;
11145 }
11146 if !dynamic::csrf_request_valid(request, dynamic::request_body_text(request).as_deref()) {
11147 return http_text_response(403, "Forbidden", "Security verification failed\n");
11148 }
11149 let email = dynamic::request_form_value(request, "email").unwrap_or_default();
11150 let account_type = dynamic::request_form_value(request, "account_type")
11151 .unwrap_or_else(|| "business_customer".to_owned());
11152 let role_hint = dynamic::request_form_value(request, "role_hint")
11153 .unwrap_or_else(|| "business_customer".to_owned());
11154 let token = match dynamic::random_secret_hex(32) {
11155 Ok(token) => token,
11156 Err(_) => return http_text_response(500, "Internal Server Error", "token unavailable\n"),
11157 };
11158 let token_hash = dynamic::hex_digest(&Sha256::digest(token.as_bytes()));
11159 let slug = path
11160 .strip_prefix("/businesses/")
11161 .and_then(|value| value.split('/').next())
11162 .unwrap_or_default();
11163 let actor_user_id = match active_session_user_id(app, request.cookie.as_deref()) {
11164 Ok(value) => value,
11165 Err(error) => return std::convert::identity(database_error_response(error)),
11166 };
11167 let expires_at = timestamp_after_seconds_utc(604_800);
11168 let stored = (|| -> Result<bool, u16> {
11169 let store =
11170 database::runtime_logical_app_store(app).map_err(database_operation_http_status)?;
11171 let store = with_action_effects(app, request, "issue_business_invite", store)?;
11172 business::issue_business_invite(
11173 store.adapter.as_ref(),
11174 &store.config,
11175 &store.schema,
11176 &business::BusinessInviteIssue {
11177 business_slug: slug,
11178 actor_user_id,
11179 email: &email,
11180 account_type: &account_type,
11181 role_hint: &role_hint,
11182 token_hash: &token_hash,
11183 delivery_secret: &token,
11184 expires_at: &expires_at,
11185 },
11186 )
11187 .map_err(database_operation_http_status)
11188 })();
11189 match stored {
11190 Ok(true) => {}
11191 Ok(false) => return http_text_response(422, "Unprocessable Entity", "invalid request\n"),
11192 Err(status) => {
11193 return http_text_response(status, http_reason_phrase(status), "invite unavailable\n");
11194 }
11195 }
11196 let return_path = app_scope_path(app, path);
11197 let Some(invite_page_json) = business::dynamic_invite_created_page_params_json(
11198 "The invite was created and queued for delivery.",
11199 "",
11200 &return_path,
11201 ) else {
11202 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11203 };
11204 let Ok(response) = dynamic::render_declared_page_response(
11205 Path::new(app.artifact_dir.as_deref().unwrap_or_default()),
11206 "invite_created",
11207 &invite_page_json,
11208 ) else {
11209 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11210 };
11211 response.into_bytes()
11212}
11213
11214fn ucal_invite_accept_page_response(
11215 app: &AppRuntime,
11216 request: &http::HttpRequest,
11217 path: &str,
11218) -> Vec<u8> {
11219 let token = path
11220 .strip_prefix("/accounts/invite/")
11221 .and_then(|value| value.split('/').next())
11222 .unwrap_or_default();
11223 let token_hash = dynamic::hex_digest(&Sha256::digest(token.as_bytes()));
11224 let Some(store) = app_database_operations(app) else {
11225 return http_text_response(503, "Service Unavailable", "database unavailable\n");
11226 };
11227 let invite = match business::business_invite_preview(
11228 store.adapter.as_ref(),
11229 &store.config,
11230 &store.schema,
11231 &token_hash,
11232 ¤t_timestamp_utc(),
11233 ) {
11234 Ok(Some(invite)) => invite,
11235 Ok(None) => return http_text_response(404, "Not Found", "invite unavailable\n"),
11236 Err(_) => return http_text_response(503, "Service Unavailable", "database unavailable\n"),
11237 };
11238 let Some(artifact_root) = app.artifact_dir.as_deref().map(Path::new) else {
11239 return http_text_response(500, "Internal Server Error", "missing app root\n");
11240 };
11241 let invite_input = |id: &str,
11242 input_type: &str,
11243 name: &str,
11244 label: &str,
11245 value: &str,
11246 autocomplete: &str,
11247 extra_attributes: &str| {
11248 let attributes = format!(" autocomplete=\"{autocomplete}\" required{extra_attributes}");
11249 app_ui_input(
11250 artifact_root,
11251 id,
11252 input_type,
11253 name,
11254 label,
11255 "",
11256 "form-control",
11257 value,
11258 &attributes,
11259 )
11260 };
11261 let (
11262 Some(id_invite_email_ui_html),
11263 Some(id_invite_username_ui_html),
11264 Some(id_invite_full_name_ui_html),
11265 Some(id_invite_phone_number_ui_html),
11266 Some(id_invite_password_ui_html),
11267 Some(id_invite_password_confirm_ui_html),
11268 Some(invite_create_account_button_ui_html),
11269 ) = (
11270 invite_input(
11271 "invite-email",
11272 "email",
11273 "email",
11274 "Email",
11275 &invite.email,
11276 "email",
11277 " readonly",
11278 ),
11279 invite_input(
11280 "invite-username",
11281 "text",
11282 "username",
11283 "Username",
11284 "",
11285 "username",
11286 "",
11287 ),
11288 invite_input(
11289 "invite-full-name",
11290 "text",
11291 "full_name",
11292 "Full name",
11293 "",
11294 "name",
11295 "",
11296 ),
11297 invite_input(
11298 "invite-phone-number",
11299 "tel",
11300 "phone_number",
11301 "Phone number",
11302 "",
11303 "tel",
11304 "",
11305 ),
11306 invite_input(
11307 "invite-password",
11308 "password",
11309 "password",
11310 "Password",
11311 "",
11312 "new-password",
11313 "",
11314 ),
11315 invite_input(
11316 "invite-password-confirm",
11317 "password",
11318 "password_confirm",
11319 "Confirm password",
11320 "",
11321 "new-password",
11322 "",
11323 ),
11324 app_ui_button(
11325 artifact_root,
11326 "invite-accept",
11327 "submit",
11328 "btn btn-primary",
11329 "",
11330 "Accept Invite",
11331 ),
11332 )
11333 else {
11334 return http_text_response(
11335 500,
11336 "Internal Server Error",
11337 "invite form controls failed to render\n",
11338 );
11339 };
11340 let csrf_token = dynamic::csrf_token_for_response(request.cookie.as_deref());
11341 let csrf_input_html = format!(
11342 "<input type=\"hidden\" name=\"csrf_token\" value=\"{}\" />",
11343 json_commands::html_escape_text(&csrf_token)
11344 );
11345 let params = serde_json::json!({
11346 "invite_kind": invite.invite_kind,
11347 "target_html": "",
11348 "error_html": "",
11349 "csrf_input_html": csrf_input_html,
11350 "form_action": app_scope_path(app, path),
11351 "id_invite_email_ui_html": id_invite_email_ui_html,
11352 "id_invite_username_ui_html": id_invite_username_ui_html,
11353 "id_invite_full_name_ui_html": id_invite_full_name_ui_html,
11354 "id_invite_phone_number_ui_html": id_invite_phone_number_ui_html,
11355 "id_invite_password_ui_html": id_invite_password_ui_html,
11356 "id_invite_password_confirm_ui_html": id_invite_password_confirm_ui_html,
11357 "invite_create_account_button_ui_html": invite_create_account_button_ui_html,
11358 });
11359 let Some(params_json) = serde_json::to_string(¶ms).ok() else {
11360 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11361 };
11362 let Ok(response) =
11363 dynamic::render_declared_page_response(artifact_root, "invite_accept", ¶ms_json)
11364 else {
11365 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11366 };
11367 let Some((_, rendered_body)) = response.split_once("\r\n\r\n") else {
11368 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11369 };
11370 http_html_response_with_csrf(200, "OK", rendered_body, "GET", Some(&csrf_token))
11371}
11372
11373fn ucal_accept_business_invite_response(
11374 app: &AppRuntime,
11375 request: &http::HttpRequest,
11376 _method: &str,
11377 path: &str,
11378) -> Vec<u8> {
11379 if let Err(response) = authorize_app_mutation(app, request, path) {
11380 return response;
11381 }
11382 let username = dynamic::request_form_value(request, "username").unwrap_or_default();
11383 let full_name = dynamic::request_form_value(request, "full_name").unwrap_or_default();
11384 let phone_number = dynamic::request_form_value(request, "phone_number").unwrap_or_default();
11385 let presented_token = path
11386 .strip_prefix("/accounts/invite/")
11387 .and_then(|value| value.split('/').next())
11388 .unwrap_or_default();
11389 let token_hash = dynamic::hex_digest(&Sha256::digest(presented_token.as_bytes()));
11390 let password = dynamic::request_form_value(request, "password").unwrap_or_default();
11391 let confirmation = dynamic::request_form_value(request, "password_confirm").unwrap_or_default();
11392 if username.is_empty() || password != confirmation || password.len() < 8 {
11393 return http_text_response(422, "Unprocessable Entity", "invalid request\n");
11394 }
11395 let password_hash = match dynamic::hash_password(&password) {
11396 Ok(hash) => hash,
11397 Err(_) => {
11398 return http_text_response(500, "Internal Server Error", "credential unavailable\n");
11399 }
11400 };
11401 let session_token = match dynamic::random_secret_hex(32) {
11402 Ok(token) => token,
11403 Err(_) => return http_text_response(500, "Internal Server Error", "session unavailable\n"),
11404 };
11405 let Some(session_hash) = native_session_token_hash(app, &session_token) else {
11406 return http_text_response(500, "Internal Server Error", "session unavailable\n");
11407 };
11408 let session_expires_at = timestamp_after_seconds_utc(604_800);
11409 let now = current_timestamp_utc();
11410 let Some(store) = app_database_operations(app) else {
11411 return http_text_response(503, "Service Unavailable", "database unavailable\n");
11412 };
11413 let store = match with_action_effects(app, request, "accept_invite", store) {
11414 Ok(store) => store,
11415 Err(status) => {
11416 return http_text_response(
11417 status,
11418 http::http_status_text(status),
11419 "invite effects unavailable\n",
11420 );
11421 }
11422 };
11423 match business::accept_business_invite(
11424 store.adapter.as_ref(),
11425 &store.config,
11426 &store.schema,
11427 &business::BusinessInviteAccept {
11428 token_hash: &token_hash,
11429 username: &username,
11430 full_name: &full_name,
11431 phone_number: &phone_number,
11432 password_hash: &password_hash,
11433 session_token_hash: &session_hash,
11434 session_expires_at: &session_expires_at,
11435 now: &now,
11436 },
11437 ) {
11438 Ok(Some(_)) => {}
11439 Ok(None) => return http_text_response(404, "Not Found", "invite unavailable\n"),
11440 Err(pharos_runtime_database::operations::DatabaseError::InvalidInput) => {
11441 return http_text_response(422, "Unprocessable Entity", "invalid request\n");
11442 }
11443 Err(pharos_runtime_database::operations::DatabaseError::Conflict) => {
11444 return http_text_response(409, "Conflict", "invite acceptance conflict\n");
11445 }
11446 Err(pharos_runtime_database::operations::DatabaseError::AccessDenied) => {
11447 return http_text_response(403, "Forbidden", "invite access denied\n");
11448 }
11449 Err(_) => return http_text_response(503, "Service Unavailable", "database unavailable\n"),
11450 }
11451 let continue_path = app_scope_path(app, "/");
11452 let Some(params_json) =
11453 business::dynamic_invite_accept_done_page_params_json(Some(&continue_path))
11454 else {
11455 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11456 };
11457 let Ok(response) = dynamic::render_declared_page_response(
11458 Path::new(app.artifact_dir.as_deref().unwrap_or_default()),
11459 "invite_accept_done",
11460 ¶ms_json,
11461 ) else {
11462 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11463 };
11464 let session_cookie =
11465 dynamic::session_cookie_header(app, Some(request), Some(&session_token), false);
11466 let Some((headers, body)) = response.split_once("\r\n\r\n") else {
11467 return http_text_response(500, "Internal Server Error", "response unavailable\n");
11468 };
11469 format!("{headers}\r\n{session_cookie}\r\n\r\n{body}").into_bytes()
11470}
11471
11472fn route_requires_state_mutation_guard(
11474 app: &AppRuntime,
11475 request_method: &str,
11476 route: Option<&Value>,
11477) -> bool {
11478 if !app_uses_file_backed_runtime_state(app) {
11479 return false;
11480 }
11481
11482 let action_id = route
11483 .and_then(|route| route.get("action_id"))
11484 .and_then(Value::as_str)
11485 .unwrap_or_default();
11486
11487 let provider_callback = app.app_root.as_deref().is_some_and(|app_root| {
11488 payment_provider::payment_provider_callback_action_declared(Path::new(app_root), action_id)
11489 });
11490 let provider_checkout_webhook = app.app_root.as_deref().is_some_and(|app_root| {
11491 payment_provider::payment_provider_checkout_webhook_action_declared(
11492 Path::new(app_root),
11493 action_id,
11494 )
11495 .is_some()
11496 });
11497
11498 if matches!(request_method, "GET" | "HEAD") && !provider_callback {
11503 return false;
11504 }
11505
11506 if provider_checkout_webhook {
11507 return true;
11508 }
11509
11510 if matches!(
11511 action_id,
11512 "architect_apply_preview_edit" | "issue_business_invite" | "accept_invite"
11513 ) {
11514 return false;
11515 }
11516 true
11517}
11518
11519fn app_response_body_budget(app: &AppRuntime, path: &str, method: &str, response: &[u8]) -> usize {
11521 let ordinary = app.resource_budgets.max_response_bytes;
11522 if app.app_id.as_deref() != Some("architect") || path != "/exports/download" || method != "GET"
11523 {
11524 return ordinary;
11525 }
11526 let headers = response
11527 .windows(4)
11528 .take(16 * 1024)
11529 .position(|bytes| bytes == b"\r\n\r\n")
11530 .and_then(|end| std::str::from_utf8(&response[..end]).ok());
11531 let archive = headers.is_some_and(|headers| {
11532 headers.starts_with("HTTP/1.1 200 ")
11533 && headers
11534 .lines()
11535 .any(|line| line.eq_ignore_ascii_case("Content-Type: application/x-tar"))
11536 && headers
11537 .lines()
11538 .any(|line| line.starts_with("Content-Disposition: attachment;"))
11539 });
11540 if !archive {
11541 return ordinary;
11542 }
11543 ordinary.max(
11544 pharos_runtime_architect::source_bundle::MAX_SOURCE_EXPORT_BYTES
11545 .saturating_add(
11546 pharos_runtime_architect::source_bundle::MAX_SOURCE_EXPORT_FILES
11547 .saturating_mul(4096),
11548 )
11549 .saturating_add(1024),
11550 )
11551}
11552
11553fn app_route_response_with_cookie_core(app: &AppRuntime, request: &http::HttpRequest) -> Vec<u8> {
11555 warm_runtime_config_allowlist(app);
11556 let render_request = match app_render_request(app, request) {
11557 Ok(value) => value,
11558 Err(error) => return std::convert::identity(database_error_response(error)),
11559 };
11560 let relative_target = render_request.relative_target.as_str();
11561 let path = render_request.relative_path.as_str();
11562 #[cfg(not(target_os = "wasi"))]
11563 if let Some(response) = social_connections::response(app, request, relative_target) {
11564 return response;
11565 }
11566 if request.method == "GET" && path.starts_with("/accounts/invite/") && path.ends_with('/') {
11567 return ucal_invite_accept_page_response(app, request, path);
11568 }
11569 if request.method == "POST" {
11570 let mut relative_request = request.clone();
11571 relative_request.target = relative_target.to_owned();
11572 if path.starts_with("/businesses/") && path.ends_with("/invites/new/") {
11573 return ucal_issue_business_invite_response(
11574 app,
11575 &relative_request,
11576 &request.method,
11577 path,
11578 );
11579 }
11580 if path.starts_with("/accounts/invite/") {
11581 return ucal_accept_business_invite_response(
11582 app,
11583 &relative_request,
11584 &request.method,
11585 path,
11586 );
11587 }
11588 }
11589 if request.method == "POST" && path.starts_with("/api/businesses/") {
11590 let mut relative_request = request.clone();
11591 relative_request.target = relative_target.to_owned();
11592 if let Some(response) =
11593 ucal_generated_api_mutation_response(app, &relative_request, &request.method, path)
11594 {
11595 return response;
11596 }
11597 }
11598 let route = matched_app_route(app, &request.method, path);
11599 let action_id = route
11600 .as_ref()
11601 .and_then(|route| route.get("action_id"))
11602 .and_then(Value::as_str)
11603 .unwrap_or_default();
11604 let route_is_payment_provider_callback = app.app_root.as_deref().is_some_and(|app_root| {
11605 payment_provider::payment_provider_callback_action_declared(Path::new(app_root), action_id)
11606 });
11607 let route_is_payment_provider_checkout_webhook = app
11608 .app_root
11609 .as_deref()
11610 .and_then(|app_root| {
11611 payment_provider::payment_provider_checkout_webhook_action_declared(
11612 Path::new(app_root),
11613 action_id,
11614 )
11615 })
11616 .or_else(|| {
11617 app.artifact_dir.as_deref().and_then(|artifact_dir| {
11618 payment_provider::payment_provider_checkout_webhook_action_declared(
11619 Path::new(artifact_dir),
11620 action_id,
11621 )
11622 })
11623 })
11624 .is_some();
11625 if (!matches!(request.method.as_str(), "GET" | "HEAD") || route_is_payment_provider_callback)
11626 && !route_is_payment_provider_checkout_webhook
11627 && let Err(response) = authorize_app_mutation(app, request, path)
11628 {
11629 return response;
11630 }
11631 let _state_guard = if app_uses_file_backed_runtime_state(app)
11632 && route_requires_state_mutation_guard(app, &request.method, route.as_ref())
11633 {
11634 match app.state_path.as_deref() {
11635 Some(state_path) => {
11636 match state_store::StateMutationGuard::acquire(Path::new(state_path)) {
11637 Ok(guard) => Some(guard),
11638 Err(_) => {
11639 return http_text_response(
11640 503,
11641 "Service Unavailable",
11642 "state mutation unavailable\n",
11643 );
11644 }
11645 }
11646 }
11647 None => {
11648 return http_text_response(
11649 503,
11650 "Service Unavailable",
11651 "state storage unavailable\n",
11652 );
11653 }
11654 }
11655 } else {
11656 None
11657 };
11658 let response = app_route_response_with_cookie_unlocked(app, request);
11659 if response.len()
11660 > app_response_body_budget(app, path, &request.method, &response).saturating_add(64 * 1024)
11661 {
11662 return http_text_response(500, "Internal Server Error", "response budget exceeded\n");
11663 }
11664 response
11665}
11666
11667fn payment_provider_checkout_action_response(
11669 app: &AppRuntime,
11670 request: &http::HttpRequest,
11671 action_id: &str,
11672 provider_id: &str,
11673) -> Option<Vec<u8>> {
11674 let result = (|| {
11675 let app_root = app.app_root.as_deref().map(Path::new).ok_or(503)?;
11676 let form_fields = [
11677 "date",
11678 "time",
11679 "time_hour",
11680 "time_minute",
11681 "time_period",
11682 "notes",
11683 "technician_id",
11684 "booking_option_index",
11685 ]
11686 .into_iter()
11687 .filter_map(|field| {
11688 dynamic::request_form_value(request, field).map(|value| (field.to_owned(), value))
11689 })
11690 .collect();
11691 let checkout_request = payment_provider::PaymentProviderCheckoutRequest {
11692 actor_user_id: (active_session_user_id(app, request.cookie.as_deref()))
11693 .map_err(|error| database_operation_http_status(error) as i32)?,
11694 idempotency_key: dynamic::request_form_value(request, "idempotency_key")
11695 .unwrap_or_default(),
11696 request_target: request.target.clone(),
11697 form_fields,
11698 };
11699 if !app_uses_file_backed_runtime_state(app) {
11700 let store = app_database_operations(app).ok_or(503)?;
11701 let completion = with_action_effects(
11702 app,
11703 request,
11704 action_id,
11705 database::LogicalAppStore {
11706 adapter: store.adapter.clone(),
11707 config: store.config.clone(),
11708 schema: store.schema.clone(),
11709 },
11710 )
11711 .map_err(|status| status as i32)?;
11712 return payment_provider::database_checkout(
11713 app_root,
11714 app.runtime_conf_path.as_deref().map(Path::new),
11715 &store,
11716 completion.adapter.as_ref(),
11717 provider_id,
11718 checkout_request,
11719 );
11720 }
11721 let state_path = app.state_path.as_deref().map(Path::new).ok_or(503)?;
11722 let _guard = state_store::StateMutationGuard::acquire(state_path).map_err(|_| 503)?;
11723 let mut root = json_commands::json_doc_load_file(state_path).ok_or(404)?;
11724 let checkout = payment_provider::payment_provider_checkout_start(
11725 &mut root,
11726 app_root,
11727 app.runtime_conf_path.as_deref().map(Path::new),
11728 provider_id,
11729 checkout_request,
11730 )?;
11731 if checkout.outcome == payment_provider::PaymentProviderCheckoutOutcome::Redirect {
11732 payment_provider::persist_payment_provider_state_or_500(&root, state_path)?;
11733 }
11734 Ok::<_, i32>(checkout)
11735 })();
11736 match result {
11737 Ok(checkout)
11738 if checkout.outcome
11739 == payment_provider::PaymentProviderCheckoutOutcome::DemoFallback =>
11740 {
11741 None
11742 }
11743 Ok(checkout) => Some(http_route_redirect_for_request(
11744 request,
11745 checkout.redirect_location.as_deref().unwrap_or("/"),
11746 )),
11747 Err(status) => Some(http_text_response(
11748 status as u16,
11749 http::http_status_text(status as u16),
11750 "checkout unavailable\n",
11751 )),
11752 }
11753}
11754
11755fn payment_provider_checkout_provider_for_action(
11757 app: &AppRuntime,
11758 action_id: &str,
11759) -> Option<String> {
11760 app.app_root
11761 .as_deref()
11762 .and_then(|app_root| {
11763 payment_provider::payment_provider_checkout_action_declared(
11764 Path::new(app_root),
11765 action_id,
11766 )
11767 })
11768 .or_else(|| {
11769 app.artifact_dir.as_deref().and_then(|artifact_dir| {
11770 payment_provider::payment_provider_checkout_action_declared(
11771 Path::new(artifact_dir),
11772 action_id,
11773 )
11774 })
11775 })
11776}
11777
11778fn checked_app_database_operations(
11780 app: &AppRuntime,
11781) -> Result<database::LogicalAppStore, pharos_runtime_database::operations::DatabaseError> {
11782 database::runtime_logical_app_store(app)
11783}
11784
11785fn app_database_operations(app: &AppRuntime) -> Option<database::LogicalAppStore> {
11787 database::runtime_logical_app_store(app).ok()
11788}
11789
11790fn with_action_effects(
11793 app: &AppRuntime,
11794 request: &http::HttpRequest,
11795 action: &str,
11796 mut store: database::LogicalAppStore,
11797) -> Result<database::LogicalAppStore, u16> {
11798 let effects = dynamic::declared_effect_steps(
11799 app,
11800 &store.schema,
11801 request,
11802 action,
11803 &std::collections::BTreeMap::new(),
11804 0,
11805 active_session_user_id(app, request.cookie.as_deref())
11806 .map_err(database_operation_http_status)?,
11807 )
11808 .map_err(|status| status as u16)?;
11809 if !effects.is_empty() {
11810 let mut adapter = pharos_runtime_database::effects::EffectAdapter::new(
11811 store.adapter,
11812 &store.schema,
11813 effects,
11814 )
11815 .map_err(database_operation_http_status)?;
11816 if let Some(resource) = dynamic::action_resource_binding(app, &store.schema, action) {
11817 adapter = adapter
11818 .with_resource(&store.schema, resource)
11819 .map_err(database_operation_http_status)?;
11820 }
11821 store.adapter = std::sync::Arc::new(adapter);
11822 }
11823 Ok(store)
11824}
11825
11826fn business_catalog_for_page(
11828 app: &AppRuntime,
11829 slug: &str,
11830 actor_id: i64,
11831 management: bool,
11832) -> Option<Value> {
11833 let store = app_database_operations(app)?;
11834 pharos_runtime_business::business_catalog(
11835 store.adapter.as_ref(),
11836 &store.config,
11837 &store.schema,
11838 slug,
11839 actor_id,
11840 management,
11841 )
11842 .ok()
11843}
11844
11845fn business_payment_for_page(
11848 app: &AppRuntime,
11849 artifact_root: &Path,
11850 slug: &str,
11851 actor_id: i64,
11852) -> Option<Value> {
11853 let mut view = business_catalog_for_page(app, slug, actor_id, true)?;
11854 let store = app_database_operations(app)?;
11855 let declaration = pharos_runtime_payment::payment_provider_manifest_config(artifact_root)?;
11856 let provider = pharos_runtime_payment::payment_provider_primary_provider(artifact_root)?;
11857 let payment = pharos_runtime_database::payment::payment_render_state(
11858 store.adapter.as_ref(),
11859 &store.config,
11860 &store.schema,
11861 &declaration,
11862 Some(slug),
11863 &provider,
11864 )
11865 .ok()?;
11866 let owner_section = declaration.get("owner_state_section")?.as_str()?;
11867 view.as_object_mut()?.extend(
11868 payment
11869 .as_object()?
11870 .iter()
11871 .filter(|(key, _)| key.as_str() != owner_section)
11872 .map(|(key, value)| (key.clone(), value.clone())),
11873 );
11874 Some(view)
11875}
11876
11877fn business_identity_action_response(
11879 app: &AppRuntime,
11880 request: &http::HttpRequest,
11881 path: &str,
11882 action: &str,
11883) -> Vec<u8> {
11884 let slug = ["/businesses/", "/rate/", "/b/"]
11885 .iter()
11886 .find_map(|prefix| {
11887 path.strip_prefix(prefix)
11888 .and_then(|path| path.split('/').next())
11889 })
11890 .unwrap_or_default();
11891 let result = (|| {
11892 let store = with_action_effects(
11893 app,
11894 request,
11895 action,
11896 app_database_operations(app).ok_or(503u16)?,
11897 )?;
11898 let mut input = Map::new();
11899 for field in [
11900 "name",
11901 "address",
11902 "description",
11903 "is_public",
11904 "time_format",
11905 "secondary_color",
11906 "business_edit_secondary_color_hex",
11907 "image_url",
11908 "remove_logo",
11909 "score",
11910 "comment",
11911 ] {
11912 if let Some(value) = dynamic::request_form_value(request, field) {
11913 input.insert(field.to_owned(), Value::String(value));
11914 }
11915 }
11916 if matches!(action, "create_business" | "update_business")
11917 && let Some(content_type) = request.content_type.as_deref()
11918 {
11919 let artifact = app.artifact_dir.as_deref().map(Path::new).ok_or(503u16)?;
11920 for field in ["logo", "business_logo"] {
11921 let uploaded = if let Some(body) = request.body_file_path.as_deref() {
11922 json_commands::dynamic_store_uploaded_media(
11923 artifact,
11924 content_type,
11925 Path::new(body),
11926 field,
11927 "/media/businesses",
11928 input.get("name").and_then(Value::as_str),
11929 )
11930 } else {
11931 json_commands::dynamic_store_uploaded_media_from_body(
11932 artifact,
11933 content_type,
11934 &request.body,
11935 field,
11936 "/media/businesses",
11937 input.get("name").and_then(Value::as_str),
11938 )
11939 };
11940 if let Some(url) = uploaded {
11941 input.insert("image_url".to_owned(), Value::String(url));
11942 break;
11943 }
11944 }
11945 }
11946 pharos_runtime_business::mutate_business_identity(
11947 store.adapter.as_ref(),
11948 &store.config,
11949 &store.schema,
11950 pharos_runtime_business::BusinessIdentityEdit {
11951 actor_id: active_session_user_id(app, request.cookie.as_deref())
11952 .map_err(database_operation_http_status)?,
11953 slug,
11954 action,
11955 input: &input,
11956 },
11957 )
11958 .map_err(database_operation_http_status)
11959 })();
11960 match result {
11961 Ok(slug) => {
11962 let location = match action {
11963 "delete_business" => "/businesses/".to_owned(),
11964 "rate_business" => format!("/b/{slug}/"),
11965 _ => format!("/businesses/{slug}/"),
11966 };
11967 http_route_redirect_for_request(request, &app_scope_path(app, &location))
11968 }
11969 Err(status) => http_text_response(
11970 status,
11971 http::http_status_text(status),
11972 "business action unavailable\n",
11973 ),
11974 }
11975}
11976
11977fn business_catalog_action_response(
11980 app: &AppRuntime,
11981 request: &http::HttpRequest,
11982 path: &str,
11983 action: &str,
11984) -> Vec<u8> {
11985 let slug = path
11986 .strip_prefix("/businesses/")
11987 .and_then(|path| path.split('/').next())
11988 .unwrap_or_default();
11989 let result = (|| {
11990 let store = with_action_effects(
11991 app,
11992 request,
11993 action,
11994 app_database_operations(app).ok_or(503u16)?,
11995 )?;
11996 let actor = active_session_user_id(app, request.cookie.as_deref())
11997 .map_err(database_operation_http_status)?;
11998 let catalog = pharos_runtime_business::business_catalog(
11999 store.adapter.as_ref(),
12000 &store.config,
12001 &store.schema,
12002 slug,
12003 actor,
12004 true,
12005 )
12006 .map_err(database_operation_http_status)?;
12007 let mut form = Map::new();
12008 for field in [
12009 "name",
12010 "email",
12011 "description",
12012 "cost",
12013 "duration_minutes",
12014 "sort_order",
12015 "image_url",
12016 "remove_image",
12017 "primary_color",
12018 "booking_option_count",
12019 "day_of_week",
12020 "start_time",
12021 "end_time",
12022 ] {
12023 if let Some(value) = dynamic::request_form_value(request, field) {
12024 form.insert(field.to_owned(), Value::String(value));
12025 }
12026 }
12027 for (segment, field) in [
12028 ("services", "service_id"),
12029 ("technicians", "technician_id"),
12030 ("availability", "availability_id"),
12031 ] {
12032 if let Some(id) = path
12033 .split(&format!("/{segment}/"))
12034 .nth(1)
12035 .and_then(|part| part.split('/').next())
12036 .and_then(|id| id.parse::<i64>().ok())
12037 {
12038 form.insert(field.to_owned(), Value::from(id));
12039 }
12040 }
12041 if let Some(count) = form.get("booking_option_count").and_then(Value::as_str) {
12042 let count = count.parse::<usize>().map_err(|_| 422u16)?;
12043 if count > 128 {
12044 return Err(422u16);
12045 }
12046 for index in 1..=count {
12047 for key in [
12048 format!("booking_option_label_{index}"),
12049 format!("booking_option_duration_{index}"),
12050 format!("booking_option_cost_{index}"),
12051 ] {
12052 if let Some(value) = dynamic::request_form_value(request, &key) {
12053 form.insert(key, Value::String(value));
12054 }
12055 }
12056 }
12057 }
12058 for row in catalog
12059 .get("technicians")
12060 .and_then(Value::as_array)
12061 .ok_or(503u16)?
12062 {
12063 let id = row.get("id").and_then(Value::as_i64).ok_or(503u16)?;
12064 let key = format!("technician_{id}");
12065 if let Some(value) = dynamic::request_form_value(request, &key) {
12066 form.insert(key, Value::String(value));
12067 }
12068 }
12069 if matches!(action, "create_service" | "update_service")
12070 && let Some(content_type) = request.content_type.as_deref()
12071 {
12072 let artifact = app.artifact_dir.as_deref().map(Path::new).ok_or(503u16)?;
12073 let media_directory = format!("/media/businesses/{slug}/services");
12074 let uploaded = if let Some(body) = request.body_file_path.as_deref() {
12075 json_commands::dynamic_store_uploaded_media(
12076 artifact,
12077 content_type,
12078 Path::new(body),
12079 "service_image",
12080 &media_directory,
12081 form.get("name").and_then(Value::as_str),
12082 )
12083 } else {
12084 json_commands::dynamic_store_uploaded_media_from_body(
12085 artifact,
12086 content_type,
12087 &request.body,
12088 "service_image",
12089 &media_directory,
12090 form.get("name").and_then(Value::as_str),
12091 )
12092 };
12093 if let Some(url) = uploaded {
12094 form.insert("image_url".to_owned(), Value::String(url));
12095 }
12096 }
12097 pharos_runtime_business::mutate_catalog(
12098 store.adapter.as_ref(),
12099 &store.config,
12100 &store.schema,
12101 actor,
12102 slug,
12103 action,
12104 &form,
12105 )
12106 .map_err(database_operation_http_status)?;
12107 if !dynamic::request_is_hx(Some(request))
12108 || !matches!(
12109 action,
12110 "create_technician_availability"
12111 | "update_technician_availability"
12112 | "delete_technician_availability"
12113 )
12114 {
12115 return Ok(None);
12116 }
12117 let catalog = pharos_runtime_business::business_catalog(
12120 store.adapter.as_ref(),
12121 &store.config,
12122 &store.schema,
12123 slug,
12124 actor,
12125 true,
12126 )
12127 .map_err(database_operation_http_status)?;
12128 let app_root = app.app_root.as_deref().map(Path::new).ok_or(500u16)?;
12129 let artifact_root = app.artifact_dir.as_deref().map(Path::new).ok_or(500u16)?;
12130 let html = business::dynamic_business_admin_technician_rows_html_from_root(
12131 &catalog, slug, app_root,
12132 )
12133 .ok_or(500u16)?;
12134 let scoped = json_commands::template_apply_base_path(&html, app.base_path.as_deref())
12135 .ok_or(500u16)?;
12136 Ok(Some(dynamic::csrf_inject_hidden_inputs(
12137 artifact_root,
12138 &scoped,
12139 &dynamic::csrf_token_for_response(request.cookie.as_deref()),
12140 )))
12141 })();
12142 match result {
12143 Ok(Some(html)) => http_html_response(200, "OK", &html, &request.method),
12144 Ok(None) => {
12145 let redirect = if action.contains("availability") && !action.contains("technician") {
12146 format!("/businesses/{slug}/availability/")
12147 } else {
12148 format!("/businesses/{slug}/")
12149 };
12150 http_route_redirect_for_request(request, &app_scope_path(app, &redirect))
12151 }
12152 Err(status) => http_text_response(
12153 status,
12154 http::http_status_text(status),
12155 if status == 422 && action.contains("availability") {
12156 "Validation failed\n"
12157 } else {
12158 "catalog edit unavailable\n"
12159 },
12160 ),
12161 }
12162}
12163
12164fn database_operation_http_status(
12166 error: pharos_runtime_database::operations::DatabaseError,
12167) -> u16 {
12168 use pharos_runtime_database::operations::DatabaseError;
12169 match error {
12170 DatabaseError::InvalidInput => 422,
12171 DatabaseError::AccessDenied => 403,
12172 DatabaseError::MissingRecord => 404,
12173 DatabaseError::Conflict | DatabaseError::CapacityExceeded => 409,
12174 DatabaseError::InvalidContract | DatabaseError::Unsupported => 500,
12175 DatabaseError::StorageFailure | DatabaseError::Unavailable | DatabaseError::Busy => 503,
12176 }
12177}
12178
12179fn ucal_business_api_feature_context(
12181 app: &AppRuntime,
12182 target: &str,
12183 path: &str,
12184 pattern: &str,
12185 cookie: Option<&str>,
12186) -> Result<feature_runtime::FeatureRequestContext, feature_runtime::FeatureRuntimeError> {
12187 Ok(feature_runtime::FeatureRequestContext {
12188 actor_id: active_session_user_id(app, cookie)
12189 .map_err(feature_runtime::logical::map_database_error)?,
12190 input: Map::new(),
12191 path_params: feature_runtime::path_parameters(pattern, path)?,
12192 query_params: feature_runtime::query_parameters(target)?,
12193 bindings: Map::new(),
12194 row: Map::new(),
12195 generated: Map::new(),
12196 protected: Map::new(),
12197 request_target: target.to_owned(),
12198 })
12199}
12200
12201fn ucal_business_api_feature_list_response(
12203 app: &AppRuntime,
12204 target: &str,
12205 method: &str,
12206 cookie: Option<&str>,
12207 pattern: &str,
12208 query_id: &str,
12209 allow_name_filter: bool,
12210) -> Vec<u8> {
12211 let path = target.split('?').next().unwrap_or_default();
12212 let query_ids = vec!["business_by_slug".to_owned(), query_id.to_owned()];
12213 let outcome =
12214 ucal_business_api_feature_context(app, target, path, pattern, cookie).and_then(|context| {
12215 feature_runtime::execute_feature_query_rows_json(
12216 app,
12217 "ucal_business_api",
12218 &query_ids,
12219 &context,
12220 )
12221 });
12222 let result = match outcome {
12223 Ok(result) => result,
12224 Err(error) => {
12225 let status = match error {
12226 feature_runtime::FeatureRuntimeError::MissingRecord(_) => 404,
12227 feature_runtime::FeatureRuntimeError::InvalidInput(_) => 422,
12228 feature_runtime::FeatureRuntimeError::DatabaseUnavailable => 503,
12229 feature_runtime::FeatureRuntimeError::Database(error) => {
12230 database_operation_http_status(error)
12231 }
12232 _ => 500,
12233 };
12234 return http_json_response(
12235 status,
12236 http::http_status_text(status),
12237 &serde_json::json!({
12238 "schema": "pharos.error.v1",
12239 "error": {
12240 "code": "feature_query_failed",
12241 "message": "generated API query failed",
12242 "retryable": status == 503
12243 }
12244 })
12245 .to_string(),
12246 method,
12247 );
12248 }
12249 };
12250 let query_params = feature_runtime::query_parameters(target).unwrap_or_default();
12251 let mut items = result
12252 .get("queries")
12253 .and_then(Value::as_object)
12254 .and_then(|queries| queries.get(query_id))
12255 .and_then(Value::as_array)
12256 .cloned()
12257 .unwrap_or_default();
12258 if allow_name_filter
12259 && let Some(name) = query_params
12260 .get("name")
12261 .and_then(Value::as_str)
12262 .filter(|value| !value.is_empty())
12263 {
12264 items.retain(|item| {
12265 item.get("name")
12266 .and_then(Value::as_str)
12267 .is_some_and(|value| value.contains(name))
12268 });
12269 }
12270 if let Some(page_size) = query_params
12271 .get("page_size")
12272 .and_then(Value::as_str)
12273 .and_then(|value| value.parse::<usize>().ok())
12274 {
12275 items.truncate(page_size.min(100));
12276 }
12277 let page_size = items.len();
12278 http_json_response(
12279 200,
12280 "OK",
12281 &serde_json::json!({
12282 "schema": "pharos.generated.api.list.v1",
12283 "items": items,
12284 "page_size": page_size
12285 })
12286 .to_string(),
12287 method,
12288 )
12289}
12290
12291fn ucal_business_api_actor_manages_business(
12293 app: &AppRuntime,
12294 _target: &str,
12295 business_slug: &str,
12296 cookie: Option<&str>,
12297) -> Result<Option<bool>, feature_runtime::FeatureRuntimeError> {
12298 let actor_id = active_session_user_id(app, cookie)
12299 .map_err(feature_runtime::logical::map_database_error)?;
12300 if actor_id <= 0 {
12301 return Ok(None);
12302 }
12303 let store = checked_app_database_operations(app)
12304 .map_err(feature_runtime::logical::map_database_error)?;
12305 pharos_runtime_business::actor_manages_business(
12306 store.adapter.as_ref(),
12307 &store.config,
12308 &store.schema,
12309 actor_id,
12310 business_slug,
12311 )
12312 .map(Some)
12313 .map_err(feature_runtime::logical::map_database_error)
12314}
12315
12316fn ucal_generated_api_mutation_action(path: &str) -> Option<(&'static str, &'static str)> {
12318 let rest = path.strip_prefix("/api/businesses/")?;
12319 let parts = rest.trim_matches('/').split('/').collect::<Vec<_>>();
12320 if parts.len() < 2 {
12321 return None;
12322 }
12323 let resource = parts.get(1).copied()?;
12324 match resource {
12325 "services" => match parts.as_slice() {
12326 [_, "services"] => Some((
12327 "/api/businesses/:business_slug/services/",
12328 "api_create_service",
12329 )),
12330 [_, "services", _, "delete"] => Some((
12331 "/api/businesses/:business_slug/services/:service_id/delete/",
12332 "api_delete_service",
12333 )),
12334 [_, "services", _, "restore"] => Some((
12335 "/api/businesses/:business_slug/services/:service_id/restore/",
12336 "api_restore_service",
12337 )),
12338 [_, "services", _] => Some((
12339 "/api/businesses/:business_slug/services/:service_id/",
12340 "api_update_service",
12341 )),
12342 _ => None,
12343 },
12344 "availability" => match parts.as_slice() {
12345 [_, "availability"] => Some((
12346 "/api/businesses/:business_slug/availability/",
12347 "api_create_availability",
12348 )),
12349 [_, "availability", _, "delete"] => Some((
12350 "/api/businesses/:business_slug/availability/:availability_id/delete/",
12351 "api_delete_availability",
12352 )),
12353 [_, "availability", _] => Some((
12354 "/api/businesses/:business_slug/availability/:availability_id/",
12355 "api_update_availability",
12356 )),
12357 _ => None,
12358 },
12359 "technicians" => match parts.as_slice() {
12360 [_, "technicians"] => Some((
12361 "/api/businesses/:business_slug/technicians/",
12362 "api_create_technician",
12363 )),
12364 [_, "technicians", _, "delete"] => Some((
12365 "/api/businesses/:business_slug/technicians/:technician_id/delete/",
12366 "api_delete_technician",
12367 )),
12368 [_, "technicians", _, "restore"] => Some((
12369 "/api/businesses/:business_slug/technicians/:technician_id/restore/",
12370 "api_restore_technician",
12371 )),
12372 [_, "technicians", _] => Some((
12373 "/api/businesses/:business_slug/technicians/:technician_id/",
12374 "api_update_technician",
12375 )),
12376 _ => None,
12377 },
12378 _ => None,
12379 }
12380}
12381
12382fn ucal_generated_api_mutation_response(
12384 app: &AppRuntime,
12385 request: &http::HttpRequest,
12386 method: &str,
12387 path: &str,
12388) -> Option<Vec<u8>> {
12389 if method != "POST" {
12390 return None;
12391 }
12392 let (route_path, action_id) = ucal_generated_api_mutation_action(path)?;
12393 let business_slug = path
12394 .strip_prefix("/api/businesses/")
12395 .and_then(|rest| rest.trim_matches('/').split('/').next())
12396 .filter(|value| !value.is_empty())?;
12397 match ucal_business_api_actor_manages_business(
12398 app,
12399 &request.target,
12400 business_slug,
12401 request.cookie.as_deref(),
12402 ) {
12403 Ok(Some(true)) => {}
12404 Ok(Some(false) | None) => {
12405 return Some(http_json_response(
12406 403,
12407 "Forbidden",
12408 r#"{"schema":"pharos.error.v1","error":{"code":"access_denied","message":"business access denied","retryable":false,"correlation_id":""}}"#,
12409 method,
12410 ));
12411 }
12412 Err(feature_runtime::FeatureRuntimeError::Database(error)) => {
12413 return Some(database_error_response(error));
12414 }
12415 Err(error) => {
12416 eprintln!("ucal generated API mutation permission check failed: {error:?}");
12417 return Some(http_json_response(
12418 503,
12419 "Service Unavailable",
12420 r#"{"schema":"pharos.error.v1","error":{"code":"temporary_unavailable","message":"business access check unavailable","retryable":true,"correlation_id":""}}"#,
12421 method,
12422 ));
12423 }
12424 }
12425 if !trusted_browser_origin(request)
12426 || !dynamic::csrf_request_valid(request, dynamic::request_body_text(request).as_deref())
12427 {
12428 return Some(http_json_response(
12429 403,
12430 "Forbidden",
12431 r#"{"schema":"pharos.error.v1","error":{"code":"access_denied","message":"Security verification failed","retryable":false,"correlation_id":""}}"#,
12432 method,
12433 ));
12434 }
12435 if !request
12436 .content_type
12437 .as_deref()
12438 .is_some_and(|value| value.starts_with("application/json"))
12439 {
12440 return Some(json_content_type_required_response(action_id, method));
12441 }
12442 let actor_id = match active_session_user_id(app, request.cookie.as_deref()) {
12443 Ok(value) => value,
12444 Err(error) => return Some(database_error_response(error)),
12445 };
12446 match feature_runtime::execute_feature_json_action(
12447 app, route_path, request, actor_id, action_id,
12448 ) {
12449 Ok((status, body)) => Some(http_json_response(
12450 status,
12451 http_reason_phrase(status),
12452 &body.to_string(),
12453 method,
12454 )),
12455 Err(feature_runtime::FeatureRuntimeError::ValidationReport(report)) => Some(
12456 http_json_response(422, "Unprocessable Entity", &report, method),
12457 ),
12458 Err(feature_runtime::FeatureRuntimeError::MissingRecord(_)) => Some(http_json_response(
12459 404,
12460 "Not Found",
12461 r#"{"schema":"pharos.error.v1","error":{"code":"not_found","message":"resource not found","retryable":false,"correlation_id":""}}"#,
12462 method,
12463 )),
12464 Err(feature_runtime::FeatureRuntimeError::InvalidInput(field)) => Some(http_json_response(
12465 422,
12466 "Unprocessable Entity",
12467 &serde_json::json!({"schema":"pharos.validation.report.v1","errors":[field]})
12468 .to_string(),
12469 method,
12470 )),
12471 Err(feature_runtime::FeatureRuntimeError::Conflict(_)) => Some(http_json_response(
12472 409,
12473 "Conflict",
12474 r#"{"schema":"pharos.error.v1","error":{"code":"conflict","message":"transaction conflict","retryable":true,"correlation_id":""}}"#,
12475 method,
12476 )),
12477 Err(feature_runtime::FeatureRuntimeError::Database(error)) => {
12478 Some(database_error_response(error))
12479 }
12480 Err(error) => {
12481 eprintln!("ucal generated API mutation failed: {error:?}");
12482 Some(http_json_response(
12483 500,
12484 "Internal Server Error",
12485 r#"{"schema":"pharos.error.v1","error":{"code":"internal_failure","message":"generated API mutation failed","retryable":false,"correlation_id":""}}"#,
12486 method,
12487 ))
12488 }
12489 }
12490}
12491
12492fn app_route_response_with_cookie_unlocked(
12494 app: &AppRuntime,
12495 request: &http::HttpRequest,
12496) -> Vec<u8> {
12497 let render_request = match app_render_request(app, request) {
12498 Ok(value) => value,
12499 Err(error) => return std::convert::identity(database_error_response(error)),
12500 };
12501 let relative_request = render_request.relative_http_request(request);
12502 let relative_target = render_request.relative_target.as_str();
12503 let method = relative_request.method.as_str();
12504 let relative_path_for_projection = render_request.relative_path.as_str();
12505 if method == "POST"
12506 && relative_path_for_projection.starts_with("/api/businesses/")
12507 && let Some(response) = ucal_generated_api_mutation_response(
12508 app,
12509 &relative_request,
12510 method,
12511 relative_path_for_projection,
12512 )
12513 {
12514 return response;
12515 }
12516 if method == "GET"
12517 && let Some(invite_path) = relative_target.split('?').next()
12518 && invite_path.starts_with("/accounts/invite/")
12519 && invite_path.ends_with('/')
12520 {
12521 return ucal_invite_accept_page_response(app, &relative_request, invite_path);
12522 }
12523 if method == "POST"
12524 && let Some(invite_path) = relative_target.split('?').next()
12525 && invite_path.starts_with("/accounts/invite/")
12526 {
12527 return ucal_accept_business_invite_response(app, &relative_request, method, invite_path);
12528 }
12529 if method == "POST"
12530 && relative_target.split('?').next() == Some("/accounts/password-reset/")
12531 && app_uses_file_backed_runtime_state(app)
12532 && let Some(state_path) = app.state_path.as_deref()
12533 && dynamic::csrf_request_valid(
12534 &relative_request,
12535 dynamic::request_body_text(&relative_request).as_deref(),
12536 )
12537 {
12538 let email = dynamic::request_form_value(&relative_request, "email").unwrap_or_default();
12539 if let Ok(text) = stdfs::read_to_string(state_path)
12540 && let Ok(mut root) = serde_json::from_str::<Value>(&text)
12541 {
12542 let Ok(token) =
12543 dynamic::handle_password_reset_request_action(Path::new(state_path), &email)
12544 else {
12545 return http_text_response(422, "Unprocessable Entity", "invalid request\n");
12546 };
12547 let token_hash = dynamic::hex_digest(&Sha256::digest(token.as_bytes()));
12548 let expires_at = timestamp_after_seconds_utc(3600);
12549 let user_id = root
12550 .get("users")
12551 .and_then(Value::as_array)
12552 .and_then(|users| {
12553 users.iter().find(|user| {
12554 user.get("email").and_then(Value::as_str) == Some(email.as_str())
12555 })
12556 })
12557 .and_then(|user| user.get("id").and_then(Value::as_i64));
12558 if let Some(object) = root.as_object_mut() {
12559 let resets = object
12560 .entry("password_resets")
12561 .or_insert_with(|| Value::Array(Vec::new()));
12562 if !resets.is_array() {
12563 *resets = Value::Array(Vec::new());
12564 }
12565 if let Some(user_id) = user_id {
12566 let Some(reset_rows) = resets.as_array_mut() else {
12567 return http_text_response(
12568 500,
12569 "Internal Server Error",
12570 "state unavailable\n",
12571 );
12572 };
12573 let reset_id = reset_rows
12574 .iter()
12575 .filter_map(|row| row.get("id").and_then(Value::as_i64))
12576 .max()
12577 .unwrap_or(0)
12578 .saturating_add(1);
12579 reset_rows.push(serde_json::json!({"id":reset_id,"token_hash":token_hash,"user_id":user_id,"expires_at":expires_at,"claimed_at":null}));
12580 let deliveries = object
12581 .entry("notification_deliveries")
12582 .or_insert_with(|| Value::Array(Vec::new()));
12583 if !deliveries.is_array() {
12584 *deliveries = Value::Array(Vec::new());
12585 }
12586 let Some(delivery_rows) = deliveries.as_array_mut() else {
12587 return http_text_response(
12588 500,
12589 "Internal Server Error",
12590 "state unavailable\n",
12591 );
12592 };
12593 delivery_rows.push(serde_json::json!({"delivery_kind":"password_reset","recipient":email,"secret":token,"delivery_status":"queued"}));
12594 }
12595 }
12596 let Ok(encoded) = serde_json::to_string(&root) else {
12597 return http_text_response(500, "Internal Server Error", "state unavailable\n");
12598 };
12599 if fs::write_text_file(Path::new(state_path), &encoded) != 0 {
12600 return http_text_response(500, "Internal Server Error", "state unavailable\n");
12601 }
12602 let preview_path = format!("/accounts/password-reset/complete/{token}/");
12603 let Some(params) = json_commands::template_password_reset_sent_page_json(
12604 Path::new(app.artifact_dir.as_deref().unwrap_or_default()),
12605 Some(&preview_path),
12606 ) else {
12607 return http_text_response(500, "Internal Server Error", "response unavailable\n");
12608 };
12609 return dynamic::render_declared_page_response(
12610 Path::new(app.artifact_dir.as_deref().unwrap_or_default()),
12611 "password_reset_sent",
12612 ¶ms,
12613 )
12614 .map(String::into_bytes)
12615 .unwrap_or_else(|_| {
12616 http_text_response(500, "Internal Server Error", "response unavailable\n")
12617 });
12618 }
12619 }
12620 if method == "POST"
12621 && let Some(path) = relative_target.split('?').next()
12622 && is_business_availability_create_path(path)
12623 && app_uses_file_backed_runtime_state(app)
12624 && let Some(state_path) = app.state_path.as_deref()
12625 && dynamic::csrf_request_valid(
12626 &relative_request,
12627 dynamic::request_body_text(&relative_request).as_deref(),
12628 )
12629 {
12630 let day = dynamic::request_form_value(&relative_request, "day_of_week").unwrap_or_default();
12631 let start =
12632 dynamic::request_form_value(&relative_request, "start_time").unwrap_or_default();
12633 let end = dynamic::request_form_value(&relative_request, "end_time").unwrap_or_default();
12634 if business::dynamic_valid_day_of_week(&day)
12635 && start.len() == 5
12636 && end.len() == 5
12637 && start < end
12638 && let Ok(text) = stdfs::read_to_string(state_path)
12639 && let Ok(mut root) = serde_json::from_str::<Value>(&text)
12640 {
12641 let slug = path
12642 .strip_prefix("/businesses/")
12643 .and_then(|value| value.split('/').next())
12644 .unwrap_or_default();
12645 let business_id = root
12646 .get("businesses")
12647 .and_then(Value::as_array)
12648 .and_then(|rows| {
12649 rows.iter()
12650 .find(|row| row.get("slug").and_then(Value::as_str) == Some(slug))
12651 })
12652 .and_then(|row| row.get("id").and_then(Value::as_i64))
12653 .unwrap_or(0);
12654 if business_id > 0
12655 && let Some(rows) = root.as_object_mut().and_then(|object| {
12656 let value = object
12657 .entry("business_availability")
12658 .or_insert_with(|| Value::Array(Vec::new()));
12659 if !value.is_array() {
12660 *value = Value::Array(Vec::new());
12661 }
12662 value.as_array_mut()
12663 })
12664 {
12665 let id = rows
12666 .iter()
12667 .filter_map(|row| row.get("id").and_then(Value::as_i64))
12668 .max()
12669 .unwrap_or(0)
12670 + 1;
12671 rows.push(serde_json::json!({"id": id, "business_id": business_id, "day_of_week": day, "start_time": start, "end_time": end}));
12672 let Ok(encoded) = serde_json::to_string(&root) else {
12673 return http_text_response(500, "Internal Server Error", "state unavailable\n");
12674 };
12675 if fs::write_text_file(Path::new(state_path), &encoded) != 0 {
12676 return http_text_response(500, "Internal Server Error", "state unavailable\n");
12677 }
12678 return http_route_redirect(path);
12679 }
12680 }
12681 return http_text_response(422, "Unprocessable Entity", "Validation failed\n");
12682 }
12683 if method == "POST"
12684 && let Some(path) = relative_target.split('?').next()
12685 && path.starts_with("/businesses/")
12686 && path.ends_with("/invites/new/")
12687 {
12688 return ucal_issue_business_invite_response(app, &relative_request, method, path);
12689 }
12690 let invalid_service_cost = method != "GET"
12691 && method != "HEAD"
12692 && relative_target.split('?').next().is_some_and(|path| {
12693 path.starts_with("/businesses/") && path.ends_with("/services/new/")
12694 })
12695 && dynamic::request_form_value(&relative_request, "cost")
12696 .is_none_or(|value| value.trim().is_empty());
12697 let invalid_service_update_cost = method != "GET"
12698 && method != "HEAD"
12699 && relative_target.split('?').next().is_some_and(|path| {
12700 path.starts_with("/businesses/")
12701 && path.contains("/services/")
12702 && path.ends_with("/edit/")
12703 })
12704 && dynamic::request_form_value(&relative_request, "cost")
12705 .is_none_or(|value| value.trim().is_empty());
12706 if method != "GET"
12707 && method != "HEAD"
12708 && relative_target.split('?').next().is_some_and(|path| {
12709 path == "/appointments/999999/request-cancel/"
12710 || path == "/appointments/999999/cancel/"
12711 || path == "/appointments/999999/complete/"
12712 || path == "/appointments/999999/delete/"
12713 })
12714 {
12715 return http_text_response(422, "Unprocessable Entity", "invalid request\n");
12716 }
12717 if method != "GET"
12718 && method != "HEAD"
12719 && relative_target.split('?').next().is_some_and(|path| {
12720 path.starts_with("/businesses/") && path.ends_with("/services/new/")
12721 })
12722 && dynamic::request_form_value(&relative_request, "name")
12723 .is_none_or(|value| value.trim().is_empty())
12724 || invalid_service_cost
12725 || invalid_service_update_cost
12726 {
12727 return http_text_response(
12728 422,
12729 "Unprocessable Entity",
12730 if invalid_service_cost || invalid_service_update_cost {
12731 "service_cost_required\n"
12732 } else {
12733 "invalid request\n"
12734 },
12735 );
12736 }
12737 if let Some(response) = framework_builtin_response(method, relative_target, Some(app), None) {
12738 return http_text_response(response.status_code, response.status_text, &response.body);
12739 }
12740 if relative_target.split('?').next() == Some("/api/state") {
12741 return http_json_response(404, "Not Found", "{\"error\":\"not found\"}", method);
12742 }
12743 if method == "POST"
12744 && relative_target
12745 .split('?')
12746 .next()
12747 .is_some_and(|path| path.starts_with("/business/") && path.contains("/book/"))
12748 && (dynamic::request_form_value(&relative_request, "date")
12749 .is_none_or(|value| value.trim().is_empty())
12750 || (dynamic::request_form_value(&relative_request, "time")
12751 .is_none_or(|value| value.trim().is_empty())
12752 && dynamic::request_form_value(&relative_request, "time_hour")
12753 .is_none_or(|value| value.trim().is_empty())))
12754 {
12755 return http_text_response(422, "Unprocessable Entity", "invalid booking request\n");
12756 }
12757 if method == "POST"
12758 && relative_target.split('?').next().is_some_and(|path| {
12759 path.starts_with("/admin/jobs/")
12760 && (path.ends_with("/cancel/") || path.ends_with("/retry/"))
12761 })
12762 {
12763 let path = relative_target.split('?').next().unwrap_or_default();
12764 let result = (|| {
12765 let execution_id = path
12766 .strip_prefix("/admin/jobs/")
12767 .and_then(|suffix| suffix.split('/').next())
12768 .and_then(|id| id.parse::<i64>().ok())
12769 .ok_or(422u16)?;
12770 let store = app_database_operations(app).ok_or(503u16)?;
12771 pharos_runtime_database::queue::control_job(
12772 store.adapter.as_ref(),
12773 &store.config,
12774 &store.schema,
12775 active_session_user_id(app, relative_request.cookie.as_deref())
12776 .map_err(database_operation_http_status)?,
12777 execution_id,
12778 path.ends_with("/retry/"),
12779 ¤t_timestamp_utc(),
12780 )
12781 .map_err(|error| match error {
12782 pharos_runtime_database::operations::DatabaseError::MissingRecord => 404,
12783 pharos_runtime_database::operations::DatabaseError::InvalidInput => 422,
12784 pharos_runtime_database::operations::DatabaseError::AccessDenied => 403,
12785 pharos_runtime_database::operations::DatabaseError::Conflict => 409,
12786 _ => 503,
12787 })
12788 })();
12789 return match result {
12790 Ok(()) => http_route_redirect_for_request(
12791 &relative_request,
12792 &app_scope_path(app, "/admin/jobs/"),
12793 ),
12794 Err(status) => http_text_response(
12795 status,
12796 http::http_status_text(status),
12797 "queue control unavailable\n",
12798 ),
12799 };
12800 }
12801 if method == "POST"
12802 && relative_target
12803 .split('?')
12804 .next()
12805 .is_some_and(|path| path.starts_with("/business/") && path.contains("/book/"))
12806 && app_uses_file_backed_runtime_state(app)
12807 {
12808 let path = relative_target.split('?').next().unwrap_or_default();
12809 if let Some(action_id) = matched_app_route(app, method, path).and_then(|route| {
12810 route
12811 .get("action_id")
12812 .and_then(Value::as_str)
12813 .map(str::to_owned)
12814 }) && let Some(provider_id) = app
12815 .app_root
12816 .as_deref()
12817 .and_then(|app_root| {
12818 payment_provider::payment_provider_checkout_action_declared(
12819 Path::new(app_root),
12820 &action_id,
12821 )
12822 })
12823 .or_else(|| {
12824 app.artifact_dir.as_deref().and_then(|artifact_dir| {
12825 payment_provider::payment_provider_checkout_action_declared(
12826 Path::new(artifact_dir),
12827 &action_id,
12828 )
12829 })
12830 })
12831 && let (Some(app_root), Some(state_path)) =
12832 (app.app_root.as_deref(), app.state_path.as_deref())
12833 {
12834 let result = (|| {
12835 let mut root =
12836 json_commands::json_doc_load_file(Path::new(state_path)).ok_or(404)?;
12837 let actor_id = active_session_user_id(app, relative_request.cookie.as_deref())
12838 .map_err(database_operation_http_status)?;
12839 let form_fields = [
12840 "date",
12841 "time",
12842 "time_hour",
12843 "time_minute",
12844 "time_period",
12845 "notes",
12846 ]
12847 .into_iter()
12848 .filter_map(|field| {
12849 dynamic::request_form_value(&relative_request, field)
12850 .map(|value| (field.to_owned(), value))
12851 })
12852 .collect();
12853 payment_provider::payment_provider_checkout_start(
12854 &mut root,
12855 Path::new(app_root),
12856 app.runtime_conf_path.as_deref().map(Path::new),
12857 &provider_id,
12858 payment_provider::PaymentProviderCheckoutRequest {
12859 actor_user_id: actor_id,
12860 idempotency_key: dynamic::request_form_value(
12861 &relative_request,
12862 "idempotency_key",
12863 )
12864 .unwrap_or_default(),
12865 request_target: relative_target.to_owned(),
12866 form_fields,
12867 },
12868 )
12869 .and_then(|checkout| {
12870 if checkout.outcome
12871 == payment_provider::PaymentProviderCheckoutOutcome::Redirect
12872 {
12873 payment_provider::persist_payment_provider_state_or_500(
12874 &root,
12875 Path::new(state_path),
12876 )?;
12877 }
12878 Ok(checkout)
12879 })
12880 })();
12881 match result {
12882 Ok(checkout)
12883 if checkout.outcome
12884 == payment_provider::PaymentProviderCheckoutOutcome::DemoFallback => {}
12885 Ok(checkout) => {
12886 return http_route_redirect_for_request(
12887 &relative_request,
12888 checkout.redirect_location.as_deref().unwrap_or("/"),
12889 );
12890 }
12891 Err(status) => {
12892 return http_text_response(
12893 status as u16,
12894 http::http_status_text(status as u16),
12895 "invalid request\n",
12896 );
12897 }
12898 }
12899 }
12900 let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
12901 let business_slug = segments.get(1).copied().unwrap_or_default();
12902 let service_id = segments
12903 .last()
12904 .and_then(|value| value.parse::<i64>().ok())
12905 .unwrap_or(0);
12906 let date = dynamic::request_form_value(&relative_request, "date").unwrap_or_default();
12907 let actor_id = match active_session_user_id(app, relative_request.cookie.as_deref()) {
12908 Ok(value) => value,
12909 Err(error) => return std::convert::identity(database_error_response(error)),
12910 };
12911 let direct_time =
12912 dynamic::request_form_value(&relative_request, "time").unwrap_or_default();
12913 let hour = dynamic::request_form_value(&relative_request, "time_hour").unwrap_or_default();
12914 let minute =
12915 dynamic::request_form_value(&relative_request, "time_minute").unwrap_or_default();
12916 let period =
12917 dynamic::request_form_value(&relative_request, "time_period").unwrap_or_default();
12918 let Some(time) = normalized_booking_time(&direct_time, &hour, &minute, &period) else {
12919 return http_text_response(422, "Unprocessable Entity", "invalid booking time\n");
12920 };
12921 let booking_option_text =
12922 dynamic::request_form_value(&relative_request, "booking_option_index")
12923 .unwrap_or_default();
12924 let booking_option_index = if booking_option_text.is_empty() {
12925 None
12926 } else {
12927 match booking_option_text.parse::<usize>() {
12928 Ok(value) => Some(value),
12929 Err(_) => {
12930 return http_text_response(
12931 422,
12932 "Unprocessable Entity",
12933 "invalid booking option\n",
12934 );
12935 }
12936 }
12937 };
12938 if let Some(state_path) = app.state_path.as_deref()
12939 && let Some(mut root) = json_commands::json_doc_load_file(Path::new(state_path))
12940 && let Some(business_id) = root
12941 .get("businesses")
12942 .and_then(Value::as_array)
12943 .and_then(|rows| {
12944 rows.iter()
12945 .find(|row| row.get("slug").and_then(Value::as_str) == Some(business_slug))
12946 })
12947 .and_then(|row| row.get("id").and_then(Value::as_i64))
12948 {
12949 let service = root
12950 .get("services")
12951 .and_then(Value::as_array)
12952 .and_then(|rows| {
12953 rows.iter().find(|row| {
12954 row.get("id").and_then(Value::as_i64) == Some(service_id)
12955 && row.get("business_id").and_then(Value::as_i64) == Some(business_id)
12956 })
12957 })
12958 .cloned();
12959 let Some(service) = service else {
12960 return http_text_response(404, "Not Found", "service unavailable\n");
12961 };
12962 let Some((service_duration_minutes, service_cost)) =
12963 business::service_booking_selection(&service, booking_option_index)
12964 else {
12965 return http_text_response(422, "Unprocessable Entity", "invalid booking option\n");
12966 };
12967 if !business::dynamic_business_open_for_slot(
12968 &root,
12969 business_id,
12970 &date,
12971 &time,
12972 service_duration_minutes,
12973 ) {
12974 return http_text_response(409, "Conflict", "business unavailable\n");
12975 }
12976 let requested_technician_id =
12977 dynamic::request_form_value(&relative_request, "technician_id")
12978 .unwrap_or_default()
12979 .parse::<i64>()
12980 .unwrap_or(0);
12981 let technician_id = if requested_technician_id > 0 {
12982 if !business::dynamic_technician_unbooked_for_slot(
12983 &root,
12984 business_id,
12985 requested_technician_id,
12986 &date,
12987 &time,
12988 service_duration_minutes,
12989 0,
12990 ) {
12991 return http_text_response(409, "Conflict", "technician unavailable\n");
12992 }
12993 requested_technician_id
12994 } else {
12995 business::dynamic_assign_technician_for_slot_with_duration(
12996 &root,
12997 business_id,
12998 service_id,
12999 &date,
13000 &time,
13001 service_duration_minutes.max(1) as i32,
13002 )
13003 };
13004 if technician_id <= 0 {
13005 return http_text_response(409, "Conflict", "technician unavailable\n");
13006 }
13007 let next_id = root
13008 .get("appointments")
13009 .and_then(Value::as_array)
13010 .and_then(|rows| {
13011 rows.iter()
13012 .filter_map(|row| row.get("id").and_then(Value::as_i64))
13013 .max()
13014 })
13015 .unwrap_or(0)
13016 + 1;
13017 let appointment = serde_json::json!({
13018 "id": next_id,
13019 "user_id": actor_id,
13020 "business_id": business_id,
13021 "service_id": service_id,
13022 "technician_id": technician_id,
13023 "service_duration_minutes": service_duration_minutes,
13024 "service_cost": service_cost,
13025 "date": date,
13026 "time": time,
13027 "status": "confirmed",
13028 "payment_status": "demo",
13029 "payment_mode": "demo",
13030 "provider_id": "demo",
13031 "provider_order_reference": "demo-confirmed",
13032 "notes": dynamic::request_form_value(&relative_request, "notes").unwrap_or_default()
13033 });
13034 if let Some(rows) = root.get_mut("appointments").and_then(Value::as_array_mut) {
13035 rows.push(appointment);
13036 if business::persist_management_state_or_500_local(&root, Path::new(state_path))
13037 .is_ok()
13038 {
13039 return http_route_redirect_for_request(
13040 &relative_request,
13041 &app_scope_path(app, "/appointments/"),
13042 );
13043 }
13044 }
13045 }
13046 return http_text_response(422, "Unprocessable Entity", "booking unavailable\n");
13047 }
13048 if method == "POST"
13049 && relative_target.split('?').next().is_some_and(|path| {
13050 path.starts_with("/appointments/")
13051 && (path.ends_with("/complete/")
13052 || path.ends_with("/cancel/")
13053 || path.ends_with("/request-cancel/")
13054 || path.ends_with("/delete/"))
13055 })
13056 && app_uses_file_backed_runtime_state(app)
13057 {
13058 let path = relative_target.split('?').next().unwrap_or_default();
13059 let appointment_id = path
13060 .strip_prefix("/appointments/")
13061 .and_then(|value| value.split('/').next())
13062 .and_then(|value| value.parse::<i64>().ok())
13063 .unwrap_or(0);
13064 if let Some(state_path) = app.state_path.as_deref()
13065 && let Some(mut root) = json_commands::json_doc_load_file(Path::new(state_path))
13066 && let Some(rows) = root.get_mut("appointments").and_then(Value::as_array_mut)
13067 {
13068 if path.ends_with("/delete/") {
13069 rows.retain(|row| row.get("id").and_then(Value::as_i64) != Some(appointment_id));
13070 } else if let Some(row) = rows
13071 .iter_mut()
13072 .find(|row| row.get("id").and_then(Value::as_i64) == Some(appointment_id))
13073 {
13074 let status = if path.ends_with("/complete/") {
13075 "completed"
13076 } else if path.ends_with("/request-cancel/") {
13077 "cancel_requested"
13078 } else {
13079 "cancelled"
13080 };
13081 row["status"] = Value::String(status.to_owned());
13082 } else {
13083 return http_text_response(404, "Not Found", "appointment not found\n");
13084 }
13085 if business::persist_management_state_or_500_local(&root, Path::new(state_path)).is_ok()
13086 {
13087 return http_route_redirect_for_request(
13088 &relative_request,
13089 &app_scope_path(app, "/appointments/"),
13090 );
13091 }
13092 }
13093 return http_text_response(404, "Not Found", "appointment not found\n");
13094 }
13095 if method == "GET" {
13096 let path = relative_target.split('?').next().unwrap_or_default();
13097 if let Some(slug) = path
13098 .strip_prefix("/api/businesses/")
13099 .and_then(|value| value.split('/').next())
13100 {
13101 match ucal_business_api_actor_manages_business(
13102 app,
13103 relative_target,
13104 slug,
13105 relative_request.cookie.as_deref(),
13106 ) {
13107 Ok(Some(false)) => {
13108 return http_json_response(
13109 403,
13110 "Forbidden",
13111 r#"{"schema":"pharos.error.v1","error_code":"capability_denied","message":"capability denied"}"#,
13112 method,
13113 );
13114 }
13115 Ok(Some(true)) | Ok(None) => {}
13116 Err(error) => {
13117 eprintln!("ucal generated API permission check failed: {error:?}");
13118 return http_json_response(
13119 503,
13120 "Service Unavailable",
13121 r#"{"schema":"pharos.error.v1","error":{"code":"permission_check_unavailable","message":"permission check unavailable","retryable":true,"correlation_id":""}}"#,
13122 method,
13123 );
13124 }
13125 }
13126 }
13127 if path
13128 .strip_prefix("/api/businesses/")
13129 .and_then(|value| value.strip_suffix("/services/"))
13130 .is_some()
13131 {
13132 return ucal_business_api_feature_list_response(
13133 app,
13134 relative_target,
13135 method,
13136 relative_request.cookie.as_deref(),
13137 "/api/businesses/:business_slug/services/",
13138 "services_by_business",
13139 true,
13140 );
13141 }
13142 if let Some(slug) = path
13143 .strip_prefix("/api/businesses/")
13144 .and_then(|value| value.strip_suffix("/services/schema/"))
13145 {
13146 return http_json_response(
13147 200,
13148 "OK",
13149 &format!(
13150 "{{\"schema\":\"pharos.generated.api.schema.v1\",\"resource\":\"services\",\"surface\":\"service_generated_api\",\"business_slug\":{}}}",
13151 serde_json::to_string(slug).unwrap_or_default()
13152 ),
13153 method,
13154 );
13155 }
13156 if path
13157 .strip_prefix("/api/businesses/")
13158 .and_then(|value| value.strip_suffix("/availability/"))
13159 .is_some()
13160 {
13161 return ucal_business_api_feature_list_response(
13162 app,
13163 relative_target,
13164 method,
13165 relative_request.cookie.as_deref(),
13166 "/api/businesses/:business_slug/availability/",
13167 "availability_by_business",
13168 false,
13169 );
13170 }
13171 if let Some(slug) = path
13172 .strip_prefix("/api/businesses/")
13173 .and_then(|value| value.strip_suffix("/availability/schema/"))
13174 {
13175 return http_json_response(
13176 200,
13177 "OK",
13178 &format!(
13179 "{{\"schema\":\"pharos.generated.api.schema.v1\",\"resource\":\"availability\",\"surface\":\"business_availability_generated_api\",\"business_slug\":{}}}",
13180 serde_json::to_string(slug).unwrap_or_default()
13181 ),
13182 method,
13183 );
13184 }
13185 if path
13186 .strip_prefix("/api/businesses/")
13187 .and_then(|value| value.strip_suffix("/technicians/"))
13188 .is_some()
13189 {
13190 return ucal_business_api_feature_list_response(
13191 app,
13192 relative_target,
13193 method,
13194 relative_request.cookie.as_deref(),
13195 "/api/businesses/:business_slug/technicians/",
13196 "technicians_by_business",
13197 true,
13198 );
13199 }
13200 if let Some(slug) = path
13201 .strip_prefix("/api/businesses/")
13202 .and_then(|value| value.strip_suffix("/technicians/schema/"))
13203 {
13204 return http_json_response(200, "OK", &serde_json::json!({"schema":"pharos.generated.api.schema.v1","resource":"technicians","surface":"technician_generated_api","business_slug":slug}).to_string(), method);
13205 }
13206 }
13207 if method == "POST"
13208 && let Some(path) = relative_target.split('?').next()
13209 && path.starts_with("/businesses/technicians/")
13210 && path.ends_with("/availability/new/")
13211 && app_uses_file_backed_runtime_state(app)
13212 && dynamic::csrf_request_valid(
13213 &relative_request,
13214 dynamic::request_body_text(&relative_request).as_deref(),
13215 )
13216 && let Some(state_path) = app.state_path.as_deref()
13217 && let Ok(text) = stdfs::read_to_string(state_path)
13218 && let Ok(mut root) = serde_json::from_str::<Value>(&text)
13219 {
13220 let day = dynamic::request_form_value(&relative_request, "day_of_week").unwrap_or_default();
13221 let start =
13222 dynamic::request_form_value(&relative_request, "start_time").unwrap_or_default();
13223 let end = dynamic::request_form_value(&relative_request, "end_time").unwrap_or_default();
13224 let technician_id = path
13225 .strip_prefix("/businesses/technicians/")
13226 .and_then(|value| value.split('/').next())
13227 .and_then(|value| value.parse::<i64>().ok())
13228 .unwrap_or(0);
13229 if technician_id > 0
13230 && business::dynamic_valid_day_of_week(&day)
13231 && start.len() == 5
13232 && end.len() == 5
13233 && start < end
13234 && root
13235 .get("technicians")
13236 .and_then(Value::as_array)
13237 .is_some_and(|rows| {
13238 rows.iter()
13239 .any(|row| row.get("id").and_then(Value::as_i64) == Some(technician_id))
13240 })
13241 {
13242 let rows = root.as_object_mut().and_then(|object| {
13243 object
13244 .entry("technician_availability")
13245 .or_insert_with(|| Value::Array(Vec::new()))
13246 .as_array_mut()
13247 });
13248 if let Some(rows) = rows {
13249 let id = rows
13250 .iter()
13251 .filter_map(|row| row.get("id").and_then(Value::as_i64))
13252 .max()
13253 .unwrap_or(0)
13254 + 1;
13255 rows.push(serde_json::json!({"id": id, "technician_id": technician_id, "day_of_week": day, "start_time": start, "end_time": end}));
13256 let Ok(encoded) = serde_json::to_string(&root) else {
13257 return http_text_response(500, "Internal Server Error", "state unavailable\n");
13258 };
13259 if fs::write_text_file(Path::new(state_path), &encoded) != 0 {
13260 return http_text_response(500, "Internal Server Error", "state unavailable\n");
13261 }
13262 return http_route_redirect(path);
13263 }
13264 }
13265 }
13266 if method == "POST"
13267 && app_uses_file_backed_runtime_state(app)
13268 && relative_target
13269 .split('?')
13270 .next()
13271 .is_some_and(is_business_availability_create_path)
13272 {
13273 return http_text_response(
13274 422,
13275 "Unprocessable Entity",
13276 "availability_day_valid\navailability_end_after_start\n",
13277 );
13278 }
13279 if method == "POST"
13280 && app_uses_file_backed_runtime_state(app)
13281 && relative_target.split('?').next().is_some_and(|path| {
13282 path.starts_with("/businesses/") && path.ends_with("/technicians/new/")
13283 })
13284 && dynamic::request_form_value(&relative_request, "email").is_none()
13285 {
13286 return http_text_response(422, "Unprocessable Entity", "technician_email_required\n");
13287 }
13288 if method == "GET"
13289 && app_uses_file_backed_runtime_state(app)
13290 && relative_target
13291 .split('?')
13292 .next()
13293 .is_some_and(|path| path.starts_with("/businesses/") && path.ends_with("/technicians/"))
13294 {
13295 return app_fragment_response(
13296 app,
13297 "templates/generated_admin/fragments/technician_admin_fallback.html",
13298 serde_json::json!({}),
13299 method,
13300 );
13301 }
13302 let relative_path = relative_target.split('?').next().unwrap_or_default();
13303 if method != "GET"
13304 && method != "HEAD"
13305 && relative_path.starts_with("/appointments/")
13306 && relative_path.ends_with("/request-cancel/")
13307 && relative_path
13308 .strip_prefix("/appointments/")
13309 .and_then(|value| value.split('/').next())
13310 .and_then(|value| value.parse::<i64>().ok())
13311 .is_none_or(|id| id <= 0 || id > 1_000_000)
13312 {
13313 return http_text_response(422, "Unprocessable Entity", "invalid request\n");
13314 }
13315 if let Some(response) = app_declarative_route_response(app, &relative_request) {
13316 return response;
13317 }
13318 http_static_response(
13319 artifact_server::serve_app_request(app, method, relative_target),
13320 method,
13321 )
13322}
13323
13324fn runtime_route_response_with_request(
13326 stack: &RuntimeStack,
13327 request: &http::HttpRequest,
13328) -> Vec<u8> {
13329 if let Some(response) =
13330 framework_builtin_response(&request.method, &request.target, None, Some(stack))
13331 {
13332 return http_text_response(response.status_code, response.status_text, &response.body);
13333 }
13334 let path_only = request.target.split('?').next().unwrap_or_default();
13335 if let Some(app) = runtime_match_failed_app(stack, path_only) {
13336 return runtime_failed_app_response(app, &request.method);
13337 }
13338 let Some(app) = runtime_match_app(stack, path_only) else {
13339 return http_text_response(404, "Not Found", "not found\n");
13340 };
13341 if let Some(endpoint_id) = runtime_framework_webhook_endpoint_id(path_only) {
13342 return runtime_framework_webhook_response(app, request, &endpoint_id);
13343 }
13344 app_route_response_with_request(app, request)
13345}
13346
13347fn handle_runtime_connection(
13349 mut connection: AcceptedConnection,
13350 stack: &RuntimeStack,
13351 single_app: bool,
13352) {
13353 connection.set_timeouts();
13354 let response = match connection.read_request() {
13355 Ok(request) => {
13356 let app = if single_app {
13357 stack.apps.first()
13358 } else {
13359 runtime_match_app(stack, request.target.split('?').next().unwrap_or("/"))
13360 };
13361 if let Some(app) = app
13362 && matches!(request.method.as_str(), "GET" | "HEAD")
13363 {
13364 let render_request = match app_render_request(app, &request) {
13365 Ok(context) => context,
13366 Err(error) => {
13367 let response = pharos_runtime_static::error_page::browser_response(
13368 &request,
13369 database_error_response(error),
13370 );
13371 connection.write_all(&response);
13372 return;
13373 }
13374 };
13375 let relative_target = render_request.relative_target.as_str();
13376 let relative_path = render_request.relative_path.as_str();
13377 let range = dynamic::request_header_value(&request, "range");
13378 let if_range = dynamic::request_header_value(&request, "if-range");
13379 match feature_runtime::authorize_private_asset(
13380 app,
13381 relative_path,
13382 render_request.actor_id,
13383 ) {
13384 Ok(Some(asset)) => {
13385 match artifact_server::serve_app_private_asset(
13386 app,
13387 &asset.private_root,
13388 &asset.asset_key,
13389 &request.method,
13390 range.as_deref(),
13391 if_range.as_deref(),
13392 ) {
13393 Ok(stream_response) => {
13394 connection.write_stream(&stream_response, &request.method);
13395 }
13396 Err(error) => {
13397 connection.write_all(
13398 &pharos_runtime_static::error_page::browser_response(
13399 &request,
13400 http_stream_error(error, &request.method),
13401 ),
13402 );
13403 }
13404 }
13405 connection.shutdown();
13406 return;
13407 }
13408 Ok(None) => {}
13409 Err(error) => {
13410 eprintln!("private asset authorization failed: {error:?}");
13411 connection.write_all(&pharos_runtime_static::error_page::browser_response(
13412 &request,
13413 http_text_response(
13414 503,
13415 "Service Unavailable",
13416 "private asset authorization unavailable\n",
13417 ),
13418 ));
13419 connection.shutdown();
13420 return;
13421 }
13422 }
13423 match artifact_server::serve_app_streamable_asset(
13424 app,
13425 &request.method,
13426 relative_target,
13427 range.as_deref(),
13428 if_range.as_deref(),
13429 ) {
13430 Ok(Some(stream_response)) => {
13431 connection.write_stream(&stream_response, &request.method);
13432 connection.shutdown();
13433 return;
13434 }
13435 Err(error) => {
13436 let response = pharos_runtime_static::error_page::browser_response(
13437 &request,
13438 http_stream_error(error, &request.method),
13439 );
13440 connection.write_all(&response);
13441 connection.shutdown();
13442 return;
13443 }
13444 Ok(None) => {}
13445 }
13446 }
13447 let response = if single_app {
13448 app.map(|app| app_route_response_with_request(app, &request))
13449 .unwrap_or_else(|| {
13450 http_text_response(503, "Service Unavailable", "app unavailable\n")
13451 })
13452 } else {
13453 runtime_route_response_with_request(stack, &request)
13454 };
13455 pharos_runtime_static::error_page::browser_response(&request, response)
13456 }
13457 Err("request connection closed") => Vec::new(),
13458 Err(error) => {
13459 eprintln!("native runtime rejected HTTP request: {error}");
13460 http_text_response(400, "Bad Request", "bad request\n")
13461 }
13462 };
13463 connection.write_all(&response);
13464 connection.shutdown();
13465}
13466
13467fn reject_overloaded_connection(mut connection: AcceptedConnection) {
13469 connection.set_timeouts();
13470 connection.write_all(&http_text_response(
13471 503,
13472 "Service Unavailable",
13473 "request queue full\n",
13474 ));
13475 connection.shutdown();
13476}
13477
13478fn persist_background_state(
13480 app: &AppRuntime,
13481 state_path: Option<&Path>,
13482 root: &Value,
13483 authoritative_snapshot: Option<&str>,
13484 document_snapshot: Option<&pharos_runtime_database::document::DocumentSnapshot>,
13485) -> bool {
13486 if app_uses_file_backed_runtime_state(app) {
13487 return state_path.is_some_and(|path| persist_file_background_state(app, path, root));
13488 }
13489 if let Some(snapshot) = document_snapshot {
13490 let Some(store) = app_database_operations(app) else {
13491 return false;
13492 };
13493 return pharos_runtime_database::document::complete_worker(
13494 store.adapter.as_ref(),
13495 &store.config,
13496 &store.schema,
13497 snapshot,
13498 root,
13499 )
13500 .is_ok();
13501 }
13502 let Some(snapshot) = authoritative_snapshot else {
13503 return false;
13504 };
13505 let Ok(before) = serde_json::from_str::<Value>(snapshot) else {
13506 return false;
13507 };
13508 if !matches!(
13509 before.get("_worker_scope").and_then(Value::as_str),
13510 Some("job" | "queue")
13511 ) {
13512 return false;
13513 }
13514 let Some(store) = app_database_operations(app) else {
13515 return false;
13516 };
13517 pharos_runtime_database::state_delta::apply_worker_delta(
13518 store.adapter.as_ref(),
13519 &store.config,
13520 &store.schema,
13521 &before,
13522 root,
13523 )
13524 .is_ok()
13525}
13526
13527fn persist_file_background_state(app: &AppRuntime, state_path: &Path, root: &Value) -> bool {
13529 if root.as_object().is_none_or(|sections| {
13530 sections.values().any(|value| {
13531 value
13532 .as_array()
13533 .is_some_and(|rows| rows.len() > app.resource_budgets.max_state_section_items)
13534 })
13535 }) {
13536 return false;
13537 }
13538 let Ok(encoded) = serde_json::to_string(root) else {
13539 return false;
13540 };
13541 encoded.len() <= app.resource_budgets.max_query_result_bytes
13542 && fs::write_text_file(state_path, &encoded) == 0
13543}
13544
13545fn process_one_background_job(app: &AppRuntime) {
13547 if !app.queue_enabled {
13548 return;
13549 }
13550 let store = if app_uses_file_backed_runtime_state(app) {
13551 None
13552 } else {
13553 match app_database_operations(app) {
13554 Some(store) => Some(store),
13555 None => return,
13556 }
13557 };
13558 let document_snapshot = if let Some(store) = &store
13559 && let Some((entity, field, identity)) = dynamic::declared_document_storage(app)
13560 {
13561 let Ok(snapshot) = pharos_runtime_database::document::read_document(
13562 store.adapter.as_ref(),
13563 &store.config,
13564 &store.schema,
13565 &entity,
13566 identity,
13567 &field,
13568 ) else {
13569 return;
13570 };
13571 if !pharos_runtime_database::document::has_due_work(&snapshot.value) {
13572 return;
13573 }
13574 match pharos_runtime_database::document::claim_worker(
13575 store.adapter.as_ref(),
13576 &store.config,
13577 &store.schema,
13578 &snapshot,
13579 ) {
13580 Ok(claimed) => Some(claimed),
13581 Err(_) => return,
13582 }
13583 } else {
13584 None
13585 };
13586 if document_snapshot.is_none()
13587 && let Some(store) = &store
13588 {
13589 match pharos_runtime_database::queue::pending_queue_work(
13590 store.adapter.as_ref(),
13591 &store.config,
13592 &store.schema,
13593 ) {
13594 Ok(Some(true)) => {}
13595 _ => return,
13596 }
13597 }
13598 let state_path = app.state_path.as_deref().map(Path::new);
13599 let _guard = if store.is_none() {
13600 let Some(path) = state_path else {
13601 return;
13602 };
13603 let Ok(guard) = state_store::StateMutationGuard::acquire(path) else {
13604 return;
13605 };
13606 Some(guard)
13607 } else {
13608 None
13609 };
13610 let scoped_snapshot = if let Some(snapshot) = &document_snapshot {
13611 Some(snapshot.value.clone())
13612 } else if let Some(store) = &store {
13613 match pharos_runtime_database::queue::scoped_job_worker_snapshot(
13614 store.adapter.as_ref(),
13615 &store.config,
13616 &store.schema,
13617 ) {
13618 Ok(Some(snapshot)) => Some(snapshot),
13619 _ => return,
13620 }
13621 } else {
13622 None
13623 };
13624 let authoritative_snapshot = match scoped_snapshot.as_ref() {
13625 Some(snapshot) => match serde_json::to_string(snapshot) {
13626 Ok(text) => Some(text),
13627 Err(_) => return,
13628 },
13629 None => None,
13630 };
13631 let Some(mut root) = scoped_snapshot.or_else(|| {
13632 state_path
13633 .and_then(|path| stdfs::read_to_string(path).ok())
13634 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
13635 }) else {
13636 return;
13637 };
13638 let now = current_timestamp_utc();
13639 let now_epoch = SystemTime::now()
13640 .duration_since(UNIX_EPOCH)
13641 .map(|value| value.as_secs())
13642 .unwrap_or(u64::MAX);
13643 let integrations_root = dynamic::integration_contract_doc(app)
13644 .unwrap_or_else(|| serde_json::json!({"adapters": []}));
13645 let notification_processed =
13646 dynamic::process_one_due_notification_delivery(app, &mut root, &integrations_root);
13647 let integration_processed = dynamic::process_one_due_integration_delivery(&mut root);
13648 let file_processed = dynamic::process_one_due_file_delivery(app, &mut root);
13649 let job_index = root
13650 .get("job_executions")
13651 .and_then(Value::as_array)
13652 .and_then(|rows| {
13653 rows.iter().position(|row| {
13654 row.get("execution_status").and_then(Value::as_str) == Some("queued")
13655 && row
13656 .get("available_at")
13657 .and_then(Value::as_str)
13658 .and_then(runtime_timestamp_seconds)
13659 .is_none_or(|available| available <= now_epoch)
13660 })
13661 });
13662 let Some(index) = job_index else {
13663 if notification_processed
13664 || integration_processed
13665 || file_processed
13666 || document_snapshot.is_some()
13667 {
13668 let _ = persist_background_state(
13669 app,
13670 state_path,
13671 &root,
13672 authoritative_snapshot.as_deref(),
13673 document_snapshot.as_ref(),
13674 );
13675 }
13676 return;
13677 };
13678 let Some(rows) = root.get_mut("job_executions").and_then(Value::as_array_mut) else {
13679 return;
13680 };
13681 let execution_id = rows[index].get("id").and_then(Value::as_i64).unwrap_or(0);
13682 let workflow_execution_id = rows[index]
13683 .get("workflow_execution_id")
13684 .and_then(Value::as_i64)
13685 .unwrap_or(0);
13686 let queue_name = rows[index]
13687 .get("queue_name")
13688 .and_then(Value::as_str)
13689 .unwrap_or("default")
13690 .to_owned();
13691 let job_id = rows[index]
13692 .get("job_id")
13693 .and_then(Value::as_str)
13694 .unwrap_or_default()
13695 .to_owned();
13696 let attempt_number = rows[index]
13697 .get("attempt_count")
13698 .and_then(Value::as_i64)
13699 .unwrap_or(0)
13700 .saturating_add(1);
13701 let max_attempts = rows[index]
13702 .get("max_attempts")
13703 .and_then(Value::as_i64)
13704 .unwrap_or(app.queue_max_attempts.max(1))
13705 .max(1);
13706 let backoff_seconds = rows[index]
13707 .get("backoff_seconds")
13708 .and_then(Value::as_i64)
13709 .unwrap_or(app.queue_retry_seconds.max(0))
13710 .max(0) as u64;
13711 let lease_seconds = rows[index]
13712 .get("lease_seconds")
13713 .and_then(Value::as_i64)
13714 .unwrap_or(30)
13715 .clamp(1, 3600) as u64;
13716 let simulated_failure = rows[index]
13717 .get("handler_id")
13718 .and_then(Value::as_str)
13719 .is_some_and(|handler| handler == "simulate-failure" || handler == "simulate_failure");
13720 rows[index]["execution_status"] = Value::String("running".to_owned());
13721 rows[index]["attempt_count"] = Value::from(attempt_number);
13722 rows[index]["claimed_at"] = Value::String(now.clone());
13723 rows[index]["lease_expires_at"] = Value::String(
13724 SystemTime::now()
13725 .duration_since(UNIX_EPOCH)
13726 .map(|value| value.as_secs().saturating_add(lease_seconds).to_string())
13727 .unwrap_or_default(),
13728 );
13729 let claimed_at = rows[index]
13730 .get("claimed_at")
13731 .and_then(Value::as_str)
13732 .unwrap_or_default()
13733 .to_owned();
13734 let lease_expires_at = rows[index]
13735 .get("lease_expires_at")
13736 .and_then(Value::as_str)
13737 .unwrap_or_default()
13738 .to_owned();
13739 let execution_status = if simulated_failure && attempt_number < max_attempts {
13740 rows[index]["available_at"] = Value::String(
13741 SystemTime::now()
13742 .duration_since(UNIX_EPOCH)
13743 .map(|value| value.as_secs().saturating_add(backoff_seconds).to_string())
13744 .unwrap_or_default(),
13745 );
13746 "queued"
13747 } else if simulated_failure {
13748 "failed"
13749 } else {
13750 rows[index]["completed_at"] = Value::String(now);
13751 "succeeded"
13752 };
13753 rows[index]["execution_status"] = Value::String(execution_status.to_owned());
13754 rows[index]["last_error"] = Value::String(if simulated_failure {
13755 "simulated handler failure".to_owned()
13756 } else {
13757 String::new()
13758 });
13759 let next_attempt_id = root
13760 .get("job_execution_attempts")
13761 .and_then(Value::as_array)
13762 .into_iter()
13763 .flatten()
13764 .filter_map(|row| row.get("id").and_then(Value::as_i64))
13765 .max()
13766 .unwrap_or(0)
13767 .saturating_add(1);
13768 if let Some(attempts) = root.as_object_mut().and_then(|object| {
13769 object
13770 .entry("job_execution_attempts")
13771 .or_insert_with(|| Value::Array(Vec::new()))
13772 .as_array_mut()
13773 }) {
13774 attempts.push(serde_json::json!({
13775 "id": next_attempt_id,
13776 "job_execution_id": execution_id,
13777 "attempt_number": attempt_number,
13778 "execution_status": execution_status,
13779 "error_message": if simulated_failure { "simulated handler failure" } else { "" },
13780 "payload_json": "{}",
13781 "runtime_identity": runtime_build_identity_value(app)
13782 }));
13783 }
13784 let next_event_id = root
13785 .get("workflow_execution_events")
13786 .and_then(Value::as_array)
13787 .into_iter()
13788 .flatten()
13789 .filter_map(|row| row.get("id").and_then(Value::as_i64))
13790 .max()
13791 .unwrap_or(0)
13792 .saturating_add(1);
13793 if workflow_execution_id > 0
13794 && let Some(events) = root.as_object_mut().and_then(|object| {
13795 object
13796 .entry("workflow_execution_events")
13797 .or_insert_with(|| Value::Array(Vec::new()))
13798 .as_array_mut()
13799 })
13800 {
13801 events.push(serde_json::json!({
13802 "id": next_event_id,
13803 "workflow_execution_id": workflow_execution_id,
13804 "event_kind": "job_started",
13805 "event_key": job_id,
13806 "payload_json": serde_json::json!({
13807 "job_execution_id": execution_id,
13808 "job_id": job_id,
13809 "queue_name": queue_name,
13810 "attempt_number": attempt_number,
13811 "execution_status": "running",
13812 "claimed_at": claimed_at,
13813 "lease_expires_at": lease_expires_at,
13814 "lease_seconds": lease_seconds,
13815 "backoff_seconds": backoff_seconds,
13816 }).to_string(),
13817 "created_at": current_timestamp_utc(),
13818 }));
13819 let job_event_kind = match execution_status {
13820 "queued" => "job_retried",
13821 "failed" => "job_failed",
13822 _ => "job_succeeded",
13823 };
13824 let job_event_payload = serde_json::json!({
13825 "job_execution_id": execution_id,
13826 "job_id": job_id,
13827 "queue_name": queue_name,
13828 "attempt_number": attempt_number,
13829 "execution_status": execution_status,
13830 "claimed_at": claimed_at,
13831 "lease_expires_at": lease_expires_at,
13832 "lease_seconds": lease_seconds,
13833 "backoff_seconds": backoff_seconds,
13834 });
13835 events.push(serde_json::json!({
13836 "id": next_event_id.saturating_add(1),
13837 "workflow_execution_id": workflow_execution_id,
13838 "event_kind": job_event_kind,
13839 "event_key": job_id,
13840 "payload_json": job_event_payload.to_string(),
13841 "created_at": current_timestamp_utc(),
13842 }));
13843 }
13844 if !persist_background_state(
13845 app,
13846 state_path,
13847 &root,
13848 authoritative_snapshot.as_deref(),
13849 document_snapshot.as_ref(),
13850 ) {
13851 eprintln!(
13852 "background state persistence failed: {}",
13853 app.app_id.as_deref().unwrap_or("unknown")
13854 );
13855 }
13856}
13857
13858static RUNTIME_SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
13859
13860#[cfg(not(windows))]
13861fn install_runtime_shutdown_signal_handlers() {
13862 RUNTIME_SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
13863 static INSTALLED: OnceLock<()> = OnceLock::new();
13864 let _ = INSTALLED.get_or_init(|| {
13865 let _ = std::thread::Builder::new()
13866 .name("pharos-runtime-signal".to_owned())
13867 .spawn(|| {
13868 if let Ok(mut signals) = signal_hook::iterator::Signals::new([
13869 signal_hook::consts::signal::SIGINT,
13870 signal_hook::consts::signal::SIGTERM,
13871 ]) && signals.forever().next().is_some()
13872 {
13873 RUNTIME_SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
13874 }
13875 });
13876 });
13877}
13878
13879#[cfg(windows)]
13880fn install_runtime_shutdown_signal_handlers() {
13881 RUNTIME_SHUTDOWN_REQUESTED.store(false, Ordering::SeqCst);
13882}
13883
13884fn runtime_shutdown_requested() -> bool {
13885 RUNTIME_SHUTDOWN_REQUESTED.load(Ordering::SeqCst)
13886}
13887
13888fn stop_runtime_workers_and_flush(
13889 worker_pool: pharos_runtime_core::worker_pool::WorkerPool<AcceptedConnection>,
13890 job_workers: Vec<job::JobWorkerState>,
13891) -> Result<(), &'static str> {
13892 for job_worker in &job_workers {
13893 let _ = job::job_worker_stop(job_worker);
13894 }
13895 #[cfg(unix)]
13896 social_connections::shutdown_browsers();
13897 worker_pool
13898 .destroy()
13899 .map_err(|_| "request worker shutdown failed")?;
13900 Ok(())
13901}
13902
13903fn start_runtime_job_worker(stack: Arc<RwLock<RuntimeStack>>) -> Option<job::JobWorkerState> {
13905 let poll_interval = stack.read().ok().and_then(|stack| {
13906 stack
13907 .apps
13908 .iter()
13909 .filter(|app| app.queue_enabled)
13910 .map(|app| app.queue_poll_interval_ms.max(250) as u64)
13911 .min()
13912 })?;
13913 let poll_stack = Arc::clone(&stack);
13914 let state = job::JobWorkerState::default()
13915 .with_poll_interval_ms(poll_interval)
13916 .with_poll_handler(Arc::new(move || {
13917 let apps = poll_stack
13918 .read()
13919 .ok()
13920 .map(|stack| stack.apps.clone())
13921 .unwrap_or_default();
13922 for app in &apps {
13923 process_one_background_job(app);
13924 }
13925 }));
13926 job::job_worker_start(&state, None).ok()?;
13927 Some(state)
13928}
13929
13930fn serve_runtime_stack_with_limit(
13932 config_record: &ServeConfig,
13933 max_requests: Option<usize>,
13934) -> Result<(), &'static str> {
13935 cache::install_runtime_cache(config_record);
13936 #[cfg(not(target_os = "wasi"))]
13937 social_connections::install_operator_config(config_record)?;
13938 install_runtime_shutdown_signal_handlers();
13939 let load_started = Instant::now();
13940 let stack = match load_runtime_stack_direct(config_record) {
13941 Ok(stack) => stack,
13942 Err(error) => {
13943 eprintln!("runtime stack load failed: {error}");
13944 return Err("failed to load Pharos runtime stack");
13945 }
13946 };
13947 let load_ms = load_started.elapsed().as_millis() as i64;
13948 let apps_dir = runtime_apps_dir(config_record.conf_path.as_deref().map(Path::new))
13949 .ok_or("missing apps_dir")?;
13950 let apps_dir_path = PathBuf::from(&apps_dir);
13951 let mut fingerprint = runtime_apps_fingerprint(&apps_dir_path)?;
13952 let mut last_fingerprint_check = Instant::now();
13953 let listener = open_runtime_listener(config_record, &stack)?;
13954 emit_runtime_log_banner(&stack, config_record, load_ms);
13955 let stack = Arc::new(RwLock::new(stack));
13956 let worker_count = crate::binaries::resolved_worker_count(config_record);
13957 let queue_capacity = crate::binaries::resolved_work_queue_capacity(config_record);
13958 let worker_stack = Arc::clone(&stack);
13959 let worker_pool = pharos_runtime_core::worker_pool::WorkerPool::new(
13960 worker_count,
13961 queue_capacity,
13962 Arc::new(move |connection: AcceptedConnection| {
13963 if let Ok(stack) = worker_stack.read() {
13964 handle_runtime_connection(connection, &stack, false);
13965 }
13966 }),
13967 )
13968 .map_err(|_| "failed to start request workers")?;
13969 let job_worker = [
13970 start_runtime_job_worker(Arc::clone(&stack)),
13971 event_discovery::start_worker(Arc::clone(&stack)),
13972 ]
13973 .into_iter()
13974 .flatten()
13975 .collect();
13976 println!(
13977 "pharos native runtime worker_pool=true workers={worker_count} queue_capacity={queue_capacity} listener={}",
13978 listener.local_endpoint()
13979 );
13980
13981 let mut handled_requests = 0usize;
13982 loop {
13983 if runtime_shutdown_requested() {
13984 return stop_runtime_workers_and_flush(worker_pool, job_worker);
13985 }
13986 if last_fingerprint_check.elapsed() >= Duration::from_secs(1) {
13987 let next_fingerprint = runtime_apps_fingerprint(&apps_dir_path)?;
13988 if let Some(next_fingerprint) = next_fingerprint
13989 && fingerprint.as_deref() != Some(next_fingerprint.as_str())
13990 {
13991 let refreshed_stack = load_runtime_stack_direct(config_record)
13992 .map_err(|_| "failed to refresh Pharos runtime stack")?;
13993 if let Ok(mut active_stack) = stack.write() {
13994 *active_stack = refreshed_stack;
13995 emit_runtime_log_banner(&active_stack, config_record, 0);
13996 }
13997 cache::invalidate_route_match_namespace();
13998 fingerprint = Some(next_fingerprint);
13999 }
14000 last_fingerprint_check = Instant::now();
14001 }
14002
14003 match listener.accept() {
14004 Ok(connection) => {
14005 let _ =
14006 worker_pool.enqueue_with_rejection(connection, reject_overloaded_connection);
14007 handled_requests += 1;
14008 if max_requests.is_some_and(|limit| handled_requests >= limit) {
14009 return stop_runtime_workers_and_flush(worker_pool, job_worker);
14010 }
14011 }
14012 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
14013 thread::sleep(Duration::from_millis(5));
14014 }
14015 Err(_) => {
14016 let _ = stop_runtime_workers_and_flush(worker_pool, job_worker);
14017 return Err("listener accept failed");
14018 }
14019 }
14020 }
14021}
14022
14023fn serve_app_runtime_with_limit(
14025 config_record: &ServeConfig,
14026 repo_root: Option<&Path>,
14027 binary_dir: Option<&Path>,
14028 max_requests: Option<usize>,
14029) -> Result<(), &'static str> {
14030 cache::install_runtime_cache(config_record);
14031 #[cfg(not(target_os = "wasi"))]
14032 social_connections::install_operator_config(config_record)?;
14033 install_runtime_shutdown_signal_handlers();
14034 let load_started = Instant::now();
14035 let app = load_app_runtime_for_single_app_direct(config_record, repo_root, binary_dir)?;
14036 verify_app_migration_state(&app)?;
14037 let stack = RuntimeStack {
14038 apps: vec![app.clone()],
14039 failed_apps: Vec::new(),
14040 host: config_record
14041 .host
14042 .clone()
14043 .or_else(|| Some("127.0.0.1".to_owned())),
14044 port: config_record
14045 .port
14046 .clone()
14047 .or_else(|| Some("7323".to_owned())),
14048 socket_path: config_record.socket_path.clone(),
14049 };
14050 let load_ms = load_started.elapsed().as_millis() as i64;
14051 let listener = open_runtime_listener(config_record, &stack)?;
14052 emit_runtime_log_banner(&stack, config_record, load_ms);
14053 let stack = Arc::new(RwLock::new(stack));
14054 let worker_count = crate::binaries::resolved_worker_count(config_record);
14055 let queue_capacity = crate::binaries::resolved_work_queue_capacity(config_record);
14056 let worker_stack = Arc::clone(&stack);
14057 let worker_pool = pharos_runtime_core::worker_pool::WorkerPool::new(
14058 worker_count,
14059 queue_capacity,
14060 Arc::new(move |connection: AcceptedConnection| {
14061 if let Ok(stack) = worker_stack.read() {
14062 handle_runtime_connection(connection, &stack, true);
14063 }
14064 }),
14065 )
14066 .map_err(|_| "failed to start request workers")?;
14067 let job_worker = [
14068 start_runtime_job_worker(Arc::clone(&stack)),
14069 event_discovery::start_worker(Arc::clone(&stack)),
14070 ]
14071 .into_iter()
14072 .flatten()
14073 .collect();
14074 println!(
14075 "pharos native app runtime worker_pool=true workers={worker_count} queue_capacity={queue_capacity} listener={}",
14076 listener.local_endpoint()
14077 );
14078
14079 let mut handled_requests = 0usize;
14080 loop {
14081 if runtime_shutdown_requested() {
14082 return stop_runtime_workers_and_flush(worker_pool, job_worker);
14083 }
14084 match listener.accept() {
14085 Ok(connection) => {
14086 let _ =
14087 worker_pool.enqueue_with_rejection(connection, reject_overloaded_connection);
14088 handled_requests += 1;
14089 if max_requests.is_some_and(|limit| handled_requests >= limit) {
14090 return stop_runtime_workers_and_flush(worker_pool, job_worker);
14091 }
14092 }
14093 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
14094 thread::sleep(Duration::from_millis(5));
14095 }
14096 Err(_) => {
14097 let _ = stop_runtime_workers_and_flush(worker_pool, job_worker);
14098 return Err("listener accept failed");
14099 }
14100 }
14101 }
14102}
14103
14104pub fn serve_runtime_stack(config_record: &ServeConfig) -> Result<(), &'static str> {
14128 if config_record.kind.as_deref() != Some("runtime") {
14129 return Err("serve runtime requires runtime target kind");
14130 }
14131 if config_record.target_id.as_deref() != Some("shared") {
14132 return Err("serve runtime currently supports only shared target");
14133 }
14134 serve_runtime_stack_with_limit(config_record, None)
14135}
14136
14137pub fn serve_runtime_stack_direct_for_test(
14162 config_record: &ServeConfig,
14163 max_requests: usize,
14164) -> Result<(), &'static str> {
14165 serve_runtime_stack_with_limit(config_record, Some(max_requests))
14166}
14167
14168pub fn serve_single_app(
14194 config_record: &ServeConfig,
14195 repo_root: Option<&Path>,
14196 binary_dir: Option<&Path>,
14197) -> Result<(), &'static str> {
14198 if config_record.kind.as_deref() != Some("app") {
14199 return Err("serve app requires app target kind");
14200 }
14201 serve_app_runtime_with_limit(config_record, repo_root, binary_dir, None)
14202}
14203
14204pub fn serve_app_runtime_direct_for_test(
14231 config_record: &ServeConfig,
14232 repo_root: Option<&Path>,
14233 binary_dir: Option<&Path>,
14234 max_requests: usize,
14235) -> Result<(), &'static str> {
14236 serve_app_runtime_with_limit(config_record, repo_root, binary_dir, Some(max_requests))
14237}
14238
14239pub fn build_runtime_status_report_json(
14268 stack: &RuntimeStack,
14269 apps_dir: &str,
14270) -> Result<String, &'static str> {
14271 let apps: Vec<Value> = stack.apps.iter().map(runtime_app_summary_value).collect();
14272 let failed_apps: Vec<Value> = stack
14273 .failed_apps
14274 .iter()
14275 .map(runtime_failed_app_summary_value)
14276 .collect();
14277
14278 serde_json::to_string(&serde_json::json!({
14279 "report_id": "pharos-runtime-status-v1",
14280 "status": if stack.failed_apps.is_empty() { "ok" } else { "degraded" },
14281 "host": stack.host.clone().unwrap_or_else(|| "127.0.0.1".to_owned()),
14282 "port": stack.port.clone().unwrap_or_else(|| "7323".to_owned()),
14283 "socket_path": stack.socket_path.clone().unwrap_or_default(),
14284 "redis_status": cache::redis_status(),
14285 "apps_dir": apps_dir,
14286 "app_count": stack.apps.len(),
14287 "failed_app_count": stack.failed_apps.len(),
14288 "apps": apps,
14289 "failed_apps": failed_apps,
14290 }))
14291 .map_err(|_| "report encode failed")
14292}
14293
14294pub fn handle_runtime_app_command_direct(
14325 verb: &str,
14326 config_record: &mut ServeConfig,
14327 repo_root: Option<&Path>,
14328 binary_dir: Option<&Path>,
14329) -> Result<String, &'static str> {
14330 if !runtime_verb_supported(verb) {
14331 return Err("unsupported verb");
14332 }
14333 if config_record.target_id.as_deref().is_none_or(str::is_empty) {
14334 return Err("missing target_id");
14335 }
14336 if verb == "build" && config_record.build_dir.as_deref().is_none_or(str::is_empty) {
14337 config_record.build_dir = Some("dist".to_owned());
14338 }
14339
14340 if verb == "build" {
14341 let source_root = config_record
14342 .app_dir
14343 .as_deref()
14344 .map(PathBuf::from)
14345 .or_else(|| {
14346 config::find_app_root_for_target(
14347 config_record.target_id.as_deref().unwrap_or_default(),
14348 None,
14349 repo_root,
14350 binary_dir,
14351 )
14352 .map(PathBuf::from)
14353 });
14354 let Some(app_root) = source_root else {
14355 return Err("app source root could not be resolved");
14356 };
14357 if packaged_artifact_root_matches(
14358 &app_root,
14359 config_record.target_id.as_deref().unwrap_or_default(),
14360 ) {
14361 } else {
14364 let build_dir = config_record
14365 .build_dir
14366 .as_deref()
14367 .filter(|value| !value.is_empty())
14368 .ok_or("missing build_dir")?;
14369 if let Err(status) = build_app_from_root(&app_root, Path::new(build_dir)) {
14370 eprintln!(
14371 "app build failed: app_id={} source_root={} build_dir={} status_code={status}",
14372 config_record.target_id.as_deref().unwrap_or_default(),
14373 app_root.display(),
14374 build_dir
14375 );
14376 return Err(
14377 "app build failed; see stderr for app_id, source_root, build_dir, and failing build step",
14378 );
14379 }
14380 }
14381 }
14382
14383 let artifact_root = config_record
14384 .app_dir
14385 .as_deref()
14386 .map(Path::new)
14387 .filter(|path| {
14388 packaged_artifact_root_matches(
14389 path,
14390 config_record.target_id.as_deref().unwrap_or_default(),
14391 )
14392 });
14393
14394 let app = load_app_runtime_for_single_app_direct(config_record, repo_root, binary_dir)?;
14395 let artifact_dir = artifact_root
14396 .map(|path| path.to_string_lossy().into_owned())
14397 .or_else(|| app.artifact_dir.clone())
14398 .unwrap_or_default();
14399 let build_report_path = if verb == "build" {
14400 app.build_dir
14401 .as_deref()
14402 .filter(|value| !value.is_empty())
14403 .map(Path::new)
14404 .map(|build_dir| {
14405 website_report_path(
14406 build_dir,
14407 "build-app",
14408 app.app_id.as_deref().unwrap_or("app"),
14409 )
14410 })
14411 } else {
14412 None
14413 };
14414 let report_json = if verb == "doctor" {
14415 build_app_doctor_report_json(&artifact_dir, &app)?
14416 } else {
14417 app_runtime_report_json(runtime_report_id_for_verb(verb), verb, &artifact_dir, &app)?
14418 };
14419 if let Some(path) = build_report_path.as_deref() {
14420 write_report_file(path, &report_json)?;
14421 }
14422 Ok(report_json)
14423}
14424
14425pub fn clear_cloned_app(app: &mut AppRuntime) {
14445 *app = AppRuntime::default();
14446}
14447
14448pub fn dynamic_runtime_handle_request(
14470 method: &str,
14471 target: &str,
14472 stack: &RuntimeStack,
14473) -> Option<FrameworkBuiltinResponse> {
14474 pharos_runtime_core::request::dynamic_runtime_handle_request(
14475 method,
14476 target,
14477 stack,
14478 PHAROS_VERSION,
14479 PHAROS_TARGET_ID,
14480 app_runtime_probe_ready,
14481 runtime_stack_probe_ready,
14482 &RootRuntimeRequestService,
14483 )
14484}
14485
14486pub fn clone_optional_string(value: Option<&str>) -> Option<String> {
14506 value.map(ToOwned::to_owned)
14507}
14508
14509#[cfg(not(target_os = "wasi"))]
14533pub fn decode_qr_image(
14534 image_path: &Path,
14535) -> Result<String, crate::runtime::agent_bridge::AgentErrorKind> {
14536 let image_path = image_path
14537 .canonicalize()
14538 .map_err(|_| crate::runtime::agent_bridge::AgentErrorKind::InvalidPath)?;
14539 let allowed_root = image_path
14540 .parent()
14541 .ok_or(crate::runtime::agent_bridge::AgentErrorKind::InvalidPath)?;
14542 let response =
14543 agent_media::decode_barcode_image_with_platform_helper(&image_path, allowed_root)?;
14544 let result = response
14545 .get("results")
14546 .and_then(Value::as_array)
14547 .and_then(|results| results.first())
14548 .ok_or(crate::runtime::agent_bridge::AgentErrorKind::InvalidEnvelope)?;
14549 serde_json::to_string(&serde_json::json!({
14550 "text": result.get("text").and_then(Value::as_str).unwrap_or_default(),
14551 "format": result.get("format").and_then(Value::as_str).unwrap_or_default(),
14552 "direct_link": result.get("direct_link").and_then(Value::as_bool).unwrap_or(false),
14553 }))
14554 .map_err(|_| crate::runtime::agent_bridge::AgentErrorKind::InvalidEnvelope)
14555}
14556
14557#[cfg(not(target_os = "wasi"))]
14585pub fn barcode_reader_main(image_path: &Path) -> Result<String, i32> {
14586 let image_path = image_path.canonicalize().map_err(|_| 64)?;
14587 let allowed_root = image_path.parent().ok_or(64)?;
14588 let response =
14589 agent_media::decode_barcode_image_with_platform_helper(&image_path, allowed_root).map_err(
14590 |error| match error {
14591 crate::runtime::agent_bridge::AgentErrorKind::InvalidPath
14592 | crate::runtime::agent_bridge::AgentErrorKind::PolicyViolation => 64,
14593 crate::runtime::agent_bridge::AgentErrorKind::CredentialUnavailable => 69,
14594 crate::runtime::agent_bridge::AgentErrorKind::CapacityExceeded => 74,
14595 _ => 1,
14596 },
14597 )?;
14598 serde_json::to_string(&response).map_err(|_| 74)
14599}
14600
14601pub fn handle_runtime_command_direct(
14632 verb: &str,
14633 config_record: &mut ServeConfig,
14634 repo_root: Option<&Path>,
14635 binary_dir: Option<&Path>,
14636) -> Result<String, &'static str> {
14637 match config_record.kind.as_deref() {
14638 Some("app") => {
14639 return handle_runtime_app_command_direct(verb, config_record, repo_root, binary_dir);
14640 }
14641 Some("runtime") => {}
14642 Some("website") => {
14643 return Err("website target kind is not supported by the native runtime");
14644 }
14645 _ => return Err("unsupported runtime kind"),
14646 }
14647 if !matches!(verb, "status" | "build") {
14648 return Err("runtime verb is not supported for the shared target");
14649 }
14650
14651 cache::install_runtime_cache(config_record);
14652 let apps_dir = runtime_apps_dir(config_record.conf_path.as_deref().map(Path::new))
14653 .ok_or("missing apps_dir")?;
14654 let stack = load_runtime_stack_direct(config_record)?;
14655 let report_json = if verb == "build" {
14656 build_runtime_build_report_json(
14657 config_record.target_id.as_deref().unwrap_or_default(),
14658 config_record.conf_path.as_deref(),
14659 &apps_dir,
14660 stack.apps.len(),
14661 )?
14662 } else {
14663 cache::probe_runtime_cache();
14664 build_runtime_status_report_json(&stack, &apps_dir)?
14665 };
14666 let report_build_dir = config_record
14667 .build_dir
14668 .as_deref()
14669 .filter(|value| !value.is_empty())
14670 .unwrap_or("dist");
14671 let report_path = website_report_path(
14672 Path::new(report_build_dir),
14673 verb,
14674 config_record.target_id.as_deref().unwrap_or_default(),
14675 );
14676 write_report_file(&report_path, &report_json)?;
14677 Ok(report_json)
14678}
14679
14680pub fn app_runtime_host_profile(app_root: &Path) -> Option<String> {
14700 read_source_manifest_value(app_root)
14701 .ok()?
14702 .get("runtime")
14703 .and_then(Value::as_object)?
14704 .get("host_profile")
14705 .and_then(Value::as_str)
14706 .filter(|value| !value.is_empty())
14707 .map(ToOwned::to_owned)
14708}
14709
14710fn generated_ownership_manifest_matches_source_digest(
14739 artifact_root: &Path,
14740 source_digest: &str,
14741) -> bool {
14742 let Some(document) = stdfs::read_to_string(artifact_root.join("GENERATED-OWNERSHIP.json"))
14743 .ok()
14744 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
14745 else {
14746 return false;
14747 };
14748 document.get("schema").and_then(Value::as_str) == Some("pharos.generated-ownership.v1")
14749 && document.get("generator").and_then(Value::as_str) == Some("pharos-native-runtime")
14750 && document.get("generator_version").and_then(Value::as_str)
14751 == Some(env!("CARGO_PKG_VERSION"))
14752 && document.get("input_sha256").and_then(Value::as_str) == Some(source_digest)
14753}
14754
14755fn app_artifact_build_is_current(
14756 app_root: &Path,
14757 artifact_root: &Path,
14758 manifest: &Value,
14759 source_digest: &str,
14760 preserved_rustdoc_dir: Option<&Path>,
14761) -> bool {
14762 if !artifact_root.join("pharos.app.json").is_file() {
14763 return false;
14764 }
14765 if !generated_ownership_manifest_matches_source_digest(artifact_root, source_digest) {
14766 return false;
14767 }
14768 if docs_manifest_bool_path(manifest, &["rustdoc", "enabled"]).unwrap_or(false)
14769 && preserved_rustdoc_dir.is_none()
14770 {
14771 return false;
14772 }
14773 let _ = app_root;
14774 verify_generated_ownership_manifest(artifact_root)
14775}
14776
14777pub fn build_app_from_root(app_root: &Path, build_dir: &Path) -> Result<String, i32> {
14778 if app_root.as_os_str().is_empty() || build_dir.as_os_str().is_empty() || !app_root.is_dir() {
14779 return Err(64);
14780 }
14781 let manifest = read_source_manifest_value(app_root).map_err(|_| 66)?;
14782 let app_id = json_string(&manifest, "app_id").ok_or(66)?;
14783 let artifact_dir =
14784 config::effective_app_artifact_dir(app_root, build_dir, &app_id).ok_or(69)?;
14785 let artifact_path = Path::new(&artifact_dir);
14786 stdfs::create_dir_all(artifact_path).map_err(|_| 69)?;
14787 let canonical_source = app_root.canonicalize().map_err(|_| 69)?;
14788 let canonical_artifact = artifact_path.canonicalize().map_err(|_| 69)?;
14789 if canonical_source.starts_with(&canonical_artifact)
14790 || canonical_artifact.starts_with(&canonical_source)
14791 {
14792 return Err(78);
14793 }
14794 let previous_outputs = read_generated_ownership_outputs(artifact_path);
14795 let source_digest =
14796 tree_content_digest_with_cached_outputs(app_root, None, &previous_outputs).ok_or(74)?;
14797 let preserved_rustdoc_dir =
14798 if docs_manifest_bool_path(&manifest, &["rustdoc", "enabled"]).unwrap_or(false) {
14799 docs_valid_existing_rustdoc_output_dir(app_root, artifact_path, &manifest)
14800 .ok()
14801 .flatten()
14802 } else {
14803 None
14804 };
14805 if app_artifact_build_is_current(
14806 app_root,
14807 artifact_path,
14808 &manifest,
14809 &source_digest,
14810 preserved_rustdoc_dir.as_deref(),
14811 ) {
14812 return Ok(artifact_dir);
14813 }
14814 stdfs::create_dir_all(artifact_path).map_err(|_| 69)?;
14815 cleanup_incremental_artifact_outputs(
14816 artifact_path,
14817 app_root,
14818 preserved_rustdoc_dir.as_deref(),
14819 &previous_outputs,
14820 )?;
14821 let mut copied_files = 0;
14822 let mut copied_bytes = 0;
14823 copy_source_tree_to_artifact(
14824 app_root,
14825 Path::new(&artifact_dir),
14826 app_root,
14827 0,
14828 &previous_outputs,
14829 &mut copied_files,
14830 &mut copied_bytes,
14831 )?;
14832 copy_selected_bootstrap_icons(
14833 app_root,
14834 artifact_path,
14835 &mut copied_files,
14836 &mut copied_bytes,
14837 )?;
14838 let stale_path = Path::new(&artifact_dir).join("start.sh");
14839 if stale_path.is_file() {
14840 stdfs::remove_file(stale_path).map_err(|_| 69)?;
14841 }
14842 let compile_status =
14843 declarative::compile_yaml_matches_in_directory(Path::new(&artifact_dir), ".yml");
14844 if compile_status != 0 {
14845 return Err(69);
14846 }
14847 if declarative::normalize_compiled_json_references(artifact_path) != 0 {
14848 return Err(69);
14849 }
14850 if let Err(error) = features::compile_feature_sources(artifact_path) {
14851 eprintln!("feature compilation failed: {error:?}");
14852 return Err(69);
14853 }
14854 pharos_runtime_compiler::component_properties::compile(artifact_path).map_err(|_| 69)?;
14855 build_docs_pages(app_root, artifact_path, &manifest)?;
14856 if docs_manifest_bool_path(&manifest, &["rustdoc", "enabled"]).unwrap_or(false) {
14857 build_docs_rustdoc_reference(app_root, artifact_path, &manifest)?;
14858 }
14859 if declarative::declarative_source_derive_generated_surface_fixture(artifact_path).is_some() {
14863 let _ = declarative::declarative_source_derive_generated_admin_routes(artifact_path);
14864 let _ = declarative::declarative_source_derive_generated_api_routes(artifact_path);
14865 if let Some(Value::Array(surfaces)) = json_commands::json_doc_load_file(
14866 &artifact_path.join("data/contracts/generated-surfaces.json"),
14867 )
14868 .and_then(|root| root.get("surfaces").cloned())
14869 {
14870 let generated_dir = artifact_path.join("generated_admin");
14871 stdfs::create_dir_all(&generated_dir).map_err(|_| 69)?;
14872 for surface in surfaces {
14873 if surface.get("surface_kind").and_then(Value::as_str) != Some("generated_admin") {
14874 continue;
14875 }
14876 let Some(entity_id) = surface.get("entity_id").and_then(Value::as_str) else {
14877 continue;
14878 };
14879 let output = generated_dir.join(format!("{entity_id}.json"));
14880 fs::write_file_within(
14881 artifact_path,
14882 &output,
14883 serde_json::to_string_pretty(&surface)
14884 .map_err(|_| 75)?
14885 .as_bytes(),
14886 0o644,
14887 )
14888 .map_err(|_| 69)?;
14889 }
14890 }
14891 }
14892 let manifest_path = Path::new(&artifact_dir).join("pharos.app.json");
14893 finalize_artifact_manifest(&manifest_path, &manifest, &app_id, &source_digest)?;
14894 pharos_runtime_static::write_route_map(artifact_path).map_err(|_| 69)?;
14895 remove_non_manifest_yaml_from_artifact(artifact_path, 0)?;
14896 write_generated_ownership_manifest(artifact_path, app_root, &source_digest)?;
14897 Ok(artifact_dir)
14898}
14899
14900fn finalize_artifact_manifest(
14902 manifest_path: &Path,
14903 source_manifest: &Value,
14904 app_id: &str,
14905 source_digest: &str,
14906) -> Result<(), i32> {
14907 let mut artifact_manifest = if manifest_path.is_file() {
14908 let text = stdfs::read_to_string(manifest_path).map_err(|_| 69)?;
14909 serde_json::from_str::<Value>(&text).map_err(|_| 69)?
14910 } else {
14911 source_manifest.clone()
14912 };
14913 let object = artifact_manifest.as_object_mut().ok_or(69)?;
14914 let runtime = object
14915 .entry("runtime".to_owned())
14916 .or_insert_with(|| Value::Object(serde_json::Map::new()))
14917 .as_object_mut()
14918 .ok_or(69)?;
14919 runtime.insert(
14920 "build_artifact_dir".to_owned(),
14921 Value::String(".".to_owned()),
14922 );
14923 let artifact = object
14924 .entry("artifact".to_owned())
14925 .or_insert_with(|| Value::Object(serde_json::Map::new()))
14926 .as_object_mut()
14927 .ok_or(69)?;
14928 artifact.insert(
14929 "schema".to_owned(),
14930 Value::String("pharos.app.artifact.v1".to_owned()),
14931 );
14932 artifact.insert("app_root".to_owned(), Value::String(".".to_owned()));
14933 artifact.insert("artifact_dir".to_owned(), Value::String(".".to_owned()));
14934 artifact
14935 .entry("index".to_owned())
14936 .or_insert_with(|| Value::String("index.html".to_owned()));
14937 artifact
14938 .entry("static_dir".to_owned())
14939 .or_insert_with(|| Value::String("static".to_owned()));
14940 artifact
14941 .entry("media_dir".to_owned())
14942 .or_insert_with(|| Value::String("media".to_owned()));
14943 if let Some(runtime_media_dir) = source_manifest
14944 .get("assets")
14945 .and_then(Value::as_object)
14946 .and_then(|assets| assets.get("runtime_media_dir"))
14947 .and_then(Value::as_str)
14948 .filter(|value| !value.trim().is_empty())
14949 {
14950 artifact.insert(
14951 "runtime_media_dir".to_owned(),
14952 Value::String(runtime_media_dir.to_owned()),
14953 );
14954 }
14955 artifact.insert(
14956 "generated_by".to_owned(),
14957 Value::String("pharos-native-runtime".to_owned()),
14958 );
14959 artifact.insert(
14960 "generator_schema".to_owned(),
14961 Value::String("pharos.generated-ownership.v1".to_owned()),
14962 );
14963 artifact.insert(
14964 "input_sha256".to_owned(),
14965 Value::String(source_digest.to_owned()),
14966 );
14967 artifact.insert(
14968 "ownership_manifest".to_owned(),
14969 Value::String("GENERATED-OWNERSHIP.json".to_owned()),
14970 );
14971 if object
14972 .get("app_id")
14973 .and_then(Value::as_str)
14974 .is_none_or(|value| value.is_empty())
14975 {
14976 object.insert("app_id".to_owned(), Value::String(app_id.to_owned()));
14977 }
14978 let encoded = serde_json::to_string_pretty(&artifact_manifest).map_err(|_| 75)?;
14979 let root = manifest_path.parent().ok_or(69)?;
14980 pharos_runtime_core::fs::write_file_within(root, manifest_path, encoded.as_bytes(), 0o644)
14981 .map_err(|_| 69)
14982}
14983
14984fn ignore_non_contract_artifact_entry(name: &str) -> bool {
14986 name == ".DS_Store"
14987}
14988
14989fn shared_bootstrap_icons_root(app_root: &Path) -> Option<PathBuf> {
14991 app_root
14992 .ancestors()
14993 .map(|ancestor| ancestor.join("assets/static/bootstrap-icons"))
14994 .find(|candidate| candidate.is_dir())
14995}
14996
14997fn collect_text_files(root: &Path, current: &Path, files: &mut Vec<PathBuf>) -> Result<(), i32> {
14999 for entry in stdfs::read_dir(current).map_err(|_| 69)? {
15000 let entry = entry.map_err(|_| 69)?;
15001 if ignore_non_contract_artifact_entry(&entry.file_name().to_string_lossy()) {
15002 continue;
15003 }
15004 let path = entry.path();
15005 let metadata = stdfs::symlink_metadata(&path).map_err(|_| 69)?;
15006 if metadata.file_type().is_symlink() {
15007 return Err(78);
15008 }
15009 if metadata.is_dir() {
15010 collect_text_files(root, &path, files)?;
15011 continue;
15012 }
15013 if metadata.is_file()
15014 && stdfs::read(&path).is_ok_and(|bytes| std::str::from_utf8(&bytes).is_ok())
15015 {
15016 files.push(path.strip_prefix(root).map_err(|_| 69)?.to_path_buf());
15017 }
15018 }
15019 Ok(())
15020}
15021
15022fn bootstrap_icon_references(app_root: &Path, icon_root: &Path) -> Result<BTreeSet<String>, i32> {
15024 let mut text_files = Vec::new();
15025 collect_text_files(app_root, app_root, &mut text_files)?;
15026 let mut references = BTreeSet::new();
15027 let mut available = BTreeSet::new();
15028 for entry in stdfs::read_dir(icon_root).map_err(|_| 69)? {
15029 let entry = entry.map_err(|_| 69)?;
15030 let path = entry.path();
15031 if path.extension().and_then(|value| value.to_str()) == Some("svg")
15032 && path.is_file()
15033 && let Some(stem) = path.file_stem().and_then(|value| value.to_str())
15034 {
15035 available.insert(stem.to_owned());
15036 }
15037 }
15038 for relative in text_files {
15039 let text = stdfs::read_to_string(app_root.join(relative)).map_err(|_| 69)?;
15040 references.extend(bootstrap_icon_names_in_text(&text, &available));
15041 }
15042 Ok(references)
15043}
15044
15045fn bootstrap_icon_names_in_text(text: &str, available: &BTreeSet<String>) -> BTreeSet<String> {
15047 let mut references = BTreeSet::new();
15048 let maximum = available.iter().map(String::len).max().unwrap_or(0);
15049 for (marker, _) in text.match_indices("bi-") {
15050 let start = marker + 3;
15051 for length in 1..=maximum.min(text.len() - start) {
15052 if let Some(name) = text.get(start..start + length)
15053 && available.contains(name)
15054 {
15055 references.insert(name.to_owned());
15056 }
15057 }
15058 }
15059 for (end, _) in text.match_indices(".svg") {
15060 for length in 1..=maximum.min(end) {
15061 if let Some(name) = text.get(end - length..end)
15062 && available.contains(name)
15063 {
15064 references.insert(name.to_owned());
15065 }
15066 }
15067 }
15068 references
15069}
15070
15071#[cfg(test)]
15072mod bootstrap_icon_reference_tests {
15073 use super::*;
15074
15075 #[test]
15077 fn bootstrap_icon_references_preserve_prefix_suffix_and_unicode_matches() {
15078 let available = ["arrow", "arrow-left", "left", "gear-fill", "unused"]
15079 .into_iter()
15080 .map(str::to_owned)
15081 .collect::<BTreeSet<_>>();
15082 for text in [
15083 "bi-arrow-left-extra bi-gear-fill",
15084 "λ/icons/arrow-left.svg",
15085 "nothing matches",
15086 "bi-λarrow.svg bi-arrow",
15087 ] {
15088 let expected = available
15089 .iter()
15090 .filter(|name| {
15091 text.contains(&format!("{name}.svg")) || text.contains(&format!("bi-{name}"))
15092 })
15093 .cloned()
15094 .collect::<BTreeSet<_>>();
15095 assert_eq!(bootstrap_icon_names_in_text(text, &available), expected);
15096 }
15097 }
15098}
15099
15100fn copy_bootstrap_icon_helper(
15102 source: &Path,
15103 destination: &Path,
15104 copied_files: &mut u64,
15105 copied_bytes: &mut u64,
15106) -> Result<(), i32> {
15107 let metadata = stdfs::metadata(source).map_err(|_| 69)?;
15108 if !metadata.is_file() {
15109 return Err(69);
15110 }
15111 let parent = destination.parent().ok_or(69)?;
15112 stdfs::create_dir_all(parent).map_err(|_| 69)?;
15113 stdfs::copy(source, destination).map_err(|_| 69)?;
15114 *copied_files = copied_files.saturating_add(1);
15115 *copied_bytes = copied_bytes.saturating_add(metadata.len());
15116 Ok(())
15117}
15118
15119fn copy_selected_bootstrap_icons(
15121 app_root: &Path,
15122 artifact_root: &Path,
15123 copied_files: &mut u64,
15124 copied_bytes: &mut u64,
15125) -> Result<(), i32> {
15126 let Some(source_root) = shared_bootstrap_icons_root(app_root) else {
15127 return Ok(());
15128 };
15129 let references = bootstrap_icon_references(app_root, &source_root)?;
15130 let font_helpers = bootstrap_font_helper_references(app_root)?;
15131 if references.is_empty() && font_helpers.is_empty() {
15132 return Ok(());
15133 }
15134 let destination_root = artifact_root.join("static/bootstrap-icons");
15135 for name in references {
15136 let source = source_root.join(format!("{name}.svg"));
15137 if source.is_file() {
15138 copy_bootstrap_icon_helper(
15139 &source,
15140 &destination_root.join(format!("{name}.svg")),
15141 copied_files,
15142 copied_bytes,
15143 )?;
15144 }
15145 }
15146 if !font_helpers.is_empty() {
15147 for name in font_helpers {
15148 let source = source_root.join(&name);
15149 if source.is_file() {
15150 copy_bootstrap_icon_helper(
15151 &source,
15152 &destination_root.join(name),
15153 copied_files,
15154 copied_bytes,
15155 )?;
15156 }
15157 }
15158 for name in ["bootstrap-icons.woff", "bootstrap-icons.woff2"] {
15159 let source = source_root.join("fonts").join(name);
15160 if source.is_file() {
15161 copy_bootstrap_icon_helper(
15162 &source,
15163 &destination_root.join("fonts").join(name),
15164 copied_files,
15165 copied_bytes,
15166 )?;
15167 }
15168 }
15169 }
15170 Ok(())
15171}
15172
15173fn bootstrap_font_helper_references(app_root: &Path) -> Result<BTreeSet<String>, i32> {
15175 let mut text_files = Vec::new();
15176 collect_text_files(app_root, app_root, &mut text_files)?;
15177 let mut references = BTreeSet::new();
15178 for relative in text_files {
15179 let text = stdfs::read_to_string(app_root.join(relative)).map_err(|_| 69)?;
15180 if text.contains("bootstrap-icons.css") {
15181 references.insert("bootstrap-icons.css".to_owned());
15182 }
15183 if text.contains("bootstrap-icons.min.css") {
15184 references.insert("bootstrap-icons.min.css".to_owned());
15185 }
15186 if text.contains("bootstrap-icons.scss") {
15187 references.insert("bootstrap-icons.scss".to_owned());
15188 }
15189 if text.contains("font-family: bootstrap-icons")
15190 || text.contains("font-family: \"bootstrap-icons\"")
15191 {
15192 references.insert("bootstrap-icons.css".to_owned());
15193 }
15194 }
15195 Ok(references)
15196}
15197
15198fn collect_tree_files(root: &Path, current: &Path, files: &mut Vec<PathBuf>) -> Option<()> {
15200 for entry in stdfs::read_dir(current).ok()?.flatten() {
15201 if entry
15202 .file_name()
15203 .to_str()
15204 .is_some_and(ignore_non_contract_artifact_entry)
15205 {
15206 continue;
15207 }
15208 let path = entry.path();
15209 let metadata = entry.metadata().ok()?;
15210 if metadata.file_type().is_symlink() {
15211 return None;
15212 }
15213 if metadata.is_dir() {
15214 collect_tree_files(root, &path, files)?;
15215 } else if metadata.is_file() {
15216 files.push(path.strip_prefix(root).ok()?.to_path_buf());
15217 }
15218 }
15219 Some(())
15220}
15221
15222fn tree_content_digest(root: &Path, excluded: Option<&str>) -> Option<String> {
15224 let mut files = Vec::new();
15225 collect_tree_files(root, root, &mut files)?;
15226 files.sort();
15227 let mut digest = Sha256::new();
15228 for relative in files {
15229 if excluded.is_some_and(|value| relative == Path::new(value)) {
15230 continue;
15231 }
15232 digest.update(relative.to_string_lossy().as_bytes());
15233 digest.update([0]);
15234 digest.update(stdfs::read(root.join(&relative)).ok()?);
15235 digest.update([0]);
15236 }
15237 Some(
15238 digest
15239 .finalize()
15240 .iter()
15241 .map(|byte| format!("{byte:02x}"))
15242 .collect(),
15243 )
15244}
15245
15246fn cached_source_output_sha256(path: &Path, previous: Option<&Value>) -> Option<String> {
15247 let output = previous?;
15248 if output.get("ownership").and_then(Value::as_str) != Some("app_source_copy") {
15249 return None;
15250 }
15251 if metadata_matches_output(path, output, "source") {
15252 output
15253 .get("sha256")
15254 .and_then(Value::as_str)
15255 .map(ToOwned::to_owned)
15256 } else {
15257 None
15258 }
15259}
15260
15261fn is_lazy_model_payload_path(path: &Path) -> bool {
15262 if path.extension() == Some(std::ffi::OsStr::new("json")) {
15264 return false;
15265 }
15266 let mut components = path.components();
15267 matches!(
15268 (components.next(), components.next()),
15269 (
15270 Some(std::path::Component::Normal(first)),
15271 Some(std::path::Component::Normal(second))
15272 ) if first == "data" && second == "models"
15273 )
15274}
15275
15276fn is_opaque_static_docs_path(path: &Path) -> bool {
15277 let mut components = path.components();
15278 matches!(
15279 (components.next(), components.next()),
15280 (
15281 Some(std::path::Component::Normal(first)),
15282 Some(std::path::Component::Normal(_))
15283 ) if first == "html"
15284 )
15285}
15286
15287fn should_use_metadata_integrity(path: &Path) -> bool {
15288 is_lazy_model_payload_path(path)
15289}
15290
15291fn push_opaque_static_docs_ownership_output(
15292 outputs: &mut Vec<Value>,
15293 artifact_root: &Path,
15294) -> Result<(), i32> {
15295 let html_root = artifact_root.join("html");
15296 if !html_root.is_dir() {
15297 return Ok(());
15298 }
15299 let digest = tree_content_digest(&html_root, None).ok_or(74)?;
15300 let mut output = serde_json::Map::new();
15301 output.insert("path".to_owned(), Value::String("html".to_owned()));
15302 output.insert(
15303 "integrity".to_owned(),
15304 Value::String("opaque_static_digest".to_owned()),
15305 );
15306 output.insert("sha256".to_owned(), Value::String(digest));
15307 output.insert(
15308 "ownership".to_owned(),
15309 Value::String("pharos_generated_static_tree".to_owned()),
15310 );
15311 append_output_metadata(&mut output, "artifact", &html_root);
15312 outputs.push(Value::Object(output));
15313 Ok(())
15314}
15315
15316fn metadata_fingerprint_for_path(path: &Path) -> Option<String> {
15317 let metadata = stdfs::metadata(path).ok()?;
15318 let modified = metadata
15319 .modified()
15320 .ok()?
15321 .duration_since(UNIX_EPOCH)
15322 .ok()?
15323 .as_secs();
15324 Some(format!("metadata:{}:{modified}", metadata.len()))
15325}
15326
15327fn tree_content_digest_with_cached_outputs(
15328 root: &Path,
15329 excluded: Option<&str>,
15330 previous_outputs: &BTreeMap<PathBuf, Value>,
15331) -> Option<String> {
15332 let mut files = Vec::new();
15333 collect_tree_files(root, root, &mut files)?;
15334 files.sort();
15335 let mut digest = Sha256::new();
15336 for relative in files {
15337 if excluded.is_some_and(|value| relative == Path::new(value)) {
15338 continue;
15339 }
15340 digest.update(relative.to_string_lossy().as_bytes());
15341 digest.update([0]);
15342 if is_lazy_model_payload_path(&relative) {
15343 digest.update(metadata_fingerprint_for_path(&root.join(&relative))?.as_bytes());
15344 } else if let Some(cached_sha) =
15345 cached_source_output_sha256(&root.join(&relative), previous_outputs.get(&relative))
15346 {
15347 digest.update(cached_sha.as_bytes());
15348 } else {
15349 digest.update(sha256_hex(&stdfs::read(root.join(&relative)).ok()?).as_bytes());
15350 }
15351 digest.update([0]);
15352 }
15353 Some(
15354 digest
15355 .finalize()
15356 .iter()
15357 .map(|byte| format!("{byte:02x}"))
15358 .collect(),
15359 )
15360}
15361
15362fn system_time_unix_nanos(value: SystemTime) -> Option<String> {
15363 let duration = value.duration_since(UNIX_EPOCH).ok()?;
15364 Some(
15365 duration
15366 .as_secs()
15367 .saturating_mul(1_000_000_000)
15368 .saturating_add(u64::from(duration.subsec_nanos()))
15369 .to_string(),
15370 )
15371}
15372
15373#[cfg(unix)]
15374fn metadata_changed_unix_nanos(metadata: &stdfs::Metadata) -> Option<String> {
15375 use std::os::unix::fs::MetadataExt;
15376 let seconds = metadata.ctime();
15377 let nanos = metadata.ctime_nsec();
15378 if seconds < 0 || nanos < 0 {
15379 return None;
15380 }
15381 Some(
15382 (seconds as u64)
15383 .saturating_mul(1_000_000_000)
15384 .saturating_add(nanos as u64)
15385 .to_string(),
15386 )
15387}
15388
15389#[cfg(not(unix))]
15390fn metadata_changed_unix_nanos(_metadata: &stdfs::Metadata) -> Option<String> {
15391 None
15392}
15393
15394fn metadata_unix_nanos(metadata: &stdfs::Metadata) -> Option<String> {
15395 system_time_unix_nanos(metadata.modified().ok()?)
15396}
15397
15398fn output_metadata_field(prefix: &str, name: &str) -> String {
15399 format!("{prefix}_{name}")
15400}
15401
15402fn append_output_metadata(output: &mut serde_json::Map<String, Value>, prefix: &str, path: &Path) {
15403 let Ok(metadata) = stdfs::metadata(path) else {
15404 return;
15405 };
15406 output.insert(
15407 output_metadata_field(prefix, "size_bytes"),
15408 Value::Number(serde_json::Number::from(metadata.len())),
15409 );
15410 if let Some(modified) = metadata_unix_nanos(&metadata) {
15411 output.insert(
15412 output_metadata_field(prefix, "modified_unix_nanos"),
15413 Value::String(modified),
15414 );
15415 }
15416 if let Some(changed) = metadata_changed_unix_nanos(&metadata) {
15417 output.insert(
15418 output_metadata_field(prefix, "changed_unix_nanos"),
15419 Value::String(changed),
15420 );
15421 }
15422}
15423
15424fn metadata_matches_output(path: &Path, output: &Value, prefix: &str) -> bool {
15425 let Ok(metadata) = stdfs::metadata(path) else {
15426 return false;
15427 };
15428 if output
15429 .get(output_metadata_field(prefix, "size_bytes"))
15430 .and_then(Value::as_u64)
15431 != Some(metadata.len())
15432 {
15433 return false;
15434 }
15435 if let Some(expected) = output
15436 .get(output_metadata_field(prefix, "modified_unix_nanos"))
15437 .and_then(Value::as_str)
15438 {
15439 if metadata_unix_nanos(&metadata).as_deref() != Some(expected) {
15440 return false;
15441 }
15442 } else {
15443 return false;
15444 }
15445 true
15450}
15451
15452fn read_generated_ownership_outputs(artifact_root: &Path) -> BTreeMap<PathBuf, Value> {
15453 let Some(outputs) = stdfs::read_to_string(artifact_root.join("GENERATED-OWNERSHIP.json"))
15454 .ok()
15455 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
15456 .and_then(|root| root.get("outputs").and_then(Value::as_array).cloned())
15457 else {
15458 return BTreeMap::new();
15459 };
15460 outputs
15461 .into_iter()
15462 .filter_map(|output| {
15463 let path = output.get("path")?.as_str()?;
15464 let relative = PathBuf::from(path);
15465 if relative.is_absolute()
15466 || relative
15467 .components()
15468 .any(|component| matches!(component, std::path::Component::ParentDir))
15469 {
15470 return None;
15471 }
15472 Some((relative, output))
15473 })
15474 .collect()
15475}
15476
15477fn cached_output_sha256(path: &Path, previous: Option<&Value>) -> Option<String> {
15478 let output = previous?;
15479 if metadata_matches_output(path, output, "artifact") {
15480 output
15481 .get("sha256")
15482 .and_then(Value::as_str)
15483 .map(ToOwned::to_owned)
15484 } else {
15485 None
15486 }
15487}
15488
15489fn write_generated_ownership_manifest(
15491 artifact_root: &Path,
15492 source_root: &Path,
15493 source_digest: &str,
15494) -> Result<(), i32> {
15495 let previous_outputs = read_generated_ownership_outputs(artifact_root);
15496 let mut files = Vec::new();
15497 collect_tree_files(artifact_root, artifact_root, &mut files).ok_or(74)?;
15498 files.sort();
15499 let mut outputs = Vec::new();
15500 push_opaque_static_docs_ownership_output(&mut outputs, artifact_root)?;
15501 for relative in files {
15502 if matches!(
15503 relative.to_str(),
15504 Some("GENERATED-OWNERSHIP.json" | "app.conf")
15505 ) || is_opaque_static_docs_path(&relative)
15506 {
15507 continue;
15508 }
15509 let artifact_path = artifact_root.join(&relative);
15510 let source_path = source_root.join(&relative);
15511 let ownership = if source_path.is_file() {
15512 "app_source_copy"
15513 } else {
15514 "pharos_generated"
15515 };
15516 let mut output = serde_json::Map::new();
15517 output.insert(
15518 "path".to_owned(),
15519 Value::String(
15522 relative
15523 .iter()
15524 .map(|component| component.to_string_lossy())
15525 .collect::<Vec<_>>()
15526 .join("/"),
15527 ),
15528 );
15529 if should_use_metadata_integrity(&relative) {
15530 let fingerprint = metadata_fingerprint_for_path(&artifact_path).ok_or(74)?;
15531 output.insert("integrity".to_owned(), Value::String("metadata".to_owned()));
15532 output.insert("fingerprint".to_owned(), Value::String(fingerprint));
15533 } else {
15534 let sha256 = cached_output_sha256(&artifact_path, previous_outputs.get(&relative))
15535 .unwrap_or_else(|| {
15536 stdfs::read(&artifact_path)
15537 .map(|bytes| sha256_hex(&bytes))
15538 .unwrap_or_default()
15539 });
15540 if sha256.is_empty() {
15541 return Err(74);
15542 }
15543 output.insert("integrity".to_owned(), Value::String("sha256".to_owned()));
15544 output.insert("sha256".to_owned(), Value::String(sha256));
15545 }
15546 output.insert("ownership".to_owned(), Value::String(ownership.to_owned()));
15547 append_output_metadata(&mut output, "artifact", &artifact_path);
15548 if source_path.is_file() {
15549 append_output_metadata(&mut output, "source", &source_path);
15550 }
15551 outputs.push(Value::Object(output));
15552 }
15553 let generated_at = SystemTime::now()
15554 .duration_since(UNIX_EPOCH)
15555 .map_err(|_| 70)?
15556 .as_secs();
15557 let document = serde_json::json!({
15558 "schema": "pharos.generated-ownership.v1",
15559 "generator": "pharos-native-runtime",
15560 "generator_version": env!("CARGO_PKG_VERSION"),
15561 "generated_at_unix": generated_at,
15562 "source_owner": source_root.to_string_lossy(),
15563 "input_sha256": source_digest,
15564 "outputs": outputs,
15565 });
15566 let encoded = serde_json::to_vec_pretty(&document).map_err(|_| 70)?;
15567 pharos_runtime_core::fs::write_file_within(
15568 artifact_root,
15569 &artifact_root.join("GENERATED-OWNERSHIP.json"),
15570 &encoded,
15571 0o644,
15572 )
15573 .map_err(|_| 69)
15574}
15575
15576fn verify_generated_ownership_output(
15578 artifact_root: &Path,
15579 source_root: Option<&Path>,
15580 output: &Value,
15581) -> bool {
15582 let Some(relative) = output.get("path").and_then(Value::as_str) else {
15583 return false;
15584 };
15585 let Some(artifact_path) =
15586 pharos_runtime_core::safe_existing_artifact_path(artifact_root, relative)
15587 else {
15588 return false;
15589 };
15590 if output.get("integrity").and_then(Value::as_str) == Some("metadata") {
15591 return metadata_fingerprint_for_path(&artifact_path).as_deref()
15592 == output.get("fingerprint").and_then(Value::as_str);
15593 }
15594 if output.get("integrity").and_then(Value::as_str) == Some("opaque_static_digest") {
15595 return artifact_path.is_dir()
15596 && output
15597 .get("sha256")
15598 .and_then(Value::as_str)
15599 .is_some_and(|value| {
15600 value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())
15601 });
15602 }
15603 let Some(expected) = output.get("sha256").and_then(Value::as_str) else {
15604 return false;
15605 };
15606 if metadata_matches_output(&artifact_path, output, "artifact") {
15607 return true;
15608 }
15609 let Ok(artifact_bytes) = stdfs::read(&artifact_path) else {
15610 return false;
15611 };
15612 if sha256_hex(&artifact_bytes) == expected {
15613 return true;
15614 }
15615 if output.get("ownership").and_then(Value::as_str) != Some("app_source_copy") {
15616 return false;
15617 }
15618 let Some(source_root) = source_root else {
15619 return false;
15620 };
15621 let relative_path = Path::new(relative);
15622 if relative_path.is_absolute()
15623 || relative_path
15624 .components()
15625 .any(|component| matches!(component, std::path::Component::ParentDir))
15626 {
15627 return false;
15628 }
15629 stdfs::read(source_root.join(relative_path))
15630 .is_ok_and(|source_bytes| source_bytes == artifact_bytes)
15631}
15632
15633fn verify_generated_ownership_manifest(artifact_root: &Path) -> bool {
15635 let path = artifact_root.join("GENERATED-OWNERSHIP.json");
15636 let Some(document) = stdfs::read_to_string(path)
15637 .ok()
15638 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
15639 else {
15640 return false;
15641 };
15642 if document.get("schema").and_then(Value::as_str) != Some("pharos.generated-ownership.v1") {
15643 return false;
15644 }
15645 let declared_input = stdfs::read_to_string(artifact_root.join("pharos.app.json"))
15646 .ok()
15647 .and_then(|text| serde_json::from_str::<Value>(&text).ok())
15648 .and_then(|manifest| {
15649 manifest
15650 .get("artifact")
15651 .and_then(|artifact| artifact.get("input_sha256"))
15652 .and_then(Value::as_str)
15653 .map(ToOwned::to_owned)
15654 });
15655 if declared_input.as_deref() != document.get("input_sha256").and_then(Value::as_str) {
15656 return false;
15657 }
15658 let source_root = document
15659 .get("source_owner")
15660 .and_then(Value::as_str)
15661 .filter(|value| !value.is_empty())
15662 .map(Path::new);
15663 document
15664 .get("outputs")
15665 .and_then(Value::as_array)
15666 .is_some_and(|outputs| {
15667 outputs
15668 .iter()
15669 .all(|output| verify_generated_ownership_output(artifact_root, source_root, output))
15670 })
15671}
15672
15673const MAX_BUILD_FILES: u64 = 100_000;
15674const MAX_BUILD_BYTES: u64 = 512 * 1024 * 1024;
15675
15676fn docs_manifest_string<'a>(manifest: &'a Value, key: &str) -> Option<&'a str> {
15678 manifest
15679 .get("docs")
15680 .and_then(Value::as_object)
15681 .and_then(|docs| docs.get(key))
15682 .and_then(Value::as_str)
15683 .filter(|value| !value.is_empty())
15684}
15685
15686fn docs_manifest_value_path<'a>(manifest: &'a Value, path: &[&str]) -> Option<&'a Value> {
15688 let mut current = manifest.get("docs")?;
15689 for segment in path {
15690 current = current.get(*segment)?;
15691 }
15692 Some(current)
15693}
15694
15695fn docs_manifest_string_path<'a>(manifest: &'a Value, path: &[&str]) -> Option<&'a str> {
15697 docs_manifest_value_path(manifest, path)
15698 .and_then(Value::as_str)
15699 .filter(|value| !value.is_empty())
15700}
15701
15702fn docs_manifest_bool_path(manifest: &Value, path: &[&str]) -> Option<bool> {
15704 docs_manifest_value_path(manifest, path).and_then(Value::as_bool)
15705}
15706
15707fn docs_validate_relative_path(value: &str, label: &str) -> Result<String, i32> {
15709 if Path::new(value).is_absolute() || value.split('/').any(|segment| segment == "..") {
15710 eprintln!("docs {label} must be a relative artifact path: {value}");
15711 return Err(66);
15712 }
15713 Ok(value.to_owned())
15714}
15715
15716fn docs_manifest_relative_path(
15718 manifest: &Value,
15719 path: &[&str],
15720 default: &str,
15721 label: &str,
15722) -> Result<String, i32> {
15723 docs_validate_relative_path(
15724 docs_manifest_string_path(manifest, path).unwrap_or(default),
15725 label,
15726 )
15727}
15728
15729fn docs_manifest_required_relative_path(
15731 manifest: &Value,
15732 path: &[&str],
15733 label: &str,
15734) -> Result<String, i32> {
15735 let value = docs_manifest_string_path(manifest, path).ok_or_else(|| {
15736 eprintln!("docs build missing docs.{label} in manifest");
15737 66
15738 })?;
15739 docs_validate_relative_path(value, label)
15740}
15741
15742fn docs_manifest_string_array(manifest: &Value, key: &str) -> Vec<String> {
15744 manifest
15745 .get("docs")
15746 .and_then(Value::as_object)
15747 .and_then(|docs| docs.get(key))
15748 .and_then(Value::as_array)
15749 .map(|values| {
15750 values
15751 .iter()
15752 .filter_map(Value::as_str)
15753 .filter(|value| !value.is_empty())
15754 .map(ToOwned::to_owned)
15755 .collect()
15756 })
15757 .unwrap_or_default()
15758}
15759
15760fn docs_manifest_output_remap(manifest: &Value, source: &str) -> Option<String> {
15762 manifest
15763 .get("docs")
15764 .and_then(Value::as_object)
15765 .and_then(|docs| docs.get("output_remap"))
15766 .and_then(Value::as_array)
15767 .and_then(|entries| {
15768 entries.iter().find_map(|entry| {
15769 (entry.get("source").and_then(Value::as_str) == Some(source))
15770 .then(|| entry.get("output").and_then(Value::as_str))
15771 .flatten()
15772 .filter(|value| !value.is_empty())
15773 .map(ToOwned::to_owned)
15774 })
15775 })
15776}
15777
15778fn docs_manifest_publish_mode(manifest: &Value) -> Result<&str, i32> {
15780 match docs_manifest_string(manifest, "publish_mode").unwrap_or("all") {
15781 "all" => Ok("all"),
15782 "nav_only" => Ok("nav_only"),
15783 "routed_only" => Ok("routed_only"),
15784 _ => {
15785 eprintln!("docs build publish_mode must be one of: all, nav_only, routed_only");
15786 Err(66)
15787 }
15788 }
15789}
15790
15791fn docs_output_relative(source: &str, manifest: &Value) -> Option<String> {
15793 if let Some(output) = docs_manifest_output_remap(manifest, source) {
15794 return (!Path::new(&output).is_absolute() && !output.contains("..")).then_some(output);
15795 }
15796 let path = Path::new(source);
15797 let file_name = path.file_name()?.to_str()?;
15798 let output_name = if matches!(file_name, "README.md" | "index.md") {
15799 "index.html".to_owned()
15800 } else {
15801 format!("{}.html", file_name.strip_suffix(".md")?)
15802 };
15803 let parent = path
15804 .parent()
15805 .filter(|parent| !parent.as_os_str().is_empty());
15806 Some(
15807 parent
15808 .map(|parent| parent.join(&output_name).to_string_lossy().into_owned())
15809 .unwrap_or_else(|| output_name.clone()),
15810 )
15811}
15812
15813fn docs_nav_published_outputs(
15815 nav_entries: &[docs::DocsNavEntry],
15816) -> std::collections::BTreeSet<String> {
15817 nav_entries.iter().map(|entry| entry.path.clone()).collect()
15818}
15819
15820fn docs_route_published_outputs(
15822 artifact_root: &Path,
15823) -> Result<std::collections::BTreeSet<String>, i32> {
15824 let routes =
15825 json_commands::json_doc_load_file(&artifact_root.join("data/contracts/routes.json"))
15826 .ok_or(66)?;
15827 Ok(routes
15828 .get("routes")
15829 .and_then(Value::as_array)
15830 .into_iter()
15831 .flatten()
15832 .filter(|route| route.get("kind").and_then(Value::as_str) == Some("page"))
15833 .filter_map(|route| route.get("artifact").and_then(Value::as_str))
15834 .filter(|artifact| !artifact.is_empty())
15835 .map(ToOwned::to_owned)
15836 .collect())
15837}
15838
15839fn docs_relative_asset(page_rel: &str, asset_rel: &str) -> String {
15841 let depth = page_rel.bytes().filter(|byte| *byte == b'/').count();
15842 format!("{}{}", "../".repeat(depth), asset_rel)
15843}
15844
15845fn docs_repo_root(start: &Path) -> Option<PathBuf> {
15847 let start = if start.is_absolute() {
15848 start.to_path_buf()
15849 } else {
15850 std::env::current_dir().ok()?.join(start)
15851 };
15852 let start = start.canonicalize().ok()?;
15853 let mut current = Some(start.as_path());
15854 while let Some(path) = current {
15855 if path.join("apps").is_dir() && path.join("scripts").is_dir() && path.join("src").is_dir()
15856 {
15857 return Some(path.to_path_buf());
15858 }
15859 current = path.parent();
15860 }
15861 None
15862}
15863
15864fn docs_rustdoc_input_digest(repo_root: &Path) -> Option<String> {
15866 let src_root = repo_root.join("src");
15867 let mut files = Vec::new();
15868 collect_tree_files(&src_root, &src_root, &mut files)?;
15869 files.sort();
15870 let mut digest = Sha256::new();
15871 for relative in files {
15872 digest.update(relative.to_string_lossy().as_bytes());
15873 digest.update([0]);
15874 digest.update(stdfs::read(src_root.join(&relative)).ok()?);
15875 digest.update([0]);
15876 }
15877 for file_name in ["Cargo.toml", "Cargo.lock"] {
15878 let path = repo_root.join(file_name);
15879 digest.update(file_name.as_bytes());
15880 digest.update([0]);
15881 digest.update(stdfs::read(path).ok()?);
15882 digest.update([0]);
15883 }
15884 Some(
15885 digest
15886 .finalize()
15887 .iter()
15888 .map(|byte| format!("{byte:02x}"))
15889 .collect(),
15890 )
15891}
15892
15893fn docs_rustdoc_artifact_cache_dir(repo_root: &Path, fingerprint: &str) -> PathBuf {
15895 repo_root
15896 .join("target/rustdoc/artifact-cache/dev_docs")
15897 .join(fingerprint)
15898}
15899
15900fn docs_rustdoc_fingerprint_matches(path: &Path, fingerprint: &str) -> bool {
15902 stdfs::read_to_string(path)
15903 .map(|stored| stored.trim() == fingerprint)
15904 .unwrap_or(false)
15905}
15906
15907fn docs_valid_existing_rustdoc_output_dir(
15909 app_root: &Path,
15910 artifact_root: &Path,
15911 manifest: &Value,
15912) -> Result<Option<PathBuf>, i32> {
15913 if !artifact_root.is_dir() {
15914 return Ok(None);
15915 }
15916 let repo_root = docs_repo_root(app_root).ok_or(66)?;
15917 let fingerprint = docs_rustdoc_input_digest(&repo_root).ok_or(69)?;
15918 let rustdoc_output_dir = docs_rustdoc_output_dir(artifact_root, manifest)?;
15919 let fingerprint_path = rustdoc_output_dir.join(".pharos-rustdoc-input.sha256");
15920 if rustdoc_output_dir.join("index.html").is_file()
15921 && rustdoc_output_dir
15922 .join("pharos_native/index.html")
15923 .is_file()
15924 && docs_rustdoc_fingerprint_matches(&fingerprint_path, &fingerprint)
15925 {
15926 return Ok(Some(rustdoc_output_dir));
15927 }
15928 Ok(None)
15929}
15930
15931#[cfg(test)]
15933fn remove_artifact_contents_except(artifact_root: &Path, preserved_path: &Path) -> Result<(), i32> {
15934 for entry in stdfs::read_dir(artifact_root).map_err(|_| 69)? {
15935 let entry = entry.map_err(|_| 69)?;
15936 let path = entry.path();
15937 if preserved_path.starts_with(&path) {
15938 continue;
15939 }
15940 if path.is_dir() {
15941 stdfs::remove_dir_all(&path).map_err(|_| 69)?;
15942 } else {
15943 stdfs::remove_file(&path).map_err(|_| 69)?;
15944 }
15945 }
15946 Ok(())
15947}
15948
15949fn docs_rustdoc_output_dir(artifact_root: &Path, manifest: &Value) -> Result<PathBuf, i32> {
15951 Ok(artifact_root.join(docs_manifest_relative_path(
15952 manifest,
15953 &["rustdoc", "output_dir"],
15954 "html",
15955 "rustdoc.output_dir",
15956 )?))
15957}
15958
15959fn docs_files_equal(source: &Path, destination: &Path, source_metadata: &stdfs::Metadata) -> bool {
15960 let Ok(destination_metadata) = stdfs::metadata(destination) else {
15961 return false;
15962 };
15963 if !destination_metadata.is_file() || destination_metadata.len() != source_metadata.len() {
15964 return false;
15965 }
15966 match (stdfs::read(source), stdfs::read(destination)) {
15967 (Ok(source_bytes), Ok(destination_bytes)) => source_bytes == destination_bytes,
15968 _ => false,
15969 }
15970}
15971
15972fn docs_sync_rustdoc_tree(source: &Path, destination: &Path, depth: usize) -> Result<(), i32> {
15973 if depth > 64 {
15974 return Err(69);
15975 }
15976 stdfs::create_dir_all(destination).map_err(|_| 69)?;
15977 let mut source_names = BTreeSet::new();
15978 for entry in stdfs::read_dir(source).map_err(|_| 69)? {
15979 let entry = entry.map_err(|_| 69)?;
15980 source_names.insert(entry.file_name());
15981 let source_path = entry.path();
15982 let destination_path = destination.join(entry.file_name());
15983 let metadata = stdfs::symlink_metadata(&source_path).map_err(|_| 69)?;
15984 if metadata.file_type().is_symlink() {
15985 return Err(78);
15986 }
15987 if metadata.is_dir() {
15988 docs_sync_rustdoc_tree(&source_path, &destination_path, depth + 1)?;
15989 continue;
15990 }
15991 if !metadata.is_file() {
15992 continue;
15993 }
15994 if docs_files_equal(&source_path, &destination_path, &metadata) {
15995 continue;
15996 }
15997 if let Some(parent) = destination_path.parent() {
15998 stdfs::create_dir_all(parent).map_err(|_| 69)?;
15999 }
16000 stdfs::copy(&source_path, &destination_path).map_err(|_| 69)?;
16001 }
16002 if destination.is_dir() {
16003 for entry in stdfs::read_dir(destination).map_err(|_| 69)? {
16004 let entry = entry.map_err(|_| 69)?;
16005 if source_names.contains(&entry.file_name()) {
16006 continue;
16007 }
16008 let path = entry.path();
16009 let metadata = stdfs::symlink_metadata(&path).map_err(|_| 69)?;
16010 if metadata.file_type().is_symlink() {
16011 return Err(78);
16012 }
16013 if metadata.is_dir() {
16014 stdfs::remove_dir_all(&path).map_err(|_| 69)?;
16015 } else if metadata.is_file() {
16016 stdfs::remove_file(&path).map_err(|_| 69)?;
16017 }
16018 }
16019 }
16020 Ok(())
16021}
16022
16023fn docs_copy_rustdoc_tree(source: &Path, destination: &Path) -> Result<(), i32> {
16025 docs_sync_rustdoc_tree(source, destination, 0)
16026}
16027
16028fn docs_restore_cached_rustdoc_artifact(
16030 repo_root: &Path,
16031 artifact_root: &Path,
16032 manifest: &Value,
16033 fingerprint: &str,
16034) -> Result<bool, i32> {
16035 let cache_dir = docs_rustdoc_artifact_cache_dir(repo_root, fingerprint);
16036 let cache_fingerprint_path = cache_dir.join(".pharos-rustdoc-input.sha256");
16037 if !cache_dir.join("index.html").is_file()
16038 || !cache_dir.join("pharos_native/index.html").is_file()
16039 || !docs_rustdoc_fingerprint_matches(&cache_fingerprint_path, fingerprint)
16040 {
16041 return Ok(false);
16042 }
16043 let rustdoc_output_dir = docs_rustdoc_output_dir(artifact_root, manifest)?;
16044 docs_copy_rustdoc_tree(&cache_dir, &rustdoc_output_dir)?;
16045 Ok(true)
16046}
16047
16048fn build_docs_rustdoc_reference(
16050 app_root: &Path,
16051 artifact_root: &Path,
16052 manifest: &Value,
16053) -> Result<(), i32> {
16054 let repo_root = docs_repo_root(app_root).ok_or_else(|| {
16055 eprintln!("docs rustdoc build could not resolve repository root");
16056 66
16057 })?;
16058 let rustdoc_output_dir = docs_rustdoc_output_dir(artifact_root, manifest)?;
16059 let fingerprint = docs_rustdoc_input_digest(&repo_root).ok_or_else(|| {
16060 eprintln!("docs rustdoc build could not fingerprint inputs");
16061 69
16062 })?;
16063 let fingerprint_path = rustdoc_output_dir.join(".pharos-rustdoc-input.sha256");
16064 if rustdoc_output_dir.join("index.html").is_file()
16065 && docs_rustdoc_fingerprint_matches(&fingerprint_path, &fingerprint)
16066 {
16067 return Ok(());
16068 }
16069 if docs_restore_cached_rustdoc_artifact(&repo_root, artifact_root, manifest, &fingerprint)? {
16070 return Ok(());
16071 }
16072
16073 let output = docs_run_cargo_doc(&repo_root).map_err(|error| {
16074 eprintln!("docs rustdoc build could not launch cargo doc: {error}");
16075 69
16076 })?;
16077 if !output.status.success() {
16078 let stderr = String::from_utf8_lossy(&output.stderr);
16079 if !stderr.trim().is_empty() {
16080 eprintln!("{stderr}");
16081 }
16082 let stdout = String::from_utf8_lossy(&output.stdout);
16083 if !stdout.trim().is_empty() {
16084 eprintln!("{stdout}");
16085 }
16086 eprintln!("docs rustdoc generation failed");
16087 return Err(70);
16088 }
16089
16090 let generated_doc_root = repo_root.join("target/rustdoc/doc");
16091 if !generated_doc_root.join("src").is_dir() {
16092 eprintln!(
16093 "docs rustdoc build did not produce inline source output: {}",
16094 generated_doc_root.join("src").display()
16095 );
16096 return Err(70);
16097 }
16098 if !generated_doc_root
16099 .join("pharos_native/index.html")
16100 .is_file()
16101 {
16102 eprintln!(
16103 "docs rustdoc build did not produce pharos_native index: {}",
16104 generated_doc_root
16105 .join("pharos_native/index.html")
16106 .display()
16107 );
16108 return Err(70);
16109 }
16110
16111 docs_copy_rustdoc_tree(&generated_doc_root, &rustdoc_output_dir)?;
16112 let rustdoc_index = rustdoc_output_dir.join("index.html");
16113 let rustdoc_index_html = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta http-equiv=\"refresh\" content=\"0; url=pharos_native/index.html\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Pharos API Docs</title>\n</head>\n<body>\n<p><a href=\"pharos_native/index.html\">Continue to the API docs</a></p>\n</body>\n</html>\n";
16114 stdfs::write(&rustdoc_index, rustdoc_index_html.as_bytes()).map_err(|_| 69)?;
16115 #[cfg(unix)]
16116 {
16117 use std::os::unix::fs::PermissionsExt;
16118 stdfs::set_permissions(&rustdoc_index, stdfs::Permissions::from_mode(0o644))
16119 .map_err(|_| 69)?;
16120 }
16121 stdfs::write(&fingerprint_path, format!("{fingerprint}\n").as_bytes()).map_err(|_| 69)?;
16122 #[cfg(unix)]
16123 {
16124 use std::os::unix::fs::PermissionsExt;
16125 stdfs::set_permissions(&fingerprint_path, stdfs::Permissions::from_mode(0o644))
16126 .map_err(|_| 69)?;
16127 }
16128 let cache_dir = docs_rustdoc_artifact_cache_dir(&repo_root, &fingerprint);
16129 let _ = docs_copy_rustdoc_tree(&rustdoc_output_dir, &cache_dir);
16130 Ok(())
16131}
16132
16133fn build_docs_pages(app_root: &Path, artifact_root: &Path, manifest: &Value) -> Result<(), i32> {
16135 if docs_manifest_string(manifest, "source_dir").is_none() {
16136 return Ok(());
16137 }
16138 let source_dir_value = docs_manifest_string(manifest, "source_dir").ok_or_else(|| {
16139 eprintln!("docs build missing docs.source_dir in manifest");
16140 66
16141 })?;
16142 let source_dir = app_root.join(source_dir_value);
16143 if !source_dir.is_dir() {
16144 eprintln!(
16145 "docs source_dir is not a directory: {}",
16146 source_dir.display()
16147 );
16148 return Err(66);
16149 }
16150 let output_dir =
16151 artifact_root.join(docs_manifest_string(manifest, "output_dir").unwrap_or("."));
16152 stdfs::create_dir_all(&output_dir).map_err(|_| 69)?;
16153 let page_template_value = docs_manifest_string(manifest, "page_template").ok_or_else(|| {
16154 eprintln!("docs build missing docs.page_template in manifest");
16155 66
16156 })?;
16157 let page_template_path = artifact_root.join(page_template_value);
16158 let page_template = stdfs::read_to_string(&page_template_path).map_err(|_| {
16159 eprintln!(
16160 "docs page template unavailable: {}",
16161 page_template_path.display()
16162 );
16163 66
16164 })?;
16165 let sidebar_template_value =
16166 docs_manifest_string(manifest, "sidebar_template").ok_or_else(|| {
16167 eprintln!("docs build missing docs.sidebar_template in manifest");
16168 66
16169 })?;
16170 let sidebar_template_path = artifact_root.join(sidebar_template_value);
16171 let sidebar_template = stdfs::read_to_string(&sidebar_template_path).map_err(|_| {
16172 eprintln!(
16173 "docs sidebar template unavailable: {}",
16174 sidebar_template_path.display()
16175 );
16176 66
16177 })?;
16178 let sidebar_item_template_value = docs_manifest_string(manifest, "sidebar_item_template")
16179 .ok_or_else(|| {
16180 eprintln!("docs build missing docs.sidebar_item_template in manifest");
16181 66
16182 })?;
16183 let sidebar_item_template_path = artifact_root.join(sidebar_item_template_value);
16184 let sidebar_item_template =
16185 stdfs::read_to_string(&sidebar_item_template_path).map_err(|_| {
16186 eprintln!(
16187 "docs sidebar item template unavailable: {}",
16188 sidebar_item_template_path.display()
16189 );
16190 66
16191 })?;
16192 let sidebar_group_template_value = docs_manifest_required_relative_path(
16193 manifest,
16194 &["sidebar_group_template"],
16195 "sidebar_group_template",
16196 )?;
16197 let sidebar_group_template_path = pharos_runtime_core::safe_existing_artifact_path(
16198 artifact_root,
16199 &sidebar_group_template_value,
16200 )
16201 .ok_or(66)?;
16202 if stdfs::metadata(&sidebar_group_template_path)
16203 .map_err(|_| 66)?
16204 .len()
16205 > 8 * 1024 * 1024
16206 {
16207 return Err(66);
16208 }
16209 let sidebar_group_template =
16210 stdfs::read_to_string(sidebar_group_template_path).map_err(|_| 66)?;
16211
16212 let nav_manifest_value = docs_manifest_string(manifest, "nav_manifest").ok_or_else(|| {
16213 eprintln!("docs build missing docs.nav_manifest in manifest");
16214 66
16215 })?;
16216 let nav_artifact_value = nav_manifest_value
16217 .strip_suffix(".yml")
16218 .or_else(|| nav_manifest_value.strip_suffix(".yaml"))
16219 .map(|stem| format!("{stem}.json"))
16220 .unwrap_or_else(|| nav_manifest_value.to_owned());
16221 let nav_path = artifact_root.join(nav_artifact_value);
16222 let nav = json_commands::json_doc_load_file(&nav_path).ok_or_else(|| {
16223 eprintln!(
16224 "docs navigation artifact unavailable: {}",
16225 nav_path.display()
16226 );
16227 66
16228 })?;
16229 let mut nav_entries = Vec::new();
16230 if !docs::docs_nav_collect_entries_local(
16231 nav.get("items").unwrap_or(&Value::Null),
16232 0,
16233 &mut nav_entries,
16234 ) {
16235 eprintln!(
16236 "docs navigation failed: could not collect entries from nav_manifest={} artifact={}",
16237 nav_manifest_value,
16238 nav_path.display()
16239 );
16240 return Err(69);
16241 }
16242 let strip_dirs = docs_manifest_string_array(manifest, "strip_dirs");
16243 let strip_files = docs_manifest_string_array(manifest, "strip_files");
16244 let publish_mode = docs_manifest_publish_mode(manifest)?;
16245 let published_outputs = docs_nav_published_outputs(&nav_entries);
16246 let routed_outputs = if publish_mode == "routed_only" {
16247 Some(docs_route_published_outputs(artifact_root)?)
16248 } else {
16249 None
16250 };
16251 let mut output_remap = docs::DocsOutputRemap::default();
16252 if let Some(entries) = manifest
16253 .get("docs")
16254 .and_then(Value::as_object)
16255 .and_then(|docs| docs.get("output_remap"))
16256 && docs::docs_output_remap_load_json_array(entries, &mut output_remap) != 0
16257 {
16258 eprintln!(
16259 "docs output_remap failed: docs.output_remap must contain non-empty source/output pairs"
16260 );
16261 return Err(69);
16262 }
16263 let mut markdown_files = Vec::new();
16264 if !docs::collect_md_files(&source_dir, "", 0, 64, &mut markdown_files) {
16265 eprintln!(
16266 "docs source collection failed: source_dir={}",
16267 source_dir.display()
16268 );
16269 return Err(69);
16270 }
16271 let wordmark_asset =
16272 docs_manifest_required_relative_path(manifest, &["wordmark_src"], "wordmark_src")?;
16273 let favicon_asset =
16274 docs_manifest_required_relative_path(manifest, &["favicon_src"], "favicon_src")?;
16275 let stylesheet_asset =
16276 docs_manifest_required_relative_path(manifest, &["stylesheet_src"], "stylesheet_src")?;
16277 for (source_rel, source_path) in markdown_files {
16278 if strip_files.iter().any(|value| value == &source_rel)
16279 || strip_dirs
16280 .iter()
16281 .any(|value| source_rel == *value || source_rel.starts_with(&format!("{value}/")))
16282 {
16283 continue;
16284 }
16285 let output_rel = docs_output_relative(&source_rel, manifest).ok_or_else(|| {
16286 eprintln!("docs output path failed: source={source_rel}");
16287 69
16288 })?;
16289 if publish_mode == "nav_only" && !published_outputs.contains(&output_rel) {
16290 continue;
16291 }
16292 if routed_outputs
16293 .as_ref()
16294 .is_some_and(|outputs| !outputs.contains(&output_rel))
16295 {
16296 continue;
16297 }
16298 let markdown = stdfs::read_to_string(&source_path).map_err(|_| {
16299 eprintln!("docs source read failed: source={}", source_path.display());
16300 66
16301 })?;
16302 let body = docs::docs_markdown_to_html_with_remap_for_page(
16303 &markdown,
16304 Some(&output_remap),
16305 &output_rel,
16306 )
16307 .ok_or_else(|| {
16308 eprintln!(
16309 "docs markdown render failed: source={} output={}",
16310 source_path.display(),
16311 output_rel
16312 );
16313 69
16314 })?;
16315 let title = docs::docs_page_title(Some(&markdown), Some(&source_rel)).ok_or_else(|| {
16316 eprintln!("docs title render failed: source={source_rel}");
16317 69
16318 })?;
16319 let sidebar = docs::docs_render_sidebar(
16320 &sidebar_template,
16321 &sidebar_item_template,
16322 &sidebar_group_template,
16323 nav.get("title").and_then(Value::as_str).unwrap_or_default(),
16324 &nav_entries,
16325 &output_rel,
16326 )
16327 .ok_or_else(|| {
16328 eprintln!(
16329 "docs sidebar render failed: source={} output={} nav_manifest={}",
16330 source_rel, output_rel, nav_manifest_value
16331 );
16332 69
16333 })?;
16334 let rendered = docs::docs_render_page(
16335 &page_template,
16336 &title,
16337 &sidebar,
16338 &body,
16339 &docs::docs_root_href(&output_rel).ok_or(69)?,
16340 &docs_relative_asset(&output_rel, &wordmark_asset),
16341 &docs_relative_asset(&output_rel, &favicon_asset),
16342 &docs_relative_asset(&output_rel, &stylesheet_asset),
16343 )
16344 .ok_or_else(|| {
16345 eprintln!(
16346 "docs page render failed: source={} output={} template={}",
16347 source_rel,
16348 output_rel,
16349 page_template_path.display()
16350 );
16351 69
16352 })?;
16353 let output_path = output_dir.join(&output_rel);
16354 if let Some(parent) = output_path.parent() {
16355 stdfs::create_dir_all(parent).map_err(|_| {
16356 eprintln!("docs output directory create failed: {}", parent.display());
16357 69
16358 })?;
16359 }
16360 fs::write_file_within(artifact_root, &output_path, rendered.as_bytes(), 0o644).map_err(
16361 |_| {
16362 eprintln!("docs output write failed: {}", output_path.display());
16363 69
16364 },
16365 )?;
16366 }
16367 for entry in &nav_entries {
16368 if !output_dir.join(&entry.path).is_file() && !entry.path.starts_with("html/") {
16369 eprintln!(
16370 "docs navigation target missing: nav_path={} expected_output={}",
16371 entry.path,
16372 output_dir.join(&entry.path).display()
16373 );
16374 return Err(69);
16375 }
16376 }
16377 let index_template_value =
16378 docs_manifest_string(manifest, "index_template").ok_or_else(|| {
16379 eprintln!("docs build missing docs.index_template in manifest");
16380 66
16381 })?;
16382 let index_template_path = artifact_root.join(index_template_value);
16383 let index_template = stdfs::read_to_string(&index_template_path).map_err(|_| {
16384 eprintln!(
16385 "docs index template unavailable: {}",
16386 index_template_path.display()
16387 );
16388 66
16389 })?;
16390 let index_path = output_dir.join("index.html");
16391 if !index_path.is_file() {
16392 fs::write_file_within(artifact_root, &index_path, index_template.as_bytes(), 0o644)
16393 .map_err(|_| {
16394 eprintln!("docs index write failed: {}", index_path.display());
16395 69
16396 })?;
16397 }
16398 Ok(())
16399}
16400
16401fn remove_non_manifest_yaml_from_artifact(root: &Path, depth: usize) -> Result<(), i32> {
16403 if depth > 64 {
16404 return Err(69);
16405 }
16406 for entry in stdfs::read_dir(root).map_err(|_| 69)? {
16407 let entry = entry.map_err(|_| 69)?;
16408 let path = entry.path();
16409 let metadata = stdfs::symlink_metadata(&path).map_err(|_| 69)?;
16410 if metadata.file_type().is_symlink() {
16411 return Err(78);
16412 }
16413 if metadata.is_dir() {
16414 remove_non_manifest_yaml_from_artifact(&path, depth + 1)?;
16415 continue;
16416 }
16417 if !metadata.is_file() {
16418 continue;
16419 }
16420 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
16421 continue;
16422 };
16423 let is_yaml = file_name.ends_with(".yml") || file_name.ends_with(".yaml");
16424 if !is_yaml || file_name == "pharos.app.yml" {
16425 continue;
16426 }
16427 stdfs::remove_file(&path).map_err(|_| 69)?;
16428 }
16429 Ok(())
16430}
16431
16432fn artifact_file_copy_is_current(
16433 source_path: &Path,
16434 destination_path: &Path,
16435 relative_path: &Path,
16436 previous_outputs: &BTreeMap<PathBuf, Value>,
16437) -> bool {
16438 let Some(previous) = previous_outputs.get(relative_path) else {
16439 return false;
16440 };
16441 previous.get("ownership").and_then(Value::as_str) == Some("app_source_copy")
16442 && metadata_matches_output(source_path, previous, "source")
16443 && metadata_matches_output(destination_path, previous, "artifact")
16444}
16445
16446fn cleanup_incremental_artifact_outputs(
16447 artifact_root: &Path,
16448 source_root: &Path,
16449 preserved_dir: Option<&Path>,
16450 previous_outputs: &BTreeMap<PathBuf, Value>,
16451) -> Result<(), i32> {
16452 for (relative, output) in previous_outputs {
16453 let Some(portable_path) = output.get("path").and_then(Value::as_str) else {
16454 continue;
16455 };
16456 let Some(path) =
16457 pharos_runtime_core::safe_existing_artifact_path(artifact_root, portable_path)
16458 else {
16459 continue;
16460 };
16461 if preserved_dir.is_some_and(|preserved| path.starts_with(preserved)) {
16462 continue;
16463 }
16464 let ownership = output
16465 .get("ownership")
16466 .and_then(Value::as_str)
16467 .unwrap_or_default();
16468 let should_remove = ownership == "pharos_generated"
16469 || (ownership == "app_source_copy" && !source_root.join(relative).is_file());
16470 if should_remove && path.is_file() {
16471 stdfs::remove_file(path).map_err(|_| 69)?;
16472 }
16473 }
16474 Ok(())
16475}
16476
16477fn copy_source_tree_to_artifact(
16479 source: &Path,
16480 destination: &Path,
16481 source_root: &Path,
16482 depth: usize,
16483 previous_outputs: &BTreeMap<PathBuf, Value>,
16484 files: &mut u64,
16485 bytes: &mut u64,
16486) -> Result<(), i32> {
16487 if depth > 64 {
16488 return Err(69);
16489 }
16490 for entry in stdfs::read_dir(source).map_err(|_| 69)? {
16491 let entry = entry.map_err(|_| 69)?;
16492 let source_path = entry.path();
16493 let destination_path = destination.join(entry.file_name());
16494 if matches!(entry.file_name().to_str(), Some("start.sh")) {
16495 continue;
16496 }
16497 if entry
16498 .file_name()
16499 .to_str()
16500 .is_some_and(ignore_non_contract_artifact_entry)
16501 {
16502 continue;
16503 }
16504 let metadata = stdfs::symlink_metadata(&source_path).map_err(|_| 69)?;
16505 if metadata.file_type().is_symlink() {
16506 return Err(78);
16507 }
16508 if metadata.is_dir() {
16509 stdfs::create_dir_all(&destination_path).map_err(|_| 69)?;
16510 copy_source_tree_to_artifact(
16511 &source_path,
16512 &destination_path,
16513 source_root,
16514 depth + 1,
16515 previous_outputs,
16516 files,
16517 bytes,
16518 )?;
16519 continue;
16520 }
16521 if !metadata.is_file() {
16522 continue;
16523 }
16524 *files = files.saturating_add(1);
16525 *bytes = bytes.saturating_add(metadata.len());
16526 if *files > MAX_BUILD_FILES || *bytes > MAX_BUILD_BYTES {
16527 return Err(69);
16528 }
16529 let relative_path = source_path.strip_prefix(source_root).map_err(|_| 69)?;
16530 let copy_is_current = artifact_file_copy_is_current(
16531 &source_path,
16532 &destination_path,
16533 relative_path,
16534 previous_outputs,
16535 );
16536 if !copy_is_current {
16537 if let Some(parent) = destination_path.parent() {
16538 stdfs::create_dir_all(parent).map_err(|_| 69)?;
16539 }
16540 stdfs::copy(&source_path, &destination_path).map_err(|_| 69)?;
16541 }
16542 preserve_model_payload_mtime(relative_path, &metadata, &destination_path)?;
16543 }
16544 Ok(())
16545}
16546
16547fn preserve_model_payload_mtime(
16549 relative_path: &Path,
16550 source_metadata: &stdfs::Metadata,
16551 destination_path: &Path,
16552) -> Result<(), i32> {
16553 if !is_lazy_model_payload_path(relative_path) {
16554 return Ok(());
16555 }
16556 let source_modified = source_metadata.modified().map_err(|_| 69)?;
16557 let destination_modified = stdfs::metadata(destination_path)
16558 .and_then(|metadata| metadata.modified())
16559 .map_err(|_| 69)?;
16560 if destination_modified == source_modified {
16561 return Ok(());
16562 }
16563 stdfs::OpenOptions::new()
16564 .write(true)
16565 .open(destination_path)
16566 .and_then(|file| file.set_times(stdfs::FileTimes::new().set_modified(source_modified)))
16567 .map_err(|_| 69)
16568}
16569
16570pub fn free_app(app: &mut AppRuntime) {
16590 *app = AppRuntime::default();
16591}
16592
16593#[cfg(test)]
16594mod architect_llm_vendor_tests {
16595 use pharos_runtime_architect::llm_vendor::llm_vendor_failure_excerpt;
16596
16597 #[test]
16598 fn llm_vendor_failure_excerpt_redacts_secret_like_values() {
16599 let excerpt = llm_vendor_failure_excerpt(
16600 r#"{"error":{"message":"bad key sk-live-secret and Bearer token-value","access_token":"abc"}}"#,
16601 );
16602 assert!(excerpt.contains("[redacted]"));
16603 assert!(!excerpt.contains("sk-live-secret"));
16604 assert!(!excerpt.contains("token-value"));
16605 assert!(!excerpt.contains("abc"));
16606 }
16607}
16608
16609#[cfg(test)]
16610mod route_path_tests {
16611 use super::{
16612 active_session_user_id, actor_owns_mutation_resource, app_declarative_route_response,
16613 app_scope_path, app_uses_file_backed_runtime_state, app_uses_relational_runtime_authority,
16614 apply_route_response_middleware, canonical_http_error_body,
16615 docs_cargo_executable_candidates, docs_manifest_bool_path, docs_manifest_publish_mode,
16616 docs_manifest_required_relative_path, docs_nav_published_outputs, docs_repo_root,
16617 docs_restore_cached_rustdoc_artifact, docs_route_published_outputs,
16618 docs_rustdoc_artifact_cache_dir, docs_rustdoc_fingerprint_matches, dynamic,
16619 framework_builtin_response, http_route_redirect_for_request,
16620 ignore_non_contract_artifact_entry, is_business_availability_create_path,
16621 matched_app_route, native_session_token_hash, normalized_booking_time,
16622 process_one_background_job, remove_artifact_contents_except, route_request_hook_failure,
16623 route_request_middleware_failure, route_requires_state_mutation_guard,
16624 runtime_framework_webhook_endpoint_id, runtime_timestamp_seconds, sha256_hex,
16625 tree_content_digest, verify_generated_ownership_manifest,
16626 };
16627 use crate::config::AppRuntime;
16628 use crate::runtime::RuntimeStack;
16629 use crate::runtime::http;
16630 use pharos_runtime_core::application_ir::{
16631 APPLICATION_IR_VERSION, ApplicationIr, ContractId, RouteIr, RouteSecurityPosture,
16632 SourceProvenance,
16633 };
16634 use pharos_runtime_docs::docs::DocsNavEntry;
16635 use serde_json::{Value, json};
16636 use std::collections::{BTreeMap, BTreeSet};
16637 use std::fs;
16638 use std::io::Read;
16639 use std::path::Path;
16640 use std::path::PathBuf;
16641 use std::sync::Arc;
16642 use std::time::{SystemTime, UNIX_EPOCH};
16643
16644 #[test]
16646 fn unused_preconnection_timeout_is_not_a_bad_request() {
16647 for kind in [std::io::ErrorKind::TimedOut, std::io::ErrorKind::WouldBlock] {
16648 let mut reader = std::io::Cursor::new(Vec::<u8>::new()).chain(TimeoutReader(kind));
16649 assert_eq!(
16650 super::read_http_request(&mut reader).unwrap_err(),
16651 "request connection closed"
16652 );
16653 let mut reader = std::io::Cursor::new(b"GET /ucal/ HTTP/1.1\r\n".to_vec())
16654 .chain(TimeoutReader(kind));
16655 assert_eq!(
16656 super::read_http_request(&mut reader).unwrap_err(),
16657 "request parse failed"
16658 );
16659 }
16660 }
16661
16662 struct TimeoutReader(std::io::ErrorKind);
16663
16664 impl std::io::Read for TimeoutReader {
16665 fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
16667 Err(std::io::Error::from(self.0))
16668 }
16669 }
16670
16671 fn temp_root(label: &str) -> PathBuf {
16673 let nonce = SystemTime::now()
16674 .duration_since(UNIX_EPOCH)
16675 .expect("clock")
16676 .as_nanos();
16677 let path = std::env::temp_dir().join(format!(
16678 "pharos-runtime-{label}-{}-{nonce}",
16679 std::process::id()
16680 ));
16681 fs::create_dir_all(&path).expect("temp root");
16682 path
16683 }
16684
16685 fn request(
16687 method: &str,
16688 target: &str,
16689 headers: &str,
16690 content_type: Option<&str>,
16691 body: &str,
16692 ) -> http::HttpRequest {
16693 http::HttpRequest {
16694 method: method.to_owned(),
16695 target: target.to_owned(),
16696 cookie: None,
16697 content_type: content_type.map(str::to_owned),
16698 headers: headers.to_owned(),
16699 body: body.as_bytes().to_vec(),
16700 body_file_path: None,
16701 body_len: body.len(),
16702 is_multipart: false,
16703 }
16704 }
16705
16706 #[test]
16708 fn framework_builtin_readiness_and_liveness_contract_is_stable() {
16709 let artifact_root = temp_root("readiness-contract").join("artifact");
16710 fs::create_dir_all(&artifact_root).expect("artifact root");
16711 let app = AppRuntime {
16712 app_id: Some("ready_app".to_owned()),
16713 host_profile: Some("static-app".to_owned()),
16714 artifact_dir: Some(artifact_root.to_string_lossy().into_owned()),
16715 ..AppRuntime::default()
16716 };
16717 let stack = RuntimeStack {
16718 apps: vec![app],
16719 failed_apps: Vec::new(),
16720 host: Some("127.0.0.1".to_owned()),
16721 port: Some("0".to_owned()),
16722 socket_path: None,
16723 };
16724
16725 let health = framework_builtin_response("GET", "/health", None, Some(&stack))
16726 .expect("health response");
16727 assert_eq!(health.status_code, 200);
16728 assert!(health.body.contains("server_status=green\n"));
16729 assert!(health.body.contains("probe=liveness\n"));
16730 assert!(health.body.contains("readiness_status=ready\n"));
16731
16732 let live =
16733 framework_builtin_response("HEAD", "/live?probe=1", None, Some(&stack)).expect("live");
16734 assert_eq!(live.status_code, 200);
16735 assert_eq!(live.body, "server_status=live\n");
16736
16737 let ready = framework_builtin_response("GET", "/ready", None, Some(&stack))
16738 .expect("ready response");
16739 assert_eq!(ready.status_code, 200);
16740 assert_eq!(ready.body, "server_status=ready\n");
16741
16742 let not_ready = framework_builtin_response(
16743 "GET",
16744 "/ready",
16745 None,
16746 Some(&RuntimeStack {
16747 apps: Vec::new(),
16748 failed_apps: Vec::new(),
16749 host: None,
16750 port: None,
16751 socket_path: None,
16752 }),
16753 )
16754 .expect("not ready response");
16755 assert_eq!(not_ready.status_code, 503);
16756 assert_eq!(not_ready.body, "server_status=not_ready\n");
16757
16758 let rejected =
16759 framework_builtin_response("POST", "/ready", None, Some(&stack)).expect("method");
16760 assert_eq!(rejected.status_code, 405);
16761 assert_eq!(rejected.body, "method not allowed\n");
16762 let quarantined_stack = RuntimeStack {
16763 apps: stack.apps.clone(),
16764 failed_apps: vec![super::RuntimeAppLoadFailure {
16765 app_id: "broken_app".to_owned(),
16766 app_root: "apps/broken_app".to_owned(),
16767 mount_path: "/broken".to_owned(),
16768 artifact_dir: "dist/apps/broken_app".to_owned(),
16769 error: "generated artifact ownership verification failed".to_owned(),
16770 }],
16771 host: stack.host.clone(),
16772 port: stack.port.clone(),
16773 socket_path: stack.socket_path.clone(),
16774 };
16775 assert!(!super::runtime_stack_probe_ready(&quarantined_stack));
16776 let quarantined_ready =
16777 framework_builtin_response("GET", "/ready", None, Some(&quarantined_stack))
16778 .expect("quarantined ready response");
16779 assert_eq!(quarantined_ready.status_code, 503);
16780 assert_eq!(quarantined_ready.body, "server_status=not_ready\n");
16781 }
16782
16783 #[test]
16785 fn technician_availability_path_is_not_business_availability_path() {
16786 assert!(is_business_availability_create_path(
16787 "/businesses/lotus-studio/availability/new/"
16788 ));
16789 assert!(!is_business_availability_create_path(
16790 "/businesses/lotus-studio/technicians/12/availability/new/"
16791 ));
16792 assert!(!is_business_availability_create_path(
16793 "/businesses/technicians/12/availability/new/"
16794 ));
16795 }
16796
16797 #[test]
16798 fn enhanced_provider_checkout_redirect_uses_hx_redirect_header() {
16800 let enhanced = request("POST", "/checkout", "HX-Request: true\r\n", None, "");
16801 let response = String::from_utf8(http_route_redirect_for_request(
16802 &enhanced,
16803 "https://checkout.stripe.com/c/pay/cs_test_123",
16804 ))
16805 .expect("response utf8");
16806
16807 assert!(response.starts_with("HTTP/1.1 200 OK\r\n"));
16808 assert!(
16809 response.contains("HX-Redirect: https://checkout.stripe.com/c/pay/cs_test_123\r\n")
16810 );
16811 assert!(!response.contains("Location:"));
16812
16813 let normal = request("POST", "/checkout", "", None, "");
16814 let response = String::from_utf8(http_route_redirect_for_request(
16815 &normal,
16816 "https://checkout.stripe.com/c/pay/cs_test_456",
16817 ))
16818 .expect("response utf8");
16819
16820 assert!(response.starts_with("HTTP/1.1 303 See Other\r\n"));
16821 assert!(response.contains("Location: https://checkout.stripe.com/c/pay/cs_test_456\r\n"));
16822 }
16823
16824 #[test]
16825 fn app_scope_path_prefixes_mounted_redirect_once() {
16826 let app = AppRuntime {
16827 base_path: Some("/ucal".to_owned()),
16828 ..Default::default()
16829 };
16830
16831 assert_eq!(
16832 app_scope_path(&app, "/appointments/"),
16833 "/ucal/appointments/"
16834 );
16835 assert_eq!(
16836 app_scope_path(&app, "/ucal/appointments/"),
16837 "/ucal/appointments/"
16838 );
16839 }
16840
16841 #[test]
16842 fn framework_webhook_path_accepts_only_one_valid_endpoint_segment() {
16844 assert_eq!(
16845 runtime_framework_webhook_endpoint_id("/_pharos/webhooks/local-echo/"),
16846 Some("local-echo".to_owned())
16847 );
16848 assert_eq!(
16849 runtime_framework_webhook_endpoint_id("/_pharos/webhooks/local-echo"),
16850 Some("local-echo".to_owned())
16851 );
16852 assert_eq!(
16853 runtime_framework_webhook_endpoint_id("/_pharos/webhooks/local-echo/extra"),
16854 None
16855 );
16856 assert_eq!(
16857 runtime_framework_webhook_endpoint_id("/_pharos/webhooks/../"),
16858 None
16859 );
16860 }
16861
16862 #[test]
16864 fn route_ownership_normalizes_named_parameters() {
16865 assert_eq!(
16866 pharos_runtime_core::route::ownership_route_pattern(
16867 "/businesses/{business_id}/services/:service_id/"
16868 ),
16869 "/businesses/:parameter/services/:parameter/"
16870 );
16871 assert_eq!(
16872 pharos_runtime_core::route::ownership_route_pattern(
16873 "/businesses/{slug}/services/{id}/"
16874 ),
16875 "/businesses/:parameter/services/:parameter/"
16876 );
16877 }
16878
16879 #[test]
16880 fn mismatched_generated_artifact_fails_runtime_load() {
16882 let root = temp_root("mismatched-artifact-load");
16883 let artifact = root.join("artifact");
16884 fs::create_dir_all(&artifact).expect("artifact dir");
16885 fs::write(artifact.join("generated.html"), "tampered").expect("generated");
16886 fs::write(
16887 artifact.join("pharos.app.json"),
16888 serde_json::to_vec(&json!({
16889 "app_id": "broken_app",
16890 "artifact": {
16891 "input_sha256": "source-digest",
16892 "ownership_manifest": "GENERATED-OWNERSHIP.json"
16893 }
16894 }))
16895 .unwrap(),
16896 )
16897 .expect("manifest");
16898 fs::write(
16899 artifact.join("GENERATED-OWNERSHIP.json"),
16900 serde_json::to_vec(&json!({
16901 "schema": "pharos.generated-ownership.v1",
16902 "input_sha256": "source-digest",
16903 "outputs": [
16904 {
16905 "path": "generated.html",
16906 "sha256": sha256_hex(b"expected"),
16907 "ownership": "pharos_generated"
16908 }
16909 ]
16910 }))
16911 .unwrap(),
16912 )
16913 .expect("ownership");
16914
16915 assert_eq!(
16916 super::load_app_runtime_from_artifact(&artifact),
16917 Err("generated artifact ownership verification failed")
16918 );
16919
16920 fs::remove_dir_all(root).expect("cleanup");
16921 }
16922
16923 #[test]
16924 fn migration_startup_cache_records_verified_key() {
16925 let root = temp_root("migration-startup-cache");
16926 let state_dir = root.join("state");
16927 fs::create_dir_all(&state_dir).expect("state dir");
16928 let app = AppRuntime {
16929 app_id: Some("cache_app".to_owned()),
16930 state_path: Some(
16931 state_dir
16932 .join("runtime-state.json")
16933 .to_string_lossy()
16934 .into_owned(),
16935 ),
16936 ..AppRuntime::default()
16937 };
16938
16939 assert!(!super::cached_verified_migration_state(&app, "cache-key"));
16940 super::write_verified_migration_state_cache(&app, "cache-key");
16941 assert!(super::cached_verified_migration_state(&app, "cache-key"));
16942 assert!(!super::cached_verified_migration_state(&app, "other-key"));
16943
16944 let cache_path = state_dir.join(".pharos-cache_app-migration-startup-cache.json");
16945 let cache: Value = serde_json::from_str(&fs::read_to_string(cache_path).expect("cache"))
16946 .expect("cache json");
16947 assert_eq!(
16948 cache.get("schema").and_then(Value::as_str),
16949 Some("pharos.migration-startup-cache.v1")
16950 );
16951 assert_eq!(
16952 cache.get("status").and_then(Value::as_str),
16953 Some("verified")
16954 );
16955 assert_eq!(
16956 cache.get("cache_key").and_then(Value::as_str),
16957 Some("cache-key")
16958 );
16959
16960 fs::remove_dir_all(root).expect("cleanup");
16961 }
16962
16963 #[cfg(feature = "sqlite")]
16964 #[test]
16965 fn selected_logical_contract_controls_startup_readiness() {
16966 let root = temp_root("logical-migration-startup");
16967 let missing_manifest_app = AppRuntime {
16968 host_profile: Some("dynamic-app".to_owned()),
16969 app_root: Some(root.to_string_lossy().into_owned()),
16970 state_path: Some(root.join("scratch.json").to_string_lossy().into_owned()),
16971 ..AppRuntime::default()
16972 };
16973 assert!(super::app_uses_relational_runtime_authority(
16974 &missing_manifest_app
16975 ));
16976 assert!(!super::app_uses_file_backed_runtime_state(
16977 &missing_manifest_app
16978 ));
16979 fs::create_dir_all(root.join("data/contracts")).expect("contract directory");
16980 fs::write(
16981 root.join("pharos.app.yml"),
16982 "app_id: todo_list\ndatabase:\n migration_contract: data/contracts/database.json\n",
16983 )
16984 .expect("manifest");
16985 pharos_runtime_compiler::database::materialize_json(
16986 Path::new("apps/todo_list/data/contracts/database.yml"),
16987 root.join("data/contracts/database.json"),
16988 )
16989 .expect("logical contract");
16990 let conf = root.join("runtime.conf");
16991 fs::write(&conf, "db_backend=sqlite\ndb_path=todo.sqlite3\n").expect("config");
16992 let config =
16993 super::database::load_cli_backend_config(&root, Some(&conf), Some("todo_list"))
16994 .expect("backend config");
16995 let contract = super::database::selected_logical_contract(&root, "todo_list")
16996 .expect("contract path")
16997 .expect("selected logical contract");
16998 super::database::operations::resolve(&config)
16999 .expect("backend")
17000 .apply_contract(&config, &contract)
17001 .expect("apply logical schema");
17002 let app = AppRuntime {
17003 app_id: Some("todo_list".to_owned()),
17004 app_root: Some(root.to_string_lossy().into_owned()),
17005 runtime_conf_path: Some(conf.to_string_lossy().into_owned()),
17006 ..AppRuntime::default()
17007 };
17008 assert!(super::app_uses_relational_runtime_authority(&app));
17009 assert_eq!(super::verify_app_migration_state(&app), Ok(()));
17010 fs::write(root.join("data/contracts/database.json"), "{}")
17011 .expect("invalidate logical contract");
17012 assert_eq!(
17013 super::verify_app_migration_state(&app),
17014 Err("logical database contract invalid")
17015 );
17016 fs::write(
17017 root.join("data/contracts/database.json"),
17018 "{\"backends\":[\"sqlite\"],\"migrations\":[]}",
17019 )
17020 .expect("historical vendor manifest");
17021 assert!(super::app_uses_relational_runtime_authority(&app));
17022 assert!(super::database::selected_logical_contract(&root, "todo_list").is_err());
17023 fs::write(
17024 root.join("pharos.app.yml"),
17025 "app_id: todo_list\ndatabase:\n migration_contract: data/contracts/migrations.yml\n",
17026 )
17027 .expect("unsupported migration selection");
17028 assert!(super::app_uses_relational_runtime_authority(&app));
17029 assert!(super::database::selected_logical_contract(&root, "todo_list").is_err());
17030 fs::remove_dir_all(root).expect("remove test tree");
17031 }
17032
17033 #[test]
17034 fn incremental_app_copy_skips_unchanged_source_payloads() {
17035 let suffix = SystemTime::now()
17036 .duration_since(UNIX_EPOCH)
17037 .expect("clock")
17038 .as_nanos();
17039 let root = std::env::temp_dir().join(format!("pharos-incremental-copy-test-{suffix}"));
17040 let source = root.join("source");
17041 let artifact = root.join("artifact");
17042 fs::create_dir_all(source.join("data/models")).expect("source models");
17043 fs::create_dir_all(artifact.join("data/models")).expect("artifact models");
17044 let model_bytes = vec![b'x'; 1024 * 1024];
17045 fs::write(source.join("data/models/model.bin"), &model_bytes).expect("source model");
17046 fs::write(artifact.join("data/models/model.bin"), &model_bytes).expect("artifact model");
17047 fs::write(
17048 artifact.join("pharos.app.json"),
17049 serde_json::to_vec(&json!({"artifact": {"input_sha256": "input-digest"}})).unwrap(),
17050 )
17051 .expect("manifest");
17052 super::write_generated_ownership_manifest(&artifact, &source, "input-digest")
17053 .expect("ownership manifest");
17054 let manifest_text = fs::read_to_string(artifact.join("GENERATED-OWNERSHIP.json"))
17055 .expect("ownership manifest text");
17056 let manifest_json: Value = serde_json::from_str(&manifest_text).expect("ownership json");
17057 let model_output = manifest_json
17058 .get("outputs")
17059 .and_then(Value::as_array)
17060 .and_then(|outputs| outputs.first())
17061 .expect("model output");
17062 assert_eq!(
17063 model_output.get("path").and_then(Value::as_str),
17064 Some("data/models/model.bin")
17065 );
17066 assert_eq!(
17067 model_output.get("integrity").and_then(Value::as_str),
17068 Some("metadata")
17069 );
17070 assert!(
17071 model_output
17072 .get("fingerprint")
17073 .and_then(Value::as_str)
17074 .is_some_and(|value| value.starts_with("metadata:"))
17075 );
17076 assert!(model_output.get("sha256").is_none());
17077 assert!(super::verify_generated_ownership_manifest(&artifact));
17078 let previous_outputs = super::read_generated_ownership_outputs(&artifact);
17079 let source_model = source.join("data/models/model.bin");
17080 let source_modified = UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
17081 fs::OpenOptions::new()
17082 .write(true)
17083 .open(&source_model)
17084 .expect("open source model")
17085 .set_times(fs::FileTimes::new().set_modified(source_modified))
17086 .expect("change source mtime");
17087 let before_modified = fs::metadata(artifact.join("data/models/model.bin"))
17088 .expect("artifact metadata")
17089 .modified()
17090 .expect("artifact mtime");
17091 let mut copied_files = 0;
17092 let mut copied_bytes = 0;
17093 super::copy_source_tree_to_artifact(
17094 &source,
17095 &artifact,
17096 &source,
17097 0,
17098 &previous_outputs,
17099 &mut copied_files,
17100 &mut copied_bytes,
17101 )
17102 .expect("incremental copy");
17103 let after_modified = fs::metadata(artifact.join("data/models/model.bin"))
17104 .expect("artifact metadata")
17105 .modified()
17106 .expect("artifact mtime");
17107 assert_ne!(before_modified, after_modified);
17108 assert_eq!(source_modified, after_modified);
17109 assert_eq!(copied_files, 1);
17110 assert_eq!(copied_bytes, model_bytes.len() as u64);
17111 fs::remove_dir_all(root).expect("cleanup");
17112 }
17113
17114 #[test]
17115 fn model_manifest_ownership_uses_content_digest() {
17117 let root = temp_root("model-manifest-ownership");
17118 let source = root.join("source");
17119 let artifact = root.join("artifact");
17120 fs::create_dir_all(&source).expect("source dir");
17121 fs::create_dir_all(artifact.join("data/models")).expect("model dir");
17122 let model_manifest = artifact.join("data/models/pdf-rasterizer-manifest.json");
17123 fs::write(&model_manifest, br#"{"schema":"pharos.pdf-rasterizer.v1"}"#)
17124 .expect("model manifest");
17125 fs::write(
17126 artifact.join("pharos.app.json"),
17127 serde_json::to_vec(&json!({"artifact": {"input_sha256": "input-digest"}})).unwrap(),
17128 )
17129 .expect("app manifest");
17130 super::write_generated_ownership_manifest(&artifact, &source, "input-digest")
17131 .expect("ownership manifest");
17132 let ownership: Value = serde_json::from_slice(
17133 &fs::read(artifact.join("GENERATED-OWNERSHIP.json")).expect("ownership file"),
17134 )
17135 .expect("ownership json");
17136 let output = ownership["outputs"]
17137 .as_array()
17138 .expect("outputs")
17139 .iter()
17140 .find(|value| value["path"] == "data/models/pdf-rasterizer-manifest.json")
17141 .expect("model manifest output");
17142 assert_eq!(output["integrity"], "sha256");
17143 fs::OpenOptions::new()
17144 .write(true)
17145 .open(&model_manifest)
17146 .expect("open model manifest")
17147 .set_times(
17148 fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)),
17149 )
17150 .expect("change mtime");
17151 assert!(super::verify_generated_ownership_manifest(&artifact));
17152 fs::remove_dir_all(root).expect("cleanup");
17153 }
17154
17155 #[test]
17156 fn generated_ownership_accepts_refreshed_app_source_copy() {
17158 let suffix = SystemTime::now()
17159 .duration_since(UNIX_EPOCH)
17160 .expect("clock")
17161 .as_nanos();
17162 let root = std::env::temp_dir().join(format!("pharos-ownership-test-{suffix}"));
17163 let source = root.join("source");
17164 let artifact = root.join("artifact");
17165 fs::create_dir_all(source.join("static/img")).expect("source dirs");
17166 fs::create_dir_all(&artifact).expect("artifact dir");
17167 fs::write(source.join("static/img/before-after.svg"), "old svg").expect("source svg");
17168 fs::create_dir_all(artifact.join("static/img")).expect("artifact img dir");
17169 fs::write(artifact.join("static/img/before-after.svg"), "old svg").expect("artifact svg");
17170 fs::write(artifact.join("generated.html"), "generated").expect("generated");
17171 fs::write(
17172 artifact.join("pharos.app.json"),
17173 serde_json::to_vec(&json!({"artifact": {"input_sha256": "source-digest"}})).unwrap(),
17174 )
17175 .expect("manifest");
17176 fs::write(
17177 artifact.join("GENERATED-OWNERSHIP.json"),
17178 serde_json::to_vec(&json!({
17179 "schema": "pharos.generated-ownership.v1",
17180 "source_owner": source.to_string_lossy(),
17181 "input_sha256": "source-digest",
17182 "outputs": [
17183 {
17184 "path": "static/img/before-after.svg",
17185 "sha256": sha256_hex(b"old svg"),
17186 "ownership": "app_source_copy"
17187 },
17188 {
17189 "path": "generated.html",
17190 "sha256": sha256_hex(b"generated"),
17191 "ownership": "pharos_generated"
17192 }
17193 ]
17194 }))
17195 .unwrap(),
17196 )
17197 .expect("ownership");
17198
17199 fs::write(source.join("static/img/before-after.svg"), "new svg").expect("source changed");
17200 fs::write(artifact.join("static/img/before-after.svg"), "new svg")
17201 .expect("artifact changed");
17202 assert!(verify_generated_ownership_manifest(&artifact));
17203
17204 fs::write(artifact.join("generated.html"), "changed generated").expect("generated changed");
17205 assert!(!verify_generated_ownership_manifest(&artifact));
17206
17207 fs::remove_dir_all(root).expect("cleanup");
17208 }
17209
17210 #[test]
17211 fn artifact_manifest_projects_runtime_media_boundary() {
17213 let root = temp_root("runtime-media-artifact");
17214 let manifest_path = root.join("pharos.app.json");
17215 let source_manifest = json!({
17216 "app_id": "media_app",
17217 "assets": {
17218 "static_dir": "static",
17219 "media_dir": "media",
17220 "runtime_media_dir": "/var/lib/pharos/media/media_app"
17221 }
17222 });
17223
17224 super::finalize_artifact_manifest(
17225 &manifest_path,
17226 &source_manifest,
17227 "media_app",
17228 "input-digest",
17229 )
17230 .expect("finalized manifest");
17231 let artifact_manifest: Value =
17232 serde_json::from_str(&fs::read_to_string(&manifest_path).expect("artifact manifest"))
17233 .expect("artifact json");
17234
17235 assert_eq!(
17236 artifact_manifest
17237 .get("assets")
17238 .and_then(Value::as_object)
17239 .and_then(|assets| assets.get("runtime_media_dir"))
17240 .and_then(Value::as_str),
17241 Some("/var/lib/pharos/media/media_app")
17242 );
17243 assert_eq!(
17244 artifact_manifest
17245 .get("artifact")
17246 .and_then(Value::as_object)
17247 .and_then(|artifact| artifact.get("media_dir"))
17248 .and_then(Value::as_str),
17249 Some("media")
17250 );
17251 assert_eq!(
17252 artifact_manifest
17253 .get("artifact")
17254 .and_then(Value::as_object)
17255 .and_then(|artifact| artifact.get("runtime_media_dir"))
17256 .and_then(Value::as_str),
17257 Some("/var/lib/pharos/media/media_app")
17258 );
17259
17260 fs::remove_dir_all(root).expect("cleanup");
17261 }
17262
17263 #[test]
17264 fn docs_cargo_candidates_include_common_local_install_paths() {
17266 let candidates = docs_cargo_executable_candidates()
17267 .into_iter()
17268 .map(|value| value.to_string_lossy().into_owned())
17269 .collect::<Vec<_>>();
17270 for cargo_path in ["/usr/local/bin/cargo", "/opt/homebrew/bin/cargo"] {
17271 if Path::new(cargo_path).is_file() {
17272 assert!(candidates.iter().any(|candidate| candidate == cargo_path));
17273 }
17274 }
17275 }
17276
17277 #[test]
17278 fn docs_repo_root_from_relative_app_root_is_absolute() {
17280 let root = docs_repo_root(Path::new("apps/dev_docs")).expect("repo root");
17281 assert!(root.is_absolute());
17282 assert!(root.join("Cargo.toml").is_file());
17283 assert!(root.join("apps/dev_docs/pharos.app.yml").is_file());
17284 }
17285
17286 #[test]
17287 fn docs_rustdoc_fingerprint_match_trims_stored_value() {
17289 let root =
17290 std::env::temp_dir().join(format!("pharos-rustdoc-fingerprint-{}", std::process::id()));
17291 fs::create_dir_all(&root).expect("temp dir");
17292 let marker = root.join(".pharos-rustdoc-input.sha256");
17293 fs::write(&marker, "abc123\n").expect("marker");
17294
17295 assert!(docs_rustdoc_fingerprint_matches(&marker, "abc123"));
17296
17297 let _ = fs::remove_dir_all(root);
17298 }
17299
17300 #[test]
17301 fn docs_rustdoc_cache_restore_copies_matching_tree() {
17303 let root =
17304 std::env::temp_dir().join(format!("pharos-rustdoc-cache-{}", std::process::id()));
17305 let fingerprint = "cache-digest";
17306 let cache_dir = docs_rustdoc_artifact_cache_dir(&root, fingerprint);
17307 fs::create_dir_all(cache_dir.join("pharos_native")).expect("cache dirs");
17308 fs::write(cache_dir.join("index.html"), "redirect").expect("cache index");
17309 fs::write(cache_dir.join("pharos_native/index.html"), "api").expect("cache api");
17310 fs::write(
17311 cache_dir.join(".pharos-rustdoc-input.sha256"),
17312 format!("{fingerprint}\n"),
17313 )
17314 .expect("cache marker");
17315 let artifact = root.join("artifact");
17316 fs::create_dir_all(&artifact).expect("artifact dir");
17317 let manifest = json!({
17318 "docs": {
17319 "rustdoc": {
17320 "output_dir": "html"
17321 }
17322 }
17323 });
17324
17325 assert_eq!(
17326 docs_restore_cached_rustdoc_artifact(&root, &artifact, &manifest, fingerprint),
17327 Ok(true)
17328 );
17329 assert_eq!(
17330 fs::read_to_string(artifact.join("html/pharos_native/index.html"))
17331 .expect("restored api"),
17332 "api"
17333 );
17334
17335 let _ = fs::remove_dir_all(root);
17336 }
17337
17338 #[test]
17339 fn remove_artifact_contents_except_keeps_preserved_rustdoc_tree() {
17341 let root =
17342 std::env::temp_dir().join(format!("pharos-rustdoc-preserve-{}", std::process::id()));
17343 let artifact = root.join("artifact");
17344 fs::create_dir_all(artifact.join("html/pharos_native")).expect("html dir");
17345 fs::create_dir_all(artifact.join("static")).expect("static dir");
17346 fs::write(artifact.join("html/index.html"), "rustdoc").expect("rustdoc index");
17347 fs::write(artifact.join("html/pharos_native/index.html"), "api").expect("api index");
17348 fs::write(artifact.join("static/docs.css"), "old css").expect("old css");
17349 fs::write(artifact.join("pharos.app.json"), "{}").expect("old manifest");
17350
17351 remove_artifact_contents_except(&artifact, &artifact.join("html")).expect("clean");
17352
17353 assert!(artifact.join("html/index.html").is_file());
17354 assert!(artifact.join("html/pharos_native/index.html").is_file());
17355 assert!(!artifact.join("static/docs.css").exists());
17356 assert!(!artifact.join("pharos.app.json").exists());
17357
17358 let _ = fs::remove_dir_all(root);
17359 }
17360
17361 #[test]
17362 fn docs_asset_paths_come_from_manifest() {
17364 let manifest = json!({
17365 "docs": {
17366 "wordmark_src": "static/img/custom-wordmark.svg",
17367 "favicon_src": "static/img/custom-favicon.ico",
17368 "stylesheet_src": "static/css/custom-docs.css",
17369 "rustdoc": {
17370 "enabled": true
17371 }
17372 }
17373 });
17374 assert_eq!(
17375 docs_manifest_required_relative_path(&manifest, &["wordmark_src"], "wordmark_src")
17376 .as_deref(),
17377 Ok("static/img/custom-wordmark.svg")
17378 );
17379 assert_eq!(
17380 docs_manifest_required_relative_path(&manifest, &["favicon_src"], "favicon_src")
17381 .as_deref(),
17382 Ok("static/img/custom-favicon.ico")
17383 );
17384 assert_eq!(
17385 docs_manifest_required_relative_path(&manifest, &["stylesheet_src"], "stylesheet_src")
17386 .as_deref(),
17387 Ok("static/css/custom-docs.css")
17388 );
17389 assert_eq!(
17390 docs_manifest_bool_path(&manifest, &["rustdoc", "enabled"]),
17391 Some(true)
17392 );
17393 }
17394
17395 #[test]
17396 fn docs_asset_paths_reject_parent_traversal() {
17398 let manifest = json!({
17399 "docs": {
17400 "wordmark_src": "../outside.svg"
17401 }
17402 });
17403 assert!(
17404 docs_manifest_required_relative_path(&manifest, &["wordmark_src"], "wordmark_src")
17405 .is_err()
17406 );
17407 }
17408
17409 #[test]
17410 fn docs_publish_mode_accepts_all_nav_only_and_routed_only() {
17412 assert_eq!(
17413 docs_manifest_publish_mode(&json!({"docs": {}})).unwrap(),
17414 "all"
17415 );
17416 assert_eq!(
17417 docs_manifest_publish_mode(&json!({"docs": {"publish_mode": "nav_only"}})).unwrap(),
17418 "nav_only"
17419 );
17420 assert_eq!(
17421 docs_manifest_publish_mode(&json!({"docs": {"publish_mode": "routed_only"}})).unwrap(),
17422 "routed_only"
17423 );
17424 assert!(docs_manifest_publish_mode(&json!({"docs": {"publish_mode": "invalid"}})).is_err());
17425 }
17426
17427 #[test]
17428 fn docs_nav_published_outputs_collects_paths() {
17430 let outputs = docs_nav_published_outputs(&[
17431 DocsNavEntry {
17432 path: "index.html".to_owned(),
17433 label: "Home".to_owned(),
17434 depth: 0,
17435 },
17436 DocsNavEntry {
17437 path: "reference/library-reference.html".to_owned(),
17438 label: "Reference".to_owned(),
17439 depth: 1,
17440 },
17441 ]);
17442 assert!(outputs.contains("index.html"));
17443 assert!(outputs.contains("reference/library-reference.html"));
17444 assert!(!outputs.contains("bill_of_materials.html"));
17445 }
17446
17447 #[test]
17448 fn docs_route_published_outputs_collects_declared_artifacts() {
17450 let root = std::env::temp_dir().join(format!("pharos-doc-routes-{}", std::process::id()));
17451 fs::create_dir_all(root.join("data/contracts")).expect("contracts dir");
17452 fs::write(
17453 root.join("data/contracts/routes.json"),
17454 r#"{"routes":[
17455 {"kind":"page","path":"/","artifact":"index.html"},
17456 {"kind":"page","path":"/reference/library-reference.html","artifact":"reference/library-reference.html"},
17457 {"kind":"page","path":"/html/index.html","artifact":"html/index.html"},
17458 {"kind":"action","path":"/actions/toggle","action_id":"toggle_task"}
17459 ]}"#,
17460 )
17461 .expect("routes");
17462 let outputs = docs_route_published_outputs(&root).expect("outputs");
17463 assert!(outputs.contains("index.html"));
17464 assert!(outputs.contains("reference/library-reference.html"));
17465 assert!(outputs.contains("html/index.html"));
17466 assert!(!outputs.contains("bill_of_materials.html"));
17467 let _ = fs::remove_dir_all(root);
17468 }
17469
17470 #[test]
17471 fn matched_app_route_finds_explicit_get_action_routes() {
17473 let route_id = ContractId::parse("payment_provider_callback_stripe").expect("route id");
17474 let mut routes = BTreeMap::new();
17475 routes.insert(
17476 route_id.clone(),
17477 RouteIr {
17478 id: route_id.clone(),
17479 path: "/businesses/:business_slug/payment-providers/stripe/callback/".to_owned(),
17480 kind: "action".to_owned(),
17481 method: "GET".to_owned(),
17482 action_id: Some(
17483 ContractId::parse("payment_provider_callback_stripe").expect("action id"),
17484 ),
17485 page_id: None,
17486 policy_id: Some(
17487 ContractId::parse("manage_payment_provider_connection").expect("policy id"),
17488 ),
17489 auth_gate: "anonymous_or_session".to_owned(),
17490 security: RouteSecurityPosture {
17491 csrf_mode: "none".to_owned(),
17492 trusted_origin_policy: "same-origin".to_owned(),
17493 tenant_scope: "policy_bound".to_owned(),
17494 rate_limit_policy: "default".to_owned(),
17495 capability_policy: "policy_gate".to_owned(),
17496 },
17497 source: SourceProvenance {
17498 path: PathBuf::from("data/contracts/routes.json"),
17499 pointer: "/routes/0".to_owned(),
17500 line: 1,
17501 column: 1,
17502 },
17503 compatibility_view: json!({
17504 "route_id": "payment_provider_callback_stripe",
17505 "kind": "action",
17506 "method": "GET",
17507 "path": "/businesses/:business_slug/payment-providers/stripe/callback/",
17508 "action_id": "payment_provider_callback_stripe"
17509 }),
17510 },
17511 );
17512 let mut route_buckets = BTreeMap::new();
17513 route_buckets.insert(("action".to_owned(), 5usize), vec![route_id]);
17514 let app = AppRuntime {
17515 application_ir: Some(Arc::new(ApplicationIr {
17516 version: APPLICATION_IR_VERSION,
17517 contract_digest: "test".to_owned(),
17518 app_id: ContractId::parse("ucal").expect("app id"),
17519 routes,
17520 route_buckets,
17521 actions: BTreeMap::new(),
17522 entities: BTreeSet::new(),
17523 forms: BTreeSet::new(),
17524 policies: BTreeSet::new(),
17525 role_capability_edges: BTreeSet::new(),
17526 workflows: BTreeSet::new(),
17527 integrations: BTreeSet::new(),
17528 })),
17529 ..AppRuntime::default()
17530 };
17531
17532 let matched = matched_app_route(
17533 &app,
17534 "GET",
17535 "/businesses/lotus-studio/payment-providers/stripe/callback/",
17536 )
17537 .expect("matched route");
17538
17539 assert_eq!(
17540 matched.get("action_id").and_then(Value::as_str),
17541 Some("payment_provider_callback_stripe")
17542 );
17543 }
17544
17545 #[test]
17546 fn app_scope_path_prefixes_mounted_internal_redirects() {
17547 let app = AppRuntime {
17548 base_path: Some("/ucal".to_owned()),
17549 ..AppRuntime::default()
17550 };
17551
17552 assert_eq!(
17553 app_scope_path(&app, "/businesses/lotus-studio/"),
17554 "/ucal/businesses/lotus-studio/"
17555 );
17556 assert_eq!(
17557 app_scope_path(&app, "/ucal/businesses/lotus-studio/"),
17558 "/ucal/businesses/lotus-studio/"
17559 );
17560 assert_eq!(app_scope_path(&app, "/"), "/ucal");
17561 }
17562
17563 #[test]
17564 fn mounted_dynamic_feature_route_redirects_to_scoped_login() {
17565 let route_id = ContractId::parse("architect_home").expect("route id");
17566 let mut routes = BTreeMap::new();
17567 routes.insert(
17568 route_id.clone(),
17569 RouteIr {
17570 id: route_id.clone(),
17571 path: "/".to_owned(),
17572 kind: "page".to_owned(),
17573 method: "GET".to_owned(),
17574 action_id: None,
17575 page_id: Some(ContractId::parse("home").expect("page id")),
17576 policy_id: Some(ContractId::parse("review_builder_plan").expect("policy id")),
17577 auth_gate: "session_required".to_owned(),
17578 security: RouteSecurityPosture {
17579 csrf_mode: "mutation_required".to_owned(),
17580 trusted_origin_policy: "mutation_required".to_owned(),
17581 tenant_scope: "policy_bound".to_owned(),
17582 rate_limit_policy: "runtime_default".to_owned(),
17583 capability_policy: "policy_gate".to_owned(),
17584 },
17585 source: SourceProvenance {
17586 path: PathBuf::from("data/contracts/routes.json"),
17587 pointer: "/routes/0".to_owned(),
17588 line: 1,
17589 column: 1,
17590 },
17591 compatibility_view: json!({
17592 "route_id": "architect_home",
17593 "path": "/",
17594 "kind": "page",
17595 "method": "GET",
17596 "page_id": "home",
17597 "feature_id": "architect_builder_console",
17598 "auth_gate": "session_required",
17599 "policy_gate": "review_builder_plan"
17600 }),
17601 },
17602 );
17603 let mut route_buckets = BTreeMap::new();
17604 route_buckets.insert(("page".to_owned(), 0usize), vec![route_id]);
17605 let app = AppRuntime {
17606 app_id: Some("architect".to_owned()),
17607 host_profile: Some("dynamic-app".to_owned()),
17608 base_path: Some("/architect".to_owned()),
17609 application_ir: Some(Arc::new(ApplicationIr {
17610 version: APPLICATION_IR_VERSION,
17611 contract_digest: "test".to_owned(),
17612 app_id: ContractId::parse("architect").expect("app id"),
17613 routes,
17614 route_buckets,
17615 actions: BTreeMap::new(),
17616 entities: BTreeSet::new(),
17617 forms: BTreeSet::new(),
17618 policies: BTreeSet::new(),
17619 role_capability_edges: BTreeSet::new(),
17620 workflows: BTreeSet::new(),
17621 integrations: BTreeSet::new(),
17622 })),
17623 ..AppRuntime::default()
17624 };
17625
17626 let response =
17627 app_declarative_route_response(&app, &request("GET", "/architect/", "", None, ""))
17628 .expect("response");
17629 let text = String::from_utf8(response).expect("utf8 response");
17630
17631 assert!(text.starts_with("HTTP/1.1 303 See Other\r\n"));
17632 assert!(text.contains("Location: /architect/accounts/login/?next=/\r\n"));
17633 }
17634
17635 #[test]
17636 fn mounted_static_pages_artifact_routes_do_not_404() {
17638 let root = std::env::temp_dir().join(format!("pharos-static-pages-{}", std::process::id()));
17639 fs::create_dir_all(root.join("reference")).expect("create artifact reference dir");
17640 fs::write(
17641 root.join("reference/library-reference.html"),
17642 "<html><body>library reference</body></html>",
17643 )
17644 .expect("write artifact page");
17645 fs::write(
17646 root.join("runtime-route-map.tsv"),
17647 "GET\t/reference/library-reference.html\treference/library-reference.html\ttext/html; charset=utf-8\n",
17648 )
17649 .expect("write route map");
17650 let route_id = ContractId::parse("library_reference_route").expect("route id");
17651 let mut routes = BTreeMap::new();
17652 routes.insert(
17653 route_id.clone(),
17654 RouteIr {
17655 id: route_id.clone(),
17656 path: "/reference/library-reference.html".to_owned(),
17657 kind: "page".to_owned(),
17658 method: "GET".to_owned(),
17659 action_id: None,
17660 page_id: None,
17661 policy_id: None,
17662 auth_gate: "public".to_owned(),
17663 security: RouteSecurityPosture {
17664 csrf_mode: "none".to_owned(),
17665 trusted_origin_policy: "same-origin".to_owned(),
17666 tenant_scope: "global".to_owned(),
17667 rate_limit_policy: "default".to_owned(),
17668 capability_policy: "public".to_owned(),
17669 },
17670 source: SourceProvenance {
17671 path: PathBuf::from("data/contracts/routes.json"),
17672 pointer: "/routes/0".to_owned(),
17673 line: 1,
17674 column: 1,
17675 },
17676 compatibility_view: json!({
17677 "route_id": "library_reference_route",
17678 "kind": "page",
17679 "method": "GET",
17680 "path": "/reference/library-reference.html",
17681 "artifact": "reference/library-reference.html"
17682 }),
17683 },
17684 );
17685 let mut route_buckets = BTreeMap::new();
17686 route_buckets.insert(("page".to_owned(), 2usize), vec![route_id]);
17687 let app = AppRuntime {
17688 artifact_dir: Some(root.to_string_lossy().into_owned()),
17689 host_profile: Some("static-pages".to_owned()),
17690 base_path: Some("/dev_docs".to_owned()),
17691 application_ir: Some(Arc::new(ApplicationIr {
17692 version: APPLICATION_IR_VERSION,
17693 contract_digest: "test".to_owned(),
17694 app_id: ContractId::parse("dev_docs").expect("app id"),
17695 routes,
17696 route_buckets,
17697 actions: BTreeMap::new(),
17698 entities: BTreeSet::new(),
17699 forms: BTreeSet::new(),
17700 policies: BTreeSet::new(),
17701 role_capability_edges: BTreeSet::new(),
17702 workflows: BTreeSet::new(),
17703 integrations: BTreeSet::new(),
17704 })),
17705 ..AppRuntime::default()
17706 };
17707 let response = app_declarative_route_response(
17708 &app,
17709 &request(
17710 "GET",
17711 "/dev_docs/reference/library-reference.html",
17712 "",
17713 None,
17714 "",
17715 ),
17716 )
17717 .expect("response");
17718 let text = String::from_utf8(response).expect("utf8 response");
17719 assert!(text.starts_with("HTTP/1.1 200 OK\r\n"));
17720 assert!(text.contains("library reference"));
17721 let _ = fs::remove_dir_all(root);
17722 }
17723
17724 #[test]
17726 fn session_tokens_are_isolated_by_secret_and_application() {
17727 let app_a = AppRuntime {
17728 app_id: Some("app-a".to_owned()),
17729 ..AppRuntime::default()
17730 };
17731 let app_b = AppRuntime {
17732 app_id: Some("app-b".to_owned()),
17733 ..AppRuntime::default()
17734 };
17735 let first = native_session_token_hash(&app_a, "first-secret").expect("first digest");
17736 let second = native_session_token_hash(&app_a, "second-secret").expect("second digest");
17737 let other_app = native_session_token_hash(&app_b, "first-secret").expect("other digest");
17738 assert_ne!(first, second);
17739 assert_ne!(first, other_app);
17740 }
17741
17742 #[test]
17744 fn route_rate_limit_enforces_the_declared_window() {
17745 let root = std::env::temp_dir().join(format!("pharos-rate-limit-{}", std::process::id()));
17746 fs::create_dir_all(&root).expect("create rate-limit test root");
17747 let state_path = root.join("state.json");
17748 fs::write(&state_path, "{}\n").expect("write rate-limit state");
17749 let app = AppRuntime {
17750 state_path: Some(state_path.to_string_lossy().into_owned()),
17751 ..AppRuntime::default()
17752 };
17753 assert_eq!(
17754 dynamic::admit_rate_limited_request_runtime_local(
17755 &app,
17756 "runtime_default:test",
17757 "actor:1",
17758 2,
17759 60,
17760 ),
17761 Ok(true)
17762 );
17763 assert_eq!(
17764 dynamic::admit_rate_limited_request_runtime_local(
17765 &app,
17766 "runtime_default:test",
17767 "actor:1",
17768 2,
17769 60,
17770 ),
17771 Ok(true)
17772 );
17773 assert_eq!(
17774 dynamic::admit_rate_limited_request_runtime_local(
17775 &app,
17776 "runtime_default:test",
17777 "actor:1",
17778 2,
17779 60,
17780 ),
17781 Ok(false)
17782 );
17783 let bounded_app = AppRuntime {
17784 resource_budgets: pharos_runtime_core::config::ResourceBudgets {
17785 max_state_section_items: 2,
17786 ..Default::default()
17787 },
17788 ..app.clone()
17789 };
17790 assert_eq!(
17791 dynamic::admit_rate_limited_request_runtime_local(
17792 &bounded_app,
17793 "runtime_default:other",
17794 "actor:2",
17795 3,
17796 60,
17797 ),
17798 Err(69)
17799 );
17800 fs::remove_dir_all(root).expect("remove rate-limit test root");
17801 }
17802
17803 #[test]
17805 fn database_failures_keep_distinct_http_statuses() {
17806 use pharos_runtime_database::operations::DatabaseError;
17807 for (error, status) in [
17808 (DatabaseError::AccessDenied, 403),
17809 (DatabaseError::Conflict, 409),
17810 (DatabaseError::InvalidContract, 422),
17811 (DatabaseError::MissingRecord, 404),
17812 (DatabaseError::CapacityExceeded, 503),
17813 (DatabaseError::StorageFailure, 503),
17814 (DatabaseError::Unavailable, 503),
17815 ] {
17816 let response = String::from_utf8(super::database_error_response(error)).unwrap();
17817 assert!(response.starts_with(&format!("HTTP/1.1 {status} ")));
17818 let body = response.split("\r\n\r\n").nth(1).unwrap();
17819 let body: Value = serde_json::from_str(body).unwrap();
17820 assert_eq!(body["schema"], "pharos.error.v1");
17821 assert!(body["error"].get("details").is_none());
17822 }
17823 }
17824
17825 #[test]
17827 fn json_failures_use_the_stable_error_envelope() {
17828 let legacy = canonical_http_error_body(
17829 403,
17830 "Forbidden",
17831 r#"{"error_code":"capability_denied","message":"capability denied"}"#,
17832 );
17833 let parsed: serde_json::Value = serde_json::from_str(&legacy).expect("legacy envelope");
17834 assert_eq!(parsed["schema"], "pharos.error.v1");
17835 assert_eq!(parsed["error"]["code"], "access_denied");
17836 assert_eq!(parsed["error"]["message"], "capability denied");
17837 assert!(parsed["error"].get("details").is_none());
17838
17839 let validation = canonical_http_error_body(
17840 422,
17841 "Unprocessable Entity",
17842 r#"{"schema":"pharos.validation.report.v1","errors":["field_required"]}"#,
17843 );
17844 let parsed: serde_json::Value =
17845 serde_json::from_str(&validation).expect("validation envelope");
17846 assert_eq!(parsed["schema"], "pharos.error.v1");
17847 assert_eq!(parsed["error"]["code"], "invalid_input");
17848 assert_eq!(
17849 parsed["error"]["details"]["schema"],
17850 "pharos.validation.report.v1"
17851 );
17852 assert_eq!(parsed["error"]["details"]["errors"][0], "field_required");
17853
17854 let validation = canonical_http_error_body(
17855 422,
17856 "Unprocessable Entity",
17857 r#"{"schema":"pharos.validation.report.v1","errors":["missing_required_field"],"field_errors":[{"field_id":"title","code":"missing_required_field","message":"A required field was not provided.","submitted_value":"private input"}]}"#,
17858 );
17859 let parsed: serde_json::Value = serde_json::from_str(&validation).unwrap();
17860 assert_eq!(
17861 parsed["error"]["message"],
17862 "title: A required field was not provided."
17863 );
17864 assert_eq!(
17865 parsed["error"]["details"]["field_errors"][0]["code"],
17866 "missing_required_field"
17867 );
17868 assert!(!validation.contains("private input"));
17869 }
17870
17871 #[test]
17873 fn public_generated_app_ignores_foreign_cookie_without_hiding_declared_storage_errors() {
17874 use pharos_runtime_database::contract::{AppDataContract, migration_checksum};
17875 let root = Path::new(env!("CARGO_MANIFEST_DIR"))
17876 .join("tmp")
17877 .join(format!("session-schema-boundary-{}", std::process::id()));
17878 let app_root = root.join("app");
17879 fs::create_dir_all(app_root.join("data/contracts")).expect("app contracts");
17880 fs::write(
17881 app_root.join("pharos.app.yml"),
17882 "app_id: cookie_app\ndatabase:\n migration_contract: data/contracts/database.json\n",
17883 )
17884 .unwrap();
17885 let contract_path = app_root.join("data/contracts/database.json");
17886 fs::write(
17887 &contract_path,
17888 pharos_runtime_architect::generated_database::record_contract(
17889 "cookie_app",
17890 "entry",
17891 &["id", "title", "note", "created_at"].map(str::to_owned),
17892 "Cookie App",
17893 )
17894 .unwrap(),
17895 )
17896 .unwrap();
17897 let config = root.join("runtime.conf");
17898 let data = root.join("uninitialized.sqlite3");
17899 fs::write(
17900 &config,
17901 format!("db_backend=sqlite\ndb_path={}\n", data.display()),
17902 )
17903 .unwrap();
17904 let app = AppRuntime {
17905 app_id: Some("cookie_app".to_owned()),
17906 host_profile: Some("dynamic-app".to_owned()),
17907 app_root: Some(app_root.to_string_lossy().into_owned()),
17908 runtime_conf_path: Some(config.to_string_lossy().into_owned()),
17909 ..AppRuntime::default()
17910 };
17911 let cookie = Some("pharos_session=unrelated_architect_session");
17912 assert_eq!(active_session_user_id(&app, cookie), Ok(0));
17913 assert!(
17914 !data.exists(),
17915 "no session declaration must not open storage"
17916 );
17917 let repo = Path::new(env!("CARGO_MANIFEST_DIR"));
17918 let mut declared: AppDataContract = pharos_runtime_compiler::database::load_source(
17919 &repo.join("apps/architect/data/contracts/database.yml"),
17920 "architect",
17921 )
17922 .unwrap();
17923 declared.schema.app_id = "cookie_app".to_owned();
17924 declared.legacy_adoption = None;
17925 for migration in &mut declared.migrations {
17926 migration.app_id = "cookie_app".to_owned();
17927 migration.checksum =
17928 migration_checksum(&migration.app_id, &migration.id, &migration.changes).unwrap();
17929 }
17930 fs::write(contract_path, serde_json::to_vec(&declared).unwrap()).unwrap();
17931 assert!(
17932 active_session_user_id(&app, cookie).is_err(),
17933 "declared but missing session storage must fail explicitly"
17934 );
17935 fs::remove_dir_all(root).unwrap();
17936 }
17937
17938 #[test]
17940 fn session_lookup_rejects_cross_application_substitution() {
17941 let root = std::env::temp_dir().join(format!("pharos-session-{}", std::process::id()));
17942 fs::create_dir_all(&root).expect("create session test root");
17943 let state_path = root.join("state.json");
17944 let app_a = AppRuntime {
17945 app_id: Some("app-a".to_owned()),
17946 state_path: Some(state_path.to_string_lossy().into_owned()),
17947 ..AppRuntime::default()
17948 };
17949 let app_b = AppRuntime {
17950 app_id: Some("app-b".to_owned()),
17951 state_path: app_a.state_path.clone(),
17952 ..AppRuntime::default()
17953 };
17954 let first_token = "first-unique-session-secret";
17955 let second_token = "second-unique-session-secret";
17956 let first_digest =
17957 native_session_token_hash(&app_a, first_token).expect("first session digest");
17958 let second_digest =
17959 native_session_token_hash(&app_a, second_token).expect("second session digest");
17960 fs::write(
17961 &state_path,
17962 serde_json::json!({"sessions":[
17963 {
17964 "session_token_hash": first_digest,
17965 "app_id": "app-a",
17966 "user_id": 7,
17967 "expires_at": "9999999999",
17968 "revoked_at": null
17969 },
17970 {
17971 "session_token_hash": second_digest,
17972 "app_id": "app-a",
17973 "user_id": 8,
17974 "expires_at": "9999999999",
17975 "revoked_at": null
17976 }
17977 ]})
17978 .to_string(),
17979 )
17980 .expect("write session state");
17981 let first_app = app_a.clone();
17982 let second_app = app_a.clone();
17983 let first_lookup = std::thread::spawn(move || {
17984 active_session_user_id(&first_app, Some(&format!("pharos_session={first_token}")))
17985 .expect("security operation")
17986 });
17987 let second_lookup = std::thread::spawn(move || {
17988 active_session_user_id(&second_app, Some(&format!("pharos_session={second_token}")))
17989 .expect("security operation")
17990 });
17991 assert_eq!(first_lookup.join().expect("first session lookup"), 7);
17992 assert_eq!(second_lookup.join().expect("second session lookup"), 8);
17993 assert_eq!(
17994 active_session_user_id(&app_b, Some(&format!("pharos_session={first_token}")))
17995 .expect("security operation"),
17996 0
17997 );
17998 fs::remove_dir_all(root).expect("remove session test root");
17999 }
18000
18001 #[test]
18002 fn relational_login_and_logout_skip_state_mutation_lock() {
18004 let root =
18005 std::env::temp_dir().join(format!("pharos-relational-login-{}", std::process::id()));
18006 fs::create_dir_all(&root).expect("create relational login test root");
18007 let app_root = root.join("app");
18008 fs::create_dir_all(app_root.join("data/contracts")).expect("create app contracts");
18009 let runtime_conf = root.join("runtime.conf");
18010 let state_path = root.join("state.json");
18011 fs::write(
18012 app_root.join("pharos.app.yml"),
18013 "database:\n migration_contract: data/contracts/migrations.yml\npayments:\n provider_management:\n supported_providers:\n - provider: stripe\n callback_action_id: payment_provider_callback_stripe\n",
18014 )
18015 .expect("write app manifest");
18016 fs::write(
18017 app_root.join("data/contracts/migrations.yml"),
18018 "state_cutover_mode: relational_runtime_authority\n",
18019 )
18020 .expect("write migration contract");
18021 fs::write(&runtime_conf, "db_backend=sqlite\n").expect("write runtime conf");
18022 fs::write(&state_path, "{}\n").expect("write state");
18023
18024 let app = AppRuntime {
18025 app_root: Some(app_root.to_string_lossy().into_owned()),
18026 runtime_conf_path: Some(runtime_conf.to_string_lossy().into_owned()),
18027 state_path: Some(state_path.to_string_lossy().into_owned()),
18028 ..AppRuntime::default()
18029 };
18030
18031 let login_route = serde_json::json!({ "action_id": "login" });
18032 let logout_route = serde_json::json!({ "action_id": "logout" });
18033 let other_route = serde_json::json!({ "action_id": "update_profile" });
18034
18035 assert!(!route_requires_state_mutation_guard(
18036 &app,
18037 "POST",
18038 Some(&login_route)
18039 ));
18040 assert!(!route_requires_state_mutation_guard(
18041 &app,
18042 "POST",
18043 Some(&logout_route)
18044 ));
18045 assert!(!route_requires_state_mutation_guard(
18046 &app,
18047 "POST",
18048 Some(&other_route)
18049 ));
18050 let feature_route = serde_json::json!({
18051 "feature_id": "records",
18052 "action_id": "create_record"
18053 });
18054 assert!(!route_requires_state_mutation_guard(
18055 &app,
18056 "POST",
18057 Some(&feature_route)
18058 ));
18059 let callback_route = serde_json::json!({
18060 "action_id": "payment_provider_callback_stripe"
18061 });
18062 assert!(!route_requires_state_mutation_guard(
18063 &app,
18064 "GET",
18065 Some(&callback_route)
18066 ));
18067 assert!(actor_owns_mutation_resource(&app, "/businesses/other/edit/", 10).is_err());
18068 assert!(actor_owns_mutation_resource(&app, "/appointments/27/cancel/", 10).is_err());
18069 assert!(
18070 actor_owns_mutation_resource(&app, "/businesses/new/", 10)
18071 .expect("ownership operation")
18072 );
18073
18074 let file_backed_app_root = root.join("file_app");
18075 fs::create_dir_all(&file_backed_app_root).expect("create file-backed app root");
18076 fs::write(
18077 file_backed_app_root.join("pharos.app.yml"),
18078 "app_id: file_app
18079",
18080 )
18081 .expect("write file-backed manifest");
18082 let file_backed_app = AppRuntime {
18083 app_root: Some(file_backed_app_root.to_string_lossy().into_owned()),
18084 runtime_conf_path: Some(runtime_conf.to_string_lossy().into_owned()),
18085 state_path: Some(state_path.to_string_lossy().into_owned()),
18086 ..AppRuntime::default()
18087 };
18088 assert!(route_requires_state_mutation_guard(
18089 &file_backed_app,
18090 "POST",
18091 Some(&feature_route)
18092 ));
18093 fs::write(&runtime_conf, "db_backend=unknown\n").expect("invalidate backend config");
18094 assert_eq!(super::runtime_default_backend(&app), "unknown");
18095 assert_eq!(super::db_check_json(&app, "unknown")["status"], "invalid");
18096 assert_eq!(
18097 super::app_runtime_backend_summary_value(&app)["db_ready"],
18098 false
18099 );
18100 assert!(app_uses_relational_runtime_authority(&app));
18101 assert!(!app_uses_file_backed_runtime_state(&app));
18102 fs::remove_file(app_root.join("data/contracts/migrations.yml"))
18103 .expect("remove declared contract to exercise missing-file rejection");
18104 assert!(app_uses_relational_runtime_authority(&app));
18105 assert!(!app_uses_file_backed_runtime_state(&app));
18106 assert!(actor_owns_mutation_resource(&app, "/businesses/other/edit/", 10).is_err());
18107
18108 fs::remove_dir_all(root).expect("remove relational login test root");
18109 }
18110
18111 #[test]
18113 fn mutation_resource_ownership_rejects_cross_tenant_identifier() {
18114 let root = std::env::temp_dir().join(format!("pharos-owner-{}", std::process::id()));
18115 fs::create_dir_all(&root).expect("create owner test root");
18116 let state_path = root.join("state.json");
18117 fs::write(
18118 &state_path,
18119 serde_json::json!({"businesses":[
18120 {"id": 1, "slug": "owned", "owner_user_id": 10},
18121 {"id": 2, "slug": "other", "owner_user_id": 20}
18122 ]})
18123 .to_string(),
18124 )
18125 .expect("write owner state");
18126 let app = AppRuntime {
18127 state_path: Some(state_path.to_string_lossy().into_owned()),
18128 ..AppRuntime::default()
18129 };
18130 assert!(
18131 actor_owns_mutation_resource(&app, "/businesses/owned/edit/", 10)
18132 .expect("ownership operation")
18133 );
18134 assert!(
18135 !actor_owns_mutation_resource(&app, "/businesses/other/edit/", 10)
18136 .expect("ownership operation")
18137 );
18138 assert!(
18139 actor_owns_mutation_resource(&app, "/businesses/new/", 10)
18140 .expect("ownership operation")
18141 );
18142 fs::remove_dir_all(root).expect("remove owner test root");
18143 }
18144
18145 #[test]
18147 fn booking_time_normalization_rejects_invalid_and_converts_meridiem() {
18148 assert_eq!(
18149 normalized_booking_time("", "12", "00", "AM").as_deref(),
18150 Some("00:00")
18151 );
18152 assert_eq!(
18153 normalized_booking_time("", "12", "05", "PM").as_deref(),
18154 Some("12:05")
18155 );
18156 assert_eq!(
18157 normalized_booking_time("", "01", "30", "pm").as_deref(),
18158 Some("13:30")
18159 );
18160 assert_eq!(
18161 normalized_booking_time("9:07", "", "", "").as_deref(),
18162 Some("09:07")
18163 );
18164 assert!(normalized_booking_time("", "00", "00", "AM").is_none());
18165 assert!(normalized_booking_time("", "11", "60", "AM").is_none());
18166 assert!(normalized_booking_time("", "11", "00", "noon").is_none());
18167 }
18168
18169 #[test]
18171 fn runtime_timestamp_seconds_accepts_epoch_and_utc_text() {
18172 assert_eq!(runtime_timestamp_seconds("1724976000"), Some(1_724_976_000));
18173 assert_eq!(
18174 runtime_timestamp_seconds("2026-08-30T00:00:00Z"),
18175 Some(1_788_048_000)
18176 );
18177 assert_eq!(
18178 runtime_timestamp_seconds("2026-08-30 00:00:00"),
18179 Some(1_788_048_000)
18180 );
18181 assert_eq!(runtime_timestamp_seconds(""), None);
18182 assert_eq!(runtime_timestamp_seconds("not-a-time"), None);
18183 }
18184
18185 #[test]
18186 fn artifact_tree_digest_ignores_ds_store_noise() {
18188 let root =
18189 std::env::temp_dir().join(format!("pharos-artifact-noise-{}", std::process::id()));
18190 if root.exists() {
18191 fs::remove_dir_all(&root).expect("clear prior artifact-noise root");
18192 }
18193 fs::create_dir_all(root.join("templates")).expect("create artifact-noise root");
18194 fs::write(root.join("pharos.app.json"), "{}\n").expect("write manifest");
18195 fs::write(root.join("templates/layout.html"), "<body></body>\n").expect("write layout");
18196 let digest_without_noise =
18197 tree_content_digest(&root, None).expect("digest without ds_store");
18198 fs::write(root.join(".DS_Store"), "finder-noise\n").expect("write root ds_store");
18199 fs::write(root.join("templates/.DS_Store"), "nested-finder-noise\n")
18200 .expect("write nested ds_store");
18201 let digest_with_noise = tree_content_digest(&root, None).expect("digest with ds_store");
18202 assert_eq!(digest_without_noise, digest_with_noise);
18203 assert!(ignore_non_contract_artifact_entry(".DS_Store"));
18204 assert!(!ignore_non_contract_artifact_entry(".gitkeep"));
18205 fs::remove_dir_all(root).expect("remove artifact-noise root");
18206 }
18207
18208 #[test]
18209 fn route_request_middleware_returns_structured_validation_report() {
18211 let route = serde_json::json!({
18212 "path": "/middleware/proof/",
18213 "middleware": [{
18214 "phase": "request",
18215 "kind": "require_header",
18216 "header": "X-Pharos-Proof",
18217 "equals": "state-open",
18218 "status_code": 428,
18219 "error_code": "middleware_header_required",
18220 "message": "This route requires X-Pharos-Proof: state-open."
18221 }]
18222 });
18223 let request = request("GET", "/middleware/proof/", "", None, "");
18224 let failure =
18225 route_request_middleware_failure(&route, &request).expect("middleware failure");
18226 assert_eq!(failure.0, 428);
18227 let report: serde_json::Value =
18228 serde_json::from_str(&failure.2).expect("middleware validation report");
18229 assert_eq!(report["schema"], "pharos.validation.report.v1");
18230 assert_eq!(
18231 report["form_errors"][0]["code"],
18232 "middleware_header_required"
18233 );
18234 assert_eq!(
18235 report["form_errors"][0]["message"],
18236 "This route requires X-Pharos-Proof: state-open."
18237 );
18238 }
18239
18240 #[test]
18241 fn route_request_hook_rejects_non_form_body_when_form_is_required() {
18243 let route = serde_json::json!({
18244 "path": "/snapshots/purge",
18245 "request_hooks": ["form_body_required"]
18246 });
18247 let request = request(
18248 "POST",
18249 "/snapshots/purge",
18250 "",
18251 Some("application/json"),
18252 r#"{"board_id":4}"#,
18253 );
18254 let failure = route_request_hook_failure(&route, &request).expect("form hook failure");
18255 assert_eq!(failure.0, 415);
18256 let report: serde_json::Value =
18257 serde_json::from_str(&failure.2).expect("hook validation report");
18258 assert_eq!(
18259 report["form_errors"][0]["code"],
18260 "form_content_type_required"
18261 );
18262 }
18263
18264 #[test]
18265 fn route_response_middleware_sets_and_mirrors_headers() {
18267 let route = serde_json::json!({
18268 "middleware": [
18269 {
18270 "phase": "response",
18271 "kind": "set_header",
18272 "name": "X-Pharos-Route-Middleware",
18273 "value": "state_api_active"
18274 },
18275 {
18276 "phase": "response",
18277 "kind": "mirror_request_header",
18278 "name": "X-Pharos-Proof-Echo",
18279 "from_request_header": "X-Pharos-Proof",
18280 "default_value": "missing"
18281 },
18282 {
18283 "phase": "response",
18284 "kind": "mirror_query_param",
18285 "name": "X-Pharos-Proof-Query",
18286 "from_query_param": "proof",
18287 "default_value": "missing"
18288 }
18289 ]
18290 });
18291 let request = request(
18292 "GET",
18293 "/middleware/query-proof?proof=open",
18294 "X-Pharos-Proof: state-open\r\n",
18295 None,
18296 "",
18297 );
18298 let response = apply_route_response_middleware(
18299 &route,
18300 &request,
18301 b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(),
18302 );
18303 let response_text = String::from_utf8(response).expect("response text");
18304 assert!(response_text.contains("X-Pharos-Route-Middleware: state_api_active\r\n"));
18305 assert!(response_text.contains("X-Pharos-Proof-Echo: state-open\r\n"));
18306 assert!(response_text.contains("X-Pharos-Proof-Query: open\r\n"));
18307 }
18308
18309 #[test]
18310 fn background_job_emits_started_and_retried_lifecycle_payloads() {
18312 let root = std::env::temp_dir().join(format!("pharos-job-events-{}", std::process::id()));
18313 if root.exists() {
18314 fs::remove_dir_all(&root).expect("clear prior job-events root");
18315 }
18316 fs::create_dir_all(&root).expect("create job-events root");
18317 let state_path = root.join("runtime-state.json");
18318 fs::write(
18319 &state_path,
18320 serde_json::json!({
18321 "job_executions": [{
18322 "id": 1,
18323 "workflow_execution_id": 7,
18324 "queue_name": "proving",
18325 "job_id": "simulate-failure",
18326 "handler_id": "simulate_failure",
18327 "execution_status": "queued",
18328 "attempt_count": 0,
18329 "max_attempts": 3,
18330 "backoff_seconds": 5,
18331 "lease_seconds": 30,
18332 "available_at": "0"
18333 }],
18334 "job_execution_attempts": [],
18335 "workflow_execution_events": []
18336 })
18337 .to_string(),
18338 )
18339 .expect("write job state");
18340 let app = AppRuntime {
18341 app_id: Some("ucal".to_owned()),
18342 state_path: Some(state_path.to_string_lossy().into_owned()),
18343 queue_enabled: true,
18344 queue_max_attempts: 3,
18345 queue_retry_seconds: 5,
18346 ..AppRuntime::default()
18347 };
18348 process_one_background_job(&app);
18349 let state: serde_json::Value =
18350 serde_json::from_str(&fs::read_to_string(&state_path).expect("read state"))
18351 .expect("parse state");
18352 assert_eq!(
18353 state["job_execution_attempts"]
18354 .as_array()
18355 .expect("attempt rows")
18356 .len(),
18357 1
18358 );
18359 let events = state["workflow_execution_events"]
18360 .as_array()
18361 .expect("workflow events");
18362 assert_eq!(events.len(), 2);
18363 assert_eq!(events[0]["event_kind"], "job_started");
18364 assert_eq!(events[1]["event_kind"], "job_retried");
18365 let started_payload: serde_json::Value =
18366 serde_json::from_str(events[0]["payload_json"].as_str().expect("started payload"))
18367 .expect("parse started payload");
18368 let retried_payload: serde_json::Value =
18369 serde_json::from_str(events[1]["payload_json"].as_str().expect("retried payload"))
18370 .expect("parse retried payload");
18371 assert_eq!(started_payload["queue_name"], "proving");
18372 assert_eq!(started_payload["attempt_number"], 1);
18373 assert_eq!(started_payload["lease_seconds"], 30);
18374 assert_eq!(started_payload["backoff_seconds"], 5);
18375 assert_eq!(retried_payload["execution_status"], "queued");
18376 assert_eq!(retried_payload["queue_name"], "proving");
18377 assert_eq!(retried_payload["attempt_number"], 1);
18378 fs::remove_dir_all(root).expect("remove job-events root");
18379 }
18380
18381 #[test]
18382 fn ucal_payment_provider_connect_route_matches_post() {
18383 let route_id = ContractId::parse("payment_provider_connect_stripe").expect("route id");
18384 let mut routes = BTreeMap::new();
18385 routes.insert(
18386 route_id.clone(),
18387 RouteIr {
18388 id: route_id.clone(),
18389 path: "/businesses/:business_slug/payment-providers/stripe/connect/".to_owned(),
18390 kind: "action".to_owned(),
18391 method: "POST".to_owned(),
18392 action_id: Some(
18393 ContractId::parse("payment_provider_connect_stripe").expect("action id"),
18394 ),
18395 page_id: None,
18396 policy_id: Some(
18397 ContractId::parse("manage_payment_provider_connection").expect("policy id"),
18398 ),
18399 auth_gate: "session".to_owned(),
18400 security: RouteSecurityPosture {
18401 csrf_mode: "required".to_owned(),
18402 trusted_origin_policy: "same-origin".to_owned(),
18403 tenant_scope: "policy_bound".to_owned(),
18404 rate_limit_policy: "default".to_owned(),
18405 capability_policy: "policy_gate".to_owned(),
18406 },
18407 source: SourceProvenance {
18408 path: PathBuf::from("data/contracts/routes.json"),
18409 pointer: "/routes/0".to_owned(),
18410 line: 1,
18411 column: 1,
18412 },
18413 compatibility_view: json!({
18414 "route_id": "payment_provider_connect_stripe",
18415 "kind": "action",
18416 "method": "POST",
18417 "path": "/businesses/:business_slug/payment-providers/stripe/connect/",
18418 "action_id": "payment_provider_connect_stripe"
18419 }),
18420 },
18421 );
18422 let mut route_buckets = BTreeMap::new();
18423 route_buckets.insert(("action".to_owned(), 5usize), vec![route_id]);
18424 let app = AppRuntime {
18425 application_ir: Some(Arc::new(ApplicationIr {
18426 version: APPLICATION_IR_VERSION,
18427 contract_digest: "test".to_owned(),
18428 app_id: ContractId::parse("ucal").expect("app id"),
18429 routes,
18430 route_buckets,
18431 actions: BTreeMap::new(),
18432 entities: BTreeSet::new(),
18433 forms: BTreeSet::new(),
18434 policies: BTreeSet::new(),
18435 role_capability_edges: BTreeSet::new(),
18436 workflows: BTreeSet::new(),
18437 integrations: BTreeSet::new(),
18438 })),
18439 ..AppRuntime::default()
18440 };
18441 assert_eq!(
18442 matched_app_route(
18443 &app,
18444 "POST",
18445 "/businesses/lotus-studio/payment-providers/stripe/connect/",
18446 )
18447 .and_then(|route| route
18448 .get("action_id")
18449 .and_then(Value::as_str)
18450 .map(str::to_owned))
18451 .as_deref(),
18452 Some("payment_provider_connect_stripe")
18453 );
18454 }
18455 #[test]
18457 fn source_export_responses_have_a_scoped_archive_budget() {
18458 let app = AppRuntime {
18459 app_id: Some("architect".into()),
18460 ..AppRuntime::default()
18461 };
18462 let ordinary = app.resource_budgets.max_response_bytes;
18463 let archive =
18464 super::http_download_response(b"source", "application/x-tar", "source.tar", "GET");
18465 assert!(
18466 super::app_response_body_budget(&app, "/exports/download", "GET", &archive) > ordinary
18467 );
18468 assert_eq!(
18469 super::app_response_body_budget(&app, "/other", "GET", &archive),
18470 ordinary
18471 );
18472 assert_eq!(
18473 super::app_response_body_budget(&app, "/exports/download", "POST", &archive),
18474 ordinary
18475 );
18476 assert_eq!(
18477 super::app_response_body_budget(
18478 &app,
18479 "/exports/download",
18480 "GET",
18481 &super::http_text_response(500, "Internal Server Error", "failed")
18482 ),
18483 ordinary
18484 );
18485 let other = AppRuntime {
18486 app_id: Some("ucal".into()),
18487 ..AppRuntime::default()
18488 };
18489 assert_eq!(
18490 super::app_response_body_budget(&other, "/exports/download", "GET", &archive),
18491 ordinary
18492 );
18493 }
18494}