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 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}