1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use super::{
7 ConnectorCapabilities, ProviderConnectorManifest, ProviderOAuthManifest,
8 ResolvedProviderConnectorKind,
9};
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct ProviderManifestEntry {
13 pub id: harn_vm::ProviderId,
14 pub connector: ProviderConnectorManifest,
15 #[serde(default)]
16 pub oauth: Option<ProviderOAuthManifest>,
17 #[serde(default)]
18 pub setup: Option<ProviderSetupManifest>,
19 #[serde(default)]
20 pub service: Option<ConnectorServiceManifest>,
21 #[serde(default)]
22 pub capabilities: ConnectorCapabilities,
23}
24
25#[derive(Debug, Clone)]
26pub struct ResolvedProviderConnectorConfig {
27 pub id: harn_vm::ProviderId,
28 pub manifest_dir: PathBuf,
29 pub connector: ResolvedProviderConnectorKind,
30 pub oauth: Option<ProviderOAuthManifest>,
31 pub setup: Option<ProviderSetupManifest>,
32 pub service: Option<ConnectorServiceManifest>,
33 pub connector_contract_version: u32,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct ConnectorServiceManifest {
44 pub name: String,
45 pub description: String,
46 #[serde(default)]
47 pub operations: Vec<ConnectorOperationManifest>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct ConnectorOperationManifest {
53 pub id: String,
54 pub capability: String,
55 pub purpose: String,
57 pub effect: ConnectorOperationEffect,
58 #[serde(default)]
59 pub environments: Vec<ConnectorEnvironment>,
60 #[serde(default)]
61 pub evidence: Vec<ConnectorEvidenceRequirement>,
62 #[serde(default)]
63 pub protected_profile: ConnectorProtectedProfileManifest,
64 #[serde(default)]
65 pub test_profile: ConnectorTestProfile,
66 #[serde(default)]
67 pub external_spend: ConnectorExternalSpend,
68 #[serde(default)]
69 pub reconciliation: ConnectorReconciliation,
70 #[serde(default)]
71 pub redaction: Vec<ConnectorRedactionTarget>,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ConnectorOperationEffect {
77 Read,
78 Consequential,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum ConnectorEnvironment {
84 Mock,
85 Test,
86 Live,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ConnectorEvidenceRequirement {
92 Citation,
93 CurrentProviderState,
94 FreshQuote,
95 UserConfirmation,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum ProtectedProfileFieldClass {
101 LegalIdentity,
102 BirthDate,
103 ContactDetails,
104 AccessibilityNeeds,
105 LoyaltyAccounts,
106 TravelDocuments,
107}
108
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct ConnectorProtectedProfileManifest {
112 #[serde(default)]
113 pub required: Vec<ProtectedProfileFieldClass>,
114 #[serde(default)]
115 pub optional: Vec<ProtectedProfileFieldClass>,
116 #[serde(default)]
117 pub conditional: Vec<ConnectorConditionalProfileRequirement>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(deny_unknown_fields)]
122pub struct ConnectorConditionalProfileRequirement {
123 pub condition: String,
125 pub field_classes: Vec<ProtectedProfileFieldClass>,
126}
127
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum ConnectorExternalSpend {
131 #[default]
132 None,
133 Estimate,
134 Commit,
135}
136
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum ConnectorReconciliation {
140 #[default]
141 None,
142 Supported,
143 Required,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum ConnectorRedactionTarget {
149 RequestBody,
150 ResponseBody,
151 ErrorBody,
152 Headers,
153 Query,
154}
155
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum ConnectorTestProfile {
159 #[default]
160 None,
161 FictionalRequired,
162}
163
164pub fn connector_service_issues(service: &ConnectorServiceManifest) -> Vec<String> {
166 let mut issues = Vec::new();
167 if service.name.trim().is_empty() {
168 issues.push("service.name is required".to_string());
169 }
170 if service.description.trim().is_empty() {
171 issues.push("service.description is required".to_string());
172 }
173 if service.operations.is_empty() {
174 issues.push("service.operations must include at least one operation".to_string());
175 }
176
177 let mut operation_ids = std::collections::BTreeSet::new();
178 for operation in &service.operations {
179 let label = if operation.id.trim().is_empty() {
180 "<missing>"
181 } else {
182 operation.id.as_str()
183 };
184 if !portable_operation_id(&operation.id) {
185 issues.push(format!(
186 "service operation '{label}' id must use ASCII letters, digits, '.', '_', or '-'"
187 ));
188 } else if !operation_ids.insert(operation.id.as_str()) {
189 issues.push(format!(
190 "service operation id '{}' is repeated",
191 operation.id
192 ));
193 }
194 if !portable_id(&operation.capability) {
195 issues.push(format!(
196 "service operation '{label}' capability must use lowercase letters, digits, '.', '_', or '-'"
197 ));
198 }
199 if operation.purpose.trim().is_empty() {
200 issues.push(format!("service operation '{label}' purpose is required"));
201 }
202 if operation.environments.is_empty() {
203 issues.push(format!(
204 "service operation '{label}' environments must not be empty"
205 ));
206 }
207 if operation.effect == ConnectorOperationEffect::Read
208 && operation.external_spend != ConnectorExternalSpend::None
209 {
210 issues.push(format!(
211 "read operation '{label}' cannot declare external spend"
212 ));
213 }
214 if operation.effect == ConnectorOperationEffect::Read
215 && operation.reconciliation == ConnectorReconciliation::Required
216 {
217 issues.push(format!(
218 "read operation '{label}' cannot require reconciliation"
219 ));
220 }
221
222 let profile = &operation.protected_profile;
223 let mut declared_classes = std::collections::BTreeSet::new();
224 for class in profile.required.iter().chain(profile.optional.iter()) {
225 if !declared_classes.insert(*class as u8) {
226 issues.push(format!(
227 "service operation '{label}' repeats protected profile class '{class:?}'"
228 ));
229 }
230 }
231 for requirement in &profile.conditional {
232 if !portable_id(&requirement.condition) {
233 issues.push(format!(
234 "service operation '{label}' conditional profile id '{}' is invalid",
235 requirement.condition
236 ));
237 }
238 if requirement.field_classes.is_empty() {
239 issues.push(format!(
240 "service operation '{label}' conditional profile '{}' has no field classes",
241 requirement.condition
242 ));
243 }
244 }
245
246 let has_profile = !profile.required.is_empty()
247 || !profile.optional.is_empty()
248 || profile
249 .conditional
250 .iter()
251 .any(|requirement| !requirement.field_classes.is_empty());
252 if has_profile
253 && operation.environments.contains(&ConnectorEnvironment::Test)
254 && operation.test_profile != ConnectorTestProfile::FictionalRequired
255 {
256 issues.push(format!(
257 "service operation '{label}' uses protected profile fields in test mode but does not require a fictional fixture"
258 ));
259 }
260 if has_profile {
261 for required_target in [
262 ConnectorRedactionTarget::RequestBody,
263 ConnectorRedactionTarget::ResponseBody,
264 ConnectorRedactionTarget::ErrorBody,
265 ] {
266 if !operation.redaction.contains(&required_target) {
267 issues.push(format!(
268 "service operation '{label}' with protected profile fields must redact {required_target:?}"
269 ));
270 }
271 }
272 }
273 }
274 issues
275}
276
277fn portable_id(value: &str) -> bool {
278 !value.is_empty()
279 && value.bytes().all(|byte| {
280 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
281 })
282}
283
284fn portable_operation_id(value: &str) -> bool {
285 !value.is_empty()
286 && value.bytes().all(|byte| {
287 byte.is_ascii_alphabetic()
288 || byte.is_ascii_digit()
289 || matches!(byte, b'.' | b'_' | b'-')
290 })
291}
292
293#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
294pub struct ProviderSetupManifest {
295 #[serde(default, alias = "auth-type")]
296 pub auth_type: Option<String>,
297 #[serde(default)]
298 pub flow: Option<String>,
299 #[serde(default, alias = "required-scopes", alias = "scopes")]
300 pub required_scopes: Vec<String>,
301 #[serde(default, alias = "required-secrets")]
302 pub required_secrets: Vec<String>,
303 #[serde(default, alias = "credential-environment")]
304 pub credential_environment: Vec<ConnectorCredentialEnvironmentManifest>,
305 #[serde(default, alias = "configuration-environment")]
306 pub configuration_environment: Vec<ConnectorConfigurationEnvironmentManifest>,
307 #[serde(default, alias = "setup-command")]
308 pub setup_command: Vec<String>,
309 #[serde(default, alias = "validation-command")]
310 pub validation_command: Vec<String>,
311 #[serde(default, alias = "health-checks")]
312 pub health_checks: Vec<ConnectorHealthCheckManifest>,
313 #[serde(default)]
314 pub recovery: ConnectorRecoveryCopy,
315 #[serde(flatten, default)]
316 pub extra: BTreeMap<String, toml::Value>,
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
323pub enum ConnectorSetupConfigurationField {
324 #[serde(rename = "oauth_client_id")]
325 OAuthClientId,
326}
327
328impl ConnectorSetupConfigurationField {
329 pub const WIRE_VALUES: &'static [&'static str] = &["oauth_client_id"];
330
331 pub const fn as_str(self) -> &'static str {
332 match self {
333 Self::OAuthClientId => "oauth_client_id",
334 }
335 }
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339pub struct ConnectorConfigurationEnvironmentManifest {
340 pub field: ConnectorSetupConfigurationField,
341 #[serde(default, alias = "environment-names")]
342 pub environment_names: Vec<String>,
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
351pub struct ConnectorCredentialEnvironmentManifest {
352 pub secret: String,
353 #[serde(default, alias = "environment-names")]
354 pub environment_names: Vec<String>,
355}
356
357#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
358pub struct ConnectorHealthCheckManifest {
359 pub id: String,
360 pub kind: String,
361 #[serde(default)]
362 pub command: Vec<String>,
363 #[serde(default)]
364 pub secret: Option<String>,
365 #[serde(default)]
366 pub url: Option<String>,
367}
368
369#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
370pub struct ConnectorRecoveryCopy {
371 #[serde(default, alias = "missing-install")]
372 pub missing_install: Option<String>,
373 #[serde(default, alias = "missing-auth")]
374 pub missing_auth: Option<String>,
375 #[serde(default, alias = "expired-credentials")]
376 pub expired_credentials: Option<String>,
377 #[serde(default, alias = "revoked-credentials")]
378 pub revoked_credentials: Option<String>,
379 #[serde(default, alias = "missing-scopes")]
380 pub missing_scopes: Option<String>,
381 #[serde(default, alias = "inaccessible-resource")]
382 pub inaccessible_resource: Option<String>,
383 #[serde(default, alias = "transient-provider-outage")]
384 pub transient_provider_outage: Option<String>,
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 #[test]
392 fn protected_profile_contract_is_typed_and_complete() {
393 let service: ConnectorServiceManifest = toml::from_str(
394 r#"
395name = "Duffel"
396description = "Searches flights and creates governed test orders."
397
398[[operations]]
399id = "orders.create"
400capability = "travel.booking"
401purpose = "Create the exact reviewed flight order."
402effect = "consequential"
403environments = ["test"]
404evidence = ["fresh_quote", "user_confirmation"]
405external_spend = "commit"
406reconciliation = "required"
407redaction = ["request_body", "response_body", "error_body"]
408test_profile = "fictional_required"
409
410[operations.protected_profile]
411required = ["legal_identity", "birth_date"]
412optional = ["contact_details"]
413
414[[operations.protected_profile.conditional]]
415condition = "international_itinerary"
416field_classes = ["travel_documents"]
417"#,
418 )
419 .expect("typed service manifest");
420
421 assert!(connector_service_issues(&service).is_empty());
422 assert_eq!(
423 service.operations[0].protected_profile.required,
424 [
425 ProtectedProfileFieldClass::LegalIdentity,
426 ProtectedProfileFieldClass::BirthDate,
427 ]
428 );
429 }
430
431 #[test]
432 fn protected_profile_test_actions_require_fictional_fixture_and_redaction() {
433 let service = ConnectorServiceManifest {
434 name: "Duffel".to_string(),
435 description: "Travel".to_string(),
436 operations: vec![ConnectorOperationManifest {
437 id: "orders.create".to_string(),
438 capability: "travel.booking".to_string(),
439 purpose: "Create an order".to_string(),
440 effect: ConnectorOperationEffect::Consequential,
441 environments: vec![ConnectorEnvironment::Test],
442 evidence: Vec::new(),
443 protected_profile: ConnectorProtectedProfileManifest {
444 required: vec![ProtectedProfileFieldClass::LegalIdentity],
445 ..ConnectorProtectedProfileManifest::default()
446 },
447 test_profile: ConnectorTestProfile::None,
448 external_spend: ConnectorExternalSpend::Commit,
449 reconciliation: ConnectorReconciliation::Required,
450 redaction: vec![ConnectorRedactionTarget::ErrorBody],
451 }],
452 };
453
454 let issues = connector_service_issues(&service).join("\n");
455 assert!(issues.contains("does not require a fictional fixture"));
456 assert!(issues.contains("RequestBody"));
457 assert!(issues.contains("ResponseBody"));
458 }
459
460 #[test]
461 fn service_manifest_rejects_unknown_policy_fields() {
462 let error = toml::from_str::<ConnectorServiceManifest>(
463 r#"
464name = "Echo"
465description = "Echoes messages."
466automatic_approval = true
467"#,
468 )
469 .expect_err("unknown policy keys must fail closed");
470 assert!(error.to_string().contains("unknown field"));
471 }
472}