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, namespace_arg, node_edges_json, node_history_json, node_info_json,
15    params_from_json, parse_ingest_edges, result_set_json, rule_def_from_json, stamp_namespace,
16    stamp_namespace_row,
17};
18use crate::{AppState, AuthIdentity};
19use arrow_bridge::to_ipc_bytes;
20use axum::extract::{Extension, Path, Query, Request, State};
21use axum::http::{header, HeaderValue, Method, StatusCode};
22use axum::middleware::{self, Next};
23use axum::response::{IntoResponse, Response};
24use axum::routing::{get, post};
25use axum::{Json, Router};
26use core_api::{
27    is_write_query, json_to_rows, json_to_value, AsOfScope, AutoFk, BackupReport, BatchOp,
28    DegreeConfig, Dir, GraphError, IngestOptions, MaskMode, NodeMask, PageRankConfig,
29    PropPredicate, ResultSet, SharedDb, SuggestConfig, Value, WccConfig, SUGGEST_DEFAULT_SEED,
30};
31use serde_json::{json, Value as Js};
32use std::collections::{BTreeMap, HashMap};
33use std::net::SocketAddr;
34use std::path::PathBuf;
35use tower_http::services::ServeDir;
36
37/// Build the HTTP router over `db`. Read endpoints take the read lock;
38/// `/ingest` takes the write lock. Guards are dropped before any `.await`.
39/// `GET /watch` upgrades to a WebSocket fed by the post-commit sink.
40///
41/// **Call at most once per [`SharedDb`].** A second call replaces the sink
42/// and terminates all existing `/watch` subscribers with
43/// [`tokio::sync::broadcast::error::RecvError::Closed`].
44///
45/// Installing the watch sink replaces any previously installed
46/// [`core_api::GraphDb::set_event_sink`]. The sink only
47/// `broadcast::Sender::send`s (non-blocking) and never re-enters `db`.
48///
49/// # Blocking
50///
51/// [`SharedDb`] uses a std [`std::sync::RwLock`]. Write handlers
52/// (`query_write`, `/ingest`, `create_rule`) run in
53/// `tokio::task::spawn_blocking` and drop the write guard before `.await`.
54/// Reads stay on the worker (neighborhood is µs). `suggest` and `algo`
55/// already use the blocking pool.
56pub fn router(db: SharedDb) -> Router {
57    router_with_auth(db, None)
58}
59
60/// [`router`] with an optional bearer/`?token=` requirement on every route
61/// except unauthenticated `GET /health`.
62///
63/// Role enforcement is not active on this entry point; use
64/// [`router_with_role_tokens`] when role-bound tokens are required.
65pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
66    build_app(
67        db,
68        token,
69        HashMap::new(),
70        UiFallback::None,
71        default_advertise_addr(),
72        false,
73    )
74}
75
76/// [`router`] with a full-access token and a map of role-bound tokens.
77///
78/// Role-bound tokens (`role_tokens`: bearer → role name) receive masked reads
79/// on `/query` and `/node/*`, and 403 on all write or subscription endpoints.
80/// Unknown token: 401.  Token bound to a role not in the DB at request time: 401.
81pub fn router_with_role_tokens(
82    db: SharedDb,
83    token: Option<String>,
84    role_tokens: HashMap<String, String>,
85) -> Router {
86    build_app(
87        db,
88        token,
89        role_tokens,
90        UiFallback::None,
91        default_advertise_addr(),
92        false,
93    )
94}
95
96/// Same as [`router_with_auth`], then `ServeDir` as the fallback so API routes win.
97///
98/// Role enforcement is not active on this entry point; use
99/// [`router_with_role_tokens`] when role-bound tokens are required.
100pub fn router_with_ui(
101    db: SharedDb,
102    ui_dir: impl AsRef<std::path::Path>,
103    token: Option<String>,
104) -> Router {
105    build_app(
106        db,
107        token,
108        HashMap::new(),
109        UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
110        default_advertise_addr(),
111        false,
112    )
113}
114
115/// Like [`router_with_ui`] but marks the connection as TLS-active so the auth
116/// cookie carries the `Secure` attribute. Use when the binary is serving
117/// directly over HTTPS (i.e. via `serve_tls`) rather than behind a proxy.
118pub fn router_with_ui_tls(
119    db: SharedDb,
120    ui_dir: impl AsRef<std::path::Path>,
121    token: Option<String>,
122) -> Router {
123    build_app(
124        db,
125        token,
126        HashMap::new(),
127        UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
128        default_advertise_addr(),
129        true,
130    )
131}
132
133#[cfg(feature = "embed-ui")]
134static EMBEDDED_UI: include_dir::Dir<'_> =
135    include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
136
137/// [`router`] plus the `embed-ui` static tree as fallback.
138#[cfg(feature = "embed-ui")]
139pub fn router_with_embedded_ui(db: SharedDb) -> Router {
140    build_app(
141        db,
142        None,
143        HashMap::new(),
144        UiFallback::Embedded,
145        default_advertise_addr(),
146        false,
147    )
148}
149
150#[cfg(feature = "embed-ui")]
151async fn embedded_fallback(uri: axum::http::Uri) -> Response {
152    let rel = if uri.path() == "/" || uri.path().is_empty() {
153        "index.html"
154    } else {
155        uri.path().trim_start_matches('/')
156    };
157    if rel.split('/').any(|seg| seg == "..") {
158        return StatusCode::NOT_FOUND.into_response();
159    }
160    match EMBEDDED_UI.get_file(rel) {
161        Some(file) => (
162            StatusCode::OK,
163            [(header::CONTENT_TYPE, embedded_ctype(rel))],
164            file.contents(),
165        )
166            .into_response(),
167        None => StatusCode::NOT_FOUND.into_response(),
168    }
169}
170
171#[cfg(feature = "embed-ui")]
172fn embedded_ctype(path: &str) -> &'static str {
173    if path.ends_with(".html") {
174        "text/html; charset=utf-8"
175    } else if path.ends_with(".js") {
176        "application/javascript; charset=utf-8"
177    } else if path.ends_with(".css") {
178        "text/css; charset=utf-8"
179    } else if path.ends_with(".woff2") {
180        "font/woff2"
181    } else if path.ends_with(".svg") {
182        "image/svg+xml"
183    } else if path.ends_with(".ico") {
184        "image/x-icon"
185    } else if path.ends_with(".txt") {
186        "text/plain; charset=utf-8"
187    } else {
188        "application/octet-stream"
189    }
190}
191
192/// Bind `addr` (port 0 is ephemeral) and serve.
193///
194/// Sends the resolved local address on `ready` once the listener is accepting.
195/// Does not hold a database lock.
196///
197/// Role enforcement is not active on this entry point; use
198/// [`serve_with_role_tokens`] when role-bound tokens are required.
199#[deprecated(
200    since = "0.2.0",
201    note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
202)]
203#[doc(hidden)]
204pub async fn serve(
205    db: SharedDb,
206    addr: SocketAddr,
207    ready: tokio::sync::oneshot::Sender<SocketAddr>,
208    token: Option<String>,
209) -> std::io::Result<()> {
210    serve_inner(
211        db,
212        addr,
213        ready,
214        UiFallback::None,
215        token,
216        HashMap::new(),
217        None,
218    )
219    .await
220}
221
222/// [`serve`] with role-bound tokens in addition to the optional full-access token.
223///
224/// `role_tokens` maps bearer values to role names.  See [`router_with_role_tokens`]
225/// for the enforcement semantics.
226pub async fn serve_with_role_tokens(
227    db: SharedDb,
228    addr: SocketAddr,
229    ready: tokio::sync::oneshot::Sender<SocketAddr>,
230    token: Option<String>,
231    role_tokens: HashMap<String, String>,
232) -> std::io::Result<()> {
233    serve_inner(db, addr, ready, UiFallback::None, token, role_tokens, None).await
234}
235
236/// [`serve`] plus a UI dist directory mounted behind the API routes.
237///
238/// Role enforcement is not active on this entry point; use
239/// [`serve_with_ui_and_role_tokens`] when role-bound tokens are required.
240#[deprecated(
241    since = "0.2.0",
242    note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
243)]
244#[doc(hidden)]
245pub async fn serve_with_ui(
246    db: SharedDb,
247    addr: SocketAddr,
248    ready: tokio::sync::oneshot::Sender<SocketAddr>,
249    ui_dir: PathBuf,
250    token: Option<String>,
251) -> std::io::Result<()> {
252    serve_inner(
253        db,
254        addr,
255        ready,
256        UiFallback::Dir(ui_dir),
257        token,
258        HashMap::new(),
259        None,
260    )
261    .await
262}
263
264/// [`serve_with_ui`] with role-bound tokens.
265pub async fn serve_with_ui_and_role_tokens(
266    db: SharedDb,
267    addr: SocketAddr,
268    ready: tokio::sync::oneshot::Sender<SocketAddr>,
269    ui_dir: PathBuf,
270    token: Option<String>,
271    role_tokens: HashMap<String, String>,
272) -> std::io::Result<()> {
273    serve_inner(
274        db,
275        addr,
276        ready,
277        UiFallback::Dir(ui_dir),
278        token,
279        role_tokens,
280        None,
281    )
282    .await
283}
284
285/// [`serve`] plus the compiled-in UI (no-op fallback if `embed-ui` is off).
286///
287/// `role_tokens` maps bearer values to role names; see [`router_with_role_tokens`]
288/// for enforcement semantics.  Pass `HashMap::new()` when no role tokens are needed.
289#[cfg(feature = "embed-ui")]
290pub async fn serve_with_embedded_ui(
291    db: SharedDb,
292    addr: SocketAddr,
293    ready: tokio::sync::oneshot::Sender<SocketAddr>,
294    token: Option<String>,
295    role_tokens: HashMap<String, String>,
296) -> std::io::Result<()> {
297    serve_inner(
298        db,
299        addr,
300        ready,
301        UiFallback::Embedded,
302        token,
303        role_tokens,
304        None,
305    )
306    .await
307}
308
309/// The graceful-shutdown variants, added in 0.6.12.
310///
311/// Each mirrors the entry point above it and takes one extra argument: a
312/// receiver that, when it fires **or is dropped**, stops the server accepting
313/// new connections and lets every request already in flight finish. Without
314/// one, the only way to stop a server was to abort the task running it, which
315/// severs in-flight responses and live `/watch` and `/subscribe` subscribers
316/// mid-stream.
317///
318/// These are additive on purpose. The four originals keep their signatures and
319/// their behaviour, so nothing embedding this crate has to change; they simply
320/// pass no signal. The cost is eight entry points where four would do, and that
321/// was the trade chosen over a breaking parameter.
322pub async fn serve_with_shutdown(
323    db: SharedDb,
324    addr: SocketAddr,
325    ready: tokio::sync::oneshot::Sender<SocketAddr>,
326    token: Option<String>,
327    shutdown: tokio::sync::oneshot::Receiver<()>,
328) -> std::io::Result<()> {
329    serve_inner(
330        db,
331        addr,
332        ready,
333        UiFallback::None,
334        token,
335        HashMap::new(),
336        Some(shutdown),
337    )
338    .await
339}
340
341/// [`serve_with_role_tokens`] that stops gracefully. See [`serve_with_shutdown`].
342pub async fn serve_with_role_tokens_and_shutdown(
343    db: SharedDb,
344    addr: SocketAddr,
345    ready: tokio::sync::oneshot::Sender<SocketAddr>,
346    token: Option<String>,
347    role_tokens: HashMap<String, String>,
348    shutdown: tokio::sync::oneshot::Receiver<()>,
349) -> std::io::Result<()> {
350    serve_inner(
351        db,
352        addr,
353        ready,
354        UiFallback::None,
355        token,
356        role_tokens,
357        Some(shutdown),
358    )
359    .await
360}
361
362/// [`serve_with_ui_and_role_tokens`] that stops gracefully. See [`serve_with_shutdown`].
363pub async fn serve_with_ui_and_role_tokens_and_shutdown(
364    db: SharedDb,
365    addr: SocketAddr,
366    ready: tokio::sync::oneshot::Sender<SocketAddr>,
367    ui_dir: PathBuf,
368    token: Option<String>,
369    role_tokens: HashMap<String, String>,
370    shutdown: tokio::sync::oneshot::Receiver<()>,
371) -> std::io::Result<()> {
372    serve_inner(
373        db,
374        addr,
375        ready,
376        UiFallback::Dir(ui_dir),
377        token,
378        role_tokens,
379        Some(shutdown),
380    )
381    .await
382}
383
384/// [`serve_with_embedded_ui`] that stops gracefully. See [`serve_with_shutdown`].
385#[cfg(feature = "embed-ui")]
386pub async fn serve_with_embedded_ui_and_shutdown(
387    db: SharedDb,
388    addr: SocketAddr,
389    ready: tokio::sync::oneshot::Sender<SocketAddr>,
390    token: Option<String>,
391    role_tokens: HashMap<String, String>,
392    shutdown: tokio::sync::oneshot::Receiver<()>,
393) -> std::io::Result<()> {
394    serve_inner(
395        db,
396        addr,
397        ready,
398        UiFallback::Embedded,
399        token,
400        role_tokens,
401        Some(shutdown),
402    )
403    .await
404}
405
406enum UiFallback {
407    None,
408    Dir(PathBuf),
409    #[cfg(feature = "embed-ui")]
410    Embedded,
411}
412
413async fn serve_inner(
414    db: SharedDb,
415    addr: SocketAddr,
416    ready: tokio::sync::oneshot::Sender<SocketAddr>,
417    ui: UiFallback,
418    token: Option<String>,
419    role_tokens: HashMap<String, String>,
420    shutdown: Option<tokio::sync::oneshot::Receiver<()>>,
421) -> std::io::Result<()> {
422    let listener = tokio::net::TcpListener::bind(addr).await?;
423    let local = listener.local_addr()?;
424    if ready.send(local).is_err() {
425        // Caller dropped the readiness receiver; still serve.
426        eprintln!("serve: readiness receiver dropped before bind notify");
427    }
428    let app = build_app(db, token, role_tokens, ui, local, false);
429    match shutdown {
430        None => axum::serve(listener, app).await,
431        // Stop accepting, then let every request already in flight finish.
432        // Dropping the sender counts as the signal, so a caller that goes away
433        // shuts the server down rather than stranding it.
434        Some(rx) => {
435            axum::serve(listener, app)
436                .with_graceful_shutdown(async move {
437                    let _ = rx.await;
438                })
439                .await
440        }
441    }
442}
443
444/// Serve over TLS (requires the `tls` cargo feature).
445///
446/// Accepts a path to a PEM certificate and a PEM private key. Uses rustls
447/// via `axum-server`. The `ready` sender fires with the bound local address
448/// once the listener is accepting, mirroring the contract of [`serve_inner`].
449///
450/// Pass `token` / `role_tokens` the same way you would for [`serve_with_role_tokens`].
451#[cfg(feature = "tls")]
452pub async fn serve_tls(
453    db: SharedDb,
454    addr: SocketAddr,
455    ready: tokio::sync::oneshot::Sender<SocketAddr>,
456    cert_path: std::path::PathBuf,
457    key_path: std::path::PathBuf,
458    token: Option<String>,
459    role_tokens: HashMap<String, String>,
460) -> std::io::Result<()> {
461    let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
462        .await
463        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
464    let listener = std::net::TcpListener::bind(addr)?;
465    listener.set_nonblocking(true)?;
466    let local = listener.local_addr()?;
467    if ready.send(local).is_err() {
468        eprintln!("serve_tls: readiness receiver dropped before bind notify");
469    }
470    let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
471    axum_server::from_tcp_rustls(listener, config)?
472        .serve(app.into_make_service())
473        .await
474}
475
476/// [`serve_tls`] that stops gracefully. See [`serve_with_shutdown`].
477///
478/// `axum_server` has its own shutdown mechanism — a [`axum_server::Handle`] —
479/// rather than axum's `with_graceful_shutdown`, so the signal is bridged onto
480/// one here. In-flight requests get [`TLS_GRACE`] to finish before the
481/// listener is closed regardless; an unbounded grace period would let one
482/// stuck request hold a restart open forever.
483#[cfg(feature = "tls")]
484pub async fn serve_tls_with_shutdown(
485    db: SharedDb,
486    addr: SocketAddr,
487    ready: tokio::sync::oneshot::Sender<SocketAddr>,
488    cert_path: std::path::PathBuf,
489    key_path: std::path::PathBuf,
490    token: Option<String>,
491    role_tokens: HashMap<String, String>,
492    shutdown: tokio::sync::oneshot::Receiver<()>,
493) -> std::io::Result<()> {
494    let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
495        .await
496        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
497    let listener = std::net::TcpListener::bind(addr)?;
498    listener.set_nonblocking(true)?;
499    let local = listener.local_addr()?;
500    if ready.send(local).is_err() {
501        eprintln!("serve_tls: readiness receiver dropped before bind notify");
502    }
503    let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
504    let handle = axum_server::Handle::new();
505    tokio::spawn({
506        let handle = handle.clone();
507        async move {
508            let _ = shutdown.await;
509            handle.graceful_shutdown(Some(TLS_GRACE));
510        }
511    });
512    axum_server::from_tcp_rustls(listener, config)?
513        .handle(handle)
514        .serve(app.into_make_service())
515        .await
516}
517
518/// How long a TLS server lets in-flight requests finish after a shutdown
519/// signal before closing anyway.
520#[cfg(feature = "tls")]
521const TLS_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
522
523fn default_advertise_addr() -> SocketAddr {
524    SocketAddr::from(([127, 0, 0, 1], 8080))
525}
526
527fn build_app(
528    db: SharedDb,
529    token: Option<String>,
530    role_tokens: HashMap<String, String>,
531    ui: UiFallback,
532    addr: SocketAddr,
533    tls_active: bool,
534) -> Router {
535    debug_assert!(
536        !db.read().has_event_sink(),
537        "router() must be called at most once per SharedDb; a second call \
538         replaces the sink and terminates all existing /watch subscribers \
539         with RecvError::Closed"
540    );
541    let (tx, _) = tokio::sync::broadcast::channel(1024);
542    {
543        let tx = tx.clone();
544        db.write().set_event_sink(Box::new(move |ev| {
545            let _ = tx.send(ev);
546        }));
547    }
548    let state = AppState {
549        db,
550        watch: tx,
551        token,
552        role_tokens,
553        addr,
554        tls_active,
555        started_at: std::time::Instant::now(),
556    };
557    let app = Router::new()
558        .route("/health", get(health))
559        .route("/query", post(query))
560        .route("/stats", get(stats))
561        .route("/metrics", get(metrics))
562        .route("/ingest", post(ingest))
563        .route("/rules", post(create_rule))
564        .route("/suggest", get(suggest))
565        .route("/explain", get(explain))
566        .route("/find_similar", post(find_similar))
567        .route("/node/{key}", get(node_info))
568        .route("/node/{key}", axum::routing::delete(delete_node))
569        .route("/node/{key}/edges", get(node_edges))
570        .route("/node/{key}/neighborhood", get(neighborhood))
571        .route("/node/{key}/history", get(node_history_handler))
572        .route("/history/edge", get(edge_history_handler))
573        .route("/history/was_linked", get(was_linked_handler))
574        .route(
575            "/node/{key}/prop/{field}",
576            axum::routing::put(set_node_prop),
577        )
578        .route(
579            "/node/{key}/prop/{field}",
580            axum::routing::delete(remove_node_prop),
581        )
582        // Simple BatchOp-mapped endpoints — routed through the group-commit
583        // queue (submit_batch) so concurrent node/edge CRUD does not hold
584        // the write lock during fsync.
585        .route("/nodes", post(create_node))
586        .route("/nodes/{key}/rename", post(rename_node))
587        .route("/edges", post(create_edge))
588        .route("/edges/upsert", post(upsert_edge))
589        .route(
590            "/edges/{etype}/{src}/{dst}",
591            axum::routing::delete(delete_edge),
592        )
593        .route("/algo/pagerank", post(algo_pagerank))
594        .route("/algo/wcc", post(algo_wcc))
595        .route("/algo/degree", post(algo_degree))
596        .route("/backup", post(backup))
597        .with_state(state.clone());
598
599    // The two WebSocket routes are built separately and merged unlayered: a
600    // request timeout applied to them would sever a healthy subscription at
601    // the deadline, and a subscription is meant to outlive any request.
602    let streaming = Router::new()
603        .route("/watch", get(crate::ws::watch))
604        .route("/subscribe", get(crate::subscribe::subscribe))
605        .with_state(state.clone());
606
607    // Everything else gets a deadline and a ceiling.
608    //
609    // `RequestBodyTimeoutLayer` is deliberately not what is wanted here: the
610    // risk is a handler that never finishes, not only a client that never
611    // finishes sending. `TimeoutLayer` bounds the whole exchange and answers
612    // 408 when it expires.
613    //
614    // The concurrency limit sheds rather than queues. An unbounded queue in
615    // front of a single-writer engine converts overload into latency that
616    // never recovers; 503 is the honest answer, and the one a load balancer
617    // can act on.
618    let app = app
619        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
620            StatusCode::REQUEST_TIMEOUT,
621            REQUEST_TIMEOUT,
622        ))
623        .layer(
624            tower::ServiceBuilder::new()
625                // Load shedding surfaces as a `BoxError`, and a Router's stack
626                // must be infallible, so the overload is mapped to a response
627                // here rather than escaping as an error.
628                .layer(axum::error_handling::HandleErrorLayer::new(
629                    |_: tower::BoxError| async {
630                        (
631                            StatusCode::SERVICE_UNAVAILABLE,
632                            Json(json!({
633                                "error": "server at its concurrency limit; retry"
634                            })),
635                        )
636                    },
637                ))
638                .load_shed()
639                .concurrency_limit(MAX_CONCURRENT_REQUESTS),
640        );
641    let app = app.merge(streaming);
642    let app = match ui {
643        UiFallback::None => app,
644        UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
645        #[cfg(feature = "embed-ui")]
646        UiFallback::Embedded => app.fallback(embedded_fallback),
647    };
648    app.layer(middleware::from_fn_with_state(state, auth_middleware))
649        // Outside auth, so a 401 is logged too — an unauthenticated flood is
650        // exactly the thing an operator needs to see.
651        .layer(middleware::from_fn(log_middleware))
652        // Cap request bodies (default axum limit is only 2 MiB; we allow larger
653        // ingest batches but reject multi-GB bodies that would OOM the collector
654        // before any handler runs).
655        .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
656}
657
658/// Maximum accepted HTTP request body size (64 MiB). Large enough for batched
659/// `/ingest` payloads, small enough to prevent a single request from
660/// exhausting memory. Clients with larger imports should chunk.
661/// Per-request logging, with a policy chosen so the log stays readable.
662///
663/// A line per request at one level is what makes a server log unreadable: the
664/// 200s bury the 500s, and nobody reads it after the first week. So the level
665/// follows what happened:
666///
667/// | Outcome | Level | Visible by default |
668/// |---|---|---|
669/// | 5xx | `error` | yes — always worth a look |
670/// | slower than [`SLOW_REQUEST`] | `warn` | yes — the request that will time out next |
671/// | 4xx | `info` | yes — one line, the caller's mistake or a probe |
672/// | everything else | `debug` | no |
673///
674/// A healthy server is therefore quiet, and turning `debug` on for one module
675/// gives the full trace without recompiling.
676///
677/// Every response carries `x-request-id`, echoed from the caller's header when
678/// it sent one and generated otherwise, so an error someone reports can be
679/// found in the log. Before 0.6.12 there was no `tracing`, no access log and no
680/// request id anywhere in the tree: a 500 in production could not be tied to
681/// the request that caused it.
682async fn log_middleware(req: Request, next: Next) -> Response {
683    let request_id = req
684        .headers()
685        .get("x-request-id")
686        .and_then(|v| v.to_str().ok())
687        .filter(|v| !v.is_empty() && v.len() <= 128 && v.is_ascii())
688        .map(str::to_owned)
689        .unwrap_or_else(new_request_id);
690
691    let method = req.method().clone();
692    // The path only. A query string can carry `?token=`, and a log is exactly
693    // the wrong place for a credential.
694    let path = req.uri().path().to_owned();
695    let started = std::time::Instant::now();
696
697    let mut res = next.run(req).await;
698    let status = res.status();
699    let ms = started.elapsed().as_millis();
700
701    if status.is_server_error() {
702        tracing::error!(%request_id, %method, %path, status = status.as_u16(), ms, "request failed");
703    } else if started.elapsed() >= SLOW_REQUEST {
704        tracing::warn!(%request_id, %method, %path, status = status.as_u16(), ms, "slow request");
705    } else if status.is_client_error() {
706        tracing::info!(%request_id, %method, %path, status = status.as_u16(), ms, "request refused");
707    } else {
708        tracing::debug!(%request_id, %method, %path, status = status.as_u16(), ms, "request");
709    }
710
711    if let Ok(v) = axum::http::HeaderValue::from_str(&request_id) {
712        res.headers_mut().insert("x-request-id", v);
713    }
714    res
715}
716
717/// A request id with no dependency on a uuid crate: the process id, a
718/// monotonic counter, and the low bits of a nanosecond clock. Unique enough to
719/// correlate a report with a log line, which is all it is for.
720fn new_request_id() -> String {
721    use std::sync::atomic::{AtomicU64, Ordering};
722    static N: AtomicU64 = AtomicU64::new(0);
723    let n = N.fetch_add(1, Ordering::Relaxed);
724    let nanos = std::time::SystemTime::now()
725        .duration_since(std::time::UNIX_EPOCH)
726        .map(|d| d.subsec_nanos() as u64)
727        .unwrap_or(0);
728    format!("{:x}-{:x}-{:x}", std::process::id(), n, nanos)
729}
730
731/// A request slower than this is logged at `warn` whatever its status. Well
732/// under [`REQUEST_TIMEOUT`], so the log names the requests heading for a
733/// timeout before one happens.
734const SLOW_REQUEST: std::time::Duration = std::time::Duration::from_secs(5);
735
736const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
737
738/// How long any one non-streaming request may take before the server answers
739/// `408 Request Timeout` and drops it.
740///
741/// Sized for the slowest legitimate handler rather than the typical one: a
742/// `/query` against a large store, a `/backup`, or a `/suggest` that profiles
743/// the graph can all take tens of seconds. The point is not to make slow
744/// requests fast — it is that a handler which will never finish stops holding
745/// a connection, a permit and a read guard forever.
746///
747/// The WebSocket routes are exempt; see `build_app`.
748const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
749
750/// How many non-streaming requests may be in flight at once before the server
751/// sheds load with `503 Service Unavailable`.
752///
753/// The engine is single-writer and many-reader, so the ceiling exists to bound
754/// memory and the blocking pool, not to serialise work. Chosen well above any
755/// plausible operator workload and well below what would exhaust the process.
756const MAX_CONCURRENT_REQUESTS: usize = 256;
757
758async fn health(
759    State(state): State<AppState>,
760    Extension(identity): Extension<AuthIdentity>,
761) -> Response {
762    // Liveness is for everyone; the graph's shape is not. `/stats` and
763    // `/metrics` already refuse a role-bound token because counters leak graph
764    // size, and an endpoint that hands the same counters to a caller with no
765    // token at all makes that refusal decorative.
766    if !matches!(identity, AuthIdentity::Full) {
767        return json_ok(json!({ "ok": true }));
768    }
769    let (nodes, edges) = {
770        let g = state.db.read();
771        let s = g.stats();
772        (s.nodes_live, s.edges)
773    };
774    json_ok(json!({
775        "ok": true,
776        "nodes": nodes,
777        "edges": edges,
778        "addr": state.addr.to_string(),
779    }))
780}
781
782/// Run a GraphDb write on the blocking pool. The write guard lives only
783/// inside `f` and is dropped before this future awaits.
784async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
785where
786    T: Send + 'static,
787    F: FnOnce() -> core_api::Result<T> + Send + 'static,
788{
789    match tokio::task::spawn_blocking(f).await {
790        Ok(Ok(v)) => Ok(v),
791        Ok(Err(e)) => Err(graph_err(e)),
792        Err(_) => Err(err_response("write task panicked")),
793    }
794}
795
796const TOKEN_COOKIE: &str = "mushroomdb_token";
797
798async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
799    // No auth configured at all: every request is Full, no restriction.
800    if state.token.is_none() && state.role_tokens.is_empty() {
801        req.extensions_mut().insert(AuthIdentity::Full);
802        return next.run(req).await;
803    }
804
805    let presented = request_token(&req);
806
807    // Health stays reachable without a credential — a load balancer has none —
808    // but reaching it is not the same as being trusted by it. A caller whose
809    // token does not resolve to full access is `Anonymous` here, and the
810    // handler discloses liveness only.
811    //
812    // Before 0.6.12 this inserted `Full` unconditionally, so an unauthenticated
813    // caller read `nodes_live` and `edges` from `/health` while a role-bound
814    // token was refused the same counters by `/metrics` and `/stats` on the
815    // stated grounds that counters leak graph size. Two endpoints, one
816    // disclosure, opposite policies.
817    if req.method() == Method::GET && req.uri().path() == "/health" {
818        let full = state
819            .token
820            .clone()
821            .filter(|s| !s.is_empty())
822            .zip(presented.as_deref())
823            .is_some_and(|(tok, p)| constant_time_eq(p.as_bytes(), tok.as_bytes()));
824        req.extensions_mut().insert(if full {
825            AuthIdentity::Full
826        } else {
827            AuthIdentity::Anonymous
828        });
829        return next.run(req).await;
830    }
831
832    // Check full-access token first.
833    if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
834        if presented
835            .as_deref()
836            .is_some_and(|p| constant_time_eq(p.as_bytes(), full_tok.as_bytes()))
837        {
838            let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
839            req.extensions_mut().insert(AuthIdentity::Full);
840            let mut res = next.run(req).await;
841            if set_cookie && is_html_response(&res) {
842                attach_token_cookie(&mut res, full_tok, state.tls_active);
843            }
844            return res;
845        }
846    }
847
848    // Check role-bound tokens.
849    if let Some(tok) = presented.as_deref() {
850        if let Some(role_name) = state.role_tokens.get(tok) {
851            // Early-deny paths whose handlers use WebSocket upgrade: the WS
852            // extraction consumes the request before the handler body runs, so
853            // the handler-level identity check is unreachable in those cases.
854            // Returning 403 here also removes the need for the handler to
855            // hold a read lock just to enforce this.
856            let path = req.uri().path();
857            if path == "/subscribe" || path == "/watch" {
858                return forbidden("role-bound token: this endpoint is not permitted");
859            }
860            req.extensions_mut()
861                .insert(AuthIdentity::Role(role_name.clone()));
862            return next.run(req).await;
863        }
864    }
865
866    // Nothing matched → 401.
867    unauthorized()
868}
869
870fn request_token(req: &Request) -> Option<String> {
871    presented_bearer_or_query(req).or_else(|| presented_cookie(req))
872}
873
874fn presented_bearer_or_query(req: &Request) -> Option<String> {
875    if let Some(header) = req
876        .headers()
877        .get(header::AUTHORIZATION)
878        .and_then(|v| v.to_str().ok())
879    {
880        if let Some(value) = bearer_token(header) {
881            return Some(value.to_string());
882        }
883    }
884    query_param(req.uri().query().unwrap_or(""), "token")
885}
886
887fn presented_cookie(req: &Request) -> Option<String> {
888    let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
889    cookie_named(header, TOKEN_COOKIE).map(str::to_string)
890}
891
892fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
893    for part in header.split(';') {
894        let part = part.trim();
895        let Some((k, v)) = part.split_once('=') else {
896            continue;
897        };
898        if k.trim() == name {
899            return Some(v.trim());
900        }
901    }
902    None
903}
904
905fn is_html_response(res: &Response) -> bool {
906    res.headers()
907        .get(header::CONTENT_TYPE)
908        .and_then(|v| v.to_str().ok())
909        .is_some_and(|ct| {
910            ct.split(';')
911                .next()
912                .unwrap_or("")
913                .trim()
914                .eq_ignore_ascii_case("text/html")
915        })
916}
917
918fn attach_token_cookie(res: &mut Response, token: &str, secure: bool) {
919    let secure_attr = if secure { "; Secure" } else { "" };
920    let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly{secure_attr}");
921    if let Ok(hv) = HeaderValue::from_str(&value) {
922        res.headers_mut().insert(header::SET_COOKIE, hv);
923    }
924}
925
926fn bearer_token(header: &str) -> Option<&str> {
927    let (scheme, value) = header.split_once(' ')?;
928    if scheme.eq_ignore_ascii_case("Bearer") {
929        Some(value.trim())
930    } else {
931        None
932    }
933}
934
935fn query_param(query: &str, key: &str) -> Option<String> {
936    for pair in query.split('&') {
937        if pair.is_empty() {
938            continue;
939        }
940        match pair.split_once('=') {
941            Some((k, v)) if k == key => return percent_decode_plus(v),
942            None if pair == key => return Some(String::new()),
943            _ => {}
944        }
945    }
946    None
947}
948
949/// `application/x-www-form-urlencoded`: `+` is space, `%HH` is a byte.
950fn percent_decode_plus(s: &str) -> Option<String> {
951    let bytes = s.as_bytes();
952    let mut out = Vec::with_capacity(bytes.len());
953    let mut i = 0;
954    while i < bytes.len() {
955        match bytes[i] {
956            b'+' => {
957                out.push(b' ');
958                i += 1;
959            }
960            b'%' => {
961                if i + 2 >= bytes.len() {
962                    return None;
963                }
964                let hi = from_hex(bytes[i + 1])?;
965                let lo = from_hex(bytes[i + 2])?;
966                out.push((hi << 4) | lo);
967                i += 3;
968            }
969            c => {
970                out.push(c);
971                i += 1;
972            }
973        }
974    }
975    String::from_utf8(out).ok()
976}
977
978fn from_hex(b: u8) -> Option<u8> {
979    match b {
980        b'0'..=b'9' => Some(b - b'0'),
981        b'a'..=b'f' => Some(b - b'a' + 10),
982        b'A'..=b'F' => Some(b - b'A' + 10),
983        _ => None,
984    }
985}
986
987fn unauthorized() -> Response {
988    (
989        StatusCode::UNAUTHORIZED,
990        Json(json!({"error": "unauthorized"})),
991    )
992        .into_response()
993}
994
995/// 403 response for role-bound token operations that are denied in v1.
996fn forbidden(detail: &str) -> Response {
997    (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
998}
999
1000/// Convert a `mask_for_role` error into an HTTP response.
1001///
1002/// - `GraphError::Corrupt` → 500: the roles sidecar is poisoned; the server
1003///   refuses all role-token requests until the file is fixed and the DB is
1004///   re-opened.  Full-access tokens are unaffected.
1005/// - `GraphError::KeyNotFound` with a `role:` prefix → 401: the token is bound
1006///   to a role that does not exist in the DB at this request time.
1007fn role_mask_err(e: GraphError) -> Response {
1008    match e {
1009        GraphError::Corrupt { detail } => (
1010            StatusCode::INTERNAL_SERVER_ERROR,
1011            Json(json!({"error": format!("roles misconfigured: {detail}")})),
1012        )
1013            .into_response(),
1014        GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
1015        other => graph_err(other),
1016    }
1017}
1018
1019fn err_response(detail: impl Into<String>) -> Response {
1020    (
1021        StatusCode::BAD_REQUEST,
1022        Json(json!({"error": detail.into()})),
1023    )
1024        .into_response()
1025}
1026
1027/// 503 response for a store held by another process's write lock.
1028///
1029/// `Busy` is transient and nothing was written, so retrying is always safe.
1030/// 503 plus `Retry-After` is what standard client retry middleware keys on;
1031/// a 4xx would tell the client the request itself was malformed.
1032fn busy_response(detail: String) -> Response {
1033    (
1034        StatusCode::SERVICE_UNAVAILABLE,
1035        [(header::RETRY_AFTER, HeaderValue::from_static("1"))],
1036        Json(json!({ "error": detail })),
1037    )
1038        .into_response()
1039}
1040
1041fn graph_err(e: GraphError) -> Response {
1042    match e {
1043        // §4.3: role-scoped write denials map to 403 with the verbatim reason
1044        // string (Display delegates to reason, so .to_string() == reason).
1045        GraphError::RoleWriteDenied { reason } => forbidden(&reason),
1046        // Ledger row 12. `role_mask_err` already answered 500 for `Corrupt`
1047        // while this catch-all answered 400, so one damaged store answered
1048        // differently depending on whether the role-mask memo was warm. A
1049        // corrupt store is a server-side condition: 400 tells a caller to change
1050        // an input, and no input they can send will help. The message is
1051        // deliberately `to_string()` — identical to what the catch-all produced
1052        // — so only the status moves.
1053        corrupt @ GraphError::Corrupt { .. } => (
1054            StatusCode::INTERNAL_SERVER_ERROR,
1055            Json(json!({ "error": corrupt.to_string() })),
1056        )
1057            .into_response(),
1058        GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
1059            err_response(detail)
1060        }
1061        // Transient: another process holds the cross-process write lock.
1062        busy @ GraphError::Busy { .. } => busy_response(busy.to_string()),
1063        other => err_response(other.to_string()),
1064    }
1065}
1066
1067fn key_not_found(key: String) -> Response {
1068    (
1069        StatusCode::NOT_FOUND,
1070        Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
1071    )
1072        .into_response()
1073}
1074
1075fn conflict_response(key: String) -> Response {
1076    (
1077        StatusCode::CONFLICT,
1078        Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
1079    )
1080        .into_response()
1081}
1082
1083fn json_ok(value: Js) -> Response {
1084    (StatusCode::OK, Json(value)).into_response()
1085}
1086
1087fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
1088    let Some(v) = v else {
1089        return Ok(IngestOptions::default());
1090    };
1091    if v.is_null() {
1092        return Ok(IngestOptions::default());
1093    }
1094    let obj = v
1095        .as_object()
1096        .ok_or_else(|| "options must be an object".to_string())?;
1097    let mut opts = IngestOptions::default();
1098    if let Some(kf) = obj.get("key_field") {
1099        opts.key_field = kf
1100            .as_str()
1101            .ok_or_else(|| "options.key_field must be a string".to_string())?
1102            .to_string();
1103    }
1104    if let Some(fk) = obj.get("auto_fk") {
1105        if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
1106            opts.auto_fk = AutoFk::Off;
1107        } else if let Some(m) = fk.as_object() {
1108            let suf = m
1109                .get("suffix")
1110                .and_then(Js::as_str)
1111                .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
1112            opts.auto_fk = AutoFk::Auto {
1113                suffix: suf.to_string(),
1114            };
1115        } else {
1116            return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
1117        }
1118    }
1119    Ok(opts)
1120}
1121
1122/// Format a `ResultSet` as Arrow IPC or JSON depending on `format`.
1123fn format_query_result(rs: ResultSet, format: &str) -> Response {
1124    match format {
1125        "" => match to_ipc_bytes(&rs) {
1126            Ok(bytes) => (
1127                StatusCode::OK,
1128                [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
1129                bytes,
1130            )
1131                .into_response(),
1132            Err(e) => err_response(e),
1133        },
1134        "json" => json_ok(result_set_json(&rs)),
1135        other => err_response(format!("unknown format: {other}")),
1136    }
1137}
1138
1139async fn query(
1140    State(state): State<AppState>,
1141    Extension(identity): Extension<AuthIdentity>,
1142    Query(qs): Query<BTreeMap<String, String>>,
1143    Json(body): Json<Js>,
1144) -> Response {
1145    let cypher = match body.get("cypher").and_then(Js::as_str) {
1146        Some(s) => s.to_string(),
1147        None => return err_response("missing cypher"),
1148    };
1149    let params = match params_from_json(body.get("params")) {
1150        Ok(p) => p,
1151        Err(e) => return err_response(e),
1152    };
1153    let format = qs.get("format").map(String::as_str).unwrap_or("");
1154
1155    // Optional time-travel: `as_of` is a 0-based WAL commit index. The query
1156    // runs against the graph as it existed at that commit (read-only).
1157    let as_of = match body.get("as_of") {
1158        None | Some(Js::Null) => None,
1159        Some(v) => match v.as_u64() {
1160            Some(n) => Some(n),
1161            None => return err_response("as_of must be a non-negative integer commit index"),
1162        },
1163    };
1164
1165    // Parse client-supplied mask (optional array of node keys).
1166    let mask_keys: Option<Vec<String>> = match body.get("mask") {
1167        None | Some(Js::Null) => None,
1168        Some(Js::Array(arr)) => {
1169            let mut keys = Vec::with_capacity(arr.len());
1170            for v in arr {
1171                match v.as_str() {
1172                    Some(s) => keys.push(s.to_string()),
1173                    None => return err_response("mask must be an array of strings"),
1174                }
1175            }
1176            Some(keys)
1177        }
1178        Some(_) => return err_response("mask must be an array of strings"),
1179    };
1180
1181    // The second visibility axis. `namespace` is a leg that **intersects**
1182    // whatever restriction the identity and the body already impose — a role
1183    // token's binding, a client mask, both — so it can only narrow. A role bound
1184    // to namespaces honours them with no `namespace` in the body; one outside
1185    // the binding is the empty intersection, never the union.
1186    let namespace = match namespace_arg(body.get("namespace")) {
1187        Ok(n) => n,
1188        Err(e) => return err_response(e),
1189    };
1190
1191    // Stub mode discloses node existence, which is exactly the question an
1192    // as-of read is asking. The two do not compose.
1193    if as_of.is_some()
1194        && body
1195            .get("stub_hidden")
1196            .and_then(|v| v.as_bool())
1197            .unwrap_or(false)
1198    {
1199        return err_response("as_of (time-travel) does not compose with stub_hidden");
1200    }
1201
1202    // Role token: write Cypher routes to query_write_authz (scope + mask
1203    // resolved under the write lock, §5 discipline).  Read Cypher uses the
1204    // lock-free epoch snapshot path unchanged.
1205    if let AuthIdentity::Role(ref role_name) = identity {
1206        let is_write = match is_write_query(&cypher) {
1207            Ok(b) => b,
1208            Err(e) => return err_response(e),
1209        };
1210        if as_of.is_some() && is_write {
1211            return err_response("as_of (time-travel) queries are read-only");
1212        }
1213        // `mask` and `namespace` are read guards: both documented as making the
1214        // request a read, and the full-token branch below enforces that by
1215        // routing anything carrying either to `query_masked`, which refuses a
1216        // write. The role branch used to check `is_write` first and never look
1217        // at them, so a client sending a write *with* a namespace as its guard
1218        // got the write executed instead of the 400 the docs promise. Not a
1219        // widening — `check_single_op_authz` still gates the write — but the
1220        // guard the caller asked for was not applied.
1221        if is_write && (mask_keys.is_some() || namespace.is_some()) {
1222            return (
1223                StatusCode::BAD_REQUEST,
1224                Json(json!({"error": "masked queries are read-only"})),
1225            )
1226                .into_response();
1227        }
1228        if is_write {
1229            let role = role_name.clone();
1230            let cypher_c = cypher.clone();
1231            let params_c = params.clone();
1232            let db = state.db.clone();
1233            return match blocking_write(move || {
1234                db.write().query_write_authz(&role, &cypher_c, &params_c)
1235            })
1236            .await
1237            {
1238                Ok(rs) => format_query_result(rs, format),
1239                Err(resp) => resp,
1240            };
1241        }
1242        // Time travel: the role's keys and labels are resolved against the
1243        // graph as it was at `commit`, and a client mask intersects them
1244        // there.  The role *definition* is the current one — `roles.json` is a
1245        // sidecar and is never a WAL record, so it has no past version.
1246        if let Some(commit) = as_of {
1247            let scope = match mask_keys {
1248                Some(ref keys) => AsOfScope::RoleAndKeys(role_name, keys),
1249                None => AsOfScope::Role(role_name),
1250            };
1251            let g = state.db.read();
1252            let out = match namespace.as_deref() {
1253                Some(ns) => g.query_at_scoped_in_namespace(commit, &cypher, &params, scope, ns),
1254                None => g.query_at_scoped(commit, &cypher, &params, scope),
1255            };
1256            return match out {
1257                Ok(rs) => format_query_result(rs, format),
1258                Err(e) => role_mask_err(e),
1259            };
1260        }
1261        let snap = state.db.reader();
1262        let role_mask = match snap.mask_for_role(role_name) {
1263            Ok(m) => m,
1264            Err(e) => return role_mask_err(e),
1265        };
1266        let effective_mask = if let Some(ref keys) = mask_keys {
1267            // Client mask intersects role mask — never widens visibility.
1268            let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
1269            role_mask.intersect(&client_mask)
1270        } else {
1271            role_mask
1272        };
1273        // And the namespace leg intersects whatever that came to, on the same
1274        // snapshot the query runs against.
1275        let effective_mask = match namespace.as_deref() {
1276            Some(ns) => match snap.mask_for_namespace(ns) {
1277                Ok(ns_mask) => effective_mask.intersect(&ns_mask),
1278                Err(e) => return graph_err(e),
1279            },
1280            None => effective_mask,
1281        };
1282        return match snap.query_masked(&cypher, &params, &effective_mask) {
1283            Ok(rs) => format_query_result(rs, format),
1284            Err(GraphError::MaskedReadOnly) => (
1285                StatusCode::BAD_REQUEST,
1286                Json(json!({"error": "masked queries are read-only"})),
1287            )
1288                .into_response(),
1289            Err(e) => graph_err(e),
1290        };
1291    }
1292
1293    // Full token: existing paths below.
1294
1295    // When a client-supplied mask is present, route to query_masked (read-only).
1296    // Hold a single read guard for both from_keys and query_masked so the mask
1297    // and the query execute on the same database snapshot.
1298    //
1299    // `stub_hidden: true` opts into MaskMode::Stub for the mask; Cypher query
1300    // behaviour is identical in both modes (hidden nodes are excluded from
1301    // query results regardless of mode).
1302    if mask_keys.is_some() || namespace.is_some() {
1303        // Time travel: the allow-list resolves against the as-of graph, so a
1304        // key that did not exist at `commit` resolves to nothing.
1305        if let Some(commit) = as_of {
1306            let g = state.db.read();
1307            let out = match (&mask_keys, namespace.as_deref()) {
1308                (Some(keys), Some(ns)) => g.query_at_scoped_in_namespace(
1309                    commit,
1310                    &cypher,
1311                    &params,
1312                    AsOfScope::Keys(keys),
1313                    ns,
1314                ),
1315                (Some(keys), None) => {
1316                    g.query_at_scoped(commit, &cypher, &params, AsOfScope::Keys(keys))
1317                }
1318                (None, Some(ns)) => {
1319                    g.query_at_scoped(commit, &cypher, &params, AsOfScope::Namespace(ns))
1320                }
1321                (None, None) => unreachable!("one of the two is Some in this branch"),
1322            };
1323            return match out {
1324                Ok(rs) => format_query_result(rs, format),
1325                Err(e) => graph_err(e),
1326            };
1327        }
1328        let stub_hidden = body
1329            .get("stub_hidden")
1330            .and_then(|v| v.as_bool())
1331            .unwrap_or(false);
1332        let db = state.db.read();
1333        let mask = {
1334            let m = match (&mask_keys, namespace.as_deref()) {
1335                (Some(keys), Some(ns)) => {
1336                    NodeMask::from_keys(&*db, keys.iter().map(String::as_str))
1337                        .intersect(&db.mask_for_namespace(ns))
1338                }
1339                (Some(keys), None) => NodeMask::from_keys(&*db, keys.iter().map(String::as_str)),
1340                (None, Some(ns)) => db.mask_for_namespace(ns),
1341                (None, None) => unreachable!("one of the two is Some in this branch"),
1342            };
1343            if stub_hidden {
1344                m.with_mode(MaskMode::Stub)
1345            } else {
1346                m
1347            }
1348        };
1349        return match db.query_masked(&cypher, &params, &mask) {
1350            Ok(rs) => format_query_result(rs, format),
1351            Err(GraphError::MaskedReadOnly) => (
1352                StatusCode::BAD_REQUEST,
1353                Json(json!({"error": "masked queries are read-only"})),
1354            )
1355                .into_response(),
1356            Err(e) => graph_err(e),
1357        };
1358    }
1359
1360    // Detect write statements to dispatch to the correct lock.
1361    // Write statements (CREATE / MATCH…SET / MATCH…DELETE / MERGE) need the
1362    // write lock so mutations flow through WAL + rule engine with fsync before
1363    // the response is sent.  Read queries (MATCH … RETURN …) use the read lock.
1364    let is_write = match is_write_query(&cypher) {
1365        Ok(b) => b,
1366        Err(e) => return err_response(e),
1367    };
1368
1369    if as_of.is_some() && is_write {
1370        return err_response("as_of (time-travel) queries are read-only");
1371    }
1372
1373    let rs = if is_write {
1374        let db = state.db.clone();
1375        match blocking_write(move || db.write().query_write(&cypher, &params)).await {
1376            Ok(rs) => rs,
1377            Err(resp) => return resp,
1378        }
1379    } else if let Some(commit) = as_of {
1380        match state.db.read().query_at(commit, &cypher, &params) {
1381            Ok(rs) => rs,
1382            Err(e) => return graph_err(e),
1383        }
1384    } else {
1385        match state.db.read().query(&cypher, &params) {
1386            Ok(rs) => rs,
1387            Err(e) => return graph_err(e),
1388        }
1389    };
1390
1391    format_query_result(rs, format)
1392}
1393
1394/// `GET /stats` — store-wide counts, including the `namespaces` roster.
1395///
1396/// Role tokens are denied, as they always were, and that is also what settles
1397/// the namespace roster on this surface: the roster names every namespace and
1398/// its live count, which is a list of other tenants, so narrowing it for a role
1399/// token would be a second answer to a question this route never answers for one
1400/// at all. A full-access token sees the whole roster; a tenant-scoped client gets
1401/// 403 here and asks MCP `stats` with its `role`, which narrows.
1402async fn stats(
1403    State(state): State<AppState>,
1404    Extension(identity): Extension<AuthIdentity>,
1405) -> Response {
1406    // v1: deny role tokens — raw counts leak graph size beyond the role's
1407    // subgraph, and `namespaces` would name every other tenant.
1408    if let AuthIdentity::Role(_) = identity {
1409        return forbidden("role-bound token: /stats requires a full-access token");
1410    }
1411    let snap = {
1412        let g = state.db.read();
1413        g.stats()
1414    };
1415    match serde_json::to_value(&snap) {
1416        Ok(v) => json_ok(v),
1417        Err(e) => err_response(e.to_string()),
1418    }
1419}
1420
1421async fn metrics(
1422    State(state): State<AppState>,
1423    Extension(identity): Extension<AuthIdentity>,
1424) -> Response {
1425    // Mirror /stats auth: deny role tokens — counters leak graph size.
1426    if let AuthIdentity::Role(_) = identity {
1427        return forbidden("role-bound token: /metrics requires a full-access token");
1428    }
1429    let (s, commit_seq, wal_size_bytes, slow_snap) = {
1430        let g = state.db.read();
1431        let s = g.stats();
1432        let commit_seq = g.commit_seq();
1433        let wal_size_bytes = g.wal_size_bytes().ok();
1434        let slow_snap = g.slow_query_snapshot();
1435        (s, commit_seq, wal_size_bytes, slow_snap)
1436    };
1437    let uptime_s = state.started_at.elapsed().as_secs();
1438    let slow_entries: Vec<Js> = slow_snap
1439        .last
1440        .iter()
1441        .map(|e| {
1442            json!({
1443                "ms": e.ms,
1444                "query": e.query,
1445                "at_commit": e.at_commit,
1446            })
1447        })
1448        .collect();
1449    json_ok(json!({
1450        "nodes_live": s.nodes_live,
1451        "nodes_tombstoned": s.nodes_tombstoned,
1452        "edges": s.edges,
1453        "commit_seq": commit_seq,
1454        "wal_size_bytes": wal_size_bytes,
1455        "rss_bytes": rss_bytes(),
1456        "uptime_s": uptime_s,
1457        "slow_queries": {
1458            "threshold_ms": slow_snap.threshold_ms,
1459            "count": slow_snap.count,
1460            "last": slow_entries,
1461        },
1462    }))
1463}
1464
1465/// Resident set size of the current process in bytes.
1466///
1467/// macOS: mach task_info (MACH_TASK_BASIC_INFO).
1468/// Linux: /proc/self/statm RSS pages × page size.
1469/// Other: always returns None.
1470///
1471/// Never panics; returns None on any failure.
1472fn rss_bytes() -> Option<u64> {
1473    #[cfg(target_os = "macos")]
1474    {
1475        // MACH_TASK_BASIC_INFO flavor = 20.
1476        // struct mach_task_basic_info { u64 virtual, u64 resident,
1477        //   u64 resident_max, [u32;2] user_time, [u32;2] sys_time,
1478        //   i32 policy, i32 suspend_count } = 48 bytes = 12 natural_t(u32)s.
1479        const MACH_TASK_BASIC_INFO: u32 = 20;
1480        const MACH_TASK_BASIC_INFO_COUNT: u32 = 12;
1481
1482        #[repr(C)]
1483        struct MachTaskBasicInfo {
1484            virtual_size: u64,
1485            resident_size: u64,
1486            resident_size_max: u64,
1487            user_time: [u32; 2],
1488            system_time: [u32; 2],
1489            policy: i32,
1490            suspend_count: i32,
1491        }
1492
1493        extern "C" {
1494            fn mach_task_self() -> u32;
1495            fn task_info(
1496                target_task: u32,
1497                flavor: u32,
1498                task_info_out: *mut std::ffi::c_void,
1499                task_info_cnt: *mut u32,
1500            ) -> i32;
1501        }
1502
1503        let mut info: MachTaskBasicInfo = unsafe { std::mem::zeroed() };
1504        let mut count = MACH_TASK_BASIC_INFO_COUNT;
1505        let ret = unsafe {
1506            task_info(
1507                mach_task_self(),
1508                MACH_TASK_BASIC_INFO,
1509                &mut info as *mut _ as *mut _,
1510                &mut count,
1511            )
1512        };
1513        if ret != 0 {
1514            return None; // KERN_SUCCESS = 0
1515        }
1516        return Some(info.resident_size);
1517    }
1518
1519    #[cfg(target_os = "linux")]
1520    {
1521        // /proc/self/statm: "vsize rss ..." in pages.
1522        let content = std::fs::read_to_string("/proc/self/statm").ok()?;
1523        let mut parts = content.split_whitespace();
1524        let _vsize = parts.next()?;
1525        let rss_pages: u64 = parts.next()?.parse().ok()?;
1526        let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1527        if page_size <= 0 {
1528            return None;
1529        }
1530        return Some(rss_pages * page_size as u64);
1531    }
1532
1533    #[allow(unreachable_code)]
1534    None
1535}
1536
1537async fn ingest(
1538    State(state): State<AppState>,
1539    Extension(identity): Extension<AuthIdentity>,
1540    Json(body): Json<Js>,
1541) -> Response {
1542    let label = match body.get("label").and_then(Js::as_str) {
1543        Some(s) => s.to_string(),
1544        None => return err_response("missing label"),
1545    };
1546    let rows = match body.get("rows") {
1547        Some(r) => r,
1548        None => return err_response("missing rows"),
1549    };
1550    let mut converted = match json_to_rows(rows) {
1551        Ok(c) => c,
1552        Err(e) => return graph_err(e),
1553    };
1554    let opts = match ingest_options(body.get("options")) {
1555        Ok(o) => o,
1556        Err(e) => return err_response(e),
1557    };
1558    // `namespace` applies to every node this call creates.
1559    let namespace = match namespace_arg(body.get("namespace")) {
1560        Ok(n) => n,
1561        Err(e) => return err_response(e),
1562    };
1563    if let Err(e) = stamp_namespace(&mut converted.rows, namespace.as_deref()) {
1564        return err_response(e);
1565    }
1566    let taken = std::mem::take(&mut converted.rows);
1567    let edges = match body.get("edges") {
1568        None | Some(Js::Null) => Vec::new(),
1569        Some(raw) => match parse_ingest_edges(raw) {
1570            Ok(e) => e,
1571            Err(e) => return err_response(e),
1572        },
1573    };
1574    let db = state.db.clone();
1575
1576    // Role token: route to ingest_with_edges_authz (scope + mask resolved under
1577    // the write lock, §5 discipline; §7.3 — create_labels required, enforced by
1578    // the engine's per-op authz check inside commit_logged_batch).
1579    if let AuthIdentity::Role(role_name) = &identity {
1580        let role = role_name.clone();
1581        return match blocking_write(move || {
1582            db.write()
1583                .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
1584        })
1585        .await
1586        {
1587            Ok(r) => {
1588                let report = converted.into_report(r);
1589                match serde_json::to_value(&report) {
1590                    Ok(v) => json_ok(v),
1591                    Err(e) => err_response(e.to_string()),
1592                }
1593            }
1594            Err(resp) => resp,
1595        };
1596    }
1597
1598    let report =
1599        match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
1600            .await
1601        {
1602            Ok(r) => converted.into_report(r),
1603            Err(resp) => return resp,
1604        };
1605    match serde_json::to_value(&report) {
1606        Ok(v) => json_ok(v),
1607        Err(e) => err_response(e.to_string()),
1608    }
1609}
1610
1611/// `GET /suggest` — profile the database and return rule suggestions.
1612///
1613/// # Locking and blocking strategy
1614///
1615/// `suggest_rules_with_config` is CPU-intensive and synchronous. Running it on a
1616/// Tokio worker thread would starve the executor. This handler offloads the work to
1617/// `tokio::task::spawn_blocking`, which uses the blocking thread-pool. The
1618/// `std::sync::RwLock` read guard is acquired and held inside the blocking task —
1619/// reads don't block other reads; writes wait for the guard to drop. The global
1620/// budget (`SuggestConfig::global_budget_ms`, default 5 s) caps lock-hold time.
1621async fn suggest(
1622    State(state): State<AppState>,
1623    Extension(identity): Extension<AuthIdentity>,
1624) -> Response {
1625    // v1: deny role tokens — suggest scans the full graph and would reveal
1626    // existence of nodes outside the role's mask.
1627    if let AuthIdentity::Role(_) = identity {
1628        return forbidden("role-bound token: /suggest requires a full-access token");
1629    }
1630    let db = state.db.clone();
1631    match tokio::task::spawn_blocking(move || {
1632        let config = SuggestConfig::default();
1633        db.read()
1634            .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1635    })
1636    .await
1637    {
1638        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1639        Err(_) => err_response("suggest task panicked"),
1640    }
1641}
1642
1643async fn create_rule(
1644    State(state): State<AppState>,
1645    Extension(identity): Extension<AuthIdentity>,
1646    Json(body): Json<Js>,
1647) -> Response {
1648    if let AuthIdentity::Role(_) = identity {
1649        return forbidden("role-bound token: writes are not permitted");
1650    }
1651    let def = match rule_def_from_json(body) {
1652        Ok(d) => d,
1653        Err(e) => return err_response(e),
1654    };
1655    let name = def.name.clone();
1656    let db = state.db.clone();
1657    if let Err(resp) = blocking_write(move || db.write().create_rule(def)).await {
1658        return resp;
1659    }
1660    // A corpus too large to index in one commit leaves the rule installed but
1661    // deriving nothing, so the route says "accepted", not "done", and hands
1662    // back the progress the caller can poll on `GET /stats`.
1663    //
1664    // Read under a *separate* lock from the write above, so a concurrent pump
1665    // can finish the build in between and this route then answers 200 for a
1666    // create that really did defer. Benign — 200 means "the edges are there",
1667    // which by then they are — and the caller's contract is to poll `GET /stats`
1668    // either way.
1669    let building = state
1670        .db
1671        .read()
1672        .builds_in_progress()
1673        .into_iter()
1674        .find(|b| b.rule == name);
1675    match building {
1676        Some(b) => (
1677            StatusCode::ACCEPTED,
1678            Json(json!({"rule": name, "building": {"indexed": b.indexed, "total": b.total}})),
1679        )
1680            .into_response(),
1681        None => json_ok(json!({"ok": true, "name": name})),
1682    }
1683}
1684
1685async fn explain(
1686    State(state): State<AppState>,
1687    Extension(identity): Extension<AuthIdentity>,
1688    Query(qs): Query<BTreeMap<String, String>>,
1689) -> Response {
1690    // v1: deny role tokens — explain reveals hidden-node linkage through rules.
1691    if let AuthIdentity::Role(_) = identity {
1692        return forbidden(
1693            "role-bound token: /explain requires a full-access token \
1694             (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1695        );
1696    }
1697    let a = match qs.get("a") {
1698        Some(s) if !s.is_empty() => s.clone(),
1699        _ => return err_response("missing query param a"),
1700    };
1701    let b = match qs.get("b") {
1702        Some(s) if !s.is_empty() => s.clone(),
1703        _ => return err_response("missing query param b"),
1704    };
1705    let out = {
1706        let g = state.db.read();
1707        g.explain(&a, &b)
1708    };
1709    match out {
1710        Ok(v) => match serde_json::to_value(&v) {
1711            Ok(j) => json_ok(j),
1712            Err(e) => err_response(e.to_string()),
1713        },
1714        Err(e) => graph_err(e),
1715    }
1716}
1717
1718async fn node_info(
1719    State(state): State<AppState>,
1720    Extension(identity): Extension<AuthIdentity>,
1721    Path(key): Path<String>,
1722    Query(qs): Query<BTreeMap<String, String>>,
1723) -> Response {
1724    if let AuthIdentity::Role(ref role_name) = identity {
1725        // Role-token path: hard-coded Omit mode; hidden keys are indistinguishable
1726        // from absent keys.  `stub_hidden` query param is silently ignored here —
1727        // role paths must NEVER produce stubs (RBAC invariant).
1728        let snap = state.db.reader();
1729        let role_mask = match snap.mask_for_role(role_name) {
1730            Ok(m) => m,
1731            Err(e) => return role_mask_err(e),
1732        };
1733        if !snap
1734            .resolve_key(&key)
1735            .is_some_and(|id| role_mask.contains_id(id))
1736        {
1737            return key_not_found(key);
1738        }
1739        return match snap.node_info(&key) {
1740            Some(info) => json_ok(node_info_json(&info)),
1741            None => key_not_found(key),
1742        };
1743    }
1744
1745    // Full-token path: optional client mask + stub_hidden via query params.
1746    // `mask=key1,key2` — comma-separated visible keys (empty string = no mask).
1747    // `stub_hidden=true` — opt into MaskMode::Stub for this request.
1748    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1749    if !mask_param.is_empty() {
1750        let stub_hidden = qs
1751            .get("stub_hidden")
1752            .map(|v| v == "true" || v == "1")
1753            .unwrap_or(false);
1754        let g = state.db.read();
1755        let mask = {
1756            let keys = mask_param
1757                .split(',')
1758                .map(str::trim)
1759                .filter(|s| !s.is_empty());
1760            let m = NodeMask::from_keys(&*g, keys);
1761            if stub_hidden {
1762                m.with_mode(MaskMode::Stub)
1763            } else {
1764                m
1765            }
1766        };
1767        return match g.node_info_masked(&key, &mask) {
1768            Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1769            Some(core_api::MaskedNodeResult::Restricted) => {
1770                json_ok(crate::json::stub_node_json(&key))
1771            }
1772            None => key_not_found(key),
1773        };
1774    }
1775
1776    let info = {
1777        let g = state.db.read();
1778        g.node_info(&key)
1779    };
1780    match info {
1781        Some(info) => json_ok(node_info_json(&info)),
1782        None => key_not_found(key),
1783    }
1784}
1785
1786async fn node_edges(
1787    State(state): State<AppState>,
1788    Extension(identity): Extension<AuthIdentity>,
1789    Path(key): Path<String>,
1790    Query(qs): Query<BTreeMap<String, String>>,
1791) -> Response {
1792    if let AuthIdentity::Role(ref role_name) = identity {
1793        // Role-token path: hard-coded Omit mode.  `stub_hidden` query param is
1794        // silently ignored — role paths must NEVER produce stubs (RBAC invariant).
1795        //
1796        // The subject check and the hidden-endpoint filter live in
1797        // `ReaderSnapshot::node_edges_scoped`, so every scoped caller gets them,
1798        // not only this route.
1799        let snap = state.db.reader();
1800        let role_mask = match snap.mask_for_role(role_name) {
1801            Ok(m) => m,
1802            Err(e) => return role_mask_err(e),
1803        };
1804        return match snap.node_edges_scoped(&key, &role_mask) {
1805            Ok(edges) => json_ok(node_edges_json(&edges)),
1806            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1807            Err(e) => graph_err(e),
1808        };
1809    }
1810
1811    // Full-token path: optional client mask + stub_hidden via query params.
1812    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1813    if !mask_param.is_empty() {
1814        let stub_hidden = qs
1815            .get("stub_hidden")
1816            .map(|v| v == "true" || v == "1")
1817            .unwrap_or(false);
1818        let g = state.db.read();
1819        let mask = {
1820            let keys = mask_param
1821                .split(',')
1822                .map(str::trim)
1823                .filter(|s| !s.is_empty());
1824            let m = NodeMask::from_keys(&*g, keys);
1825            if stub_hidden {
1826                m.with_mode(MaskMode::Stub)
1827            } else {
1828                m
1829            }
1830        };
1831        return match g.node_edges_masked(&key, &mask) {
1832            Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1833            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1834            Err(e) => graph_err(e),
1835        };
1836    }
1837
1838    let out = {
1839        let g = state.db.read();
1840        g.node_edges(&key)
1841    };
1842    match out {
1843        Ok(edges) => json_ok(node_edges_json(&edges)),
1844        Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1845        Err(e) => graph_err(e),
1846    }
1847}
1848
1849async fn neighborhood(
1850    State(state): State<AppState>,
1851    Extension(identity): Extension<AuthIdentity>,
1852    Path(key): Path<String>,
1853    Query(qs): Query<BTreeMap<String, String>>,
1854) -> Response {
1855    let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1856        Ok(d) => d,
1857        Err(e) => return err_response(e),
1858    };
1859    let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1860        s if s.eq_ignore_ascii_case("out") => Dir::Out,
1861        s if s.eq_ignore_ascii_case("in") => Dir::In,
1862        s if s.eq_ignore_ascii_case("both") => Dir::Both,
1863        other => return err_response(format!("unknown dir: {other}")),
1864    };
1865    let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1866        s.split(',')
1867            .map(str::trim)
1868            .filter(|t| !t.is_empty())
1869            .map(str::to_string)
1870            .collect()
1871    });
1872    let etype_refs: Option<Vec<&str>> = edge_type_names
1873        .as_ref()
1874        .map(|v| v.iter().map(String::as_str).collect());
1875    if let AuthIdentity::Role(ref role_name) = identity {
1876        // Lock-free epoch snapshot: mask and neighborhood BFS on same frozen state
1877        // (constraint 2 — RBAC mask coherence).
1878        let snap = state.db.reader();
1879        let role_mask = match snap.mask_for_role(role_name) {
1880            Ok(m) => m,
1881            Err(e) => return role_mask_err(e),
1882        };
1883        // The scoped BFS: the subject is checked first (a hidden key answers as
1884        // an absent one), and hidden nodes are excluded from results AND cannot
1885        // be used as traversal intermediaries (never-leak invariant).
1886        return match snap.neighborhood_scoped(&key, depth, etype_refs.as_deref(), dir, &role_mask) {
1887            Ok(rs) => json_ok(result_set_json(&rs)),
1888            Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1889            Err(e) => graph_err(e),
1890        };
1891    }
1892    // Full-token path: optional client mask + stub_hidden via query params.
1893    // `mask=key1,key2` — comma-separated visible keys.
1894    // `stub_hidden=true` — opt into MaskMode::Stub; hidden direct neighbours
1895    // appear as stub rows (label: null) in the result; BFS does not expand
1896    // through them in either mode.
1897    let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1898    if !mask_param.is_empty() {
1899        let stub_hidden = qs
1900            .get("stub_hidden")
1901            .map(|v| v == "true" || v == "1")
1902            .unwrap_or(false);
1903        let g = state.db.read();
1904        let mask = {
1905            let keys = mask_param
1906                .split(',')
1907                .map(str::trim)
1908                .filter(|s| !s.is_empty());
1909            let m = NodeMask::from_keys(&*g, keys);
1910            if stub_hidden {
1911                m.with_mode(MaskMode::Stub)
1912            } else {
1913                m
1914            }
1915        };
1916        return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1917            Some(rs) => json_ok(result_set_json(&rs)),
1918            None => key_not_found(key),
1919        };
1920    }
1921
1922    // Unmasked full-token path (no mask param).
1923    let rs = {
1924        let g = state.db.read();
1925        match g.node_ref(&key) {
1926            Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1927            None => Err(GraphError::KeyNotFound { key: key.clone() }),
1928        }
1929    };
1930    match rs {
1931        Ok(rs) => json_ok(result_set_json(&rs)),
1932        Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1933        Err(e) => graph_err(e),
1934    }
1935}
1936
1937/// `POST /find_similar` — vector kNN. First-class read, not `/algo/*`, so a
1938/// role token is allowed and a client `mask` intersects the role (never widens).
1939/// Default `min` is **0.0** (Python), not MCP vector-mode 0.8.
1940async fn find_similar(
1941    State(state): State<AppState>,
1942    Extension(identity): Extension<AuthIdentity>,
1943    Json(body): Json<Js>,
1944) -> Response {
1945    let field = match body.get("field").and_then(Js::as_str) {
1946        Some(s) if !s.is_empty() => s.to_string(),
1947        _ => return err_response("missing field"),
1948    };
1949    let vector = match parse_find_similar_vector(body.get("vector")) {
1950        Ok(v) => v,
1951        Err(e) => return err_response(e),
1952    };
1953    let k = match body.get("k") {
1954        None | Some(Js::Null) => 10usize,
1955        Some(v) => match v.as_u64() {
1956            Some(n) => n as usize,
1957            None => return err_response("k must be a non-negative integer"),
1958        },
1959    };
1960    let min = match body.get("min") {
1961        None | Some(Js::Null) => 0.0,
1962        Some(v) => match v.as_f64() {
1963            Some(n) => n,
1964            None => return err_response("min must be a number"),
1965        },
1966    };
1967    let label = match body.get("label").and_then(Js::as_str) {
1968        None | Some("") => None,
1969        Some(s) => Some(s.to_string()),
1970    };
1971    let mask_keys = match parse_mask_keys(body.get("mask")) {
1972        Ok(m) => m,
1973        Err(e) => return err_response(e),
1974    };
1975    let where_pred = match parse_where_body(body.get("where")) {
1976        Ok(p) => p,
1977        Err(detail) => return graph_err(GraphError::QueryError { detail }),
1978    };
1979    let exact = match body.get("exact") {
1980        None | Some(Js::Null) => false,
1981        Some(v) => match v.as_bool() {
1982            Some(b) => b,
1983            None => return err_response("exact must be a boolean"),
1984        },
1985    };
1986    let exact = exact || where_pred.is_some();
1987    let role_name = match identity {
1988        AuthIdentity::Role(r) => Some(r),
1989        AuthIdentity::Full => None,
1990        // Unreachable on this route — `Anonymous` is only ever inserted for
1991        // `GET /health` — but treated as the narrowest identity rather than
1992        // the widest, so a future open endpoint cannot inherit full access by
1993        // omission.
1994        AuthIdentity::Anonymous => {
1995            return forbidden("this endpoint requires a token");
1996        }
1997    };
1998    let db = state.db.clone();
1999    match tokio::task::spawn_blocking(move || {
2000        let g = db.read();
2001        let role_mask = match role_name.as_deref() {
2002            Some(role) => Some(g.mask_for_role(role)?),
2003            None => None,
2004        };
2005        let client_mask = mask_keys
2006            .as_ref()
2007            .map(|keys| NodeMask::from_keys(&*g, keys.iter().map(String::as_str)));
2008        let effective = match (role_mask, client_mask) {
2009            (Some(role), Some(client)) => Some(role.intersect(&client)),
2010            (Some(role), None) => Some(role),
2011            (None, Some(client)) => Some(client),
2012            (None, None) => None,
2013        };
2014        g.find_similar_vector_filtered(
2015            &field,
2016            label.as_deref(),
2017            &vector,
2018            k,
2019            min,
2020            effective.as_ref(),
2021            where_pred.as_ref(),
2022            exact,
2023        )
2024    })
2025    .await
2026    {
2027        Ok(Ok(hits)) => json_ok(json!({ "hits": hits })),
2028        Ok(Err(e)) => role_mask_err(e),
2029        Err(_) => err_response("find_similar task panicked"),
2030    }
2031}
2032
2033/// `POST /algo/pagerank` — run PageRank over the unified topology.
2034///
2035/// # Locking and blocking strategy
2036///
2037/// PageRank is CPU-intensive and synchronous. This handler offloads the work
2038/// to `tokio::task::spawn_blocking` (blocking thread-pool). The read guard is
2039/// acquired and held inside the blocking task — reads don't block other reads.
2040/// The `budget_ms` field in [`PageRankConfig`] caps lock-hold time.
2041async fn algo_pagerank(
2042    State(state): State<AppState>,
2043    Extension(identity): Extension<AuthIdentity>,
2044    Json(body): Json<serde_json::Value>,
2045) -> Response {
2046    // v1: deny role tokens — algo endpoints scan the full graph.
2047    if let AuthIdentity::Role(_) = identity {
2048        return forbidden("role-bound token: /algo/* requires a full-access token");
2049    }
2050    let config: PageRankConfig = match serde_json::from_value(body) {
2051        Ok(c) => c,
2052        Err(e) => return err_response(format!("invalid pagerank config: {e}")),
2053    };
2054    let db = state.db.clone();
2055    match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
2056        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
2057        Err(_) => err_response("pagerank task panicked"),
2058    }
2059}
2060
2061/// `POST /algo/wcc` — weakly-connected components over the unified topology.
2062///
2063/// Mirrors the `suggest` locking and blocking pattern exactly: spawn_blocking,
2064/// read guard inside, `budget_ms` in config caps lock-hold time.
2065async fn algo_wcc(
2066    State(state): State<AppState>,
2067    Extension(identity): Extension<AuthIdentity>,
2068    Json(body): Json<serde_json::Value>,
2069) -> Response {
2070    if let AuthIdentity::Role(_) = identity {
2071        return forbidden("role-bound token: /algo/* requires a full-access token");
2072    }
2073    let config: WccConfig = match serde_json::from_value(body) {
2074        Ok(c) => c,
2075        Err(e) => return err_response(format!("invalid wcc config: {e}")),
2076    };
2077    let db = state.db.clone();
2078    match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
2079        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
2080        Err(_) => err_response("wcc task panicked"),
2081    }
2082}
2083
2084/// `POST /algo/degree` — degree centrality over the unified topology.
2085///
2086/// Mirrors the `suggest` locking and blocking pattern exactly.
2087async fn algo_degree(
2088    State(state): State<AppState>,
2089    Extension(identity): Extension<AuthIdentity>,
2090    Json(body): Json<serde_json::Value>,
2091) -> Response {
2092    if let AuthIdentity::Role(_) = identity {
2093        return forbidden("role-bound token: /algo/* requires a full-access token");
2094    }
2095    let config: DegreeConfig = match serde_json::from_value(body) {
2096        Ok(c) => c,
2097        Err(e) => return err_response(format!("invalid degree config: {e}")),
2098    };
2099    let db = state.db.clone();
2100    match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
2101        Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
2102        Err(_) => err_response("degree task panicked"),
2103    }
2104}
2105
2106// ── Simple BatchOp-mapped endpoints ──────────────────────────────────────────
2107//
2108// These endpoints map 1:1 to BatchOp variants and route through submit_batch
2109// so concurrent writes share one WAL fsync per drain group, keeping reader
2110// p95 latency low under write bursts.  Auth checks (role tokens → 403) run
2111// before enqueue so RBAC enforcement is unchanged.
2112//
2113// Complex paths (/query Cypher writes, /ingest bulk JSON) stay on db.write()
2114// because they are multi-step operations that cannot be pre-expressed as a
2115// Vec<BatchOp> without redesigning the query executor.
2116
2117/// Parse a JSON object into a `Vec<(String, Value)>` prop list.
2118fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
2119    let obj = match v.as_object() {
2120        Some(o) => o,
2121        None => return Err("props must be a JSON object".into()),
2122    };
2123    let mut out = Vec::with_capacity(obj.len());
2124    for (k, val) in obj {
2125        if let Some(v) = json_to_value(val.clone()) {
2126            out.push((k.clone(), v));
2127        }
2128    }
2129    Ok(out)
2130}
2131
2132/// `POST /nodes` — create or upsert a node via the group-commit queue.
2133///
2134/// Body: `{"label": "Person", "key": "alice", "props": {"age": 30}}`
2135async fn create_node(
2136    State(state): State<AppState>,
2137    Extension(identity): Extension<AuthIdentity>,
2138    Json(body): Json<Js>,
2139) -> Response {
2140    let label = match body.get("label").and_then(Js::as_str) {
2141        Some(s) => s.to_string(),
2142        None => return err_response("missing label"),
2143    };
2144    let key = match body.get("key").and_then(Js::as_str) {
2145        Some(s) => s.to_string(),
2146        None => return err_response("missing key"),
2147    };
2148    let mut props = match body.get("props") {
2149        None | Some(Js::Null) => vec![],
2150        Some(v) => match props_from_json_obj(v) {
2151            Ok(p) => p,
2152            Err(e) => return err_response(e),
2153        },
2154    };
2155    // `namespace` is the namespace the node is created in — the same write-once
2156    // `ns` property, named on the call instead of buried in `props`.
2157    match namespace_arg(body.get("namespace")) {
2158        Ok(None) => {}
2159        Ok(Some(ns)) => {
2160            let mut row: BTreeMap<String, Value> = props.into_iter().collect();
2161            if let Err(e) = stamp_namespace_row(&mut row, &ns) {
2162                return err_response(e);
2163            }
2164            props = row.into_iter().collect();
2165        }
2166        Err(e) => return err_response(e),
2167    }
2168    let db = state.db.clone();
2169    if let AuthIdentity::Role(role_name) = &identity {
2170        let role = role_name.clone();
2171        return match blocking_write(move || {
2172            db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
2173        })
2174        .await
2175        {
2176            Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
2177            Err(resp) => resp,
2178        };
2179    }
2180    match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
2181        .await
2182    {
2183        Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
2184        Err(resp) => resp,
2185    }
2186}
2187
2188/// `DELETE /node/{key}` — delete a node via the group-commit queue.
2189async fn delete_node(
2190    State(state): State<AppState>,
2191    Extension(identity): Extension<AuthIdentity>,
2192    Path(key): Path<String>,
2193) -> Response {
2194    let db = state.db.clone();
2195    if let AuthIdentity::Role(role_name) = &identity {
2196        let role = role_name.clone();
2197        return match blocking_write(move || {
2198            db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
2199        })
2200        .await
2201        {
2202            Ok(_) => json_ok(json!({"ok": true})),
2203            Err(resp) => resp,
2204        };
2205    }
2206    match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
2207        Ok(_) => json_ok(json!({"ok": true})),
2208        Err(resp) => resp,
2209    }
2210}
2211
2212/// `POST /edges` — create an edge via the group-commit queue.
2213///
2214/// Body: `{"type": "KNOWS", "src": "alice", "dst": "bob"}`
2215async fn create_edge(
2216    State(state): State<AppState>,
2217    Extension(identity): Extension<AuthIdentity>,
2218    Json(body): Json<Js>,
2219) -> Response {
2220    let edge_type = match body.get("type").and_then(Js::as_str) {
2221        Some(s) => s.to_string(),
2222        None => return err_response("missing type"),
2223    };
2224    let src = match body.get("src").and_then(Js::as_str) {
2225        Some(s) => s.to_string(),
2226        None => return err_response("missing src"),
2227    };
2228    let dst = match body.get("dst").and_then(Js::as_str) {
2229        Some(s) => s.to_string(),
2230        None => return err_response("missing dst"),
2231    };
2232    let db = state.db.clone();
2233    if let AuthIdentity::Role(role_name) = &identity {
2234        let role = role_name.clone();
2235        return match blocking_write(move || {
2236            db.submit_batch_authz(
2237                role,
2238                vec![BatchOp::InsertEdge {
2239                    edge_type,
2240                    src_key: src,
2241                    dst_key: dst,
2242                }],
2243            )
2244        })
2245        .await
2246        {
2247            Ok(_) => json_ok(json!({"ok": true})),
2248            Err(resp) => resp,
2249        };
2250    }
2251    match blocking_write(move || {
2252        db.submit_batch(vec![BatchOp::InsertEdge {
2253            edge_type,
2254            src_key: src,
2255            dst_key: dst,
2256        }])
2257    })
2258    .await
2259    {
2260        Ok(_) => json_ok(json!({"ok": true})),
2261        Err(resp) => resp,
2262    }
2263}
2264
2265/// `DELETE /edges/{etype}/{src}/{dst}` — delete an edge via the group-commit queue.
2266async fn delete_edge(
2267    State(state): State<AppState>,
2268    Extension(identity): Extension<AuthIdentity>,
2269    Path((etype, src, dst)): Path<(String, String, String)>,
2270) -> Response {
2271    let db = state.db.clone();
2272    if let AuthIdentity::Role(role_name) = &identity {
2273        let role = role_name.clone();
2274        return match blocking_write(move || {
2275            db.submit_batch_authz(
2276                role,
2277                vec![BatchOp::DeleteEdge {
2278                    edge_type: etype,
2279                    src_key: src,
2280                    dst_key: dst,
2281                }],
2282            )
2283        })
2284        .await
2285        {
2286            Ok(_) => json_ok(json!({"ok": true})),
2287            Err(resp) => resp,
2288        };
2289    }
2290    match blocking_write(move || {
2291        db.submit_batch(vec![BatchOp::DeleteEdge {
2292            edge_type: etype,
2293            src_key: src,
2294            dst_key: dst,
2295        }])
2296    })
2297    .await
2298    {
2299        Ok(_) => json_ok(json!({"ok": true})),
2300        Err(resp) => resp,
2301    }
2302}
2303
2304/// `POST /nodes/{key}/rename` — rename a node's key via the group-commit queue.
2305///
2306/// Body: `{"new_key": "alice2"}`
2307/// Returns 404 on KeyNotFound, 409 on DuplicateKey, 200 on success.
2308async fn rename_node(
2309    State(state): State<AppState>,
2310    Extension(identity): Extension<AuthIdentity>,
2311    Path(key): Path<String>,
2312    Json(body): Json<Js>,
2313) -> Response {
2314    if let AuthIdentity::Role(_) = identity {
2315        return forbidden("role-bound token: writes are not permitted");
2316    }
2317    let new_key = match body.get("new_key").and_then(Js::as_str) {
2318        Some(s) => s.to_string(),
2319        None => return err_response("missing new_key"),
2320    };
2321    let db = state.db.clone();
2322    match tokio::task::spawn_blocking(move || {
2323        db.submit_batch(vec![BatchOp::RenameNode {
2324            old_key: key,
2325            new_key,
2326        }])
2327    })
2328    .await
2329    {
2330        Ok(Ok(_)) => json_ok(json!({"ok": true})),
2331        Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
2332        Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
2333        Ok(Err(e)) => graph_err(e),
2334        Err(_) => err_response("write task panicked"),
2335    }
2336}
2337
2338/// `POST /edges/upsert` — insert an edge, auto-creating missing endpoints.
2339///
2340/// Body: `{"edge_type":"KNOWS","src_key":"alice","dst_key":"bob","placeholder_label":"Person"}`
2341/// Returns `{"nodes_created": N, "edge_inserted": bool}`.
2342async fn upsert_edge(
2343    State(state): State<AppState>,
2344    Extension(identity): Extension<AuthIdentity>,
2345    Json(body): Json<Js>,
2346) -> Response {
2347    let edge_type = match body.get("edge_type").and_then(Js::as_str) {
2348        Some(s) => s.to_string(),
2349        None => return err_response("missing edge_type"),
2350    };
2351    let src_key = match body.get("src_key").and_then(Js::as_str) {
2352        Some(s) => s.to_string(),
2353        None => return err_response("missing src_key"),
2354    };
2355    let dst_key = match body.get("dst_key").and_then(Js::as_str) {
2356        Some(s) => s.to_string(),
2357        None => return err_response("missing dst_key"),
2358    };
2359    let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
2360        Some(s) => s.to_string(),
2361        None => return err_response("missing placeholder_label"),
2362    };
2363    let db = state.db.clone();
2364    if let AuthIdentity::Role(role_name) = &identity {
2365        let role = role_name.clone();
2366        return match blocking_write(move || {
2367            db.submit_batch_authz(
2368                role,
2369                vec![BatchOp::InsertEdgeUpsert {
2370                    edge_type,
2371                    src_key,
2372                    dst_key,
2373                    placeholder_label,
2374                }],
2375            )
2376        })
2377        .await
2378        {
2379            Ok((nodes, edges)) => json_ok(json!({
2380                "nodes_created": nodes,
2381                "edge_inserted": edges > 0,
2382            })),
2383            Err(resp) => resp,
2384        };
2385    }
2386    match blocking_write(move || {
2387        db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
2388            edge_type,
2389            src_key,
2390            dst_key,
2391            placeholder_label,
2392        }])
2393    })
2394    .await
2395    {
2396        Ok((nodes, edges)) => json_ok(json!({
2397            "nodes_created": nodes,
2398            "edge_inserted": edges > 0,
2399        })),
2400        Err(resp) => resp,
2401    }
2402}
2403
2404/// `PUT /node/{key}/prop/{field}` — set a property via the group-commit queue.
2405///
2406/// Body: `{"value": 42}`
2407async fn set_node_prop(
2408    State(state): State<AppState>,
2409    Extension(identity): Extension<AuthIdentity>,
2410    Path((key, field)): Path<(String, String)>,
2411    Json(body): Json<Js>,
2412) -> Response {
2413    let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
2414        Some(v) => v,
2415        None => {
2416            return err_response(
2417                "request body must be a JSON object with a \"value\" field, \
2418                 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
2419            )
2420        }
2421    };
2422    let db = state.db.clone();
2423    if let AuthIdentity::Role(role_name) = &identity {
2424        let role = role_name.clone();
2425        return match blocking_write(move || {
2426            db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
2427        })
2428        .await
2429        {
2430            Ok(_) => json_ok(json!({"ok": true})),
2431            Err(resp) => resp,
2432        };
2433    }
2434    match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
2435        .await
2436    {
2437        Ok(_) => json_ok(json!({"ok": true})),
2438        Err(resp) => resp,
2439    }
2440}
2441
2442// ── History endpoints ─────────────────────────────────────────────────────────
2443//
2444// These are cold-path diagnostic endpoints. They scan the on-disk WAL and
2445// must NOT extend ReaderSnapshot — they use db.read() directly per the
2446// controller ruling (see module doc and task-2-brief.md).
2447//
2448// Role masking: node visibility is checked under the SAME read guard as the
2449// history call (coherent snapshot). A node outside the role's mask responds
2450// identically to an absent node — no existence oracle.
2451
2452/// `GET /node/{key}/history` — return the WAL change history for `key`.
2453///
2454/// Response: `{ key, history: [{commit, change}], total_commits, horizon }`.
2455/// `horizon` is the oldest commit history still reaches; events before it were
2456/// pruned and are not in `history`.
2457/// Role tokens: if `key` is hidden by the role mask, responds with 404
2458/// (same shape as querying an absent key — no existence oracle).
2459async fn node_history_handler(
2460    State(state): State<AppState>,
2461    Extension(identity): Extension<AuthIdentity>,
2462    Path(key): Path<String>,
2463) -> Response {
2464    if let AuthIdentity::Role(ref role_name) = identity {
2465        let g = state.db.read();
2466        let role_mask = match g.mask_for_role(role_name) {
2467            Ok(m) => m,
2468            Err(e) => return role_mask_err(e),
2469        };
2470        // Hidden keys must respond identically to absent keys (no oracle).
2471        if !role_mask.contains_node(&*g, &key) {
2472            return key_not_found(key);
2473        }
2474        let result = match g.node_history(&key) {
2475            Ok(e) => e,
2476            Err(e) => return graph_err(e),
2477        };
2478        // Filter EdgeAdded/EdgeRemoved entries whose `other` endpoint is hidden.
2479        // A role token must not learn about hidden nodes via edge history events —
2480        // mirrors the same protection in `node_edges` (http.rs ~978-989).
2481        use core_api::HistoryChange;
2482        let visible = core_api::HistoryResult {
2483            total_commits: result.total_commits,
2484            horizon: result.horizon,
2485            items: result
2486                .items
2487                .into_iter()
2488                .filter(|entry| match &entry.change {
2489                    HistoryChange::EdgeAdded { other, .. }
2490                    | HistoryChange::EdgeRemoved { other, .. } => {
2491                        role_mask.contains_node(&*g, other)
2492                    }
2493                    _ => true,
2494                })
2495                .collect(),
2496        };
2497        return json_ok(node_history_json(&key, &visible));
2498    }
2499    // Full identity: no masking. Return 404 for absent keys (consistent with
2500    // GET /node/{key} and the Role branch above).
2501    let g = state.db.read();
2502    if !g.has_node(&key) {
2503        return key_not_found(key);
2504    }
2505    let result = match g.node_history(&key) {
2506        Ok(e) => e,
2507        Err(e) => return graph_err(e),
2508    };
2509    json_ok(node_history_json(&key, &result))
2510}
2511
2512/// `GET /history/edge?a=&b=` — return the edge lifecycle between two nodes.
2513///
2514/// Response: `{ a, b, events: [{edge_type, commit, event, rule}], total_commits, horizon }`.
2515/// `horizon` is the oldest commit history still reaches; events before it were
2516/// pruned and are not in `events`.
2517/// Role tokens: BOTH `a` AND `b` must be visible in the role mask, otherwise
2518/// responds with 404 for the first invisible key (no existence oracle).
2519async fn edge_history_handler(
2520    State(state): State<AppState>,
2521    Extension(identity): Extension<AuthIdentity>,
2522    Query(qs): Query<BTreeMap<String, String>>,
2523) -> Response {
2524    let a = match qs.get("a").filter(|s| !s.is_empty()) {
2525        Some(s) => s.clone(),
2526        None => return err_response("missing query param a"),
2527    };
2528    let b = match qs.get("b").filter(|s| !s.is_empty()) {
2529        Some(s) => s.clone(),
2530        None => return err_response("missing query param b"),
2531    };
2532    if let AuthIdentity::Role(ref role_name) = identity {
2533        let g = state.db.read();
2534        let role_mask = match g.mask_for_role(role_name) {
2535            Ok(m) => m,
2536            Err(e) => return role_mask_err(e),
2537        };
2538        // BOTH endpoints must be visible (no oracle for either).
2539        if !role_mask.contains_node(&*g, &a) {
2540            return key_not_found(a);
2541        }
2542        if !role_mask.contains_node(&*g, &b) {
2543            return key_not_found(b);
2544        }
2545        let result = match g.edge_history(&a, &b) {
2546            Ok(r) => r,
2547            Err(e) => return graph_err(e),
2548        };
2549        return json_ok(edge_history_result_json(&a, &b, &result));
2550    }
2551    // Full identity: no masking.
2552    let g = state.db.read();
2553    let result = match g.edge_history(&a, &b) {
2554        Ok(r) => r,
2555        Err(e) => return graph_err(e),
2556    };
2557    json_ok(edge_history_result_json(&a, &b, &result))
2558}
2559
2560/// `GET /history/was_linked?a=&b=&edge_type=&at_commit=` — point-in-time edge check.
2561///
2562/// Response: `{ a, b, edge_type, at_commit, linked }`.
2563/// Returns 400 (not 500) when `at_commit` is outside the visible horizon.
2564/// Role tokens: BOTH `a` AND `b` must be visible, same-as-absent otherwise.
2565async fn was_linked_handler(
2566    State(state): State<AppState>,
2567    Extension(identity): Extension<AuthIdentity>,
2568    Query(qs): Query<BTreeMap<String, String>>,
2569) -> Response {
2570    let a = match qs.get("a").filter(|s| !s.is_empty()) {
2571        Some(s) => s.clone(),
2572        None => return err_response("missing query param a"),
2573    };
2574    let b = match qs.get("b").filter(|s| !s.is_empty()) {
2575        Some(s) => s.clone(),
2576        None => return err_response("missing query param b"),
2577    };
2578    let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
2579        Some(s) => s.clone(),
2580        None => return err_response("missing query param edge_type"),
2581    };
2582    // `at_commit` takes a 0-based frame index or an RFC 3339 date. A caller
2583    // asking "were they linked on 2026-06-19" should not have to find the frame
2584    // themselves — and a guessed index is how a plausible wrong answer is
2585    // returned.
2586    let at_commit: u64 = match qs.get("at_commit") {
2587        Some(s) => match s.parse::<u64>() {
2588            Ok(n) => n,
2589            Err(_) => match state.db.read().resolve_date(s) {
2590                Ok(n) => n,
2591                Err(e) => return graph_err(e),
2592            },
2593        },
2594        None => return err_response("missing query param at_commit"),
2595    };
2596
2597    if let AuthIdentity::Role(ref role_name) = identity {
2598        let g = state.db.read();
2599        let role_mask = match g.mask_for_role(role_name) {
2600            Ok(m) => m,
2601            Err(e) => return role_mask_err(e),
2602        };
2603        if !role_mask.contains_node(&*g, &a) {
2604            return key_not_found(a);
2605        }
2606        if !role_mask.contains_node(&*g, &b) {
2607            return key_not_found(b);
2608        }
2609        return match g.was_linked(&a, &b, &edge_type, at_commit) {
2610            Ok(linked) => json_ok(json!({
2611                "a": a, "b": b, "edge_type": edge_type,
2612                "at_commit": at_commit, "linked": linked,
2613            })),
2614            Err(e @ GraphError::CommitOutOfRange { .. }) => (
2615                StatusCode::BAD_REQUEST,
2616                Json(json!({"error": e.to_string()})),
2617            )
2618                .into_response(),
2619            Err(e) => graph_err(e),
2620        };
2621    }
2622
2623    // Full identity: no masking.
2624    let g = state.db.read();
2625    match g.was_linked(&a, &b, &edge_type, at_commit) {
2626        Ok(linked) => json_ok(json!({
2627            "a": a, "b": b, "edge_type": edge_type,
2628            "at_commit": at_commit, "linked": linked,
2629        })),
2630        Err(e @ GraphError::CommitOutOfRange { .. }) => (
2631            StatusCode::BAD_REQUEST,
2632            Json(json!({"error": e.to_string()})),
2633        )
2634            .into_response(),
2635        Err(e) => graph_err(e),
2636    }
2637}
2638
2639/// `DELETE /node/{key}/prop/{field}` — remove a property via the group-commit queue.
2640async fn remove_node_prop(
2641    State(state): State<AppState>,
2642    Extension(identity): Extension<AuthIdentity>,
2643    Path((key, field)): Path<(String, String)>,
2644) -> Response {
2645    let db = state.db.clone();
2646    if let AuthIdentity::Role(role_name) = &identity {
2647        let role = role_name.clone();
2648        return match blocking_write(move || {
2649            db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
2650        })
2651        .await
2652        {
2653            Ok(_) => json_ok(json!({"ok": true})),
2654            Err(resp) => resp,
2655        };
2656    }
2657    match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
2658        Ok(_) => json_ok(json!({"ok": true})),
2659        Err(resp) => resp,
2660    }
2661}
2662
2663/// Constant-time byte-string equality, used for secret (token) comparison so
2664/// the match does not short-circuit on the first differing byte and leak the
2665/// token through response-timing. Length is compared up front (token length is
2666/// not treated as a secret); the byte loop is branch-free over equal lengths.
2667fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
2668    if a.len() != b.len() {
2669        return false;
2670    }
2671    let mut diff = 0u8;
2672    for (x, y) in a.iter().zip(b.iter()) {
2673        diff |= x ^ y;
2674    }
2675    diff == 0
2676}
2677
2678/// Maximum `depth` accepted by the neighborhood endpoint. Bounds the read-lock
2679/// hold time of a single BFS request on a dense graph (a hostile
2680/// `depth=4294967295` would otherwise traverse the whole graph under the read
2681/// guard, starving writers).
2682const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
2683
2684/// Parse `POST /find_similar` `vector` — a non-empty array of numbers.
2685fn parse_find_similar_vector(v: Option<&Js>) -> Result<Vec<f64>, String> {
2686    let Some(arr) = v.and_then(Js::as_array) else {
2687        return Err("missing vector".into());
2688    };
2689    let mut out = Vec::with_capacity(arr.len());
2690    for item in arr {
2691        match item.as_f64() {
2692            Some(n) => out.push(n),
2693            None => return Err("vector must be an array of numbers".into()),
2694        }
2695    }
2696    if out.is_empty() {
2697        return Err("vector must be a non-empty array of numbers".into());
2698    }
2699    Ok(out)
2700}
2701
2702/// Parse an optional JSON `mask` array of node keys.
2703fn parse_mask_keys(v: Option<&Js>) -> Result<Option<Vec<String>>, String> {
2704    match v {
2705        None | Some(Js::Null) => Ok(None),
2706        Some(Js::Array(arr)) => {
2707            let mut keys = Vec::with_capacity(arr.len());
2708            for item in arr {
2709                match item.as_str() {
2710                    Some(s) => keys.push(s.to_string()),
2711                    None => return Err("mask must be an array of strings".into()),
2712                }
2713            }
2714            Ok(Some(keys))
2715        }
2716        Some(_) => Err("mask must be an array of strings".into()),
2717    }
2718}
2719
2720/// Parse optional `where` as a `PropPredicate`. Invalid predicates are QueryError.
2721fn parse_where_body(v: Option<&Js>) -> Result<Option<PropPredicate>, String> {
2722    match v {
2723        None | Some(Js::Null) => Ok(None),
2724        Some(w) => {
2725            let pred: PropPredicate =
2726                serde_json::from_value(w.clone()).map_err(|e| format!("where: {e}"))?;
2727            pred.validate_named("where")?;
2728            Ok(Some(pred))
2729        }
2730    }
2731}
2732
2733/// Parse and bound the neighborhood `depth` query parameter.
2734fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
2735    match raw {
2736        None => Ok(1),
2737        Some(s) => {
2738            let d: u32 = s
2739                .parse()
2740                .map_err(|_| "depth must be an integer".to_string())?;
2741            if d > MAX_NEIGHBORHOOD_DEPTH {
2742                return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
2743            }
2744            Ok(d)
2745        }
2746    }
2747}
2748
2749/// Confine a client-supplied backup `dest` to `root`, closing the arbitrary
2750/// filesystem-write vector (a full-access token could otherwise direct the
2751/// backup writer at `/etc/...`, `/root/.ssh/...`, etc.).
2752///
2753/// Rules: reject empty; reject any `..` segment; relative paths resolve under
2754/// `root`; absolute paths must already fall within `root`. The check is lexical
2755/// (no filesystem access) and safe because `..` is rejected before joining.
2756fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
2757    if dest.is_empty() {
2758        return Err("missing or empty \"dest\" field".into());
2759    }
2760    let dest_path = std::path::Path::new(dest);
2761    if dest_path
2762        .components()
2763        .any(|c| matches!(c, std::path::Component::ParentDir))
2764    {
2765        return Err("backup \"dest\" may not contain \"..\" path segments".into());
2766    }
2767    let joined = if dest_path.is_absolute() {
2768        dest_path.to_path_buf()
2769    } else {
2770        root.join(dest_path)
2771    };
2772    if !joined.starts_with(root) {
2773        return Err(format!(
2774            "backup \"dest\" must be within the backup root ({}); \
2775             set MUSHROOMDB_BACKUP_DIR to change it",
2776            root.display()
2777        ));
2778    }
2779    Ok(joined)
2780}
2781
2782/// Directory backups are confined to: `MUSHROOMDB_BACKUP_DIR` if set, else the
2783/// server's current working directory.
2784fn backup_root() -> PathBuf {
2785    std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2786        .map(PathBuf::from)
2787        .or_else(|| std::env::current_dir().ok())
2788        .unwrap_or_else(|| PathBuf::from("."))
2789}
2790
2791/// `POST /backup` — take a consistent backup of the database to `dest`.
2792///
2793/// The read guard is held for the duration of the file copies, which is the
2794/// correct cross-process synchronisation point: the server is the single
2795/// process touching the files, so holding the read lock excludes concurrent
2796/// in-process writers.  This is the safe alternative to running the
2797/// `mushroomdb backup` CLI against a live-served store.
2798///
2799/// Request body: `{"dest": "backup-dir"}`. `dest` is confined to the backup
2800/// root (`MUSHROOMDB_BACKUP_DIR`, else the server's working directory):
2801/// relative paths resolve under it, absolute paths must fall within it, and
2802/// `..` segments are rejected. This prevents a full-access token from writing
2803/// to arbitrary filesystem paths.
2804///
2805/// Responses:
2806/// - `200 OK` — backup completed; body is a `BackupReport` JSON object.
2807/// - `500 Internal Server Error` — backup succeeded but verification failed;
2808///   body is the `BackupReport` JSON object (examine `files` and `bytes`).
2809/// - `400 Bad Request` — missing or invalid `dest`.
2810/// - `403 Forbidden` — role-bound token; this endpoint requires a full-access token.
2811async fn backup(
2812    State(state): State<AppState>,
2813    Extension(identity): Extension<AuthIdentity>,
2814    Json(body): Json<Js>,
2815) -> Response {
2816    if let AuthIdentity::Role(_) = identity {
2817        return forbidden("role-bound token: /backup requires a full-access token");
2818    }
2819    let root = backup_root();
2820    let dest = match body.get("dest").and_then(Js::as_str) {
2821        Some(s) => match confine_backup_dest(s, &root) {
2822            Ok(p) => p,
2823            Err(e) => return err_response(e),
2824        },
2825        None => return err_response("missing or empty \"dest\" field"),
2826    };
2827    let db = state.db.clone();
2828    let report: BackupReport = match tokio::task::spawn_blocking(move || {
2829        // The read guard is held inside spawn_blocking so file copies happen
2830        // with the write lock excluded.  The guard drops at end of closure.
2831        let g = db.read();
2832        g.backup_to(&dest)
2833    })
2834    .await
2835    {
2836        Ok(Ok(r)) => r,
2837        Ok(Err(e)) => return graph_err(e),
2838        Err(_) => return err_response("backup task panicked"),
2839    };
2840
2841    let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2842        Ok(v) => v,
2843        Err(e) => return err_response(e.to_string()),
2844    };
2845
2846    if report.verified {
2847        json_ok(body)
2848    } else {
2849        (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2850    }
2851}
2852
2853/// JSON-serialisable projection of [`BackupReport`].
2854#[derive(serde::Serialize)]
2855struct BackupReportJson<'a> {
2856    files: &'a [String],
2857    bytes: u64,
2858    verified: bool,
2859}
2860
2861impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2862    fn from(r: &'a BackupReport) -> Self {
2863        Self {
2864            files: &r.files,
2865            bytes: r.bytes,
2866            verified: r.verified,
2867        }
2868    }
2869}
2870
2871#[cfg(test)]
2872mod tests {
2873    use super::*;
2874    use crate::json::result_set_json;
2875    use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2876
2877    #[test]
2878    fn nan_float_cell_serializes_as_null() {
2879        let mut rs = ResultSet::new(vec!["n".into()]);
2880        rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2881        let j = result_set_json(&rs);
2882        assert_eq!(j["rows"][0][0], Js::Null);
2883    }
2884
2885    /// Verify that `POST /algo/pagerank` accepts an empty JSON body `{}` and
2886    /// applies server defaults (regression guard for `#[serde(default)]`).
2887    #[test]
2888    fn pagerank_config_empty_body_uses_defaults() {
2889        let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2890        let default = PageRankConfig::default();
2891        assert_eq!(config.damping, default.damping);
2892        assert_eq!(config.max_iters, default.max_iters);
2893        assert_eq!(config.tol, default.tol);
2894        assert_eq!(config.budget_ms, default.budget_ms);
2895        assert_eq!(config.edge_type, default.edge_type);
2896    }
2897
2898    /// Same guard for `POST /algo/wcc`.
2899    #[test]
2900    fn wcc_config_empty_body_uses_defaults() {
2901        let config: WccConfig = serde_json::from_str("{}").unwrap();
2902        let default = WccConfig::default();
2903        assert_eq!(config.budget_ms, default.budget_ms);
2904        assert_eq!(config.edge_type, default.edge_type);
2905    }
2906
2907    /// Same guard for `POST /algo/degree`.
2908    #[test]
2909    fn degree_config_empty_body_uses_defaults() {
2910        let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2911        let default = DegreeConfig::default();
2912        assert_eq!(config.budget_ms, default.budget_ms);
2913        assert_eq!(config.edge_type, default.edge_type);
2914    }
2915
2916    #[test]
2917    fn backup_dest_rejects_empty() {
2918        assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2919    }
2920
2921    #[test]
2922    fn constant_time_eq_matches_equality() {
2923        assert!(constant_time_eq(b"secret-token", b"secret-token"));
2924        assert!(constant_time_eq(b"", b""));
2925        assert!(!constant_time_eq(b"secret-token", b"secret-toke")); // shorter
2926        assert!(!constant_time_eq(b"secret-token", b"secret-tokex")); // last byte differs
2927        assert!(!constant_time_eq(b"secret-token", b"Xecret-token")); // first byte differs
2928        assert!(!constant_time_eq(b"", b"x"));
2929    }
2930
2931    #[test]
2932    fn depth_defaults_to_one_when_absent() {
2933        assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2934    }
2935
2936    #[test]
2937    fn depth_within_cap_is_accepted() {
2938        assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2939        assert_eq!(
2940            resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2941            MAX_NEIGHBORHOOD_DEPTH
2942        );
2943    }
2944
2945    #[test]
2946    fn depth_over_cap_is_rejected() {
2947        assert!(resolve_neighborhood_depth(Some("65")).is_err());
2948        assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2949    }
2950
2951    #[test]
2952    fn depth_non_integer_is_rejected() {
2953        assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2954    }
2955
2956    #[test]
2957    fn backup_dest_rejects_parent_traversal() {
2958        let root = std::path::Path::new("/srv/backups");
2959        assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2960        assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2961    }
2962
2963    #[test]
2964    fn backup_dest_rejects_absolute_outside_root() {
2965        let root = std::path::Path::new("/srv/backups");
2966        assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2967        assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2968    }
2969
2970    #[test]
2971    fn backup_dest_allows_relative_within_root() {
2972        let root = std::path::Path::new("/srv/backups");
2973        assert_eq!(
2974            confine_backup_dest("nightly", root).unwrap(),
2975            PathBuf::from("/srv/backups/nightly")
2976        );
2977        assert_eq!(
2978            confine_backup_dest("2026/aug", root).unwrap(),
2979            PathBuf::from("/srv/backups/2026/aug")
2980        );
2981    }
2982
2983    #[test]
2984    fn backup_dest_allows_absolute_within_root() {
2985        let root = std::path::Path::new("/srv/backups");
2986        assert_eq!(
2987            confine_backup_dest("/srv/backups/x", root).unwrap(),
2988            PathBuf::from("/srv/backups/x")
2989        );
2990    }
2991}