Skip to main content

pharos_native/runtime/
mod.rs

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
7/// Exposes Architect webapp-owned agent worker services.
8pub mod agent_architect;
9/// Exposes the `agent_bridge` runtime module.
10///
11/// # Examples
12///
13/// ```rust,ignore
14/// use pharos_native::runtime::agent_bridge;
15/// ```
16///
17/// # Related
18///
19/// - [`crate::runtime`] for sibling modules in the same crate.
20pub mod agent_bridge;
21/// Exposes Architect attachment and task-ingestion services.
22pub mod agent_ingest;
23#[cfg(not(target_os = "wasi"))]
24/// Exposes optional Architect media extraction adapters and artifact readiness checks.
25pub mod agent_media;
26/// Exposes Architect provider-secret vault services.
27pub 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/// Exposes shared app scaffold helpers used by the CLI and agent bridge.
40#[cfg(not(target_os = "wasi"))]
41/// Exposes the `backup` runtime module.
42///
43/// # Examples
44///
45/// ```rust,ignore
46/// use pharos_native::runtime::backup;
47/// ```
48///
49/// # Related
50///
51/// - [`crate::runtime`] for sibling modules in the same crate.
52pub mod backup;
53/// Exposes the `business` runtime module.
54///
55/// # Examples
56///
57/// ```rust,ignore
58/// use pharos_native::runtime::business;
59/// ```
60///
61/// # Related
62///
63/// - [`crate::runtime`] for sibling modules in the same crate.
64pub mod business;
65/// Exposes the `cache` runtime module.
66///
67/// # Examples
68///
69/// ```rust,ignore
70/// use pharos_native::runtime::cache;
71/// ```
72///
73/// # Related
74///
75/// - [`crate::runtime`] for sibling modules in the same crate.
76pub mod cache;
77/// Exposes the `cache_key` runtime module.
78///
79/// # Examples
80///
81/// ```rust,ignore
82/// use pharos_native::runtime::cache_key;
83/// ```
84///
85/// # Related
86///
87/// - [`crate::runtime`] for sibling modules in the same crate.
88pub mod cache_key;
89/// Exposes the `connection` runtime module.
90///
91/// # Examples
92///
93/// ```rust,ignore
94/// use pharos_native::runtime::connection;
95/// ```
96///
97/// # Related
98///
99/// - [`crate::runtime`] for sibling modules in the same crate.
100pub mod connection;
101/// Exposes the `database` runtime module.
102///
103/// # Examples
104///
105/// ```rust,ignore
106/// use pharos_native::runtime::database;
107/// ```
108///
109/// # Related
110///
111/// - [`crate::runtime`] for sibling modules in the same crate.
112pub mod database;
113/// Exposes the `declarative` runtime module.
114///
115/// # Examples
116///
117/// ```rust,ignore
118/// use pharos_native::runtime::declarative;
119/// ```
120///
121/// # Related
122///
123/// - [`crate::runtime`] for sibling modules in the same crate.
124pub mod declarative;
125/// Exposes the `docs` runtime module.
126///
127/// # Examples
128///
129/// ```rust,ignore
130/// use pharos_native::runtime::docs;
131/// ```
132///
133/// # Related
134///
135/// - [`crate::runtime`] for sibling modules in the same crate.
136pub mod docs;
137/// Exposes the `dynamic` runtime module.
138///
139/// # Examples
140///
141/// ```rust,ignore
142/// use pharos_native::runtime::dynamic;
143/// ```
144///
145/// # Related
146///
147/// - [`crate::runtime`] for sibling modules in the same crate.
148pub mod dynamic;
149/// Exposes the `feature_runtime` runtime module.
150///
151/// # Examples
152///
153/// ```rust,ignore
154/// use pharos_native::runtime::feature_runtime;
155/// ```
156///
157/// # Related
158///
159/// - [`crate::runtime`] for sibling modules in the same crate.
160pub mod feature_runtime;
161/// Exposes the `features` runtime module.
162///
163/// # Examples
164///
165/// ```rust,ignore
166/// use pharos_native::runtime::features;
167/// ```
168///
169/// # Related
170///
171/// - [`crate::runtime`] for sibling modules in the same crate.
172pub mod features;
173/// Exposes the `fs` runtime module.
174///
175/// # Examples
176///
177/// ```rust,ignore
178/// use pharos_native::runtime::fs;
179/// ```
180///
181/// # Related
182///
183/// - [`crate::runtime`] for sibling modules in the same crate.
184pub mod fs;
185/// Exposes the `generated` runtime module.
186///
187/// # Examples
188///
189/// ```rust,ignore
190/// use pharos_native::runtime::generated;
191/// ```
192///
193/// # Related
194///
195/// - [`crate::runtime`] for sibling modules in the same crate.
196pub mod generated;
197/// Exposes the `http` runtime module.
198///
199/// # Examples
200///
201/// ```rust,ignore
202/// use pharos_native::runtime::http;
203/// ```
204///
205/// # Related
206///
207/// - [`crate::runtime`] for sibling modules in the same crate.
208pub mod http;
209/// Exposes the `job` runtime module.
210///
211/// # Examples
212///
213/// ```rust,ignore
214/// use pharos_native::runtime::job;
215/// ```
216///
217/// # Related
218///
219/// - [`crate::runtime`] for sibling modules in the same crate.
220pub mod job;
221/// Exposes the `json_commands` runtime module.
222///
223/// # Examples
224///
225/// ```rust,ignore
226/// use pharos_native::runtime::json_commands;
227/// ```
228///
229/// # Related
230///
231/// - [`crate::runtime`] for sibling modules in the same crate.
232pub mod json_commands;
233/// Exposes the `json_ops` runtime module.
234///
235/// # Examples
236///
237/// ```rust,ignore
238/// use pharos_native::runtime::json_ops;
239/// ```
240///
241/// # Related
242///
243/// - [`crate::runtime`] for sibling modules in the same crate.
244pub mod json_ops;
245#[cfg(not(target_os = "wasi"))]
246/// Exposes the `listener` runtime module.
247///
248/// # Examples
249///
250/// ```rust,ignore
251/// use pharos_native::runtime::listener;
252/// ```
253///
254/// # Related
255///
256/// - [`crate::runtime`] for sibling modules in the same crate.
257pub mod listener;
258/// Exposes the `ops_bridge` runtime module.
259///
260/// # Examples
261///
262/// ```rust,ignore
263/// use pharos_native::runtime::ops_bridge;
264/// ```
265///
266/// # Related
267///
268/// - [`crate::runtime`] for sibling modules in the same crate.
269pub mod ops_bridge;
270/// Framework-owned, app-neutral payment-provider lifecycle contracts.
271pub mod payment_provider;
272/// Exposes the `ui` runtime module.
273///
274/// # Examples
275///
276/// ```rust,ignore
277/// use pharos_native::runtime::ui;
278/// ```
279///
280/// # Related
281///
282/// - [`crate::runtime`] for sibling modules in the same crate.
283pub mod ui;
284
285/// Exposes the `reference` runtime module.
286///
287/// # Examples
288///
289/// ```rust,ignore
290/// use pharos_native::runtime::reference;
291/// ```
292///
293/// # Related
294///
295/// - [`crate::runtime`] for sibling modules in the same crate.
296pub mod reference;
297/// Exposes the `runtime_reports` runtime module.
298///
299/// # Examples
300///
301/// ```rust,ignore
302/// use pharos_native::runtime::runtime_reports;
303/// ```
304///
305/// # Related
306///
307/// - [`crate::runtime`] for sibling modules in the same crate.
308pub mod runtime_reports;
309/// Exposes the `runtime_stack` runtime module.
310///
311/// # Examples
312///
313/// ```rust,ignore
314/// use pharos_native::runtime::runtime_stack;
315/// ```
316///
317/// # Related
318///
319/// - [`crate::runtime`] for sibling modules in the same crate.
320pub mod runtime_stack;
321/// Exposes the `state_store` runtime module.
322///
323/// # Examples
324///
325/// ```rust,ignore
326/// use pharos_native::runtime::state_store;
327/// ```
328///
329/// # Related
330///
331/// - [`crate::runtime`] for sibling modules in the same crate.
332pub mod state_store;
333/// Exposes the `support` runtime module.
334///
335/// # Examples
336///
337/// ```rust,ignore
338/// use pharos_native::runtime::support;
339/// ```
340///
341/// # Related
342///
343/// - [`crate::runtime`] for sibling modules in the same crate.
344pub mod support;
345#[cfg(not(target_os = "wasi"))]
346/// Exposes the `worker_pool` runtime module.
347///
348/// # Examples
349///
350/// ```rust,ignore
351/// use pharos_native::runtime::worker_pool;
352/// ```
353///
354/// # Related
355///
356/// - [`crate::runtime`] for sibling modules in the same crate.
357pub 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
385/// Implementation status marker for the runtime module.
386///
387/// # Examples
388///
389/// ```rust,ignore
390/// let _ = pharos_native::runtime::MODULE_STATUS;
391/// ```
392///
393/// # Related
394///
395/// - [`crate::runtime`] for the surrounding module surface.
396pub const MODULE_STATUS: &str = "COMPLETE";
397/// Version string embedded in the runtime stack contract.
398///
399/// # Examples
400///
401/// ```rust,ignore
402/// let _ = pharos_native::runtime::PHAROS_RUNTIME_STACK_VERSION;
403/// ```
404///
405/// # Related
406///
407/// - [`crate::runtime`] for the surrounding module surface.
408pub const PHAROS_RUNTIME_STACK_VERSION: &str = env!("CARGO_PKG_VERSION");
409/// Version string embedded in the native runtime binary.
410///
411/// # Examples
412///
413/// ```rust,ignore
414/// let _ = pharos_native::runtime::PHAROS_VERSION;
415/// ```
416///
417/// # Related
418///
419/// - [`crate::runtime`] for the surrounding module surface.
420pub const PHAROS_VERSION: &str = env!("CARGO_PKG_VERSION");
421/// Stable target identifier for the native host runtime.
422///
423/// # Examples
424///
425/// ```rust,ignore
426/// let _ = pharos_native::runtime::PHAROS_TARGET_ID;
427/// ```
428///
429/// # Related
430///
431/// - [`crate::runtime`] for the surrounding module surface.
432pub 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)]
437/// TLS policy enforced for PostgreSQL runtime connections.
438///
439/// # Variants
440///
441/// - `RustlsRequired`: Supported `PostgresTlsMode` variant.
442///
443/// # Examples
444///
445/// ```rust,ignore
446/// let _ = pharos_native::runtime::PostgresTlsMode::RustlsRequired;
447/// ```
448///
449/// # Related
450///
451/// - [`crate::runtime`] for constructors, helpers, and neighboring runtime types.
452pub enum PostgresTlsMode {
453    RustlsRequired,
454}
455
456#[derive(Debug, Clone, Copy, Eq, PartialEq)]
457/// Availability state for one runtime capability.
458///
459/// # Variants
460///
461/// - `Available`: Supported `CapabilityState` variant.
462/// - `Pending`: Supported `CapabilityState` variant.
463/// - `UnsupportedOnWasi`: Supported `CapabilityState` variant.
464///
465/// # Examples
466///
467/// ```rust,ignore
468/// let _ = pharos_native::runtime::CapabilityState::Available;
469/// ```
470///
471/// # Related
472///
473/// - [`crate::runtime`] for constructors, helpers, and neighboring runtime types.
474pub enum CapabilityState {
475    Available,
476    Pending,
477    UnsupportedOnWasi,
478}
479
480#[derive(Debug, Clone, Copy, Eq, PartialEq)]
481/// Advertised runtime capability scaffold for the current host build.
482///
483/// # Fields
484///
485/// - `sqlite`: Stored field on `RuntimeScaffold`.
486/// - `postgresql`: Stored field on `RuntimeScaffold`.
487/// - `smtp`: Stored field on `RuntimeScaffold`.
488/// - `tls_mode`: Stored field on `RuntimeScaffold`.
489///
490/// # Examples
491///
492/// ```rust,ignore
493/// let _ = std::mem::size_of::<pharos_native::runtime::RuntimeScaffold>();
494/// ```
495///
496/// # Related
497///
498/// - [`crate::runtime`] for constructors, helpers, and neighboring runtime types.
499pub struct RuntimeScaffold {
500    pub sqlite: CapabilityState,
501    pub postgresql: CapabilityState,
502    pub smtp: CapabilityState,
503    pub tls_mode: PostgresTlsMode,
504}
505
506/// Advertised capability scaffold for the current native build.
507///
508/// # Examples
509///
510/// ```rust,ignore
511/// let _ = pharos_native::runtime::RUNTIME_CAPABILITY_STATUS;
512/// ```
513///
514/// # Related
515///
516/// - [`crate::runtime`] for the surrounding module surface.
517pub 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
536/// Returns the runtime capability scaffold exposed by the native host.
537///
538/// # Returns
539///
540/// Returns the computed value produced by [`crate::runtime::runtime_capability_status`].
541///
542/// # Examples
543///
544/// ```rust,ignore
545/// let _result = pharos_native::runtime::runtime_capability_status();
546/// ```
547///
548/// # Related
549///
550/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
551pub fn runtime_capability_status() -> &'static RuntimeScaffold {
552    &RUNTIME_CAPABILITY_STATUS
553}
554
555/// Returns whether a CLI/runtime verb is supported by the native host.
556///
557/// # Parameters
558///
559/// - `verb`: Input consumed by [`crate::runtime::runtime_verb_supported`].
560///
561/// # Returns
562///
563/// Returns `true` when the condition checked by [`crate::runtime::runtime_verb_supported`] holds and `false` otherwise.
564///
565/// # Examples
566///
567/// ```rust,ignore
568/// let _result = pharos_native::runtime::runtime_verb_supported(todo!());
569/// ```
570///
571/// # Related
572///
573/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
574pub fn runtime_verb_supported(verb: &str) -> bool {
575    matches!(
576        verb,
577        "build" | "status" | "verify" | "run" | "probe" | "smoke" | "doctor"
578    )
579}
580
581/// Maps a runtime verb to its stable report identifier.
582///
583/// # Parameters
584///
585/// - `verb`: Input consumed by [`crate::runtime::runtime_report_id_for_verb`].
586///
587/// # Returns
588///
589/// Returns the computed value produced by [`crate::runtime::runtime_report_id_for_verb`].
590///
591/// # Examples
592///
593/// ```rust,ignore
594/// let _result = pharos_native::runtime::runtime_report_id_for_verb(todo!());
595/// ```
596///
597/// # Related
598///
599/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
600pub 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
610/// Reads manifest JSON.
611fn 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
616/// Reads source manifest value.
617fn 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
627/// Performs JSON string.
628fn 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
635/// Performs JSON runtime string.
636fn 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
645/// Performs JSON artifact string.
646fn 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
655/// Performs path last segment.
656fn path_last_segment(path: &Path) -> Option<String> {
657    pharos_runtime_core::stack::path_last_segment(path)
658}
659
660/// Performs runtime framework webhook endpoint ID.
661fn runtime_framework_webhook_endpoint_id(path: &str) -> Option<String> {
662    dynamic::framework_webhook_endpoint_id(path)
663}
664
665/// Performs runtime integration fixture path.
666fn 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
686/// Performs runtime app declares framework webhook endpoint.
687fn 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
703/// Performs runtime framework webhook response.
704fn 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
785/// Performs infer repo root from app root.
786fn 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
794/// Resolves build dir for runtime loading.
795fn 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
813/// Loads one app runtime from a built artifact tree.
814///
815/// # Parameters
816///
817/// - `artifact_dir`: Input consumed by [`crate::runtime::load_app_runtime_from_artifact`].
818///
819/// # Returns
820///
821/// Returns the successful value produced by [`crate::runtime::load_app_runtime_from_artifact`] or an error when the operation fails.
822///
823/// # Errors
824///
825/// Returns an error value when [`crate::runtime::load_app_runtime_from_artifact`] cannot complete successfully.
826///
827/// # Examples
828///
829/// ```rust,ignore
830/// let _result = pharos_native::runtime::load_app_runtime_from_artifact(std::path::Path::new("build/dev_docs"));
831/// ```
832///
833/// # Related
834///
835/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
836pub 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
924/// Builds an inspection report for one app artifact tree.
925///
926/// # Parameters
927///
928/// - `artifact_dir`: Input consumed by [`crate::runtime::inspect_app_artifact_json`].
929///
930/// # Returns
931///
932/// Returns the successful value produced by [`crate::runtime::inspect_app_artifact_json`] or an error when the operation fails.
933///
934/// # Errors
935///
936/// Returns an error value when [`crate::runtime::inspect_app_artifact_json`] cannot complete successfully.
937///
938/// # Performance
939///
940/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::inspect_app_artifact_json`].
941///
942/// # Examples
943///
944/// ```rust,ignore
945/// let _result = pharos_native::runtime::inspect_app_artifact_json(std::path::Path::new("build/dev_docs"));
946/// ```
947///
948/// # Related
949///
950/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
951pub 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
1031/// Builds a performance-oriented inspection report for one app artifact tree.
1032///
1033/// # Parameters
1034///
1035/// - `artifact_dir`: Input consumed by [`crate::runtime::inspect_app_performance_json`].
1036///
1037/// # Returns
1038///
1039/// Returns the successful value produced by [`crate::runtime::inspect_app_performance_json`] or an error when the operation fails.
1040///
1041/// # Errors
1042///
1043/// Returns an error value when [`crate::runtime::inspect_app_performance_json`] cannot complete successfully.
1044///
1045/// # Performance
1046///
1047/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::inspect_app_performance_json`].
1048///
1049/// # Examples
1050///
1051/// ```rust,ignore
1052/// let _result = pharos_native::runtime::inspect_app_performance_json(std::path::Path::new("build/dev_docs"));
1053/// ```
1054///
1055/// # Related
1056///
1057/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1058pub 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
1160/// Initializes missing file-backed runtime state from an app-owned declarative seed.
1161///
1162/// This runs after configuration finalization so packaged, source, single-app, and
1163/// shared-server loading all use the effective state path. Existing state is never
1164/// overwritten, including when multiple loaders race to initialize the same app.
1165fn 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
1265/// Returns whether a packaged artifact root matches the expected app identity.
1266///
1267/// # Parameters
1268///
1269/// - `app_root`: Input consumed by [`crate::runtime::packaged_artifact_root_matches`].
1270/// - `target_id`: Input consumed by [`crate::runtime::packaged_artifact_root_matches`].
1271///
1272/// # Returns
1273///
1274/// Returns `true` when the condition checked by [`crate::runtime::packaged_artifact_root_matches`] holds and `false` otherwise.
1275///
1276/// # Examples
1277///
1278/// ```rust,ignore
1279/// let _result = pharos_native::runtime::packaged_artifact_root_matches(std::path::Path::new("apps/dev_docs"), "dev_docs");
1280/// ```
1281///
1282/// # Related
1283///
1284/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1285pub 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    // A source app may carry the same artifact metadata in its manifest. The
1295    // app-owned config and launcher are the authoritative source-tree marker;
1296    // never treat that tree as an already-built artifact.
1297    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
1313/// Checks whether required runtime config files are present for an app.
1314///
1315/// # Parameters
1316///
1317/// - `app_root`: Input consumed by [`crate::runtime::app_runtime_conf_required_present`].
1318///
1319/// # Returns
1320///
1321/// Returns `true` when the condition checked by [`crate::runtime::app_runtime_conf_required_present`] holds and `false` otherwise.
1322///
1323/// # Examples
1324///
1325/// ```rust,ignore
1326/// let _result = pharos_native::runtime::app_runtime_conf_required_present(std::path::Path::new("apps/dev_docs"));
1327/// ```
1328///
1329/// # Related
1330///
1331/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1332pub 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
1370/// Keys verification by the logical contract, configured data source, and adapter-owned ledger.
1371fn 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
1476/// Verifies the manifest-selected logical migration contract without exporting app data.
1477fn 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
1523/// Loads one packaged artifact into an app runtime view.
1524///
1525/// # Parameters
1526///
1527/// - `config_record`: Parsed runtime configuration entry for the app being loaded.
1528/// - `artifact_root`: Built artifact directory that owns the packaged app files.
1529///
1530/// # Returns
1531///
1532/// Returns the successful value produced by [`crate::runtime::load_packaged_artifact`] or an error when the operation fails.
1533///
1534/// # Errors
1535///
1536/// Returns an error value when [`crate::runtime::load_packaged_artifact`] cannot complete successfully.
1537///
1538/// # Examples
1539///
1540/// ```rust,ignore
1541/// let _result = pharos_native::runtime::load_packaged_artifact(todo!(), todo!(), todo!(), todo!());
1542/// ```
1543///
1544/// # Related
1545///
1546/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1547pub 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
1558/// Resolves the effective runtime build-directory override.
1559///
1560/// # Parameters
1561///
1562/// - `config`: Input consumed by [`crate::runtime::runtime_build_dir_override`].
1563/// - `app_root`: Input consumed by [`crate::runtime::runtime_build_dir_override`].
1564///
1565/// # Returns
1566///
1567/// Returns [`Some`] when [`crate::runtime::runtime_build_dir_override`] can produce a value and [`None`] otherwise.
1568///
1569/// # Examples
1570///
1571/// ```rust,ignore
1572/// let _result = pharos_native::runtime::runtime_build_dir_override(todo!(), std::path::Path::new("apps/dev_docs"));
1573/// ```
1574///
1575/// # Related
1576///
1577/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1578pub 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
1596/// Prepares one app artifact tree for runtime loading.
1597///
1598/// # Parameters
1599///
1600/// - `app_root`: Input consumed by [`crate::runtime::prepare_app_artifact`].
1601/// - `app_id`: Input consumed by [`crate::runtime::prepare_app_artifact`].
1602/// - `build_dir_override`: Input consumed by [`crate::runtime::prepare_app_artifact`].
1603///
1604/// # Returns
1605///
1606/// Returns the successful value produced by [`crate::runtime::prepare_app_artifact`] or an error when the operation fails.
1607///
1608/// # Errors
1609///
1610/// Returns an error value when [`crate::runtime::prepare_app_artifact`] cannot complete successfully.
1611///
1612/// # Examples
1613///
1614/// ```rust,ignore
1615/// let _result = pharos_native::runtime::prepare_app_artifact(std::path::Path::new("apps/dev_docs"), todo!(), todo!());
1616/// ```
1617///
1618/// # Related
1619///
1620/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1621pub 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
1641/// Reports whether runtime config is missing for one app.
1642///
1643/// # Parameters
1644///
1645/// - `config`: Input consumed by [`crate::runtime::log_runtime_app_config_missing`].
1646/// - `app_root`: Input consumed by [`crate::runtime::log_runtime_app_config_missing`].
1647/// - `app_id_hint`: Input consumed by [`crate::runtime::log_runtime_app_config_missing`].
1648///
1649/// # Returns
1650///
1651/// Returns after [`crate::runtime::log_runtime_app_config_missing`] applies its in-place effect.
1652///
1653/// # Performance
1654///
1655/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::log_runtime_app_config_missing`].
1656///
1657/// # Examples
1658///
1659/// ```rust,ignore
1660/// let _result = pharos_native::runtime::log_runtime_app_config_missing(todo!(), std::path::Path::new("apps/dev_docs"), todo!());
1661/// ```
1662///
1663/// # Related
1664///
1665/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1666pub 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
1700/// Loads runtime state for one directly targeted app.
1701///
1702/// # Parameters
1703///
1704/// - `config_record`: Input consumed by [`crate::runtime::load_app_runtime_for_single_app_direct`].
1705/// - `repo_root`: Input consumed by [`crate::runtime::load_app_runtime_for_single_app_direct`].
1706/// - `binary_dir`: Input consumed by [`crate::runtime::load_app_runtime_for_single_app_direct`].
1707///
1708/// # Returns
1709///
1710/// Returns the successful value produced by [`crate::runtime::load_app_runtime_for_single_app_direct`] or an error when the operation fails.
1711///
1712/// # Errors
1713///
1714/// Returns an error value when [`crate::runtime::load_app_runtime_for_single_app_direct`] cannot complete successfully.
1715///
1716/// # Examples
1717///
1718/// ```rust,ignore
1719/// let _result = pharos_native::runtime::load_app_runtime_for_single_app_direct(todo!(), std::path::Path::new("."), std::path::Path::new("bin"));
1720/// ```
1721///
1722/// # Related
1723///
1724/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1725pub 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
1738/// Resolves the directory that contains runtime apps.
1739///
1740/// # Parameters
1741///
1742/// - `conf_path`: Input consumed by [`crate::runtime::runtime_apps_dir`].
1743///
1744/// # Returns
1745///
1746/// Returns [`Some`] when [`crate::runtime::runtime_apps_dir`] can produce a value and [`None`] otherwise.
1747///
1748/// # Examples
1749///
1750/// ```rust,ignore
1751/// let _result = pharos_native::runtime::runtime_apps_dir(std::path::Path::new("app.conf"));
1752/// ```
1753///
1754/// # Related
1755///
1756/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1757pub 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, &current_dir)
1760}
1761
1762/// Performs runtime loaded app context.
1763fn 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
1773/// Builds the startup banner lines for the native runtime.
1774///
1775/// # Parameters
1776///
1777/// - `stack`: Input consumed by [`crate::runtime::runtime_log_banner_lines`].
1778/// - `config_record`: Input consumed by [`crate::runtime::runtime_log_banner_lines`].
1779/// - `load_ms`: Input consumed by [`crate::runtime::runtime_log_banner_lines`].
1780/// - `timestamp`: Input consumed by [`crate::runtime::runtime_log_banner_lines`].
1781///
1782/// # Returns
1783///
1784/// Returns the computed value produced by [`crate::runtime::runtime_log_banner_lines`].
1785///
1786/// # Performance
1787///
1788/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::runtime_log_banner_lines`].
1789///
1790/// # Examples
1791///
1792/// ```rust,ignore
1793/// let _result = pharos_native::runtime::runtime_log_banner_lines(todo!(), todo!(), todo!(), todo!());
1794/// ```
1795///
1796/// # Related
1797///
1798/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
1799pub 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
1882/// Performs runtime app summary value.
1883fn 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
1901/// Performs runtime failed app summary value.
1902fn 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
1912/// Returns a lowercase SHA-256 digest without an external encoder.
1913fn sha256_hex(bytes: &[u8]) -> String {
1914    pharos_runtime_core::support::sha256_hex(bytes)
1915}
1916
1917/// Computes a secret-free fingerprint of effective runtime behavior.
1918fn 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
1942/// Joins binary, source, dependency, configuration, and app-contract identity.
1943fn 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
1955/// Performs runtime default environment.
1956fn 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
1969/// Warms the bounded allowlisted configuration cache for one mounted app request.
1970fn 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
1981/// Performs runtime default backend.
1982fn 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
1995/// Builds a backend summary for one loaded app runtime.
1996pub(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
2011/// Performs environment valid.
2012fn environment_valid(environment: &str) -> bool {
2013    matches!(
2014        environment,
2015        "development" | "staging" | "production" | "test"
2016    )
2017}
2018
2019/// Performs output path check.
2020fn 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
2042/// Performs database check JSON.
2043fn 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
2085/// Builds app config report local JSON.
2086fn 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
2135/// Builds app doctor report JSON.
2136fn 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
2193/// Writes report file.
2194fn 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
2201/// Performs website report path.
2202fn 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
2208/// Performs path has runtime component.
2209fn path_has_runtime_component(path: &Path) -> bool {
2210    path.components()
2211        .any(|component| component.as_os_str() == "runtime")
2212}
2213
2214/// Performs runtime fingerprint target.
2215fn 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
2222/// Collects runtime fingerprint files.
2223fn 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
2246/// Builds a stable fingerprint for the currently loaded runtime apps.
2247///
2248/// # Parameters
2249///
2250/// - `apps_dir`: Input consumed by [`crate::runtime::runtime_apps_fingerprint`].
2251///
2252/// # Returns
2253///
2254/// Returns the successful value produced by [`crate::runtime::runtime_apps_fingerprint`] or an error when the operation fails.
2255///
2256/// # Errors
2257///
2258/// Returns an error value when [`crate::runtime::runtime_apps_fingerprint`] cannot complete successfully.
2259///
2260/// # Performance
2261///
2262/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::runtime_apps_fingerprint`].
2263///
2264/// # Examples
2265///
2266/// ```rust,ignore
2267/// let _result = pharos_native::runtime::runtime_apps_fingerprint(todo!());
2268/// ```
2269///
2270/// # Related
2271///
2272/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2273pub 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
2296/// Returns whether the runtime stack should be reloaded.
2297///
2298/// # Parameters
2299///
2300/// - `apps_dir`: Input consumed by [`crate::runtime::runtime_stack_refresh_required`].
2301/// - `previous_fingerprint`: Input consumed by [`crate::runtime::runtime_stack_refresh_required`].
2302///
2303/// # Returns
2304///
2305/// Returns the successful value produced by [`crate::runtime::runtime_stack_refresh_required`] or an error when the operation fails.
2306///
2307/// # Errors
2308///
2309/// Returns an error value when [`crate::runtime::runtime_stack_refresh_required`] cannot complete successfully.
2310///
2311/// # Examples
2312///
2313/// ```rust,ignore
2314/// let _result = pharos_native::runtime::runtime_stack_refresh_required(todo!(), todo!());
2315/// ```
2316///
2317/// # Related
2318///
2319/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2320pub 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
2330/// Normalizes dynamic route parameters for deterministic collision comparison.
2331/// Joins an application mount and route path into one owned HTTP namespace.
2332fn mounted_route_pattern(mount: &str, route: &str) -> String {
2333    pharos_runtime_core::route::mounted_route_pattern(mount, route)
2334}
2335
2336/// Rejects duplicate ownership claims before the runtime binds a listener.
2337fn 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
2495/// Loads the complete runtime stack from the current runtime root.
2496///
2497/// # Parameters
2498///
2499/// - `config_record`: Input consumed by [`crate::runtime::load_runtime_stack_direct`].
2500///
2501/// # Returns
2502///
2503/// Returns the successful value produced by [`crate::runtime::load_runtime_stack_direct`] or an error when the operation fails.
2504///
2505/// # Errors
2506///
2507/// Returns an error value when [`crate::runtime::load_runtime_stack_direct`] cannot complete successfully.
2508///
2509/// # Performance
2510///
2511/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::load_runtime_stack_direct`].
2512///
2513/// # Examples
2514///
2515/// ```rust,ignore
2516/// let _result = pharos_native::runtime::load_runtime_stack_direct(todo!());
2517/// ```
2518///
2519/// # Related
2520///
2521/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2522pub 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, &current_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
2539/// Performs app runtime report JSON.
2540fn 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
2623/// Builds the JSON report returned by runtime build flows.
2624///
2625/// # Parameters
2626///
2627/// - `target_id`: Input consumed by [`crate::runtime::build_runtime_build_report_json`].
2628/// - `conf_path`: Input consumed by [`crate::runtime::build_runtime_build_report_json`].
2629/// - `apps_dir`: Input consumed by [`crate::runtime::build_runtime_build_report_json`].
2630/// - `app_count`: Input consumed by [`crate::runtime::build_runtime_build_report_json`].
2631///
2632/// # Returns
2633///
2634/// Returns the successful value produced by [`crate::runtime::build_runtime_build_report_json`] or an error when the operation fails.
2635///
2636/// # Errors
2637///
2638/// Returns an error value when [`crate::runtime::build_runtime_build_report_json`] cannot complete successfully.
2639///
2640/// # Examples
2641///
2642/// ```rust,ignore
2643/// let _result = pharos_native::runtime::build_runtime_build_report_json("dev_docs", std::path::Path::new("app.conf"), todo!(), todo!());
2644/// ```
2645///
2646/// # Related
2647///
2648/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2649pub 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
2667/// Finds the mounted app that matches a request path.
2668///
2669/// # Parameters
2670///
2671/// - `stack`: Input consumed by [`crate::runtime::runtime_match_app`].
2672/// - `path`: Input consumed by [`crate::runtime::runtime_match_app`].
2673///
2674/// # Returns
2675///
2676/// Returns [`Some`] when [`crate::runtime::runtime_match_app`] can produce a value and [`None`] otherwise.
2677///
2678/// # Performance
2679///
2680/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::runtime_match_app`].
2681///
2682/// # Examples
2683///
2684/// ```rust,ignore
2685/// let _result = pharos_native::runtime::runtime_match_app(todo!(), std::path::Path::new("fixture.json"));
2686/// ```
2687///
2688/// # Related
2689///
2690/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2691struct 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
2715/// Performs runtime match failed app.
2716fn 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
2725/// Performs runtime failed app response.
2726fn 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
2751/// Runs a readiness probe against one loaded app runtime.
2752///
2753/// # Parameters
2754///
2755/// - `app`: Input consumed by [`crate::runtime::app_runtime_probe_ready`].
2756///
2757/// # Returns
2758///
2759/// Returns `true` when the condition checked by [`crate::runtime::app_runtime_probe_ready`] holds and `false` otherwise.
2760///
2761/// # Examples
2762///
2763/// ```rust,ignore
2764/// let _result = pharos_native::runtime::app_runtime_probe_ready(todo!());
2765/// ```
2766///
2767/// # Related
2768///
2769/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2770pub 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
2777/// Runs a readiness probe across the loaded runtime stack.
2778///
2779/// # Parameters
2780///
2781/// - `stack`: Input consumed by [`crate::runtime::runtime_stack_probe_ready`].
2782///
2783/// # Returns
2784///
2785/// Returns `true` when the condition checked by [`crate::runtime::runtime_stack_probe_ready`] holds and `false` otherwise.
2786///
2787/// # Performance
2788///
2789/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::runtime_stack_probe_ready`].
2790///
2791/// # Examples
2792///
2793/// ```rust,ignore
2794/// let _result = pharos_native::runtime::runtime_stack_probe_ready(todo!());
2795/// ```
2796///
2797/// # Related
2798///
2799/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2800pub 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
2808/// Builds the response for a framework-owned route.
2809///
2810/// # Parameters
2811///
2812/// - `method`: Input consumed by [`crate::runtime::framework_builtin_response`].
2813/// - `target`: Input consumed by [`crate::runtime::framework_builtin_response`].
2814/// - `app`: Input consumed by [`crate::runtime::framework_builtin_response`].
2815/// - `stack`: Input consumed by [`crate::runtime::framework_builtin_response`].
2816///
2817/// # Returns
2818///
2819/// Returns [`Some`] when [`crate::runtime::framework_builtin_response`] can produce a value and [`None`] otherwise.
2820///
2821/// # Performance
2822///
2823/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::framework_builtin_response`].
2824///
2825/// # Examples
2826///
2827/// ```rust,ignore
2828/// let _result = pharos_native::runtime::framework_builtin_response(todo!(), "/", todo!(), todo!());
2829/// ```
2830///
2831/// # Related
2832///
2833/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
2834pub 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
2852/// Performs HTTP status line.
2853fn 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
2883/// Performs HTTP text response.
2884fn 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
2899/// Extracts bounded public validation context without copying arbitrary failure payload fields.
2900fn 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
2958/// Returns a bounded validation report for one route-level request admission failure.
2959fn 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
2988/// Enforces narrow route-level request admission hooks before action execution.
2989fn 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
3036/// Performs route request middleware failure.
3037fn 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
3119/// Performs HTTP response insert header.
3120fn 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
3136/// Performs apply route response middleware.
3137fn 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
3222/// Returns the canonical response for a JSON-only action reached with the wrong content type.
3223fn 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
3244/// Builds a readable summary from already filtered public validation details.
3245fn 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
3280/// Normalizes every JSON failure into the stable public error envelope.
3281fn 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
3336/// Performs HTTP JSON response.
3337fn 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
3356/// Performs HTTP HTML response.
3357fn 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
3417/// Performs HTTP HTML response with CSRF.
3418fn 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
3434/// Performs HTTP route redirect.
3435fn 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
3444/// Performs an HTMX-compatible redirect response.
3445fn 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
3454/// Performs a redirect response that preserves enhanced-form navigation.
3455fn 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
3463/// Performs app fragment response.
3464fn 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, &params));
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
3509/// Performs HTTP route redirect with session.
3510fn 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
3545/// Returns the scoped login path for one mounted application.
3546fn 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
3554/// Returns one application-scoped internal path below the mounted base path.
3555fn 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
3567/// Returns one redirect-safe internal next path or the provided fallback.
3568fn 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
3699/// Builds one login redirect that preserves an internal post-authentication path.
3700fn 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
3705/// Performs HTTP route redirect clear session.
3706fn 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
3720/// Maps the shared database failure set to stable HTTP errors without driver details.
3721fn 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
3738/// Performs native session token hash.
3739fn 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
3746/// Performs app mount prefix.
3747fn 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
3763/// Isolates session and role caches by the configured data authority as well as
3764/// app identity. Changing a namespace cannot reuse another database's admission.
3765fn 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
3796/// Performs active session user ID.
3797fn 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
3892/// Performs login error response with status.
3893fn 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
3960/// Performs login error response.
3961fn 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
3965/// Performs HTTP static response.
3966fn 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
3985/// Reads HTTP request.
3986fn 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                // Browsers open speculative connections before navigation.
4002                // No request bytes means there is no request to reject.
4003                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
4125/// Performs current timestamp utc.
4126fn current_timestamp_utc() -> String {
4127    pharos_runtime_core::time::current_timestamp_utc()
4128}
4129
4130/// Performs timestamp after seconds utc.
4131fn timestamp_after_seconds_utc(additional_seconds: u64) -> String {
4132    pharos_runtime_core::time::timestamp_after_seconds_utc(additional_seconds)
4133}
4134
4135/// Formats epoch seconds through the shared runtime timestamp helper.
4136fn chrono_like_timestamp(seconds: u64) -> String {
4137    pharos_runtime_core::time::timestamp_from_epoch_seconds(seconds)
4138}
4139
4140/// Parses one bounded runtime timestamp from epoch seconds or UTC text.
4141fn 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
4152/// Performs runtime timestamp seconds utc text.
4153fn 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
4181/// Performs runtime timestamp parse date.
4182fn 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
4193/// Performs runtime timestamp parse time.
4194fn 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
4210/// Performs runtime timestamp offset seconds.
4211fn 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
4231/// Performs runtime days from civil.
4232fn 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
4254/// Performs runtime is leap year.
4255fn runtime_is_leap_year(year: i64) -> bool {
4256    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
4257}
4258
4259/// Emits runtime log banner.
4260fn 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, &timestamp) {
4263        println!("{line}");
4264    }
4265}
4266
4267enum RuntimeListener {
4268    Tcp(TcpListener),
4269    #[cfg(unix)]
4270    Unix(UnixListener, PathBuf),
4271}
4272
4273impl RuntimeListener {
4274    /// Performs accept.
4275    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    /// Performs local endpoint.
4288    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    /// Performs drop.
4302    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    /// Sets timeouts.
4318    fn set_timeouts(&self) {
4319        // Allow multipart uploads and local smoke clients enough time to
4320        // finish a bounded request without turning a slow body read into a
4321        // misleading HTTP 400 response.
4322        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    /// Performs shutdown.
4339    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    /// Reads request.
4352    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    /// Writes all.
4361    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    /// Writes a validated file slice without buffering the representation in memory.
4376    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
4385/// Copies one selected file range to an accepted connection with a fixed-size buffer.
4386fn 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
4440/// Encodes a streaming selection error including the required unsatisfied range header.
4441fn 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
4452/// Opens runtime listener.
4453fn 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
4510/// Returns whether business availability create path.
4511fn 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
4517/// Resolves the most specific declarative route for a request method and path.
4518fn 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
4522/// Rejects browser mutations whose Origin does not match the request Host.
4523fn 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
4548/// Enforces actor ownership for app-declared payment-provider resources and
4549/// legacy business/appointment resource paths.
4550fn 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
4668/// Resolves actor roles and verifies the route capability against the compiled policy contract.
4669fn 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
4770/// Converts validated booking form time fields to canonical 24-hour `HH:MM` storage.
4771fn 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
4791/// Applies the single pre-dispatch authorization boundary for every mutation.
4792fn 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
4960/// Performs app static fragment.
4961fn 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
4969/// Performs app select option.
4970fn 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
4987/// Renders one app-owned UI fragment with an explicit parameter document.
4988fn 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        &params,
4993    )
4994}
4995
4996/// Renders one canonical input control owned by the selected app bundle.
4997fn 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
5025/// Renders one canonical button control owned by the selected app bundle.
5026fn 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
5047/// Renders one canonical select control owned by the selected app bundle.
5048fn 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
5074/// Renders one canonical hidden input owned by the selected app bundle.
5075fn 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
5094/// Renders one canonical textarea control owned by the selected app bundle.
5095fn 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
5121/// Populates canonical controls for static app pages without embedding authored markup in Rust.
5122fn 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
6076/// Performs app route page HTML.
6077fn 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
6100/// Retains scoped database failures as HTTP outcomes and reads each detail once.
6101fn 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
6185/// Renders an authorized bounded detail model without repeating its database read.
6186fn 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(&params_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>(&params_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(&params_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(&params_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(&params_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(&params_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(&params_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(&params_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(&params_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, &params).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
8095/// Performs app relative target.
8096fn app_relative_target(app: &AppRuntime, target: &str) -> String {
8097    pharos_runtime_core::relative_target(app.base_path.as_deref(), target)
8098}
8099
8100/// Builds app-neutral request data for render and route adapters.
8101fn 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
8118/// Performs app declarative route response.
8119fn 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        // The task-status poll prepares stale previews after the dashboard renders.
8210        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                &params,
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>(&params_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: &notes,
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                &current_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                &current_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
11058/// Builds the response for an app-owned route request.
11059///
11060/// # Parameters
11061///
11062/// - `app`: Resolved application runtime surface used to render the route.
11063/// - `method`: Input consumed by [`crate::runtime::app_route_response`].
11064/// - `target`: Input consumed by [`crate::runtime::app_route_response`].
11065///
11066/// # Returns
11067///
11068/// Returns the computed value produced by [`crate::runtime::app_route_response`].
11069///
11070/// # Examples
11071///
11072/// ```rust,ignore
11073/// let _result = pharos_native::runtime::app_route_response(todo!(), todo!(), "/");
11074/// ```
11075///
11076/// # Related
11077///
11078/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
11079pub(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
11083/// Performs app route response with cookie.
11084fn 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
11099/// Performs app route response with request.
11100fn 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        &current_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(&params).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", &params_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        &params_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
11472/// Performs route requires state mutation guard.
11473fn 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    // OAuth providers redirect to the callback with GET, but a verified
11499    // callback consumes a nonce and changes file-backed provider state. Keep
11500    // that mutation protected even though the redirect uses GET. Database apps
11501    // use their adapter transaction and never reach this file-lock path.
11502    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
11519/// Gives authenticated source-archive responses their existing bounded source-export budget.
11520fn 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
11553/// Performs app route response with cookie core.
11554fn 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
11667/// Runs checkout against either configured database authority or a declared file app.
11668fn 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
11755/// Finds the provider bound to a declarative provider-checkout action for an app.
11756fn 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
11778/// Opens the configured logical app authority while preserving error categories.
11779fn 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
11785/// Resolves a declared app's operation store without choosing a vendor.
11786fn app_database_operations(app: &AppRuntime) -> Option<database::LogicalAppStore> {
11787    database::runtime_logical_app_store(app).ok()
11788}
11789
11790/// Binds the declared durable effects to a request-scoped logical store.
11791/// The adapter commits them with the domain mutation, preserving result indices.
11792fn 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
11826/// Loads a scoped catalog for the existing declared business page templates.
11827fn 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
11845/// Adds redacted provider settings after scoped management authorization while
11846/// retaining the catalog's descriptive owner fields for existing templates.
11847fn 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
11877/// Applies a business identity form with route-owned actor and business scope.
11878fn 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
11977/// Parses a catalog form and applies its existing rules through the generic
11978/// operation store. URL ownership takes precedence over submitted identifiers.
11979fn 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        // Re-read only the authorized catalog after committing the mutation.
12118        // HTMX replaces the technician list, so a redirect loses its fragment.
12119        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
12164/// Maps shared operation errors without revealing database or credential details.
12165fn 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
12179/// Builds the feature request context for one UCAL generated business API route.
12180fn 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
12201/// Converts a UCAL feature query result into the generated API list response shape.
12202fn 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
12291/// Checks a generated UCAL business API route through relational feature queries.
12292fn 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
12316/// Resolves one UCAL generated API mutation path to its declared feature action.
12317fn 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
12382/// Executes one UCAL generated API mutation through declared feature actions.
12383fn 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
12492/// Dispatches one app request after the authorization and state transaction boundary.
12493fn 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                &params,
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                &current_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
13324/// Performs runtime route response with request.
13325fn 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
13347/// Reads, dispatches, and closes one accepted connection inside a worker thread.
13348fn 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
13467/// Sends the explicit bounded-queue overload response and closes the connection.
13468fn 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
13478/// Commits a scoped database worker view or a declared file-backed app mutation.
13479fn 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
13527/// Persists a file-backed app's bounded worker state without a database write.
13528fn 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
13545/// Claims and completes one durable queued job under the state transaction lock.
13546fn 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
13903/// Starts one background poller that dispatches durable jobs for every enabled app.
13904fn 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
13930/// Performs serve runtime stack with limit.
13931fn 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
14023/// Performs serve app runtime with limit.
14024fn 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
14104/// Starts the shared multi-app runtime server.
14105///
14106/// # Parameters
14107///
14108/// - `config_record`: Input consumed by [`crate::runtime::serve_runtime_stack`].
14109///
14110/// # Returns
14111///
14112/// Returns the successful value produced by [`crate::runtime::serve_runtime_stack`] or an error when the operation fails.
14113///
14114/// # Errors
14115///
14116/// Returns an error value when [`crate::runtime::serve_runtime_stack`] cannot complete successfully.
14117///
14118/// # Examples
14119///
14120/// ```rust,ignore
14121/// let _result = pharos_native::runtime::serve_runtime_stack(todo!());
14122/// ```
14123///
14124/// # Related
14125///
14126/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14127pub 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
14137/// Starts the shared runtime in a direct test-oriented path.
14138///
14139/// # Parameters
14140///
14141/// - `config_record`: Shared-runtime serve configuration under test.
14142/// - `max_requests`: Maximum number of requests to serve before exiting.
14143///
14144/// # Returns
14145///
14146/// Returns the successful value produced by [`crate::runtime::serve_runtime_stack_direct_for_test`] or an error when the operation fails.
14147///
14148/// # Errors
14149///
14150/// Returns an error value when [`crate::runtime::serve_runtime_stack_direct_for_test`] cannot complete successfully.
14151///
14152/// # Examples
14153///
14154/// ```rust,ignore
14155/// let _result = pharos_native::runtime::serve_runtime_stack_direct_for_test(todo!(), todo!(), todo!());
14156/// ```
14157///
14158/// # Related
14159///
14160/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14161pub 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
14168/// Starts the single-app runtime server.
14169///
14170/// # Parameters
14171///
14172/// - `config_record`: App-mode serve configuration to execute.
14173/// - `repo_root`: Optional repository root used to resolve app-owned sources.
14174/// - `binary_dir`: Optional directory used to resolve helper binaries.
14175///
14176/// # Returns
14177///
14178/// Returns the successful value produced by [`crate::runtime::serve_single_app`] or an error when the operation fails.
14179///
14180/// # Errors
14181///
14182/// Returns an error value when [`crate::runtime::serve_single_app`] cannot complete successfully.
14183///
14184/// # Examples
14185///
14186/// ```rust,ignore
14187/// let _result = pharos_native::runtime::serve_single_app(todo!(), std::path::Path::new("."), std::path::Path::new("bin"), todo!());
14188/// ```
14189///
14190/// # Related
14191///
14192/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14193pub 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
14204/// Starts a direct single-app runtime path for tests.
14205///
14206/// # Parameters
14207///
14208/// - `config_record`: App-mode serve configuration under test.
14209/// - `repo_root`: Optional repository root used to resolve app-owned sources.
14210/// - `binary_dir`: Optional directory used to resolve helper binaries.
14211/// - `max_requests`: Maximum number of requests to serve before exiting.
14212///
14213/// # Returns
14214///
14215/// Returns the successful value produced by [`crate::runtime::serve_app_runtime_direct_for_test`] or an error when the operation fails.
14216///
14217/// # Errors
14218///
14219/// Returns an error value when [`crate::runtime::serve_app_runtime_direct_for_test`] cannot complete successfully.
14220///
14221/// # Examples
14222///
14223/// ```rust,ignore
14224/// let _result = pharos_native::runtime::serve_app_runtime_direct_for_test(todo!(), std::path::Path::new("."), std::path::Path::new("bin"), todo!(), todo!());
14225/// ```
14226///
14227/// # Related
14228///
14229/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14230pub 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
14239/// Builds the JSON status report for the loaded runtime stack.
14240///
14241/// # Parameters
14242///
14243/// - `stack`: Input consumed by [`crate::runtime::build_runtime_status_report_json`].
14244/// - `apps_dir`: Input consumed by [`crate::runtime::build_runtime_status_report_json`].
14245///
14246/// # Returns
14247///
14248/// Returns the successful value produced by [`crate::runtime::build_runtime_status_report_json`] or an error when the operation fails.
14249///
14250/// # Errors
14251///
14252/// Returns an error value when [`crate::runtime::build_runtime_status_report_json`] cannot complete successfully.
14253///
14254/// # Performance
14255///
14256/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::build_runtime_status_report_json`].
14257///
14258/// # Examples
14259///
14260/// ```rust,ignore
14261/// let _result = pharos_native::runtime::build_runtime_status_report_json(todo!(), todo!());
14262/// ```
14263///
14264/// # Related
14265///
14266/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14267pub 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
14294/// Handles one app-targeted runtime command directly.
14295///
14296/// # Parameters
14297///
14298/// - `verb`: Input consumed by [`crate::runtime::handle_runtime_app_command_direct`].
14299/// - `config_record`: Input consumed by [`crate::runtime::handle_runtime_app_command_direct`].
14300/// - `repo_root`: Input consumed by [`crate::runtime::handle_runtime_app_command_direct`].
14301/// - `binary_dir`: Input consumed by [`crate::runtime::handle_runtime_app_command_direct`].
14302///
14303/// # Returns
14304///
14305/// Returns the successful value produced by [`crate::runtime::handle_runtime_app_command_direct`] or an error when the operation fails.
14306///
14307/// # Errors
14308///
14309/// Returns an error value when [`crate::runtime::handle_runtime_app_command_direct`] cannot complete successfully.
14310///
14311/// # Performance
14312///
14313/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::handle_runtime_app_command_direct`].
14314///
14315/// # Examples
14316///
14317/// ```rust,ignore
14318/// let _result = pharos_native::runtime::handle_runtime_app_command_direct(todo!(), todo!(), std::path::Path::new("."), std::path::Path::new("bin"));
14319/// ```
14320///
14321/// # Related
14322///
14323/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14324pub 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            // Already-built app directories are valid build inputs for the
14362            // report-only path and must not be copied over themselves.
14363        } 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
14425/// Clears a cloned app runtime in place.
14426///
14427/// # Parameters
14428///
14429/// - `app`: Input consumed by [`crate::runtime::clear_cloned_app`].
14430///
14431/// # Returns
14432///
14433/// Returns after [`crate::runtime::clear_cloned_app`] applies its in-place effect.
14434///
14435/// # Examples
14436///
14437/// ```rust,ignore
14438/// let _result = pharos_native::runtime::clear_cloned_app(todo!());
14439/// ```
14440///
14441/// # Related
14442///
14443/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14444pub fn clear_cloned_app(app: &mut AppRuntime) {
14445    *app = AppRuntime::default();
14446}
14447
14448/// Handles one dynamic runtime request end to end.
14449///
14450/// # Parameters
14451///
14452/// - `method`: Input consumed by [`crate::runtime::dynamic_runtime_handle_request`].
14453/// - `target`: Input consumed by [`crate::runtime::dynamic_runtime_handle_request`].
14454/// - `stack`: Input consumed by [`crate::runtime::dynamic_runtime_handle_request`].
14455///
14456/// # Returns
14457///
14458/// Returns [`Some`] when [`crate::runtime::dynamic_runtime_handle_request`] can produce a value and [`None`] otherwise.
14459///
14460/// # Examples
14461///
14462/// ```rust,ignore
14463/// let _result = pharos_native::runtime::dynamic_runtime_handle_request(todo!(), "/", todo!());
14464/// ```
14465///
14466/// # Related
14467///
14468/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14469pub 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
14486/// Clones an optional string value into owned storage.
14487///
14488/// # Parameters
14489///
14490/// - `value`: Input consumed by [`crate::runtime::clone_optional_string`].
14491///
14492/// # Returns
14493///
14494/// Returns [`Some`] when [`crate::runtime::clone_optional_string`] can produce a value and [`None`] otherwise.
14495///
14496/// # Examples
14497///
14498/// ```rust,ignore
14499/// let _result = pharos_native::runtime::clone_optional_string("value");
14500/// ```
14501///
14502/// # Related
14503///
14504/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14505pub fn clone_optional_string(value: Option<&str>) -> Option<String> {
14506    value.map(ToOwned::to_owned)
14507}
14508
14509/// Decodes a QR image into its textual payload.
14510///
14511/// # Parameters
14512///
14513/// - `image_path`: Input consumed by [`crate::runtime::decode_qr_image`].
14514///
14515/// # Returns
14516///
14517/// Returns the successful value produced by [`crate::runtime::decode_qr_image`] or an error when the operation fails.
14518///
14519/// # Errors
14520///
14521/// Returns an error value when [`crate::runtime::decode_qr_image`] cannot complete successfully.
14522///
14523/// # Examples
14524///
14525/// ```rust,ignore
14526/// let _result = pharos_native::runtime::decode_qr_image(todo!());
14527/// ```
14528///
14529/// # Related
14530///
14531/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14532#[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/// Runs the native barcode-reader entrypoint.
14558///
14559/// # Parameters
14560///
14561/// - `image_path`: Input consumed by [`crate::runtime::barcode_reader_main`].
14562///
14563/// # Returns
14564///
14565/// Returns the successful value produced by [`crate::runtime::barcode_reader_main`] or an error when the operation fails.
14566///
14567/// # Errors
14568///
14569/// Returns an error value when [`crate::runtime::barcode_reader_main`] cannot complete successfully.
14570///
14571/// # Performance
14572///
14573/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::barcode_reader_main`].
14574///
14575/// # Examples
14576///
14577/// ```rust,ignore
14578/// let _result = pharos_native::runtime::barcode_reader_main(todo!());
14579/// ```
14580///
14581/// # Related
14582///
14583/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14584#[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
14601/// Handles one top-level runtime command directly.
14602///
14603/// # Parameters
14604///
14605/// - `verb`: Input consumed by [`crate::runtime::handle_runtime_command_direct`].
14606/// - `config_record`: Input consumed by [`crate::runtime::handle_runtime_command_direct`].
14607/// - `repo_root`: Input consumed by [`crate::runtime::handle_runtime_command_direct`].
14608/// - `binary_dir`: Input consumed by [`crate::runtime::handle_runtime_command_direct`].
14609///
14610/// # Returns
14611///
14612/// Returns the successful value produced by [`crate::runtime::handle_runtime_command_direct`] or an error when the operation fails.
14613///
14614/// # Errors
14615///
14616/// Returns an error value when [`crate::runtime::handle_runtime_command_direct`] cannot complete successfully.
14617///
14618/// # Performance
14619///
14620/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::handle_runtime_command_direct`].
14621///
14622/// # Examples
14623///
14624/// ```rust,ignore
14625/// let _result = pharos_native::runtime::handle_runtime_command_direct(todo!(), todo!(), std::path::Path::new("."), std::path::Path::new("bin"));
14626/// ```
14627///
14628/// # Related
14629///
14630/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14631pub 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
14680/// Returns the effective host profile for an app runtime.
14681///
14682/// # Parameters
14683///
14684/// - `app_root`: Input consumed by [`crate::runtime::app_runtime_host_profile`].
14685///
14686/// # Returns
14687///
14688/// Returns [`Some`] when [`crate::runtime::app_runtime_host_profile`] can produce a value and [`None`] otherwise.
14689///
14690/// # Examples
14691///
14692/// ```rust,ignore
14693/// let _result = pharos_native::runtime::app_runtime_host_profile(std::path::Path::new("apps/dev_docs"));
14694/// ```
14695///
14696/// # Related
14697///
14698/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14699pub 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
14710/// Builds an app from its source root into a runtime artifact.
14711///
14712/// # Parameters
14713///
14714/// - `app_root`: Input consumed by [`crate::runtime::build_app_from_root`].
14715/// - `build_dir`: Input consumed by [`crate::runtime::build_app_from_root`].
14716///
14717/// # Returns
14718///
14719/// Returns the successful value produced by [`crate::runtime::build_app_from_root`] or an error when the operation fails.
14720///
14721/// # Errors
14722///
14723/// Returns an error value when [`crate::runtime::build_app_from_root`] cannot complete successfully.
14724///
14725/// # Performance
14726///
14727/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::runtime::build_app_from_root`].
14728///
14729/// # Examples
14730///
14731/// ```rust,ignore
14732/// let _result = pharos_native::runtime::build_app_from_root(std::path::Path::new("apps/dev_docs"), std::path::Path::new("build/dev_docs"));
14733/// ```
14734///
14735/// # Related
14736///
14737/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
14738fn 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    // Generated surfaces and their derived routes are build artifacts, not
14860    // source files. Generate them only after YAML contracts have been
14861    // materialized so stale JSON cannot win precedence.
14862    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
14900/// Performs finalize artifact manifest.
14901fn 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
14984/// Performs ignore non contract artifact entry.
14985fn ignore_non_contract_artifact_entry(name: &str) -> bool {
14986    name == ".DS_Store"
14987}
14988
14989/// Finds the repository-level Bootstrap Icons source shared by app builds.
14990fn 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
14997/// Collects text files below an app source tree for icon reference discovery.
14998fn 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
15022/// Extracts icon names from Bootstrap class names and static SVG references.
15023fn 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
15045/// Scans reference markers once, preserving class-prefix and SVG-suffix matching.
15046fn 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    /// Preserves the former substring semantics without scanning source once per icon.
15076    #[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
15100/// Copies one central Bootstrap Icons helper file into the app artifact.
15101fn 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
15119/// Copies only the Bootstrap Icons used by an app into its built artifact.
15120fn 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
15173/// Detects the Bootstrap Icons font helper files referenced by an app.
15174fn 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
15198/// Collects all regular files below a root in deterministic relative-path order.
15199fn 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
15222/// Hashes a deterministic file tree while optionally excluding one relative file.
15223fn 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    // Small model manifests must survive archive reuse with a different mtime.
15263    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    // Unix ctime is not transport-stable: copying an otherwise unchanged artifact
15446    // preserves size and mtime but necessarily gives the destination a new ctime.
15447    // Treat ctime as diagnostic ownership metadata only so copied deployment-shaped
15448    // app artifacts can still use the cached digest fast path.
15449    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
15489/// Writes generator ownership and output digests for every app artifact file.
15490fn 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            // Artifact paths use portable separators; the verifier deliberately
15520            // rejects backslashes rather than accepting platform-specific paths.
15521            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
15576/// Verifies one generated ownership output entry against the artifact and optional source copy.
15577fn 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
15633/// Verifies every output digest declared by the generated ownership manifest.
15634fn 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
15676/// Performs docs manifest string.
15677fn 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
15686/// Looks up a nested value under the docs manifest object.
15687fn 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
15695/// Reads a nested string from the docs manifest object.
15696fn 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
15702/// Reads a nested boolean from the docs manifest object.
15703fn docs_manifest_bool_path(manifest: &Value, path: &[&str]) -> Option<bool> {
15704    docs_manifest_value_path(manifest, path).and_then(Value::as_bool)
15705}
15706
15707/// Validates one docs-owned relative artifact path.
15708fn 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
15716/// Resolves an optional docs-owned relative artifact path from the manifest.
15717fn 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
15729/// Resolves a required docs-owned relative artifact path from the manifest.
15730fn 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
15742/// Performs docs manifest string array.
15743fn 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
15760/// Performs docs manifest output remap.
15761fn 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
15778/// Performs docs manifest publish mode.
15779fn 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
15791/// Performs docs output relative.
15792fn 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
15813/// Collects published docs outputs declared by navigation.
15814fn 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
15820/// Collects published docs outputs declared by page routes.
15821fn 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
15839/// Performs docs relative asset.
15840fn 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
15845/// Resolves the trusted repository root for a docs app build.
15846fn 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
15864/// Builds a fingerprint for the Rustdoc inputs that feed the docs app.
15865fn 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
15893/// Resolves the cache directory for a Rustdoc artifact fingerprint.
15894fn 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
15900/// Checks whether a stored Rustdoc fingerprint file matches the current input digest.
15901fn 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
15907/// Returns the existing Rustdoc output directory when it matches the current Rust inputs.
15908fn 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/// Removes artifact contents while leaving one known-valid generated directory in place.
15932#[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
15949/// Resolves the Rustdoc output directory declared by a docs app manifest.
15950fn 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
16023/// Copies an existing Rustdoc tree into another directory.
16024fn docs_copy_rustdoc_tree(source: &Path, destination: &Path) -> Result<(), i32> {
16025    docs_sync_rustdoc_tree(source, destination, 0)
16026}
16027
16028/// Restores a cached Rustdoc tree into the current docs app artifact when inputs are unchanged.
16029fn 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
16048/// Builds the bundled Rust API and source reference for a docs app.
16049fn 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
16133/// Builds docs pages.
16134fn 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
16401/// Removes non manifest YAML from artifact.
16402fn 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
16477/// Copies source tree to artifact.
16478fn 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
16547/// Preserves source timestamps for model payloads so cached model archives pass ownership checks.
16548fn 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
16570/// Clears one app runtime in place.
16571///
16572/// # Parameters
16573///
16574/// - `app`: Input consumed by [`crate::runtime::free_app`].
16575///
16576/// # Returns
16577///
16578/// Returns after [`crate::runtime::free_app`] applies its in-place effect.
16579///
16580/// # Examples
16581///
16582/// ```rust,ignore
16583/// let _result = pharos_native::runtime::free_app(todo!());
16584/// ```
16585///
16586/// # Related
16587///
16588/// - [`crate::runtime`] for adjacent helpers that share the same runtime surface.
16589pub 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    /// Distinguishes unused browser connections from timed-out partial requests.
16645    #[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        /// Simulates a socket reaching its read deadline without a wall-clock wait.
16666        fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
16667            Err(std::io::Error::from(self.0))
16668        }
16669    }
16670
16671    /// Creates one isolated temp root for runtime route tests.
16672    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    /// Builds one synthetic HTTP request for route tests.
16686    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    /// Proves framework-owned readiness routes are stable, unauthenticated, and method bounded.
16707    #[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    /// Verifies technician availability routes never enter the business-hours create path.
16784    #[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    /// Performs enhanced provider checkout redirects through HX-Redirect.
16799    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    /// Performs framework webhook path accepts only one valid endpoint segment.
16843    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    /// Normalizes both supported parameter syntaxes into one collision namespace.
16863    #[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    /// Rejects a mismatched generated app artifact before it can enter readiness.
16881    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    /// Keeps small model manifests valid when a cached models archive preserves older timestamps.
17116    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    /// Allows copied app source assets to be refreshed without weakening generated outputs.
17157    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    /// Preserves mutable runtime media separately from immutable media artifact paths.
17212    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    /// Includes common local Cargo install paths for docs Rustdoc generation.
17265    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    /// Resolves a relative docs app root to the absolute repository root before spawning Cargo.
17279    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    /// Accepts Rustdoc fingerprint files written with a trailing newline.
17288    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    /// Restores a cached Rustdoc tree into a rebuilt docs app artifact.
17302    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    /// Removes rebuilt app artifact files while preserving the Rustdoc output directory.
17340    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    /// Keeps docs shell asset paths manifest-owned and relative.
17363    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    /// Rejects docs asset paths that escape the app artifact tree.
17397    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    /// Accepts only the supported declarative docs publish modes.
17411    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    /// Treats navigation paths as the published-output allowlist in nav_only mode.
17429    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    /// Treats declared page artifacts as the published-output allowlist in routed_only mode.
17449    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    /// Resolves route method before declared kind so explicit GET actions remain routable.
17472    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    /// Serves mounted static-pages artifact routes directly from the built artifact tree.
17637    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    /// Confirms session digests are unique per secret and bound to the owning application.
17725    #[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    /// Confirms the state-backed admission window rejects requests beyond its declared maximum.
17743    #[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    /// Storage failure classes reach HTTP without denial, credential or driver-detail substitution.
17804    #[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    /// Confirms legacy and validation JSON failures are normalized without losing safe details.
17826    #[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    /// Ignores foreign cookies only when session storage is absent from the logical contract.
17872    #[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    /// Confirms concurrent users remain isolated and sessions cannot cross applications.
17939    #[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    /// Performs relational login and logout skip state mutation lock.
18003    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    /// Confirms business identifiers cannot be substituted across owners.
18112    #[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    /// Confirms booking times use the relational schema's canonical 24-hour representation.
18146    #[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    /// Confirms queued-job timing accepts both epoch strings and exported UTC timestamps.
18170    #[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    /// Performs artifact tree digest ignores ds store noise.
18187    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    /// Performs route request middleware returns structured validation report.
18210    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    /// Performs route request hook rejects non form body when form is required.
18242    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    /// Performs route response middleware sets and mirrors headers.
18266    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    /// Performs background job emits started and retried lifecycle payloads.
18311    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    /// Expands only successful Architect archive downloads, preserving ordinary and error response limits.
18456    #[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}