Skip to main content

pharos_native/runtime/
architect_project_identity.rs

1//! Native lifecycle and portable preview-data handoff for Architect app-ID renames.
2use super::{architect_http, database};
3use pharos_runtime_architect::{
4    AgentErrorKind,
5    database::ArchitectStore,
6    project_identity::{IdentityCandidate, IdentityPreview},
7};
8use pharos_runtime_database::{
9    DbBackendConfig,
10    contract::AppDataContract,
11    operations::{DatabaseOperations, operation_fingerprint},
12    snapshot::DatabaseSnapshot,
13};
14use serde_json::Value;
15use std::sync::Arc;
16
17/// Original preview authority retained only for the final concurrent-write check.
18struct OriginalPreview {
19    adapter: Arc<dyn DatabaseOperations>,
20    config: DbBackendConfig,
21    contract: AppDataContract,
22    fingerprint: String,
23}
24
25/// Native adapter keeps provider credentials outside copied authoring identity.
26pub(super) struct NativeIdentityPreview<'a> {
27    original: Option<OriginalPreview>,
28    app: &'a crate::config::AppRuntime,
29    request: &'a super::http::HttpRequest,
30    actor_id: i64,
31}
32
33impl<'a> NativeIdentityPreview<'a> {
34    /// Binds long-running preview preparation to the authenticated initiating request.
35    pub(super) fn new(
36        app: &'a crate::config::AppRuntime,
37        request: &'a super::http::HttpRequest,
38        actor_id: i64,
39    ) -> Self {
40        Self {
41            original: None,
42            app,
43            request,
44            actor_id,
45        }
46    }
47}
48
49impl IdentityPreview for NativeIdentityPreview<'_> {
50    /// Builds and restores a new preview without modifying the original namespace.
51    fn prepare(
52        &mut self,
53        store: &ArchitectStore,
54        candidate: &IdentityCandidate,
55    ) -> Result<(), AgentErrorKind> {
56        let old_workspace =
57            architect_http::architect_editor_preview_workspace(&candidate.old_source);
58        if !architect_http::architect_sync_editor_preview_config(
59            store,
60            candidate.task_id,
61            &old_workspace,
62        ) {
63            return Err(AgentErrorKind::InvalidPath);
64        }
65        let old_artifact = architect_http::architect_materialized_preview_root(
66            &candidate.old_source,
67            &old_workspace,
68            &candidate.old_app_id,
69        )
70        .ok_or(AgentErrorKind::InvalidPath)?;
71        let old_conf = old_workspace.join("runtime.conf");
72        let old =
73            database::logical_app_store(&old_artifact, Some(&old_conf), &candidate.old_app_id)
74                .map_err(|_| AgentErrorKind::InvalidPath)?;
75        let old_contract =
76            database::selected_logical_contract(&old_artifact, &candidate.old_app_id)
77                .map_err(|_| AgentErrorKind::InvalidEnvelope)?
78                .ok_or(AgentErrorKind::InvalidEnvelope)?;
79        let snapshot = DatabaseSnapshot::capture(old.adapter.as_ref(), &old.config, &old_contract)
80            .map_err(|_| AgentErrorKind::InvalidPath)?;
81        let fingerprint =
82            operation_fingerprint(&snapshot).map_err(|_| AgentErrorKind::CapacityExceeded)?;
83        pharos_runtime_architect::lifecycle_validation::initialize_editor_preview_config(
84            &candidate.preview_workspace,
85            &candidate.new_app_id,
86            store.config(),
87        )?;
88        let new_conf = candidate.preview_workspace.join("runtime.conf");
89        let build_dir = candidate.preview_workspace.join("dist");
90        super::build_app_from_root(&candidate.new_source, &build_dir)
91            .map_err(|_| AgentErrorKind::InvalidEnvelope)?;
92        let new_artifact = build_dir.join("apps").join(&candidate.new_app_id);
93        if database::dynamic_runtime_prepare(
94            &new_artifact,
95            Some(&new_conf),
96            Some(&candidate.new_app_id),
97        ) != 0
98        {
99            return Err(AgentErrorKind::InvalidEnvelope);
100        }
101        let target =
102            database::logical_app_store(&new_artifact, Some(&new_conf), &candidate.new_app_id)
103                .map_err(|_| AgentErrorKind::InvalidPath)?;
104        let contract = database::selected_logical_contract(&new_artifact, &candidate.new_app_id)
105            .map_err(|_| AgentErrorKind::InvalidEnvelope)?
106            .ok_or(AgentErrorKind::InvalidEnvelope)?;
107        let old_mount = format!("/{}", candidate.old_app_id.replace('_', "-"));
108        let new_mount = format!("/{}", candidate.new_app_id.replace('_', "-"));
109        let mut copied = snapshot;
110        copied.app_id = candidate.new_app_id.clone();
111        copied.contract_fingerprint =
112            operation_fingerprint(&contract).map_err(|_| AgentErrorKind::InvalidEnvelope)?;
113        // Sessions and external consent are bound to an app identity, not portable app content.
114        copied.records.retain(|record| {
115            !matches!(
116                record.entity.as_str(),
117                "auth_session" | "social_connection" | "social_collection" | "social_observation"
118            )
119        });
120        for record in &mut copied.records {
121            for value in record.fields.values_mut() {
122                rewrite_mount(value, &old_mount, &new_mount, 0)?;
123            }
124        }
125        copied
126            .restore(target.adapter.as_ref(), &target.config, &contract, &[])
127            .map_err(|_| AgentErrorKind::InvalidPath)?;
128        self.original = Some(OriginalPreview {
129            adapter: old.adapter,
130            config: old.config,
131            contract: old_contract,
132            fingerprint,
133        });
134        Ok(())
135    }
136    /// Compares a consistent final snapshot before accepting the new authoring identity.
137    fn check_unchanged(&self) -> Result<(), AgentErrorKind> {
138        if super::active_session_user_id(self.app, self.request.cookie.as_deref())
139            .map_err(|_| AgentErrorKind::PolicyViolation)?
140            != self.actor_id
141            || !super::actor_has_route_policy(self.app, self.actor_id, "review_builder_plan")
142                .map_err(|_| AgentErrorKind::PolicyViolation)?
143        {
144            return Err(AgentErrorKind::PolicyViolation);
145        }
146        let old = self.original.as_ref().ok_or(AgentErrorKind::InvalidPath)?;
147        let snapshot = DatabaseSnapshot::capture(old.adapter.as_ref(), &old.config, &old.contract)
148            .map_err(|_| AgentErrorKind::InvalidPath)?;
149        if operation_fingerprint(&snapshot).map_err(|_| AgentErrorKind::CapacityExceeded)?
150            != old.fingerprint
151        {
152            return Err(AgentErrorKind::StalePlan);
153        }
154        Ok(())
155    }
156}
157
158/// Updates app-local URL values without rewriting external URLs or identifier substrings.
159fn rewrite_mount(
160    value: &mut Value,
161    old: &str,
162    new: &str,
163    depth: usize,
164) -> Result<(), AgentErrorKind> {
165    if depth > 64 {
166        return Err(AgentErrorKind::CapacityExceeded);
167    }
168    match value {
169        Value::String(text)
170            if text
171                .strip_prefix(old)
172                .is_some_and(|tail| tail.is_empty() || tail.starts_with(['/', '?', '#'])) =>
173        {
174            *text = format!("{new}{}", &text[old.len()..]);
175        }
176        Value::Array(values) => {
177            for value in values {
178                rewrite_mount(value, old, new, depth + 1)?;
179            }
180        }
181        Value::Object(values) => {
182            for value in values.values_mut() {
183                rewrite_mount(value, old, new, depth + 1)?;
184            }
185        }
186        _ => {}
187    }
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    /// Only app-local mount URLs follow the identity; external and prefix-like values remain intact.
195    #[test]
196    fn mount_rewrite_preserves_external_and_prefix_values() {
197        let mut value = serde_json::json!(["/original-app/static/image.png","https://example.com/original-app/","/original-application/",{"url":"/original-app?tab=1"}]);
198        rewrite_mount(&mut value, "/original-app", "/renamed-app", 0).unwrap();
199        assert_eq!(
200            value,
201            serde_json::json!(["/renamed-app/static/image.png","https://example.com/original-app/","/original-application/",{"url":"/renamed-app?tab=1"}])
202        );
203    }
204}