Skip to main content

harn_cli/package/manifest/
provider_setup.rs

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/// Product-facing connector metadata shared by every host projection.
37///
38/// Provider-specific request and response shapes stay in the connector. This
39/// contract describes only the portable service, action, disclosure, spend,
40/// evidence, and reconciliation semantics that hosts must agree on.
41#[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    /// Plain-language, action-specific reason shown before disclosure.
56    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    /// Stable connector-owned condition id evaluated by the adapter.
124    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
164/// Validate the product-facing service contract at every manifest boundary.
165pub 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/// Bounded process-environment aliases for one logical connector secret.
318///
319/// The logical secret remains the stable interface. Environment names are
320/// explicit recovery and automation sources. They never authorize scanning
321/// arbitrary process variables.
322#[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}