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 = "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/// Non-secret setup input that a connector may read from an explicit process
320/// environment allowlist. Values are consumed only by the setup adapter and
321/// are never projected into plans, status reports, or model context.
322#[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/// Bounded process-environment aliases for one logical connector secret.
346///
347/// The logical secret remains the stable interface. Environment names are
348/// explicit recovery and automation sources. They never authorize scanning
349/// arbitrary process variables.
350#[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}