Skip to main content

mnem_http/
lib.rs

1//! HTTP JSON API for mnem.
2//!
3//! Library half of the `mnem http` binary. `app(repo_dir)` builds an
4//! axum `Router` that wraps an open [`ReadonlyRepo`] on `repo_dir/.mnem`
5//! (auto-initialising if needed).
6//!
7//! Scope v1:
8//! - `GET /v1/healthz` - liveness probe.
9//! - `GET /v1/stats` - head op-id, commit CID, ref + label counts.
10//! - `POST /v1/nodes` - commit a new node (label + summary + props).
11//! - `GET /v1/nodes/{id}` - fetch one node by UUID.
12//! - `DELETE /v1/nodes/{id}` - commit a removal of one node.
13//! - `GET /v1/retrieve?text=&budget=&limit=` - dense vector retrieval
14//!   (embedder required when `text` is set). Returns rendered items
15//!   plus budget metadata.
16//!
17//! Tokio lives ONLY in this crate. `mnem-core` stays WASM-clean.
18//! This crate compiles to a single binary + library pair.
19
20#![forbid(unsafe_code)]
21#![deny(missing_docs)]
22
23use std::path::Path;
24use std::sync::{Arc, Mutex};
25
26use anyhow::Result;
27use axum::Router;
28use axum::extract::DefaultBodyLimit;
29use axum::routing::{delete, get, post};
30use mnem_backend_redb::open_or_init;
31use mnem_core::repo::ReadonlyRepo;
32use tower_http::cors::{Any, CorsLayer};
33use tower_http::trace::TraceLayer;
34
35mod auth;
36mod correlation;
37mod error;
38mod handlers;
39mod handlers_ingest;
40mod metrics;
41mod remote_rate_limit;
42mod routes;
43mod state;
44
45pub use error::{Error, RemoteError};
46pub use handlers::derive_max_path_bytes;
47pub use metrics::Metrics;
48pub use state::AppState;
49
50/// Gap 10 Phase-1 public surface: exposed for integration tests and
51/// operators who need to observe / override the Leiden cache policy.
52pub mod leiden_state {
53    pub use crate::state::{
54        COMMIT_LATENCY_WINDOW, COMMIT_STORM_CAP_PER_MIN, DEBOUNCE_FLOOR_MS, DELTA_RATIO_FORCE_FULL,
55        GRAPH_SIZE_GATE_V, LeidenCache, LeidenMode, derive_debounce_ms,
56    };
57}
58
59/// Options consumed by [`app_with_options`]. Fields default to the
60/// same values `app` derives from the environment; tests construct an
61/// explicit value to bypass the env-var read.
62#[derive(Clone, Debug, Default)]
63pub struct AppOptions {
64    /// Override for `MNEM_BENCH`. `None` means "read the env var"; set
65    /// to `Some(true)` in integration tests that exercise the label
66    /// round-trip without polluting the process-wide environment.
67    pub allow_labels: Option<bool>,
68    /// When true, use `MemoryBlockstore` + `MemoryOpHeadsStore` instead
69    /// of the redb-backed on-disk store. All commits live in RAM and
70    /// are lost on process exit. Intended for benchmark harnesses and
71    /// ephemeral agent sessions where durability is undesired and
72    /// commit throughput matters (redb fsync can be 30-40x slower than
73    /// memory per commit; see internal benchmarking). Never
74    /// enable this in a deployment that needs to survive restart.
75    pub in_memory: bool,
76    /// Mount the `/metrics` Prometheus endpoint.
77    ///
78    /// `true` mounts the route; `false` omits it entirely (scrapes
79    /// get a 404). The tracking middleware that populates the
80    /// counters still runs either way -- flipping this on at the next
81    /// restart begins exposing already-collected data.
82    pub metrics_enabled: bool,
83    /// Override for `MNEM_HTTP_PUSH_TOKEN`. `None` means "read the env
84    /// var"; set to `Some("tok".into())` in integration tests that need
85    /// to exercise authenticated write routes without touching the
86    /// process-wide environment.
87    pub push_token: Option<String>,
88}
89
90/// Build the router for a repo whose `.mnem/` lives at `repo_dir`.
91/// Opens or initialises the redb; returns the router you `serve()`.
92pub fn app(repo_dir: &Path) -> Result<Router> {
93    app_with_options(repo_dir, AppOptions::default())
94}
95
96/// audit-2026-04-25 P2-7: enumerate every route the router mounts so
97/// the startup banner in `mnem http` main is no longer hand-written
98/// and incomplete. Each entry is `(METHOD-LIST, PATH, brief)`. Kept
99/// in sync with the `Router::new().route(...)` chain in
100/// `app_with_options` by colocating the data here; tests in
101/// `tests/banner_route_table.rs` assert the count matches the
102/// router's route count.
103pub fn route_table(metrics_enabled: bool) -> Vec<(&'static str, &'static str, &'static str)> {
104    let mut routes: Vec<(&'static str, &'static str, &'static str)> = vec![
105        ("GET", "/v1/healthz", "liveness probe"),
106        (
107            "GET",
108            "/v1/stats",
109            "head op-id, commit CID, ref + label counts",
110        ),
111        (
112            "GET",
113            "/v1/log",
114            "op-log history (limit, format=json|oneline|full)",
115        ),
116        (
117            "GET",
118            "/v1/export",
119            "export all reachable blocks as NDJSON (hex-encoded)",
120        ),
121        (
122            "GET",
123            "/v1/blocks/{cid}",
124            "fetch one raw block by CID (format=json|raw|cbor)",
125        ),
126        (
127            "POST",
128            "/v1/import",
129            "import blocks from NDJSON stream (block-level sync)",
130        ),
131        (
132            "POST",
133            "/v1/diff",
134            "structural diff between two commits or ops",
135        ),
136        (
137            "POST",
138            "/v1/merge",
139            "3-way merge two commit CIDs; returns fast_forward / clean / conflicts",
140        ),
141        ("POST", "/v1/nodes", "commit a new node"),
142        (
143            "POST",
144            "/v1/nodes/bulk",
145            "commit N nodes in one transaction",
146        ),
147        ("POST", "/v1/edges", "commit a new directed edge"),
148        ("GET/DELETE", "/v1/nodes/{id}", "fetch / delete a node"),
149        (
150            "GET",
151            "/v1/nodes/{id}/embedding",
152            "fetch embedding vector for a node by model",
153        ),
154        ("POST", "/v1/nodes/{id}/tombstone", "tombstone a node"),
155        ("GET/POST", "/v1/retrieve", "agent-facing retrieval"),
156        (
157            "POST",
158            "/v1/ingest",
159            "ingest a Markdown / PDF / JSON source",
160        ),
161        ("POST", "/v1/explain", "explain a retrieve result"),
162        (
163            "POST",
164            "/v1/traverse_answer",
165            "single-call multihop (gated)",
166        ),
167        ("GET/POST", "/v1/branches", "list / create branches"),
168        ("DELETE", "/v1/branches/{*name}", "delete a branch by name"),
169        ("GET/POST", "/v1/tags", "list / create tags"),
170        ("DELETE", "/v1/tags/{*name}", "delete a tag by name"),
171        ("GET", "/remote/v1/refs", "transport: list refs"),
172        ("POST", "/remote/v1/fetch-blocks", "transport: fetch blocks"),
173        (
174            "POST",
175            "/remote/v1/push-blocks",
176            "transport: push blocks (auth)",
177        ),
178        (
179            "POST",
180            "/remote/v1/advance-head",
181            "transport: advance head (auth)",
182        ),
183    ];
184    if metrics_enabled {
185        routes.push(("GET", "/metrics", "Prometheus text-exposition"));
186    }
187    routes
188}
189
190/// [`app`] with programmatic overrides. Used by integration tests so
191/// they can flip `allow_labels` without touching the environment.
192pub fn app_with_options(repo_dir: &Path, opts: AppOptions) -> Result<Router> {
193    let data_dir = if repo_dir.ends_with(".mnem") {
194        repo_dir.to_path_buf()
195    } else {
196        repo_dir.join(".mnem")
197    };
198    std::fs::create_dir_all(&data_dir)?;
199    let (bs, ohs): (
200        std::sync::Arc<dyn mnem_core::store::Blockstore>,
201        std::sync::Arc<dyn mnem_core::store::OpHeadsStore>,
202    ) = if opts.in_memory {
203        // Ephemeral in-memory mode. `repo_dir` is still used (for
204        // `config.toml` load), but nothing persists to disk. Loud
205        // stderr warning so an operator who flipped the flag by
206        // accident sees it immediately.
207        eprintln!(
208            "mnem http: --in-memory ACTIVE. All commits are RAM-only and lost on process exit. This is intended for benchmarks and ephemeral sessions; NEVER use in a durable deployment."
209        );
210        (
211            std::sync::Arc::new(mnem_core::store::MemoryBlockstore::new()),
212            std::sync::Arc::new(mnem_core::store::MemoryOpHeadsStore::new()),
213        )
214    } else {
215        let (bs, ohs, _file) = open_or_init(&data_dir.join("repo.redb"))?;
216        (bs as _, ohs as _)
217    };
218    let repo = ReadonlyRepo::open(bs.clone(), ohs.clone()).or_else(|e| {
219        if e.is_uninitialized() {
220            ReadonlyRepo::init(bs.clone(), ohs.clone())
221        } else {
222            Err(e)
223        }
224    })?;
225
226    // Resolve embed + sparse + NER provider configs from the repo's
227    // config.toml, if any. When present, ingest and retrieve paths
228    // auto-run the corresponding provider so hybrid dense + sparse
229    // retrieval fires end-to-end (same behaviour as the CLI).
230    let embed_cfg = load_embed_config(&data_dir);
231    let sparse_cfg = load_sparse_config(&data_dir);
232    let ner_cfg = load_ner_config(&data_dir);
233
234    // `allow_labels` is gated behind the `MNEM_BENCH` env var. Off by
235    // default so casual / single-tenant callers never stumble into
236    // label-scoped state. Benchmark harnesses opt in by launching the
237    // server with `MNEM_BENCH=1` (see docs/benchmarks/RUNNING.md).
238    // Tests skip the env read by passing an explicit override.
239    let allow_labels = opts
240        .allow_labels
241        .unwrap_or_else(AppState::resolve_allow_labels_from_env);
242    if allow_labels && opts.allow_labels.is_none() {
243        eprintln!(
244            "mnem http: MNEM_BENCH set; caller-supplied `label` fields will be honoured on ingest and retrieve."
245        );
246    }
247
248    // Remote-push bearer token lives in env only (MNEM_HTTP_PUSH_TOKEN),
249    // never on disk. `None` disables the two authenticated `/remote/v1/*`
250    // verbs (fail-closed 503). See crate::auth for the extractor.
251    let push_token = opts
252        .push_token
253        .clone()
254        .or_else(AppState::resolve_push_token_from_env);
255    if push_token.is_some() {
256        tracing::info!(
257            "mnem http: MNEM_HTTP_PUSH_TOKEN configured; /remote/v1/push-blocks + /remote/v1/advance-head enabled."
258        );
259    } else {
260        tracing::info!(
261            "mnem http: MNEM_HTTP_PUSH_TOKEN not set; remote write verbs administratively disabled (503)."
262        );
263    }
264
265    let state = AppState {
266        repo: Arc::new(Mutex::new(repo)),
267        embed_cfg,
268        sparse_cfg,
269        indexes: Arc::new(Mutex::new(state::IndexCache::default())),
270        allow_labels,
271        metrics: Metrics::new(),
272        push_token,
273        graph_cache: Arc::new(Mutex::new(state::GraphCache::default())),
274        traverse_cfg: Arc::new(routes::traverse::TraverseAnswerCfg::default()),
275        ner_cfg,
276    };
277
278    // Permissive CORS for v1: the server binds to loopback by default
279    // anyway, and browser clients need CORS to talk to us at all. Users
280    // exposing mnem http to the network must front it with an auth proxy.
281    let cors = CorsLayer::new()
282        .allow_methods(Any)
283        .allow_headers(Any)
284        .allow_origin(Any);
285
286    // Request-body size cap. axum 0.7's `Json<T>` default is 2 MiB,
287    // which is fine for `POST /v1/nodes` but too small for
288    // `POST /v1/nodes/bulk` batches (128 nodes * real summaries can
289    // comfortably exceed 2 MiB). Default here is 64 MiB, overridable
290    // via `MNEM_MAX_BODY_MB` for operators who want stricter DoS
291    // posture or looser batch ingests.
292    let body_limit_bytes: usize = std::env::var("MNEM_MAX_BODY_MB")
293        .ok()
294        .and_then(|s| s.parse::<usize>().ok())
295        .unwrap_or(64)
296        .saturating_mul(1024 * 1024);
297
298    let mut router = Router::new()
299        .route("/v1/healthz", get(handlers::healthz))
300        .route("/v1/stats", get(handlers::stats))
301        .route("/v1/log", get(handlers::get_log))
302        .route("/v1/export", get(handlers::get_export))
303        .route("/v1/blocks/{cid}", get(handlers::get_block))
304        .route("/v1/import", post(handlers::post_import))
305        .route("/v1/diff", post(handlers::post_diff))
306        .route("/v1/merge", post(handlers::post_merge))
307        .route("/v1/nodes", post(handlers::post_node))
308        .route("/v1/nodes/bulk", post(handlers::post_nodes_bulk))
309        .route("/v1/edges", post(handlers::post_edge))
310        .route(
311            "/v1/nodes/{id}",
312            get(handlers::get_node).delete(handlers::delete_node),
313        )
314        .route(
315            "/v1/nodes/{id}/embedding",
316            get(handlers::get_node_embedding),
317        )
318        .route("/v1/nodes/{id}/tombstone", post(handlers::tombstone_node))
319        .route(
320            "/v1/retrieve",
321            get(handlers::retrieve).post(handlers::retrieve_full),
322        )
323        .route(
324            "/v1/branches",
325            get(handlers::get_branches).post(handlers::post_branch),
326        )
327        .route("/v1/branches/{*name}", delete(handlers::delete_branch))
328        .route("/v1/tags", get(handlers::get_tags).post(handlers::post_tag))
329        .route("/v1/tags/{*name}", delete(handlers::delete_tag))
330        .route("/v1/ingest", post(handlers_ingest::ingest))
331        .route("/v1/explain", post(handlers::explain))
332        // gap-09: `/v1/traverse_answer` is registered but gated by
333        // `experimental.single_call_multihop` (default OFF). With the
334        // flag off the handler returns 410 Gone + opt-in pointer; with
335        // it on the full hop-loop runs. See routes/traverse.rs.
336        .route(
337            "/v1/traverse_answer",
338            post(routes::traverse::traverse_answer),
339        )
340        // `/remote/v1/*` transport surface. Auth is enforced
341        // per-handler via the `RequireBearer` extractor (see
342        // crate::auth), not via a tower layer, so the read-open verbs
343        // (`refs`, `fetch-blocks`) stay reachable without a token.
344        //
345        // BUG-49: Rate limiting is applied to the entire `/remote/v1/*`
346        // group via a token-bucket middleware (100 req/s, burst 50).
347        // The limiter is process-global (not per-IP) because remote
348        // sync clients are trusted peers whose IP space is not
349        // predictable. See `crate::remote_rate_limit` for tunability
350        // via `MNEM_REMOTE_RATE_PER_SEC` / `MNEM_REMOTE_RATE_BURST`.
351        .merge(remote_routes());
352    if opts.metrics_enabled {
353        // `/metrics` is intentionally NOT under `/v1/` so a Prometheus
354        // scrape config that targets the canonical path works without
355        // a per-service rewrite. The Prometheus convention wins here
356        // over the schema-versioning we use for the v1 JSON surface.
357        router = router.route("/metrics", get(metrics::metrics_handler));
358    }
359    // Layer order (applied outside-in in axum 0.8):
360    //
361    //   correlation_id   <- outermost; runs FIRST on every request,
362    //                        LAST on every response. Mints / reuses
363    //                        the id so track_metrics + handlers + the
364    //                        `tower_http::trace` layer all see a span
365    //                        with `correlation_id=...` attached.
366    //   track_metrics    <- counts/times the request.
367    //   DefaultBodyLimit <- 64 MiB cap (see MNEM_MAX_BODY_MB above).
368    //   cors             <- permissive for v1 loopback deploy.
369    //   TraceLayer       <- `tower_http` request/response tracing;
370    //                        inherits our correlation_id span because
371    //                        `Instrument` propagates.
372    Ok(router
373        .layer(axum::middleware::from_fn_with_state(
374            state.clone(),
375            metrics::track_metrics,
376        ))
377        .layer(DefaultBodyLimit::max(body_limit_bytes))
378        // audit-2026-04-25 P2-6: rewrite axum's default 422 plain-text
379        // Json<T> deserialize errors into the mnem.v1.err envelope so
380        // clients never see a non-schema response.
381        .layer(axum::middleware::from_fn(error::json_rejection_envelope))
382        .layer(cors)
383        .layer(TraceLayer::new_for_http())
384        .layer(axum::middleware::from_fn(correlation::correlation_id))
385        .with_state(state))
386}
387
388/// Build the rate-limited `/remote/v1/*` sub-router.
389///
390/// The four remote-protocol verbs are grouped into a dedicated
391/// [`Router`] that has the [`remote_rate_limit::remote_rate_limit_middleware`]
392/// applied via `axum::middleware::from_fn`. The limiter instance is
393/// shared across all four routes via an [`Arc`] clone captured in the
394/// closure; no heap allocation occurs per request beyond the `Arc`
395/// refcount bump.
396///
397/// The resulting router is merged into the main router via
398/// [`Router::merge`] so axum's standard route-dispatch table sees
399/// the `/remote/v1/*` paths alongside `/v1/*`.
400fn remote_routes() -> Router<AppState> {
401    let limiter = Arc::new(remote_rate_limit::RemoteRateLimiter::from_env());
402    Router::new()
403        .route("/remote/v1/refs", get(routes::remote::get_refs))
404        .route(
405            "/remote/v1/fetch-blocks",
406            post(routes::remote::post_fetch_blocks),
407        )
408        .route(
409            "/remote/v1/push-blocks",
410            post(routes::remote::post_push_blocks),
411        )
412        .route(
413            "/remote/v1/advance-head",
414            post(routes::remote::post_advance_head),
415        )
416        .layer(axum::middleware::from_fn(move |req, next| {
417            let limiter = Arc::clone(&limiter);
418            remote_rate_limit::remote_rate_limit_middleware(limiter, req, next)
419        }))
420}
421
422/// Load `embed` section from `<data_dir>/config.toml` if it exists.
423/// Returns `None` on any error so a malformed config never prevents
424/// the server from starting; auto-embed just stays off.
425fn load_embed_config(data_dir: &Path) -> Option<mnem_embed_providers::ProviderConfig> {
426    #[derive(serde::Deserialize)]
427    struct MiniCfg {
428        embed: Option<mnem_embed_providers::ProviderConfig>,
429    }
430    let path = data_dir.join("config.toml");
431    let s = std::fs::read_to_string(&path).ok()?;
432    match toml::from_str::<MiniCfg>(&s) {
433        Ok(parsed) => parsed.embed,
434        Err(e) => {
435            // A malformed [embed] section is a common misconfig; log
436            // it so the operator can fix instead of silently running
437            // without auto-embed.
438            tracing::warn!(
439                path = %path.display(),
440                error = %e,
441                "config.toml [embed] parse failed; auto-embed disabled"
442            );
443            None
444        }
445    }
446}
447
448/// Load `sparse` section from `<data_dir>/config.toml` if it exists.
449/// When present, ingest paths auto-populate `Node.sparse_embed` and
450/// retrieve paths auto-encode the query via the sparse provider. Same
451/// "None on malformed config" policy as `load_embed_config`.
452fn load_sparse_config(data_dir: &Path) -> Option<mnem_sparse_providers::ProviderConfig> {
453    #[derive(serde::Deserialize)]
454    struct MiniCfg {
455        sparse: Option<mnem_sparse_providers::ProviderConfig>,
456    }
457    let path = data_dir.join("config.toml");
458    let s = std::fs::read_to_string(&path).ok()?;
459    match toml::from_str::<MiniCfg>(&s) {
460        Ok(parsed) => parsed.sparse,
461        Err(e) => {
462            tracing::warn!(
463                path = %path.display(),
464                error = %e,
465                "config.toml [sparse] parse failed; sparse auto-encode disabled"
466            );
467            None
468        }
469    }
470}
471
472/// Load `ner` section from `<data_dir>/config.toml` if it exists.
473/// `None` means ingest paths will use `NerConfig::Rule` (the default).
474/// Also respects `MNEM_NER_PROVIDER` env var: "none" → `NerConfig::None`,
475/// any other value → `NerConfig::Rule`.
476fn load_ner_config(data_dir: &Path) -> Option<mnem_ingest::NerConfig> {
477    if let Ok(p) = std::env::var("MNEM_NER_PROVIDER") {
478        return Some(match p.to_ascii_lowercase().as_str() {
479            "none" => mnem_ingest::NerConfig::None,
480            _ => mnem_ingest::NerConfig::Rule,
481        });
482    }
483    #[derive(serde::Deserialize)]
484    struct MiniCfg {
485        ner: Option<mnem_ingest::NerConfig>,
486    }
487    let path = data_dir.join("config.toml");
488    let s = std::fs::read_to_string(&path).ok()?;
489    match toml::from_str::<MiniCfg>(&s) {
490        Ok(parsed) => parsed.ner,
491        Err(e) => {
492            tracing::warn!(
493                path = %path.display(),
494                error = %e,
495                "config.toml [ner] parse failed; NER defaults to rule-based"
496            );
497            None
498        }
499    }
500}