Skip to main content

frame_host/
server.rs

1//! Static shell server for the browser client bundle.
2//!
3//! Serves exactly one directory of static files (the demo client's `dist/`:
4//! `index.html` at the root plus hashed bundle files, typically under
5//! `assets/`, including the `GraphMother` `.wasm`). Resolution is exact:
6//!
7//! - `GET /` serves `index.html`; every other path must name a real file.
8//! - A missing asset is a typed, loud 404 page — NEVER an index.html
9//!   fallback. SPA fallbacks silently mask broken asset paths (a `.wasm`
10//!   request answered with HTML burned this exact demo once already).
11//! - Traversal (`..`, absolute components, NUL) and malformed encodings are
12//!   refused with typed 400 pages; symlink escapes are refused by
13//!   canonical-prefix checking.
14//!
15//! `GET /frame/config.json` surfaces the bus endpoint to the page (as
16//! `busEndpoint`, plus the deprecated duplicate `liminalEndpoint` for one
17//! compatibility window). Per D3 the browser connects to the bus directly as
18//! a participant; this server carries no feed bytes.
19//!
20//! `GET /frame/app/status.json` (2026-07-21 boot-visibility fix, see
21//! [`crate::truth`]) serves the process's CURRENT application-truth
22//! snapshot: every lifecycle transition and announced fact observed so far,
23//! in order. A client fetches this on the SAME surface as `/frame/config.json`
24//! — reachable the instant this server binds, before the announcer pump has
25//! drained a single event onto the bus — then follows the live channel for
26//! what happens next.
27//!
28//! `GET /frame/bus/{health,ready,metrics}` are the same-origin proxy
29//! routes for the embedded bus health listener ([`crate::bus_proxy`]):
30//! observability HTTP only, never feed bytes, forwarded verbatim with typed
31//! bounded failure. The pre-rename routes `/frame/liminal/*` answer as
32//! deprecated aliases (same handlers, loud per-hit `tracing::warn!`) for the
33//! same window.
34
35use std::net::SocketAddr;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39use axum::Router;
40use axum::body::Body;
41use axum::extract::{Request, State};
42use axum::http::{HeaderValue, Method, StatusCode, header};
43use axum::response::{IntoResponse, Response};
44use axum::routing::get;
45use serde::Serialize;
46use tokio::net::TcpListener;
47
48use crate::bus_proxy::{
49    BUS_HEALTH_ROUTE, BUS_METRICS_ROUTE, BUS_READY_ROUTE, LEGACY_LIMINAL_HEALTH_ROUTE,
50    LEGACY_LIMINAL_METRICS_ROUTE, LEGACY_LIMINAL_READY_ROUTE, PRODUCTION_DEADLINES,
51    bus_metrics_body, failure_response, forward_health_request, passthrough_response,
52};
53use crate::error::HostError;
54use crate::truth::AppTruth;
55
56/// Route on which the page fetches its runtime configuration.
57pub const CONFIG_ROUTE: &str = "/frame/config.json";
58
59/// Route on which a client fetches the CURRENT application-truth snapshot
60/// (2026-07-21 boot-visibility fix, [`crate::truth`]): every lifecycle
61/// transition and announced fact observed so far, in order. Named alongside
62/// [`CONFIG_ROUTE`] under the same `/frame/` namespace, matching this
63/// module's own route-naming idiom.
64pub const APP_STATUS_ROUTE: &str = "/frame/app/status.json";
65
66/// Configuration for one shell server instance. No field has a default.
67#[derive(Clone, Debug)]
68pub struct ShellConfig {
69    /// Socket address to bind.
70    pub bind: SocketAddr,
71    /// Directory containing the static client bundle.
72    pub asset_root: PathBuf,
73    /// Bus WebSocket endpoint surfaced verbatim to the page (D3).
74    pub bus_endpoint: String,
75    /// Bearer token surfaced verbatim as `authToken`; empty = open server.
76    pub auth_token: String,
77    /// Feed channel; omitted from the served config when `None` so the shell
78    /// applies the SDK's `DEFAULT_FEED_CHANNEL`. Stays the console's PRIMARY
79    /// (default-selected) channel.
80    pub channel: Option<String>,
81    /// The FULL channel list of the embedded liminal component, served verbatim
82    /// as `channels` — the console subscribes every one of them. Must be
83    /// non-empty, duplicate-free, all entries non-empty, and contain `channel`
84    /// when one is provided.
85    pub channels: Vec<String>,
86    /// The embedded bus health listener's real bound address — the target
87    /// of the same-origin `/frame/bus/*` proxy routes.
88    pub bus_health: SocketAddr,
89    /// The optional document-service advert (IRIDIUM-A3 C2's config.json
90    /// growth): served as `document` when `[document]` is configured.
91    pub document: Option<DocumentAdvert>,
92    /// The process's application-truth recorder (2026-07-21
93    /// boot-visibility fix): backs [`APP_STATUS_ROUTE`]. See [`crate::truth`].
94    pub truth: Arc<AppTruth>,
95}
96
97/// What the authoring page needs to bind the `frame:code-editor@v1`
98/// component: the document id, the component instance, and the two
99/// channels (C2: feed + authoring per document).
100#[derive(Clone, Debug, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct DocumentAdvert {
103    /// The bound document id.
104    pub id: String,
105    /// The component instance id on every feed envelope.
106    pub component_id: String,
107    /// The feed channel (service publishes; pages subscribe).
108    pub feed_channel: String,
109    /// The authoring channel (pages publish; the service subscribes).
110    pub authoring_channel: String,
111    /// The component's declared cursor-blink interval (T4), milliseconds.
112    pub blink_interval_ms: u64,
113}
114
115/// Runtime configuration served to the page at [`CONFIG_ROUTE`], matching
116/// the shell's validation in `examples/frame-demo/src/config.ts` exactly:
117/// non-empty `busEndpoint`, string `authToken` (empty = open server),
118/// `channel` present only when the operator provided one, and `channels` —
119/// the embedded bus's full configured channel list — always present.
120#[derive(Clone, Debug, Serialize)]
121#[serde(rename_all = "camelCase")]
122struct ShellRuntimeConfig {
123    /// The embedded bus's WebSocket endpoint — the primary field.
124    bus_endpoint: String,
125    /// DEPRECATED duplicate of `bus_endpoint`, served as `liminalEndpoint`
126    /// for one compatibility window; remove with the `[liminal]` config alias.
127    liminal_endpoint: String,
128    auth_token: String,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    channel: Option<String>,
131    channels: Vec<String>,
132    /// The document-service advert; absent when no `[document]` is
133    /// configured (the page fails loudly if it needs one).
134    #[serde(skip_serializing_if = "Option::is_none")]
135    document: Option<DocumentAdvert>,
136}
137
138struct ShellState {
139    asset_root: PathBuf,
140    config: ShellRuntimeConfig,
141    bus_health: SocketAddr,
142    truth: Arc<AppTruth>,
143}
144
145/// A bound-but-not-yet-serving shell server.
146pub struct ShellServer {
147    listener: TcpListener,
148    router: Router,
149    addr: SocketAddr,
150}
151
152impl ShellServer {
153    /// Validates the asset root and the served config contract, then binds
154    /// the listener.
155    ///
156    /// # Errors
157    ///
158    /// Refuses an unreadable or non-directory asset root, a root without
159    /// `index.html`, a config the shell's contract would refuse (empty
160    /// `busEndpoint`, empty provided `channel`), and any bind failure —
161    /// all typed. Serving a config the shell will refuse at parse time is a
162    /// composition error the host catches here, not in the browser.
163    pub async fn bind(config: ShellConfig) -> Result<Self, HostError> {
164        let bind_addr = config.bind;
165        let router = Self::prepare(config)?;
166        let listener = TcpListener::bind(bind_addr)
167            .await
168            .map_err(|source| HostError::Bind {
169                addr: bind_addr,
170                source,
171            })?;
172        let addr = listener.local_addr().map_err(|source| HostError::Bind {
173            addr: bind_addr,
174            source,
175        })?;
176        Ok(Self {
177            listener,
178            router,
179            addr,
180        })
181    }
182
183    /// Adopts an ALREADY-BOUND listener (from [`crate::page::PageServer`])
184    /// instead of binding its own, then validates the served config contract
185    /// exactly as [`Self::bind`] does.
186    ///
187    /// The page-server port is resolved and BOUND up front (prefer-and-walk, or
188    /// stated-and-exact) before the embedded bus boots, so its real address can
189    /// seed the bus's derived origin allow-list. Handing the held listener
190    /// straight here — rather than probing a port, dropping it, and re-binding
191    /// — is what makes that resolution race-free: no other process can claim
192    /// the port in between. `config.bind` is ignored (the listener is already
193    /// bound); the real bound address is read from the listener itself.
194    ///
195    /// # Errors
196    ///
197    /// Refuses the same config-contract and asset-root violations as
198    /// [`Self::bind`], and any failure converting the adopted std listener to a
199    /// non-blocking async listener.
200    pub fn adopt(listener: std::net::TcpListener, config: ShellConfig) -> Result<Self, HostError> {
201        let router = Self::prepare(config)?;
202        listener.set_nonblocking(true).map_err(|source| {
203            let addr = listener
204                .local_addr()
205                .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
206            HostError::Bind { addr, source }
207        })?;
208        let addr = listener.local_addr().map_err(|source| HostError::Bind {
209            addr: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
210            source,
211        })?;
212        let listener =
213            TcpListener::from_std(listener).map_err(|source| HostError::Bind { addr, source })?;
214        Ok(Self {
215            listener,
216            router,
217            addr,
218        })
219    }
220
221    /// Validates the served config contract and asset root, then builds the
222    /// router — the shared body of [`Self::bind`] and [`Self::adopt`], which
223    /// differ only in where the listener comes from.
224    fn prepare(config: ShellConfig) -> Result<Router, HostError> {
225        if config.bus_endpoint.is_empty() {
226            return Err(HostError::ConfigContract {
227                detail: "the bus endpoint is empty: it is derived from the embedded \
228                         WebSocket listener's bound address, which must never be empty — this is \
229                         an internal error, not an operator one"
230                    .to_owned(),
231            });
232        }
233        if let Some(channel) = &config.channel
234            && channel.is_empty()
235        {
236            return Err(HostError::ConfigContract {
237                detail: "[frame].channel, when set, must be non-empty: the console refuses an \
238                         empty channel with CONFIG_INVALID; omit the key to use the SDK's default \
239                         channel"
240                    .to_owned(),
241            });
242        }
243        validate_channels_contract(&config.channels, config.channel.as_deref())?;
244        let asset_root =
245            config
246                .asset_root
247                .canonicalize()
248                .map_err(|error| HostError::AssetRoot {
249                    path: config.asset_root.clone(),
250                    detail: error.to_string(),
251                })?;
252        if !asset_root.is_dir() {
253            return Err(HostError::AssetRoot {
254                path: config.asset_root.clone(),
255                detail: "not a directory".to_owned(),
256            });
257        }
258        if !asset_root.join("index.html").is_file() {
259            return Err(HostError::MissingIndex {
260                path: config.asset_root.clone(),
261            });
262        }
263        let state = Arc::new(ShellState {
264            asset_root,
265            config: ShellRuntimeConfig {
266                bus_endpoint: config.bus_endpoint.clone(),
267                // Deprecated duplicate, same value — one compatibility window.
268                liminal_endpoint: config.bus_endpoint,
269                auth_token: config.auth_token,
270                channel: config.channel,
271                channels: config.channels,
272                document: config.document,
273            },
274            bus_health: config.bus_health,
275            truth: config.truth,
276        });
277        Ok(Router::new()
278            .route(CONFIG_ROUTE, get(serve_config))
279            .route(APP_STATUS_ROUTE, get(serve_app_status))
280            .route(BUS_HEALTH_ROUTE, get(proxy_bus_health))
281            .route(BUS_READY_ROUTE, get(proxy_bus_ready))
282            .route(BUS_METRICS_ROUTE, get(proxy_bus_metrics))
283            // DEPRECATED aliases (compatibility window): same handlers, loud
284            // per-hit deprecation warning — never a silent legacy acceptance.
285            .route(LEGACY_LIMINAL_HEALTH_ROUTE, get(proxy_legacy_health))
286            .route(LEGACY_LIMINAL_READY_ROUTE, get(proxy_legacy_ready))
287            .route(LEGACY_LIMINAL_METRICS_ROUTE, get(proxy_legacy_metrics))
288            .fallback(serve_asset)
289            .with_state(state))
290    }
291
292    /// The address actually bound (resolves an ephemeral port request).
293    #[must_use]
294    pub const fn local_addr(&self) -> SocketAddr {
295        self.addr
296    }
297
298    /// Serves until `shutdown` resolves.
299    ///
300    /// # Errors
301    ///
302    /// Propagates accept-loop failures as typed serve errors.
303    pub async fn serve(
304        self,
305        shutdown: impl Future<Output = ()> + Send + 'static,
306    ) -> Result<(), HostError> {
307        axum::serve(self.listener, self.router)
308            .with_graceful_shutdown(shutdown)
309            .await
310            .map_err(|source| HostError::Serve { source })
311    }
312}
313
314/// Refuses a served `channels` roster the console's own config contract would
315/// reject: it must be non-empty (the console subscribes every entry), every
316/// entry must be non-empty, entries must be unique (a duplicate would open two
317/// identical subscriptions — liminal's own validator refuses the same shape),
318/// and it must contain `channel` when one is provided (the console refuses a
319/// roster that omits its primary channel with `CONFIG_INVALID`).
320fn validate_channels_contract(channels: &[String], channel: Option<&str>) -> Result<(), HostError> {
321    if channels.is_empty() {
322        return Err(HostError::ConfigContract {
323            detail: "the served channels roster is empty: the console subscribes every entry of \
324                     `channels`, so an empty roster leaves it with no feed — it is derived from \
325                     [bus].channels, which must declare at least one channel"
326                .to_owned(),
327        });
328    }
329    let mut seen = std::collections::HashSet::new();
330    for entry in channels {
331        if entry.is_empty() {
332            return Err(HostError::ConfigContract {
333                detail: "the served channels roster contains an empty channel name: the console \
334                         refuses an empty channel with CONFIG_INVALID"
335                    .to_owned(),
336            });
337        }
338        if !seen.insert(entry.as_str()) {
339            return Err(HostError::ConfigContract {
340                detail: format!(
341                    "the served channels roster names \"{entry}\" more than once: a duplicate \
342                     would open two identical subscriptions, and liminal's own config validation \
343                     refuses duplicate channel names"
344                ),
345            });
346        }
347    }
348    if let Some(channel) = channel
349        && !channels.iter().any(|entry| entry == channel)
350    {
351        return Err(HostError::ConfigContract {
352            detail: format!(
353                "the served channels roster [{roster}] does not contain the primary channel \
354                 \"{channel}\": the console refuses that pair with CONFIG_INVALID",
355                roster = channels.join(", ")
356            ),
357        });
358    }
359    Ok(())
360}
361
362async fn serve_config(State(state): State<Arc<ShellState>>) -> Response {
363    tracing::info!(route = CONFIG_ROUTE, "serving shell runtime configuration");
364    axum::Json(state.config.clone()).into_response()
365}
366
367/// Serves the CURRENT application-truth snapshot (2026-07-21
368/// boot-visibility fix, [`crate::truth`]): every lifecycle transition and
369/// announced fact this process has produced so far, in order. Reachable the
370/// instant this server binds — before the announcer pump has drained a
371/// single event onto the bus — so a client can observe the full boot
372/// history without racing the bus at all.
373async fn serve_app_status(State(state): State<Arc<ShellState>>) -> Response {
374    let snapshot = state.truth.snapshot();
375    tracing::info!(
376        route = APP_STATUS_ROUTE,
377        transitions = snapshot.transitions.len(),
378        facts = snapshot.facts.len(),
379        "serving application-truth snapshot"
380    );
381    axum::Json(snapshot).into_response()
382}
383
384async fn proxy_bus_health(State(state): State<Arc<ShellState>>) -> Response {
385    proxy_bus(&state, BUS_HEALTH_ROUTE, "/health").await
386}
387
388async fn proxy_bus_ready(State(state): State<Arc<ShellState>>) -> Response {
389    proxy_bus(&state, BUS_READY_ROUTE, "/ready").await
390}
391
392async fn proxy_bus_metrics(State(state): State<Arc<ShellState>>) -> Response {
393    proxy_bus(&state, BUS_METRICS_ROUTE, "/metrics").await
394}
395
396async fn proxy_legacy_health(State(state): State<Arc<ShellState>>) -> Response {
397    warn_legacy_route(LEGACY_LIMINAL_HEALTH_ROUTE, BUS_HEALTH_ROUTE);
398    proxy_bus(&state, LEGACY_LIMINAL_HEALTH_ROUTE, "/health").await
399}
400
401async fn proxy_legacy_ready(State(state): State<Arc<ShellState>>) -> Response {
402    warn_legacy_route(LEGACY_LIMINAL_READY_ROUTE, BUS_READY_ROUTE);
403    proxy_bus(&state, LEGACY_LIMINAL_READY_ROUTE, "/ready").await
404}
405
406async fn proxy_legacy_metrics(State(state): State<Arc<ShellState>>) -> Response {
407    warn_legacy_route(LEGACY_LIMINAL_METRICS_ROUTE, BUS_METRICS_ROUTE);
408    proxy_bus(&state, LEGACY_LIMINAL_METRICS_ROUTE, "/metrics").await
409}
410
411/// The loud, per-hit deprecation on a legacy `/frame/liminal/*` alias — a
412/// legacy-name acceptance is never silent.
413fn warn_legacy_route(route: &'static str, replacement: &'static str) {
414    tracing::warn!(
415        route,
416        replacement,
417        "DEPRECATED route served: /frame/liminal/* is the legacy alias of /frame/bus/*; update \
418         the client to the bus route — the alias is removed after one compatibility window"
419    );
420}
421
422/// Forwards one same-origin health-proxy request to the embedded bus
423/// health listener, passing the upstream response through verbatim (the
424/// metrics body alone is re-vocabularied by [`bus_metrics_body`]) and
425/// rendering any forwarding failure as its typed, bounded 502 body.
426async fn proxy_bus(state: &ShellState, route: &'static str, upstream_path: &str) -> Response {
427    match forward_health_request(state.bus_health, upstream_path, PRODUCTION_DEADLINES).await {
428        Ok(mut upstream) => {
429            if upstream_path == "/metrics" {
430                upstream.body = bus_metrics_body(&upstream.body);
431            }
432            tracing::info!(
433                route,
434                upstream_path,
435                status = upstream.status,
436                bytes = upstream.body.len(),
437                "proxied bus health route"
438            );
439            passthrough_response(upstream)
440        }
441        Err(failure) => {
442            tracing::error!(
443                route,
444                upstream_path,
445                code = failure.code(),
446                detail = failure.detail(),
447                "bus health proxy failed typed"
448            );
449            failure_response(upstream_path, &failure)
450        }
451    }
452}
453
454async fn serve_asset(State(state): State<Arc<ShellState>>, request: Request) -> Response {
455    let method = request.method().clone();
456    let raw_path = request.uri().path().to_owned();
457    if method != Method::GET && method != Method::HEAD {
458        tracing::warn!(%method, path = %raw_path, "method not allowed on static shell");
459        return error_page(
460            StatusCode::METHOD_NOT_ALLOWED,
461            "METHOD NOT ALLOWED",
462            &format!("The static shell serves GET and HEAD only; {method} was refused."),
463        );
464    }
465    let relative = match sanitize_request_path(&raw_path) {
466        Ok(relative) => relative,
467        Err(refusal) => {
468            tracing::warn!(path = %raw_path, %refusal, "refusing malformed asset path");
469            return error_page(StatusCode::BAD_REQUEST, "BAD ASSET PATH", &refusal);
470        }
471    };
472    let candidate = state.asset_root.join(&relative);
473    let resolved = match candidate.canonicalize() {
474        Ok(resolved) => resolved,
475        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
476            tracing::warn!(path = %raw_path, "asset not found");
477            return not_found_page(&raw_path, &state.asset_root);
478        }
479        Err(error) => {
480            tracing::error!(path = %raw_path, %error, "asset resolution failed");
481            return error_page(
482                StatusCode::INTERNAL_SERVER_ERROR,
483                "ASSET RESOLUTION FAILED",
484                &format!("Resolving {raw_path} failed: {error}"),
485            );
486        }
487    };
488    if !resolved.starts_with(&state.asset_root) {
489        tracing::warn!(path = %raw_path, resolved = %resolved.display(), "asset escapes the asset root");
490        return error_page(
491            StatusCode::BAD_REQUEST,
492            "BAD ASSET PATH",
493            "The requested path resolves outside the configured asset directory.",
494        );
495    }
496    if !resolved.is_file() {
497        tracing::warn!(path = %raw_path, "asset path is not a file");
498        return not_found_page(&raw_path, &state.asset_root);
499    }
500    match tokio::fs::read(&resolved).await {
501        Ok(bytes) => {
502            let mime = mime_for(&resolved);
503            tracing::info!(path = %raw_path, bytes = bytes.len(), mime, "served asset");
504            let mut response = Response::new(Body::from(bytes));
505            response
506                .headers_mut()
507                .insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
508            response
509        }
510        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
511            tracing::warn!(path = %raw_path, "asset vanished between resolution and read");
512            not_found_page(&raw_path, &state.asset_root)
513        }
514        Err(error) => {
515            tracing::error!(path = %raw_path, %error, "asset read failed");
516            error_page(
517                StatusCode::INTERNAL_SERVER_ERROR,
518                "ASSET READ FAILED",
519                &format!("Reading {raw_path} failed: {error}"),
520            )
521        }
522    }
523}
524
525/// Decodes and validates a request path into a root-relative file path.
526///
527/// `/` maps to `index.html`. Every component must be a plain name: empty,
528/// `.`, `..`, NUL-bearing, and backslash-bearing components are refused, as
529/// is any malformed percent-encoding.
530fn sanitize_request_path(raw: &str) -> Result<PathBuf, String> {
531    let decoded = percent_decode(raw)?;
532    let Some(stripped) = decoded.strip_prefix('/') else {
533        return Err("request path must start with '/'".to_owned());
534    };
535    if stripped.is_empty() {
536        return Ok(PathBuf::from("index.html"));
537    }
538    let mut relative = PathBuf::new();
539    for component in stripped.split('/') {
540        if component.is_empty() {
541            return Err("empty path component (doubled or trailing '/')".to_owned());
542        }
543        if component == "." || component == ".." {
544            return Err("relative path components are refused".to_owned());
545        }
546        if component.contains('\0') || component.contains('\\') {
547            return Err("path component contains a refused character".to_owned());
548        }
549        relative.push(component);
550    }
551    Ok(relative)
552}
553
554/// Strict RFC 3986 percent-decoding: every `%` must head two hex digits and
555/// the decoded bytes must be valid UTF-8.
556fn percent_decode(raw: &str) -> Result<String, String> {
557    if !raw.contains('%') {
558        return Ok(raw.to_owned());
559    }
560    let bytes = raw.as_bytes();
561    let mut decoded = Vec::with_capacity(bytes.len());
562    let mut index = 0;
563    while index < bytes.len() {
564        if bytes[index] == b'%' {
565            let (Some(high), Some(low)) = (
566                bytes.get(index + 1).and_then(|byte| hex_value(*byte)),
567                bytes.get(index + 2).and_then(|byte| hex_value(*byte)),
568            ) else {
569                return Err("malformed percent-encoding".to_owned());
570            };
571            decoded.push(high * 16 + low);
572            index += 3;
573        } else {
574            decoded.push(bytes[index]);
575            index += 1;
576        }
577    }
578    String::from_utf8(decoded).map_err(|_| "percent-encoding decodes to invalid UTF-8".to_owned())
579}
580
581const fn hex_value(byte: u8) -> Option<u8> {
582    match byte {
583        b'0'..=b'9' => Some(byte - b'0'),
584        b'a'..=b'f' => Some(byte - b'a' + 10),
585        b'A'..=b'F' => Some(byte - b'A' + 10),
586        _ => None,
587    }
588}
589
590/// Explicit MIME map. `application/wasm` is load-bearing: browsers refuse
591/// `WebAssembly.instantiateStreaming` on anything else.
592fn mime_for(path: &Path) -> &'static str {
593    let extension = path
594        .extension()
595        .and_then(|extension| extension.to_str())
596        .map(str::to_ascii_lowercase);
597    match extension.as_deref() {
598        Some("html") => "text/html; charset=utf-8",
599        Some("js" | "mjs") => "text/javascript; charset=utf-8",
600        Some("css") => "text/css; charset=utf-8",
601        Some("wasm") => "application/wasm",
602        Some("json" | "map") => "application/json; charset=utf-8",
603        Some("svg") => "image/svg+xml",
604        Some("png") => "image/png",
605        Some("jpg" | "jpeg") => "image/jpeg",
606        Some("gif") => "image/gif",
607        Some("webp") => "image/webp",
608        Some("ico") => "image/x-icon",
609        Some("txt") => "text/plain; charset=utf-8",
610        Some("woff") => "font/woff",
611        Some("woff2") => "font/woff2",
612        Some("ttf") => "font/ttf",
613        Some("otf") => "font/otf",
614        _ => "application/octet-stream",
615    }
616}
617
618fn not_found_page(raw_path: &str, asset_root: &Path) -> Response {
619    error_page(
620        StatusCode::NOT_FOUND,
621        "ASSET NOT FOUND",
622        &format!(
623            "No file named {raw_path} exists under the configured asset directory \
624             {root}. This host never falls back to index.html: a missing asset is \
625             this loud page, not a silently mis-served shell.",
626            root = asset_root.display()
627        ),
628    )
629}
630
631fn error_page(status: StatusCode, title: &str, detail: &str) -> Response {
632    let body = format!(
633        "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\">\
634         <title>frame-host: {status} {title}</title></head>\n<body style=\"font-family: monospace; \
635         background: #1a0505; color: #ffb4b4; padding: 2rem;\">\n<h1>FRAME HOST — {status} {title}</h1>\n\
636         <p>{detail}</p>\n</body>\n</html>\n",
637        status = status.as_u16(),
638    );
639    let mut response = Response::new(Body::from(body));
640    *response.status_mut() = status;
641    response.headers_mut().insert(
642        header::CONTENT_TYPE,
643        HeaderValue::from_static("text/html; charset=utf-8"),
644    );
645    response
646}
647
648#[cfg(test)]
649mod tests {
650    use super::{mime_for, percent_decode, sanitize_request_path, validate_channels_contract};
651    use crate::error::HostError;
652    use std::path::{Path, PathBuf};
653
654    fn roster(entries: &[&str]) -> Vec<String> {
655        entries.iter().map(|entry| (*entry).to_owned()).collect()
656    }
657
658    /// The channels contract: non-empty roster, non-empty unique entries, and
659    /// primary-channel membership — each refusal typed and loud.
660    #[test]
661    fn channels_contract_accepts_valid_rosters() {
662        assert!(validate_channels_contract(&roster(&["telemetry.stream"]), None).is_ok());
663        assert!(
664            validate_channels_contract(
665                &roster(&["telemetry.stream", "telemetry.east"]),
666                Some("telemetry.east"),
667            )
668            .is_ok()
669        );
670    }
671
672    #[test]
673    fn channels_contract_refuses_empty_roster() {
674        let refusal = validate_channels_contract(&[], None);
675        assert!(matches!(refusal, Err(HostError::ConfigContract { .. })));
676    }
677
678    #[test]
679    fn channels_contract_refuses_empty_and_duplicate_entries()
680    -> Result<(), Box<dyn std::error::Error>> {
681        let empty_entry = validate_channels_contract(&roster(&["telemetry.stream", ""]), None);
682        assert!(matches!(empty_entry, Err(HostError::ConfigContract { .. })));
683
684        let duplicate = validate_channels_contract(
685            &roster(&["telemetry.stream", "telemetry.stream"]),
686            Some("telemetry.stream"),
687        );
688        let Err(HostError::ConfigContract { detail }) = duplicate else {
689            return Err("expected ConfigContract for a duplicate entry".into());
690        };
691        assert!(
692            detail.contains("more than once"),
693            "duplicate refusal must state the duplication: {detail}"
694        );
695        Ok(())
696    }
697
698    #[test]
699    fn channels_contract_refuses_a_primary_outside_the_roster()
700    -> Result<(), Box<dyn std::error::Error>> {
701        let refusal = validate_channels_contract(
702            &roster(&["telemetry.stream", "telemetry.east"]),
703            Some("telemetry.west"),
704        );
705        let Err(HostError::ConfigContract { detail }) = refusal else {
706            return Err("expected ConfigContract for a foreign primary".into());
707        };
708        assert!(
709            detail.contains("telemetry.west"),
710            "refusal must name the missing primary: {detail}"
711        );
712        Ok(())
713    }
714
715    #[test]
716    fn root_maps_to_index_only() {
717        assert_eq!(
718            sanitize_request_path("/").ok(),
719            Some(PathBuf::from("index.html"))
720        );
721        assert_eq!(
722            sanitize_request_path("/assets/app.js").ok(),
723            Some(PathBuf::from("assets/app.js"))
724        );
725    }
726
727    #[test]
728    fn traversal_and_malformed_paths_are_refused() {
729        assert!(sanitize_request_path("/../secret").is_err());
730        assert!(sanitize_request_path("/a/../b").is_err());
731        assert!(sanitize_request_path("/%2e%2e/secret").is_err());
732        assert!(sanitize_request_path("//double").is_err());
733        assert!(sanitize_request_path("/a/./b").is_err());
734        assert!(sanitize_request_path("/a%00b").is_err());
735        assert!(sanitize_request_path("/a%zz").is_err());
736        assert!(sanitize_request_path("no-leading-slash").is_err());
737    }
738
739    #[test]
740    fn percent_decoding_is_strict() {
741        assert_eq!(percent_decode("/plain").ok().as_deref(), Some("/plain"));
742        assert_eq!(percent_decode("/a%20b").ok().as_deref(), Some("/a b"));
743        assert!(percent_decode("/broken%2").is_err());
744        assert!(percent_decode("/broken%").is_err());
745        assert!(percent_decode("/bad%ff%fe").is_err());
746    }
747
748    #[test]
749    fn wasm_and_core_types_map_exactly() {
750        assert_eq!(mime_for(Path::new("a/app.wasm")), "application/wasm");
751        assert_eq!(
752            mime_for(Path::new("index.html")),
753            "text/html; charset=utf-8"
754        );
755        assert_eq!(
756            mime_for(Path::new("assets/index-abc.js")),
757            "text/javascript; charset=utf-8"
758        );
759        assert_eq!(mime_for(Path::new("noext")), "application/octet-stream");
760    }
761}