Skip to main content

server/
http.rs

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