Skip to main content

innate_core/web/
mod.rs

1//! `innate web` — local read + governance web UI for the knowledge base.
2//!
3//! Design: the **fifth access module** (alongside MCP / CLI / SDK / Daemon). Unlike
4//! the daemon (§九), this module DOES hold a live read-write `KnowledgeBase`, because
5//! governance actions (approve/archive/invalidate/restore) mutate. It is therefore
6//! security-sensitive: bound to localhost by default and gated by a one-time token
7//! for every mutating endpoint.
8//!
9//! Synchronous stack only (`tiny_http` + the std accept loop) to match the rest of
10//! the core; no async runtime is introduced.
11
12use crate::KnowledgeBase;
13
14mod api;
15mod assets;
16
17#[cfg(test)]
18mod tests;
19
20/// Whether a bind address is loopback-only (the trusted single-user case). Used
21/// to decide both whether `--allow-remote` is required and whether read
22/// endpoints must also present the auth token (they must, when non-loopback).
23pub fn is_loopback(bind: &str) -> bool {
24    match bind.parse::<std::net::IpAddr>() {
25        Ok(ip) => ip.is_loopback(),
26        // Hostnames: only the well-known localhost aliases are treated as
27        // loopback; anything else is assumed network-reachable (fail safe).
28        Err(_) => matches!(bind, "localhost" | "localhost.localdomain"),
29    }
30}
31
32/// Start the web server and block on the accept loop until the process is killed.
33///
34/// * `bind` / `port` — listen address; defaults to `127.0.0.1:8788` via the CLI.
35/// * `require_token` — when true (default), every governance (POST) endpoint requires
36///   the printed token in the `X-Innate-Token` header. `--no-token` disables it.
37pub fn serve(kb: KnowledgeBase, bind: &str, port: u16, require_token: bool) -> anyhow::Result<()> {
38    let token = if require_token {
39        Some(crate::utils::gen_uuid())
40    } else {
41        None
42    };
43
44    let addr = format!("{bind}:{port}");
45    let server = tiny_http::Server::http(addr.as_str())
46        .map_err(|e| anyhow::anyhow!("failed to bind {addr}: {e}"))?;
47
48    // Token rides in the URL *fragment* (`#token=`), never the query string: the
49    // fragment is not sent to the server and not written to access logs/Referer.
50    // The frontend reads it, strips it from the address bar, and replays it via
51    // the `X-Innate-Token` header.
52    let url = match &token {
53        Some(t) => format!("http://{bind}:{port}/#token={t}"),
54        None => format!("http://{bind}:{port}/"),
55    };
56    eprintln!("innate web listening on http://{bind}:{port}");
57    eprintln!("open: {url}");
58    if token.is_none() {
59        eprintln!("WARNING: --no-token set; governance endpoints are unauthenticated.");
60    }
61
62    // A local single-user viewer: a single-threaded accept loop is sufficient and
63    // keeps the lifetime of the read-write KnowledgeBase trivially sound (no shared
64    // mutable state across threads). Governance methods take &self.
65    let ctx = api::Ctx {
66        kb,
67        token,
68        bind: bind.to_string(),
69        port,
70    };
71    for request in server.incoming_requests() {
72        api::handle(&ctx, request);
73    }
74    Ok(())
75}