Skip to main content

greentic_operator/
capabilities.rs

1use std::collections::BTreeMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::Context;
7use greentic_types::{ExtensionInline, decode_pack_manifest};
8use serde::Deserialize;
9use zip::ZipArchive;
10
11use crate::domains::Domain;
12
13pub const EXT_CAPABILITIES_V1: &str = "greentic.ext.capabilities.v1";
14pub const CAP_OP_HOOK_PRE: &str = "greentic.cap.op_hook.pre";
15pub const CAP_OP_HOOK_POST: &str = "greentic.cap.op_hook.post";
16pub const CAP_MESSAGING_V1: &str = "greentic.cap.messaging.provider.v1";
17pub const CAP_OAUTH_BROKER_V1: &str = "greentic.cap.oauth.broker.v1";
18pub const CAP_OAUTH_CARD_V1: &str = "greentic.cap.oauth.card.v1";
19pub const CAP_OAUTH_TOKEN_VALIDATION_V1: &str = "greentic.cap.oauth.token_validation.v1";
20pub const OAUTH_OP_INITIATE_AUTH: &str = "oauth.initiate_auth";
21pub const OAUTH_OP_AWAIT_RESULT: &str = "oauth.await_result";
22pub const OAUTH_OP_GET_ACCESS_TOKEN: &str = "oauth.get_access_token";
23pub const OAUTH_OP_REQUEST_RESOURCE_TOKEN: &str = "oauth.request_resource_token";
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum HookStage {
27    Pre,
28    Post,
29}
30
31impl HookStage {
32    fn cap_id(&self) -> &'static str {
33        match self {
34            HookStage::Pre => CAP_OP_HOOK_PRE,
35            HookStage::Post => CAP_OP_HOOK_POST,
36        }
37    }
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct ResolveScope {
42    pub env: Option<String>,
43    pub tenant: Option<String>,
44    pub team: Option<String>,
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct CapabilityPackRecord {
49    pub pack_id: String,
50    pub domain: Domain,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct CapabilityBinding {
55    pub cap_id: String,
56    pub stable_id: String,
57    pub pack_id: String,
58    pub domain: Domain,
59    pub pack_path: PathBuf,
60    pub provider_component_ref: String,
61    pub provider_op: String,
62    pub version: String,
63    pub requires_setup: bool,
64    pub setup_qa_ref: Option<String>,
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct CapabilityOfferRecord {
69    pub stable_id: String,
70    pub pack_id: String,
71    pub domain: Domain,
72    pub pack_path: PathBuf,
73    pub cap_id: String,
74    pub version: String,
75    pub provider_component_ref: String,
76    pub provider_op: String,
77    pub priority: i32,
78    pub requires_setup: bool,
79    pub setup_qa_ref: Option<String>,
80    scope: CapabilityScopeV1,
81    pub applies_to_ops: Vec<String>,
82}
83
84#[derive(Clone, Debug, Default)]
85pub struct CapabilityRegistry {
86    by_cap_id: BTreeMap<String, Vec<CapabilityOfferRecord>>,
87}
88
89impl CapabilityRegistry {
90    pub fn build_from_pack_index(
91        pack_index: &BTreeMap<PathBuf, CapabilityPackRecord>,
92    ) -> anyhow::Result<Self> {
93        let mut by_cap_id: BTreeMap<String, Vec<CapabilityOfferRecord>> = BTreeMap::new();
94
95        for (pack_path, pack_record) in pack_index {
96            let Some(ext) = read_capabilities_extension(pack_path)? else {
97                continue;
98            };
99
100            for (idx, offer) in ext.offers.into_iter().enumerate() {
101                let stable_id = match offer.offer_id {
102                    Some(id) if !id.trim().is_empty() => id,
103                    _ => format!(
104                        "{}::{}::{}::{}::{}",
105                        pack_record.pack_id,
106                        offer.cap_id,
107                        offer.provider.component_ref,
108                        offer.provider.op,
109                        idx
110                    ),
111                };
112                let applies_to_ops = offer
113                    .applies_to
114                    .map(|value| value.op_names)
115                    .unwrap_or_default();
116                let setup_qa_ref = offer.setup.map(|value| value.qa_ref);
117                by_cap_id
118                    .entry(offer.cap_id.clone())
119                    .or_default()
120                    .push(CapabilityOfferRecord {
121                        stable_id,
122                        pack_id: pack_record.pack_id.clone(),
123                        domain: pack_record.domain,
124                        pack_path: pack_path.clone(),
125                        cap_id: offer.cap_id,
126                        version: offer.version,
127                        provider_component_ref: offer.provider.component_ref,
128                        provider_op: offer.provider.op,
129                        priority: offer.priority,
130                        requires_setup: offer.requires_setup,
131                        setup_qa_ref,
132                        scope: offer.scope.unwrap_or_default(),
133                        applies_to_ops,
134                    });
135            }
136        }
137
138        for offers in by_cap_id.values_mut() {
139            offers.sort_by(|a, b| {
140                a.priority
141                    .cmp(&b.priority)
142                    .then_with(|| a.stable_id.cmp(&b.stable_id))
143            });
144        }
145
146        Ok(Self { by_cap_id })
147    }
148
149    pub fn offers_for_capability(&self, cap_id: &str) -> &[CapabilityOfferRecord] {
150        self.by_cap_id
151            .get(cap_id)
152            .map(Vec::as_slice)
153            .unwrap_or_default()
154    }
155
156    pub fn resolve(
157        &self,
158        cap_id: &str,
159        min_version: Option<&str>,
160        scope: &ResolveScope,
161    ) -> Option<CapabilityBinding> {
162        self.resolve_for_op(cap_id, min_version, scope, None)
163    }
164
165    pub fn resolve_for_op(
166        &self,
167        cap_id: &str,
168        min_version: Option<&str>,
169        scope: &ResolveScope,
170        requested_op: Option<&str>,
171    ) -> Option<CapabilityBinding> {
172        let offers = self.by_cap_id.get(cap_id)?;
173        let selected = offers.iter().find(|offer| {
174            version_matches(&offer.version, min_version)
175                && scope_matches(&offer.scope, scope)
176                && op_matches(offer, requested_op)
177        })?;
178        Some(CapabilityBinding {
179            cap_id: selected.cap_id.clone(),
180            stable_id: selected.stable_id.clone(),
181            pack_id: selected.pack_id.clone(),
182            domain: selected.domain,
183            pack_path: selected.pack_path.clone(),
184            provider_component_ref: selected.provider_component_ref.clone(),
185            provider_op: selected.provider_op.clone(),
186            version: selected.version.clone(),
187            requires_setup: selected.requires_setup,
188            setup_qa_ref: selected.setup_qa_ref.clone(),
189        })
190    }
191
192    pub fn resolve_hook_chain(&self, stage: HookStage, op_name: &str) -> Vec<CapabilityBinding> {
193        self.by_cap_id
194            .get(stage.cap_id())
195            .map(|offers| {
196                offers
197                    .iter()
198                    .filter(|offer| {
199                        offer.applies_to_ops.is_empty()
200                            || offer.applies_to_ops.iter().any(|entry| entry == op_name)
201                    })
202                    .map(|selected| CapabilityBinding {
203                        cap_id: selected.cap_id.clone(),
204                        stable_id: selected.stable_id.clone(),
205                        pack_id: selected.pack_id.clone(),
206                        domain: selected.domain,
207                        pack_path: selected.pack_path.clone(),
208                        provider_component_ref: selected.provider_component_ref.clone(),
209                        provider_op: selected.provider_op.clone(),
210                        version: selected.version.clone(),
211                        requires_setup: selected.requires_setup,
212                        setup_qa_ref: selected.setup_qa_ref.clone(),
213                    })
214                    .collect::<Vec<_>>()
215            })
216            .unwrap_or_default()
217    }
218
219    pub fn offers_requiring_setup(&self, scope: &ResolveScope) -> Vec<CapabilityOfferRecord> {
220        let mut selected = Vec::new();
221        for offers in self.by_cap_id.values() {
222            for offer in offers {
223                if !offer.requires_setup {
224                    continue;
225                }
226                if !scope_matches(&offer.scope, scope) {
227                    continue;
228                }
229                selected.push(offer.clone());
230            }
231        }
232        selected.sort_by(|a, b| {
233            a.priority
234                .cmp(&b.priority)
235                .then_with(|| a.stable_id.cmp(&b.stable_id))
236        });
237        selected
238    }
239
240    /// Validate messaging capability offers in the registry.
241    pub fn validate_messaging_offers(&self) -> Vec<String> {
242        let mut warnings = Vec::new();
243        let offers = self.offers_for_capability(CAP_MESSAGING_V1);
244
245        for offer in offers {
246            if offer.provider_op != "messaging.configure" {
247                warnings.push(format!(
248                    "messaging offer '{}' uses non-standard op '{}' (expected 'messaging.configure')",
249                    offer.stable_id, offer.provider_op
250                ));
251            }
252            if offer.requires_setup && offer.setup_qa_ref.is_none() {
253                warnings.push(format!(
254                    "messaging offer '{}' requires setup but has no setup.qa_ref",
255                    offer.stable_id
256                ));
257            }
258        }
259
260        warnings
261    }
262}
263
264pub fn is_oauth_broker_operation(op_name: &str) -> bool {
265    matches!(
266        op_name,
267        OAUTH_OP_INITIATE_AUTH
268            | OAUTH_OP_AWAIT_RESULT
269            | OAUTH_OP_GET_ACCESS_TOKEN
270            | OAUTH_OP_REQUEST_RESOURCE_TOKEN
271    )
272}
273
274#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
275pub struct CapabilityInstallRecord {
276    pub cap_id: String,
277    pub stable_id: String,
278    pub pack_id: String,
279    pub status: String,
280    pub config_state_keys: Vec<String>,
281    pub timestamp_unix_sec: u64,
282}
283
284impl CapabilityInstallRecord {
285    pub fn ready(cap_id: &str, stable_id: &str, pack_id: &str) -> Self {
286        Self {
287            cap_id: cap_id.to_string(),
288            stable_id: stable_id.to_string(),
289            pack_id: pack_id.to_string(),
290            status: "ready".to_string(),
291            config_state_keys: Vec::new(),
292            timestamp_unix_sec: now_unix_sec(),
293        }
294    }
295
296    pub fn failed(cap_id: &str, stable_id: &str, pack_id: &str, key: &str) -> Self {
297        Self {
298            cap_id: cap_id.to_string(),
299            stable_id: stable_id.to_string(),
300            pack_id: pack_id.to_string(),
301            status: "failed".to_string(),
302            config_state_keys: vec![key.to_string()],
303            timestamp_unix_sec: now_unix_sec(),
304        }
305    }
306}
307
308pub fn install_record_path(
309    bundle_root: &Path,
310    tenant: &str,
311    team: Option<&str>,
312    stable_id: &str,
313) -> PathBuf {
314    let team = team.unwrap_or("default");
315    bundle_root
316        .join("state")
317        .join("runtime")
318        .join(tenant)
319        .join(team)
320        .join("capabilities")
321        .join(format!("{stable_id}.install.json"))
322}
323
324pub fn write_install_record(
325    bundle_root: &Path,
326    tenant: &str,
327    team: Option<&str>,
328    record: &CapabilityInstallRecord,
329) -> anyhow::Result<PathBuf> {
330    let path = install_record_path(bundle_root, tenant, team, &record.stable_id);
331    if let Some(parent) = path.parent() {
332        std::fs::create_dir_all(parent)?;
333    }
334    let bytes = serde_json::to_vec_pretty(record)?;
335    std::fs::write(&path, bytes)?;
336    Ok(path)
337}
338
339pub fn read_install_record(
340    bundle_root: &Path,
341    tenant: &str,
342    team: Option<&str>,
343    stable_id: &str,
344) -> anyhow::Result<Option<CapabilityInstallRecord>> {
345    let path = install_record_path(bundle_root, tenant, team, stable_id);
346    if !path.exists() {
347        return Ok(None);
348    }
349    let bytes = std::fs::read(path)?;
350    let record: CapabilityInstallRecord = serde_json::from_slice(&bytes)?;
351    Ok(Some(record))
352}
353
354pub fn is_binding_ready(
355    bundle_root: &Path,
356    tenant: &str,
357    team: Option<&str>,
358    binding: &CapabilityBinding,
359) -> anyhow::Result<bool> {
360    if !binding.requires_setup {
361        return Ok(true);
362    }
363    let Some(record) = read_install_record(bundle_root, tenant, team, &binding.stable_id)? else {
364        return Ok(false);
365    };
366    Ok(record.status.eq_ignore_ascii_case("ready"))
367}
368
369fn read_capabilities_extension(path: &Path) -> anyhow::Result<Option<CapabilitiesExtensionV1>> {
370    let file = std::fs::File::open(path)?;
371    let mut archive = ZipArchive::new(file)?;
372    let mut manifest_entry = archive.by_name("manifest.cbor").map_err(|err| {
373        anyhow::anyhow!("failed to open manifest.cbor in {}: {err}", path.display())
374    })?;
375    let mut bytes = Vec::new();
376    manifest_entry.read_to_end(&mut bytes)?;
377    let manifest = decode_pack_manifest(&bytes)
378        .with_context(|| format!("failed to decode pack manifest in {}", path.display()))?;
379    let Some(extension) = manifest
380        .extensions
381        .as_ref()
382        .and_then(|extensions| extensions.get(EXT_CAPABILITIES_V1))
383    else {
384        return Ok(None);
385    };
386    let inline = extension
387        .inline
388        .as_ref()
389        .ok_or_else(|| anyhow::anyhow!("capabilities extension inline payload missing"))?;
390    let ExtensionInline::Other(value) = inline else {
391        anyhow::bail!("capabilities extension inline payload has unexpected type");
392    };
393    let decoded: CapabilitiesExtensionV1 = serde_json::from_value(value.clone())
394        .with_context(|| "failed to parse greentic.ext.capabilities.v1 payload")?;
395    if decoded.schema_version != 1 {
396        anyhow::bail!(
397            "unsupported capabilities extension schema_version={}",
398            decoded.schema_version
399        );
400    }
401    Ok(Some(decoded))
402}
403
404fn version_matches(version: &str, min_version: Option<&str>) -> bool {
405    match min_version {
406        None => true,
407        Some(requested) => version == requested,
408    }
409}
410
411fn scope_matches(offer_scope: &CapabilityScopeV1, scope: &ResolveScope) -> bool {
412    value_matches(&offer_scope.envs, scope.env.as_deref())
413        && value_matches(&offer_scope.tenants, scope.tenant.as_deref())
414        && value_matches(&offer_scope.teams, scope.team.as_deref())
415}
416
417fn op_matches(offer: &CapabilityOfferRecord, requested_op: Option<&str>) -> bool {
418    let Some(requested_op) = requested_op else {
419        return true;
420    };
421    if offer.applies_to_ops.is_empty() {
422        return true;
423    }
424    offer
425        .applies_to_ops
426        .iter()
427        .any(|entry| entry == requested_op)
428}
429
430fn value_matches(values: &[String], current: Option<&str>) -> bool {
431    if values.is_empty() {
432        return true;
433    }
434    let Some(current) = current else {
435        return false;
436    };
437    values.iter().any(|value| value == current)
438}
439
440#[derive(Debug, Deserialize)]
441struct CapabilitiesExtensionV1 {
442    #[serde(default = "default_schema_version")]
443    schema_version: u32,
444    #[serde(default)]
445    offers: Vec<CapabilityOfferV1>,
446}
447
448#[derive(Debug, Deserialize)]
449struct CapabilityOfferV1 {
450    #[serde(default)]
451    offer_id: Option<String>,
452    cap_id: String,
453    version: String,
454    provider: CapabilityProviderRefV1,
455    #[serde(default)]
456    scope: Option<CapabilityScopeV1>,
457    #[serde(default)]
458    priority: i32,
459    #[serde(default)]
460    requires_setup: bool,
461    #[serde(default)]
462    setup: Option<CapabilitySetupV1>,
463    #[serde(default)]
464    applies_to: Option<HookAppliesToV1>,
465}
466
467#[derive(Debug, Deserialize)]
468struct CapabilityProviderRefV1 {
469    component_ref: String,
470    op: String,
471}
472
473#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
474struct CapabilityScopeV1 {
475    #[serde(default)]
476    envs: Vec<String>,
477    #[serde(default)]
478    tenants: Vec<String>,
479    #[serde(default)]
480    teams: Vec<String>,
481}
482
483#[derive(Debug, Deserialize)]
484struct CapabilitySetupV1 {
485    qa_ref: String,
486}
487
488#[derive(Debug, Deserialize)]
489struct HookAppliesToV1 {
490    #[serde(default)]
491    op_names: Vec<String>,
492}
493
494const fn default_schema_version() -> u32 {
495    1
496}
497
498fn now_unix_sec() -> u64 {
499    SystemTime::now()
500        .duration_since(UNIX_EPOCH)
501        .map(|value| value.as_secs())
502        .unwrap_or(0)
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use greentic_types::{ExtensionRef, PackId, PackKind, PackManifest, PackSignatures};
509    use semver::Version;
510    use serde_json::json;
511    use std::fs::File;
512    use std::io::Write;
513    use std::path::Path;
514    use tempfile::tempdir;
515    use zip::ZipWriter;
516    use zip::write::FileOptions;
517
518    #[test]
519    fn scope_matching_accepts_unrestricted_scope() {
520        let offer_scope = CapabilityScopeV1::default();
521        let scope = ResolveScope::default();
522        assert!(scope_matches(&offer_scope, &scope));
523    }
524
525    #[test]
526    fn scope_matching_rejects_missing_restricted_value() {
527        let offer_scope = CapabilityScopeV1 {
528            envs: vec!["prod".to_string()],
529            tenants: Vec::new(),
530            teams: Vec::new(),
531        };
532        let scope = ResolveScope::default();
533        assert!(!scope_matches(&offer_scope, &scope));
534    }
535
536    #[test]
537    fn value_matching_handles_lists() {
538        assert!(value_matches(&[], None));
539        assert!(value_matches(&["demo".to_string()], Some("demo")));
540        assert!(!value_matches(&["demo".to_string()], Some("prod")));
541    }
542
543    #[test]
544    fn install_record_roundtrip() {
545        let tmp = tempdir().expect("tempdir");
546        let record =
547            CapabilityInstallRecord::ready("greentic.cap.test", "offer.test.01", "pack-test");
548        let path = write_install_record(tmp.path(), "tenant-a", Some("team-b"), &record)
549            .expect("write install record");
550        assert!(path.exists());
551        let loaded = read_install_record(tmp.path(), "tenant-a", Some("team-b"), "offer.test.01")
552            .expect("read install record")
553            .expect("record should exist");
554        assert_eq!(loaded.cap_id, record.cap_id);
555        assert_eq!(loaded.status, "ready");
556    }
557
558    #[test]
559    fn setup_required_binding_reports_not_ready_without_record() {
560        let tmp = tempdir().expect("tempdir");
561        let binding = CapabilityBinding {
562            cap_id: "greentic.cap.test".to_string(),
563            stable_id: "offer.setup.01".to_string(),
564            pack_id: "pack-test".to_string(),
565            domain: Domain::Messaging,
566            pack_path: tmp.path().join("dummy.gtpack"),
567            provider_component_ref: "component".to_string(),
568            provider_op: "invoke".to_string(),
569            version: "v1".to_string(),
570            requires_setup: true,
571            setup_qa_ref: Some("qa/setup.cbor".to_string()),
572        };
573        let ready = is_binding_ready(tmp.path(), "tenant-a", Some("team-b"), &binding)
574            .expect("ready check");
575        assert!(!ready);
576    }
577
578    #[test]
579    fn resolve_for_op_prefers_offer_with_matching_applies_to() {
580        let mut by_cap_id = BTreeMap::new();
581        by_cap_id.insert(
582            CAP_OAUTH_BROKER_V1.to_string(),
583            vec![
584                CapabilityOfferRecord {
585                    stable_id: "offer.a".to_string(),
586                    pack_id: "pack".to_string(),
587                    domain: Domain::Messaging,
588                    pack_path: PathBuf::from("/tmp/a.gtpack"),
589                    cap_id: CAP_OAUTH_BROKER_V1.to_string(),
590                    version: "v1".to_string(),
591                    provider_component_ref: "oauth".to_string(),
592                    provider_op: "provider.dispatch".to_string(),
593                    priority: 0,
594                    requires_setup: false,
595                    setup_qa_ref: None,
596                    scope: CapabilityScopeV1::default(),
597                    applies_to_ops: vec![OAUTH_OP_INITIATE_AUTH.to_string()],
598                },
599                CapabilityOfferRecord {
600                    stable_id: "offer.b".to_string(),
601                    pack_id: "pack".to_string(),
602                    domain: Domain::Messaging,
603                    pack_path: PathBuf::from("/tmp/b.gtpack"),
604                    cap_id: CAP_OAUTH_BROKER_V1.to_string(),
605                    version: "v1".to_string(),
606                    provider_component_ref: "oauth".to_string(),
607                    provider_op: "provider.await".to_string(),
608                    priority: 1,
609                    requires_setup: false,
610                    setup_qa_ref: None,
611                    scope: CapabilityScopeV1::default(),
612                    applies_to_ops: vec![OAUTH_OP_AWAIT_RESULT.to_string()],
613                },
614            ],
615        );
616        let registry = CapabilityRegistry { by_cap_id };
617        let scope = ResolveScope::default();
618        let resolved = registry
619            .resolve_for_op(
620                CAP_OAUTH_BROKER_V1,
621                None,
622                &scope,
623                Some(OAUTH_OP_AWAIT_RESULT),
624            )
625            .expect("should resolve");
626        assert_eq!(resolved.provider_op, "provider.await");
627    }
628
629    #[test]
630    fn oauth_broker_operation_whitelist_is_enforced() {
631        assert!(is_oauth_broker_operation(OAUTH_OP_INITIATE_AUTH));
632        assert!(is_oauth_broker_operation(OAUTH_OP_AWAIT_RESULT));
633        assert!(is_oauth_broker_operation(OAUTH_OP_GET_ACCESS_TOKEN));
634        assert!(is_oauth_broker_operation(OAUTH_OP_REQUEST_RESOURCE_TOKEN));
635        assert!(!is_oauth_broker_operation("oauth.unknown"));
636    }
637
638    #[test]
639    fn oauth_capability_offers_load_into_registry() {
640        let tmp = tempdir().expect("tempdir");
641        let pack_path = tmp.path().join("oauth-provider.gtpack");
642        write_gtpack_with_oauth_capabilities(&pack_path).expect("write pack");
643
644        let mut pack_index = BTreeMap::new();
645        pack_index.insert(
646            pack_path.clone(),
647            CapabilityPackRecord {
648                pack_id: "oauth.provider".to_string(),
649                domain: Domain::Messaging,
650            },
651        );
652        let registry = CapabilityRegistry::build_from_pack_index(&pack_index).expect("registry");
653
654        assert_eq!(
655            registry.offers_for_capability(CAP_OAUTH_BROKER_V1).len(),
656            1,
657            "oauth broker capability offer missing from registry"
658        );
659        assert_eq!(
660            registry
661                .offers_for_capability("greentic.cap.oauth.card.v1")
662                .len(),
663            1,
664            "oauth card capability offer missing from registry"
665        );
666        assert_eq!(
667            registry
668                .offers_for_capability("greentic.cap.oauth.token_validation.v1")
669                .len(),
670            1,
671            "oauth token_validation capability offer missing from registry"
672        );
673        assert_eq!(
674            registry
675                .offers_for_capability("greentic.cap.oauth.discovery.v1")
676                .len(),
677            1,
678            "oauth discovery capability offer missing from registry"
679        );
680    }
681
682    fn write_gtpack_with_oauth_capabilities(path: &Path) -> anyhow::Result<()> {
683        let mut extensions = BTreeMap::new();
684        extensions.insert(
685            EXT_CAPABILITIES_V1.to_string(),
686            ExtensionRef {
687                kind: EXT_CAPABILITIES_V1.to_string(),
688                version: "1.0.0".to_string(),
689                digest: None,
690                location: None,
691                inline: Some(greentic_types::ExtensionInline::Other(json!({
692                    "schema_version": 1,
693                    "offers": [
694                        {
695                            "offer_id": "oauth.broker.v1",
696                            "cap_id": CAP_OAUTH_BROKER_V1,
697                            "version": "v1",
698                            "provider": {"component_ref": "oauth.component", "op": "oauth.broker.dispatch"},
699                            "priority": 10,
700                            "requires_setup": true,
701                            "setup": {"qa_ref": "qa/oauth_broker.setup.json"}
702                        },
703                        {
704                            "offer_id": "oauth.card.v1",
705                            "cap_id": "greentic.cap.oauth.card.v1",
706                            "version": "v1",
707                            "provider": {"component_ref": "oauth.component", "op": "oauth.card.dispatch"},
708                            "priority": 20,
709                            "requires_setup": true,
710                            "setup": {"qa_ref": "qa/oauth_card.setup.json"}
711                        },
712                        {
713                            "offer_id": "oauth.token_validation.v1",
714                            "cap_id": "greentic.cap.oauth.token_validation.v1",
715                            "version": "v1",
716                            "provider": {"component_ref": "oauth.component", "op": "oauth.token_validation.dispatch"},
717                            "priority": 30,
718                            "requires_setup": true,
719                            "setup": {"qa_ref": "qa/oauth_token_validation.setup.json"}
720                        },
721                        {
722                            "offer_id": "oauth.discovery.v1",
723                            "cap_id": "greentic.cap.oauth.discovery.v1",
724                            "version": "v1",
725                            "provider": {"component_ref": "oauth.component", "op": "oauth.discovery.dispatch"},
726                            "priority": 40,
727                            "requires_setup": true,
728                            "setup": {"qa_ref": "qa/oauth_discovery.setup.json"}
729                        }
730                    ]
731                }))),
732            },
733        );
734
735        let manifest = PackManifest {
736            schema_version: "pack-v1".to_string(),
737            pack_id: PackId::new("oauth.provider").expect("pack id"),
738            name: None,
739            version: Version::parse("0.1.0").expect("version"),
740            kind: PackKind::Provider,
741            publisher: "demo".to_string(),
742            components: Vec::new(),
743            flows: Vec::new(),
744            dependencies: Vec::new(),
745            capabilities: Vec::new(),
746            secret_requirements: Vec::new(),
747            signatures: PackSignatures::default(),
748            bootstrap: None,
749            extensions: Some(extensions),
750        };
751
752        let bytes = greentic_types::encode_pack_manifest(&manifest)?;
753        let file = File::create(path)?;
754        let mut zip = ZipWriter::new(file);
755        zip.start_file("manifest.cbor", FileOptions::<()>::default())?;
756        zip.write_all(&bytes)?;
757        zip.finish()?;
758        Ok(())
759    }
760
761    // -- Messaging capability tests --
762
763    fn make_messaging_offer(
764        stable_id: &str,
765        provider_op: &str,
766        requires_setup: bool,
767        setup_qa_ref: Option<&str>,
768    ) -> CapabilityOfferRecord {
769        CapabilityOfferRecord {
770            stable_id: stable_id.to_string(),
771            pack_id: "messaging-telegram".to_string(),
772            domain: Domain::Messaging,
773            pack_path: PathBuf::from("dummy.gtpack"),
774            cap_id: CAP_MESSAGING_V1.to_string(),
775            version: "v1".to_string(),
776            provider_component_ref: "messaging-provider-telegram".to_string(),
777            provider_op: provider_op.to_string(),
778            priority: 100,
779            requires_setup,
780            setup_qa_ref: setup_qa_ref.map(String::from),
781            scope: CapabilityScopeV1::default(),
782            applies_to_ops: Vec::new(),
783        }
784    }
785
786    #[test]
787    fn validate_messaging_no_offers_returns_empty() {
788        let registry = CapabilityRegistry::default();
789        assert!(registry.validate_messaging_offers().is_empty());
790    }
791
792    #[test]
793    fn validate_messaging_standard_offer_no_warnings() {
794        let mut by_cap_id = BTreeMap::new();
795        by_cap_id.insert(
796            CAP_MESSAGING_V1.to_string(),
797            vec![make_messaging_offer(
798                "messaging-telegram-v1",
799                "messaging.configure",
800                true,
801                Some("setup.yaml"),
802            )],
803        );
804        let registry = CapabilityRegistry { by_cap_id };
805        assert!(registry.validate_messaging_offers().is_empty());
806    }
807
808    #[test]
809    fn validate_messaging_multiple_offers_is_ok() {
810        let mut by_cap_id = BTreeMap::new();
811        by_cap_id.insert(
812            CAP_MESSAGING_V1.to_string(),
813            vec![
814                make_messaging_offer(
815                    "offer-telegram",
816                    "messaging.configure",
817                    true,
818                    Some("setup.yaml"),
819                ),
820                make_messaging_offer(
821                    "offer-slack",
822                    "messaging.configure",
823                    true,
824                    Some("setup.yaml"),
825                ),
826            ],
827        );
828        let registry = CapabilityRegistry { by_cap_id };
829        assert!(registry.validate_messaging_offers().is_empty());
830    }
831
832    #[test]
833    fn validate_messaging_warns_on_non_standard_op() {
834        let mut by_cap_id = BTreeMap::new();
835        by_cap_id.insert(
836            CAP_MESSAGING_V1.to_string(),
837            vec![make_messaging_offer(
838                "offer-custom",
839                "custom.init",
840                false,
841                None,
842            )],
843        );
844        let registry = CapabilityRegistry { by_cap_id };
845        let warnings = registry.validate_messaging_offers();
846        assert!(
847            warnings.iter().any(|w| w.contains("non-standard op")),
848            "expected 'non-standard op' warning: {warnings:?}"
849        );
850    }
851
852    #[test]
853    fn validate_messaging_warns_on_missing_qa_ref() {
854        let mut by_cap_id = BTreeMap::new();
855        by_cap_id.insert(
856            CAP_MESSAGING_V1.to_string(),
857            vec![make_messaging_offer(
858                "offer-no-qa",
859                "messaging.configure",
860                true,
861                None,
862            )],
863        );
864        let registry = CapabilityRegistry { by_cap_id };
865        let warnings = registry.validate_messaging_offers();
866        assert!(
867            warnings.iter().any(|w| w.contains("no setup.qa_ref")),
868            "expected 'no setup.qa_ref' warning: {warnings:?}"
869        );
870    }
871}