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 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 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}