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}