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    )
72}
73
74/// [`router`] with a full-access token and a map of role-bound tokens.
75///
76/// Role-bound tokens (`role_tokens`: bearer → role name) receive masked reads
77/// on `/query` and `/node/*`, and 403 on all write or subscription endpoints.
78/// Unknown token: 401.  Token bound to a role not in the DB at request time: 401.
79pub fn router_with_role_tokens(
80    db: SharedDb,
81    token: Option<String>,
82    role_tokens: HashMap<String, String>,
83) -> Router {
84    build_app(
85        db,
86        token,
87        role_tokens,
88        UiFallback::None,
89        default_advertise_addr(),
90    )
91}
92
93/// Same as [`router_with_auth`], then `ServeDir` as the fallback so API routes win.
94///
95/// Role enforcement is not active on this entry point; use
96/// [`router_with_role_tokens`] when role-bound tokens are required.
97pub fn router_with_ui(
98    db: SharedDb,
99    ui_dir: impl AsRef<std::path::Path>,
100    token: Option<String>,
101) -> Router {
102    build_app(
103        db,
104        token,
105        HashMap::new(),
106        UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
107        default_advertise_addr(),
108    )
109}
110
111#[cfg(feature = "embed-ui")]
112static EMBEDDED_UI: include_dir::Dir<'_> =
113    include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
114
115/// [`router`] plus the `embed-ui` static tree as fallback.
116#[cfg(feature = "embed-ui")]
117pub fn router_with_embedded_ui(db: SharedDb) -> Router {
118    build_app(
119        db,
120        None,
121        HashMap::new(),
122        UiFallback::Embedded,
123        default_advertise_addr(),
124    )
125}
126
127#[cfg(feature = "embed-ui")]
128async fn embedded_fallback(uri: axum::http::Uri) -> Response {
129    let rel = if uri.path() == "/" || uri.path().is_empty() {
130        "index.html"
131    } else {
132        uri.path().trim_start_matches('/')
133    };
134    if rel.split('/').any(|seg| seg == "..") {
135        return StatusCode::NOT_FOUND.into_response();
136    }
137    match EMBEDDED_UI.get_file(rel) {
138        Some(file) => (
139            StatusCode::OK,
140            [(header::CONTENT_TYPE, embedded_ctype(rel))],
141            file.contents(),
142        )
143            .into_response(),
144        None => StatusCode::NOT_FOUND.into_response(),
145    }
146}
147
148#[cfg(feature = "embed-ui")]
149fn embedded_ctype(path: &str) -> &'static str {
150    if path.ends_with(".html") {
151        "text/html; charset=utf-8"
152    } else if path.ends_with(".js") {
153        "application/javascript; charset=utf-8"
154    } else if path.ends_with(".css") {
155        "text/css; charset=utf-8"
156    } else if path.ends_with(".woff2") {
157        "font/woff2"
158    } else if path.ends_with(".svg") {
159        "image/svg+xml"
160    } else if path.ends_with(".ico") {
161        "image/x-icon"
162    } else if path.ends_with(".txt") {
163        "text/plain; charset=utf-8"
164    } else {
165        "application/octet-stream"
166    }
167}
168
169/// Bind `addr` (port 0 is ephemeral) and serve.
170///
171/// Sends the resolved local address on `ready` once the listener is accepting.
172/// Does not hold a database lock.
173///
174/// Role enforcement is not active on this entry point; use
175/// [`serve_with_role_tokens`] when role-bound tokens are required.
176#[deprecated(
177    since = "0.2.0",
178    note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
179)]
180#[doc(hidden)]
181pub async fn serve(
182    db: SharedDb,
183    addr: SocketAddr,
184    ready: tokio::sync::oneshot::Sender<SocketAddr>,
185    token: Option<String>,
186) -> std::io::Result<()> {
187    serve_inner(db, addr, ready, UiFallback::None, token, HashMap::new()).await
188}
189
190/// [`serve`] with role-bound tokens in addition to the optional full-access token.
191///
192/// `role_tokens` maps bearer values to role names.  See [`router_with_role_tokens`]
193/// for the enforcement semantics.
194pub async fn serve_with_role_tokens(
195    db: SharedDb,
196    addr: SocketAddr,
197    ready: tokio::sync::oneshot::Sender<SocketAddr>,
198    token: Option<String>,
199    role_tokens: HashMap<String, String>,
200) -> std::io::Result<()> {
201    serve_inner(db, addr, ready, UiFallback::None, token, role_tokens).await
202}
203
204/// [`serve`] plus a UI dist directory mounted behind the API routes.
205///
206/// Role enforcement is not active on this entry point; use
207/// [`serve_with_ui_and_role_tokens`] when role-bound tokens are required.
208#[deprecated(
209    since = "0.2.0",
210    note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
211)]
212#[doc(hidden)]
213pub async fn serve_with_ui(
214    db: SharedDb,
215    addr: SocketAddr,
216    ready: tokio::sync::oneshot::Sender<SocketAddr>,
217    ui_dir: PathBuf,
218    token: Option<String>,
219) -> std::io::Result<()> {
220    serve_inner(
221        db,
222        addr,
223        ready,
224        UiFallback::Dir(ui_dir),
225        token,
226        HashMap::new(),
227    )
228    .await
229}
230
231/// [`serve_with_ui`] with role-bound tokens.
232pub async fn serve_with_ui_and_role_tokens(
233    db: SharedDb,
234    addr: SocketAddr,
235    ready: tokio::sync::oneshot::Sender<SocketAddr>,
236    ui_dir: PathBuf,
237    token: Option<String>,
238    role_tokens: HashMap<String, String>,
239) -> std::io::Result<()> {
240    serve_inner(db, addr, ready, UiFallback::Dir(ui_dir), token, role_tokens).await
241}
242
243/// [`serve`] plus the compiled-in UI (no-op fallback if `embed-ui` is off).
244///
245/// `role_tokens` maps bearer values to role names; see [`router_with_role_tokens`]
246/// for enforcement semantics.  Pass `HashMap::new()` when no role tokens are needed.
247#[cfg(feature = "embed-ui")]
248pub async fn serve_with_embedded_ui(
249    db: SharedDb,
250    addr: SocketAddr,
251    ready: tokio::sync::oneshot::Sender<SocketAddr>,
252    token: Option<String>,
253    role_tokens: HashMap<String, String>,
254) -> std::io::Result<()> {
255    serve_inner(db, addr, ready, UiFallback::Embedded, token, role_tokens).await
256}
257
258enum UiFallback {
259    None,
260    Dir(PathBuf),
261    #[cfg(feature = "embed-ui")]
262    Embedded,
263}
264
265async fn serve_inner(
266    db: SharedDb,
267    addr: SocketAddr,
268    ready: tokio::sync::oneshot::Sender<SocketAddr>,
269    ui: UiFallback,
270    token: Option<String>,
271    role_tokens: HashMap<String, String>,
272) -> std::io::Result<()> {
273    let listener = tokio::net::TcpListener::bind(addr).await?;
274    let local = listener.local_addr()?;
275    if ready.send(local).is_err() {
276        // Caller dropped the readiness receiver; still serve.
277        eprintln!("serve: readiness receiver dropped before bind notify");
278    }
279    let app = build_app(db, token, role_tokens, ui, local);
280    axum::serve(listener, app).await
281}
282
283fn default_advertise_addr() -> SocketAddr {
284    SocketAddr::from(([127, 0, 0, 1], 8080))
285}
286
287fn build_app(
288    db: SharedDb,
289    token: Option<String>,
290    role_tokens: HashMap<String, String>,
291    ui: UiFallback,
292    addr: SocketAddr,
293) -> Router {
294    debug_assert!(
295        !db.read().has_event_sink(),
296        "router() must be called at most once per SharedDb; a second call \
297         replaces the sink and terminates all existing /watch subscribers \
298         with RecvError::Closed"
299    );
300    let (tx, _) = tokio::sync::broadcast::channel(1024);
301    {
302        let tx = tx.clone();
303        db.write().set_event_sink(Box::new(move |ev| {
304            let _ = tx.send(ev);
305        }));
306    }
307    let state = AppState {
308        db,
309        watch: tx,
310        token,
311        role_tokens,
312        addr,
313    };
314    let app = Router::new()
315        .route("/health", get(health))
316        .route("/query", post(query))
317        .route("/stats", get(stats))
318        .route("/ingest", post(ingest))
319        .route("/rules", post(create_rule))
320        .route("/suggest", get(suggest))
321        .route("/explain", get(explain))
322        .route("/node/{key}", get(node_info))
323        .route("/node/{key}", axum::routing::delete(delete_node))
324        .route("/node/{key}/edges", get(node_edges))
325        .route("/node/{key}/neighborhood", get(neighborhood))
326        .route("/node/{key}/history", get(node_history_handler))
327        .route("/history/edge", get(edge_history_handler))
328        .route("/history/was_linked", get(was_linked_handler))
329        .route(
330            "/node/{key}/prop/{field}",
331            axum::routing::put(set_node_prop),
332        )
333        .route(
334            "/node/{key}/prop/{field}",
335            axum::routing::delete(remove_node_prop),
336        )
337        // Simple BatchOp-mapped endpoints — routed through the group-commit
338        // queue (submit_batch) so concurrent node/edge CRUD does not hold
339        // the write lock during fsync.
340        .route("/nodes", post(create_node))
341        .route("/nodes/{key}/rename", post(rename_node))
342        .route("/edges", post(create_edge))
343        .route("/edges/upsert", post(upsert_edge))
344        .route(
345            "/edges/{etype}/{src}/{dst}",
346            axum::routing::delete(delete_edge),
347        )
348        .route("/algo/pagerank", post(algo_pagerank))
349        .route("/algo/wcc", post(algo_wcc))
350        .route("/algo/degree", post(algo_degree))
351        .route("/backup", post(backup))
352        .route("/watch", get(crate::ws::watch))
353        .route("/subscribe", get(crate::subscribe::subscribe))
354        .with_state(state.clone());
355    let app = match ui {
356        UiFallback::None => app,
357        UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
358        #[cfg(feature = "embed-ui")]
359        UiFallback::Embedded => app.fallback(embedded_fallback),
360    };
361    app.layer(middleware::from_fn_with_state(state, auth_middleware))
362}
363
364async fn health(State(state): State<AppState>) -> Response {
365    let (nodes, edges) = {
366        let g = state.db.read();
367        let s = g.stats();
368        (s.nodes_live, s.edges)
369    };
370    json_ok(json!({
371        "ok": true,
372        "nodes": nodes,
373        "edges": edges,
374        "addr": state.addr.to_string(),
375    }))
376}
377
378/// Run a GraphDb write on the blocking pool. The write guard lives only
379/// inside `f` and is dropped before this future awaits.
380async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
381where
382    T: Send + 'static,
383    F: FnOnce() -> core_api::Result<T> + Send + 'static,
384{
385    match tokio::task::spawn_blocking(f).await {
386        Ok(Ok(v)) => Ok(v),
387        Ok(Err(e)) => Err(graph_err(e)),
388        Err(_) => Err(err_response("write task panicked")),
389    }
390}
391
392const TOKEN_COOKIE: &str = "mushroomdb_token";
393
394async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
395    // No auth configured at all: every request is Full, no restriction.
396    if state.token.is_none() && state.role_tokens.is_empty() {
397        req.extensions_mut().insert(AuthIdentity::Full);
398        return next.run(req).await;
399    }
400
401    // Health is always open regardless of token configuration.
402    if req.method() == Method::GET && req.uri().path() == "/health" {
403        req.extensions_mut().insert(AuthIdentity::Full);
404        return next.run(req).await;
405    }
406
407    let presented = request_token(&req);
408
409    // Check full-access token first.
410    if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
411        if presented.as_deref() == Some(full_tok.as_str()) {
412            let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
413            req.extensions_mut().insert(AuthIdentity::Full);
414            let mut res = next.run(req).await;
415            if set_cookie && is_html_response(&res) {
416                attach_token_cookie(&mut res, full_tok);
417            }
418            return res;
419        }
420    }
421
422    // Check role-bound tokens.
423    if let Some(tok) = presented.as_deref() {
424        if let Some(role_name) = state.role_tokens.get(tok) {
425            // Early-deny paths whose handlers use WebSocket upgrade: the WS
426            // extraction consumes the request before the handler body runs, so
427            // the handler-level identity check is unreachable in those cases.
428            // Returning 403 here also removes the need for the handler to
429            // hold a read lock just to enforce this.
430            let path = req.uri().path();
431            if path == "/subscribe" || path == "/watch" {
432                return forbidden("role-bound token: this endpoint is not permitted");
433            }
434            req.extensions_mut()
435                .insert(AuthIdentity::Role(role_name.clone()));
436            return next.run(req).await;
437        }
438    }
439
440    // Nothing matched → 401.
441    unauthorized()
442}
443
444fn request_token(req: &Request) -> Option<String> {
445    presented_bearer_or_query(req).or_else(|| presented_cookie(req))
446}
447
448fn presented_bearer_or_query(req: &Request) -> Option<String> {
449    if let Some(header) = req
450        .headers()
451        .get(header::AUTHORIZATION)
452        .and_then(|v| v.to_str().ok())
453    {
454        if let Some(value) = bearer_token(header) {
455            return Some(value.to_string());
456        }
457    }
458    query_param(req.uri().query().unwrap_or(""), "token")
459}
460
461fn presented_cookie(req: &Request) -> Option<String> {
462    let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
463    cookie_named(header, TOKEN_COOKIE).map(str::to_string)
464}
465
466fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
467    for part in header.split(';') {
468        let part = part.trim();
469        let Some((k, v)) = part.split_once('=') else {
470            continue;
471        };
472        if k.trim() == name {
473            return Some(v.trim());
474        }
475    }
476    None
477}
478
479fn is_html_response(res: &Response) -> bool {
480    res.headers()
481        .get(header::CONTENT_TYPE)
482        .and_then(|v| v.to_str().ok())
483        .is_some_and(|ct| {
484            ct.split(';')
485                .next()
486                .unwrap_or("")
487                .trim()
488                .eq_ignore_ascii_case("text/html")
489        })
490}
491
492fn attach_token_cookie(res: &mut Response, token: &str) {
493    let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly");
494    if let Ok(hv) = HeaderValue::from_str(&value) {
495        res.headers_mut().insert(header::SET_COOKIE, hv);
496    }
497}
498
499fn bearer_token(header: &str) -> Option<&str> {
500    let (scheme, value) = header.split_once(' ')?;
501    if scheme.eq_ignore_ascii_case("Bearer") {
502        Some(value.trim())
503    } else {
504        None
505    }
506}
507
508fn query_param(query: &str, key: &str) -> Option<String> {
509    for pair in query.split('&') {
510        if pair.is_empty() {
511            continue;
512        }
513        match pair.split_once('=') {
514            Some((k, v)) if k == key => return percent_decode_plus(v),
515            None if pair == key => return Some(String::new()),
516            _ => {}
517        }
518    }
519    None
520}
521
522/// `application/x-www-form-urlencoded`: `+` is space, `%HH` is a byte.
523fn percent_decode_plus(s: &str) -> Option<String> {
524    let bytes = s.as_bytes();
525    let mut out = Vec::with_capacity(bytes.len());
526    let mut i = 0;
527    while i < bytes.len() {
528        match bytes[i] {
529            b'+' => {
530                out.push(b' ');
531                i += 1;
532            }
533            b'%' => {
534                if i + 2 >= bytes.len() {
535                    return None;
536                }
537                let hi = from_hex(bytes[i + 1])?;
538                let lo = from_hex(bytes[i + 2])?;
539                out.push((hi << 4) | lo);
540                i += 3;
541            }
542            c => {
543                out.push(c);
544                i += 1;
545            }
546        }
547    }
548    String::from_utf8(out).ok()
549}
550
551fn from_hex(b: u8) -> Option<u8> {
552    match b {
553        b'0'..=b'9' => Some(b - b'0'),
554        b'a'..=b'f' => Some(b - b'a' + 10),
555        b'A'..=b'F' => Some(b - b'A' + 10),
556        _ => None,
557    }
558}
559
560fn unauthorized() -> Response {
561    (
562        StatusCode::UNAUTHORIZED,
563        Json(json!({"error": "unauthorized"})),
564    )
565        .into_response()
566}
567
568/// 403 response for role-bound token operations that are denied in v1.
569fn forbidden(detail: &str) -> Response {
570    (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
571}
572
573/// Convert a `mask_for_role` error into an HTTP response.
574///
575/// - `GraphError::Corrupt` → 500: the roles sidecar is poisoned; the server
576///   refuses all role-token requests until the file is fixed and the DB is
577///   re-opened.  Full-access tokens are unaffected.
578/// - `GraphError::KeyNotFound` with a `role:` prefix → 401: the token is bound
579///   to a role that does not exist in the DB at this request time.
580fn role_mask_err(e: GraphError) -> Response {
581    match e {
582        GraphError::Corrupt { detail } => (
583            StatusCode::INTERNAL_SERVER_ERROR,
584            Json(json!({"error": format!("roles misconfigured: {detail}")})),
585        )
586            .into_response(),
587        GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
588        other => graph_err(other),
589    }
590}
591
592fn err_response(detail: impl Into<String>) -> Response {
593    (
594        StatusCode::BAD_REQUEST,
595        Json(json!({"error": detail.into()})),
596    )
597        .into_response()
598}
599
600fn graph_err(e: GraphError) -> Response {
601    match e {
602        // §4.3: role-scoped write denials map to 403 with the verbatim reason
603        // string (Display delegates to reason, so .to_string() == reason).
604        GraphError::RoleWriteDenied { reason } => forbidden(&reason),
605        GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
606            err_response(detail)
607        }
608        other => err_response(other.to_string()),
609    }
610}
611
612fn key_not_found(key: String) -> Response {
613    (
614        StatusCode::NOT_FOUND,
615        Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
616    )
617        .into_response()
618}
619
620fn conflict_response(key: String) -> Response {
621    (
622        StatusCode::CONFLICT,
623        Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
624    )
625        .into_response()
626}
627
628fn json_ok(value: Js) -> Response {
629    (StatusCode::OK, Json(value)).into_response()
630}
631
632fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
633    let Some(v) = v else {
634        return Ok(IngestOptions::default());
635    };
636    if v.is_null() {
637        return Ok(IngestOptions::default());
638    }
639    let obj = v
640        .as_object()
641        .ok_or_else(|| "options must be an object".to_string())?;
642    let mut opts = IngestOptions::default();
643    if let Some(kf) = obj.get("key_field") {
644        opts.key_field = kf
645            .as_str()
646            .ok_or_else(|| "options.key_field must be a string".to_string())?
647            .to_string();
648    }
649    if let Some(fk) = obj.get("auto_fk") {
650        if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
651            opts.auto_fk = AutoFk::Off;
652        } else if let Some(m) = fk.as_object() {
653            let suf = m
654                .get("suffix")
655                .and_then(Js::as_str)
656                .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
657            opts.auto_fk = AutoFk::Auto {
658                suffix: suf.to_string(),
659            };
660        } else {
661            return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
662        }
663    }
664    Ok(opts)
665}
666
667/// Format a `ResultSet` as Arrow IPC or JSON depending on `format`.
668fn format_query_result(rs: ResultSet, format: &str) -> Response {
669    match format {
670        "" => match to_ipc_bytes(&rs) {
671            Ok(bytes) => (
672                StatusCode::OK,
673                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
674                bytes,
675            )
676                .into_response(),
677            Err(e) => err_response(e),
678        },
679        "json" => json_ok(result_set_json(&rs)),
680        other => err_response(format!("unknown format: {other}")),
681    }
682}
683
684async fn query(
685    State(state): State<AppState>,
686    Extension(identity): Extension<AuthIdentity>,
687    Query(qs): Query<BTreeMap<String, String>>,
688    Json(body): Json<Js>,
689) -> Response {
690    let cypher = match body.get("cypher").and_then(Js::as_str) {
691        Some(s) => s.to_string(),
692        None => return err_response("missing cypher"),
693    };
694    let params = match params_from_json(body.get("params")) {
695        Ok(p) => p,
696        Err(e) => return err_response(e),
697    };
698    let format = qs.get("format").map(String::as_str).unwrap_or("");
699
700    // Parse client-supplied mask (optional array of node keys).
701    let mask_keys: Option<Vec<String>> = match body.get("mask") {
702        None | Some(Js::Null) => None,
703        Some(Js::Array(arr)) => {
704            let mut keys = Vec::with_capacity(arr.len());
705            for v in arr {
706                match v.as_str() {
707                    Some(s) => keys.push(s.to_string()),
708                    None => return err_response("mask must be an array of strings"),
709                }
710            }
711            Some(keys)
712        }
713        Some(_) => return err_response("mask must be an array of strings"),
714    };
715
716    // Role token: write Cypher routes to query_write_authz (scope + mask
717    // resolved under the write lock, §5 discipline).  Read Cypher uses the
718    // lock-free epoch snapshot path unchanged.
719    if let AuthIdentity::Role(ref role_name) = identity {
720        let is_write = match is_write_query(&cypher) {
721            Ok(b) => b,
722            Err(e) => return err_response(e),
723        };
724        if is_write {
725            let role = role_name.clone();
726            let cypher_c = cypher.clone();
727            let params_c = params.clone();
728            let db = state.db.clone();
729            return match blocking_write(move || {
730                db.write().query_write_authz(&role, &cypher_c, &params_c)
731            })
732            .await
733            {
734                Ok(rs) => format_query_result(rs, format),
735                Err(resp) => resp,
736            };
737        }
738        let snap = state.db.reader();
739        let role_mask = match snap.mask_for_role(role_name) {
740            Ok(m) => m,
741            Err(e) => return role_mask_err(e),
742        };
743        let effective_mask = if let Some(ref keys) = mask_keys {
744            // Client mask intersects role mask — never widens visibility.
745            let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
746            role_mask.intersect(&client_mask)
747        } else {
748            role_mask
749        };
750        return match snap.query_masked(&cypher, &params, &effective_mask) {
751            Ok(rs) => format_query_result(rs, format),
752            Err(GraphError::MaskedReadOnly) => (
753                StatusCode::BAD_REQUEST,
754                Json(json!({"error": "masked queries are read-only"})),
755            )
756                .into_response(),
757            Err(e) => graph_err(e),
758        };
759    }
760
761    // Full token: existing paths below.
762
763    // When a client-supplied mask is present, route to query_masked (read-only).
764    // Hold a single read guard for both from_keys and query_masked so the mask
765    // and the query execute on the same database snapshot.
766    //
767    // `stub_hidden: true` opts into MaskMode::Stub for the mask; Cypher query
768    // behaviour is identical in both modes (hidden nodes are excluded from
769    // query results regardless of mode).
770    if let Some(ref keys) = mask_keys {
771        let stub_hidden = body
772            .get("stub_hidden")
773            .and_then(|v| v.as_bool())
774            .unwrap_or(false);
775        let db = state.db.read();
776        let mask = {
777            let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
778            if stub_hidden {
779                m.with_mode(MaskMode::Stub)
780            } else {
781                m
782            }
783        };
784        return match db.query_masked(&cypher, &params, &mask) {
785            Ok(rs) => format_query_result(rs, format),
786            Err(GraphError::MaskedReadOnly) => (
787                StatusCode::BAD_REQUEST,
788                Json(json!({"error": "masked queries are read-only"})),
789            )
790                .into_response(),
791            Err(e) => graph_err(e),
792        };
793    }
794
795    // Detect write statements to dispatch to the correct lock.
796    // Write statements (CREATE / MATCH…SET / MATCH…DELETE / MERGE) need the
797    // write lock so mutations flow through WAL + rule engine with fsync before
798    // the response is sent.  Read queries (MATCH … RETURN …) use the read lock.
799    let is_write = match is_write_query(&cypher) {
800        Ok(b) => b,
801        Err(e) => return err_response(e),
802    };
803
804    let rs = if is_write {
805        let db = state.db.clone();
806        match blocking_write(move || db.write().query_write(&cypher, &params)).await {
807            Ok(rs) => rs,
808            Err(resp) => return resp,
809        }
810    } else {
811        match state.db.read().query(&cypher, &params) {
812            Ok(rs) => rs,
813            Err(e) => return graph_err(e),
814        }
815    };
816
817    format_query_result(rs, format)
818}
819
820async fn stats(
821    State(state): State<AppState>,
822    Extension(identity): Extension<AuthIdentity>,
823) -> Response {
824    // v1: deny role tokens — raw counts leak graph size beyond the role's subgraph.
825    if let AuthIdentity::Role(_) = identity {
826        return forbidden("role-bound token: /stats requires a full-access token");
827    }
828    let snap = {
829        let g = state.db.read();
830        g.stats()
831    };
832    match serde_json::to_value(&snap) {
833        Ok(v) => json_ok(v),
834        Err(e) => err_response(e.to_string()),
835    }
836}
837
838async fn ingest(
839    State(state): State<AppState>,
840    Extension(identity): Extension<AuthIdentity>,
841    Json(body): Json<Js>,
842) -> Response {
843    let label = match body.get("label").and_then(Js::as_str) {
844        Some(s) => s.to_string(),
845        None => return err_response("missing label"),
846    };
847    let rows = match body.get("rows") {
848        Some(r) => r,
849        None => return err_response("missing rows"),
850    };
851    let mut converted = match json_to_rows(rows) {
852        Ok(c) => c,
853        Err(e) => return graph_err(e),
854    };
855    let opts = match ingest_options(body.get("options")) {
856        Ok(o) => o,
857        Err(e) => return err_response(e),
858    };
859    let taken = std::mem::take(&mut converted.rows);
860    let edges = match body.get("edges") {
861        None | Some(Js::Null) => Vec::new(),
862        Some(raw) => match parse_ingest_edges(raw) {
863            Ok(e) => e,
864            Err(e) => return err_response(e),
865        },
866    };
867    let db = state.db.clone();
868
869    // Role token: route to ingest_with_edges_authz (scope + mask resolved under
870    // the write lock, §5 discipline; §7.3 — create_labels required, enforced by
871    // the engine's per-op authz check inside commit_logged_batch).
872    if let AuthIdentity::Role(role_name) = &identity {
873        let role = role_name.clone();
874        return match blocking_write(move || {
875            db.write()
876                .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
877        })
878        .await
879        {
880            Ok(r) => {
881                let report = converted.into_report(r);
882                match serde_json::to_value(&report) {
883                    Ok(v) => json_ok(v),
884                    Err(e) => err_response(e.to_string()),
885                }
886            }
887            Err(resp) => resp,
888        };
889    }
890
891    let report =
892        match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
893            .await
894        {
895            Ok(r) => converted.into_report(r),
896            Err(resp) => return resp,
897        };
898    match serde_json::to_value(&report) {
899        Ok(v) => json_ok(v),
900        Err(e) => err_response(e.to_string()),
901    }
902}
903
904/// `GET /suggest` — profile the database and return rule suggestions.
905///
906/// # Locking and blocking strategy
907///
908/// `suggest_rules_with_config` is CPU-intensive and synchronous. Running it on a
909/// Tokio worker thread would starve the executor. This handler offloads the work to
910/// `tokio::task::spawn_blocking`, which uses the blocking thread-pool. The
911/// `std::sync::RwLock` read guard is acquired and held inside the blocking task —
912/// reads don't block other reads; writes wait for the guard to drop. The global
913/// budget (`SuggestConfig::global_budget_ms`, default 5 s) caps lock-hold time.
914async fn suggest(
915    State(state): State<AppState>,
916    Extension(identity): Extension<AuthIdentity>,
917) -> Response {
918    // v1: deny role tokens — suggest scans the full graph and would reveal
919    // existence of nodes outside the role's mask.
920    if let AuthIdentity::Role(_) = identity {
921        return forbidden("role-bound token: /suggest requires a full-access token");
922    }
923    let db = state.db.clone();
924    match tokio::task::spawn_blocking(move || {
925        let config = SuggestConfig::default();
926        db.read()
927            .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
928    })
929    .await
930    {
931        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
932        Err(_) => err_response("suggest task panicked"),
933    }
934}
935
936async fn create_rule(
937    State(state): State<AppState>,
938    Extension(identity): Extension<AuthIdentity>,
939    Json(body): Json<Js>,
940) -> Response {
941    if let AuthIdentity::Role(_) = identity {
942        return forbidden("role-bound token: writes are not permitted");
943    }
944    let def = match rule_def_from_json(body) {
945        Ok(d) => d,
946        Err(e) => return err_response(e),
947    };
948    let name = def.name.clone();
949    let db = state.db.clone();
950    match blocking_write(move || db.write().create_rule(def)).await {
951        Ok(()) => json_ok(json!({"ok": true, "name": name})),
952        Err(resp) => resp,
953    }
954}
955
956async fn explain(
957    State(state): State<AppState>,
958    Extension(identity): Extension<AuthIdentity>,
959    Query(qs): Query<BTreeMap<String, String>>,
960) -> Response {
961    // v1: deny role tokens — explain reveals hidden-node linkage through rules.
962    if let AuthIdentity::Role(_) = identity {
963        return forbidden(
964            "role-bound token: /explain requires a full-access token \
965             (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
966        );
967    }
968    let a = match qs.get("a") {
969        Some(s) if !s.is_empty() => s.clone(),
970        _ => return err_response("missing query param a"),
971    };
972    let b = match qs.get("b") {
973        Some(s) if !s.is_empty() => s.clone(),
974        _ => return err_response("missing query param b"),
975    };
976    let out = {
977        let g = state.db.read();
978        g.explain(&a, &b)
979    };
980    match out {
981        Ok(v) => match serde_json::to_value(&v) {
982            Ok(j) => json_ok(j),
983            Err(e) => err_response(e.to_string()),
984        },
985        Err(e) => graph_err(e),
986    }
987}
988
989async fn node_info(
990    State(state): State<AppState>,
991    Extension(identity): Extension<AuthIdentity>,
992    Path(key): Path<String>,
993    Query(qs): Query<BTreeMap<String, String>>,
994) -> Response {
995    if let AuthIdentity::Role(ref role_name) = identity {
996        // Role-token path: hard-coded Omit mode; hidden keys are indistinguishable
997        // from absent keys.  `stub_hidden` query param is silently ignored here —
998        // role paths must NEVER produce stubs (RBAC invariant).
999        let snap = state.db.reader();
1000        let role_mask = match snap.mask_for_role(role_name) {
1001            Ok(m) => m,
1002            Err(e) => return role_mask_err(e),
1003        };
1004        if !snap
1005            .resolve_key(&key)
1006            .is_some_and(|id| role_mask.contains_id(id))
1007        {
1008            return key_not_found(key);
1009        }
1010        return match snap.node_info(&key) {
1011            Some(info) => json_ok(node_info_json(&info)),
1012            None => key_not_found(key),
1013        };
1014    }
1015
1016    // Full-token path: optional client mask + stub_hidden via query params.
1017    // `mask=key1,key2` — comma-separated visible keys (empty string = no mask).
1018    // `stub_hidden=true` — opt into MaskMode::Stub for this request.
1019    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1020    if !mask_param.is_empty() {
1021        let stub_hidden = qs
1022            .get("stub_hidden")
1023            .map(|v| v == "true" || v == "1")
1024            .unwrap_or(false);
1025        let g = state.db.read();
1026        let mask = {
1027            let keys = mask_param
1028                .split(',')
1029                .map(str::trim)
1030                .filter(|s| !s.is_empty());
1031            let m = NodeMask::from_keys(&*g, keys);
1032            if stub_hidden {
1033                m.with_mode(MaskMode::Stub)
1034            } else {
1035                m
1036            }
1037        };
1038        return match g.node_info_masked(&key, &mask) {
1039            Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1040            Some(core_api::MaskedNodeResult::Restricted) => {
1041                json_ok(crate::json::stub_node_json(&key))
1042            }
1043            None => key_not_found(key),
1044        };
1045    }
1046
1047    let info = {
1048        let g = state.db.read();
1049        g.node_info(&key)
1050    };
1051    match info {
1052        Some(info) => json_ok(node_info_json(&info)),
1053        None => key_not_found(key),
1054    }
1055}
1056
1057async fn node_edges(
1058    State(state): State<AppState>,
1059    Extension(identity): Extension<AuthIdentity>,
1060    Path(key): Path<String>,
1061    Query(qs): Query<BTreeMap<String, String>>,
1062) -> Response {
1063    if let AuthIdentity::Role(ref role_name) = identity {
1064        // Role-token path: hard-coded Omit mode.  `stub_hidden` query param is
1065        // silently ignored — role paths must NEVER produce stubs (RBAC invariant).
1066        let snap = state.db.reader();
1067        let role_mask = match snap.mask_for_role(role_name) {
1068            Ok(m) => m,
1069            Err(e) => return role_mask_err(e),
1070        };
1071        if !snap
1072            .resolve_key(&key)
1073            .is_some_and(|id| role_mask.contains_id(id))
1074        {
1075            return key_not_found(key);
1076        }
1077        return match snap.node_edges(&key) {
1078            Ok(edges) => {
1079                // Filter out edges whose OTHER endpoint is hidden in the role
1080                // mask.  A role token must not learn about hidden neighbors via
1081                // the edge list even when the entry key itself is visible.
1082                let visible: Vec<_> = edges
1083                    .into_iter()
1084                    .filter(|e| {
1085                        let other = if e.src_key == key {
1086                            &e.dst_key
1087                        } else {
1088                            &e.src_key
1089                        };
1090                        snap.resolve_key(other)
1091                            .is_some_and(|id| role_mask.contains_id(id))
1092                    })
1093                    .collect();
1094                json_ok(node_edges_json(&visible))
1095            }
1096            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1097            Err(e) => graph_err(e),
1098        };
1099    }
1100
1101    // Full-token path: optional client mask + stub_hidden via query params.
1102    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1103    if !mask_param.is_empty() {
1104        let stub_hidden = qs
1105            .get("stub_hidden")
1106            .map(|v| v == "true" || v == "1")
1107            .unwrap_or(false);
1108        let g = state.db.read();
1109        let mask = {
1110            let keys = mask_param
1111                .split(',')
1112                .map(str::trim)
1113                .filter(|s| !s.is_empty());
1114            let m = NodeMask::from_keys(&*g, keys);
1115            if stub_hidden {
1116                m.with_mode(MaskMode::Stub)
1117            } else {
1118                m
1119            }
1120        };
1121        return match g.node_edges_masked(&key, &mask) {
1122            Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1123            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1124            Err(e) => graph_err(e),
1125        };
1126    }
1127
1128    let out = {
1129        let g = state.db.read();
1130        g.node_edges(&key)
1131    };
1132    match out {
1133        Ok(edges) => json_ok(node_edges_json(&edges)),
1134        Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1135        Err(e) => graph_err(e),
1136    }
1137}
1138
1139async fn neighborhood(
1140    State(state): State<AppState>,
1141    Extension(identity): Extension<AuthIdentity>,
1142    Path(key): Path<String>,
1143    Query(qs): Query<BTreeMap<String, String>>,
1144) -> Response {
1145    let depth = match qs.get("depth") {
1146        None => 1u32,
1147        Some(s) => match s.parse() {
1148            Ok(d) => d,
1149            Err(_) => return err_response("depth must be an integer"),
1150        },
1151    };
1152    let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1153        s if s.eq_ignore_ascii_case("out") => Dir::Out,
1154        s if s.eq_ignore_ascii_case("in") => Dir::In,
1155        s if s.eq_ignore_ascii_case("both") => Dir::Both,
1156        other => return err_response(format!("unknown dir: {other}")),
1157    };
1158    let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1159        s.split(',')
1160            .map(str::trim)
1161            .filter(|t| !t.is_empty())
1162            .map(str::to_string)
1163            .collect()
1164    });
1165    let etype_refs: Option<Vec<&str>> = edge_type_names
1166        .as_ref()
1167        .map(|v| v.iter().map(String::as_str).collect());
1168    if let AuthIdentity::Role(ref role_name) = identity {
1169        // Lock-free epoch snapshot: mask and neighborhood BFS on same frozen state
1170        // (constraint 2 — RBAC mask coherence).
1171        let snap = state.db.reader();
1172        let role_mask = match snap.mask_for_role(role_name) {
1173            Ok(m) => m,
1174            Err(e) => return role_mask_err(e),
1175        };
1176        if !snap
1177            .resolve_key(&key)
1178            .is_some_and(|id| role_mask.contains_id(id))
1179        {
1180            return key_not_found(key);
1181        }
1182        // Use the mask-aware BFS: hidden nodes are excluded from results AND
1183        // cannot be used as traversal intermediaries (never-leak invariant).
1184        let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1185        {
1186            Some(rs) => rs,
1187            None => return key_not_found(key),
1188        };
1189        return json_ok(result_set_json(&rs));
1190    }
1191    // Full-token path: optional client mask + stub_hidden via query params.
1192    // `mask=key1,key2` — comma-separated visible keys.
1193    // `stub_hidden=true` — opt into MaskMode::Stub; hidden direct neighbours
1194    // appear as stub rows (label: null) in the result; BFS does not expand
1195    // through them in either mode.
1196    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1197    if !mask_param.is_empty() {
1198        let stub_hidden = qs
1199            .get("stub_hidden")
1200            .map(|v| v == "true" || v == "1")
1201            .unwrap_or(false);
1202        let g = state.db.read();
1203        let mask = {
1204            let keys = mask_param
1205                .split(',')
1206                .map(str::trim)
1207                .filter(|s| !s.is_empty());
1208            let m = NodeMask::from_keys(&*g, keys);
1209            if stub_hidden {
1210                m.with_mode(MaskMode::Stub)
1211            } else {
1212                m
1213            }
1214        };
1215        return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1216            Some(rs) => json_ok(result_set_json(&rs)),
1217            None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1218        };
1219    }
1220
1221    // Unmasked full-token path (no mask param).
1222    let rs = {
1223        let g = state.db.read();
1224        match g.node_ref(&key) {
1225            Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1226            None => Err(GraphError::KeyNotFound { key: key.clone() }),
1227        }
1228    };
1229    match rs {
1230        Ok(rs) => json_ok(result_set_json(&rs)),
1231        Err(e) => graph_err(e),
1232    }
1233}
1234
1235/// `POST /algo/pagerank` — run PageRank over the unified topology.
1236///
1237/// # Locking and blocking strategy
1238///
1239/// PageRank is CPU-intensive and synchronous. This handler offloads the work
1240/// to `tokio::task::spawn_blocking` (blocking thread-pool). The read guard is
1241/// acquired and held inside the blocking task — reads don't block other reads.
1242/// The `budget_ms` field in [`PageRankConfig`] caps lock-hold time.
1243async fn algo_pagerank(
1244    State(state): State<AppState>,
1245    Extension(identity): Extension<AuthIdentity>,
1246    Json(body): Json<serde_json::Value>,
1247) -> Response {
1248    // v1: deny role tokens — algo endpoints scan the full graph.
1249    if let AuthIdentity::Role(_) = identity {
1250        return forbidden("role-bound token: /algo/* requires a full-access token");
1251    }
1252    let config: PageRankConfig = match serde_json::from_value(body) {
1253        Ok(c) => c,
1254        Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1255    };
1256    let db = state.db.clone();
1257    match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1258        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1259        Err(_) => err_response("pagerank task panicked"),
1260    }
1261}
1262
1263/// `POST /algo/wcc` — weakly-connected components over the unified topology.
1264///
1265/// Mirrors the `suggest` locking and blocking pattern exactly: spawn_blocking,
1266/// read guard inside, `budget_ms` in config caps lock-hold time.
1267async fn algo_wcc(
1268    State(state): State<AppState>,
1269    Extension(identity): Extension<AuthIdentity>,
1270    Json(body): Json<serde_json::Value>,
1271) -> Response {
1272    if let AuthIdentity::Role(_) = identity {
1273        return forbidden("role-bound token: /algo/* requires a full-access token");
1274    }
1275    let config: WccConfig = match serde_json::from_value(body) {
1276        Ok(c) => c,
1277        Err(e) => return err_response(format!("invalid wcc config: {e}")),
1278    };
1279    let db = state.db.clone();
1280    match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1281        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1282        Err(_) => err_response("wcc task panicked"),
1283    }
1284}
1285
1286/// `POST /algo/degree` — degree centrality over the unified topology.
1287///
1288/// Mirrors the `suggest` locking and blocking pattern exactly.
1289async fn algo_degree(
1290    State(state): State<AppState>,
1291    Extension(identity): Extension<AuthIdentity>,
1292    Json(body): Json<serde_json::Value>,
1293) -> Response {
1294    if let AuthIdentity::Role(_) = identity {
1295        return forbidden("role-bound token: /algo/* requires a full-access token");
1296    }
1297    let config: DegreeConfig = match serde_json::from_value(body) {
1298        Ok(c) => c,
1299        Err(e) => return err_response(format!("invalid degree config: {e}")),
1300    };
1301    let db = state.db.clone();
1302    match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1303        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1304        Err(_) => err_response("degree task panicked"),
1305    }
1306}
1307
1308// ── Simple BatchOp-mapped endpoints ──────────────────────────────────────────
1309//
1310// These endpoints map 1:1 to BatchOp variants and route through submit_batch
1311// so concurrent writes share one WAL fsync per drain group, keeping reader
1312// p95 latency low under write bursts.  Auth checks (role tokens → 403) run
1313// before enqueue so RBAC enforcement is unchanged.
1314//
1315// Complex paths (/query Cypher writes, /ingest bulk JSON) stay on db.write()
1316// because they are multi-step operations that cannot be pre-expressed as a
1317// Vec<BatchOp> without redesigning the query executor.
1318
1319/// Parse a JSON object into a `Vec<(String, Value)>` prop list.
1320fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1321    let obj = match v.as_object() {
1322        Some(o) => o,
1323        None => return Err("props must be a JSON object".into()),
1324    };
1325    let mut out = Vec::with_capacity(obj.len());
1326    for (k, val) in obj {
1327        if let Some(v) = json_to_value(val.clone()) {
1328            out.push((k.clone(), v));
1329        }
1330    }
1331    Ok(out)
1332}
1333
1334/// `POST /nodes` — create or upsert a node via the group-commit queue.
1335///
1336/// Body: `{"label": "Person", "key": "alice", "props": {"age": 30}}`
1337async fn create_node(
1338    State(state): State<AppState>,
1339    Extension(identity): Extension<AuthIdentity>,
1340    Json(body): Json<Js>,
1341) -> Response {
1342    let label = match body.get("label").and_then(Js::as_str) {
1343        Some(s) => s.to_string(),
1344        None => return err_response("missing label"),
1345    };
1346    let key = match body.get("key").and_then(Js::as_str) {
1347        Some(s) => s.to_string(),
1348        None => return err_response("missing key"),
1349    };
1350    let props = match body.get("props") {
1351        None | Some(Js::Null) => vec![],
1352        Some(v) => match props_from_json_obj(v) {
1353            Ok(p) => p,
1354            Err(e) => return err_response(e),
1355        },
1356    };
1357    let db = state.db.clone();
1358    if let AuthIdentity::Role(role_name) = &identity {
1359        let role = role_name.clone();
1360        return match blocking_write(move || {
1361            db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1362        })
1363        .await
1364        {
1365            Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1366            Err(resp) => resp,
1367        };
1368    }
1369    match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1370        .await
1371    {
1372        Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1373        Err(resp) => resp,
1374    }
1375}
1376
1377/// `DELETE /node/{key}` — delete a node via the group-commit queue.
1378async fn delete_node(
1379    State(state): State<AppState>,
1380    Extension(identity): Extension<AuthIdentity>,
1381    Path(key): Path<String>,
1382) -> Response {
1383    let db = state.db.clone();
1384    if let AuthIdentity::Role(role_name) = &identity {
1385        let role = role_name.clone();
1386        return match blocking_write(move || {
1387            db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1388        })
1389        .await
1390        {
1391            Ok(_) => json_ok(json!({"ok": true})),
1392            Err(resp) => resp,
1393        };
1394    }
1395    match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1396        Ok(_) => json_ok(json!({"ok": true})),
1397        Err(resp) => resp,
1398    }
1399}
1400
1401/// `POST /edges` — create an edge via the group-commit queue.
1402///
1403/// Body: `{"type": "KNOWS", "src": "alice", "dst": "bob"}`
1404async fn create_edge(
1405    State(state): State<AppState>,
1406    Extension(identity): Extension<AuthIdentity>,
1407    Json(body): Json<Js>,
1408) -> Response {
1409    let edge_type = match body.get("type").and_then(Js::as_str) {
1410        Some(s) => s.to_string(),
1411        None => return err_response("missing type"),
1412    };
1413    let src = match body.get("src").and_then(Js::as_str) {
1414        Some(s) => s.to_string(),
1415        None => return err_response("missing src"),
1416    };
1417    let dst = match body.get("dst").and_then(Js::as_str) {
1418        Some(s) => s.to_string(),
1419        None => return err_response("missing dst"),
1420    };
1421    let db = state.db.clone();
1422    if let AuthIdentity::Role(role_name) = &identity {
1423        let role = role_name.clone();
1424        return match blocking_write(move || {
1425            db.submit_batch_authz(
1426                role,
1427                vec![BatchOp::InsertEdge {
1428                    edge_type,
1429                    src_key: src,
1430                    dst_key: dst,
1431                }],
1432            )
1433        })
1434        .await
1435        {
1436            Ok(_) => json_ok(json!({"ok": true})),
1437            Err(resp) => resp,
1438        };
1439    }
1440    match blocking_write(move || {
1441        db.submit_batch(vec![BatchOp::InsertEdge {
1442            edge_type,
1443            src_key: src,
1444            dst_key: dst,
1445        }])
1446    })
1447    .await
1448    {
1449        Ok(_) => json_ok(json!({"ok": true})),
1450        Err(resp) => resp,
1451    }
1452}
1453
1454/// `DELETE /edges/{etype}/{src}/{dst}` — delete an edge via the group-commit queue.
1455async fn delete_edge(
1456    State(state): State<AppState>,
1457    Extension(identity): Extension<AuthIdentity>,
1458    Path((etype, src, dst)): Path<(String, String, String)>,
1459) -> Response {
1460    let db = state.db.clone();
1461    if let AuthIdentity::Role(role_name) = &identity {
1462        let role = role_name.clone();
1463        return match blocking_write(move || {
1464            db.submit_batch_authz(
1465                role,
1466                vec![BatchOp::DeleteEdge {
1467                    edge_type: etype,
1468                    src_key: src,
1469                    dst_key: dst,
1470                }],
1471            )
1472        })
1473        .await
1474        {
1475            Ok(_) => json_ok(json!({"ok": true})),
1476            Err(resp) => resp,
1477        };
1478    }
1479    match blocking_write(move || {
1480        db.submit_batch(vec![BatchOp::DeleteEdge {
1481            edge_type: etype,
1482            src_key: src,
1483            dst_key: dst,
1484        }])
1485    })
1486    .await
1487    {
1488        Ok(_) => json_ok(json!({"ok": true})),
1489        Err(resp) => resp,
1490    }
1491}
1492
1493/// `POST /nodes/{key}/rename` — rename a node's key via the group-commit queue.
1494///
1495/// Body: `{"new_key": "alice2"}`
1496/// Returns 404 on KeyNotFound, 409 on DuplicateKey, 200 on success.
1497async fn rename_node(
1498    State(state): State<AppState>,
1499    Extension(identity): Extension<AuthIdentity>,
1500    Path(key): Path<String>,
1501    Json(body): Json<Js>,
1502) -> Response {
1503    if let AuthIdentity::Role(_) = identity {
1504        return forbidden("role-bound token: writes are not permitted");
1505    }
1506    let new_key = match body.get("new_key").and_then(Js::as_str) {
1507        Some(s) => s.to_string(),
1508        None => return err_response("missing new_key"),
1509    };
1510    let db = state.db.clone();
1511    match tokio::task::spawn_blocking(move || {
1512        db.submit_batch(vec![BatchOp::RenameNode {
1513            old_key: key,
1514            new_key,
1515        }])
1516    })
1517    .await
1518    {
1519        Ok(Ok(_)) => json_ok(json!({"ok": true})),
1520        Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1521        Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1522        Ok(Err(e)) => graph_err(e),
1523        Err(_) => err_response("write task panicked"),
1524    }
1525}
1526
1527/// `POST /edges/upsert` — insert an edge, auto-creating missing endpoints.
1528///
1529/// Body: `{"edge_type":"KNOWS","src_key":"alice","dst_key":"bob","placeholder_label":"Person"}`
1530/// Returns `{"nodes_created": N, "edge_inserted": bool}`.
1531async fn upsert_edge(
1532    State(state): State<AppState>,
1533    Extension(identity): Extension<AuthIdentity>,
1534    Json(body): Json<Js>,
1535) -> Response {
1536    let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1537        Some(s) => s.to_string(),
1538        None => return err_response("missing edge_type"),
1539    };
1540    let src_key = match body.get("src_key").and_then(Js::as_str) {
1541        Some(s) => s.to_string(),
1542        None => return err_response("missing src_key"),
1543    };
1544    let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1545        Some(s) => s.to_string(),
1546        None => return err_response("missing dst_key"),
1547    };
1548    let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1549        Some(s) => s.to_string(),
1550        None => return err_response("missing placeholder_label"),
1551    };
1552    let db = state.db.clone();
1553    if let AuthIdentity::Role(role_name) = &identity {
1554        let role = role_name.clone();
1555        return match blocking_write(move || {
1556            db.submit_batch_authz(
1557                role,
1558                vec![BatchOp::InsertEdgeUpsert {
1559                    edge_type,
1560                    src_key,
1561                    dst_key,
1562                    placeholder_label,
1563                }],
1564            )
1565        })
1566        .await
1567        {
1568            Ok((nodes, edges)) => json_ok(json!({
1569                "nodes_created": nodes,
1570                "edge_inserted": edges > 0,
1571            })),
1572            Err(resp) => resp,
1573        };
1574    }
1575    match blocking_write(move || {
1576        db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1577            edge_type,
1578            src_key,
1579            dst_key,
1580            placeholder_label,
1581        }])
1582    })
1583    .await
1584    {
1585        Ok((nodes, edges)) => json_ok(json!({
1586            "nodes_created": nodes,
1587            "edge_inserted": edges > 0,
1588        })),
1589        Err(resp) => resp,
1590    }
1591}
1592
1593/// `PUT /node/{key}/prop/{field}` — set a property via the group-commit queue.
1594///
1595/// Body: `{"value": 42}`
1596async fn set_node_prop(
1597    State(state): State<AppState>,
1598    Extension(identity): Extension<AuthIdentity>,
1599    Path((key, field)): Path<(String, String)>,
1600    Json(body): Json<Js>,
1601) -> Response {
1602    let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1603        Some(v) => v,
1604        None => return err_response("missing or null value"),
1605    };
1606    let db = state.db.clone();
1607    if let AuthIdentity::Role(role_name) = &identity {
1608        let role = role_name.clone();
1609        return match blocking_write(move || {
1610            db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1611        })
1612        .await
1613        {
1614            Ok(_) => json_ok(json!({"ok": true})),
1615            Err(resp) => resp,
1616        };
1617    }
1618    match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1619        .await
1620    {
1621        Ok(_) => json_ok(json!({"ok": true})),
1622        Err(resp) => resp,
1623    }
1624}
1625
1626// ── History endpoints ─────────────────────────────────────────────────────────
1627//
1628// These are cold-path diagnostic endpoints. They scan the on-disk WAL and
1629// must NOT extend ReaderSnapshot — they use db.read() directly per the
1630// controller ruling (see module doc and task-2-brief.md).
1631//
1632// Role masking: node visibility is checked under the SAME read guard as the
1633// history call (coherent snapshot). A node outside the role's mask responds
1634// identically to an absent node — no existence oracle.
1635
1636/// `GET /node/{key}/history` — return the WAL change history for `key`.
1637///
1638/// Response: `{ key, history: [{commit, change}], total_commits }`.
1639/// Role tokens: if `key` is hidden by the role mask, responds with 404
1640/// (same shape as querying an absent key — no existence oracle).
1641async fn node_history_handler(
1642    State(state): State<AppState>,
1643    Extension(identity): Extension<AuthIdentity>,
1644    Path(key): Path<String>,
1645) -> Response {
1646    if let AuthIdentity::Role(ref role_name) = identity {
1647        let g = state.db.read();
1648        let role_mask = match g.mask_for_role(role_name) {
1649            Ok(m) => m,
1650            Err(e) => return role_mask_err(e),
1651        };
1652        // Hidden keys must respond identically to absent keys (no oracle).
1653        if !role_mask.contains_node(&*g, &key) {
1654            return key_not_found(key);
1655        }
1656        let entries = match g.node_history(&key) {
1657            Ok(e) => e,
1658            Err(e) => return graph_err(e),
1659        };
1660        let total_commits = match g.wal_total_commits() {
1661            Ok(n) => n,
1662            Err(e) => return graph_err(e),
1663        };
1664        // Filter EdgeAdded/EdgeRemoved entries whose `other` endpoint is hidden.
1665        // A role token must not learn about hidden nodes via edge history events —
1666        // mirrors the same protection in `node_edges` (http.rs ~978-989).
1667        use core_api::HistoryChange;
1668        let visible: Vec<_> = entries
1669            .into_iter()
1670            .filter(|entry| match &entry.change {
1671                HistoryChange::EdgeAdded { other, .. }
1672                | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1673                _ => true,
1674            })
1675            .collect();
1676        return json_ok(node_history_json(&key, &visible, total_commits));
1677    }
1678    // Full identity: no masking. Return 404 for absent keys (consistent with
1679    // GET /node/{key} and the Role branch above).
1680    let g = state.db.read();
1681    if !g.has_node(&key) {
1682        return key_not_found(key);
1683    }
1684    let entries = match g.node_history(&key) {
1685        Ok(e) => e,
1686        Err(e) => return graph_err(e),
1687    };
1688    let total_commits = match g.wal_total_commits() {
1689        Ok(n) => n,
1690        Err(e) => return graph_err(e),
1691    };
1692    json_ok(node_history_json(&key, &entries, total_commits))
1693}
1694
1695/// `GET /history/edge?a=&b=` — return the edge lifecycle between two nodes.
1696///
1697/// Response: `{ a, b, events: [{edge_type, commit, event, rule}], total_commits }`.
1698/// Role tokens: BOTH `a` AND `b` must be visible in the role mask, otherwise
1699/// responds with 404 for the first invisible key (no existence oracle).
1700async fn edge_history_handler(
1701    State(state): State<AppState>,
1702    Extension(identity): Extension<AuthIdentity>,
1703    Query(qs): Query<BTreeMap<String, String>>,
1704) -> Response {
1705    let a = match qs.get("a").filter(|s| !s.is_empty()) {
1706        Some(s) => s.clone(),
1707        None => return err_response("missing query param a"),
1708    };
1709    let b = match qs.get("b").filter(|s| !s.is_empty()) {
1710        Some(s) => s.clone(),
1711        None => return err_response("missing query param b"),
1712    };
1713    if let AuthIdentity::Role(ref role_name) = identity {
1714        let g = state.db.read();
1715        let role_mask = match g.mask_for_role(role_name) {
1716            Ok(m) => m,
1717            Err(e) => return role_mask_err(e),
1718        };
1719        // BOTH endpoints must be visible (no oracle for either).
1720        if !role_mask.contains_node(&*g, &a) {
1721            return key_not_found(a);
1722        }
1723        if !role_mask.contains_node(&*g, &b) {
1724            return key_not_found(b);
1725        }
1726        let result = match g.edge_history(&a, &b) {
1727            Ok(r) => r,
1728            Err(e) => return graph_err(e),
1729        };
1730        return json_ok(edge_history_result_json(&a, &b, &result));
1731    }
1732    // Full identity: no masking.
1733    let g = state.db.read();
1734    let result = match g.edge_history(&a, &b) {
1735        Ok(r) => r,
1736        Err(e) => return graph_err(e),
1737    };
1738    json_ok(edge_history_result_json(&a, &b, &result))
1739}
1740
1741/// `GET /history/was_linked?a=&b=&edge_type=&at_commit=` — point-in-time edge check.
1742///
1743/// Response: `{ a, b, edge_type, at_commit, linked }`.
1744/// Returns 400 (not 500) when `at_commit` is outside the visible horizon.
1745/// Role tokens: BOTH `a` AND `b` must be visible, same-as-absent otherwise.
1746async fn was_linked_handler(
1747    State(state): State<AppState>,
1748    Extension(identity): Extension<AuthIdentity>,
1749    Query(qs): Query<BTreeMap<String, String>>,
1750) -> Response {
1751    let a = match qs.get("a").filter(|s| !s.is_empty()) {
1752        Some(s) => s.clone(),
1753        None => return err_response("missing query param a"),
1754    };
1755    let b = match qs.get("b").filter(|s| !s.is_empty()) {
1756        Some(s) => s.clone(),
1757        None => return err_response("missing query param b"),
1758    };
1759    let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1760        Some(s) => s.clone(),
1761        None => return err_response("missing query param edge_type"),
1762    };
1763    let at_commit: u64 = match qs.get("at_commit") {
1764        Some(s) => match s.parse() {
1765            Ok(n) => n,
1766            Err(_) => return err_response("at_commit must be a non-negative integer"),
1767        },
1768        None => return err_response("missing query param at_commit"),
1769    };
1770
1771    if let AuthIdentity::Role(ref role_name) = identity {
1772        let g = state.db.read();
1773        let role_mask = match g.mask_for_role(role_name) {
1774            Ok(m) => m,
1775            Err(e) => return role_mask_err(e),
1776        };
1777        if !role_mask.contains_node(&*g, &a) {
1778            return key_not_found(a);
1779        }
1780        if !role_mask.contains_node(&*g, &b) {
1781            return key_not_found(b);
1782        }
1783        return match g.was_linked(&a, &b, &edge_type, at_commit) {
1784            Ok(linked) => json_ok(json!({
1785                "a": a, "b": b, "edge_type": edge_type,
1786                "at_commit": at_commit, "linked": linked,
1787            })),
1788            Err(GraphError::CommitOutOfRange { .. }) => (
1789                StatusCode::BAD_REQUEST,
1790                Json(json!({"error": format!("commit {at_commit} is out of range")})),
1791            )
1792                .into_response(),
1793            Err(e) => graph_err(e),
1794        };
1795    }
1796
1797    // Full identity: no masking.
1798    let g = state.db.read();
1799    match g.was_linked(&a, &b, &edge_type, at_commit) {
1800        Ok(linked) => json_ok(json!({
1801            "a": a, "b": b, "edge_type": edge_type,
1802            "at_commit": at_commit, "linked": linked,
1803        })),
1804        Err(GraphError::CommitOutOfRange { .. }) => (
1805            StatusCode::BAD_REQUEST,
1806            Json(json!({"error": format!("commit {at_commit} is out of range")})),
1807        )
1808            .into_response(),
1809        Err(e) => graph_err(e),
1810    }
1811}
1812
1813/// `DELETE /node/{key}/prop/{field}` — remove a property via the group-commit queue.
1814async fn remove_node_prop(
1815    State(state): State<AppState>,
1816    Extension(identity): Extension<AuthIdentity>,
1817    Path((key, field)): Path<(String, String)>,
1818) -> Response {
1819    let db = state.db.clone();
1820    if let AuthIdentity::Role(role_name) = &identity {
1821        let role = role_name.clone();
1822        return match blocking_write(move || {
1823            db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
1824        })
1825        .await
1826        {
1827            Ok(_) => json_ok(json!({"ok": true})),
1828            Err(resp) => resp,
1829        };
1830    }
1831    match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
1832        Ok(_) => json_ok(json!({"ok": true})),
1833        Err(resp) => resp,
1834    }
1835}
1836
1837/// `POST /backup` — take a consistent backup of the database to `dest`.
1838///
1839/// The read guard is held for the duration of the file copies, which is the
1840/// correct cross-process synchronisation point: the server is the single
1841/// process touching the files, so holding the read lock excludes concurrent
1842/// in-process writers.  This is the safe alternative to running the
1843/// `mushroomdb backup` CLI against a live-served store.
1844///
1845/// Request body: `{"dest": "/absolute/path/to/backup-dir"}`
1846///
1847/// Responses:
1848/// - `200 OK` — backup completed; body is a `BackupReport` JSON object.
1849/// - `500 Internal Server Error` — backup succeeded but verification failed;
1850///   body is the `BackupReport` JSON object (examine `files` and `bytes`).
1851/// - `400 Bad Request` — missing or invalid `dest`.
1852/// - `403 Forbidden` — role-bound token; this endpoint requires a full-access token.
1853async fn backup(
1854    State(state): State<AppState>,
1855    Extension(identity): Extension<AuthIdentity>,
1856    Json(body): Json<Js>,
1857) -> Response {
1858    if let AuthIdentity::Role(_) = identity {
1859        return forbidden("role-bound token: /backup requires a full-access token");
1860    }
1861    let dest = match body.get("dest").and_then(Js::as_str) {
1862        Some(s) if !s.is_empty() => std::path::PathBuf::from(s),
1863        _ => return err_response("missing or empty \"dest\" field"),
1864    };
1865    let db = state.db.clone();
1866    let report: BackupReport = match tokio::task::spawn_blocking(move || {
1867        // The read guard is held inside spawn_blocking so file copies happen
1868        // with the write lock excluded.  The guard drops at end of closure.
1869        let g = db.read();
1870        g.backup_to(&dest)
1871    })
1872    .await
1873    {
1874        Ok(Ok(r)) => r,
1875        Ok(Err(e)) => return graph_err(e),
1876        Err(_) => return err_response("backup task panicked"),
1877    };
1878
1879    let body = match serde_json::to_value(BackupReportJson::from(&report)) {
1880        Ok(v) => v,
1881        Err(e) => return err_response(e.to_string()),
1882    };
1883
1884    if report.verified {
1885        json_ok(body)
1886    } else {
1887        (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
1888    }
1889}
1890
1891/// JSON-serialisable projection of [`BackupReport`].
1892#[derive(serde::Serialize)]
1893struct BackupReportJson<'a> {
1894    files: &'a [String],
1895    bytes: u64,
1896    verified: bool,
1897}
1898
1899impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
1900    fn from(r: &'a BackupReport) -> Self {
1901        Self {
1902            files: &r.files,
1903            bytes: r.bytes,
1904            verified: r.verified,
1905        }
1906    }
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911    use super::*;
1912    use crate::json::result_set_json;
1913    use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
1914
1915    #[test]
1916    fn nan_float_cell_serializes_as_null() {
1917        let mut rs = ResultSet::new(vec!["n".into()]);
1918        rs.push_row(vec![Some(Value::Float(f64::NAN))]);
1919        let j = result_set_json(&rs);
1920        assert_eq!(j["rows"][0][0], Js::Null);
1921    }
1922
1923    /// Verify that `POST /algo/pagerank` accepts an empty JSON body `{}` and
1924    /// applies server defaults (regression guard for `#[serde(default)]`).
1925    #[test]
1926    fn pagerank_config_empty_body_uses_defaults() {
1927        let config: PageRankConfig = serde_json::from_str("{}").unwrap();
1928        let default = PageRankConfig::default();
1929        assert_eq!(config.damping, default.damping);
1930        assert_eq!(config.max_iters, default.max_iters);
1931        assert_eq!(config.tol, default.tol);
1932        assert_eq!(config.budget_ms, default.budget_ms);
1933        assert_eq!(config.edge_type, default.edge_type);
1934    }
1935
1936    /// Same guard for `POST /algo/wcc`.
1937    #[test]
1938    fn wcc_config_empty_body_uses_defaults() {
1939        let config: WccConfig = serde_json::from_str("{}").unwrap();
1940        let default = WccConfig::default();
1941        assert_eq!(config.budget_ms, default.budget_ms);
1942        assert_eq!(config.edge_type, default.edge_type);
1943    }
1944
1945    /// Same guard for `POST /algo/degree`.
1946    #[test]
1947    fn degree_config_empty_body_uses_defaults() {
1948        let config: DegreeConfig = serde_json::from_str("{}").unwrap();
1949        let default = DegreeConfig::default();
1950        assert_eq!(config.budget_ms, default.budget_ms);
1951        assert_eq!(config.edge_type, default.edge_type);
1952    }
1953}