Skip to main content

hennery_kernel/
capabilities.rs

1//! `GET /api/capabilities` (kernel spec §8; plan 4b): which mode this
2//! collector runs in, and which features it has switched on. The web UI
3//! shows the views of its mode (frontend spec §2) and hides a screen whose
4//! feature is off.
5//!
6//! Behind the session cookie: the shell asks for it first, so a 401 here is
7//! what sends a signed-out browser to the login screen. The login and setup
8//! screens never ask, so they render in either mode.
9
10use crate::operator::Operator;
11use axum::routing::get;
12use axum::{Json, Router};
13use hennery_proto::rest::{CapabilitiesResponse, DeploymentMode};
14use std::sync::Arc;
15
16/// The one place the mode is decided. Always the whole cockpit until the
17/// standalone gateway (plan 8g) adds its switch here.
18pub fn mode() -> DeploymentMode {
19    DeploymentMode::Full
20}
21
22/// The one place features are switched on: each gateway part adds its name
23/// when its API lands (`mcp_connections`, `mcp_stdio`, `mcp_oauth`,
24/// `mcp_clients`). Plan 8a's connections API (`/api/mcp/connections`) is
25/// served, alongside stdio servers and plan 8f's OAuth (`…/authorize`, the callback, the
26/// pre-registered client, the probe).
27pub fn features() -> Vec<String> {
28    vec![
29        "mcp_connections".to_owned(),
30        "mcp_stdio".to_owned(),
31        "mcp_oauth".to_owned(),
32        "web_push".to_owned(),
33        "session_defaults".to_owned(),
34    ]
35}
36
37/// What `GET /api/capabilities` answers.
38pub fn current() -> CapabilitiesResponse {
39    CapabilitiesResponse {
40        mode: mode(),
41        features: features(),
42        collector_version: env!("CARGO_PKG_VERSION").to_owned(),
43        source_commit: source_commit(option_env!("HENNERY_BUILD_SOURCE_SHA")),
44    }
45}
46
47fn source_commit(recorded: Option<&str>) -> Option<String> {
48    recorded
49        .filter(|sha| matches!(sha.len(), 40 | 64) && sha.bytes().all(|b| b.is_ascii_hexdigit()))
50        .map(str::to_owned)
51}
52
53/// The route, behind the browser rules and the session cookie.
54pub fn router(operator: Arc<Operator>) -> Router {
55    crate::auth::operator_only(
56        Router::new().route("/api/capabilities", get(|| async { Json(current()) })),
57        operator,
58    )
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn the_collector_is_the_whole_cockpit_with_the_gateways_connections() {
67        let mut body = serde_json::to_value(current()).unwrap();
68        let source = body.as_object_mut().unwrap().remove("source_commit").unwrap();
69        assert!(
70            source.is_null()
71                || source.as_str().is_some_and(|sha| {
72                    matches!(sha.len(), 40 | 64) && sha.bytes().all(|byte| byte.is_ascii_hexdigit())
73                })
74        );
75        assert_eq!(
76            body,
77            serde_json::json!({"mode": "full", "features": ["mcp_connections", "mcp_stdio", "mcp_oauth", "web_push", "session_defaults"],
78                "collector_version": env!("CARGO_PKG_VERSION")})
79        );
80    }
81
82    /// The other mode, as the web UI reads it (`lib/views.ts`).
83    #[test]
84    fn the_gateway_mode_is_named_gateway() {
85        assert_eq!(
86            serde_json::to_value(DeploymentMode::Gateway).unwrap(),
87            serde_json::json!("gateway")
88        );
89    }
90
91    #[test]
92    fn source_identity_is_only_a_recorded_commit() {
93        let sha = "abcdef1234567890abcdef1234567890abcdef12";
94        assert_eq!(source_commit(Some(sha)).as_deref(), Some(sha));
95        let sha256 = "a".repeat(64);
96        assert_eq!(source_commit(Some(&sha256)).as_deref(), Some(sha256.as_str()));
97        for value in [
98            None,
99            Some(""),
100            Some("unrecorded (development build)"),
101            Some("unrecorded (dirty source)"),
102            Some("/source/checkout"),
103            Some("not-a-commit00000000000000000000000000000"),
104        ] {
105            assert_eq!(source_commit(value), None);
106        }
107    }
108}