majordomus_cli/capability/builtin/
web.rs1use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::capability::handler::{CapabilityError, Context};
27use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
28use crate::capability::module::ModuleDescriptor;
29use crate::capability::CachePolicy;
30use crate::web::validate::{self, Artifacts, Finding};
31use crate::web::{Surface, Visibility};
32use crate::{capability, module};
33
34use super::{get, Empty};
35
36pub const SURFACES_URI: &str = "majordomus://web";
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41pub struct SurfaceReport {
42 pub surfaces: Vec<Surface>,
45 pub served: Vec<String>,
48 pub published: Vec<String>,
50 pub public: Vec<String>,
52 pub findings: Vec<Finding>,
55}
56
57fn surfaces(ctx: &Context, _: Empty) -> Result<SurfaceReport, CapabilityError> {
58 Ok(report(
59 &ctx.web,
60 std::path::Path::new(&ctx.index.repository.root),
61 ))
62}
63
64fn report(topology: &crate::web::Topology, root: &std::path::Path) -> SurfaceReport {
70 let ids = |take: &dyn Fn(&Surface) -> bool| -> Vec<String> {
71 topology
72 .surfaces
73 .iter()
74 .filter(|s| take(s))
75 .map(|s| s.id.clone())
76 .collect()
77 };
78 SurfaceReport {
79 served: ids(&|s| s.availability.is_served()),
80 published: ids(&|s| s.publishes()),
81 public: ids(&|s| s.visibility == Visibility::Public),
82 findings: validate::validate(topology, root, Artifacts::Ignore),
83 surfaces: topology.surfaces.clone(),
84 }
85}
86
87pub fn module() -> ModuleDescriptor {
89 module! {
90 id: "web",
91 title: "Web surfaces",
92 description: "What this repository exposes over HTTP, resolved from the producers that make it rather than from a register anybody maintains: the routes the executable answers itself, the documentation build, and every generated report that declared its own mount. The same resolution serves the router, renders the home page and composes a publication.",
93 stability: Stability::BehaviorallyVerified,
94 capabilities: [
95 capability! {
96 id: "web.surfaces",
97 title: "Every web surface, resolved",
98 description: "The web topology in route-precedence order, with each surface's mount, category, visibility, kind, producer, artifact, runtime feature and the provenance of every value a reader could be surprised by; and which ids are served, published and offered to a person. Answered from the resolution this process serves from, so it cannot disagree with what the router routes or what the home page lists.",
99 input: Empty,
100 output: SurfaceReport,
101 stability: Stability::BehaviorallyVerified,
102 exposure: Exposure {
103 mcp: Some(McpExposure {
104 tool: Some("majordomus_web_surfaces".into()),
105 resource: Some(McpResource { uri: SURFACES_URI.into(), name: "web".into() }),
106 }),
107 http: get("/api/v1/web/surfaces"),
108 cli: None,
109 },
110 tags: ["web", "introspection"],
111 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: Some(5) },
112 handler: surfaces,
113 },
114 ],
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::web::discover::{self, Runtime};
122 use crate::web::Topology;
123
124 #[test]
125 fn the_report_is_three_readings_of_one_topology_and_invents_nothing() {
126 let mut surfaces = discover::native_all();
127 surfaces.extend(discover::application(std::path::Path::new("/nonexistent")));
128 let topology = Topology::new(surfaces);
129 let report = report(&topology, std::path::Path::new("/nonexistent"));
130
131 assert_eq!(report.surfaces.len(), topology.surfaces.len());
132 for id in report
134 .served
135 .iter()
136 .chain(&report.published)
137 .chain(&report.public)
138 {
139 assert!(
140 topology.get(id).is_some(),
141 "{id} is in a list and not in the topology"
142 );
143 }
144 assert!(report.served.contains(&"swagger".to_string()));
145 assert!(report.public.contains(&"swagger".to_string()));
146 assert!(report.served.contains(&"mcp".to_string()));
148 assert!(!report.public.contains(&"mcp".to_string()));
149 assert!(report.findings.is_empty(), "{:?}", report.findings);
150 }
151
152 #[test]
153 fn a_narrowed_topology_answers_for_the_process_that_narrowed_it() {
154 let bare = Topology::new(discover::native(Runtime::default()));
155 let report = report(&bare, std::path::Path::new("/nonexistent"));
156 assert!(!report.served.contains(&"cockpit".to_string()));
157 assert!(!report.served.contains(&"mcp".to_string()));
158 assert!(report.published.is_empty(), "a process publishes nothing");
159 }
160}