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    };
370    let app = Router::new()
371        .route("/health", get(health))
372        .route("/query", post(query))
373        .route("/stats", get(stats))
374        .route("/ingest", post(ingest))
375        .route("/rules", post(create_rule))
376        .route("/suggest", get(suggest))
377        .route("/explain", get(explain))
378        .route("/node/{key}", get(node_info))
379        .route("/node/{key}", axum::routing::delete(delete_node))
380        .route("/node/{key}/edges", get(node_edges))
381        .route("/node/{key}/neighborhood", get(neighborhood))
382        .route("/node/{key}/history", get(node_history_handler))
383        .route("/history/edge", get(edge_history_handler))
384        .route("/history/was_linked", get(was_linked_handler))
385        .route(
386            "/node/{key}/prop/{field}",
387            axum::routing::put(set_node_prop),
388        )
389        .route(
390            "/node/{key}/prop/{field}",
391            axum::routing::delete(remove_node_prop),
392        )
393        // Simple BatchOp-mapped endpoints — routed through the group-commit
394        // queue (submit_batch) so concurrent node/edge CRUD does not hold
395        // the write lock during fsync.
396        .route("/nodes", post(create_node))
397        .route("/nodes/{key}/rename", post(rename_node))
398        .route("/edges", post(create_edge))
399        .route("/edges/upsert", post(upsert_edge))
400        .route(
401            "/edges/{etype}/{src}/{dst}",
402            axum::routing::delete(delete_edge),
403        )
404        .route("/algo/pagerank", post(algo_pagerank))
405        .route("/algo/wcc", post(algo_wcc))
406        .route("/algo/degree", post(algo_degree))
407        .route("/backup", post(backup))
408        .route("/watch", get(crate::ws::watch))
409        .route("/subscribe", get(crate::subscribe::subscribe))
410        .with_state(state.clone());
411    let app = match ui {
412        UiFallback::None => app,
413        UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
414        #[cfg(feature = "embed-ui")]
415        UiFallback::Embedded => app.fallback(embedded_fallback),
416    };
417    app.layer(middleware::from_fn_with_state(state, auth_middleware))
418        // Cap request bodies (default axum limit is only 2 MiB; we allow larger
419        // ingest batches but reject multi-GB bodies that would OOM the collector
420        // before any handler runs).
421        .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
422}
423
424/// Maximum accepted HTTP request body size (64 MiB). Large enough for batched
425/// `/ingest` payloads, small enough to prevent a single request from
426/// exhausting memory. Clients with larger imports should chunk.
427const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
428
429async fn health(State(state): State<AppState>) -> Response {
430    let (nodes, edges) = {
431        let g = state.db.read();
432        let s = g.stats();
433        (s.nodes_live, s.edges)
434    };
435    json_ok(json!({
436        "ok": true,
437        "nodes": nodes,
438        "edges": edges,
439        "addr": state.addr.to_string(),
440    }))
441}
442
443/// Run a GraphDb write on the blocking pool. The write guard lives only
444/// inside `f` and is dropped before this future awaits.
445async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
446where
447    T: Send + 'static,
448    F: FnOnce() -> core_api::Result<T> + Send + 'static,
449{
450    match tokio::task::spawn_blocking(f).await {
451        Ok(Ok(v)) => Ok(v),
452        Ok(Err(e)) => Err(graph_err(e)),
453        Err(_) => Err(err_response("write task panicked")),
454    }
455}
456
457const TOKEN_COOKIE: &str = "mushroomdb_token";
458
459async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
460    // No auth configured at all: every request is Full, no restriction.
461    if state.token.is_none() && state.role_tokens.is_empty() {
462        req.extensions_mut().insert(AuthIdentity::Full);
463        return next.run(req).await;
464    }
465
466    // Health is always open regardless of token configuration.
467    if req.method() == Method::GET && req.uri().path() == "/health" {
468        req.extensions_mut().insert(AuthIdentity::Full);
469        return next.run(req).await;
470    }
471
472    let presented = request_token(&req);
473
474    // Check full-access token first.
475    if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
476        if presented
477            .as_deref()
478            .is_some_and(|p| constant_time_eq(p.as_bytes(), full_tok.as_bytes()))
479        {
480            let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
481            req.extensions_mut().insert(AuthIdentity::Full);
482            let mut res = next.run(req).await;
483            if set_cookie && is_html_response(&res) {
484                attach_token_cookie(&mut res, full_tok, state.tls_active);
485            }
486            return res;
487        }
488    }
489
490    // Check role-bound tokens.
491    if let Some(tok) = presented.as_deref() {
492        if let Some(role_name) = state.role_tokens.get(tok) {
493            // Early-deny paths whose handlers use WebSocket upgrade: the WS
494            // extraction consumes the request before the handler body runs, so
495            // the handler-level identity check is unreachable in those cases.
496            // Returning 403 here also removes the need for the handler to
497            // hold a read lock just to enforce this.
498            let path = req.uri().path();
499            if path == "/subscribe" || path == "/watch" {
500                return forbidden("role-bound token: this endpoint is not permitted");
501            }
502            req.extensions_mut()
503                .insert(AuthIdentity::Role(role_name.clone()));
504            return next.run(req).await;
505        }
506    }
507
508    // Nothing matched → 401.
509    unauthorized()
510}
511
512fn request_token(req: &Request) -> Option<String> {
513    presented_bearer_or_query(req).or_else(|| presented_cookie(req))
514}
515
516fn presented_bearer_or_query(req: &Request) -> Option<String> {
517    if let Some(header) = req
518        .headers()
519        .get(header::AUTHORIZATION)
520        .and_then(|v| v.to_str().ok())
521    {
522        if let Some(value) = bearer_token(header) {
523            return Some(value.to_string());
524        }
525    }
526    query_param(req.uri().query().unwrap_or(""), "token")
527}
528
529fn presented_cookie(req: &Request) -> Option<String> {
530    let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
531    cookie_named(header, TOKEN_COOKIE).map(str::to_string)
532}
533
534fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
535    for part in header.split(';') {
536        let part = part.trim();
537        let Some((k, v)) = part.split_once('=') else {
538            continue;
539        };
540        if k.trim() == name {
541            return Some(v.trim());
542        }
543    }
544    None
545}
546
547fn is_html_response(res: &Response) -> bool {
548    res.headers()
549        .get(header::CONTENT_TYPE)
550        .and_then(|v| v.to_str().ok())
551        .is_some_and(|ct| {
552            ct.split(';')
553                .next()
554                .unwrap_or("")
555                .trim()
556                .eq_ignore_ascii_case("text/html")
557        })
558}
559
560fn attach_token_cookie(res: &mut Response, token: &str, secure: bool) {
561    let secure_attr = if secure { "; Secure" } else { "" };
562    let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly{secure_attr}");
563    if let Ok(hv) = HeaderValue::from_str(&value) {
564        res.headers_mut().insert(header::SET_COOKIE, hv);
565    }
566}
567
568fn bearer_token(header: &str) -> Option<&str> {
569    let (scheme, value) = header.split_once(' ')?;
570    if scheme.eq_ignore_ascii_case("Bearer") {
571        Some(value.trim())
572    } else {
573        None
574    }
575}
576
577fn query_param(query: &str, key: &str) -> Option<String> {
578    for pair in query.split('&') {
579        if pair.is_empty() {
580            continue;
581        }
582        match pair.split_once('=') {
583            Some((k, v)) if k == key => return percent_decode_plus(v),
584            None if pair == key => return Some(String::new()),
585            _ => {}
586        }
587    }
588    None
589}
590
591/// `application/x-www-form-urlencoded`: `+` is space, `%HH` is a byte.
592fn percent_decode_plus(s: &str) -> Option<String> {
593    let bytes = s.as_bytes();
594    let mut out = Vec::with_capacity(bytes.len());
595    let mut i = 0;
596    while i < bytes.len() {
597        match bytes[i] {
598            b'+' => {
599                out.push(b' ');
600                i += 1;
601            }
602            b'%' => {
603                if i + 2 >= bytes.len() {
604                    return None;
605                }
606                let hi = from_hex(bytes[i + 1])?;
607                let lo = from_hex(bytes[i + 2])?;
608                out.push((hi << 4) | lo);
609                i += 3;
610            }
611            c => {
612                out.push(c);
613                i += 1;
614            }
615        }
616    }
617    String::from_utf8(out).ok()
618}
619
620fn from_hex(b: u8) -> Option<u8> {
621    match b {
622        b'0'..=b'9' => Some(b - b'0'),
623        b'a'..=b'f' => Some(b - b'a' + 10),
624        b'A'..=b'F' => Some(b - b'A' + 10),
625        _ => None,
626    }
627}
628
629fn unauthorized() -> Response {
630    (
631        StatusCode::UNAUTHORIZED,
632        Json(json!({"error": "unauthorized"})),
633    )
634        .into_response()
635}
636
637/// 403 response for role-bound token operations that are denied in v1.
638fn forbidden(detail: &str) -> Response {
639    (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
640}
641
642/// Convert a `mask_for_role` error into an HTTP response.
643///
644/// - `GraphError::Corrupt` → 500: the roles sidecar is poisoned; the server
645///   refuses all role-token requests until the file is fixed and the DB is
646///   re-opened.  Full-access tokens are unaffected.
647/// - `GraphError::KeyNotFound` with a `role:` prefix → 401: the token is bound
648///   to a role that does not exist in the DB at this request time.
649fn role_mask_err(e: GraphError) -> Response {
650    match e {
651        GraphError::Corrupt { detail } => (
652            StatusCode::INTERNAL_SERVER_ERROR,
653            Json(json!({"error": format!("roles misconfigured: {detail}")})),
654        )
655            .into_response(),
656        GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
657        other => graph_err(other),
658    }
659}
660
661fn err_response(detail: impl Into<String>) -> Response {
662    (
663        StatusCode::BAD_REQUEST,
664        Json(json!({"error": detail.into()})),
665    )
666        .into_response()
667}
668
669fn graph_err(e: GraphError) -> Response {
670    match e {
671        // §4.3: role-scoped write denials map to 403 with the verbatim reason
672        // string (Display delegates to reason, so .to_string() == reason).
673        GraphError::RoleWriteDenied { reason } => forbidden(&reason),
674        GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
675            err_response(detail)
676        }
677        other => err_response(other.to_string()),
678    }
679}
680
681fn key_not_found(key: String) -> Response {
682    (
683        StatusCode::NOT_FOUND,
684        Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
685    )
686        .into_response()
687}
688
689fn conflict_response(key: String) -> Response {
690    (
691        StatusCode::CONFLICT,
692        Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
693    )
694        .into_response()
695}
696
697fn json_ok(value: Js) -> Response {
698    (StatusCode::OK, Json(value)).into_response()
699}
700
701fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
702    let Some(v) = v else {
703        return Ok(IngestOptions::default());
704    };
705    if v.is_null() {
706        return Ok(IngestOptions::default());
707    }
708    let obj = v
709        .as_object()
710        .ok_or_else(|| "options must be an object".to_string())?;
711    let mut opts = IngestOptions::default();
712    if let Some(kf) = obj.get("key_field") {
713        opts.key_field = kf
714            .as_str()
715            .ok_or_else(|| "options.key_field must be a string".to_string())?
716            .to_string();
717    }
718    if let Some(fk) = obj.get("auto_fk") {
719        if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
720            opts.auto_fk = AutoFk::Off;
721        } else if let Some(m) = fk.as_object() {
722            let suf = m
723                .get("suffix")
724                .and_then(Js::as_str)
725                .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
726            opts.auto_fk = AutoFk::Auto {
727                suffix: suf.to_string(),
728            };
729        } else {
730            return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
731        }
732    }
733    Ok(opts)
734}
735
736/// Format a `ResultSet` as Arrow IPC or JSON depending on `format`.
737fn format_query_result(rs: ResultSet, format: &str) -> Response {
738    match format {
739        "" => match to_ipc_bytes(&rs) {
740            Ok(bytes) => (
741                StatusCode::OK,
742                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
743                bytes,
744            )
745                .into_response(),
746            Err(e) => err_response(e),
747        },
748        "json" => json_ok(result_set_json(&rs)),
749        other => err_response(format!("unknown format: {other}")),
750    }
751}
752
753async fn query(
754    State(state): State<AppState>,
755    Extension(identity): Extension<AuthIdentity>,
756    Query(qs): Query<BTreeMap<String, String>>,
757    Json(body): Json<Js>,
758) -> Response {
759    let cypher = match body.get("cypher").and_then(Js::as_str) {
760        Some(s) => s.to_string(),
761        None => return err_response("missing cypher"),
762    };
763    let params = match params_from_json(body.get("params")) {
764        Ok(p) => p,
765        Err(e) => return err_response(e),
766    };
767    let format = qs.get("format").map(String::as_str).unwrap_or("");
768
769    // Optional time-travel: `as_of` is a 0-based WAL commit index. The query
770    // runs against the graph as it existed at that commit (read-only).
771    let as_of = match body.get("as_of") {
772        None | Some(Js::Null) => None,
773        Some(v) => match v.as_u64() {
774            Some(n) => Some(n),
775            None => return err_response("as_of must be a non-negative integer commit index"),
776        },
777    };
778
779    // Parse client-supplied mask (optional array of node keys).
780    let mask_keys: Option<Vec<String>> = match body.get("mask") {
781        None | Some(Js::Null) => None,
782        Some(Js::Array(arr)) => {
783            let mut keys = Vec::with_capacity(arr.len());
784            for v in arr {
785                match v.as_str() {
786                    Some(s) => keys.push(s.to_string()),
787                    None => return err_response("mask must be an array of strings"),
788                }
789            }
790            Some(keys)
791        }
792        Some(_) => return err_response("mask must be an array of strings"),
793    };
794
795    // Time-travel is currently supported only on the full-token, unmasked read
796    // path (temporal + RBAC-mask composition is a follow-on).
797    if as_of.is_some() && (matches!(identity, AuthIdentity::Role(_)) || mask_keys.is_some()) {
798        return err_response(
799            "as_of (time-travel) is not yet supported with role tokens or a client mask",
800        );
801    }
802
803    // Role token: write Cypher routes to query_write_authz (scope + mask
804    // resolved under the write lock, §5 discipline).  Read Cypher uses the
805    // lock-free epoch snapshot path unchanged.
806    if let AuthIdentity::Role(ref role_name) = identity {
807        let is_write = match is_write_query(&cypher) {
808            Ok(b) => b,
809            Err(e) => return err_response(e),
810        };
811        if is_write {
812            let role = role_name.clone();
813            let cypher_c = cypher.clone();
814            let params_c = params.clone();
815            let db = state.db.clone();
816            return match blocking_write(move || {
817                db.write().query_write_authz(&role, &cypher_c, &params_c)
818            })
819            .await
820            {
821                Ok(rs) => format_query_result(rs, format),
822                Err(resp) => resp,
823            };
824        }
825        let snap = state.db.reader();
826        let role_mask = match snap.mask_for_role(role_name) {
827            Ok(m) => m,
828            Err(e) => return role_mask_err(e),
829        };
830        let effective_mask = if let Some(ref keys) = mask_keys {
831            // Client mask intersects role mask — never widens visibility.
832            let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
833            role_mask.intersect(&client_mask)
834        } else {
835            role_mask
836        };
837        return match snap.query_masked(&cypher, &params, &effective_mask) {
838            Ok(rs) => format_query_result(rs, format),
839            Err(GraphError::MaskedReadOnly) => (
840                StatusCode::BAD_REQUEST,
841                Json(json!({"error": "masked queries are read-only"})),
842            )
843                .into_response(),
844            Err(e) => graph_err(e),
845        };
846    }
847
848    // Full token: existing paths below.
849
850    // When a client-supplied mask is present, route to query_masked (read-only).
851    // Hold a single read guard for both from_keys and query_masked so the mask
852    // and the query execute on the same database snapshot.
853    //
854    // `stub_hidden: true` opts into MaskMode::Stub for the mask; Cypher query
855    // behaviour is identical in both modes (hidden nodes are excluded from
856    // query results regardless of mode).
857    if let Some(ref keys) = mask_keys {
858        let stub_hidden = body
859            .get("stub_hidden")
860            .and_then(|v| v.as_bool())
861            .unwrap_or(false);
862        let db = state.db.read();
863        let mask = {
864            let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
865            if stub_hidden {
866                m.with_mode(MaskMode::Stub)
867            } else {
868                m
869            }
870        };
871        return match db.query_masked(&cypher, &params, &mask) {
872            Ok(rs) => format_query_result(rs, format),
873            Err(GraphError::MaskedReadOnly) => (
874                StatusCode::BAD_REQUEST,
875                Json(json!({"error": "masked queries are read-only"})),
876            )
877                .into_response(),
878            Err(e) => graph_err(e),
879        };
880    }
881
882    // Detect write statements to dispatch to the correct lock.
883    // Write statements (CREATE / MATCH…SET / MATCH…DELETE / MERGE) need the
884    // write lock so mutations flow through WAL + rule engine with fsync before
885    // the response is sent.  Read queries (MATCH … RETURN …) use the read lock.
886    let is_write = match is_write_query(&cypher) {
887        Ok(b) => b,
888        Err(e) => return err_response(e),
889    };
890
891    if as_of.is_some() && is_write {
892        return err_response("as_of (time-travel) queries are read-only");
893    }
894
895    let rs = if is_write {
896        let db = state.db.clone();
897        match blocking_write(move || db.write().query_write(&cypher, &params)).await {
898            Ok(rs) => rs,
899            Err(resp) => return resp,
900        }
901    } else if let Some(commit) = as_of {
902        match state.db.read().query_at(commit, &cypher, &params) {
903            Ok(rs) => rs,
904            Err(e) => return graph_err(e),
905        }
906    } else {
907        match state.db.read().query(&cypher, &params) {
908            Ok(rs) => rs,
909            Err(e) => return graph_err(e),
910        }
911    };
912
913    format_query_result(rs, format)
914}
915
916async fn stats(
917    State(state): State<AppState>,
918    Extension(identity): Extension<AuthIdentity>,
919) -> Response {
920    // v1: deny role tokens — raw counts leak graph size beyond the role's subgraph.
921    if let AuthIdentity::Role(_) = identity {
922        return forbidden("role-bound token: /stats requires a full-access token");
923    }
924    let snap = {
925        let g = state.db.read();
926        g.stats()
927    };
928    match serde_json::to_value(&snap) {
929        Ok(v) => json_ok(v),
930        Err(e) => err_response(e.to_string()),
931    }
932}
933
934async fn ingest(
935    State(state): State<AppState>,
936    Extension(identity): Extension<AuthIdentity>,
937    Json(body): Json<Js>,
938) -> Response {
939    let label = match body.get("label").and_then(Js::as_str) {
940        Some(s) => s.to_string(),
941        None => return err_response("missing label"),
942    };
943    let rows = match body.get("rows") {
944        Some(r) => r,
945        None => return err_response("missing rows"),
946    };
947    let mut converted = match json_to_rows(rows) {
948        Ok(c) => c,
949        Err(e) => return graph_err(e),
950    };
951    let opts = match ingest_options(body.get("options")) {
952        Ok(o) => o,
953        Err(e) => return err_response(e),
954    };
955    let taken = std::mem::take(&mut converted.rows);
956    let edges = match body.get("edges") {
957        None | Some(Js::Null) => Vec::new(),
958        Some(raw) => match parse_ingest_edges(raw) {
959            Ok(e) => e,
960            Err(e) => return err_response(e),
961        },
962    };
963    let db = state.db.clone();
964
965    // Role token: route to ingest_with_edges_authz (scope + mask resolved under
966    // the write lock, §5 discipline; §7.3 — create_labels required, enforced by
967    // the engine's per-op authz check inside commit_logged_batch).
968    if let AuthIdentity::Role(role_name) = &identity {
969        let role = role_name.clone();
970        return match blocking_write(move || {
971            db.write()
972                .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
973        })
974        .await
975        {
976            Ok(r) => {
977                let report = converted.into_report(r);
978                match serde_json::to_value(&report) {
979                    Ok(v) => json_ok(v),
980                    Err(e) => err_response(e.to_string()),
981                }
982            }
983            Err(resp) => resp,
984        };
985    }
986
987    let report =
988        match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
989            .await
990        {
991            Ok(r) => converted.into_report(r),
992            Err(resp) => return resp,
993        };
994    match serde_json::to_value(&report) {
995        Ok(v) => json_ok(v),
996        Err(e) => err_response(e.to_string()),
997    }
998}
999
1000/// `GET /suggest` — profile the database and return rule suggestions.
1001///
1002/// # Locking and blocking strategy
1003///
1004/// `suggest_rules_with_config` is CPU-intensive and synchronous. Running it on a
1005/// Tokio worker thread would starve the executor. This handler offloads the work to
1006/// `tokio::task::spawn_blocking`, which uses the blocking thread-pool. The
1007/// `std::sync::RwLock` read guard is acquired and held inside the blocking task —
1008/// reads don't block other reads; writes wait for the guard to drop. The global
1009/// budget (`SuggestConfig::global_budget_ms`, default 5 s) caps lock-hold time.
1010async fn suggest(
1011    State(state): State<AppState>,
1012    Extension(identity): Extension<AuthIdentity>,
1013) -> Response {
1014    // v1: deny role tokens — suggest scans the full graph and would reveal
1015    // existence of nodes outside the role's mask.
1016    if let AuthIdentity::Role(_) = identity {
1017        return forbidden("role-bound token: /suggest requires a full-access token");
1018    }
1019    let db = state.db.clone();
1020    match tokio::task::spawn_blocking(move || {
1021        let config = SuggestConfig::default();
1022        db.read()
1023            .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1024    })
1025    .await
1026    {
1027        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1028        Err(_) => err_response("suggest task panicked"),
1029    }
1030}
1031
1032async fn create_rule(
1033    State(state): State<AppState>,
1034    Extension(identity): Extension<AuthIdentity>,
1035    Json(body): Json<Js>,
1036) -> Response {
1037    if let AuthIdentity::Role(_) = identity {
1038        return forbidden("role-bound token: writes are not permitted");
1039    }
1040    let def = match rule_def_from_json(body) {
1041        Ok(d) => d,
1042        Err(e) => return err_response(e),
1043    };
1044    let name = def.name.clone();
1045    let db = state.db.clone();
1046    match blocking_write(move || db.write().create_rule(def)).await {
1047        Ok(()) => json_ok(json!({"ok": true, "name": name})),
1048        Err(resp) => resp,
1049    }
1050}
1051
1052async fn explain(
1053    State(state): State<AppState>,
1054    Extension(identity): Extension<AuthIdentity>,
1055    Query(qs): Query<BTreeMap<String, String>>,
1056) -> Response {
1057    // v1: deny role tokens — explain reveals hidden-node linkage through rules.
1058    if let AuthIdentity::Role(_) = identity {
1059        return forbidden(
1060            "role-bound token: /explain requires a full-access token \
1061             (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1062        );
1063    }
1064    let a = match qs.get("a") {
1065        Some(s) if !s.is_empty() => s.clone(),
1066        _ => return err_response("missing query param a"),
1067    };
1068    let b = match qs.get("b") {
1069        Some(s) if !s.is_empty() => s.clone(),
1070        _ => return err_response("missing query param b"),
1071    };
1072    let out = {
1073        let g = state.db.read();
1074        g.explain(&a, &b)
1075    };
1076    match out {
1077        Ok(v) => match serde_json::to_value(&v) {
1078            Ok(j) => json_ok(j),
1079            Err(e) => err_response(e.to_string()),
1080        },
1081        Err(e) => graph_err(e),
1082    }
1083}
1084
1085async fn node_info(
1086    State(state): State<AppState>,
1087    Extension(identity): Extension<AuthIdentity>,
1088    Path(key): Path<String>,
1089    Query(qs): Query<BTreeMap<String, String>>,
1090) -> Response {
1091    if let AuthIdentity::Role(ref role_name) = identity {
1092        // Role-token path: hard-coded Omit mode; hidden keys are indistinguishable
1093        // from absent keys.  `stub_hidden` query param is silently ignored here —
1094        // role paths must NEVER produce stubs (RBAC invariant).
1095        let snap = state.db.reader();
1096        let role_mask = match snap.mask_for_role(role_name) {
1097            Ok(m) => m,
1098            Err(e) => return role_mask_err(e),
1099        };
1100        if !snap
1101            .resolve_key(&key)
1102            .is_some_and(|id| role_mask.contains_id(id))
1103        {
1104            return key_not_found(key);
1105        }
1106        return match snap.node_info(&key) {
1107            Some(info) => json_ok(node_info_json(&info)),
1108            None => key_not_found(key),
1109        };
1110    }
1111
1112    // Full-token path: optional client mask + stub_hidden via query params.
1113    // `mask=key1,key2` — comma-separated visible keys (empty string = no mask).
1114    // `stub_hidden=true` — opt into MaskMode::Stub for this request.
1115    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1116    if !mask_param.is_empty() {
1117        let stub_hidden = qs
1118            .get("stub_hidden")
1119            .map(|v| v == "true" || v == "1")
1120            .unwrap_or(false);
1121        let g = state.db.read();
1122        let mask = {
1123            let keys = mask_param
1124                .split(',')
1125                .map(str::trim)
1126                .filter(|s| !s.is_empty());
1127            let m = NodeMask::from_keys(&*g, keys);
1128            if stub_hidden {
1129                m.with_mode(MaskMode::Stub)
1130            } else {
1131                m
1132            }
1133        };
1134        return match g.node_info_masked(&key, &mask) {
1135            Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1136            Some(core_api::MaskedNodeResult::Restricted) => {
1137                json_ok(crate::json::stub_node_json(&key))
1138            }
1139            None => key_not_found(key),
1140        };
1141    }
1142
1143    let info = {
1144        let g = state.db.read();
1145        g.node_info(&key)
1146    };
1147    match info {
1148        Some(info) => json_ok(node_info_json(&info)),
1149        None => key_not_found(key),
1150    }
1151}
1152
1153async fn node_edges(
1154    State(state): State<AppState>,
1155    Extension(identity): Extension<AuthIdentity>,
1156    Path(key): Path<String>,
1157    Query(qs): Query<BTreeMap<String, String>>,
1158) -> Response {
1159    if let AuthIdentity::Role(ref role_name) = identity {
1160        // Role-token path: hard-coded Omit mode.  `stub_hidden` query param is
1161        // silently ignored — role paths must NEVER produce stubs (RBAC invariant).
1162        let snap = state.db.reader();
1163        let role_mask = match snap.mask_for_role(role_name) {
1164            Ok(m) => m,
1165            Err(e) => return role_mask_err(e),
1166        };
1167        if !snap
1168            .resolve_key(&key)
1169            .is_some_and(|id| role_mask.contains_id(id))
1170        {
1171            return key_not_found(key);
1172        }
1173        return match snap.node_edges(&key) {
1174            Ok(edges) => {
1175                // Filter out edges whose OTHER endpoint is hidden in the role
1176                // mask.  A role token must not learn about hidden neighbors via
1177                // the edge list even when the entry key itself is visible.
1178                let visible: Vec<_> = edges
1179                    .into_iter()
1180                    .filter(|e| {
1181                        let other = if e.src_key == key {
1182                            &e.dst_key
1183                        } else {
1184                            &e.src_key
1185                        };
1186                        snap.resolve_key(other)
1187                            .is_some_and(|id| role_mask.contains_id(id))
1188                    })
1189                    .collect();
1190                json_ok(node_edges_json(&visible))
1191            }
1192            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1193            Err(e) => graph_err(e),
1194        };
1195    }
1196
1197    // Full-token path: optional client mask + stub_hidden via query params.
1198    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1199    if !mask_param.is_empty() {
1200        let stub_hidden = qs
1201            .get("stub_hidden")
1202            .map(|v| v == "true" || v == "1")
1203            .unwrap_or(false);
1204        let g = state.db.read();
1205        let mask = {
1206            let keys = mask_param
1207                .split(',')
1208                .map(str::trim)
1209                .filter(|s| !s.is_empty());
1210            let m = NodeMask::from_keys(&*g, keys);
1211            if stub_hidden {
1212                m.with_mode(MaskMode::Stub)
1213            } else {
1214                m
1215            }
1216        };
1217        return match g.node_edges_masked(&key, &mask) {
1218            Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1219            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1220            Err(e) => graph_err(e),
1221        };
1222    }
1223
1224    let out = {
1225        let g = state.db.read();
1226        g.node_edges(&key)
1227    };
1228    match out {
1229        Ok(edges) => json_ok(node_edges_json(&edges)),
1230        Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1231        Err(e) => graph_err(e),
1232    }
1233}
1234
1235async fn neighborhood(
1236    State(state): State<AppState>,
1237    Extension(identity): Extension<AuthIdentity>,
1238    Path(key): Path<String>,
1239    Query(qs): Query<BTreeMap<String, String>>,
1240) -> Response {
1241    let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1242        Ok(d) => d,
1243        Err(e) => return err_response(e),
1244    };
1245    let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1246        s if s.eq_ignore_ascii_case("out") => Dir::Out,
1247        s if s.eq_ignore_ascii_case("in") => Dir::In,
1248        s if s.eq_ignore_ascii_case("both") => Dir::Both,
1249        other => return err_response(format!("unknown dir: {other}")),
1250    };
1251    let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1252        s.split(',')
1253            .map(str::trim)
1254            .filter(|t| !t.is_empty())
1255            .map(str::to_string)
1256            .collect()
1257    });
1258    let etype_refs: Option<Vec<&str>> = edge_type_names
1259        .as_ref()
1260        .map(|v| v.iter().map(String::as_str).collect());
1261    if let AuthIdentity::Role(ref role_name) = identity {
1262        // Lock-free epoch snapshot: mask and neighborhood BFS on same frozen state
1263        // (constraint 2 — RBAC mask coherence).
1264        let snap = state.db.reader();
1265        let role_mask = match snap.mask_for_role(role_name) {
1266            Ok(m) => m,
1267            Err(e) => return role_mask_err(e),
1268        };
1269        if !snap
1270            .resolve_key(&key)
1271            .is_some_and(|id| role_mask.contains_id(id))
1272        {
1273            return key_not_found(key);
1274        }
1275        // Use the mask-aware BFS: hidden nodes are excluded from results AND
1276        // cannot be used as traversal intermediaries (never-leak invariant).
1277        let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1278        {
1279            Some(rs) => rs,
1280            None => return key_not_found(key),
1281        };
1282        return json_ok(result_set_json(&rs));
1283    }
1284    // Full-token path: optional client mask + stub_hidden via query params.
1285    // `mask=key1,key2` — comma-separated visible keys.
1286    // `stub_hidden=true` — opt into MaskMode::Stub; hidden direct neighbours
1287    // appear as stub rows (label: null) in the result; BFS does not expand
1288    // through them in either mode.
1289    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1290    if !mask_param.is_empty() {
1291        let stub_hidden = qs
1292            .get("stub_hidden")
1293            .map(|v| v == "true" || v == "1")
1294            .unwrap_or(false);
1295        let g = state.db.read();
1296        let mask = {
1297            let keys = mask_param
1298                .split(',')
1299                .map(str::trim)
1300                .filter(|s| !s.is_empty());
1301            let m = NodeMask::from_keys(&*g, keys);
1302            if stub_hidden {
1303                m.with_mode(MaskMode::Stub)
1304            } else {
1305                m
1306            }
1307        };
1308        return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1309            Some(rs) => json_ok(result_set_json(&rs)),
1310            None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1311        };
1312    }
1313
1314    // Unmasked full-token path (no mask param).
1315    let rs = {
1316        let g = state.db.read();
1317        match g.node_ref(&key) {
1318            Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1319            None => Err(GraphError::KeyNotFound { key: key.clone() }),
1320        }
1321    };
1322    match rs {
1323        Ok(rs) => json_ok(result_set_json(&rs)),
1324        Err(e) => graph_err(e),
1325    }
1326}
1327
1328/// `POST /algo/pagerank` — run PageRank over the unified topology.
1329///
1330/// # Locking and blocking strategy
1331///
1332/// PageRank is CPU-intensive and synchronous. This handler offloads the work
1333/// to `tokio::task::spawn_blocking` (blocking thread-pool). The read guard is
1334/// acquired and held inside the blocking task — reads don't block other reads.
1335/// The `budget_ms` field in [`PageRankConfig`] caps lock-hold time.
1336async fn algo_pagerank(
1337    State(state): State<AppState>,
1338    Extension(identity): Extension<AuthIdentity>,
1339    Json(body): Json<serde_json::Value>,
1340) -> Response {
1341    // v1: deny role tokens — algo endpoints scan the full graph.
1342    if let AuthIdentity::Role(_) = identity {
1343        return forbidden("role-bound token: /algo/* requires a full-access token");
1344    }
1345    let config: PageRankConfig = match serde_json::from_value(body) {
1346        Ok(c) => c,
1347        Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1348    };
1349    let db = state.db.clone();
1350    match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1351        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1352        Err(_) => err_response("pagerank task panicked"),
1353    }
1354}
1355
1356/// `POST /algo/wcc` — weakly-connected components over the unified topology.
1357///
1358/// Mirrors the `suggest` locking and blocking pattern exactly: spawn_blocking,
1359/// read guard inside, `budget_ms` in config caps lock-hold time.
1360async fn algo_wcc(
1361    State(state): State<AppState>,
1362    Extension(identity): Extension<AuthIdentity>,
1363    Json(body): Json<serde_json::Value>,
1364) -> Response {
1365    if let AuthIdentity::Role(_) = identity {
1366        return forbidden("role-bound token: /algo/* requires a full-access token");
1367    }
1368    let config: WccConfig = match serde_json::from_value(body) {
1369        Ok(c) => c,
1370        Err(e) => return err_response(format!("invalid wcc config: {e}")),
1371    };
1372    let db = state.db.clone();
1373    match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1374        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1375        Err(_) => err_response("wcc task panicked"),
1376    }
1377}
1378
1379/// `POST /algo/degree` — degree centrality over the unified topology.
1380///
1381/// Mirrors the `suggest` locking and blocking pattern exactly.
1382async fn algo_degree(
1383    State(state): State<AppState>,
1384    Extension(identity): Extension<AuthIdentity>,
1385    Json(body): Json<serde_json::Value>,
1386) -> Response {
1387    if let AuthIdentity::Role(_) = identity {
1388        return forbidden("role-bound token: /algo/* requires a full-access token");
1389    }
1390    let config: DegreeConfig = match serde_json::from_value(body) {
1391        Ok(c) => c,
1392        Err(e) => return err_response(format!("invalid degree config: {e}")),
1393    };
1394    let db = state.db.clone();
1395    match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1396        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1397        Err(_) => err_response("degree task panicked"),
1398    }
1399}
1400
1401// ── Simple BatchOp-mapped endpoints ──────────────────────────────────────────
1402//
1403// These endpoints map 1:1 to BatchOp variants and route through submit_batch
1404// so concurrent writes share one WAL fsync per drain group, keeping reader
1405// p95 latency low under write bursts.  Auth checks (role tokens → 403) run
1406// before enqueue so RBAC enforcement is unchanged.
1407//
1408// Complex paths (/query Cypher writes, /ingest bulk JSON) stay on db.write()
1409// because they are multi-step operations that cannot be pre-expressed as a
1410// Vec<BatchOp> without redesigning the query executor.
1411
1412/// Parse a JSON object into a `Vec<(String, Value)>` prop list.
1413fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1414    let obj = match v.as_object() {
1415        Some(o) => o,
1416        None => return Err("props must be a JSON object".into()),
1417    };
1418    let mut out = Vec::with_capacity(obj.len());
1419    for (k, val) in obj {
1420        if let Some(v) = json_to_value(val.clone()) {
1421            out.push((k.clone(), v));
1422        }
1423    }
1424    Ok(out)
1425}
1426
1427/// `POST /nodes` — create or upsert a node via the group-commit queue.
1428///
1429/// Body: `{"label": "Person", "key": "alice", "props": {"age": 30}}`
1430async fn create_node(
1431    State(state): State<AppState>,
1432    Extension(identity): Extension<AuthIdentity>,
1433    Json(body): Json<Js>,
1434) -> Response {
1435    let label = match body.get("label").and_then(Js::as_str) {
1436        Some(s) => s.to_string(),
1437        None => return err_response("missing label"),
1438    };
1439    let key = match body.get("key").and_then(Js::as_str) {
1440        Some(s) => s.to_string(),
1441        None => return err_response("missing key"),
1442    };
1443    let props = match body.get("props") {
1444        None | Some(Js::Null) => vec![],
1445        Some(v) => match props_from_json_obj(v) {
1446            Ok(p) => p,
1447            Err(e) => return err_response(e),
1448        },
1449    };
1450    let db = state.db.clone();
1451    if let AuthIdentity::Role(role_name) = &identity {
1452        let role = role_name.clone();
1453        return match blocking_write(move || {
1454            db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1455        })
1456        .await
1457        {
1458            Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1459            Err(resp) => resp,
1460        };
1461    }
1462    match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1463        .await
1464    {
1465        Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1466        Err(resp) => resp,
1467    }
1468}
1469
1470/// `DELETE /node/{key}` — delete a node via the group-commit queue.
1471async fn delete_node(
1472    State(state): State<AppState>,
1473    Extension(identity): Extension<AuthIdentity>,
1474    Path(key): Path<String>,
1475) -> Response {
1476    let db = state.db.clone();
1477    if let AuthIdentity::Role(role_name) = &identity {
1478        let role = role_name.clone();
1479        return match blocking_write(move || {
1480            db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1481        })
1482        .await
1483        {
1484            Ok(_) => json_ok(json!({"ok": true})),
1485            Err(resp) => resp,
1486        };
1487    }
1488    match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1489        Ok(_) => json_ok(json!({"ok": true})),
1490        Err(resp) => resp,
1491    }
1492}
1493
1494/// `POST /edges` — create an edge via the group-commit queue.
1495///
1496/// Body: `{"type": "KNOWS", "src": "alice", "dst": "bob"}`
1497async fn create_edge(
1498    State(state): State<AppState>,
1499    Extension(identity): Extension<AuthIdentity>,
1500    Json(body): Json<Js>,
1501) -> Response {
1502    let edge_type = match body.get("type").and_then(Js::as_str) {
1503        Some(s) => s.to_string(),
1504        None => return err_response("missing type"),
1505    };
1506    let src = match body.get("src").and_then(Js::as_str) {
1507        Some(s) => s.to_string(),
1508        None => return err_response("missing src"),
1509    };
1510    let dst = match body.get("dst").and_then(Js::as_str) {
1511        Some(s) => s.to_string(),
1512        None => return err_response("missing dst"),
1513    };
1514    let db = state.db.clone();
1515    if let AuthIdentity::Role(role_name) = &identity {
1516        let role = role_name.clone();
1517        return match blocking_write(move || {
1518            db.submit_batch_authz(
1519                role,
1520                vec![BatchOp::InsertEdge {
1521                    edge_type,
1522                    src_key: src,
1523                    dst_key: dst,
1524                }],
1525            )
1526        })
1527        .await
1528        {
1529            Ok(_) => json_ok(json!({"ok": true})),
1530            Err(resp) => resp,
1531        };
1532    }
1533    match blocking_write(move || {
1534        db.submit_batch(vec![BatchOp::InsertEdge {
1535            edge_type,
1536            src_key: src,
1537            dst_key: dst,
1538        }])
1539    })
1540    .await
1541    {
1542        Ok(_) => json_ok(json!({"ok": true})),
1543        Err(resp) => resp,
1544    }
1545}
1546
1547/// `DELETE /edges/{etype}/{src}/{dst}` — delete an edge via the group-commit queue.
1548async fn delete_edge(
1549    State(state): State<AppState>,
1550    Extension(identity): Extension<AuthIdentity>,
1551    Path((etype, src, dst)): Path<(String, String, String)>,
1552) -> Response {
1553    let db = state.db.clone();
1554    if let AuthIdentity::Role(role_name) = &identity {
1555        let role = role_name.clone();
1556        return match blocking_write(move || {
1557            db.submit_batch_authz(
1558                role,
1559                vec![BatchOp::DeleteEdge {
1560                    edge_type: etype,
1561                    src_key: src,
1562                    dst_key: dst,
1563                }],
1564            )
1565        })
1566        .await
1567        {
1568            Ok(_) => json_ok(json!({"ok": true})),
1569            Err(resp) => resp,
1570        };
1571    }
1572    match blocking_write(move || {
1573        db.submit_batch(vec![BatchOp::DeleteEdge {
1574            edge_type: etype,
1575            src_key: src,
1576            dst_key: dst,
1577        }])
1578    })
1579    .await
1580    {
1581        Ok(_) => json_ok(json!({"ok": true})),
1582        Err(resp) => resp,
1583    }
1584}
1585
1586/// `POST /nodes/{key}/rename` — rename a node's key via the group-commit queue.
1587///
1588/// Body: `{"new_key": "alice2"}`
1589/// Returns 404 on KeyNotFound, 409 on DuplicateKey, 200 on success.
1590async fn rename_node(
1591    State(state): State<AppState>,
1592    Extension(identity): Extension<AuthIdentity>,
1593    Path(key): Path<String>,
1594    Json(body): Json<Js>,
1595) -> Response {
1596    if let AuthIdentity::Role(_) = identity {
1597        return forbidden("role-bound token: writes are not permitted");
1598    }
1599    let new_key = match body.get("new_key").and_then(Js::as_str) {
1600        Some(s) => s.to_string(),
1601        None => return err_response("missing new_key"),
1602    };
1603    let db = state.db.clone();
1604    match tokio::task::spawn_blocking(move || {
1605        db.submit_batch(vec![BatchOp::RenameNode {
1606            old_key: key,
1607            new_key,
1608        }])
1609    })
1610    .await
1611    {
1612        Ok(Ok(_)) => json_ok(json!({"ok": true})),
1613        Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1614        Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1615        Ok(Err(e)) => graph_err(e),
1616        Err(_) => err_response("write task panicked"),
1617    }
1618}
1619
1620/// `POST /edges/upsert` — insert an edge, auto-creating missing endpoints.
1621///
1622/// Body: `{"edge_type":"KNOWS","src_key":"alice","dst_key":"bob","placeholder_label":"Person"}`
1623/// Returns `{"nodes_created": N, "edge_inserted": bool}`.
1624async fn upsert_edge(
1625    State(state): State<AppState>,
1626    Extension(identity): Extension<AuthIdentity>,
1627    Json(body): Json<Js>,
1628) -> Response {
1629    let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1630        Some(s) => s.to_string(),
1631        None => return err_response("missing edge_type"),
1632    };
1633    let src_key = match body.get("src_key").and_then(Js::as_str) {
1634        Some(s) => s.to_string(),
1635        None => return err_response("missing src_key"),
1636    };
1637    let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1638        Some(s) => s.to_string(),
1639        None => return err_response("missing dst_key"),
1640    };
1641    let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1642        Some(s) => s.to_string(),
1643        None => return err_response("missing placeholder_label"),
1644    };
1645    let db = state.db.clone();
1646    if let AuthIdentity::Role(role_name) = &identity {
1647        let role = role_name.clone();
1648        return match blocking_write(move || {
1649            db.submit_batch_authz(
1650                role,
1651                vec![BatchOp::InsertEdgeUpsert {
1652                    edge_type,
1653                    src_key,
1654                    dst_key,
1655                    placeholder_label,
1656                }],
1657            )
1658        })
1659        .await
1660        {
1661            Ok((nodes, edges)) => json_ok(json!({
1662                "nodes_created": nodes,
1663                "edge_inserted": edges > 0,
1664            })),
1665            Err(resp) => resp,
1666        };
1667    }
1668    match blocking_write(move || {
1669        db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1670            edge_type,
1671            src_key,
1672            dst_key,
1673            placeholder_label,
1674        }])
1675    })
1676    .await
1677    {
1678        Ok((nodes, edges)) => json_ok(json!({
1679            "nodes_created": nodes,
1680            "edge_inserted": edges > 0,
1681        })),
1682        Err(resp) => resp,
1683    }
1684}
1685
1686/// `PUT /node/{key}/prop/{field}` — set a property via the group-commit queue.
1687///
1688/// Body: `{"value": 42}`
1689async fn set_node_prop(
1690    State(state): State<AppState>,
1691    Extension(identity): Extension<AuthIdentity>,
1692    Path((key, field)): Path<(String, String)>,
1693    Json(body): Json<Js>,
1694) -> Response {
1695    let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1696        Some(v) => v,
1697        None => {
1698            return err_response(
1699                "request body must be a JSON object with a \"value\" field, \
1700                 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
1701            )
1702        }
1703    };
1704    let db = state.db.clone();
1705    if let AuthIdentity::Role(role_name) = &identity {
1706        let role = role_name.clone();
1707        return match blocking_write(move || {
1708            db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1709        })
1710        .await
1711        {
1712            Ok(_) => json_ok(json!({"ok": true})),
1713            Err(resp) => resp,
1714        };
1715    }
1716    match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1717        .await
1718    {
1719        Ok(_) => json_ok(json!({"ok": true})),
1720        Err(resp) => resp,
1721    }
1722}
1723
1724// ── History endpoints ─────────────────────────────────────────────────────────
1725//
1726// These are cold-path diagnostic endpoints. They scan the on-disk WAL and
1727// must NOT extend ReaderSnapshot — they use db.read() directly per the
1728// controller ruling (see module doc and task-2-brief.md).
1729//
1730// Role masking: node visibility is checked under the SAME read guard as the
1731// history call (coherent snapshot). A node outside the role's mask responds
1732// identically to an absent node — no existence oracle.
1733
1734/// `GET /node/{key}/history` — return the WAL change history for `key`.
1735///
1736/// Response: `{ key, history: [{commit, change}], total_commits }`.
1737/// Role tokens: if `key` is hidden by the role mask, responds with 404
1738/// (same shape as querying an absent key — no existence oracle).
1739async fn node_history_handler(
1740    State(state): State<AppState>,
1741    Extension(identity): Extension<AuthIdentity>,
1742    Path(key): Path<String>,
1743) -> Response {
1744    if let AuthIdentity::Role(ref role_name) = identity {
1745        let g = state.db.read();
1746        let role_mask = match g.mask_for_role(role_name) {
1747            Ok(m) => m,
1748            Err(e) => return role_mask_err(e),
1749        };
1750        // Hidden keys must respond identically to absent keys (no oracle).
1751        if !role_mask.contains_node(&*g, &key) {
1752            return key_not_found(key);
1753        }
1754        let entries = match g.node_history(&key) {
1755            Ok(e) => e,
1756            Err(e) => return graph_err(e),
1757        };
1758        let total_commits = match g.wal_total_commits() {
1759            Ok(n) => n,
1760            Err(e) => return graph_err(e),
1761        };
1762        // Filter EdgeAdded/EdgeRemoved entries whose `other` endpoint is hidden.
1763        // A role token must not learn about hidden nodes via edge history events —
1764        // mirrors the same protection in `node_edges` (http.rs ~978-989).
1765        use core_api::HistoryChange;
1766        let visible: Vec<_> = entries
1767            .into_iter()
1768            .filter(|entry| match &entry.change {
1769                HistoryChange::EdgeAdded { other, .. }
1770                | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1771                _ => true,
1772            })
1773            .collect();
1774        return json_ok(node_history_json(&key, &visible, total_commits));
1775    }
1776    // Full identity: no masking. Return 404 for absent keys (consistent with
1777    // GET /node/{key} and the Role branch above).
1778    let g = state.db.read();
1779    if !g.has_node(&key) {
1780        return key_not_found(key);
1781    }
1782    let entries = match g.node_history(&key) {
1783        Ok(e) => e,
1784        Err(e) => return graph_err(e),
1785    };
1786    let total_commits = match g.wal_total_commits() {
1787        Ok(n) => n,
1788        Err(e) => return graph_err(e),
1789    };
1790    json_ok(node_history_json(&key, &entries, total_commits))
1791}
1792
1793/// `GET /history/edge?a=&b=` — return the edge lifecycle between two nodes.
1794///
1795/// Response: `{ a, b, events: [{edge_type, commit, event, rule}], total_commits }`.
1796/// Role tokens: BOTH `a` AND `b` must be visible in the role mask, otherwise
1797/// responds with 404 for the first invisible key (no existence oracle).
1798async fn edge_history_handler(
1799    State(state): State<AppState>,
1800    Extension(identity): Extension<AuthIdentity>,
1801    Query(qs): Query<BTreeMap<String, String>>,
1802) -> Response {
1803    let a = match qs.get("a").filter(|s| !s.is_empty()) {
1804        Some(s) => s.clone(),
1805        None => return err_response("missing query param a"),
1806    };
1807    let b = match qs.get("b").filter(|s| !s.is_empty()) {
1808        Some(s) => s.clone(),
1809        None => return err_response("missing query param b"),
1810    };
1811    if let AuthIdentity::Role(ref role_name) = identity {
1812        let g = state.db.read();
1813        let role_mask = match g.mask_for_role(role_name) {
1814            Ok(m) => m,
1815            Err(e) => return role_mask_err(e),
1816        };
1817        // BOTH endpoints must be visible (no oracle for either).
1818        if !role_mask.contains_node(&*g, &a) {
1819            return key_not_found(a);
1820        }
1821        if !role_mask.contains_node(&*g, &b) {
1822            return key_not_found(b);
1823        }
1824        let result = match g.edge_history(&a, &b) {
1825            Ok(r) => r,
1826            Err(e) => return graph_err(e),
1827        };
1828        return json_ok(edge_history_result_json(&a, &b, &result));
1829    }
1830    // Full identity: no masking.
1831    let g = state.db.read();
1832    let result = match g.edge_history(&a, &b) {
1833        Ok(r) => r,
1834        Err(e) => return graph_err(e),
1835    };
1836    json_ok(edge_history_result_json(&a, &b, &result))
1837}
1838
1839/// `GET /history/was_linked?a=&b=&edge_type=&at_commit=` — point-in-time edge check.
1840///
1841/// Response: `{ a, b, edge_type, at_commit, linked }`.
1842/// Returns 400 (not 500) when `at_commit` is outside the visible horizon.
1843/// Role tokens: BOTH `a` AND `b` must be visible, same-as-absent otherwise.
1844async fn was_linked_handler(
1845    State(state): State<AppState>,
1846    Extension(identity): Extension<AuthIdentity>,
1847    Query(qs): Query<BTreeMap<String, String>>,
1848) -> Response {
1849    let a = match qs.get("a").filter(|s| !s.is_empty()) {
1850        Some(s) => s.clone(),
1851        None => return err_response("missing query param a"),
1852    };
1853    let b = match qs.get("b").filter(|s| !s.is_empty()) {
1854        Some(s) => s.clone(),
1855        None => return err_response("missing query param b"),
1856    };
1857    let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1858        Some(s) => s.clone(),
1859        None => return err_response("missing query param edge_type"),
1860    };
1861    let at_commit: u64 = match qs.get("at_commit") {
1862        Some(s) => match s.parse() {
1863            Ok(n) => n,
1864            Err(_) => return err_response("at_commit must be a non-negative integer"),
1865        },
1866        None => return err_response("missing query param at_commit"),
1867    };
1868
1869    if let AuthIdentity::Role(ref role_name) = identity {
1870        let g = state.db.read();
1871        let role_mask = match g.mask_for_role(role_name) {
1872            Ok(m) => m,
1873            Err(e) => return role_mask_err(e),
1874        };
1875        if !role_mask.contains_node(&*g, &a) {
1876            return key_not_found(a);
1877        }
1878        if !role_mask.contains_node(&*g, &b) {
1879            return key_not_found(b);
1880        }
1881        return match g.was_linked(&a, &b, &edge_type, at_commit) {
1882            Ok(linked) => json_ok(json!({
1883                "a": a, "b": b, "edge_type": edge_type,
1884                "at_commit": at_commit, "linked": linked,
1885            })),
1886            Err(GraphError::CommitOutOfRange { .. }) => (
1887                StatusCode::BAD_REQUEST,
1888                Json(json!({"error": format!("commit {at_commit} is out of range")})),
1889            )
1890                .into_response(),
1891            Err(e) => graph_err(e),
1892        };
1893    }
1894
1895    // Full identity: no masking.
1896    let g = state.db.read();
1897    match g.was_linked(&a, &b, &edge_type, at_commit) {
1898        Ok(linked) => json_ok(json!({
1899            "a": a, "b": b, "edge_type": edge_type,
1900            "at_commit": at_commit, "linked": linked,
1901        })),
1902        Err(GraphError::CommitOutOfRange { .. }) => (
1903            StatusCode::BAD_REQUEST,
1904            Json(json!({"error": format!("commit {at_commit} is out of range")})),
1905        )
1906            .into_response(),
1907        Err(e) => graph_err(e),
1908    }
1909}
1910
1911/// `DELETE /node/{key}/prop/{field}` — remove a property via the group-commit queue.
1912async fn remove_node_prop(
1913    State(state): State<AppState>,
1914    Extension(identity): Extension<AuthIdentity>,
1915    Path((key, field)): Path<(String, String)>,
1916) -> Response {
1917    let db = state.db.clone();
1918    if let AuthIdentity::Role(role_name) = &identity {
1919        let role = role_name.clone();
1920        return match blocking_write(move || {
1921            db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
1922        })
1923        .await
1924        {
1925            Ok(_) => json_ok(json!({"ok": true})),
1926            Err(resp) => resp,
1927        };
1928    }
1929    match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
1930        Ok(_) => json_ok(json!({"ok": true})),
1931        Err(resp) => resp,
1932    }
1933}
1934
1935/// Constant-time byte-string equality, used for secret (token) comparison so
1936/// the match does not short-circuit on the first differing byte and leak the
1937/// token through response-timing. Length is compared up front (token length is
1938/// not treated as a secret); the byte loop is branch-free over equal lengths.
1939fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1940    if a.len() != b.len() {
1941        return false;
1942    }
1943    let mut diff = 0u8;
1944    for (x, y) in a.iter().zip(b.iter()) {
1945        diff |= x ^ y;
1946    }
1947    diff == 0
1948}
1949
1950/// Maximum `depth` accepted by the neighborhood endpoint. Bounds the read-lock
1951/// hold time of a single BFS request on a dense graph (a hostile
1952/// `depth=4294967295` would otherwise traverse the whole graph under the read
1953/// guard, starving writers).
1954const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
1955
1956/// Parse and bound the neighborhood `depth` query parameter.
1957fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
1958    match raw {
1959        None => Ok(1),
1960        Some(s) => {
1961            let d: u32 = s
1962                .parse()
1963                .map_err(|_| "depth must be an integer".to_string())?;
1964            if d > MAX_NEIGHBORHOOD_DEPTH {
1965                return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
1966            }
1967            Ok(d)
1968        }
1969    }
1970}
1971
1972/// Confine a client-supplied backup `dest` to `root`, closing the arbitrary
1973/// filesystem-write vector (a full-access token could otherwise direct the
1974/// backup writer at `/etc/...`, `/root/.ssh/...`, etc.).
1975///
1976/// Rules: reject empty; reject any `..` segment; relative paths resolve under
1977/// `root`; absolute paths must already fall within `root`. The check is lexical
1978/// (no filesystem access) and safe because `..` is rejected before joining.
1979fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
1980    if dest.is_empty() {
1981        return Err("missing or empty \"dest\" field".into());
1982    }
1983    let dest_path = std::path::Path::new(dest);
1984    if dest_path
1985        .components()
1986        .any(|c| matches!(c, std::path::Component::ParentDir))
1987    {
1988        return Err("backup \"dest\" may not contain \"..\" path segments".into());
1989    }
1990    let joined = if dest_path.is_absolute() {
1991        dest_path.to_path_buf()
1992    } else {
1993        root.join(dest_path)
1994    };
1995    if !joined.starts_with(root) {
1996        return Err(format!(
1997            "backup \"dest\" must be within the backup root ({}); \
1998             set MUSHROOMDB_BACKUP_DIR to change it",
1999            root.display()
2000        ));
2001    }
2002    Ok(joined)
2003}
2004
2005/// Directory backups are confined to: `MUSHROOMDB_BACKUP_DIR` if set, else the
2006/// server's current working directory.
2007fn backup_root() -> PathBuf {
2008    std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2009        .map(PathBuf::from)
2010        .or_else(|| std::env::current_dir().ok())
2011        .unwrap_or_else(|| PathBuf::from("."))
2012}
2013
2014/// `POST /backup` — take a consistent backup of the database to `dest`.
2015///
2016/// The read guard is held for the duration of the file copies, which is the
2017/// correct cross-process synchronisation point: the server is the single
2018/// process touching the files, so holding the read lock excludes concurrent
2019/// in-process writers.  This is the safe alternative to running the
2020/// `mushroomdb backup` CLI against a live-served store.
2021///
2022/// Request body: `{"dest": "backup-dir"}`. `dest` is confined to the backup
2023/// root (`MUSHROOMDB_BACKUP_DIR`, else the server's working directory):
2024/// relative paths resolve under it, absolute paths must fall within it, and
2025/// `..` segments are rejected. This prevents a full-access token from writing
2026/// to arbitrary filesystem paths.
2027///
2028/// Responses:
2029/// - `200 OK` — backup completed; body is a `BackupReport` JSON object.
2030/// - `500 Internal Server Error` — backup succeeded but verification failed;
2031///   body is the `BackupReport` JSON object (examine `files` and `bytes`).
2032/// - `400 Bad Request` — missing or invalid `dest`.
2033/// - `403 Forbidden` — role-bound token; this endpoint requires a full-access token.
2034async fn backup(
2035    State(state): State<AppState>,
2036    Extension(identity): Extension<AuthIdentity>,
2037    Json(body): Json<Js>,
2038) -> Response {
2039    if let AuthIdentity::Role(_) = identity {
2040        return forbidden("role-bound token: /backup requires a full-access token");
2041    }
2042    let root = backup_root();
2043    let dest = match body.get("dest").and_then(Js::as_str) {
2044        Some(s) => match confine_backup_dest(s, &root) {
2045            Ok(p) => p,
2046            Err(e) => return err_response(e),
2047        },
2048        None => return err_response("missing or empty \"dest\" field"),
2049    };
2050    let db = state.db.clone();
2051    let report: BackupReport = match tokio::task::spawn_blocking(move || {
2052        // The read guard is held inside spawn_blocking so file copies happen
2053        // with the write lock excluded.  The guard drops at end of closure.
2054        let g = db.read();
2055        g.backup_to(&dest)
2056    })
2057    .await
2058    {
2059        Ok(Ok(r)) => r,
2060        Ok(Err(e)) => return graph_err(e),
2061        Err(_) => return err_response("backup task panicked"),
2062    };
2063
2064    let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2065        Ok(v) => v,
2066        Err(e) => return err_response(e.to_string()),
2067    };
2068
2069    if report.verified {
2070        json_ok(body)
2071    } else {
2072        (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2073    }
2074}
2075
2076/// JSON-serialisable projection of [`BackupReport`].
2077#[derive(serde::Serialize)]
2078struct BackupReportJson<'a> {
2079    files: &'a [String],
2080    bytes: u64,
2081    verified: bool,
2082}
2083
2084impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2085    fn from(r: &'a BackupReport) -> Self {
2086        Self {
2087            files: &r.files,
2088            bytes: r.bytes,
2089            verified: r.verified,
2090        }
2091    }
2092}
2093
2094#[cfg(test)]
2095mod tests {
2096    use super::*;
2097    use crate::json::result_set_json;
2098    use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2099
2100    #[test]
2101    fn nan_float_cell_serializes_as_null() {
2102        let mut rs = ResultSet::new(vec!["n".into()]);
2103        rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2104        let j = result_set_json(&rs);
2105        assert_eq!(j["rows"][0][0], Js::Null);
2106    }
2107
2108    /// Verify that `POST /algo/pagerank` accepts an empty JSON body `{}` and
2109    /// applies server defaults (regression guard for `#[serde(default)]`).
2110    #[test]
2111    fn pagerank_config_empty_body_uses_defaults() {
2112        let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2113        let default = PageRankConfig::default();
2114        assert_eq!(config.damping, default.damping);
2115        assert_eq!(config.max_iters, default.max_iters);
2116        assert_eq!(config.tol, default.tol);
2117        assert_eq!(config.budget_ms, default.budget_ms);
2118        assert_eq!(config.edge_type, default.edge_type);
2119    }
2120
2121    /// Same guard for `POST /algo/wcc`.
2122    #[test]
2123    fn wcc_config_empty_body_uses_defaults() {
2124        let config: WccConfig = serde_json::from_str("{}").unwrap();
2125        let default = WccConfig::default();
2126        assert_eq!(config.budget_ms, default.budget_ms);
2127        assert_eq!(config.edge_type, default.edge_type);
2128    }
2129
2130    /// Same guard for `POST /algo/degree`.
2131    #[test]
2132    fn degree_config_empty_body_uses_defaults() {
2133        let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2134        let default = DegreeConfig::default();
2135        assert_eq!(config.budget_ms, default.budget_ms);
2136        assert_eq!(config.edge_type, default.edge_type);
2137    }
2138
2139    #[test]
2140    fn backup_dest_rejects_empty() {
2141        assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2142    }
2143
2144    #[test]
2145    fn constant_time_eq_matches_equality() {
2146        assert!(constant_time_eq(b"secret-token", b"secret-token"));
2147        assert!(constant_time_eq(b"", b""));
2148        assert!(!constant_time_eq(b"secret-token", b"secret-toke")); // shorter
2149        assert!(!constant_time_eq(b"secret-token", b"secret-tokex")); // last byte differs
2150        assert!(!constant_time_eq(b"secret-token", b"Xecret-token")); // first byte differs
2151        assert!(!constant_time_eq(b"", b"x"));
2152    }
2153
2154    #[test]
2155    fn depth_defaults_to_one_when_absent() {
2156        assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2157    }
2158
2159    #[test]
2160    fn depth_within_cap_is_accepted() {
2161        assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2162        assert_eq!(
2163            resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2164            MAX_NEIGHBORHOOD_DEPTH
2165        );
2166    }
2167
2168    #[test]
2169    fn depth_over_cap_is_rejected() {
2170        assert!(resolve_neighborhood_depth(Some("65")).is_err());
2171        assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2172    }
2173
2174    #[test]
2175    fn depth_non_integer_is_rejected() {
2176        assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2177    }
2178
2179    #[test]
2180    fn backup_dest_rejects_parent_traversal() {
2181        let root = std::path::Path::new("/srv/backups");
2182        assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2183        assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2184    }
2185
2186    #[test]
2187    fn backup_dest_rejects_absolute_outside_root() {
2188        let root = std::path::Path::new("/srv/backups");
2189        assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2190        assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2191    }
2192
2193    #[test]
2194    fn backup_dest_allows_relative_within_root() {
2195        let root = std::path::Path::new("/srv/backups");
2196        assert_eq!(
2197            confine_backup_dest("nightly", root).unwrap(),
2198            PathBuf::from("/srv/backups/nightly")
2199        );
2200        assert_eq!(
2201            confine_backup_dest("2026/aug", root).unwrap(),
2202            PathBuf::from("/srv/backups/2026/aug")
2203        );
2204    }
2205
2206    #[test]
2207    fn backup_dest_allows_absolute_within_root() {
2208        let root = std::path::Path::new("/srv/backups");
2209        assert_eq!(
2210            confine_backup_dest("/srv/backups/x", root).unwrap(),
2211            PathBuf::from("/srv/backups/x")
2212        );
2213    }
2214}