Skip to main content

server/
http.rs

1//! Thin HTTP wrapper over [`SharedDb`]. Every endpoint is a lock, a public
2//! core-api call, then a response — no business logic.
3//!
4//! # Single-sink design
5//!
6//! The HTTP router is the designated broadcast producer. [`router`] installs
7//! one `broadcast::Sender` as the [`core_api::GraphDb`] event sink. MCP and
8//! CLI mutations on the same [`SharedDb`] fire into that same sink (one
9//! producer, many `/watch` subscribers). A second [`router`] call replaces
10//! the sink and terminates every existing subscriber with
11//! [`tokio::sync::broadcast::error::RecvError::Closed`].
12
13use crate::json::{
14    edge_history_result_json, node_edges_json, node_history_json, node_info_json, params_from_json,
15    parse_ingest_edges, result_set_json, rule_def_from_json,
16};
17use crate::{AppState, AuthIdentity};
18use arrow_bridge::to_ipc_bytes;
19use axum::extract::{Extension, Path, Query, Request, State};
20use axum::http::{header, HeaderValue, Method, StatusCode};
21use axum::middleware::{self, Next};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::{Json, Router};
25use core_api::{
26    is_write_query, json_to_rows, json_to_value, AutoFk, BackupReport, BatchOp, DegreeConfig, Dir,
27    GraphError, IngestOptions, MaskMode, NodeMask, PageRankConfig, ResultSet, SharedDb,
28    SuggestConfig, Value, WccConfig, SUGGEST_DEFAULT_SEED,
29};
30use serde_json::{json, Value as Js};
31use std::collections::{BTreeMap, HashMap};
32use std::net::SocketAddr;
33use std::path::PathBuf;
34use tower_http::services::ServeDir;
35
36/// Build the HTTP router over `db`. Read endpoints take the read lock;
37/// `/ingest` takes the write lock. Guards are dropped before any `.await`.
38/// `GET /watch` upgrades to a WebSocket fed by the post-commit sink.
39///
40/// **Call at most once per [`SharedDb`].** A second call replaces the sink
41/// and terminates all existing `/watch` subscribers with
42/// [`tokio::sync::broadcast::error::RecvError::Closed`].
43///
44/// Installing the watch sink replaces any previously installed
45/// [`core_api::GraphDb::set_event_sink`]. The sink only
46/// `broadcast::Sender::send`s (non-blocking) and never re-enters `db`.
47///
48/// # Blocking
49///
50/// [`SharedDb`] uses a std [`std::sync::RwLock`]. Write handlers
51/// (`query_write`, `/ingest`, `create_rule`) run in
52/// `tokio::task::spawn_blocking` and drop the write guard before `.await`.
53/// Reads stay on the worker (neighborhood is µs). `suggest` and `algo`
54/// already use the blocking pool.
55pub fn router(db: SharedDb) -> Router {
56    router_with_auth(db, None)
57}
58
59/// [`router`] with an optional bearer/`?token=` requirement on every route
60/// except unauthenticated `GET /health`.
61///
62/// Role enforcement is not active on this entry point; use
63/// [`router_with_role_tokens`] when role-bound tokens are required.
64pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
65    build_app(
66        db,
67        token,
68        HashMap::new(),
69        UiFallback::None,
70        default_advertise_addr(),
71        false,
72    )
73}
74
75/// [`router`] with a full-access token and a map of role-bound tokens.
76///
77/// Role-bound tokens (`role_tokens`: bearer → role name) receive masked reads
78/// on `/query` and `/node/*`, and 403 on all write or subscription endpoints.
79/// Unknown token: 401.  Token bound to a role not in the DB at request time: 401.
80pub fn router_with_role_tokens(
81    db: SharedDb,
82    token: Option<String>,
83    role_tokens: HashMap<String, String>,
84) -> Router {
85    build_app(
86        db,
87        token,
88        role_tokens,
89        UiFallback::None,
90        default_advertise_addr(),
91        false,
92    )
93}
94
95/// Same as [`router_with_auth`], then `ServeDir` as the fallback so API routes win.
96///
97/// Role enforcement is not active on this entry point; use
98/// [`router_with_role_tokens`] when role-bound tokens are required.
99pub fn router_with_ui(
100    db: SharedDb,
101    ui_dir: impl AsRef<std::path::Path>,
102    token: Option<String>,
103) -> Router {
104    build_app(
105        db,
106        token,
107        HashMap::new(),
108        UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
109        default_advertise_addr(),
110        false,
111    )
112}
113
114/// Like [`router_with_ui`] but marks the connection as TLS-active so the auth
115/// cookie carries the `Secure` attribute. Use when the binary is serving
116/// directly over HTTPS (i.e. via `serve_tls`) rather than behind a proxy.
117pub fn router_with_ui_tls(
118    db: SharedDb,
119    ui_dir: impl AsRef<std::path::Path>,
120    token: Option<String>,
121) -> Router {
122    build_app(
123        db,
124        token,
125        HashMap::new(),
126        UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
127        default_advertise_addr(),
128        true,
129    )
130}
131
132#[cfg(feature = "embed-ui")]
133static EMBEDDED_UI: include_dir::Dir<'_> =
134    include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
135
136/// [`router`] plus the `embed-ui` static tree as fallback.
137#[cfg(feature = "embed-ui")]
138pub fn router_with_embedded_ui(db: SharedDb) -> Router {
139    build_app(
140        db,
141        None,
142        HashMap::new(),
143        UiFallback::Embedded,
144        default_advertise_addr(),
145        false,
146    )
147}
148
149#[cfg(feature = "embed-ui")]
150async fn embedded_fallback(uri: axum::http::Uri) -> Response {
151    let rel = if uri.path() == "/" || uri.path().is_empty() {
152        "index.html"
153    } else {
154        uri.path().trim_start_matches('/')
155    };
156    if rel.split('/').any(|seg| seg == "..") {
157        return StatusCode::NOT_FOUND.into_response();
158    }
159    match EMBEDDED_UI.get_file(rel) {
160        Some(file) => (
161            StatusCode::OK,
162            [(header::CONTENT_TYPE, embedded_ctype(rel))],
163            file.contents(),
164        )
165            .into_response(),
166        None => StatusCode::NOT_FOUND.into_response(),
167    }
168}
169
170#[cfg(feature = "embed-ui")]
171fn embedded_ctype(path: &str) -> &'static str {
172    if path.ends_with(".html") {
173        "text/html; charset=utf-8"
174    } else if path.ends_with(".js") {
175        "application/javascript; charset=utf-8"
176    } else if path.ends_with(".css") {
177        "text/css; charset=utf-8"
178    } else if path.ends_with(".woff2") {
179        "font/woff2"
180    } else if path.ends_with(".svg") {
181        "image/svg+xml"
182    } else if path.ends_with(".ico") {
183        "image/x-icon"
184    } else if path.ends_with(".txt") {
185        "text/plain; charset=utf-8"
186    } else {
187        "application/octet-stream"
188    }
189}
190
191/// Bind `addr` (port 0 is ephemeral) and serve.
192///
193/// Sends the resolved local address on `ready` once the listener is accepting.
194/// Does not hold a database lock.
195///
196/// Role enforcement is not active on this entry point; use
197/// [`serve_with_role_tokens`] when role-bound tokens are required.
198#[deprecated(
199    since = "0.2.0",
200    note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
201)]
202#[doc(hidden)]
203pub async fn serve(
204    db: SharedDb,
205    addr: SocketAddr,
206    ready: tokio::sync::oneshot::Sender<SocketAddr>,
207    token: Option<String>,
208) -> std::io::Result<()> {
209    serve_inner(db, addr, ready, UiFallback::None, token, HashMap::new()).await
210}
211
212/// [`serve`] with role-bound tokens in addition to the optional full-access token.
213///
214/// `role_tokens` maps bearer values to role names.  See [`router_with_role_tokens`]
215/// for the enforcement semantics.
216pub async fn serve_with_role_tokens(
217    db: SharedDb,
218    addr: SocketAddr,
219    ready: tokio::sync::oneshot::Sender<SocketAddr>,
220    token: Option<String>,
221    role_tokens: HashMap<String, String>,
222) -> std::io::Result<()> {
223    serve_inner(db, addr, ready, UiFallback::None, token, role_tokens).await
224}
225
226/// [`serve`] plus a UI dist directory mounted behind the API routes.
227///
228/// Role enforcement is not active on this entry point; use
229/// [`serve_with_ui_and_role_tokens`] when role-bound tokens are required.
230#[deprecated(
231    since = "0.2.0",
232    note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
233)]
234#[doc(hidden)]
235pub async fn serve_with_ui(
236    db: SharedDb,
237    addr: SocketAddr,
238    ready: tokio::sync::oneshot::Sender<SocketAddr>,
239    ui_dir: PathBuf,
240    token: Option<String>,
241) -> std::io::Result<()> {
242    serve_inner(
243        db,
244        addr,
245        ready,
246        UiFallback::Dir(ui_dir),
247        token,
248        HashMap::new(),
249    )
250    .await
251}
252
253/// [`serve_with_ui`] with role-bound tokens.
254pub async fn serve_with_ui_and_role_tokens(
255    db: SharedDb,
256    addr: SocketAddr,
257    ready: tokio::sync::oneshot::Sender<SocketAddr>,
258    ui_dir: PathBuf,
259    token: Option<String>,
260    role_tokens: HashMap<String, String>,
261) -> std::io::Result<()> {
262    serve_inner(db, addr, ready, UiFallback::Dir(ui_dir), token, role_tokens).await
263}
264
265/// [`serve`] plus the compiled-in UI (no-op fallback if `embed-ui` is off).
266///
267/// `role_tokens` maps bearer values to role names; see [`router_with_role_tokens`]
268/// for enforcement semantics.  Pass `HashMap::new()` when no role tokens are needed.
269#[cfg(feature = "embed-ui")]
270pub async fn serve_with_embedded_ui(
271    db: SharedDb,
272    addr: SocketAddr,
273    ready: tokio::sync::oneshot::Sender<SocketAddr>,
274    token: Option<String>,
275    role_tokens: HashMap<String, String>,
276) -> std::io::Result<()> {
277    serve_inner(db, addr, ready, UiFallback::Embedded, token, role_tokens).await
278}
279
280enum UiFallback {
281    None,
282    Dir(PathBuf),
283    #[cfg(feature = "embed-ui")]
284    Embedded,
285}
286
287async fn serve_inner(
288    db: SharedDb,
289    addr: SocketAddr,
290    ready: tokio::sync::oneshot::Sender<SocketAddr>,
291    ui: UiFallback,
292    token: Option<String>,
293    role_tokens: HashMap<String, String>,
294) -> std::io::Result<()> {
295    let listener = tokio::net::TcpListener::bind(addr).await?;
296    let local = listener.local_addr()?;
297    if ready.send(local).is_err() {
298        // Caller dropped the readiness receiver; still serve.
299        eprintln!("serve: readiness receiver dropped before bind notify");
300    }
301    let app = build_app(db, token, role_tokens, ui, local, false);
302    axum::serve(listener, app).await
303}
304
305/// Serve over TLS (requires the `tls` cargo feature).
306///
307/// Accepts a path to a PEM certificate and a PEM private key. Uses rustls
308/// via `axum-server`. The `ready` sender fires with the bound local address
309/// once the listener is accepting, mirroring the contract of [`serve_inner`].
310///
311/// Pass `token` / `role_tokens` the same way you would for [`serve_with_role_tokens`].
312#[cfg(feature = "tls")]
313pub async fn serve_tls(
314    db: SharedDb,
315    addr: SocketAddr,
316    ready: tokio::sync::oneshot::Sender<SocketAddr>,
317    cert_path: std::path::PathBuf,
318    key_path: std::path::PathBuf,
319    token: Option<String>,
320    role_tokens: HashMap<String, String>,
321) -> std::io::Result<()> {
322    let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
323        .await
324        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
325    let listener = std::net::TcpListener::bind(addr)?;
326    listener.set_nonblocking(true)?;
327    let local = listener.local_addr()?;
328    if ready.send(local).is_err() {
329        eprintln!("serve_tls: readiness receiver dropped before bind notify");
330    }
331    let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
332    axum_server::from_tcp_rustls(listener, config)?
333        .serve(app.into_make_service())
334        .await
335}
336
337fn default_advertise_addr() -> SocketAddr {
338    SocketAddr::from(([127, 0, 0, 1], 8080))
339}
340
341fn build_app(
342    db: SharedDb,
343    token: Option<String>,
344    role_tokens: HashMap<String, String>,
345    ui: UiFallback,
346    addr: SocketAddr,
347    tls_active: bool,
348) -> Router {
349    debug_assert!(
350        !db.read().has_event_sink(),
351        "router() must be called at most once per SharedDb; a second call \
352         replaces the sink and terminates all existing /watch subscribers \
353         with RecvError::Closed"
354    );
355    let (tx, _) = tokio::sync::broadcast::channel(1024);
356    {
357        let tx = tx.clone();
358        db.write().set_event_sink(Box::new(move |ev| {
359            let _ = tx.send(ev);
360        }));
361    }
362    let state = AppState {
363        db,
364        watch: tx,
365        token,
366        role_tokens,
367        addr,
368        tls_active,
369        started_at: std::time::Instant::now(),
370    };
371    let app = Router::new()
372        .route("/health", get(health))
373        .route("/query", post(query))
374        .route("/stats", get(stats))
375        .route("/metrics", get(metrics))
376        .route("/ingest", post(ingest))
377        .route("/rules", post(create_rule))
378        .route("/suggest", get(suggest))
379        .route("/explain", get(explain))
380        .route("/node/{key}", get(node_info))
381        .route("/node/{key}", axum::routing::delete(delete_node))
382        .route("/node/{key}/edges", get(node_edges))
383        .route("/node/{key}/neighborhood", get(neighborhood))
384        .route("/node/{key}/history", get(node_history_handler))
385        .route("/history/edge", get(edge_history_handler))
386        .route("/history/was_linked", get(was_linked_handler))
387        .route(
388            "/node/{key}/prop/{field}",
389            axum::routing::put(set_node_prop),
390        )
391        .route(
392            "/node/{key}/prop/{field}",
393            axum::routing::delete(remove_node_prop),
394        )
395        // Simple BatchOp-mapped endpoints — routed through the group-commit
396        // queue (submit_batch) so concurrent node/edge CRUD does not hold
397        // the write lock during fsync.
398        .route("/nodes", post(create_node))
399        .route("/nodes/{key}/rename", post(rename_node))
400        .route("/edges", post(create_edge))
401        .route("/edges/upsert", post(upsert_edge))
402        .route(
403            "/edges/{etype}/{src}/{dst}",
404            axum::routing::delete(delete_edge),
405        )
406        .route("/algo/pagerank", post(algo_pagerank))
407        .route("/algo/wcc", post(algo_wcc))
408        .route("/algo/degree", post(algo_degree))
409        .route("/backup", post(backup))
410        .route("/watch", get(crate::ws::watch))
411        .route("/subscribe", get(crate::subscribe::subscribe))
412        .with_state(state.clone());
413    let app = match ui {
414        UiFallback::None => app,
415        UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
416        #[cfg(feature = "embed-ui")]
417        UiFallback::Embedded => app.fallback(embedded_fallback),
418    };
419    app.layer(middleware::from_fn_with_state(state, auth_middleware))
420        // Cap request bodies (default axum limit is only 2 MiB; we allow larger
421        // ingest batches but reject multi-GB bodies that would OOM the collector
422        // before any handler runs).
423        .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
424}
425
426/// Maximum accepted HTTP request body size (64 MiB). Large enough for batched
427/// `/ingest` payloads, small enough to prevent a single request from
428/// exhausting memory. Clients with larger imports should chunk.
429const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
430
431async fn health(State(state): State<AppState>) -> Response {
432    let (nodes, edges) = {
433        let g = state.db.read();
434        let s = g.stats();
435        (s.nodes_live, s.edges)
436    };
437    json_ok(json!({
438        "ok": true,
439        "nodes": nodes,
440        "edges": edges,
441        "addr": state.addr.to_string(),
442    }))
443}
444
445/// Run a GraphDb write on the blocking pool. The write guard lives only
446/// inside `f` and is dropped before this future awaits.
447async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
448where
449    T: Send + 'static,
450    F: FnOnce() -> core_api::Result<T> + Send + 'static,
451{
452    match tokio::task::spawn_blocking(f).await {
453        Ok(Ok(v)) => Ok(v),
454        Ok(Err(e)) => Err(graph_err(e)),
455        Err(_) => Err(err_response("write task panicked")),
456    }
457}
458
459const TOKEN_COOKIE: &str = "mushroomdb_token";
460
461async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
462    // No auth configured at all: every request is Full, no restriction.
463    if state.token.is_none() && state.role_tokens.is_empty() {
464        req.extensions_mut().insert(AuthIdentity::Full);
465        return next.run(req).await;
466    }
467
468    // Health is always open regardless of token configuration.
469    if req.method() == Method::GET && req.uri().path() == "/health" {
470        req.extensions_mut().insert(AuthIdentity::Full);
471        return next.run(req).await;
472    }
473
474    let presented = request_token(&req);
475
476    // Check full-access token first.
477    if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
478        if presented
479            .as_deref()
480            .is_some_and(|p| constant_time_eq(p.as_bytes(), full_tok.as_bytes()))
481        {
482            let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
483            req.extensions_mut().insert(AuthIdentity::Full);
484            let mut res = next.run(req).await;
485            if set_cookie && is_html_response(&res) {
486                attach_token_cookie(&mut res, full_tok, state.tls_active);
487            }
488            return res;
489        }
490    }
491
492    // Check role-bound tokens.
493    if let Some(tok) = presented.as_deref() {
494        if let Some(role_name) = state.role_tokens.get(tok) {
495            // Early-deny paths whose handlers use WebSocket upgrade: the WS
496            // extraction consumes the request before the handler body runs, so
497            // the handler-level identity check is unreachable in those cases.
498            // Returning 403 here also removes the need for the handler to
499            // hold a read lock just to enforce this.
500            let path = req.uri().path();
501            if path == "/subscribe" || path == "/watch" {
502                return forbidden("role-bound token: this endpoint is not permitted");
503            }
504            req.extensions_mut()
505                .insert(AuthIdentity::Role(role_name.clone()));
506            return next.run(req).await;
507        }
508    }
509
510    // Nothing matched → 401.
511    unauthorized()
512}
513
514fn request_token(req: &Request) -> Option<String> {
515    presented_bearer_or_query(req).or_else(|| presented_cookie(req))
516}
517
518fn presented_bearer_or_query(req: &Request) -> Option<String> {
519    if let Some(header) = req
520        .headers()
521        .get(header::AUTHORIZATION)
522        .and_then(|v| v.to_str().ok())
523    {
524        if let Some(value) = bearer_token(header) {
525            return Some(value.to_string());
526        }
527    }
528    query_param(req.uri().query().unwrap_or(""), "token")
529}
530
531fn presented_cookie(req: &Request) -> Option<String> {
532    let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
533    cookie_named(header, TOKEN_COOKIE).map(str::to_string)
534}
535
536fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
537    for part in header.split(';') {
538        let part = part.trim();
539        let Some((k, v)) = part.split_once('=') else {
540            continue;
541        };
542        if k.trim() == name {
543            return Some(v.trim());
544        }
545    }
546    None
547}
548
549fn is_html_response(res: &Response) -> bool {
550    res.headers()
551        .get(header::CONTENT_TYPE)
552        .and_then(|v| v.to_str().ok())
553        .is_some_and(|ct| {
554            ct.split(';')
555                .next()
556                .unwrap_or("")
557                .trim()
558                .eq_ignore_ascii_case("text/html")
559        })
560}
561
562fn attach_token_cookie(res: &mut Response, token: &str, secure: bool) {
563    let secure_attr = if secure { "; Secure" } else { "" };
564    let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly{secure_attr}");
565    if let Ok(hv) = HeaderValue::from_str(&value) {
566        res.headers_mut().insert(header::SET_COOKIE, hv);
567    }
568}
569
570fn bearer_token(header: &str) -> Option<&str> {
571    let (scheme, value) = header.split_once(' ')?;
572    if scheme.eq_ignore_ascii_case("Bearer") {
573        Some(value.trim())
574    } else {
575        None
576    }
577}
578
579fn query_param(query: &str, key: &str) -> Option<String> {
580    for pair in query.split('&') {
581        if pair.is_empty() {
582            continue;
583        }
584        match pair.split_once('=') {
585            Some((k, v)) if k == key => return percent_decode_plus(v),
586            None if pair == key => return Some(String::new()),
587            _ => {}
588        }
589    }
590    None
591}
592
593/// `application/x-www-form-urlencoded`: `+` is space, `%HH` is a byte.
594fn percent_decode_plus(s: &str) -> Option<String> {
595    let bytes = s.as_bytes();
596    let mut out = Vec::with_capacity(bytes.len());
597    let mut i = 0;
598    while i < bytes.len() {
599        match bytes[i] {
600            b'+' => {
601                out.push(b' ');
602                i += 1;
603            }
604            b'%' => {
605                if i + 2 >= bytes.len() {
606                    return None;
607                }
608                let hi = from_hex(bytes[i + 1])?;
609                let lo = from_hex(bytes[i + 2])?;
610                out.push((hi << 4) | lo);
611                i += 3;
612            }
613            c => {
614                out.push(c);
615                i += 1;
616            }
617        }
618    }
619    String::from_utf8(out).ok()
620}
621
622fn from_hex(b: u8) -> Option<u8> {
623    match b {
624        b'0'..=b'9' => Some(b - b'0'),
625        b'a'..=b'f' => Some(b - b'a' + 10),
626        b'A'..=b'F' => Some(b - b'A' + 10),
627        _ => None,
628    }
629}
630
631fn unauthorized() -> Response {
632    (
633        StatusCode::UNAUTHORIZED,
634        Json(json!({"error": "unauthorized"})),
635    )
636        .into_response()
637}
638
639/// 403 response for role-bound token operations that are denied in v1.
640fn forbidden(detail: &str) -> Response {
641    (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
642}
643
644/// Convert a `mask_for_role` error into an HTTP response.
645///
646/// - `GraphError::Corrupt` → 500: the roles sidecar is poisoned; the server
647///   refuses all role-token requests until the file is fixed and the DB is
648///   re-opened.  Full-access tokens are unaffected.
649/// - `GraphError::KeyNotFound` with a `role:` prefix → 401: the token is bound
650///   to a role that does not exist in the DB at this request time.
651fn role_mask_err(e: GraphError) -> Response {
652    match e {
653        GraphError::Corrupt { detail } => (
654            StatusCode::INTERNAL_SERVER_ERROR,
655            Json(json!({"error": format!("roles misconfigured: {detail}")})),
656        )
657            .into_response(),
658        GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
659        other => graph_err(other),
660    }
661}
662
663fn err_response(detail: impl Into<String>) -> Response {
664    (
665        StatusCode::BAD_REQUEST,
666        Json(json!({"error": detail.into()})),
667    )
668        .into_response()
669}
670
671fn graph_err(e: GraphError) -> Response {
672    match e {
673        // §4.3: role-scoped write denials map to 403 with the verbatim reason
674        // string (Display delegates to reason, so .to_string() == reason).
675        GraphError::RoleWriteDenied { reason } => forbidden(&reason),
676        GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
677            err_response(detail)
678        }
679        other => err_response(other.to_string()),
680    }
681}
682
683fn key_not_found(key: String) -> Response {
684    (
685        StatusCode::NOT_FOUND,
686        Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
687    )
688        .into_response()
689}
690
691fn conflict_response(key: String) -> Response {
692    (
693        StatusCode::CONFLICT,
694        Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
695    )
696        .into_response()
697}
698
699fn json_ok(value: Js) -> Response {
700    (StatusCode::OK, Json(value)).into_response()
701}
702
703fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
704    let Some(v) = v else {
705        return Ok(IngestOptions::default());
706    };
707    if v.is_null() {
708        return Ok(IngestOptions::default());
709    }
710    let obj = v
711        .as_object()
712        .ok_or_else(|| "options must be an object".to_string())?;
713    let mut opts = IngestOptions::default();
714    if let Some(kf) = obj.get("key_field") {
715        opts.key_field = kf
716            .as_str()
717            .ok_or_else(|| "options.key_field must be a string".to_string())?
718            .to_string();
719    }
720    if let Some(fk) = obj.get("auto_fk") {
721        if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
722            opts.auto_fk = AutoFk::Off;
723        } else if let Some(m) = fk.as_object() {
724            let suf = m
725                .get("suffix")
726                .and_then(Js::as_str)
727                .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
728            opts.auto_fk = AutoFk::Auto {
729                suffix: suf.to_string(),
730            };
731        } else {
732            return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
733        }
734    }
735    Ok(opts)
736}
737
738/// Format a `ResultSet` as Arrow IPC or JSON depending on `format`.
739fn format_query_result(rs: ResultSet, format: &str) -> Response {
740    match format {
741        "" => match to_ipc_bytes(&rs) {
742            Ok(bytes) => (
743                StatusCode::OK,
744                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
745                bytes,
746            )
747                .into_response(),
748            Err(e) => err_response(e),
749        },
750        "json" => json_ok(result_set_json(&rs)),
751        other => err_response(format!("unknown format: {other}")),
752    }
753}
754
755async fn query(
756    State(state): State<AppState>,
757    Extension(identity): Extension<AuthIdentity>,
758    Query(qs): Query<BTreeMap<String, String>>,
759    Json(body): Json<Js>,
760) -> Response {
761    let cypher = match body.get("cypher").and_then(Js::as_str) {
762        Some(s) => s.to_string(),
763        None => return err_response("missing cypher"),
764    };
765    let params = match params_from_json(body.get("params")) {
766        Ok(p) => p,
767        Err(e) => return err_response(e),
768    };
769    let format = qs.get("format").map(String::as_str).unwrap_or("");
770
771    // Optional time-travel: `as_of` is a 0-based WAL commit index. The query
772    // runs against the graph as it existed at that commit (read-only).
773    let as_of = match body.get("as_of") {
774        None | Some(Js::Null) => None,
775        Some(v) => match v.as_u64() {
776            Some(n) => Some(n),
777            None => return err_response("as_of must be a non-negative integer commit index"),
778        },
779    };
780
781    // Parse client-supplied mask (optional array of node keys).
782    let mask_keys: Option<Vec<String>> = match body.get("mask") {
783        None | Some(Js::Null) => None,
784        Some(Js::Array(arr)) => {
785            let mut keys = Vec::with_capacity(arr.len());
786            for v in arr {
787                match v.as_str() {
788                    Some(s) => keys.push(s.to_string()),
789                    None => return err_response("mask must be an array of strings"),
790                }
791            }
792            Some(keys)
793        }
794        Some(_) => return err_response("mask must be an array of strings"),
795    };
796
797    // Time-travel is currently supported only on the full-token, unmasked read
798    // path (temporal + RBAC-mask composition is a follow-on).
799    if as_of.is_some() && (matches!(identity, AuthIdentity::Role(_)) || mask_keys.is_some()) {
800        return err_response(
801            "as_of (time-travel) is not yet supported with role tokens or a client mask",
802        );
803    }
804
805    // Role token: write Cypher routes to query_write_authz (scope + mask
806    // resolved under the write lock, §5 discipline).  Read Cypher uses the
807    // lock-free epoch snapshot path unchanged.
808    if let AuthIdentity::Role(ref role_name) = identity {
809        let is_write = match is_write_query(&cypher) {
810            Ok(b) => b,
811            Err(e) => return err_response(e),
812        };
813        if is_write {
814            let role = role_name.clone();
815            let cypher_c = cypher.clone();
816            let params_c = params.clone();
817            let db = state.db.clone();
818            return match blocking_write(move || {
819                db.write().query_write_authz(&role, &cypher_c, &params_c)
820            })
821            .await
822            {
823                Ok(rs) => format_query_result(rs, format),
824                Err(resp) => resp,
825            };
826        }
827        let snap = state.db.reader();
828        let role_mask = match snap.mask_for_role(role_name) {
829            Ok(m) => m,
830            Err(e) => return role_mask_err(e),
831        };
832        let effective_mask = if let Some(ref keys) = mask_keys {
833            // Client mask intersects role mask — never widens visibility.
834            let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
835            role_mask.intersect(&client_mask)
836        } else {
837            role_mask
838        };
839        return match snap.query_masked(&cypher, &params, &effective_mask) {
840            Ok(rs) => format_query_result(rs, format),
841            Err(GraphError::MaskedReadOnly) => (
842                StatusCode::BAD_REQUEST,
843                Json(json!({"error": "masked queries are read-only"})),
844            )
845                .into_response(),
846            Err(e) => graph_err(e),
847        };
848    }
849
850    // Full token: existing paths below.
851
852    // When a client-supplied mask is present, route to query_masked (read-only).
853    // Hold a single read guard for both from_keys and query_masked so the mask
854    // and the query execute on the same database snapshot.
855    //
856    // `stub_hidden: true` opts into MaskMode::Stub for the mask; Cypher query
857    // behaviour is identical in both modes (hidden nodes are excluded from
858    // query results regardless of mode).
859    if let Some(ref keys) = mask_keys {
860        let stub_hidden = body
861            .get("stub_hidden")
862            .and_then(|v| v.as_bool())
863            .unwrap_or(false);
864        let db = state.db.read();
865        let mask = {
866            let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
867            if stub_hidden {
868                m.with_mode(MaskMode::Stub)
869            } else {
870                m
871            }
872        };
873        return match db.query_masked(&cypher, &params, &mask) {
874            Ok(rs) => format_query_result(rs, format),
875            Err(GraphError::MaskedReadOnly) => (
876                StatusCode::BAD_REQUEST,
877                Json(json!({"error": "masked queries are read-only"})),
878            )
879                .into_response(),
880            Err(e) => graph_err(e),
881        };
882    }
883
884    // Detect write statements to dispatch to the correct lock.
885    // Write statements (CREATE / MATCH…SET / MATCH…DELETE / MERGE) need the
886    // write lock so mutations flow through WAL + rule engine with fsync before
887    // the response is sent.  Read queries (MATCH … RETURN …) use the read lock.
888    let is_write = match is_write_query(&cypher) {
889        Ok(b) => b,
890        Err(e) => return err_response(e),
891    };
892
893    if as_of.is_some() && is_write {
894        return err_response("as_of (time-travel) queries are read-only");
895    }
896
897    let rs = if is_write {
898        let db = state.db.clone();
899        match blocking_write(move || db.write().query_write(&cypher, &params)).await {
900            Ok(rs) => rs,
901            Err(resp) => return resp,
902        }
903    } else if let Some(commit) = as_of {
904        match state.db.read().query_at(commit, &cypher, &params) {
905            Ok(rs) => rs,
906            Err(e) => return graph_err(e),
907        }
908    } else {
909        match state.db.read().query(&cypher, &params) {
910            Ok(rs) => rs,
911            Err(e) => return graph_err(e),
912        }
913    };
914
915    format_query_result(rs, format)
916}
917
918async fn stats(
919    State(state): State<AppState>,
920    Extension(identity): Extension<AuthIdentity>,
921) -> Response {
922    // v1: deny role tokens — raw counts leak graph size beyond the role's subgraph.
923    if let AuthIdentity::Role(_) = identity {
924        return forbidden("role-bound token: /stats requires a full-access token");
925    }
926    let snap = {
927        let g = state.db.read();
928        g.stats()
929    };
930    match serde_json::to_value(&snap) {
931        Ok(v) => json_ok(v),
932        Err(e) => err_response(e.to_string()),
933    }
934}
935
936async fn metrics(
937    State(state): State<AppState>,
938    Extension(identity): Extension<AuthIdentity>,
939) -> Response {
940    // Mirror /stats auth: deny role tokens — counters leak graph size.
941    if let AuthIdentity::Role(_) = identity {
942        return forbidden("role-bound token: /metrics requires a full-access token");
943    }
944    let (s, commit_seq, wal_size_bytes, slow_snap) = {
945        let g = state.db.read();
946        let s = g.stats();
947        let commit_seq = g.commit_seq();
948        let wal_size_bytes = g.wal_size_bytes().ok();
949        let slow_snap = g.slow_query_snapshot();
950        (s, commit_seq, wal_size_bytes, slow_snap)
951    };
952    let uptime_s = state.started_at.elapsed().as_secs();
953    let slow_entries: Vec<Js> = slow_snap
954        .last
955        .iter()
956        .map(|e| {
957            json!({
958                "ms": e.ms,
959                "query": e.query,
960                "at_commit": e.at_commit,
961            })
962        })
963        .collect();
964    json_ok(json!({
965        "nodes_live": s.nodes_live,
966        "nodes_tombstoned": s.nodes_tombstoned,
967        "edges": s.edges,
968        "commit_seq": commit_seq,
969        "wal_size_bytes": wal_size_bytes,
970        "rss_bytes": rss_bytes(),
971        "uptime_s": uptime_s,
972        "slow_queries": {
973            "threshold_ms": slow_snap.threshold_ms,
974            "count": slow_snap.count,
975            "last": slow_entries,
976        },
977    }))
978}
979
980/// Resident set size of the current process in bytes.
981///
982/// macOS: mach task_info (MACH_TASK_BASIC_INFO).
983/// Linux: /proc/self/statm RSS pages × page size.
984/// Other: always returns None.
985///
986/// Never panics; returns None on any failure.
987fn rss_bytes() -> Option<u64> {
988    #[cfg(target_os = "macos")]
989    {
990        // MACH_TASK_BASIC_INFO flavor = 20.
991        // struct mach_task_basic_info { u64 virtual, u64 resident,
992        //   u64 resident_max, [u32;2] user_time, [u32;2] sys_time,
993        //   i32 policy, i32 suspend_count } = 48 bytes = 12 natural_t(u32)s.
994        const MACH_TASK_BASIC_INFO: u32 = 20;
995        const MACH_TASK_BASIC_INFO_COUNT: u32 = 12;
996
997        #[repr(C)]
998        struct MachTaskBasicInfo {
999            virtual_size: u64,
1000            resident_size: u64,
1001            resident_size_max: u64,
1002            user_time: [u32; 2],
1003            system_time: [u32; 2],
1004            policy: i32,
1005            suspend_count: i32,
1006        }
1007
1008        extern "C" {
1009            fn mach_task_self() -> u32;
1010            fn task_info(
1011                target_task: u32,
1012                flavor: u32,
1013                task_info_out: *mut std::ffi::c_void,
1014                task_info_cnt: *mut u32,
1015            ) -> i32;
1016        }
1017
1018        let mut info: MachTaskBasicInfo = unsafe { std::mem::zeroed() };
1019        let mut count = MACH_TASK_BASIC_INFO_COUNT;
1020        let ret = unsafe {
1021            task_info(
1022                mach_task_self(),
1023                MACH_TASK_BASIC_INFO,
1024                &mut info as *mut _ as *mut _,
1025                &mut count,
1026            )
1027        };
1028        if ret != 0 {
1029            return None; // KERN_SUCCESS = 0
1030        }
1031        return Some(info.resident_size);
1032    }
1033
1034    #[cfg(target_os = "linux")]
1035    {
1036        // /proc/self/statm: "vsize rss ..." in pages.
1037        let content = std::fs::read_to_string("/proc/self/statm").ok()?;
1038        let mut parts = content.split_whitespace();
1039        let _vsize = parts.next()?;
1040        let rss_pages: u64 = parts.next()?.parse().ok()?;
1041        let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1042        if page_size <= 0 {
1043            return None;
1044        }
1045        return Some(rss_pages * page_size as u64);
1046    }
1047
1048    #[allow(unreachable_code)]
1049    None
1050}
1051
1052async fn ingest(
1053    State(state): State<AppState>,
1054    Extension(identity): Extension<AuthIdentity>,
1055    Json(body): Json<Js>,
1056) -> Response {
1057    let label = match body.get("label").and_then(Js::as_str) {
1058        Some(s) => s.to_string(),
1059        None => return err_response("missing label"),
1060    };
1061    let rows = match body.get("rows") {
1062        Some(r) => r,
1063        None => return err_response("missing rows"),
1064    };
1065    let mut converted = match json_to_rows(rows) {
1066        Ok(c) => c,
1067        Err(e) => return graph_err(e),
1068    };
1069    let opts = match ingest_options(body.get("options")) {
1070        Ok(o) => o,
1071        Err(e) => return err_response(e),
1072    };
1073    let taken = std::mem::take(&mut converted.rows);
1074    let edges = match body.get("edges") {
1075        None | Some(Js::Null) => Vec::new(),
1076        Some(raw) => match parse_ingest_edges(raw) {
1077            Ok(e) => e,
1078            Err(e) => return err_response(e),
1079        },
1080    };
1081    let db = state.db.clone();
1082
1083    // Role token: route to ingest_with_edges_authz (scope + mask resolved under
1084    // the write lock, §5 discipline; §7.3 — create_labels required, enforced by
1085    // the engine's per-op authz check inside commit_logged_batch).
1086    if let AuthIdentity::Role(role_name) = &identity {
1087        let role = role_name.clone();
1088        return match blocking_write(move || {
1089            db.write()
1090                .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
1091        })
1092        .await
1093        {
1094            Ok(r) => {
1095                let report = converted.into_report(r);
1096                match serde_json::to_value(&report) {
1097                    Ok(v) => json_ok(v),
1098                    Err(e) => err_response(e.to_string()),
1099                }
1100            }
1101            Err(resp) => resp,
1102        };
1103    }
1104
1105    let report =
1106        match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
1107            .await
1108        {
1109            Ok(r) => converted.into_report(r),
1110            Err(resp) => return resp,
1111        };
1112    match serde_json::to_value(&report) {
1113        Ok(v) => json_ok(v),
1114        Err(e) => err_response(e.to_string()),
1115    }
1116}
1117
1118/// `GET /suggest` — profile the database and return rule suggestions.
1119///
1120/// # Locking and blocking strategy
1121///
1122/// `suggest_rules_with_config` is CPU-intensive and synchronous. Running it on a
1123/// Tokio worker thread would starve the executor. This handler offloads the work to
1124/// `tokio::task::spawn_blocking`, which uses the blocking thread-pool. The
1125/// `std::sync::RwLock` read guard is acquired and held inside the blocking task —
1126/// reads don't block other reads; writes wait for the guard to drop. The global
1127/// budget (`SuggestConfig::global_budget_ms`, default 5 s) caps lock-hold time.
1128async fn suggest(
1129    State(state): State<AppState>,
1130    Extension(identity): Extension<AuthIdentity>,
1131) -> Response {
1132    // v1: deny role tokens — suggest scans the full graph and would reveal
1133    // existence of nodes outside the role's mask.
1134    if let AuthIdentity::Role(_) = identity {
1135        return forbidden("role-bound token: /suggest requires a full-access token");
1136    }
1137    let db = state.db.clone();
1138    match tokio::task::spawn_blocking(move || {
1139        let config = SuggestConfig::default();
1140        db.read()
1141            .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1142    })
1143    .await
1144    {
1145        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1146        Err(_) => err_response("suggest task panicked"),
1147    }
1148}
1149
1150async fn create_rule(
1151    State(state): State<AppState>,
1152    Extension(identity): Extension<AuthIdentity>,
1153    Json(body): Json<Js>,
1154) -> Response {
1155    if let AuthIdentity::Role(_) = identity {
1156        return forbidden("role-bound token: writes are not permitted");
1157    }
1158    let def = match rule_def_from_json(body) {
1159        Ok(d) => d,
1160        Err(e) => return err_response(e),
1161    };
1162    let name = def.name.clone();
1163    let db = state.db.clone();
1164    match blocking_write(move || db.write().create_rule(def)).await {
1165        Ok(()) => json_ok(json!({"ok": true, "name": name})),
1166        Err(resp) => resp,
1167    }
1168}
1169
1170async fn explain(
1171    State(state): State<AppState>,
1172    Extension(identity): Extension<AuthIdentity>,
1173    Query(qs): Query<BTreeMap<String, String>>,
1174) -> Response {
1175    // v1: deny role tokens — explain reveals hidden-node linkage through rules.
1176    if let AuthIdentity::Role(_) = identity {
1177        return forbidden(
1178            "role-bound token: /explain requires a full-access token \
1179             (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1180        );
1181    }
1182    let a = match qs.get("a") {
1183        Some(s) if !s.is_empty() => s.clone(),
1184        _ => return err_response("missing query param a"),
1185    };
1186    let b = match qs.get("b") {
1187        Some(s) if !s.is_empty() => s.clone(),
1188        _ => return err_response("missing query param b"),
1189    };
1190    let out = {
1191        let g = state.db.read();
1192        g.explain(&a, &b)
1193    };
1194    match out {
1195        Ok(v) => match serde_json::to_value(&v) {
1196            Ok(j) => json_ok(j),
1197            Err(e) => err_response(e.to_string()),
1198        },
1199        Err(e) => graph_err(e),
1200    }
1201}
1202
1203async fn node_info(
1204    State(state): State<AppState>,
1205    Extension(identity): Extension<AuthIdentity>,
1206    Path(key): Path<String>,
1207    Query(qs): Query<BTreeMap<String, String>>,
1208) -> Response {
1209    if let AuthIdentity::Role(ref role_name) = identity {
1210        // Role-token path: hard-coded Omit mode; hidden keys are indistinguishable
1211        // from absent keys.  `stub_hidden` query param is silently ignored here —
1212        // role paths must NEVER produce stubs (RBAC invariant).
1213        let snap = state.db.reader();
1214        let role_mask = match snap.mask_for_role(role_name) {
1215            Ok(m) => m,
1216            Err(e) => return role_mask_err(e),
1217        };
1218        if !snap
1219            .resolve_key(&key)
1220            .is_some_and(|id| role_mask.contains_id(id))
1221        {
1222            return key_not_found(key);
1223        }
1224        return match snap.node_info(&key) {
1225            Some(info) => json_ok(node_info_json(&info)),
1226            None => key_not_found(key),
1227        };
1228    }
1229
1230    // Full-token path: optional client mask + stub_hidden via query params.
1231    // `mask=key1,key2` — comma-separated visible keys (empty string = no mask).
1232    // `stub_hidden=true` — opt into MaskMode::Stub for this request.
1233    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1234    if !mask_param.is_empty() {
1235        let stub_hidden = qs
1236            .get("stub_hidden")
1237            .map(|v| v == "true" || v == "1")
1238            .unwrap_or(false);
1239        let g = state.db.read();
1240        let mask = {
1241            let keys = mask_param
1242                .split(',')
1243                .map(str::trim)
1244                .filter(|s| !s.is_empty());
1245            let m = NodeMask::from_keys(&*g, keys);
1246            if stub_hidden {
1247                m.with_mode(MaskMode::Stub)
1248            } else {
1249                m
1250            }
1251        };
1252        return match g.node_info_masked(&key, &mask) {
1253            Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1254            Some(core_api::MaskedNodeResult::Restricted) => {
1255                json_ok(crate::json::stub_node_json(&key))
1256            }
1257            None => key_not_found(key),
1258        };
1259    }
1260
1261    let info = {
1262        let g = state.db.read();
1263        g.node_info(&key)
1264    };
1265    match info {
1266        Some(info) => json_ok(node_info_json(&info)),
1267        None => key_not_found(key),
1268    }
1269}
1270
1271async fn node_edges(
1272    State(state): State<AppState>,
1273    Extension(identity): Extension<AuthIdentity>,
1274    Path(key): Path<String>,
1275    Query(qs): Query<BTreeMap<String, String>>,
1276) -> Response {
1277    if let AuthIdentity::Role(ref role_name) = identity {
1278        // Role-token path: hard-coded Omit mode.  `stub_hidden` query param is
1279        // silently ignored — role paths must NEVER produce stubs (RBAC invariant).
1280        let snap = state.db.reader();
1281        let role_mask = match snap.mask_for_role(role_name) {
1282            Ok(m) => m,
1283            Err(e) => return role_mask_err(e),
1284        };
1285        if !snap
1286            .resolve_key(&key)
1287            .is_some_and(|id| role_mask.contains_id(id))
1288        {
1289            return key_not_found(key);
1290        }
1291        return match snap.node_edges(&key) {
1292            Ok(edges) => {
1293                // Filter out edges whose OTHER endpoint is hidden in the role
1294                // mask.  A role token must not learn about hidden neighbors via
1295                // the edge list even when the entry key itself is visible.
1296                let visible: Vec<_> = edges
1297                    .into_iter()
1298                    .filter(|e| {
1299                        let other = if e.src_key == key {
1300                            &e.dst_key
1301                        } else {
1302                            &e.src_key
1303                        };
1304                        snap.resolve_key(other)
1305                            .is_some_and(|id| role_mask.contains_id(id))
1306                    })
1307                    .collect();
1308                json_ok(node_edges_json(&visible))
1309            }
1310            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1311            Err(e) => graph_err(e),
1312        };
1313    }
1314
1315    // Full-token path: optional client mask + stub_hidden via query params.
1316    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1317    if !mask_param.is_empty() {
1318        let stub_hidden = qs
1319            .get("stub_hidden")
1320            .map(|v| v == "true" || v == "1")
1321            .unwrap_or(false);
1322        let g = state.db.read();
1323        let mask = {
1324            let keys = mask_param
1325                .split(',')
1326                .map(str::trim)
1327                .filter(|s| !s.is_empty());
1328            let m = NodeMask::from_keys(&*g, keys);
1329            if stub_hidden {
1330                m.with_mode(MaskMode::Stub)
1331            } else {
1332                m
1333            }
1334        };
1335        return match g.node_edges_masked(&key, &mask) {
1336            Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1337            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1338            Err(e) => graph_err(e),
1339        };
1340    }
1341
1342    let out = {
1343        let g = state.db.read();
1344        g.node_edges(&key)
1345    };
1346    match out {
1347        Ok(edges) => json_ok(node_edges_json(&edges)),
1348        Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1349        Err(e) => graph_err(e),
1350    }
1351}
1352
1353async fn neighborhood(
1354    State(state): State<AppState>,
1355    Extension(identity): Extension<AuthIdentity>,
1356    Path(key): Path<String>,
1357    Query(qs): Query<BTreeMap<String, String>>,
1358) -> Response {
1359    let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1360        Ok(d) => d,
1361        Err(e) => return err_response(e),
1362    };
1363    let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1364        s if s.eq_ignore_ascii_case("out") => Dir::Out,
1365        s if s.eq_ignore_ascii_case("in") => Dir::In,
1366        s if s.eq_ignore_ascii_case("both") => Dir::Both,
1367        other => return err_response(format!("unknown dir: {other}")),
1368    };
1369    let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1370        s.split(',')
1371            .map(str::trim)
1372            .filter(|t| !t.is_empty())
1373            .map(str::to_string)
1374            .collect()
1375    });
1376    let etype_refs: Option<Vec<&str>> = edge_type_names
1377        .as_ref()
1378        .map(|v| v.iter().map(String::as_str).collect());
1379    if let AuthIdentity::Role(ref role_name) = identity {
1380        // Lock-free epoch snapshot: mask and neighborhood BFS on same frozen state
1381        // (constraint 2 — RBAC mask coherence).
1382        let snap = state.db.reader();
1383        let role_mask = match snap.mask_for_role(role_name) {
1384            Ok(m) => m,
1385            Err(e) => return role_mask_err(e),
1386        };
1387        if !snap
1388            .resolve_key(&key)
1389            .is_some_and(|id| role_mask.contains_id(id))
1390        {
1391            return key_not_found(key);
1392        }
1393        // Use the mask-aware BFS: hidden nodes are excluded from results AND
1394        // cannot be used as traversal intermediaries (never-leak invariant).
1395        let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1396        {
1397            Some(rs) => rs,
1398            None => return key_not_found(key),
1399        };
1400        return json_ok(result_set_json(&rs));
1401    }
1402    // Full-token path: optional client mask + stub_hidden via query params.
1403    // `mask=key1,key2` — comma-separated visible keys.
1404    // `stub_hidden=true` — opt into MaskMode::Stub; hidden direct neighbours
1405    // appear as stub rows (label: null) in the result; BFS does not expand
1406    // through them in either mode.
1407    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1408    if !mask_param.is_empty() {
1409        let stub_hidden = qs
1410            .get("stub_hidden")
1411            .map(|v| v == "true" || v == "1")
1412            .unwrap_or(false);
1413        let g = state.db.read();
1414        let mask = {
1415            let keys = mask_param
1416                .split(',')
1417                .map(str::trim)
1418                .filter(|s| !s.is_empty());
1419            let m = NodeMask::from_keys(&*g, keys);
1420            if stub_hidden {
1421                m.with_mode(MaskMode::Stub)
1422            } else {
1423                m
1424            }
1425        };
1426        return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1427            Some(rs) => json_ok(result_set_json(&rs)),
1428            None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1429        };
1430    }
1431
1432    // Unmasked full-token path (no mask param).
1433    let rs = {
1434        let g = state.db.read();
1435        match g.node_ref(&key) {
1436            Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1437            None => Err(GraphError::KeyNotFound { key: key.clone() }),
1438        }
1439    };
1440    match rs {
1441        Ok(rs) => json_ok(result_set_json(&rs)),
1442        Err(e) => graph_err(e),
1443    }
1444}
1445
1446/// `POST /algo/pagerank` — run PageRank over the unified topology.
1447///
1448/// # Locking and blocking strategy
1449///
1450/// PageRank is CPU-intensive and synchronous. This handler offloads the work
1451/// to `tokio::task::spawn_blocking` (blocking thread-pool). The read guard is
1452/// acquired and held inside the blocking task — reads don't block other reads.
1453/// The `budget_ms` field in [`PageRankConfig`] caps lock-hold time.
1454async fn algo_pagerank(
1455    State(state): State<AppState>,
1456    Extension(identity): Extension<AuthIdentity>,
1457    Json(body): Json<serde_json::Value>,
1458) -> Response {
1459    // v1: deny role tokens — algo endpoints scan the full graph.
1460    if let AuthIdentity::Role(_) = identity {
1461        return forbidden("role-bound token: /algo/* requires a full-access token");
1462    }
1463    let config: PageRankConfig = match serde_json::from_value(body) {
1464        Ok(c) => c,
1465        Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1466    };
1467    let db = state.db.clone();
1468    match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1469        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1470        Err(_) => err_response("pagerank task panicked"),
1471    }
1472}
1473
1474/// `POST /algo/wcc` — weakly-connected components over the unified topology.
1475///
1476/// Mirrors the `suggest` locking and blocking pattern exactly: spawn_blocking,
1477/// read guard inside, `budget_ms` in config caps lock-hold time.
1478async fn algo_wcc(
1479    State(state): State<AppState>,
1480    Extension(identity): Extension<AuthIdentity>,
1481    Json(body): Json<serde_json::Value>,
1482) -> Response {
1483    if let AuthIdentity::Role(_) = identity {
1484        return forbidden("role-bound token: /algo/* requires a full-access token");
1485    }
1486    let config: WccConfig = match serde_json::from_value(body) {
1487        Ok(c) => c,
1488        Err(e) => return err_response(format!("invalid wcc config: {e}")),
1489    };
1490    let db = state.db.clone();
1491    match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1492        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1493        Err(_) => err_response("wcc task panicked"),
1494    }
1495}
1496
1497/// `POST /algo/degree` — degree centrality over the unified topology.
1498///
1499/// Mirrors the `suggest` locking and blocking pattern exactly.
1500async fn algo_degree(
1501    State(state): State<AppState>,
1502    Extension(identity): Extension<AuthIdentity>,
1503    Json(body): Json<serde_json::Value>,
1504) -> Response {
1505    if let AuthIdentity::Role(_) = identity {
1506        return forbidden("role-bound token: /algo/* requires a full-access token");
1507    }
1508    let config: DegreeConfig = match serde_json::from_value(body) {
1509        Ok(c) => c,
1510        Err(e) => return err_response(format!("invalid degree config: {e}")),
1511    };
1512    let db = state.db.clone();
1513    match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1514        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1515        Err(_) => err_response("degree task panicked"),
1516    }
1517}
1518
1519// ── Simple BatchOp-mapped endpoints ──────────────────────────────────────────
1520//
1521// These endpoints map 1:1 to BatchOp variants and route through submit_batch
1522// so concurrent writes share one WAL fsync per drain group, keeping reader
1523// p95 latency low under write bursts.  Auth checks (role tokens → 403) run
1524// before enqueue so RBAC enforcement is unchanged.
1525//
1526// Complex paths (/query Cypher writes, /ingest bulk JSON) stay on db.write()
1527// because they are multi-step operations that cannot be pre-expressed as a
1528// Vec<BatchOp> without redesigning the query executor.
1529
1530/// Parse a JSON object into a `Vec<(String, Value)>` prop list.
1531fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1532    let obj = match v.as_object() {
1533        Some(o) => o,
1534        None => return Err("props must be a JSON object".into()),
1535    };
1536    let mut out = Vec::with_capacity(obj.len());
1537    for (k, val) in obj {
1538        if let Some(v) = json_to_value(val.clone()) {
1539            out.push((k.clone(), v));
1540        }
1541    }
1542    Ok(out)
1543}
1544
1545/// `POST /nodes` — create or upsert a node via the group-commit queue.
1546///
1547/// Body: `{"label": "Person", "key": "alice", "props": {"age": 30}}`
1548async fn create_node(
1549    State(state): State<AppState>,
1550    Extension(identity): Extension<AuthIdentity>,
1551    Json(body): Json<Js>,
1552) -> Response {
1553    let label = match body.get("label").and_then(Js::as_str) {
1554        Some(s) => s.to_string(),
1555        None => return err_response("missing label"),
1556    };
1557    let key = match body.get("key").and_then(Js::as_str) {
1558        Some(s) => s.to_string(),
1559        None => return err_response("missing key"),
1560    };
1561    let props = match body.get("props") {
1562        None | Some(Js::Null) => vec![],
1563        Some(v) => match props_from_json_obj(v) {
1564            Ok(p) => p,
1565            Err(e) => return err_response(e),
1566        },
1567    };
1568    let db = state.db.clone();
1569    if let AuthIdentity::Role(role_name) = &identity {
1570        let role = role_name.clone();
1571        return match blocking_write(move || {
1572            db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1573        })
1574        .await
1575        {
1576            Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1577            Err(resp) => resp,
1578        };
1579    }
1580    match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1581        .await
1582    {
1583        Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1584        Err(resp) => resp,
1585    }
1586}
1587
1588/// `DELETE /node/{key}` — delete a node via the group-commit queue.
1589async fn delete_node(
1590    State(state): State<AppState>,
1591    Extension(identity): Extension<AuthIdentity>,
1592    Path(key): Path<String>,
1593) -> Response {
1594    let db = state.db.clone();
1595    if let AuthIdentity::Role(role_name) = &identity {
1596        let role = role_name.clone();
1597        return match blocking_write(move || {
1598            db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1599        })
1600        .await
1601        {
1602            Ok(_) => json_ok(json!({"ok": true})),
1603            Err(resp) => resp,
1604        };
1605    }
1606    match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1607        Ok(_) => json_ok(json!({"ok": true})),
1608        Err(resp) => resp,
1609    }
1610}
1611
1612/// `POST /edges` — create an edge via the group-commit queue.
1613///
1614/// Body: `{"type": "KNOWS", "src": "alice", "dst": "bob"}`
1615async fn create_edge(
1616    State(state): State<AppState>,
1617    Extension(identity): Extension<AuthIdentity>,
1618    Json(body): Json<Js>,
1619) -> Response {
1620    let edge_type = match body.get("type").and_then(Js::as_str) {
1621        Some(s) => s.to_string(),
1622        None => return err_response("missing type"),
1623    };
1624    let src = match body.get("src").and_then(Js::as_str) {
1625        Some(s) => s.to_string(),
1626        None => return err_response("missing src"),
1627    };
1628    let dst = match body.get("dst").and_then(Js::as_str) {
1629        Some(s) => s.to_string(),
1630        None => return err_response("missing dst"),
1631    };
1632    let db = state.db.clone();
1633    if let AuthIdentity::Role(role_name) = &identity {
1634        let role = role_name.clone();
1635        return match blocking_write(move || {
1636            db.submit_batch_authz(
1637                role,
1638                vec![BatchOp::InsertEdge {
1639                    edge_type,
1640                    src_key: src,
1641                    dst_key: dst,
1642                }],
1643            )
1644        })
1645        .await
1646        {
1647            Ok(_) => json_ok(json!({"ok": true})),
1648            Err(resp) => resp,
1649        };
1650    }
1651    match blocking_write(move || {
1652        db.submit_batch(vec![BatchOp::InsertEdge {
1653            edge_type,
1654            src_key: src,
1655            dst_key: dst,
1656        }])
1657    })
1658    .await
1659    {
1660        Ok(_) => json_ok(json!({"ok": true})),
1661        Err(resp) => resp,
1662    }
1663}
1664
1665/// `DELETE /edges/{etype}/{src}/{dst}` — delete an edge via the group-commit queue.
1666async fn delete_edge(
1667    State(state): State<AppState>,
1668    Extension(identity): Extension<AuthIdentity>,
1669    Path((etype, src, dst)): Path<(String, String, String)>,
1670) -> Response {
1671    let db = state.db.clone();
1672    if let AuthIdentity::Role(role_name) = &identity {
1673        let role = role_name.clone();
1674        return match blocking_write(move || {
1675            db.submit_batch_authz(
1676                role,
1677                vec![BatchOp::DeleteEdge {
1678                    edge_type: etype,
1679                    src_key: src,
1680                    dst_key: dst,
1681                }],
1682            )
1683        })
1684        .await
1685        {
1686            Ok(_) => json_ok(json!({"ok": true})),
1687            Err(resp) => resp,
1688        };
1689    }
1690    match blocking_write(move || {
1691        db.submit_batch(vec![BatchOp::DeleteEdge {
1692            edge_type: etype,
1693            src_key: src,
1694            dst_key: dst,
1695        }])
1696    })
1697    .await
1698    {
1699        Ok(_) => json_ok(json!({"ok": true})),
1700        Err(resp) => resp,
1701    }
1702}
1703
1704/// `POST /nodes/{key}/rename` — rename a node's key via the group-commit queue.
1705///
1706/// Body: `{"new_key": "alice2"}`
1707/// Returns 404 on KeyNotFound, 409 on DuplicateKey, 200 on success.
1708async fn rename_node(
1709    State(state): State<AppState>,
1710    Extension(identity): Extension<AuthIdentity>,
1711    Path(key): Path<String>,
1712    Json(body): Json<Js>,
1713) -> Response {
1714    if let AuthIdentity::Role(_) = identity {
1715        return forbidden("role-bound token: writes are not permitted");
1716    }
1717    let new_key = match body.get("new_key").and_then(Js::as_str) {
1718        Some(s) => s.to_string(),
1719        None => return err_response("missing new_key"),
1720    };
1721    let db = state.db.clone();
1722    match tokio::task::spawn_blocking(move || {
1723        db.submit_batch(vec![BatchOp::RenameNode {
1724            old_key: key,
1725            new_key,
1726        }])
1727    })
1728    .await
1729    {
1730        Ok(Ok(_)) => json_ok(json!({"ok": true})),
1731        Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1732        Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1733        Ok(Err(e)) => graph_err(e),
1734        Err(_) => err_response("write task panicked"),
1735    }
1736}
1737
1738/// `POST /edges/upsert` — insert an edge, auto-creating missing endpoints.
1739///
1740/// Body: `{"edge_type":"KNOWS","src_key":"alice","dst_key":"bob","placeholder_label":"Person"}`
1741/// Returns `{"nodes_created": N, "edge_inserted": bool}`.
1742async fn upsert_edge(
1743    State(state): State<AppState>,
1744    Extension(identity): Extension<AuthIdentity>,
1745    Json(body): Json<Js>,
1746) -> Response {
1747    let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1748        Some(s) => s.to_string(),
1749        None => return err_response("missing edge_type"),
1750    };
1751    let src_key = match body.get("src_key").and_then(Js::as_str) {
1752        Some(s) => s.to_string(),
1753        None => return err_response("missing src_key"),
1754    };
1755    let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1756        Some(s) => s.to_string(),
1757        None => return err_response("missing dst_key"),
1758    };
1759    let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1760        Some(s) => s.to_string(),
1761        None => return err_response("missing placeholder_label"),
1762    };
1763    let db = state.db.clone();
1764    if let AuthIdentity::Role(role_name) = &identity {
1765        let role = role_name.clone();
1766        return match blocking_write(move || {
1767            db.submit_batch_authz(
1768                role,
1769                vec![BatchOp::InsertEdgeUpsert {
1770                    edge_type,
1771                    src_key,
1772                    dst_key,
1773                    placeholder_label,
1774                }],
1775            )
1776        })
1777        .await
1778        {
1779            Ok((nodes, edges)) => json_ok(json!({
1780                "nodes_created": nodes,
1781                "edge_inserted": edges > 0,
1782            })),
1783            Err(resp) => resp,
1784        };
1785    }
1786    match blocking_write(move || {
1787        db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1788            edge_type,
1789            src_key,
1790            dst_key,
1791            placeholder_label,
1792        }])
1793    })
1794    .await
1795    {
1796        Ok((nodes, edges)) => json_ok(json!({
1797            "nodes_created": nodes,
1798            "edge_inserted": edges > 0,
1799        })),
1800        Err(resp) => resp,
1801    }
1802}
1803
1804/// `PUT /node/{key}/prop/{field}` — set a property via the group-commit queue.
1805///
1806/// Body: `{"value": 42}`
1807async fn set_node_prop(
1808    State(state): State<AppState>,
1809    Extension(identity): Extension<AuthIdentity>,
1810    Path((key, field)): Path<(String, String)>,
1811    Json(body): Json<Js>,
1812) -> Response {
1813    let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1814        Some(v) => v,
1815        None => {
1816            return err_response(
1817                "request body must be a JSON object with a \"value\" field, \
1818                 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
1819            )
1820        }
1821    };
1822    let db = state.db.clone();
1823    if let AuthIdentity::Role(role_name) = &identity {
1824        let role = role_name.clone();
1825        return match blocking_write(move || {
1826            db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1827        })
1828        .await
1829        {
1830            Ok(_) => json_ok(json!({"ok": true})),
1831            Err(resp) => resp,
1832        };
1833    }
1834    match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1835        .await
1836    {
1837        Ok(_) => json_ok(json!({"ok": true})),
1838        Err(resp) => resp,
1839    }
1840}
1841
1842// ── History endpoints ─────────────────────────────────────────────────────────
1843//
1844// These are cold-path diagnostic endpoints. They scan the on-disk WAL and
1845// must NOT extend ReaderSnapshot — they use db.read() directly per the
1846// controller ruling (see module doc and task-2-brief.md).
1847//
1848// Role masking: node visibility is checked under the SAME read guard as the
1849// history call (coherent snapshot). A node outside the role's mask responds
1850// identically to an absent node — no existence oracle.
1851
1852/// `GET /node/{key}/history` — return the WAL change history for `key`.
1853///
1854/// Response: `{ key, history: [{commit, change}], total_commits }`.
1855/// Role tokens: if `key` is hidden by the role mask, responds with 404
1856/// (same shape as querying an absent key — no existence oracle).
1857async fn node_history_handler(
1858    State(state): State<AppState>,
1859    Extension(identity): Extension<AuthIdentity>,
1860    Path(key): Path<String>,
1861) -> Response {
1862    if let AuthIdentity::Role(ref role_name) = identity {
1863        let g = state.db.read();
1864        let role_mask = match g.mask_for_role(role_name) {
1865            Ok(m) => m,
1866            Err(e) => return role_mask_err(e),
1867        };
1868        // Hidden keys must respond identically to absent keys (no oracle).
1869        if !role_mask.contains_node(&*g, &key) {
1870            return key_not_found(key);
1871        }
1872        let entries = match g.node_history(&key) {
1873            Ok(e) => e,
1874            Err(e) => return graph_err(e),
1875        };
1876        let total_commits = match g.wal_total_commits() {
1877            Ok(n) => n,
1878            Err(e) => return graph_err(e),
1879        };
1880        // Filter EdgeAdded/EdgeRemoved entries whose `other` endpoint is hidden.
1881        // A role token must not learn about hidden nodes via edge history events —
1882        // mirrors the same protection in `node_edges` (http.rs ~978-989).
1883        use core_api::HistoryChange;
1884        let visible: Vec<_> = entries
1885            .into_iter()
1886            .filter(|entry| match &entry.change {
1887                HistoryChange::EdgeAdded { other, .. }
1888                | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1889                _ => true,
1890            })
1891            .collect();
1892        return json_ok(node_history_json(&key, &visible, total_commits));
1893    }
1894    // Full identity: no masking. Return 404 for absent keys (consistent with
1895    // GET /node/{key} and the Role branch above).
1896    let g = state.db.read();
1897    if !g.has_node(&key) {
1898        return key_not_found(key);
1899    }
1900    let entries = match g.node_history(&key) {
1901        Ok(e) => e,
1902        Err(e) => return graph_err(e),
1903    };
1904    let total_commits = match g.wal_total_commits() {
1905        Ok(n) => n,
1906        Err(e) => return graph_err(e),
1907    };
1908    json_ok(node_history_json(&key, &entries, total_commits))
1909}
1910
1911/// `GET /history/edge?a=&b=` — return the edge lifecycle between two nodes.
1912///
1913/// Response: `{ a, b, events: [{edge_type, commit, event, rule}], total_commits }`.
1914/// Role tokens: BOTH `a` AND `b` must be visible in the role mask, otherwise
1915/// responds with 404 for the first invisible key (no existence oracle).
1916async fn edge_history_handler(
1917    State(state): State<AppState>,
1918    Extension(identity): Extension<AuthIdentity>,
1919    Query(qs): Query<BTreeMap<String, String>>,
1920) -> Response {
1921    let a = match qs.get("a").filter(|s| !s.is_empty()) {
1922        Some(s) => s.clone(),
1923        None => return err_response("missing query param a"),
1924    };
1925    let b = match qs.get("b").filter(|s| !s.is_empty()) {
1926        Some(s) => s.clone(),
1927        None => return err_response("missing query param b"),
1928    };
1929    if let AuthIdentity::Role(ref role_name) = identity {
1930        let g = state.db.read();
1931        let role_mask = match g.mask_for_role(role_name) {
1932            Ok(m) => m,
1933            Err(e) => return role_mask_err(e),
1934        };
1935        // BOTH endpoints must be visible (no oracle for either).
1936        if !role_mask.contains_node(&*g, &a) {
1937            return key_not_found(a);
1938        }
1939        if !role_mask.contains_node(&*g, &b) {
1940            return key_not_found(b);
1941        }
1942        let result = match g.edge_history(&a, &b) {
1943            Ok(r) => r,
1944            Err(e) => return graph_err(e),
1945        };
1946        return json_ok(edge_history_result_json(&a, &b, &result));
1947    }
1948    // Full identity: no masking.
1949    let g = state.db.read();
1950    let result = match g.edge_history(&a, &b) {
1951        Ok(r) => r,
1952        Err(e) => return graph_err(e),
1953    };
1954    json_ok(edge_history_result_json(&a, &b, &result))
1955}
1956
1957/// `GET /history/was_linked?a=&b=&edge_type=&at_commit=` — point-in-time edge check.
1958///
1959/// Response: `{ a, b, edge_type, at_commit, linked }`.
1960/// Returns 400 (not 500) when `at_commit` is outside the visible horizon.
1961/// Role tokens: BOTH `a` AND `b` must be visible, same-as-absent otherwise.
1962async fn was_linked_handler(
1963    State(state): State<AppState>,
1964    Extension(identity): Extension<AuthIdentity>,
1965    Query(qs): Query<BTreeMap<String, String>>,
1966) -> Response {
1967    let a = match qs.get("a").filter(|s| !s.is_empty()) {
1968        Some(s) => s.clone(),
1969        None => return err_response("missing query param a"),
1970    };
1971    let b = match qs.get("b").filter(|s| !s.is_empty()) {
1972        Some(s) => s.clone(),
1973        None => return err_response("missing query param b"),
1974    };
1975    let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1976        Some(s) => s.clone(),
1977        None => return err_response("missing query param edge_type"),
1978    };
1979    let at_commit: u64 = match qs.get("at_commit") {
1980        Some(s) => match s.parse() {
1981            Ok(n) => n,
1982            Err(_) => return err_response("at_commit must be a non-negative integer"),
1983        },
1984        None => return err_response("missing query param at_commit"),
1985    };
1986
1987    if let AuthIdentity::Role(ref role_name) = identity {
1988        let g = state.db.read();
1989        let role_mask = match g.mask_for_role(role_name) {
1990            Ok(m) => m,
1991            Err(e) => return role_mask_err(e),
1992        };
1993        if !role_mask.contains_node(&*g, &a) {
1994            return key_not_found(a);
1995        }
1996        if !role_mask.contains_node(&*g, &b) {
1997            return key_not_found(b);
1998        }
1999        return match g.was_linked(&a, &b, &edge_type, at_commit) {
2000            Ok(linked) => json_ok(json!({
2001                "a": a, "b": b, "edge_type": edge_type,
2002                "at_commit": at_commit, "linked": linked,
2003            })),
2004            Err(GraphError::CommitOutOfRange { .. }) => (
2005                StatusCode::BAD_REQUEST,
2006                Json(json!({"error": format!("commit {at_commit} is out of range")})),
2007            )
2008                .into_response(),
2009            Err(e) => graph_err(e),
2010        };
2011    }
2012
2013    // Full identity: no masking.
2014    let g = state.db.read();
2015    match g.was_linked(&a, &b, &edge_type, at_commit) {
2016        Ok(linked) => json_ok(json!({
2017            "a": a, "b": b, "edge_type": edge_type,
2018            "at_commit": at_commit, "linked": linked,
2019        })),
2020        Err(GraphError::CommitOutOfRange { .. }) => (
2021            StatusCode::BAD_REQUEST,
2022            Json(json!({"error": format!("commit {at_commit} is out of range")})),
2023        )
2024            .into_response(),
2025        Err(e) => graph_err(e),
2026    }
2027}
2028
2029/// `DELETE /node/{key}/prop/{field}` — remove a property via the group-commit queue.
2030async fn remove_node_prop(
2031    State(state): State<AppState>,
2032    Extension(identity): Extension<AuthIdentity>,
2033    Path((key, field)): Path<(String, String)>,
2034) -> Response {
2035    let db = state.db.clone();
2036    if let AuthIdentity::Role(role_name) = &identity {
2037        let role = role_name.clone();
2038        return match blocking_write(move || {
2039            db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
2040        })
2041        .await
2042        {
2043            Ok(_) => json_ok(json!({"ok": true})),
2044            Err(resp) => resp,
2045        };
2046    }
2047    match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
2048        Ok(_) => json_ok(json!({"ok": true})),
2049        Err(resp) => resp,
2050    }
2051}
2052
2053/// Constant-time byte-string equality, used for secret (token) comparison so
2054/// the match does not short-circuit on the first differing byte and leak the
2055/// token through response-timing. Length is compared up front (token length is
2056/// not treated as a secret); the byte loop is branch-free over equal lengths.
2057fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2058    if a.len() != b.len() {
2059        return false;
2060    }
2061    let mut diff = 0u8;
2062    for (x, y) in a.iter().zip(b.iter()) {
2063        diff |= x ^ y;
2064    }
2065    diff == 0
2066}
2067
2068/// Maximum `depth` accepted by the neighborhood endpoint. Bounds the read-lock
2069/// hold time of a single BFS request on a dense graph (a hostile
2070/// `depth=4294967295` would otherwise traverse the whole graph under the read
2071/// guard, starving writers).
2072const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
2073
2074/// Parse and bound the neighborhood `depth` query parameter.
2075fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
2076    match raw {
2077        None => Ok(1),
2078        Some(s) => {
2079            let d: u32 = s
2080                .parse()
2081                .map_err(|_| "depth must be an integer".to_string())?;
2082            if d > MAX_NEIGHBORHOOD_DEPTH {
2083                return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
2084            }
2085            Ok(d)
2086        }
2087    }
2088}
2089
2090/// Confine a client-supplied backup `dest` to `root`, closing the arbitrary
2091/// filesystem-write vector (a full-access token could otherwise direct the
2092/// backup writer at `/etc/...`, `/root/.ssh/...`, etc.).
2093///
2094/// Rules: reject empty; reject any `..` segment; relative paths resolve under
2095/// `root`; absolute paths must already fall within `root`. The check is lexical
2096/// (no filesystem access) and safe because `..` is rejected before joining.
2097fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
2098    if dest.is_empty() {
2099        return Err("missing or empty \"dest\" field".into());
2100    }
2101    let dest_path = std::path::Path::new(dest);
2102    if dest_path
2103        .components()
2104        .any(|c| matches!(c, std::path::Component::ParentDir))
2105    {
2106        return Err("backup \"dest\" may not contain \"..\" path segments".into());
2107    }
2108    let joined = if dest_path.is_absolute() {
2109        dest_path.to_path_buf()
2110    } else {
2111        root.join(dest_path)
2112    };
2113    if !joined.starts_with(root) {
2114        return Err(format!(
2115            "backup \"dest\" must be within the backup root ({}); \
2116             set MUSHROOMDB_BACKUP_DIR to change it",
2117            root.display()
2118        ));
2119    }
2120    Ok(joined)
2121}
2122
2123/// Directory backups are confined to: `MUSHROOMDB_BACKUP_DIR` if set, else the
2124/// server's current working directory.
2125fn backup_root() -> PathBuf {
2126    std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2127        .map(PathBuf::from)
2128        .or_else(|| std::env::current_dir().ok())
2129        .unwrap_or_else(|| PathBuf::from("."))
2130}
2131
2132/// `POST /backup` — take a consistent backup of the database to `dest`.
2133///
2134/// The read guard is held for the duration of the file copies, which is the
2135/// correct cross-process synchronisation point: the server is the single
2136/// process touching the files, so holding the read lock excludes concurrent
2137/// in-process writers.  This is the safe alternative to running the
2138/// `mushroomdb backup` CLI against a live-served store.
2139///
2140/// Request body: `{"dest": "backup-dir"}`. `dest` is confined to the backup
2141/// root (`MUSHROOMDB_BACKUP_DIR`, else the server's working directory):
2142/// relative paths resolve under it, absolute paths must fall within it, and
2143/// `..` segments are rejected. This prevents a full-access token from writing
2144/// to arbitrary filesystem paths.
2145///
2146/// Responses:
2147/// - `200 OK` — backup completed; body is a `BackupReport` JSON object.
2148/// - `500 Internal Server Error` — backup succeeded but verification failed;
2149///   body is the `BackupReport` JSON object (examine `files` and `bytes`).
2150/// - `400 Bad Request` — missing or invalid `dest`.
2151/// - `403 Forbidden` — role-bound token; this endpoint requires a full-access token.
2152async fn backup(
2153    State(state): State<AppState>,
2154    Extension(identity): Extension<AuthIdentity>,
2155    Json(body): Json<Js>,
2156) -> Response {
2157    if let AuthIdentity::Role(_) = identity {
2158        return forbidden("role-bound token: /backup requires a full-access token");
2159    }
2160    let root = backup_root();
2161    let dest = match body.get("dest").and_then(Js::as_str) {
2162        Some(s) => match confine_backup_dest(s, &root) {
2163            Ok(p) => p,
2164            Err(e) => return err_response(e),
2165        },
2166        None => return err_response("missing or empty \"dest\" field"),
2167    };
2168    let db = state.db.clone();
2169    let report: BackupReport = match tokio::task::spawn_blocking(move || {
2170        // The read guard is held inside spawn_blocking so file copies happen
2171        // with the write lock excluded.  The guard drops at end of closure.
2172        let g = db.read();
2173        g.backup_to(&dest)
2174    })
2175    .await
2176    {
2177        Ok(Ok(r)) => r,
2178        Ok(Err(e)) => return graph_err(e),
2179        Err(_) => return err_response("backup task panicked"),
2180    };
2181
2182    let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2183        Ok(v) => v,
2184        Err(e) => return err_response(e.to_string()),
2185    };
2186
2187    if report.verified {
2188        json_ok(body)
2189    } else {
2190        (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2191    }
2192}
2193
2194/// JSON-serialisable projection of [`BackupReport`].
2195#[derive(serde::Serialize)]
2196struct BackupReportJson<'a> {
2197    files: &'a [String],
2198    bytes: u64,
2199    verified: bool,
2200}
2201
2202impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2203    fn from(r: &'a BackupReport) -> Self {
2204        Self {
2205            files: &r.files,
2206            bytes: r.bytes,
2207            verified: r.verified,
2208        }
2209    }
2210}
2211
2212#[cfg(test)]
2213mod tests {
2214    use super::*;
2215    use crate::json::result_set_json;
2216    use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2217
2218    #[test]
2219    fn nan_float_cell_serializes_as_null() {
2220        let mut rs = ResultSet::new(vec!["n".into()]);
2221        rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2222        let j = result_set_json(&rs);
2223        assert_eq!(j["rows"][0][0], Js::Null);
2224    }
2225
2226    /// Verify that `POST /algo/pagerank` accepts an empty JSON body `{}` and
2227    /// applies server defaults (regression guard for `#[serde(default)]`).
2228    #[test]
2229    fn pagerank_config_empty_body_uses_defaults() {
2230        let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2231        let default = PageRankConfig::default();
2232        assert_eq!(config.damping, default.damping);
2233        assert_eq!(config.max_iters, default.max_iters);
2234        assert_eq!(config.tol, default.tol);
2235        assert_eq!(config.budget_ms, default.budget_ms);
2236        assert_eq!(config.edge_type, default.edge_type);
2237    }
2238
2239    /// Same guard for `POST /algo/wcc`.
2240    #[test]
2241    fn wcc_config_empty_body_uses_defaults() {
2242        let config: WccConfig = serde_json::from_str("{}").unwrap();
2243        let default = WccConfig::default();
2244        assert_eq!(config.budget_ms, default.budget_ms);
2245        assert_eq!(config.edge_type, default.edge_type);
2246    }
2247
2248    /// Same guard for `POST /algo/degree`.
2249    #[test]
2250    fn degree_config_empty_body_uses_defaults() {
2251        let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2252        let default = DegreeConfig::default();
2253        assert_eq!(config.budget_ms, default.budget_ms);
2254        assert_eq!(config.edge_type, default.edge_type);
2255    }
2256
2257    #[test]
2258    fn backup_dest_rejects_empty() {
2259        assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2260    }
2261
2262    #[test]
2263    fn constant_time_eq_matches_equality() {
2264        assert!(constant_time_eq(b"secret-token", b"secret-token"));
2265        assert!(constant_time_eq(b"", b""));
2266        assert!(!constant_time_eq(b"secret-token", b"secret-toke")); // shorter
2267        assert!(!constant_time_eq(b"secret-token", b"secret-tokex")); // last byte differs
2268        assert!(!constant_time_eq(b"secret-token", b"Xecret-token")); // first byte differs
2269        assert!(!constant_time_eq(b"", b"x"));
2270    }
2271
2272    #[test]
2273    fn depth_defaults_to_one_when_absent() {
2274        assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2275    }
2276
2277    #[test]
2278    fn depth_within_cap_is_accepted() {
2279        assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2280        assert_eq!(
2281            resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2282            MAX_NEIGHBORHOOD_DEPTH
2283        );
2284    }
2285
2286    #[test]
2287    fn depth_over_cap_is_rejected() {
2288        assert!(resolve_neighborhood_depth(Some("65")).is_err());
2289        assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2290    }
2291
2292    #[test]
2293    fn depth_non_integer_is_rejected() {
2294        assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2295    }
2296
2297    #[test]
2298    fn backup_dest_rejects_parent_traversal() {
2299        let root = std::path::Path::new("/srv/backups");
2300        assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2301        assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2302    }
2303
2304    #[test]
2305    fn backup_dest_rejects_absolute_outside_root() {
2306        let root = std::path::Path::new("/srv/backups");
2307        assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2308        assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2309    }
2310
2311    #[test]
2312    fn backup_dest_allows_relative_within_root() {
2313        let root = std::path::Path::new("/srv/backups");
2314        assert_eq!(
2315            confine_backup_dest("nightly", root).unwrap(),
2316            PathBuf::from("/srv/backups/nightly")
2317        );
2318        assert_eq!(
2319            confine_backup_dest("2026/aug", root).unwrap(),
2320            PathBuf::from("/srv/backups/2026/aug")
2321        );
2322    }
2323
2324    #[test]
2325    fn backup_dest_allows_absolute_within_root() {
2326        let root = std::path::Path::new("/srv/backups");
2327        assert_eq!(
2328            confine_backup_dest("/srv/backups/x", root).unwrap(),
2329            PathBuf::from("/srv/backups/x")
2330        );
2331    }
2332}