Skip to main content

majordomus_cli/cockpit/
mod.rs

1//! The Cockpit: the human-facing projection of the registry, server-rendered.
2//!
3//! It is a projection and not an application. Every page is laid out from what a
4//! capability answered, through the same executor MCP and the HTTP routes call; the
5//! navigation's catalogues come from the registry, the index and the graph derivations;
6//! the runner's form comes from the input schema. Nothing here holds a list of
7//! capabilities, routes, kinds or graphs, and adding one to the registry adds it here.
8//!
9//! ```text
10//!            capability! ─── registry ────┬──── MCP tools and resources
11//!                    declarative objects ─┤──── HTTP routes ── OpenAPI ── Swagger UI
12//!                                         ├──── the command line
13//!                                         └──── Cockpit ── pages, navigation, runner, graphs
14//! ```
15//!
16//! The routes it serves are the projection's own, like `/swagger` and `/openapi.json`: they
17//! are not capabilities, they declare no schema, and they are listed as infrastructure in
18//! the OpenAPI document rather than as operations.
19//!
20//! What the browser adds is an enhancement in the strict sense. The pages are complete
21//! HTML; the scripts add a command palette, a runner, a graph drawing and two optional
22//! visual views. With no JavaScript at all, every page still shows everything it knows.
23
24pub mod assets;
25pub mod html;
26pub(crate) mod intents;
27pub(crate) mod mcp;
28pub(crate) mod monitor;
29pub(crate) mod nav;
30pub(crate) mod pages;
31pub(crate) mod plan;
32pub(crate) mod reasoning;
33pub(crate) mod trains;
34pub(crate) mod view;
35
36use std::sync::Arc;
37
38use crate::capability::Context;
39use crate::http::router::{percent_decode, Request, Response};
40use crate::live::{IntoLive, Live};
41
42use assets::Assets;
43
44/// Every Cockpit route starts here.
45pub const PREFIX: &str = "/cockpit";
46
47/// The Cockpit's identity in the web topology (`web.surfaces`).
48pub const SURFACE: &str = "cockpit";
49
50/// The stylesheet the shell links.
51pub const STYLESHEET: &str = "cockpit.css";
52
53/// The script modules every page loads. `cockpit.js` is not among them: the palette
54/// imports it, so it is in the graph of every page anyway. Every module that imports it
55/// names it by the one digested URL the server rewrites the import to (`assets.rs`), so a
56/// `<script>` tag here would add nothing but a second request.
57///
58/// A page adds its own module to this list, which is what keeps a graph library off the
59/// overview.
60pub const SHELL_SCRIPTS: &[&str] = &["palette.js"];
61
62/// The site's own scripts the bar at the top of every page behaves by — its menus, then its
63/// theme toggle — served as byte copies of `site/nav.js` and `site/theme.js` that
64/// `scripts/cockpit-assets` writes and checks (`project.the-ui-wears-the-sites-navbar`).
65pub(crate) const BAR_SCRIPTS: &[&str] = &["nav.js", "theme.js"];
66
67/// The vendored libraries the page modules load at run time, through `vendor()` and
68/// `vendorModule()` in `cockpit.js`. A run-time URL is not a static import the server can
69/// rewrite, so the page publishes each one's digested URL on its body (`data-vendor`), the
70/// way the UI hands its graph library over in `data-graph-lib`; a file not there is left
71/// out, and the script falls back to its plain path. A library a module imports itself
72/// (`three.core.min.js`, imported by `three.module.min.js`) is versioned by that import.
73pub(crate) const VENDOR: &[&str] = &[
74    "alpine.csp.min.js",
75    "cytoscape.min.js",
76    "three.module.min.js",
77    "p5.min.js",
78];
79
80/// The Cockpit over the repository as it is now. Cheap to clone into every worker thread:
81/// the assets are shared and the view of the repository is an `Arc`.
82pub struct Cockpit {
83    live: Arc<Live>,
84    version: &'static str,
85    assets: Assets,
86}
87
88/// Every route the dispatcher answers without an identifier in the path, and the area each
89/// one belongs to — `""` for a page that belongs to none, which the sidebar does not carry.
90///
91/// Written beside the `match` that answers them rather than derived from it, because a
92/// `match` over string literals is not data a program can read back at run time. What keeps
93/// the two honest is that both are compared, in each direction:
94/// `every_page_renders_complete_html_with_the_shell_and_the_security_headers` drives this
95/// list over a socket, so a route the dispatcher lost is a failure;
96/// `every_plain_arm_of_the_dispatcher_is_a_static_route` reads the `match` from this file's
97/// source, so an arm added there and not here is a failure; and
98/// `every_area_has_a_route_and_every_route_its_area` compares it against the navigation, so
99/// a page nothing links to is a failure too. Integration, Reasoning and Economics were the
100/// second kind: each arm and area landed after this list was written, and nothing compared
101/// the list with the `match` it describes. `/cockpit/quality` was exactly that page: the
102/// dispatcher answered it, the navigation did not name it, and no test and no browser sweep
103/// could reach it, for as long as the two lists were kept by hand and never compared.
104pub const STATIC_ROUTES: &[(&str, &str)] = &[
105    (PREFIX, "overview"),
106    ("/cockpit/capabilities", "capabilities"),
107    ("/cockpit/commands", "commands"),
108    ("/cockpit/objects", "objects"),
109    ("/cockpit/object", ""),
110    ("/cockpit/executions", "executions"),
111    ("/cockpit/graphs", "graphs"),
112    ("/cockpit/graphs/topology", ""),
113    ("/cockpit/continuity", "continuity"),
114    ("/cockpit/intents", "intents"),
115    ("/cockpit/plan", "plan"),
116    ("/cockpit/worktrees", "worktrees"),
117    ("/cockpit/peers", "peers"),
118    ("/cockpit/integration", "integration"),
119    ("/cockpit/trains", "trains"),
120    ("/cockpit/mesh", "mesh"),
121    ("/cockpit/monitor", ""),
122    ("/cockpit/models", "models"),
123    ("/cockpit/reasoning", "reasoning"),
124    ("/cockpit/economics", "economics"),
125    ("/cockpit/directories", "directories"),
126    ("/cockpit/health", "health"),
127    ("/cockpit/quality", "quality"),
128    ("/cockpit/artifacts", "artifacts"),
129    ("/cockpit/release", "release"),
130    ("/cockpit/design", "design"),
131    ("/cockpit/api", "api"),
132    ("/cockpit/mcp", "mcp"),
133    ("/cockpit/search", ""),
134    ("/cockpit/activity", ""),
135];
136
137impl Cockpit {
138    /// A Cockpit over a view of the repository, serving its assets from `share_dir/cockpit`.
139    pub fn new(
140        live: impl IntoLive,
141        version: &'static str,
142        share_dir: Option<&std::path::Path>,
143    ) -> Self {
144        let assets = match share_dir {
145            Some(dir) => Assets::new(dir),
146            None => Assets::none(),
147        };
148        if !assets.present() {
149            tracing::warn!(
150                "the distribution has no share/cockpit/ directory: the Cockpit will serve unstyled markup; run `just cockpit-assets` to build it"
151            );
152        }
153        Cockpit {
154            live: live.into_live(),
155            version,
156            assets,
157        }
158    }
159
160    /// Does this request belong to the Cockpit?
161    ///
162    /// ```
163    /// use majordomus_cli::cockpit::Cockpit;
164    /// assert!(Cockpit::owns("/cockpit"));
165    /// assert!(Cockpit::owns("/cockpit/capabilities"));
166    /// assert!(!Cockpit::owns("/cockpitx"));
167    /// assert!(!Cockpit::owns("/api/v1/objects"));
168    /// ```
169    pub fn owns(path: &str) -> bool {
170        path == PREFIX || path.starts_with(&format!("{PREFIX}/"))
171    }
172
173    /// Answer one request under [`PREFIX`].
174    pub fn handle(&self, req: &Request) -> Response {
175        if req.method != "GET" {
176            return Response::error(
177                405,
178                "method_not_allowed",
179                "the Cockpit's pages are read with GET; a capability that changes something is called on its own route",
180            );
181        }
182        if let Some(name) = req.path.strip_prefix(assets::PREFIX) {
183            return self.assets.respond(name, req);
184        }
185
186        // One reading of the repository for the whole page: the navigation and the page
187        // itself must be two views of one generation, never two generations side by side.
188        let ctx = self.live.current();
189        let page = self.route(&ctx, req);
190        let navigation = nav::build(&ctx, &req.path);
191        let scripts = SHELL_SCRIPTS
192            .iter()
193            .copied()
194            .chain(page.scripts.iter().copied())
195            .map(|name| self.assets.url(name))
196            .collect();
197        let shell = view::Shell {
198            title: &page.title,
199            subtitle: page.subtitle.clone(),
200            area: page.area,
201            breadcrumbs: page.breadcrumbs.clone(),
202            navigation: &navigation,
203            stylesheet: self.assets.url(STYLESHEET),
204            favicon: self.assets.url("favicon.svg"),
205            scripts,
206            bar_scripts: BAR_SCRIPTS.iter().map(|s| self.assets.url(s)).collect(),
207            vendor: self.vendor_urls(),
208            assets_present: self.assets.present(),
209            version: self.version,
210        };
211        let body = view::page(&shell, page.main);
212        let mut response = Response::new(page.status, "text/html; charset=utf-8", body);
213        // a page is derived from the registry and the index, which follow the repository
214        // as it moves under this process: revalidation is right, and the assets carry the
215        // long cache because their URLs carry their digests
216        response
217            .headers
218            .push(("Cache-Control".into(), "no-cache".into()));
219        response
220            .headers
221            .push(("Content-Security-Policy".into(), csp().into()));
222        response
223            .headers
224            .push(("X-Content-Type-Options".into(), "nosniff".into()));
225        response
226            .headers
227            .push(("Referrer-Policy".into(), "no-referrer".into()));
228        response
229    }
230
231    /// The digested URL of every vendored library that is there, by the file name
232    /// `vendor()` is called with, as the JSON object the page publishes in `data-vendor`.
233    fn vendor_urls(&self) -> String {
234        let urls: std::collections::BTreeMap<&str, String> = VENDOR
235            .iter()
236            .map(|file| (*file, format!("vendor/{file}")))
237            .filter(|(_, path)| self.assets.has(path))
238            .map(|(file, path)| (file, self.assets.url(&path)))
239            .collect();
240        serde_json::to_string(&urls).unwrap_or_else(|_| "{}".into())
241    }
242
243    fn route(&self, ctx: &Arc<Context>, req: &Request) -> pages::Page {
244        let path = req.path.trim_end_matches('/');
245        let query = &req.query;
246        match path {
247            "" | PREFIX => pages::overview(ctx),
248            "/cockpit/capabilities" => pages::capabilities(ctx, query),
249            "/cockpit/commands" => pages::commands(ctx, query),
250            "/cockpit/objects" => pages::objects(ctx, query),
251            // the address an object was reachable at before it had one of its own; kept
252            // so that a link written against it still lands on the entity it named
253            "/cockpit/object" => match query.iter().find(|(k, _)| k == "uri") {
254                Some((_, uri)) => pages::object(ctx, uri),
255                None => pages::objects(ctx, query),
256            },
257            "/cockpit/executions" => pages::executions(ctx, query),
258            "/cockpit/graphs" => pages::graphs(ctx),
259            "/cockpit/graphs/topology" => pages::topology(ctx),
260            "/cockpit/continuity" => pages::continuity(ctx),
261            "/cockpit/intents" => intents::list(ctx),
262            "/cockpit/plan" => plan::plan(ctx, query),
263            "/cockpit/worktrees" => pages::worktrees(ctx),
264            "/cockpit/peers" => pages::peers(ctx),
265            "/cockpit/integration" => pages::integration(ctx),
266            "/cockpit/trains" => trains::page(ctx),
267            "/cockpit/mesh" => pages::mesh(ctx),
268            "/cockpit/monitor" => monitor::page(ctx, query),
269            "/cockpit/models" => pages::models(ctx),
270            "/cockpit/reasoning" => reasoning::page(ctx),
271            "/cockpit/economics" => pages::economics(ctx),
272            "/cockpit/directories" => pages::directories(ctx, query),
273            "/cockpit/health" => pages::health(ctx),
274            "/cockpit/quality" => pages::quality(ctx),
275            "/cockpit/artifacts" => pages::artifacts(ctx),
276            "/cockpit/release" => pages::release(ctx),
277            "/cockpit/design" => pages::design(ctx),
278            "/cockpit/api" => pages::api(ctx),
279            "/cockpit/mcp" => mcp::page(ctx, query),
280            "/cockpit/search" => pages::search(ctx, query),
281            "/cockpit/activity" => pages::activity(ctx),
282            other => {
283                if let Some(id) = other.strip_prefix("/cockpit/executions/") {
284                    pages::execution(ctx, &percent_decode(id))
285                } else if let Some(id) = other.strip_prefix("/cockpit/capabilities/") {
286                    pages::capability(ctx, &percent_decode(id))
287                } else if let Some(id) = other.strip_prefix("/cockpit/commands/") {
288                    pages::command(ctx, &percent_decode(id))
289                } else if let Some(id) = other.strip_prefix("/cockpit/plan/milestones/") {
290                    plan::milestone(ctx, &percent_decode(id))
291                } else if let Some(id) = other.strip_prefix("/cockpit/plan/issues/") {
292                    plan::issue(ctx, &percent_decode(id))
293                } else if let Some(id) = other.strip_prefix("/cockpit/intents/") {
294                    intents::intent(ctx, &percent_decode(id))
295                } else if let Some(id) = other.strip_prefix("/cockpit/graphs/") {
296                    pages::graph(ctx, &percent_decode(id), query)
297                } else if let Some(rest) = other.strip_prefix("/cockpit/objects/") {
298                    // one kind per segment, one entity per two. No kind and no entity is
299                    // named here: both are read from the index, which is what makes adding
300                    // an object add its page.
301                    let rest = percent_decode(rest);
302                    match rest.split_once('/') {
303                        Some((kind, slug)) => pages::entity(ctx, kind, slug),
304                        None => pages::objects_of_kind(ctx, &rest, query),
305                    }
306                } else {
307                    pages::not_found(other)
308                }
309            }
310        }
311    }
312}
313
314/// The content-security policy every page carries. Scripts come from this origin and from
315/// nowhere else; the one inline script is allowed by the digest of its own bytes, so an
316/// injected inline script is refused even if one ever got through the escaping. No
317/// `unsafe-eval`, no remote origin, and nothing may frame the page.
318///
319/// `style-src` allows `unsafe-inline` and `script-src` does not, which is the one asymmetry
320/// here and it is deliberate. A drawing library sets `style` attributes on the elements it
321/// creates — that is how Cytoscape sizes its canvas — and a policy that forbids them makes
322/// the optional views silently misrender rather than fail. The exposure is a style
323/// injection, which needs the escaping to have already failed and which cannot execute; the
324/// exposure `script-src 'unsafe-inline'` would carry is arbitrary code, and that stays shut.
325///
326/// The digest is computed from `view::theme_bootstrap()` itself, so the policy cannot
327/// drift from the script it allows.
328///
329/// ```
330/// let policy = majordomus_cli::cockpit::csp();
331/// assert!(policy.starts_with("default-src 'none'"));
332/// assert!(!policy.contains("unsafe-eval"));
333/// ```
334pub fn csp() -> &'static str {
335    static POLICY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
336    POLICY.get_or_init(|| {
337        use sha2::{Digest, Sha256};
338        let mut hasher = Sha256::new();
339        hasher.update(view::theme_bootstrap().as_bytes());
340        let digest = base64(&hasher.finalize());
341        format!(
342            "default-src 'none'; script-src 'self' 'sha256-{digest}'; \
343             style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; \
344             font-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
345        )
346    })
347}
348
349/// Standard base64, for the one digest the policy names. Four characters per three bytes,
350/// padded; no dependency for twenty lines.
351///
352/// ```
353/// use majordomus_cli::cockpit::base64;
354/// assert_eq!(base64(b""), "");
355/// assert_eq!(base64(b"f"), "Zg==");
356/// assert_eq!(base64(b"fo"), "Zm8=");
357/// assert_eq!(base64(b"foo"), "Zm9v");
358/// assert_eq!(base64(b"foobar"), "Zm9vYmFy");
359/// ```
360pub fn base64(bytes: &[u8]) -> String {
361    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
362    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
363    for chunk in bytes.chunks(3) {
364        let n = ((chunk[0] as u32) << 16)
365            | ((*chunk.get(1).unwrap_or(&0) as u32) << 8)
366            | *chunk.get(2).unwrap_or(&0) as u32;
367        out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
368        out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
369        out.push(if chunk.len() > 1 {
370            ALPHABET[(n >> 6 & 63) as usize] as char
371        } else {
372            '='
373        });
374        out.push(if chunk.len() > 2 {
375            ALPHABET[(n & 63) as usize] as char
376        } else {
377            '='
378        });
379    }
380    out
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    /// Every library a script loads at run time is one the page publishes a digested URL
388    /// for: a `vendor('x')` that `VENDOR` does not name would still load, from its plain
389    /// path, and be downloaded again on every navigation without anything saying so.
390    #[test]
391    fn every_vendored_library_a_script_loads_is_published_with_its_digest() {
392        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share/cockpit");
393        let mut named = Vec::new();
394        for entry in std::fs::read_dir(&dir).expect("share/cockpit") {
395            let path = entry.expect("an entry").path();
396            if path.extension().is_none_or(|e| e != "js") {
397                continue;
398            }
399            let source = std::fs::read_to_string(&path).expect("a script");
400            for call in ["vendor('", "vendorModule('"] {
401                for rest in source.split(call).skip(1) {
402                    let file = rest.split('\'').next().unwrap_or_default();
403                    named.push((path.display().to_string(), file.to_string()));
404                }
405            }
406        }
407        assert!(
408            named.iter().any(|(_, f)| f == "alpine.csp.min.js"),
409            "the scan found the shell's own Alpine load: {named:?}"
410        );
411        for (script, file) in &named {
412            assert!(
413                VENDOR.contains(&file.as_str()),
414                "{script} loads {file}, which VENDOR does not name"
415            );
416        }
417    }
418
419    #[test]
420    fn the_policy_allows_no_remote_origin_and_no_unsafe_source() {
421        let policy = csp();
422        assert!(policy.contains("script-src 'self' 'sha256-"), "{policy}");
423        assert!(!policy.contains("unsafe-eval"), "{policy}");
424        // the asymmetry, asserted rather than assumed: styles may be inline, scripts never
425        assert!(
426            policy.contains("style-src 'self' 'unsafe-inline'"),
427            "{policy}"
428        );
429        let script_src = policy
430            .split("script-src ")
431            .nth(1)
432            .and_then(|s| s.split(';').next())
433            .expect("a script-src directive");
434        assert!(!script_src.contains("unsafe-inline"), "{script_src}");
435        assert!(!policy.contains("http"), "{policy}");
436        assert!(policy.contains("frame-ancestors 'none'"), "{policy}");
437    }
438
439    #[test]
440    fn the_policy_names_the_digest_of_the_script_it_allows() {
441        use sha2::{Digest, Sha256};
442        let mut h = Sha256::new();
443        h.update(view::theme_bootstrap().as_bytes());
444        let expected = base64(&h.finalize());
445        assert!(
446            csp().contains(&format!("'sha256-{expected}'")),
447            "the policy names the digest of the bootstrap it ships"
448        );
449        assert_eq!(expected.len(), 44, "base64 of 32 bytes");
450    }
451
452    /// A page the dispatcher answers and the navigation does not name is a page a reader
453    /// cannot reach, a crawl cannot find and therefore no test covers. The reverse — an area
454    /// the sidebar offers and no route answers — is a link to nothing. Both are failures.
455    #[test]
456    fn every_area_has_a_route_and_every_route_its_area() {
457        let areas = nav::areas();
458        let missing_route: Vec<&str> = areas
459            .iter()
460            .filter(|a| !STATIC_ROUTES.iter().any(|(path, _)| *path == a.href))
461            .map(|a| a.href)
462            .collect();
463        assert!(
464            missing_route.is_empty(),
465            "the navigation offers areas no route answers: {missing_route:?}"
466        );
467
468        let unnavigable: Vec<&str> = STATIC_ROUTES
469            .iter()
470            .filter(|(_, area)| !area.is_empty())
471            .filter(|(_, area)| !areas.iter().any(|a| a.id == *area))
472            .map(|(path, _)| *path)
473            .collect();
474        assert!(
475            unnavigable.is_empty(),
476            "the dispatcher answers routes the navigation does not name, so nothing links to \
477             them and no crawl reaches them: {unnavigable:?}"
478        );
479    }
480
481    /// `STATIC_ROUTES` is a claim about the `match` in `Cockpit::route`, and a claim checked
482    /// only from its own side drifts: three arms were added to the dispatcher after the list
483    /// was written, and every test that read the list went on passing. This reads the arms
484    /// from the source the dispatcher is compiled from and compares both directions.
485    #[test]
486    fn every_plain_arm_of_the_dispatcher_is_a_static_route() {
487        let src = include_str!("mod.rs");
488        let start = src
489            .find("fn route(&self")
490            .expect("cockpit/mod.rs carries the dispatcher as fn route");
491        let body = &src[start..];
492        let end = body
493            .find("other =>")
494            .expect("the dispatcher ends its plain arms with a catch-all");
495        let mut arms: Vec<&str> = body[..end]
496            .lines()
497            .map(str::trim_start)
498            .filter_map(|line| line.strip_prefix('"'))
499            .filter_map(|line| line.split_once("\" =>").map(|(path, _)| path))
500            .filter(|path| path.starts_with("/cockpit"))
501            .collect();
502        if body[..end].contains("\"\" | PREFIX =>") {
503            arms.push(PREFIX);
504        }
505        assert!(
506            arms.len() > 10,
507            "the dispatcher's arms were not found, so nothing was compared: {arms:?}"
508        );
509
510        let unlisted: Vec<&str> = arms
511            .iter()
512            .copied()
513            .filter(|arm| !STATIC_ROUTES.iter().any(|(path, _)| path == arm))
514            .collect();
515        assert!(
516            unlisted.is_empty(),
517            "the dispatcher answers plain routes STATIC_ROUTES does not list: {unlisted:?}"
518        );
519        let unanswered: Vec<&str> = STATIC_ROUTES
520            .iter()
521            .map(|(path, _)| *path)
522            .filter(|path| !arms.contains(path))
523            .collect();
524        assert!(
525            unanswered.is_empty(),
526            "STATIC_ROUTES lists routes the dispatcher has no arm for: {unanswered:?}"
527        );
528    }
529
530    /// The public route table is a projection of the dispatcher too. docs/COCKPIT.md was
531    /// written by hand and fell eight routes behind, so a reader of the documentation could
532    /// not learn that Commands, Mesh, Models, Directories, Quality, Artifacts, Release or
533    /// Design existed. Every plain route the dispatcher answers has a row, and the table
534    /// names no plain route the dispatcher does not answer.
535    #[test]
536    fn the_documented_route_table_is_the_dispatchers() {
537        let doc = include_str!("../../../../docs/COCKPIT.md");
538        let start = doc
539            .find("## What is on it")
540            .expect("docs/COCKPIT.md carries the route table under 'What is on it'");
541        let section = &doc[start..];
542        let end = section[3..].find("\n## ").map_or(section.len(), |i| i + 3);
543        let documented: Vec<&str> = section[..end]
544            .lines()
545            .filter_map(|line| line.strip_prefix("| `"))
546            .filter_map(|line| line.split('`').next())
547            .filter(|route| !route.contains('<'))
548            .map(|route| route.split('?').next().unwrap_or(route))
549            .collect();
550
551        let undocumented: Vec<&str> = STATIC_ROUTES
552            .iter()
553            .map(|(path, _)| *path)
554            .filter(|path| !documented.contains(path))
555            .collect();
556        assert!(
557            undocumented.is_empty(),
558            "docs/COCKPIT.md has no row for routes the dispatcher answers: {undocumented:?}"
559        );
560
561        let unrouted: Vec<&str> = documented
562            .iter()
563            .copied()
564            .filter(|route| route.starts_with("/cockpit") && !route.starts_with("/cockpit/assets"))
565            .filter(|route| !STATIC_ROUTES.iter().any(|(path, _)| path == route))
566            .collect();
567        assert!(
568            unrouted.is_empty(),
569            "docs/COCKPIT.md documents routes the dispatcher does not answer: {unrouted:?}"
570        );
571    }
572}