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::{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 routes;
42mod state;
43
44pub use error::{Error, RemoteError};
45pub use handlers::derive_max_path_bytes;
46pub use metrics::Metrics;
47pub use state::AppState;
48
49/// Gap 10 Phase-1 public surface: exposed for integration tests and
50/// operators who need to observe / override the Leiden cache policy.
51pub mod leiden_state {
52 pub use crate::state::{
53 COMMIT_LATENCY_WINDOW, COMMIT_STORM_CAP_PER_MIN, DEBOUNCE_FLOOR_MS, DELTA_RATIO_FORCE_FULL,
54 GRAPH_SIZE_GATE_V, LeidenCache, LeidenMode, derive_debounce_ms,
55 };
56}
57
58/// Options consumed by [`app_with_options`]. Fields default to the
59/// same values `app` derives from the environment; tests construct an
60/// explicit value to bypass the env-var read.
61#[derive(Clone, Debug, Default)]
62pub struct AppOptions {
63 /// Override for `MNEM_BENCH`. `None` means "read the env var"; set
64 /// to `Some(true)` in integration tests that exercise the label
65 /// round-trip without polluting the process-wide environment.
66 pub allow_labels: Option<bool>,
67 /// When true, use `MemoryBlockstore` + `MemoryOpHeadsStore` instead
68 /// of the redb-backed on-disk store. All commits live in RAM and
69 /// are lost on process exit. Intended for benchmark harnesses and
70 /// ephemeral agent sessions where durability is undesired and
71 /// commit throughput matters (redb fsync can be 30-40x slower than
72 /// memory per commit; see internal benchmarking). Never
73 /// enable this in a deployment that needs to survive restart.
74 pub in_memory: bool,
75 /// Mount the `/metrics` Prometheus endpoint.
76 ///
77 /// `true` mounts the route; `false` omits it entirely (scrapes
78 /// get a 404). The tracking middleware that populates the
79 /// counters still runs either way -- flipping this on at the next
80 /// restart begins exposing already-collected data.
81 pub metrics_enabled: bool,
82}
83
84/// Build the router for a repo whose `.mnem/` lives at `repo_dir`.
85/// Opens or initialises the redb; returns the router you `serve()`.
86pub fn app(repo_dir: &Path) -> Result<Router> {
87 app_with_options(repo_dir, AppOptions::default())
88}
89
90/// audit-2026-04-25 P2-7: enumerate every route the router mounts so
91/// the startup banner in `mnem http` main is no longer hand-written
92/// and incomplete. Each entry is `(METHOD-LIST, PATH, brief)`. Kept
93/// in sync with the `Router::new().route(...)` chain in
94/// `app_with_options` by colocating the data here; tests in
95/// `tests/banner_route_table.rs` assert the count matches the
96/// router's route count.
97pub fn route_table(metrics_enabled: bool) -> Vec<(&'static str, &'static str, &'static str)> {
98 let mut routes: Vec<(&'static str, &'static str, &'static str)> = vec![
99 ("GET", "/v1/healthz", "liveness probe"),
100 (
101 "GET",
102 "/v1/stats",
103 "head op-id, commit CID, ref + label counts",
104 ),
105 ("POST", "/v1/nodes", "commit a new node"),
106 (
107 "POST",
108 "/v1/nodes/bulk",
109 "commit N nodes in one transaction",
110 ),
111 ("GET/DELETE", "/v1/nodes/{id}", "fetch / delete a node"),
112 ("POST", "/v1/nodes/{id}/tombstone", "tombstone a node"),
113 ("GET/POST", "/v1/retrieve", "agent-facing retrieval"),
114 (
115 "POST",
116 "/v1/ingest",
117 "ingest a Markdown / PDF / JSON source",
118 ),
119 ("POST", "/v1/explain", "explain a retrieve result"),
120 (
121 "POST",
122 "/v1/traverse_answer",
123 "single-call multihop (gated)",
124 ),
125 ("GET", "/remote/v1/refs", "transport: list refs"),
126 ("POST", "/remote/v1/fetch-blocks", "transport: fetch blocks"),
127 (
128 "POST",
129 "/remote/v1/push-blocks",
130 "transport: push blocks (auth)",
131 ),
132 (
133 "POST",
134 "/remote/v1/advance-head",
135 "transport: advance head (auth)",
136 ),
137 ];
138 if metrics_enabled {
139 routes.push(("GET", "/metrics", "Prometheus text-exposition"));
140 }
141 routes
142}
143
144/// [`app`] with programmatic overrides. Used by integration tests so
145/// they can flip `allow_labels` without touching the environment.
146pub fn app_with_options(repo_dir: &Path, opts: AppOptions) -> Result<Router> {
147 let data_dir = if repo_dir.ends_with(".mnem") {
148 repo_dir.to_path_buf()
149 } else {
150 repo_dir.join(".mnem")
151 };
152 std::fs::create_dir_all(&data_dir)?;
153 let (bs, ohs): (
154 std::sync::Arc<dyn mnem_core::store::Blockstore>,
155 std::sync::Arc<dyn mnem_core::store::OpHeadsStore>,
156 ) = if opts.in_memory {
157 // Ephemeral in-memory mode. `repo_dir` is still used (for
158 // `config.toml` load), but nothing persists to disk. Loud
159 // stderr warning so an operator who flipped the flag by
160 // accident sees it immediately.
161 eprintln!(
162 "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."
163 );
164 (
165 std::sync::Arc::new(mnem_core::store::MemoryBlockstore::new()),
166 std::sync::Arc::new(mnem_core::store::MemoryOpHeadsStore::new()),
167 )
168 } else {
169 let (bs, ohs, _file) = open_or_init(&data_dir.join("repo.redb"))?;
170 (bs as _, ohs as _)
171 };
172 let repo = ReadonlyRepo::open(bs.clone(), ohs.clone()).or_else(|e| {
173 if e.is_uninitialized() {
174 ReadonlyRepo::init(bs.clone(), ohs.clone())
175 } else {
176 Err(e)
177 }
178 })?;
179
180 // Resolve embed + sparse + NER provider configs from the repo's
181 // config.toml, if any. When present, ingest and retrieve paths
182 // auto-run the corresponding provider so hybrid dense + sparse
183 // retrieval fires end-to-end (same behaviour as the CLI).
184 let embed_cfg = load_embed_config(&data_dir);
185 let sparse_cfg = load_sparse_config(&data_dir);
186 let ner_cfg = load_ner_config(&data_dir);
187
188 // `allow_labels` is gated behind the `MNEM_BENCH` env var. Off by
189 // default so casual / single-tenant callers never stumble into
190 // label-scoped state. Benchmark harnesses opt in by launching the
191 // server with `MNEM_BENCH=1` (see docs/benchmarks/RUNNING.md).
192 // Tests skip the env read by passing an explicit override.
193 let allow_labels = opts
194 .allow_labels
195 .unwrap_or_else(AppState::resolve_allow_labels_from_env);
196 if allow_labels && opts.allow_labels.is_none() {
197 eprintln!(
198 "mnem http: MNEM_BENCH set; caller-supplied `label` fields will be honoured on ingest and retrieve."
199 );
200 }
201
202 // Remote-push bearer token lives in env only (MNEM_HTTP_PUSH_TOKEN),
203 // never on disk. `None` disables the two authenticated `/remote/v1/*`
204 // verbs (fail-closed 503). See crate::auth for the extractor.
205 let push_token = AppState::resolve_push_token_from_env();
206 if push_token.is_some() {
207 tracing::info!(
208 "mnem http: MNEM_HTTP_PUSH_TOKEN configured; /remote/v1/push-blocks + /remote/v1/advance-head enabled."
209 );
210 } else {
211 tracing::info!(
212 "mnem http: MNEM_HTTP_PUSH_TOKEN not set; remote write verbs administratively disabled (503)."
213 );
214 }
215
216 let state = AppState {
217 repo: Arc::new(Mutex::new(repo)),
218 embed_cfg,
219 sparse_cfg,
220 indexes: Arc::new(Mutex::new(state::IndexCache::default())),
221 allow_labels,
222 metrics: Metrics::new(),
223 push_token,
224 graph_cache: Arc::new(Mutex::new(state::GraphCache::default())),
225 traverse_cfg: Arc::new(routes::traverse::TraverseAnswerCfg::default()),
226 ner_cfg,
227 };
228
229 // Permissive CORS for v1: the server binds to loopback by default
230 // anyway, and browser clients need CORS to talk to us at all. Users
231 // exposing mnem http to the network must front it with an auth proxy.
232 let cors = CorsLayer::new()
233 .allow_methods(Any)
234 .allow_headers(Any)
235 .allow_origin(Any);
236
237 // Request-body size cap. axum 0.7's `Json<T>` default is 2 MiB,
238 // which is fine for `POST /v1/nodes` but too small for
239 // `POST /v1/nodes/bulk` batches (128 nodes * real summaries can
240 // comfortably exceed 2 MiB). Default here is 64 MiB, overridable
241 // via `MNEM_MAX_BODY_MB` for operators who want stricter DoS
242 // posture or looser batch ingests.
243 let body_limit_bytes: usize = std::env::var("MNEM_MAX_BODY_MB")
244 .ok()
245 .and_then(|s| s.parse::<usize>().ok())
246 .unwrap_or(64)
247 .saturating_mul(1024 * 1024);
248
249 let mut router = Router::new()
250 .route("/v1/healthz", get(handlers::healthz))
251 .route("/v1/stats", get(handlers::stats))
252 .route("/v1/nodes", post(handlers::post_node))
253 .route("/v1/nodes/bulk", post(handlers::post_nodes_bulk))
254 .route(
255 "/v1/nodes/{id}",
256 get(handlers::get_node).delete(handlers::delete_node),
257 )
258 .route("/v1/nodes/{id}/tombstone", post(handlers::tombstone_node))
259 .route(
260 "/v1/retrieve",
261 get(handlers::retrieve).post(handlers::retrieve_full),
262 )
263 .route("/v1/ingest", post(handlers_ingest::ingest))
264 .route("/v1/explain", post(handlers::explain))
265 // gap-09: `/v1/traverse_answer` is registered but gated by
266 // `experimental.single_call_multihop` (default OFF). With the
267 // flag off the handler returns 410 Gone + opt-in pointer; with
268 // it on the full hop-loop runs. See routes/traverse.rs.
269 .route(
270 "/v1/traverse_answer",
271 post(routes::traverse::traverse_answer),
272 )
273 // `/remote/v1/*` transport surface . Auth is
274 // enforced per-handler via the `RequireBearer` extractor
275 // (see crate::auth), not via a tower layer, so the
276 // read-open verbs (`refs`, `fetch-blocks`) stay reachable
277 // without a token.
278 .route("/remote/v1/refs", get(routes::remote::get_refs))
279 .route(
280 "/remote/v1/fetch-blocks",
281 post(routes::remote::post_fetch_blocks),
282 )
283 .route(
284 "/remote/v1/push-blocks",
285 post(routes::remote::post_push_blocks),
286 )
287 .route(
288 "/remote/v1/advance-head",
289 post(routes::remote::post_advance_head),
290 );
291 if opts.metrics_enabled {
292 // `/metrics` is intentionally NOT under `/v1/` so a Prometheus
293 // scrape config that targets the canonical path works without
294 // a per-service rewrite. The Prometheus convention wins here
295 // over the schema-versioning we use for the v1 JSON surface.
296 router = router.route("/metrics", get(metrics::metrics_handler));
297 }
298 // Layer order (applied outside-in in axum 0.8):
299 //
300 // correlation_id <- outermost; runs FIRST on every request,
301 // LAST on every response. Mints / reuses
302 // the id so track_metrics + handlers + the
303 // `tower_http::trace` layer all see a span
304 // with `correlation_id=...` attached.
305 // track_metrics <- counts/times the request.
306 // DefaultBodyLimit <- 64 MiB cap (see MNEM_MAX_BODY_MB above).
307 // cors <- permissive for v1 loopback deploy.
308 // TraceLayer <- `tower_http` request/response tracing;
309 // inherits our correlation_id span because
310 // `Instrument` propagates.
311 Ok(router
312 .layer(axum::middleware::from_fn_with_state(
313 state.clone(),
314 metrics::track_metrics,
315 ))
316 .layer(DefaultBodyLimit::max(body_limit_bytes))
317 // audit-2026-04-25 P2-6: rewrite axum's default 422 plain-text
318 // Json<T> deserialize errors into the mnem.v1.err envelope so
319 // clients never see a non-schema response.
320 .layer(axum::middleware::from_fn(error::json_rejection_envelope))
321 .layer(cors)
322 .layer(TraceLayer::new_for_http())
323 .layer(axum::middleware::from_fn(correlation::correlation_id))
324 .with_state(state))
325}
326
327/// Load `embed` section from `<data_dir>/config.toml` if it exists.
328/// Returns `None` on any error so a malformed config never prevents
329/// the server from starting; auto-embed just stays off.
330fn load_embed_config(data_dir: &Path) -> Option<mnem_embed_providers::ProviderConfig> {
331 #[derive(serde::Deserialize)]
332 struct MiniCfg {
333 embed: Option<mnem_embed_providers::ProviderConfig>,
334 }
335 let path = data_dir.join("config.toml");
336 let s = std::fs::read_to_string(&path).ok()?;
337 match toml::from_str::<MiniCfg>(&s) {
338 Ok(parsed) => parsed.embed,
339 Err(e) => {
340 // A malformed [embed] section is a common misconfig; log
341 // it so the operator can fix instead of silently running
342 // without auto-embed.
343 tracing::warn!(
344 path = %path.display(),
345 error = %e,
346 "config.toml [embed] parse failed; auto-embed disabled"
347 );
348 None
349 }
350 }
351}
352
353/// Load `sparse` section from `<data_dir>/config.toml` if it exists.
354/// When present, ingest paths auto-populate `Node.sparse_embed` and
355/// retrieve paths auto-encode the query via the sparse provider. Same
356/// "None on malformed config" policy as `load_embed_config`.
357fn load_sparse_config(data_dir: &Path) -> Option<mnem_sparse_providers::ProviderConfig> {
358 #[derive(serde::Deserialize)]
359 struct MiniCfg {
360 sparse: Option<mnem_sparse_providers::ProviderConfig>,
361 }
362 let path = data_dir.join("config.toml");
363 let s = std::fs::read_to_string(&path).ok()?;
364 match toml::from_str::<MiniCfg>(&s) {
365 Ok(parsed) => parsed.sparse,
366 Err(e) => {
367 tracing::warn!(
368 path = %path.display(),
369 error = %e,
370 "config.toml [sparse] parse failed; sparse auto-encode disabled"
371 );
372 None
373 }
374 }
375}
376
377/// Load `ner` section from `<data_dir>/config.toml` if it exists.
378/// `None` means ingest paths will use `NerConfig::Rule` (the default).
379/// Also respects `MNEM_NER_PROVIDER` env var: "none" → `NerConfig::None`,
380/// any other value → `NerConfig::Rule`.
381fn load_ner_config(data_dir: &Path) -> Option<mnem_ingest::NerConfig> {
382 if let Ok(p) = std::env::var("MNEM_NER_PROVIDER") {
383 return Some(match p.to_ascii_lowercase().as_str() {
384 "none" => mnem_ingest::NerConfig::None,
385 _ => mnem_ingest::NerConfig::Rule,
386 });
387 }
388 #[derive(serde::Deserialize)]
389 struct MiniCfg {
390 ner: Option<mnem_ingest::NerConfig>,
391 }
392 let path = data_dir.join("config.toml");
393 let s = std::fs::read_to_string(&path).ok()?;
394 match toml::from_str::<MiniCfg>(&s) {
395 Ok(parsed) => parsed.ner,
396 Err(e) => {
397 tracing::warn!(
398 path = %path.display(),
399 error = %e,
400 "config.toml [ner] parse failed; NER defaults to rule-based"
401 );
402 None
403 }
404 }
405}