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 = "setup-command")]
306 pub setup_command: Vec<String>,
307 #[serde(default, alias = "validation-command")]
308 pub validation_command: Vec<String>,
309 #[serde(default, alias = "health-checks")]
310 pub health_checks: Vec<ConnectorHealthCheckManifest>,
311 #[serde(default)]
312 pub recovery: ConnectorRecoveryCopy,
313 #[serde(flatten, default)]
314 pub extra: BTreeMap<String, toml::Value>,
315}
316
317#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
323pub struct ConnectorCredentialEnvironmentManifest {
324 pub secret: String,
325 #[serde(default, alias = "environment-names")]
326 pub environment_names: Vec<String>,
327}
328
329#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
330pub struct ConnectorHealthCheckManifest {
331 pub id: String,
332 pub kind: String,
333 #[serde(default)]
334 pub command: Vec<String>,
335 #[serde(default)]
336 pub secret: Option<String>,
337 #[serde(default)]
338 pub url: Option<String>,
339}
340
341#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
342pub struct ConnectorRecoveryCopy {
343 #[serde(default, alias = "missing-install")]
344 pub missing_install: Option<String>,
345 #[serde(default, alias = "missing-auth")]
346 pub missing_auth: Option<String>,
347 #[serde(default, alias = "expired-credentials")]
348 pub expired_credentials: Option<String>,
349 #[serde(default, alias = "revoked-credentials")]
350 pub revoked_credentials: Option<String>,
351 #[serde(default, alias = "missing-scopes")]
352 pub missing_scopes: Option<String>,
353 #[serde(default, alias = "inaccessible-resource")]
354 pub inaccessible_resource: Option<String>,
355 #[serde(default, alias = "transient-provider-outage")]
356 pub transient_provider_outage: Option<String>,
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn protected_profile_contract_is_typed_and_complete() {
365 let service: ConnectorServiceManifest = toml::from_str(
366 r#"
367name = "Duffel"
368description = "Searches flights and creates governed test orders."
369
370[[operations]]
371id = "orders.create"
372capability = "travel.booking"
373purpose = "Create the exact reviewed flight order."
374effect = "consequential"
375environments = ["test"]
376evidence = ["fresh_quote", "user_confirmation"]
377external_spend = "commit"
378reconciliation = "required"
379redaction = ["request_body", "response_body", "error_body"]
380test_profile = "fictional_required"
381
382[operations.protected_profile]
383required = ["legal_identity", "birth_date"]
384optional = ["contact_details"]
385
386[[operations.protected_profile.conditional]]
387condition = "international_itinerary"
388field_classes = ["travel_documents"]
389"#,
390 )
391 .expect("typed service manifest");
392
393 assert!(connector_service_issues(&service).is_empty());
394 assert_eq!(
395 service.operations[0].protected_profile.required,
396 [
397 ProtectedProfileFieldClass::LegalIdentity,
398 ProtectedProfileFieldClass::BirthDate,
399 ]
400 );
401 }
402
403 #[test]
404 fn protected_profile_test_actions_require_fictional_fixture_and_redaction() {
405 let service = ConnectorServiceManifest {
406 name: "Duffel".to_string(),
407 description: "Travel".to_string(),
408 operations: vec![ConnectorOperationManifest {
409 id: "orders.create".to_string(),
410 capability: "travel.booking".to_string(),
411 purpose: "Create an order".to_string(),
412 effect: ConnectorOperationEffect::Consequential,
413 environments: vec![ConnectorEnvironment::Test],
414 evidence: Vec::new(),
415 protected_profile: ConnectorProtectedProfileManifest {
416 required: vec![ProtectedProfileFieldClass::LegalIdentity],
417 ..ConnectorProtectedProfileManifest::default()
418 },
419 test_profile: ConnectorTestProfile::None,
420 external_spend: ConnectorExternalSpend::Commit,
421 reconciliation: ConnectorReconciliation::Required,
422 redaction: vec![ConnectorRedactionTarget::ErrorBody],
423 }],
424 };
425
426 let issues = connector_service_issues(&service).join("\n");
427 assert!(issues.contains("does not require a fictional fixture"));
428 assert!(issues.contains("RequestBody"));
429 assert!(issues.contains("ResponseBody"));
430 }
431
432 #[test]
433 fn service_manifest_rejects_unknown_policy_fields() {
434 let error = toml::from_str::<ConnectorServiceManifest>(
435 r#"
436name = "Echo"
437description = "Echoes messages."
438automatic_approval = true
439"#,
440 )
441 .expect_err("unknown policy keys must fail closed");
442 assert!(error.to_string().contains("unknown field"));
443 }
444}