Skip to main content

hennery_kernel/
web.rs

1//! The web UI, embedded at build time (`build.rs`; kernel spec §7): the
2//! router's fallback, so it answers only paths no route claims.
3//!
4//! - `/api` and `/api/…`, and `/mcp/…` (the gateway's proxy, umbrella §8):
5//!   a JSON 404, whatever the method. Never the app.
6//! - Any method but `GET` and `HEAD`: a JSON 404.
7//! - A file of the build: the file. Hashed files (`/assets/…`) are cached
8//!   for good; the rest revalidate with their ETag.
9//! - A missing file under `/assets/`: a plain 404, so a stale chunk fails
10//!   rather than running the page as a script.
11//! - Anything else: `index.html`, whose router shows the route.
12//!
13//! The page holds no data, so the fallback sits outside the browser rules
14//! and the cookie: a link opened from another site loads the app, and the
15//! app's API calls are where those rules apply. Every answer is
16//! `nosniff`; every file and page is `no-referrer`, `/index.html` asked for
17//! by name included, and `/setup`'s `no-store` (kernel spec §3.1). The `Content-Security-Policy` comes from
18//! `csp::on_html` over the whole router.
19
20use crate::auth_api::error;
21use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header};
22use axum::response::{IntoResponse, Response};
23
24/// One file of the build, as `build.rs` embeds it.
25struct Asset {
26    path: &'static str,
27    body: &'static [u8],
28    content_type: &'static str,
29    etag: &'static str,
30}
31
32include!(concat!(env!("OUT_DIR"), "/web_assets.rs"));
33
34/// How long a hashed file may be cached: its name changes with its bytes.
35const IMMUTABLE: &str = "public, max-age=31536000, immutable";
36
37/// The router's fallback.
38pub async fn serve(method: Method, uri: Uri, headers: HeaderMap) -> Response {
39    serve_assets(method, uri, headers, FILES)
40}
41
42fn serve_assets(method: Method, uri: Uri, headers: HeaderMap, assets: &'static [Asset]) -> Response {
43    let find = |path: &str| assets.iter().find(|a| a.path == path);
44    let path = uri.path();
45    if is_reserved(path) || (method != Method::GET && method != Method::HEAD) {
46        let mut response = error(StatusCode::NOT_FOUND, "not_found", "no such route");
47        response
48            .headers_mut()
49            .insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
50        return response;
51    }
52    if let Some(asset) = find(path) {
53        let cache = if path.starts_with("/assets/") {
54            IMMUTABLE
55        } else {
56            "no-cache"
57        };
58        return file(asset, cache, &headers);
59    }
60    if path.starts_with("/assets/") || matches!(path, "/sw.js" | "/manifest.webmanifest") {
61        return (
62            StatusCode::NOT_FOUND,
63            [(header::X_CONTENT_TYPE_OPTIONS, "nosniff")],
64            "not found",
65        )
66            .into_response();
67    }
68    let index = find("/index.html").expect("the build has an index.html");
69    if is_setup(path) {
70        file(index, "no-store", &HeaderMap::new())
71    } else {
72        file(index, "no-cache", &headers)
73    }
74}
75
76/// Paths the app never answers: the API, and the gateway's proxy.
77fn is_reserved(path: &str) -> bool {
78    path == "/api" || path.starts_with("/api/") || path.starts_with("/mcp/")
79}
80
81/// The page a setup link opens: never cached (kernel spec §3.1).
82fn is_setup(path: &str) -> bool {
83    path == "/setup" || path.starts_with("/setup/")
84}
85
86#[cfg(test)]
87fn find(path: &str) -> Option<&'static Asset> {
88    FILES.iter().find(|a| a.path == path)
89}
90
91fn file(asset: &'static Asset, cache: &'static str, request: &HeaderMap) -> Response {
92    let fresh = cache == "no-cache"
93        && request
94            .get(header::IF_NONE_MATCH)
95            .and_then(|v| v.to_str().ok())
96            .is_some_and(|tags| tags.split(',').any(|t| t.trim() == asset.etag || t.trim() == "*"));
97    // A 304 names no type: the cached copy keeps its own.
98    let mut response = if fresh {
99        StatusCode::NOT_MODIFIED.into_response()
100    } else {
101        ([(header::CONTENT_TYPE, asset.content_type)], asset.body).into_response()
102    };
103    let headers = response.headers_mut();
104    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
105    headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
106    headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
107    if asset.path == "/sw.js" {
108        headers.insert(
109            header::CONTENT_SECURITY_POLICY,
110            HeaderValue::from_static(crate::csp::WORKER_POLICY),
111        );
112    }
113    if cache != "no-store" {
114        headers.insert(header::ETAG, HeaderValue::from_static(asset.etag));
115    }
116    response
117}
118
119/// Whether the real UI is embedded, not the placeholder.
120pub fn ui_embedded() -> bool {
121    EMBEDDED
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn the_api_and_the_gateways_proxy_are_reserved() {
130        for path in ["/api", "/api/", "/api/nope", "/mcp/slug", "/mcp/"] {
131            assert!(is_reserved(path), "{path}");
132        }
133        for path in ["/", "/apis", "/mcp", "/sessions/s-1", "/setup"] {
134            assert!(!is_reserved(path), "{path}");
135        }
136    }
137
138    #[test]
139    fn the_build_has_an_index() {
140        let index = find("/index.html").unwrap();
141        assert_eq!(index.content_type, "text/html; charset=utf-8");
142        assert!(index.etag.starts_with('"') && index.etag.len() == 18, "{}", index.etag);
143    }
144
145    #[test]
146    fn missing_worker_and_manifest_do_not_fall_back_to_html() {
147        static INDEX_ONLY: &[Asset] = &[Asset {
148            path: "/index.html",
149            body: b"index",
150            content_type: "text/html; charset=utf-8",
151            etag: "\"index\"",
152        }];
153        for path in ["/sw.js", "/manifest.webmanifest"] {
154            let response = serve_assets(Method::GET, path.parse().unwrap(), HeaderMap::new(), INDEX_ONLY);
155            assert_eq!(response.status(), StatusCode::NOT_FOUND);
156        }
157    }
158
159    #[test]
160    fn a_worker_response_has_its_own_policy() {
161        static WORKER: Asset = Asset {
162            path: "/sw.js",
163            body: b"worker",
164            content_type: "text/javascript; charset=utf-8",
165            etag: "\"worker\"",
166        };
167        let response = file(&WORKER, "no-cache", &HeaderMap::new());
168        assert_eq!(
169            response.headers()[header::CONTENT_SECURITY_POLICY],
170            crate::csp::WORKER_POLICY
171        );
172        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
173        assert_eq!(response.headers()[header::ETAG], "\"worker\"");
174        assert!(crate::csp::WORKER_POLICY.contains("img-src 'self'"));
175        assert!(crate::csp::POLICY.contains("worker-src 'self'") && crate::csp::POLICY.contains("manifest-src 'self'"));
176    }
177}