Skip to main content

platform_provider/
source.rs

1use crate::ProviderHostEffectCoordinator;
2use crate::admin_action::ProviderAdminActionSource;
3use crate::admin_data::ProviderAdminDataSource;
4use crate::binding::ProviderBinding;
5use crate::config::{ProviderConfig, ProviderTransport};
6use crate::protocol::{PROVIDER_PROTOCOL, ProviderDescriptor, ProviderManifestResponse};
7use crate::response::{
8    MAX_PROVIDER_JSON_RESPONSE_BYTES, ResponseBodyPolicy, decode_json_response_with_policy,
9};
10use platform_core::error::ErrorDetail;
11use platform_core::{AppError, AppResult, ErrorCode};
12use platform_module::{
13    AdminDeclarativeComponent, AdminDeclarativeSurface, AdminSurface, Module, ModuleHttpRoute,
14    ModuleManifest,
15};
16use std::sync::Arc;
17use std::time::Duration;
18
19#[derive(Debug, Clone)]
20pub struct ProviderSource {
21    client: reqwest::Client,
22    config: ProviderConfig,
23    effects: ProviderHostEffectCoordinator,
24}
25
26#[derive(Debug)]
27pub struct LoadedProvider {
28    pub module: Module,
29    pub config: ProviderConfig,
30}
31
32impl ProviderSource {
33    pub fn new(config: ProviderConfig) -> AppResult<Self> {
34        let client = reqwest::Client::builder()
35            .timeout(Duration::from_millis(config.timeout_ms))
36            .build()
37            .map_err(|error| {
38                AppError::new(
39                    ErrorCode::Internal,
40                    format!("failed to build Provider Service client: {error}"),
41                )
42            })?;
43        Ok(Self {
44            client,
45            config,
46            effects: ProviderHostEffectCoordinator::rejecting(),
47        })
48    }
49
50    #[must_use]
51    pub fn with_effect_coordinator(mut self, effects: ProviderHostEffectCoordinator) -> Self {
52        self.effects = effects;
53        self
54    }
55
56    /// Verifies the live Provider descriptor against a locked Manifest, then
57    /// builds behavior exclusively from the locked copy. The endpoint can
58    /// confirm identity but can never discover or replace a Module.
59    pub async fn load_locked(
60        &self,
61        expected_service_id: &str,
62        expected_service_version: &str,
63        expected_service_release_digest: &str,
64        expected_export_key: &str,
65        expected_module_release_digest: &str,
66        expected_manifest_digest: &str,
67        expected_contract_digests: &[String],
68        locked: &ModuleManifest,
69    ) -> AppResult<LoadedProvider> {
70        let descriptor = self.fetch_descriptor().await?;
71        if descriptor.protocol != PROVIDER_PROTOCOL
72            || descriptor.service_id != expected_service_id
73            || descriptor.service_release_version != expected_service_version
74            || descriptor.service_release_digest != expected_service_release_digest
75        {
76            return Err(AppError::new(
77                ErrorCode::ExternalDependency,
78                format!(
79                    "Provider descriptor Service identity for '{}' differs from the locked Service Release",
80                    locked.module_id
81                ),
82            ));
83        }
84        let export = descriptor
85            .exports
86            .into_iter()
87            .find(|export| export.export_key == expected_export_key)
88            .ok_or_else(|| {
89                AppError::new(
90                    ErrorCode::ExternalDependency,
91                    format!("Provider descriptor omitted locked export '{expected_export_key}'"),
92                )
93            })?;
94        let mut observed_contracts = export
95            .contract_digests
96            .values()
97            .cloned()
98            .collect::<Vec<_>>();
99        let mut expected_contracts = expected_contract_digests.to_vec();
100        observed_contracts.sort();
101        expected_contracts.sort();
102        if export.module_id != locked.module_id
103            || export.module_release_digest != expected_module_release_digest
104            || export.manifest_digest != expected_manifest_digest
105            || export.manifest != *locked
106            || observed_contracts != expected_contracts
107            || !export.ready
108        {
109            return Err(AppError::new(
110                ErrorCode::ExternalDependency,
111                format!(
112                    "Provider export '{}' differs from the locked Module Release",
113                    expected_export_key
114                ),
115            ));
116        }
117
118        self.load_module(locked.clone(), self.config.clone())
119    }
120
121    fn load_module(
122        &self,
123        manifest: ProviderManifestResponse,
124        config: ProviderConfig,
125    ) -> AppResult<LoadedProvider> {
126        validate_provider_http_routes(&manifest.http_routes)?;
127        let binding = ProviderBinding::from_surfaces_with_effects(
128            config.clone(),
129            manifest.runtime.as_ref(),
130            manifest.events.as_ref(),
131            self.effects.clone(),
132        )?;
133
134        let has_admin_data = match &manifest.admin {
135            Some(AdminSurface::Schema(_)) => true,
136            Some(AdminSurface::DeclarativeCustom(surface)) => surface.fallback_schema.is_some(),
137            _ => false,
138        };
139        let has_admin_actions = matches!(
140            &manifest.admin,
141            Some(AdminSurface::DeclarativeCustom(surface)) if !surface.actions.is_empty()
142        );
143        let has_admin_queries = matches!(
144            &manifest.admin,
145            Some(AdminSurface::DeclarativeCustom(surface)) if has_query_value_component(surface)
146        );
147        let mut module = Module::service(manifest, Arc::new(binding));
148        if has_admin_data {
149            module = module.with_admin_data(Arc::new(
150                ProviderAdminDataSource::new(config.clone())?
151                    .with_effect_coordinator(self.effects.clone()),
152            ));
153        }
154        if has_admin_actions {
155            module = module.with_admin_actions(Arc::new(
156                ProviderAdminActionSource::new(config.clone())?
157                    .with_effect_coordinator(self.effects.clone()),
158            ));
159        }
160        if has_admin_queries {
161            module = module.with_admin_queries(Arc::new(
162                ProviderAdminDataSource::new(config.clone())?
163                    .with_effect_coordinator(self.effects.clone()),
164            ));
165        }
166        Ok(LoadedProvider { module, config })
167    }
168
169    async fn fetch_descriptor(&self) -> AppResult<ProviderDescriptor> {
170        if self.config.transport == ProviderTransport::Grpc {
171            return crate::grpc::fetch_descriptor(&self.config).await;
172        }
173
174        let request = self.client.get(&self.config.base_url);
175        let request = match &self.config.auth_token {
176            Some(token) => request.bearer_auth(token),
177            None => request,
178        };
179        let response = request.send().await.map_err(|error| {
180            AppError::new(
181                ErrorCode::ExternalDependency,
182                format!("Provider descriptor request failed: {error}"),
183            )
184            .retryable()
185        })?;
186
187        decode_json_response_with_policy(
188            response,
189            "Provider descriptor",
190            false,
191            ResponseBodyPolicy {
192                max_bytes: Some(MAX_PROVIDER_JSON_RESPONSE_BYTES),
193                require_json_content_type: true,
194                allow_empty_success: false,
195            },
196        )
197        .await?
198        .ok_or_else(|| AppError::new(ErrorCode::NotFound, "Provider descriptor not found"))
199    }
200}
201
202fn has_query_value_component(surface: &AdminDeclarativeSurface) -> bool {
203    surface.pages.iter().any(|page| {
204        page.sections.iter().any(|section| {
205            matches!(
206                section.component,
207                AdminDeclarativeComponent::QueryValue { .. }
208            )
209        })
210    })
211}
212
213fn validate_provider_http_routes(routes: &[ModuleHttpRoute]) -> AppResult<()> {
214    let mut details = Vec::new();
215    for (index, route) in routes.iter().enumerate() {
216        if !is_valid_provider_http_route_path(&route.path) {
217            details.push(ErrorDetail {
218                field: Some(format!("http_routes.{index}.path")),
219                reason: "provider HTTP route path must be module-local, start with '/', and not contain empty or '..' segments".to_owned(),
220            });
221        }
222    }
223
224    if details.is_empty() {
225        Ok(())
226    } else {
227        Err(AppError::validation(
228            "Service-delivered Module manifest contains invalid HTTP route declarations",
229            details,
230        ))
231    }
232}
233
234fn is_valid_provider_http_route_path(path: &str) -> bool {
235    path.starts_with('/')
236        && !path.starts_with("//")
237        && !path.contains('\\')
238        && !path.contains("://")
239        && !path.contains('?')
240        && !path.contains('#')
241        && path
242            .split('/')
243            .skip(1)
244            .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use platform_module::{ModuleHttpMethod, ModuleHttpRoute};
251
252    #[test]
253    fn manifest_routes_reject_backslashes() {
254        let route = ModuleHttpRoute {
255            method: ModuleHttpMethod::Get,
256            path: "/contacts\\..\\admin".to_owned(),
257            capability: Some("provider_crm.contacts.read".to_owned()),
258            display_name: None,
259            story_title: None,
260            operation: None,
261        };
262
263        assert!(validate_provider_http_routes(&[route]).is_err());
264    }
265}