trusty-memory 0.24.0

MCP server (stdio + HTTP/SSE) for trusty-memory
Documentation
//! HTTP API + embedded SPA shell for the trusty-memory admin UI.
//!
//! Why: The web admin panel is the primary GUI for non-MCP clients. Bundling
//! the Svelte build via `rust-embed` keeps deployment to "drop the binary on
//! a host"; the JSON API surface mirrors the MCP tool set so anything
//! trusty-memory can do via Claude Code can also be done via curl or browser.
//! What: All `/api/v1/*` handlers (status, palaces, drawers, recall, KG,
//! config, chat) plus an embedded-asset fallback that serves `ui/dist/`.
//! Test: `cargo test -p trusty-memory web::tests` covers the asset
//! fallback and JSON shape of every read endpoint against an in-memory
//! palace built on a `tempdir`.

use axum::{
    routing::{delete, get, post},
    Router,
};

use crate::AppState;

pub(crate) mod activity;
pub(crate) mod admin;
pub(crate) mod error;
pub(crate) mod health;
pub(crate) mod kg_routes;
pub(crate) mod palace_routes;
pub(crate) mod prompt_context;
pub(crate) mod recall_routes;
pub(crate) mod rpc;
pub(crate) mod static_assets;

// Re-export the pub(crate) items that other modules (primarily `chat.rs`)
// reference via `crate::web::`. Only items that are actually imported from
// outside the `web/` module are listed here; submodule-internal items are
// accessed directly from their source module within the `web/` hierarchy.
pub(crate) use error::{open_handle, ApiError};
pub(crate) use kg_routes::DreamStatusPayload;
pub(crate) use palace_routes::{load_user_config, palace_info_from};
pub(crate) use rpc::creator_info_from_http;

/// Dedicated palace id used by the `/health` round-trip probe (issue #185).
///
/// Why: Earlier revisions of `run_health_round_trip` picked whichever palace
/// happened to be first on disk (APFS creation order on macOS), which meant
/// the probe always wrote — and, if recall failed, *leaked* — a drawer in a
/// real user-facing palace. Routing the probe to a dedicated palace whose id
/// starts with the reserved `__` prefix means leaked drawers are confined to a
/// palace the user never sees (filtered by `MemoryService::list_palaces`) and
/// real palaces stay clean.
/// What: A constant `&str` reused by the probe and tests. The leading double
/// underscore is the project-wide convention for "system" palaces hidden from
/// user listings.
/// Test: `health_probe_palace_is_invisible`, `health_probe_cleans_up_on_success`,
/// `health_probe_cleans_up_on_recall_miss`.
pub(crate) const HEALTH_PROBE_PALACE: &str = "__health_probe__";

/// Build the public router with API routes + SPA asset fallback.
///
/// Why: `run_http` calls this so the same router shape is used in tests.
/// What: All API routes under `/api/v1`, fallback to the SPA shell. Trusts only
/// loopback for the write guard (the `run_http_on` startup path uses
/// [`router_with_self_origins`] with the resolved bind address).
/// Test: `serves_index_html_fallback` and `status_endpoint_returns_payload`.
pub fn router() -> Router<AppState> {
    // #3304: loopback-only default. Every test keeps this entry point; the
    // daemon startup passes its resolved bind address (see `http_server`).
    router_with_self_origins(trusty_common::server::SelfOrigins::default())
}

/// Build the public router, additionally trusting the given bind-derived,
/// non-loopback self-origins for the router-wide same-origin write guard (#3304).
///
/// Why: trusty-memory exposes DESTRUCTIVE write routes behind the permissive-CORS
/// shared stack — `DELETE /api/v1/palaces/{id}` (palace deletion),
/// `DELETE …/drawers/{drawer_id}` (drawer deletion), `POST /api/v1/admin/stop`,
/// `POST /rpc` (the full JSON-RPC tool surface — every mutating MCP tool),
/// `POST /api/v1/dream/run`, KG asserts/deletes. Without a same-origin guard any
/// page the operator visits could drive them cross-origin (CSRF). This is the
/// guarded entry point; `router` delegates here loopback-only so existing tests
/// are unchanged. `run_http_on` passes the resolved bind address so a
/// non-loopback bind trusts itself (#3269).
/// What: identical router to `router`, except the final middleware is
/// `with_guarded_middleware` (guard + standard stack) instead of
/// `with_standard_middleware`.
/// Test: `web::tests` guard tests (cross-origin `DELETE /api/v1/palaces/{id}` and
/// `POST /rpc` → 403; loopback/missing-Origin → allowed; GET read unaffected).
pub fn router_with_self_origins(
    self_origins: trusty_common::server::SelfOrigins,
) -> Router<AppState> {
    // axum 0.8 path syntax uses `{param}` instead of `:param`. The shared
    // `trusty_common::server::with_standard_middleware` layer brings in CORS,
    // tracing, and gzip (with SSE excluded) so we don't drift from sibling
    // trusty-* daemons.
    let router = Router::new()
        .route("/api/v1/status", get(palace_routes::status))
        .route("/api/v1/config", get(palace_routes::config))
        .route(
            "/api/v1/palaces",
            get(palace_routes::list_palaces).post(palace_routes::create_palace),
        )
        .route(
            "/api/v1/palaces/{id}",
            get(palace_routes::get_palace_handler)
                .delete(palace_routes::delete_palace_handler)
                .patch(palace_routes::update_palace_handler),
        )
        .route(
            "/api/v1/palaces/{id}/drawers",
            get(palace_routes::list_drawers).post(palace_routes::create_drawer),
        )
        .route(
            "/api/v1/palaces/{id}/drawers/{drawer_id}",
            delete(palace_routes::delete_drawer),
        )
        // Issue #70 — `/memories` is a backward-compatible alias for `/drawers`.
        // Some clients (and earlier docs) POST/GET against `…/memories`, which
        // 404'd because only `/drawers` was registered. Aliasing here keeps
        // both vocabularies working against the same handlers without breaking
        // existing `/drawers` callers.
        .route(
            "/api/v1/palaces/{id}/memories",
            get(palace_routes::list_drawers).post(palace_routes::create_drawer),
        )
        .route(
            "/api/v1/palaces/{id}/memories/{drawer_id}",
            delete(palace_routes::delete_drawer),
        )
        .route(
            "/api/v1/palaces/{id}/recall",
            get(recall_routes::recall_handler),
        )
        .route("/api/v1/recall", get(recall_routes::recall_all_handler))
        .route(
            "/api/v1/palaces/{id}/kg",
            get(kg_routes::kg_query).post(kg_routes::kg_assert),
        )
        .route(
            "/api/v1/palaces/{id}/kg/subjects",
            get(kg_routes::kg_list_subjects),
        )
        .route(
            "/api/v1/palaces/{id}/kg/subjects_with_counts",
            get(kg_routes::kg_list_subjects_with_counts),
        )
        .route("/api/v1/palaces/{id}/kg/all", get(kg_routes::kg_list_all))
        .route("/api/v1/palaces/{id}/kg/graph", get(kg_routes::kg_graph))
        // #4670: progressive graph loading — seed on first paint, expand on click.
        .route(
            "/api/v1/palaces/{id}/kg/graph/seed",
            get(kg_routes::kg_graph_seed),
        )
        .route(
            "/api/v1/palaces/{id}/kg/graph/neighbors",
            get(kg_routes::kg_graph_neighbors),
        )
        .route("/api/v1/palaces/{id}/kg/count", get(kg_routes::kg_count))
        .route(
            "/api/v1/palaces/{id}/kg/triples/{triple_id}",
            delete(kg_routes::kg_delete_triple),
        )
        .route(
            "/api/v1/palaces/{id}/dream/status",
            get(kg_routes::palace_dream_status),
        )
        .route("/api/v1/dream/status", get(kg_routes::dream_status))
        .route("/api/v1/dream/run", post(kg_routes::dream_run))
        .route("/api/v1/kg/gaps", get(prompt_context::kg_gaps_handler))
        .route(
            "/api/v1/kg/prompt-context",
            get(prompt_context::prompt_context_handler),
        )
        .route(
            "/api/v1/kg/aliases",
            post(prompt_context::add_alias_handler),
        )
        .route(
            "/api/v1/kg/prompt-facts",
            get(prompt_context::list_prompt_facts_handler)
                .delete(prompt_context::remove_prompt_fact_handler),
        )
        .route("/api/v1/chat", post(crate::chat::chat_handler))
        .route("/api/v1/chat/providers", get(crate::chat::list_providers))
        .route(
            "/api/v1/palaces/{id}/chat/sessions",
            get(crate::chat::list_chat_sessions).post(crate::chat::create_chat_session),
        )
        .route(
            "/api/v1/palaces/{id}/chat/sessions/{session_id}",
            get(crate::chat::get_chat_session).delete(crate::chat::delete_chat_session),
        )
        // Issue #99: inter-project messaging.
        .route(
            "/api/v1/messages",
            get(crate::chat::list_messages_handler).post(crate::chat::send_message_handler),
        )
        .route(
            "/api/v1/messages/mark_read",
            post(crate::chat::mark_message_read_handler),
        )
        .route("/health", get(health::health))
        .route("/api/v1/logs/tail", get(admin::logs_tail))
        .route("/api/v1/activity", get(activity::activity_handler))
        .route(
            "/api/v1/activity/hook",
            post(activity::hook_activity_handler),
        )
        .route("/api/v1/admin/stop", post(admin::admin_stop))
        // Issue: fire-and-forget memory save for callers that cannot speak
        // MCP. Sub-agents spawned via Claude Code's Agent tool inherit no
        // MCP connections, so `memory_remember` is unreachable to them.
        // This endpoint lets the agent shell out to `trusty-memory note`
        // (which in turn POSTs here) and the request returns 202 the moment
        // the body is parsed — the actual `memory_remember` dispatch runs
        // on a detached `tokio::spawn`. Failures are logged at warn but
        // never surface to the caller because the contract is one-way.
        .route("/api/v1/remember", post(admin::remember_async))
        // Multi-transport refactor: a single JSON-RPC 2.0 endpoint that
        // accepts the same envelopes the UDS transport speaks. Lets
        // browser clients, curl, and the stdio bridge fallback hit the
        // tool surface without learning the REST routes. The REST
        // routes above remain for backwards compatibility.
        .route("/rpc", post(rpc::rpc_handler))
        // #3304: `/sse` (a GET event stream) is registered HERE, inside the
        // router, so it sits UNDER the guard/standard-middleware layer applied
        // below — not chained on by `run_http_on` AFTER the layer (where axum's
        // `layer` contract would leave it middleware-less). It is a safe GET so
        // the guard never blocks it; placing it here keeps "router-wide after
        // all registration" literally true and avoids the layering footgun.
        .route("/sse", get(crate::http_server::sse_handler))
        .fallback(static_assets::static_handler);

    // #3304: router-wide same-origin write guard, applied AFTER all route
    // registration so every destructive route (palace/drawer deletion, admin
    // stop, the `/rpc` tool surface) is covered.
    trusty_common::server::with_guarded_middleware(router, self_origins)
}

#[cfg(test)]
mod tests;