1use crate::json::{
14 edge_history_result_json, node_edges_json, node_history_json, node_info_json, params_from_json,
15 parse_ingest_edges, result_set_json, rule_def_from_json,
16};
17use crate::{AppState, AuthIdentity};
18use arrow_bridge::to_ipc_bytes;
19use axum::extract::{Extension, Path, Query, Request, State};
20use axum::http::{header, HeaderValue, Method, StatusCode};
21use axum::middleware::{self, Next};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{get, post};
24use axum::{Json, Router};
25use core_api::{
26 is_write_query, json_to_rows, json_to_value, AutoFk, BackupReport, BatchOp, DegreeConfig, Dir,
27 GraphError, IngestOptions, MaskMode, NodeMask, PageRankConfig, ResultSet, SharedDb,
28 SuggestConfig, Value, WccConfig, SUGGEST_DEFAULT_SEED,
29};
30use serde_json::{json, Value as Js};
31use std::collections::{BTreeMap, HashMap};
32use std::net::SocketAddr;
33use std::path::PathBuf;
34use tower_http::services::ServeDir;
35
36pub fn router(db: SharedDb) -> Router {
56 router_with_auth(db, None)
57}
58
59pub fn router_with_auth(db: SharedDb, token: Option<String>) -> Router {
65 build_app(
66 db,
67 token,
68 HashMap::new(),
69 UiFallback::None,
70 default_advertise_addr(),
71 false,
72 )
73}
74
75pub fn router_with_role_tokens(
81 db: SharedDb,
82 token: Option<String>,
83 role_tokens: HashMap<String, String>,
84) -> Router {
85 build_app(
86 db,
87 token,
88 role_tokens,
89 UiFallback::None,
90 default_advertise_addr(),
91 false,
92 )
93}
94
95pub fn router_with_ui(
100 db: SharedDb,
101 ui_dir: impl AsRef<std::path::Path>,
102 token: Option<String>,
103) -> Router {
104 build_app(
105 db,
106 token,
107 HashMap::new(),
108 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
109 default_advertise_addr(),
110 false,
111 )
112}
113
114pub fn router_with_ui_tls(
118 db: SharedDb,
119 ui_dir: impl AsRef<std::path::Path>,
120 token: Option<String>,
121) -> Router {
122 build_app(
123 db,
124 token,
125 HashMap::new(),
126 UiFallback::Dir(ui_dir.as_ref().to_path_buf()),
127 default_advertise_addr(),
128 true,
129 )
130}
131
132#[cfg(feature = "embed-ui")]
133static EMBEDDED_UI: include_dir::Dir<'_> =
134 include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../ui/dist");
135
136#[cfg(feature = "embed-ui")]
138pub fn router_with_embedded_ui(db: SharedDb) -> Router {
139 build_app(
140 db,
141 None,
142 HashMap::new(),
143 UiFallback::Embedded,
144 default_advertise_addr(),
145 false,
146 )
147}
148
149#[cfg(feature = "embed-ui")]
150async fn embedded_fallback(uri: axum::http::Uri) -> Response {
151 let rel = if uri.path() == "/" || uri.path().is_empty() {
152 "index.html"
153 } else {
154 uri.path().trim_start_matches('/')
155 };
156 if rel.split('/').any(|seg| seg == "..") {
157 return StatusCode::NOT_FOUND.into_response();
158 }
159 match EMBEDDED_UI.get_file(rel) {
160 Some(file) => (
161 StatusCode::OK,
162 [(header::CONTENT_TYPE, embedded_ctype(rel))],
163 file.contents(),
164 )
165 .into_response(),
166 None => StatusCode::NOT_FOUND.into_response(),
167 }
168}
169
170#[cfg(feature = "embed-ui")]
171fn embedded_ctype(path: &str) -> &'static str {
172 if path.ends_with(".html") {
173 "text/html; charset=utf-8"
174 } else if path.ends_with(".js") {
175 "application/javascript; charset=utf-8"
176 } else if path.ends_with(".css") {
177 "text/css; charset=utf-8"
178 } else if path.ends_with(".woff2") {
179 "font/woff2"
180 } else if path.ends_with(".svg") {
181 "image/svg+xml"
182 } else if path.ends_with(".ico") {
183 "image/x-icon"
184 } else if path.ends_with(".txt") {
185 "text/plain; charset=utf-8"
186 } else {
187 "application/octet-stream"
188 }
189}
190
191#[deprecated(
199 since = "0.2.0",
200 note = "Use `serve_with_role_tokens` instead; this variant silently ignores role-token configuration."
201)]
202#[doc(hidden)]
203pub async fn serve(
204 db: SharedDb,
205 addr: SocketAddr,
206 ready: tokio::sync::oneshot::Sender<SocketAddr>,
207 token: Option<String>,
208) -> std::io::Result<()> {
209 serve_inner(db, addr, ready, UiFallback::None, token, HashMap::new()).await
210}
211
212pub async fn serve_with_role_tokens(
217 db: SharedDb,
218 addr: SocketAddr,
219 ready: tokio::sync::oneshot::Sender<SocketAddr>,
220 token: Option<String>,
221 role_tokens: HashMap<String, String>,
222) -> std::io::Result<()> {
223 serve_inner(db, addr, ready, UiFallback::None, token, role_tokens).await
224}
225
226#[deprecated(
231 since = "0.2.0",
232 note = "Use `serve_with_ui_and_role_tokens` instead; this variant silently ignores role-token configuration."
233)]
234#[doc(hidden)]
235pub async fn serve_with_ui(
236 db: SharedDb,
237 addr: SocketAddr,
238 ready: tokio::sync::oneshot::Sender<SocketAddr>,
239 ui_dir: PathBuf,
240 token: Option<String>,
241) -> std::io::Result<()> {
242 serve_inner(
243 db,
244 addr,
245 ready,
246 UiFallback::Dir(ui_dir),
247 token,
248 HashMap::new(),
249 )
250 .await
251}
252
253pub async fn serve_with_ui_and_role_tokens(
255 db: SharedDb,
256 addr: SocketAddr,
257 ready: tokio::sync::oneshot::Sender<SocketAddr>,
258 ui_dir: PathBuf,
259 token: Option<String>,
260 role_tokens: HashMap<String, String>,
261) -> std::io::Result<()> {
262 serve_inner(db, addr, ready, UiFallback::Dir(ui_dir), token, role_tokens).await
263}
264
265#[cfg(feature = "embed-ui")]
270pub async fn serve_with_embedded_ui(
271 db: SharedDb,
272 addr: SocketAddr,
273 ready: tokio::sync::oneshot::Sender<SocketAddr>,
274 token: Option<String>,
275 role_tokens: HashMap<String, String>,
276) -> std::io::Result<()> {
277 serve_inner(db, addr, ready, UiFallback::Embedded, token, role_tokens).await
278}
279
280enum UiFallback {
281 None,
282 Dir(PathBuf),
283 #[cfg(feature = "embed-ui")]
284 Embedded,
285}
286
287async fn serve_inner(
288 db: SharedDb,
289 addr: SocketAddr,
290 ready: tokio::sync::oneshot::Sender<SocketAddr>,
291 ui: UiFallback,
292 token: Option<String>,
293 role_tokens: HashMap<String, String>,
294) -> std::io::Result<()> {
295 let listener = tokio::net::TcpListener::bind(addr).await?;
296 let local = listener.local_addr()?;
297 if ready.send(local).is_err() {
298 eprintln!("serve: readiness receiver dropped before bind notify");
300 }
301 let app = build_app(db, token, role_tokens, ui, local, false);
302 axum::serve(listener, app).await
303}
304
305#[cfg(feature = "tls")]
313pub async fn serve_tls(
314 db: SharedDb,
315 addr: SocketAddr,
316 ready: tokio::sync::oneshot::Sender<SocketAddr>,
317 cert_path: std::path::PathBuf,
318 key_path: std::path::PathBuf,
319 token: Option<String>,
320 role_tokens: HashMap<String, String>,
321) -> std::io::Result<()> {
322 let config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
323 .await
324 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
325 let listener = std::net::TcpListener::bind(addr)?;
326 listener.set_nonblocking(true)?;
327 let local = listener.local_addr()?;
328 if ready.send(local).is_err() {
329 eprintln!("serve_tls: readiness receiver dropped before bind notify");
330 }
331 let app = build_app(db, token, role_tokens, UiFallback::None, local, true);
332 axum_server::from_tcp_rustls(listener, config)?
333 .serve(app.into_make_service())
334 .await
335}
336
337fn default_advertise_addr() -> SocketAddr {
338 SocketAddr::from(([127, 0, 0, 1], 8080))
339}
340
341fn build_app(
342 db: SharedDb,
343 token: Option<String>,
344 role_tokens: HashMap<String, String>,
345 ui: UiFallback,
346 addr: SocketAddr,
347 tls_active: bool,
348) -> Router {
349 debug_assert!(
350 !db.read().has_event_sink(),
351 "router() must be called at most once per SharedDb; a second call \
352 replaces the sink and terminates all existing /watch subscribers \
353 with RecvError::Closed"
354 );
355 let (tx, _) = tokio::sync::broadcast::channel(1024);
356 {
357 let tx = tx.clone();
358 db.write().set_event_sink(Box::new(move |ev| {
359 let _ = tx.send(ev);
360 }));
361 }
362 let state = AppState {
363 db,
364 watch: tx,
365 token,
366 role_tokens,
367 addr,
368 tls_active,
369 };
370 let app = Router::new()
371 .route("/health", get(health))
372 .route("/query", post(query))
373 .route("/stats", get(stats))
374 .route("/ingest", post(ingest))
375 .route("/rules", post(create_rule))
376 .route("/suggest", get(suggest))
377 .route("/explain", get(explain))
378 .route("/node/{key}", get(node_info))
379 .route("/node/{key}", axum::routing::delete(delete_node))
380 .route("/node/{key}/edges", get(node_edges))
381 .route("/node/{key}/neighborhood", get(neighborhood))
382 .route("/node/{key}/history", get(node_history_handler))
383 .route("/history/edge", get(edge_history_handler))
384 .route("/history/was_linked", get(was_linked_handler))
385 .route(
386 "/node/{key}/prop/{field}",
387 axum::routing::put(set_node_prop),
388 )
389 .route(
390 "/node/{key}/prop/{field}",
391 axum::routing::delete(remove_node_prop),
392 )
393 .route("/nodes", post(create_node))
397 .route("/nodes/{key}/rename", post(rename_node))
398 .route("/edges", post(create_edge))
399 .route("/edges/upsert", post(upsert_edge))
400 .route(
401 "/edges/{etype}/{src}/{dst}",
402 axum::routing::delete(delete_edge),
403 )
404 .route("/algo/pagerank", post(algo_pagerank))
405 .route("/algo/wcc", post(algo_wcc))
406 .route("/algo/degree", post(algo_degree))
407 .route("/backup", post(backup))
408 .route("/watch", get(crate::ws::watch))
409 .route("/subscribe", get(crate::subscribe::subscribe))
410 .with_state(state.clone());
411 let app = match ui {
412 UiFallback::None => app,
413 UiFallback::Dir(dir) => app.fallback_service(ServeDir::new(dir)),
414 #[cfg(feature = "embed-ui")]
415 UiFallback::Embedded => app.fallback(embedded_fallback),
416 };
417 app.layer(middleware::from_fn_with_state(state, auth_middleware))
418 .layer(axum::extract::DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
422}
423
424const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
428
429async fn health(State(state): State<AppState>) -> Response {
430 let (nodes, edges) = {
431 let g = state.db.read();
432 let s = g.stats();
433 (s.nodes_live, s.edges)
434 };
435 json_ok(json!({
436 "ok": true,
437 "nodes": nodes,
438 "edges": edges,
439 "addr": state.addr.to_string(),
440 }))
441}
442
443async fn blocking_write<T, F>(f: F) -> std::result::Result<T, Response>
446where
447 T: Send + 'static,
448 F: FnOnce() -> core_api::Result<T> + Send + 'static,
449{
450 match tokio::task::spawn_blocking(f).await {
451 Ok(Ok(v)) => Ok(v),
452 Ok(Err(e)) => Err(graph_err(e)),
453 Err(_) => Err(err_response("write task panicked")),
454 }
455}
456
457const TOKEN_COOKIE: &str = "mushroomdb_token";
458
459async fn auth_middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response {
460 if state.token.is_none() && state.role_tokens.is_empty() {
462 req.extensions_mut().insert(AuthIdentity::Full);
463 return next.run(req).await;
464 }
465
466 if req.method() == Method::GET && req.uri().path() == "/health" {
468 req.extensions_mut().insert(AuthIdentity::Full);
469 return next.run(req).await;
470 }
471
472 let presented = request_token(&req);
473
474 if let Some(ref full_tok) = state.token.clone().filter(|s| !s.is_empty()) {
476 if presented
477 .as_deref()
478 .is_some_and(|p| constant_time_eq(p.as_bytes(), full_tok.as_bytes()))
479 {
480 let set_cookie = presented_bearer_or_query(&req).as_deref() == Some(full_tok.as_str());
481 req.extensions_mut().insert(AuthIdentity::Full);
482 let mut res = next.run(req).await;
483 if set_cookie && is_html_response(&res) {
484 attach_token_cookie(&mut res, full_tok, state.tls_active);
485 }
486 return res;
487 }
488 }
489
490 if let Some(tok) = presented.as_deref() {
492 if let Some(role_name) = state.role_tokens.get(tok) {
493 let path = req.uri().path();
499 if path == "/subscribe" || path == "/watch" {
500 return forbidden("role-bound token: this endpoint is not permitted");
501 }
502 req.extensions_mut()
503 .insert(AuthIdentity::Role(role_name.clone()));
504 return next.run(req).await;
505 }
506 }
507
508 unauthorized()
510}
511
512fn request_token(req: &Request) -> Option<String> {
513 presented_bearer_or_query(req).or_else(|| presented_cookie(req))
514}
515
516fn presented_bearer_or_query(req: &Request) -> Option<String> {
517 if let Some(header) = req
518 .headers()
519 .get(header::AUTHORIZATION)
520 .and_then(|v| v.to_str().ok())
521 {
522 if let Some(value) = bearer_token(header) {
523 return Some(value.to_string());
524 }
525 }
526 query_param(req.uri().query().unwrap_or(""), "token")
527}
528
529fn presented_cookie(req: &Request) -> Option<String> {
530 let header = req.headers().get(header::COOKIE)?.to_str().ok()?;
531 cookie_named(header, TOKEN_COOKIE).map(str::to_string)
532}
533
534fn cookie_named<'a>(header: &'a str, name: &str) -> Option<&'a str> {
535 for part in header.split(';') {
536 let part = part.trim();
537 let Some((k, v)) = part.split_once('=') else {
538 continue;
539 };
540 if k.trim() == name {
541 return Some(v.trim());
542 }
543 }
544 None
545}
546
547fn is_html_response(res: &Response) -> bool {
548 res.headers()
549 .get(header::CONTENT_TYPE)
550 .and_then(|v| v.to_str().ok())
551 .is_some_and(|ct| {
552 ct.split(';')
553 .next()
554 .unwrap_or("")
555 .trim()
556 .eq_ignore_ascii_case("text/html")
557 })
558}
559
560fn attach_token_cookie(res: &mut Response, token: &str, secure: bool) {
561 let secure_attr = if secure { "; Secure" } else { "" };
562 let value = format!("{TOKEN_COOKIE}={token}; Path=/; SameSite=Lax; HttpOnly{secure_attr}");
563 if let Ok(hv) = HeaderValue::from_str(&value) {
564 res.headers_mut().insert(header::SET_COOKIE, hv);
565 }
566}
567
568fn bearer_token(header: &str) -> Option<&str> {
569 let (scheme, value) = header.split_once(' ')?;
570 if scheme.eq_ignore_ascii_case("Bearer") {
571 Some(value.trim())
572 } else {
573 None
574 }
575}
576
577fn query_param(query: &str, key: &str) -> Option<String> {
578 for pair in query.split('&') {
579 if pair.is_empty() {
580 continue;
581 }
582 match pair.split_once('=') {
583 Some((k, v)) if k == key => return percent_decode_plus(v),
584 None if pair == key => return Some(String::new()),
585 _ => {}
586 }
587 }
588 None
589}
590
591fn percent_decode_plus(s: &str) -> Option<String> {
593 let bytes = s.as_bytes();
594 let mut out = Vec::with_capacity(bytes.len());
595 let mut i = 0;
596 while i < bytes.len() {
597 match bytes[i] {
598 b'+' => {
599 out.push(b' ');
600 i += 1;
601 }
602 b'%' => {
603 if i + 2 >= bytes.len() {
604 return None;
605 }
606 let hi = from_hex(bytes[i + 1])?;
607 let lo = from_hex(bytes[i + 2])?;
608 out.push((hi << 4) | lo);
609 i += 3;
610 }
611 c => {
612 out.push(c);
613 i += 1;
614 }
615 }
616 }
617 String::from_utf8(out).ok()
618}
619
620fn from_hex(b: u8) -> Option<u8> {
621 match b {
622 b'0'..=b'9' => Some(b - b'0'),
623 b'a'..=b'f' => Some(b - b'a' + 10),
624 b'A'..=b'F' => Some(b - b'A' + 10),
625 _ => None,
626 }
627}
628
629fn unauthorized() -> Response {
630 (
631 StatusCode::UNAUTHORIZED,
632 Json(json!({"error": "unauthorized"})),
633 )
634 .into_response()
635}
636
637fn forbidden(detail: &str) -> Response {
639 (StatusCode::FORBIDDEN, Json(json!({"error": detail}))).into_response()
640}
641
642fn role_mask_err(e: GraphError) -> Response {
650 match e {
651 GraphError::Corrupt { detail } => (
652 StatusCode::INTERNAL_SERVER_ERROR,
653 Json(json!({"error": format!("roles misconfigured: {detail}")})),
654 )
655 .into_response(),
656 GraphError::KeyNotFound { key } if key.starts_with("role:") => unauthorized(),
657 other => graph_err(other),
658 }
659}
660
661fn err_response(detail: impl Into<String>) -> Response {
662 (
663 StatusCode::BAD_REQUEST,
664 Json(json!({"error": detail.into()})),
665 )
666 .into_response()
667}
668
669fn graph_err(e: GraphError) -> Response {
670 match e {
671 GraphError::RoleWriteDenied { reason } => forbidden(&reason),
674 GraphError::QueryError { detail } | GraphError::IngestError { detail } => {
675 err_response(detail)
676 }
677 other => err_response(other.to_string()),
678 }
679}
680
681fn key_not_found(key: String) -> Response {
682 (
683 StatusCode::NOT_FOUND,
684 Json(json!({"error": GraphError::KeyNotFound { key }.to_string()})),
685 )
686 .into_response()
687}
688
689fn conflict_response(key: String) -> Response {
690 (
691 StatusCode::CONFLICT,
692 Json(json!({"error": GraphError::DuplicateKey { key }.to_string()})),
693 )
694 .into_response()
695}
696
697fn json_ok(value: Js) -> Response {
698 (StatusCode::OK, Json(value)).into_response()
699}
700
701fn ingest_options(v: Option<&Js>) -> Result<IngestOptions, String> {
702 let Some(v) = v else {
703 return Ok(IngestOptions::default());
704 };
705 if v.is_null() {
706 return Ok(IngestOptions::default());
707 }
708 let obj = v
709 .as_object()
710 .ok_or_else(|| "options must be an object".to_string())?;
711 let mut opts = IngestOptions::default();
712 if let Some(kf) = obj.get("key_field") {
713 opts.key_field = kf
714 .as_str()
715 .ok_or_else(|| "options.key_field must be a string".to_string())?
716 .to_string();
717 }
718 if let Some(fk) = obj.get("auto_fk") {
719 if fk == &Js::Bool(false) || fk.as_str() == Some("off") {
720 opts.auto_fk = AutoFk::Off;
721 } else if let Some(m) = fk.as_object() {
722 let suf = m
723 .get("suffix")
724 .and_then(Js::as_str)
725 .ok_or_else(|| "options.auto_fk.suffix must be a string".to_string())?;
726 opts.auto_fk = AutoFk::Auto {
727 suffix: suf.to_string(),
728 };
729 } else {
730 return Err("options.auto_fk must be false, \"off\", or {suffix}".into());
731 }
732 }
733 Ok(opts)
734}
735
736fn format_query_result(rs: ResultSet, format: &str) -> Response {
738 match format {
739 "" => match to_ipc_bytes(&rs) {
740 Ok(bytes) => (
741 StatusCode::OK,
742 [(header::CONTENT_TYPE, "application/vnd.apache.arrow.stream")],
743 bytes,
744 )
745 .into_response(),
746 Err(e) => err_response(e),
747 },
748 "json" => json_ok(result_set_json(&rs)),
749 other => err_response(format!("unknown format: {other}")),
750 }
751}
752
753async fn query(
754 State(state): State<AppState>,
755 Extension(identity): Extension<AuthIdentity>,
756 Query(qs): Query<BTreeMap<String, String>>,
757 Json(body): Json<Js>,
758) -> Response {
759 let cypher = match body.get("cypher").and_then(Js::as_str) {
760 Some(s) => s.to_string(),
761 None => return err_response("missing cypher"),
762 };
763 let params = match params_from_json(body.get("params")) {
764 Ok(p) => p,
765 Err(e) => return err_response(e),
766 };
767 let format = qs.get("format").map(String::as_str).unwrap_or("");
768
769 let as_of = match body.get("as_of") {
772 None | Some(Js::Null) => None,
773 Some(v) => match v.as_u64() {
774 Some(n) => Some(n),
775 None => return err_response("as_of must be a non-negative integer commit index"),
776 },
777 };
778
779 let mask_keys: Option<Vec<String>> = match body.get("mask") {
781 None | Some(Js::Null) => None,
782 Some(Js::Array(arr)) => {
783 let mut keys = Vec::with_capacity(arr.len());
784 for v in arr {
785 match v.as_str() {
786 Some(s) => keys.push(s.to_string()),
787 None => return err_response("mask must be an array of strings"),
788 }
789 }
790 Some(keys)
791 }
792 Some(_) => return err_response("mask must be an array of strings"),
793 };
794
795 if as_of.is_some() && (matches!(identity, AuthIdentity::Role(_)) || mask_keys.is_some()) {
798 return err_response(
799 "as_of (time-travel) is not yet supported with role tokens or a client mask",
800 );
801 }
802
803 if let AuthIdentity::Role(ref role_name) = identity {
807 let is_write = match is_write_query(&cypher) {
808 Ok(b) => b,
809 Err(e) => return err_response(e),
810 };
811 if is_write {
812 let role = role_name.clone();
813 let cypher_c = cypher.clone();
814 let params_c = params.clone();
815 let db = state.db.clone();
816 return match blocking_write(move || {
817 db.write().query_write_authz(&role, &cypher_c, ¶ms_c)
818 })
819 .await
820 {
821 Ok(rs) => format_query_result(rs, format),
822 Err(resp) => resp,
823 };
824 }
825 let snap = state.db.reader();
826 let role_mask = match snap.mask_for_role(role_name) {
827 Ok(m) => m,
828 Err(e) => return role_mask_err(e),
829 };
830 let effective_mask = if let Some(ref keys) = mask_keys {
831 let client_mask = NodeMask::from_ids(keys.iter().filter_map(|k| snap.resolve_key(k)));
833 role_mask.intersect(&client_mask)
834 } else {
835 role_mask
836 };
837 return match snap.query_masked(&cypher, ¶ms, &effective_mask) {
838 Ok(rs) => format_query_result(rs, format),
839 Err(GraphError::MaskedReadOnly) => (
840 StatusCode::BAD_REQUEST,
841 Json(json!({"error": "masked queries are read-only"})),
842 )
843 .into_response(),
844 Err(e) => graph_err(e),
845 };
846 }
847
848 if let Some(ref keys) = mask_keys {
858 let stub_hidden = body
859 .get("stub_hidden")
860 .and_then(|v| v.as_bool())
861 .unwrap_or(false);
862 let db = state.db.read();
863 let mask = {
864 let m = NodeMask::from_keys(&*db, keys.iter().map(String::as_str));
865 if stub_hidden {
866 m.with_mode(MaskMode::Stub)
867 } else {
868 m
869 }
870 };
871 return match db.query_masked(&cypher, ¶ms, &mask) {
872 Ok(rs) => format_query_result(rs, format),
873 Err(GraphError::MaskedReadOnly) => (
874 StatusCode::BAD_REQUEST,
875 Json(json!({"error": "masked queries are read-only"})),
876 )
877 .into_response(),
878 Err(e) => graph_err(e),
879 };
880 }
881
882 let is_write = match is_write_query(&cypher) {
887 Ok(b) => b,
888 Err(e) => return err_response(e),
889 };
890
891 if as_of.is_some() && is_write {
892 return err_response("as_of (time-travel) queries are read-only");
893 }
894
895 let rs = if is_write {
896 let db = state.db.clone();
897 match blocking_write(move || db.write().query_write(&cypher, ¶ms)).await {
898 Ok(rs) => rs,
899 Err(resp) => return resp,
900 }
901 } else if let Some(commit) = as_of {
902 match state.db.read().query_at(commit, &cypher, ¶ms) {
903 Ok(rs) => rs,
904 Err(e) => return graph_err(e),
905 }
906 } else {
907 match state.db.read().query(&cypher, ¶ms) {
908 Ok(rs) => rs,
909 Err(e) => return graph_err(e),
910 }
911 };
912
913 format_query_result(rs, format)
914}
915
916async fn stats(
917 State(state): State<AppState>,
918 Extension(identity): Extension<AuthIdentity>,
919) -> Response {
920 if let AuthIdentity::Role(_) = identity {
922 return forbidden("role-bound token: /stats requires a full-access token");
923 }
924 let snap = {
925 let g = state.db.read();
926 g.stats()
927 };
928 match serde_json::to_value(&snap) {
929 Ok(v) => json_ok(v),
930 Err(e) => err_response(e.to_string()),
931 }
932}
933
934async fn ingest(
935 State(state): State<AppState>,
936 Extension(identity): Extension<AuthIdentity>,
937 Json(body): Json<Js>,
938) -> Response {
939 let label = match body.get("label").and_then(Js::as_str) {
940 Some(s) => s.to_string(),
941 None => return err_response("missing label"),
942 };
943 let rows = match body.get("rows") {
944 Some(r) => r,
945 None => return err_response("missing rows"),
946 };
947 let mut converted = match json_to_rows(rows) {
948 Ok(c) => c,
949 Err(e) => return graph_err(e),
950 };
951 let opts = match ingest_options(body.get("options")) {
952 Ok(o) => o,
953 Err(e) => return err_response(e),
954 };
955 let taken = std::mem::take(&mut converted.rows);
956 let edges = match body.get("edges") {
957 None | Some(Js::Null) => Vec::new(),
958 Some(raw) => match parse_ingest_edges(raw) {
959 Ok(e) => e,
960 Err(e) => return err_response(e),
961 },
962 };
963 let db = state.db.clone();
964
965 if let AuthIdentity::Role(role_name) = &identity {
969 let role = role_name.clone();
970 return match blocking_write(move || {
971 db.write()
972 .ingest_with_edges_authz(&role, &label, taken, &opts, &edges)
973 })
974 .await
975 {
976 Ok(r) => {
977 let report = converted.into_report(r);
978 match serde_json::to_value(&report) {
979 Ok(v) => json_ok(v),
980 Err(e) => err_response(e.to_string()),
981 }
982 }
983 Err(resp) => resp,
984 };
985 }
986
987 let report =
988 match blocking_write(move || db.write().ingest_with_edges(&label, taken, &opts, &edges))
989 .await
990 {
991 Ok(r) => converted.into_report(r),
992 Err(resp) => return resp,
993 };
994 match serde_json::to_value(&report) {
995 Ok(v) => json_ok(v),
996 Err(e) => err_response(e.to_string()),
997 }
998}
999
1000async fn suggest(
1011 State(state): State<AppState>,
1012 Extension(identity): Extension<AuthIdentity>,
1013) -> Response {
1014 if let AuthIdentity::Role(_) = identity {
1017 return forbidden("role-bound token: /suggest requires a full-access token");
1018 }
1019 let db = state.db.clone();
1020 match tokio::task::spawn_blocking(move || {
1021 let config = SuggestConfig::default();
1022 db.read()
1023 .suggest_rules_with_config(&config, SUGGEST_DEFAULT_SEED)
1024 })
1025 .await
1026 {
1027 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1028 Err(_) => err_response("suggest task panicked"),
1029 }
1030}
1031
1032async fn create_rule(
1033 State(state): State<AppState>,
1034 Extension(identity): Extension<AuthIdentity>,
1035 Json(body): Json<Js>,
1036) -> Response {
1037 if let AuthIdentity::Role(_) = identity {
1038 return forbidden("role-bound token: writes are not permitted");
1039 }
1040 let def = match rule_def_from_json(body) {
1041 Ok(d) => d,
1042 Err(e) => return err_response(e),
1043 };
1044 let name = def.name.clone();
1045 let db = state.db.clone();
1046 match blocking_write(move || db.write().create_rule(def)).await {
1047 Ok(()) => json_ok(json!({"ok": true, "name": name})),
1048 Err(resp) => resp,
1049 }
1050}
1051
1052async fn explain(
1053 State(state): State<AppState>,
1054 Extension(identity): Extension<AuthIdentity>,
1055 Query(qs): Query<BTreeMap<String, String>>,
1056) -> Response {
1057 if let AuthIdentity::Role(_) = identity {
1059 return forbidden(
1060 "role-bound token: /explain requires a full-access token \
1061 (v1: explain may reveal hidden-node linkage; revisit when stubs land)",
1062 );
1063 }
1064 let a = match qs.get("a") {
1065 Some(s) if !s.is_empty() => s.clone(),
1066 _ => return err_response("missing query param a"),
1067 };
1068 let b = match qs.get("b") {
1069 Some(s) if !s.is_empty() => s.clone(),
1070 _ => return err_response("missing query param b"),
1071 };
1072 let out = {
1073 let g = state.db.read();
1074 g.explain(&a, &b)
1075 };
1076 match out {
1077 Ok(v) => match serde_json::to_value(&v) {
1078 Ok(j) => json_ok(j),
1079 Err(e) => err_response(e.to_string()),
1080 },
1081 Err(e) => graph_err(e),
1082 }
1083}
1084
1085async fn node_info(
1086 State(state): State<AppState>,
1087 Extension(identity): Extension<AuthIdentity>,
1088 Path(key): Path<String>,
1089 Query(qs): Query<BTreeMap<String, String>>,
1090) -> Response {
1091 if let AuthIdentity::Role(ref role_name) = identity {
1092 let snap = state.db.reader();
1096 let role_mask = match snap.mask_for_role(role_name) {
1097 Ok(m) => m,
1098 Err(e) => return role_mask_err(e),
1099 };
1100 if !snap
1101 .resolve_key(&key)
1102 .is_some_and(|id| role_mask.contains_id(id))
1103 {
1104 return key_not_found(key);
1105 }
1106 return match snap.node_info(&key) {
1107 Some(info) => json_ok(node_info_json(&info)),
1108 None => key_not_found(key),
1109 };
1110 }
1111
1112 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1116 if !mask_param.is_empty() {
1117 let stub_hidden = qs
1118 .get("stub_hidden")
1119 .map(|v| v == "true" || v == "1")
1120 .unwrap_or(false);
1121 let g = state.db.read();
1122 let mask = {
1123 let keys = mask_param
1124 .split(',')
1125 .map(str::trim)
1126 .filter(|s| !s.is_empty());
1127 let m = NodeMask::from_keys(&*g, keys);
1128 if stub_hidden {
1129 m.with_mode(MaskMode::Stub)
1130 } else {
1131 m
1132 }
1133 };
1134 return match g.node_info_masked(&key, &mask) {
1135 Some(core_api::MaskedNodeResult::Visible(info)) => json_ok(node_info_json(&info)),
1136 Some(core_api::MaskedNodeResult::Restricted) => {
1137 json_ok(crate::json::stub_node_json(&key))
1138 }
1139 None => key_not_found(key),
1140 };
1141 }
1142
1143 let info = {
1144 let g = state.db.read();
1145 g.node_info(&key)
1146 };
1147 match info {
1148 Some(info) => json_ok(node_info_json(&info)),
1149 None => key_not_found(key),
1150 }
1151}
1152
1153async fn node_edges(
1154 State(state): State<AppState>,
1155 Extension(identity): Extension<AuthIdentity>,
1156 Path(key): Path<String>,
1157 Query(qs): Query<BTreeMap<String, String>>,
1158) -> Response {
1159 if let AuthIdentity::Role(ref role_name) = identity {
1160 let snap = state.db.reader();
1163 let role_mask = match snap.mask_for_role(role_name) {
1164 Ok(m) => m,
1165 Err(e) => return role_mask_err(e),
1166 };
1167 if !snap
1168 .resolve_key(&key)
1169 .is_some_and(|id| role_mask.contains_id(id))
1170 {
1171 return key_not_found(key);
1172 }
1173 return match snap.node_edges(&key) {
1174 Ok(edges) => {
1175 let visible: Vec<_> = edges
1179 .into_iter()
1180 .filter(|e| {
1181 let other = if e.src_key == key {
1182 &e.dst_key
1183 } else {
1184 &e.src_key
1185 };
1186 snap.resolve_key(other)
1187 .is_some_and(|id| role_mask.contains_id(id))
1188 })
1189 .collect();
1190 json_ok(node_edges_json(&visible))
1191 }
1192 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1193 Err(e) => graph_err(e),
1194 };
1195 }
1196
1197 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1199 if !mask_param.is_empty() {
1200 let stub_hidden = qs
1201 .get("stub_hidden")
1202 .map(|v| v == "true" || v == "1")
1203 .unwrap_or(false);
1204 let g = state.db.read();
1205 let mask = {
1206 let keys = mask_param
1207 .split(',')
1208 .map(str::trim)
1209 .filter(|s| !s.is_empty());
1210 let m = NodeMask::from_keys(&*g, keys);
1211 if stub_hidden {
1212 m.with_mode(MaskMode::Stub)
1213 } else {
1214 m
1215 }
1216 };
1217 return match g.node_edges_masked(&key, &mask) {
1218 Ok(edges) => json_ok(crate::json::masked_edges_json(&edges)),
1219 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1220 Err(e) => graph_err(e),
1221 };
1222 }
1223
1224 let out = {
1225 let g = state.db.read();
1226 g.node_edges(&key)
1227 };
1228 match out {
1229 Ok(edges) => json_ok(node_edges_json(&edges)),
1230 Err(GraphError::KeyNotFound { key }) => key_not_found(key),
1231 Err(e) => graph_err(e),
1232 }
1233}
1234
1235async fn neighborhood(
1236 State(state): State<AppState>,
1237 Extension(identity): Extension<AuthIdentity>,
1238 Path(key): Path<String>,
1239 Query(qs): Query<BTreeMap<String, String>>,
1240) -> Response {
1241 let depth = match resolve_neighborhood_depth(qs.get("depth").map(String::as_str)) {
1242 Ok(d) => d,
1243 Err(e) => return err_response(e),
1244 };
1245 let dir = match qs.get("dir").map(String::as_str).unwrap_or("both") {
1246 s if s.eq_ignore_ascii_case("out") => Dir::Out,
1247 s if s.eq_ignore_ascii_case("in") => Dir::In,
1248 s if s.eq_ignore_ascii_case("both") => Dir::Both,
1249 other => return err_response(format!("unknown dir: {other}")),
1250 };
1251 let edge_type_names: Option<Vec<String>> = qs.get("edge_types").map(|s| {
1252 s.split(',')
1253 .map(str::trim)
1254 .filter(|t| !t.is_empty())
1255 .map(str::to_string)
1256 .collect()
1257 });
1258 let etype_refs: Option<Vec<&str>> = edge_type_names
1259 .as_ref()
1260 .map(|v| v.iter().map(String::as_str).collect());
1261 if let AuthIdentity::Role(ref role_name) = identity {
1262 let snap = state.db.reader();
1265 let role_mask = match snap.mask_for_role(role_name) {
1266 Ok(m) => m,
1267 Err(e) => return role_mask_err(e),
1268 };
1269 if !snap
1270 .resolve_key(&key)
1271 .is_some_and(|id| role_mask.contains_id(id))
1272 {
1273 return key_not_found(key);
1274 }
1275 let rs = match snap.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &role_mask)
1278 {
1279 Some(rs) => rs,
1280 None => return key_not_found(key),
1281 };
1282 return json_ok(result_set_json(&rs));
1283 }
1284 let mask_param = qs.get("mask").map(String::as_str).unwrap_or("").trim();
1290 if !mask_param.is_empty() {
1291 let stub_hidden = qs
1292 .get("stub_hidden")
1293 .map(|v| v == "true" || v == "1")
1294 .unwrap_or(false);
1295 let g = state.db.read();
1296 let mask = {
1297 let keys = mask_param
1298 .split(',')
1299 .map(str::trim)
1300 .filter(|s| !s.is_empty());
1301 let m = NodeMask::from_keys(&*g, keys);
1302 if stub_hidden {
1303 m.with_mode(MaskMode::Stub)
1304 } else {
1305 m
1306 }
1307 };
1308 return match g.neighborhood_masked(&key, depth, etype_refs.as_deref(), dir, &mask) {
1309 Some(rs) => json_ok(result_set_json(&rs)),
1310 None => graph_err(GraphError::KeyNotFound { key: key.clone() }),
1311 };
1312 }
1313
1314 let rs = {
1316 let g = state.db.read();
1317 match g.node_ref(&key) {
1318 Some(n) => Ok(n.neighborhood(depth, etype_refs.as_deref(), dir)),
1319 None => Err(GraphError::KeyNotFound { key: key.clone() }),
1320 }
1321 };
1322 match rs {
1323 Ok(rs) => json_ok(result_set_json(&rs)),
1324 Err(e) => graph_err(e),
1325 }
1326}
1327
1328async fn algo_pagerank(
1337 State(state): State<AppState>,
1338 Extension(identity): Extension<AuthIdentity>,
1339 Json(body): Json<serde_json::Value>,
1340) -> Response {
1341 if let AuthIdentity::Role(_) = identity {
1343 return forbidden("role-bound token: /algo/* requires a full-access token");
1344 }
1345 let config: PageRankConfig = match serde_json::from_value(body) {
1346 Ok(c) => c,
1347 Err(e) => return err_response(format!("invalid pagerank config: {e}")),
1348 };
1349 let db = state.db.clone();
1350 match tokio::task::spawn_blocking(move || db.read().pagerank(&config)).await {
1351 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1352 Err(_) => err_response("pagerank task panicked"),
1353 }
1354}
1355
1356async fn algo_wcc(
1361 State(state): State<AppState>,
1362 Extension(identity): Extension<AuthIdentity>,
1363 Json(body): Json<serde_json::Value>,
1364) -> Response {
1365 if let AuthIdentity::Role(_) = identity {
1366 return forbidden("role-bound token: /algo/* requires a full-access token");
1367 }
1368 let config: WccConfig = match serde_json::from_value(body) {
1369 Ok(c) => c,
1370 Err(e) => return err_response(format!("invalid wcc config: {e}")),
1371 };
1372 let db = state.db.clone();
1373 match tokio::task::spawn_blocking(move || db.read().connected_components(&config)).await {
1374 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1375 Err(_) => err_response("wcc task panicked"),
1376 }
1377}
1378
1379async fn algo_degree(
1383 State(state): State<AppState>,
1384 Extension(identity): Extension<AuthIdentity>,
1385 Json(body): Json<serde_json::Value>,
1386) -> Response {
1387 if let AuthIdentity::Role(_) = identity {
1388 return forbidden("role-bound token: /algo/* requires a full-access token");
1389 }
1390 let config: DegreeConfig = match serde_json::from_value(body) {
1391 Ok(c) => c,
1392 Err(e) => return err_response(format!("invalid degree config: {e}")),
1393 };
1394 let db = state.db.clone();
1395 match tokio::task::spawn_blocking(move || db.read().degree_centrality(&config)).await {
1396 Ok(report) => json_ok(serde_json::to_value(&report).unwrap_or_else(|_| json!({}))),
1397 Err(_) => err_response("degree task panicked"),
1398 }
1399}
1400
1401fn props_from_json_obj(v: &serde_json::Value) -> Result<Vec<(String, Value)>, String> {
1414 let obj = match v.as_object() {
1415 Some(o) => o,
1416 None => return Err("props must be a JSON object".into()),
1417 };
1418 let mut out = Vec::with_capacity(obj.len());
1419 for (k, val) in obj {
1420 if let Some(v) = json_to_value(val.clone()) {
1421 out.push((k.clone(), v));
1422 }
1423 }
1424 Ok(out)
1425}
1426
1427async fn create_node(
1431 State(state): State<AppState>,
1432 Extension(identity): Extension<AuthIdentity>,
1433 Json(body): Json<Js>,
1434) -> Response {
1435 let label = match body.get("label").and_then(Js::as_str) {
1436 Some(s) => s.to_string(),
1437 None => return err_response("missing label"),
1438 };
1439 let key = match body.get("key").and_then(Js::as_str) {
1440 Some(s) => s.to_string(),
1441 None => return err_response("missing key"),
1442 };
1443 let props = match body.get("props") {
1444 None | Some(Js::Null) => vec![],
1445 Some(v) => match props_from_json_obj(v) {
1446 Ok(p) => p,
1447 Err(e) => return err_response(e),
1448 },
1449 };
1450 let db = state.db.clone();
1451 if let AuthIdentity::Role(role_name) = &identity {
1452 let role = role_name.clone();
1453 return match blocking_write(move || {
1454 db.submit_batch_authz(role, vec![BatchOp::InsertNode { label, key, props }])
1455 })
1456 .await
1457 {
1458 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1459 Err(resp) => resp,
1460 };
1461 }
1462 match blocking_write(move || db.submit_batch(vec![BatchOp::InsertNode { label, key, props }]))
1463 .await
1464 {
1465 Ok((nodes, edges)) => json_ok(json!({"ok": true, "nodes": nodes, "edges": edges})),
1466 Err(resp) => resp,
1467 }
1468}
1469
1470async fn delete_node(
1472 State(state): State<AppState>,
1473 Extension(identity): Extension<AuthIdentity>,
1474 Path(key): Path<String>,
1475) -> Response {
1476 let db = state.db.clone();
1477 if let AuthIdentity::Role(role_name) = &identity {
1478 let role = role_name.clone();
1479 return match blocking_write(move || {
1480 db.submit_batch_authz(role, vec![BatchOp::DeleteNode { key }])
1481 })
1482 .await
1483 {
1484 Ok(_) => json_ok(json!({"ok": true})),
1485 Err(resp) => resp,
1486 };
1487 }
1488 match blocking_write(move || db.submit_batch(vec![BatchOp::DeleteNode { key }])).await {
1489 Ok(_) => json_ok(json!({"ok": true})),
1490 Err(resp) => resp,
1491 }
1492}
1493
1494async fn create_edge(
1498 State(state): State<AppState>,
1499 Extension(identity): Extension<AuthIdentity>,
1500 Json(body): Json<Js>,
1501) -> Response {
1502 let edge_type = match body.get("type").and_then(Js::as_str) {
1503 Some(s) => s.to_string(),
1504 None => return err_response("missing type"),
1505 };
1506 let src = match body.get("src").and_then(Js::as_str) {
1507 Some(s) => s.to_string(),
1508 None => return err_response("missing src"),
1509 };
1510 let dst = match body.get("dst").and_then(Js::as_str) {
1511 Some(s) => s.to_string(),
1512 None => return err_response("missing dst"),
1513 };
1514 let db = state.db.clone();
1515 if let AuthIdentity::Role(role_name) = &identity {
1516 let role = role_name.clone();
1517 return match blocking_write(move || {
1518 db.submit_batch_authz(
1519 role,
1520 vec![BatchOp::InsertEdge {
1521 edge_type,
1522 src_key: src,
1523 dst_key: dst,
1524 }],
1525 )
1526 })
1527 .await
1528 {
1529 Ok(_) => json_ok(json!({"ok": true})),
1530 Err(resp) => resp,
1531 };
1532 }
1533 match blocking_write(move || {
1534 db.submit_batch(vec![BatchOp::InsertEdge {
1535 edge_type,
1536 src_key: src,
1537 dst_key: dst,
1538 }])
1539 })
1540 .await
1541 {
1542 Ok(_) => json_ok(json!({"ok": true})),
1543 Err(resp) => resp,
1544 }
1545}
1546
1547async fn delete_edge(
1549 State(state): State<AppState>,
1550 Extension(identity): Extension<AuthIdentity>,
1551 Path((etype, src, dst)): Path<(String, String, String)>,
1552) -> Response {
1553 let db = state.db.clone();
1554 if let AuthIdentity::Role(role_name) = &identity {
1555 let role = role_name.clone();
1556 return match blocking_write(move || {
1557 db.submit_batch_authz(
1558 role,
1559 vec![BatchOp::DeleteEdge {
1560 edge_type: etype,
1561 src_key: src,
1562 dst_key: dst,
1563 }],
1564 )
1565 })
1566 .await
1567 {
1568 Ok(_) => json_ok(json!({"ok": true})),
1569 Err(resp) => resp,
1570 };
1571 }
1572 match blocking_write(move || {
1573 db.submit_batch(vec![BatchOp::DeleteEdge {
1574 edge_type: etype,
1575 src_key: src,
1576 dst_key: dst,
1577 }])
1578 })
1579 .await
1580 {
1581 Ok(_) => json_ok(json!({"ok": true})),
1582 Err(resp) => resp,
1583 }
1584}
1585
1586async fn rename_node(
1591 State(state): State<AppState>,
1592 Extension(identity): Extension<AuthIdentity>,
1593 Path(key): Path<String>,
1594 Json(body): Json<Js>,
1595) -> Response {
1596 if let AuthIdentity::Role(_) = identity {
1597 return forbidden("role-bound token: writes are not permitted");
1598 }
1599 let new_key = match body.get("new_key").and_then(Js::as_str) {
1600 Some(s) => s.to_string(),
1601 None => return err_response("missing new_key"),
1602 };
1603 let db = state.db.clone();
1604 match tokio::task::spawn_blocking(move || {
1605 db.submit_batch(vec![BatchOp::RenameNode {
1606 old_key: key,
1607 new_key,
1608 }])
1609 })
1610 .await
1611 {
1612 Ok(Ok(_)) => json_ok(json!({"ok": true})),
1613 Ok(Err(GraphError::KeyNotFound { key })) => key_not_found(key),
1614 Ok(Err(GraphError::DuplicateKey { key })) => conflict_response(key),
1615 Ok(Err(e)) => graph_err(e),
1616 Err(_) => err_response("write task panicked"),
1617 }
1618}
1619
1620async fn upsert_edge(
1625 State(state): State<AppState>,
1626 Extension(identity): Extension<AuthIdentity>,
1627 Json(body): Json<Js>,
1628) -> Response {
1629 let edge_type = match body.get("edge_type").and_then(Js::as_str) {
1630 Some(s) => s.to_string(),
1631 None => return err_response("missing edge_type"),
1632 };
1633 let src_key = match body.get("src_key").and_then(Js::as_str) {
1634 Some(s) => s.to_string(),
1635 None => return err_response("missing src_key"),
1636 };
1637 let dst_key = match body.get("dst_key").and_then(Js::as_str) {
1638 Some(s) => s.to_string(),
1639 None => return err_response("missing dst_key"),
1640 };
1641 let placeholder_label = match body.get("placeholder_label").and_then(Js::as_str) {
1642 Some(s) => s.to_string(),
1643 None => return err_response("missing placeholder_label"),
1644 };
1645 let db = state.db.clone();
1646 if let AuthIdentity::Role(role_name) = &identity {
1647 let role = role_name.clone();
1648 return match blocking_write(move || {
1649 db.submit_batch_authz(
1650 role,
1651 vec![BatchOp::InsertEdgeUpsert {
1652 edge_type,
1653 src_key,
1654 dst_key,
1655 placeholder_label,
1656 }],
1657 )
1658 })
1659 .await
1660 {
1661 Ok((nodes, edges)) => json_ok(json!({
1662 "nodes_created": nodes,
1663 "edge_inserted": edges > 0,
1664 })),
1665 Err(resp) => resp,
1666 };
1667 }
1668 match blocking_write(move || {
1669 db.submit_batch(vec![BatchOp::InsertEdgeUpsert {
1670 edge_type,
1671 src_key,
1672 dst_key,
1673 placeholder_label,
1674 }])
1675 })
1676 .await
1677 {
1678 Ok((nodes, edges)) => json_ok(json!({
1679 "nodes_created": nodes,
1680 "edge_inserted": edges > 0,
1681 })),
1682 Err(resp) => resp,
1683 }
1684}
1685
1686async fn set_node_prop(
1690 State(state): State<AppState>,
1691 Extension(identity): Extension<AuthIdentity>,
1692 Path((key, field)): Path<(String, String)>,
1693 Json(body): Json<Js>,
1694) -> Response {
1695 let value = match body.get("value").and_then(|v| json_to_value(v.clone())) {
1696 Some(v) => v,
1697 None => {
1698 return err_response(
1699 "request body must be a JSON object with a \"value\" field, \
1700 e.g. {\"value\": \"SanFrancisco\"} or {\"value\": [\"a\", \"b\"]}",
1701 )
1702 }
1703 };
1704 let db = state.db.clone();
1705 if let AuthIdentity::Role(role_name) = &identity {
1706 let role = role_name.clone();
1707 return match blocking_write(move || {
1708 db.submit_batch_authz(role, vec![BatchOp::SetProp { key, field, value }])
1709 })
1710 .await
1711 {
1712 Ok(_) => json_ok(json!({"ok": true})),
1713 Err(resp) => resp,
1714 };
1715 }
1716 match blocking_write(move || db.submit_batch(vec![BatchOp::SetProp { key, field, value }]))
1717 .await
1718 {
1719 Ok(_) => json_ok(json!({"ok": true})),
1720 Err(resp) => resp,
1721 }
1722}
1723
1724async fn node_history_handler(
1740 State(state): State<AppState>,
1741 Extension(identity): Extension<AuthIdentity>,
1742 Path(key): Path<String>,
1743) -> Response {
1744 if let AuthIdentity::Role(ref role_name) = identity {
1745 let g = state.db.read();
1746 let role_mask = match g.mask_for_role(role_name) {
1747 Ok(m) => m,
1748 Err(e) => return role_mask_err(e),
1749 };
1750 if !role_mask.contains_node(&*g, &key) {
1752 return key_not_found(key);
1753 }
1754 let entries = match g.node_history(&key) {
1755 Ok(e) => e,
1756 Err(e) => return graph_err(e),
1757 };
1758 let total_commits = match g.wal_total_commits() {
1759 Ok(n) => n,
1760 Err(e) => return graph_err(e),
1761 };
1762 use core_api::HistoryChange;
1766 let visible: Vec<_> = entries
1767 .into_iter()
1768 .filter(|entry| match &entry.change {
1769 HistoryChange::EdgeAdded { other, .. }
1770 | HistoryChange::EdgeRemoved { other, .. } => role_mask.contains_node(&*g, other),
1771 _ => true,
1772 })
1773 .collect();
1774 return json_ok(node_history_json(&key, &visible, total_commits));
1775 }
1776 let g = state.db.read();
1779 if !g.has_node(&key) {
1780 return key_not_found(key);
1781 }
1782 let entries = match g.node_history(&key) {
1783 Ok(e) => e,
1784 Err(e) => return graph_err(e),
1785 };
1786 let total_commits = match g.wal_total_commits() {
1787 Ok(n) => n,
1788 Err(e) => return graph_err(e),
1789 };
1790 json_ok(node_history_json(&key, &entries, total_commits))
1791}
1792
1793async fn edge_history_handler(
1799 State(state): State<AppState>,
1800 Extension(identity): Extension<AuthIdentity>,
1801 Query(qs): Query<BTreeMap<String, String>>,
1802) -> Response {
1803 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1804 Some(s) => s.clone(),
1805 None => return err_response("missing query param a"),
1806 };
1807 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1808 Some(s) => s.clone(),
1809 None => return err_response("missing query param b"),
1810 };
1811 if let AuthIdentity::Role(ref role_name) = identity {
1812 let g = state.db.read();
1813 let role_mask = match g.mask_for_role(role_name) {
1814 Ok(m) => m,
1815 Err(e) => return role_mask_err(e),
1816 };
1817 if !role_mask.contains_node(&*g, &a) {
1819 return key_not_found(a);
1820 }
1821 if !role_mask.contains_node(&*g, &b) {
1822 return key_not_found(b);
1823 }
1824 let result = match g.edge_history(&a, &b) {
1825 Ok(r) => r,
1826 Err(e) => return graph_err(e),
1827 };
1828 return json_ok(edge_history_result_json(&a, &b, &result));
1829 }
1830 let g = state.db.read();
1832 let result = match g.edge_history(&a, &b) {
1833 Ok(r) => r,
1834 Err(e) => return graph_err(e),
1835 };
1836 json_ok(edge_history_result_json(&a, &b, &result))
1837}
1838
1839async fn was_linked_handler(
1845 State(state): State<AppState>,
1846 Extension(identity): Extension<AuthIdentity>,
1847 Query(qs): Query<BTreeMap<String, String>>,
1848) -> Response {
1849 let a = match qs.get("a").filter(|s| !s.is_empty()) {
1850 Some(s) => s.clone(),
1851 None => return err_response("missing query param a"),
1852 };
1853 let b = match qs.get("b").filter(|s| !s.is_empty()) {
1854 Some(s) => s.clone(),
1855 None => return err_response("missing query param b"),
1856 };
1857 let edge_type = match qs.get("edge_type").filter(|s| !s.is_empty()) {
1858 Some(s) => s.clone(),
1859 None => return err_response("missing query param edge_type"),
1860 };
1861 let at_commit: u64 = match qs.get("at_commit") {
1862 Some(s) => match s.parse() {
1863 Ok(n) => n,
1864 Err(_) => return err_response("at_commit must be a non-negative integer"),
1865 },
1866 None => return err_response("missing query param at_commit"),
1867 };
1868
1869 if let AuthIdentity::Role(ref role_name) = identity {
1870 let g = state.db.read();
1871 let role_mask = match g.mask_for_role(role_name) {
1872 Ok(m) => m,
1873 Err(e) => return role_mask_err(e),
1874 };
1875 if !role_mask.contains_node(&*g, &a) {
1876 return key_not_found(a);
1877 }
1878 if !role_mask.contains_node(&*g, &b) {
1879 return key_not_found(b);
1880 }
1881 return match g.was_linked(&a, &b, &edge_type, at_commit) {
1882 Ok(linked) => json_ok(json!({
1883 "a": a, "b": b, "edge_type": edge_type,
1884 "at_commit": at_commit, "linked": linked,
1885 })),
1886 Err(GraphError::CommitOutOfRange { .. }) => (
1887 StatusCode::BAD_REQUEST,
1888 Json(json!({"error": format!("commit {at_commit} is out of range")})),
1889 )
1890 .into_response(),
1891 Err(e) => graph_err(e),
1892 };
1893 }
1894
1895 let g = state.db.read();
1897 match g.was_linked(&a, &b, &edge_type, at_commit) {
1898 Ok(linked) => json_ok(json!({
1899 "a": a, "b": b, "edge_type": edge_type,
1900 "at_commit": at_commit, "linked": linked,
1901 })),
1902 Err(GraphError::CommitOutOfRange { .. }) => (
1903 StatusCode::BAD_REQUEST,
1904 Json(json!({"error": format!("commit {at_commit} is out of range")})),
1905 )
1906 .into_response(),
1907 Err(e) => graph_err(e),
1908 }
1909}
1910
1911async fn remove_node_prop(
1913 State(state): State<AppState>,
1914 Extension(identity): Extension<AuthIdentity>,
1915 Path((key, field)): Path<(String, String)>,
1916) -> Response {
1917 let db = state.db.clone();
1918 if let AuthIdentity::Role(role_name) = &identity {
1919 let role = role_name.clone();
1920 return match blocking_write(move || {
1921 db.submit_batch_authz(role, vec![BatchOp::RemoveProp { key, field }])
1922 })
1923 .await
1924 {
1925 Ok(_) => json_ok(json!({"ok": true})),
1926 Err(resp) => resp,
1927 };
1928 }
1929 match blocking_write(move || db.submit_batch(vec![BatchOp::RemoveProp { key, field }])).await {
1930 Ok(_) => json_ok(json!({"ok": true})),
1931 Err(resp) => resp,
1932 }
1933}
1934
1935fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1940 if a.len() != b.len() {
1941 return false;
1942 }
1943 let mut diff = 0u8;
1944 for (x, y) in a.iter().zip(b.iter()) {
1945 diff |= x ^ y;
1946 }
1947 diff == 0
1948}
1949
1950const MAX_NEIGHBORHOOD_DEPTH: u32 = 64;
1955
1956fn resolve_neighborhood_depth(raw: Option<&str>) -> Result<u32, String> {
1958 match raw {
1959 None => Ok(1),
1960 Some(s) => {
1961 let d: u32 = s
1962 .parse()
1963 .map_err(|_| "depth must be an integer".to_string())?;
1964 if d > MAX_NEIGHBORHOOD_DEPTH {
1965 return Err(format!("depth must be ≤ {MAX_NEIGHBORHOOD_DEPTH}"));
1966 }
1967 Ok(d)
1968 }
1969 }
1970}
1971
1972fn confine_backup_dest(dest: &str, root: &std::path::Path) -> Result<PathBuf, String> {
1980 if dest.is_empty() {
1981 return Err("missing or empty \"dest\" field".into());
1982 }
1983 let dest_path = std::path::Path::new(dest);
1984 if dest_path
1985 .components()
1986 .any(|c| matches!(c, std::path::Component::ParentDir))
1987 {
1988 return Err("backup \"dest\" may not contain \"..\" path segments".into());
1989 }
1990 let joined = if dest_path.is_absolute() {
1991 dest_path.to_path_buf()
1992 } else {
1993 root.join(dest_path)
1994 };
1995 if !joined.starts_with(root) {
1996 return Err(format!(
1997 "backup \"dest\" must be within the backup root ({}); \
1998 set MUSHROOMDB_BACKUP_DIR to change it",
1999 root.display()
2000 ));
2001 }
2002 Ok(joined)
2003}
2004
2005fn backup_root() -> PathBuf {
2008 std::env::var_os("MUSHROOMDB_BACKUP_DIR")
2009 .map(PathBuf::from)
2010 .or_else(|| std::env::current_dir().ok())
2011 .unwrap_or_else(|| PathBuf::from("."))
2012}
2013
2014async fn backup(
2035 State(state): State<AppState>,
2036 Extension(identity): Extension<AuthIdentity>,
2037 Json(body): Json<Js>,
2038) -> Response {
2039 if let AuthIdentity::Role(_) = identity {
2040 return forbidden("role-bound token: /backup requires a full-access token");
2041 }
2042 let root = backup_root();
2043 let dest = match body.get("dest").and_then(Js::as_str) {
2044 Some(s) => match confine_backup_dest(s, &root) {
2045 Ok(p) => p,
2046 Err(e) => return err_response(e),
2047 },
2048 None => return err_response("missing or empty \"dest\" field"),
2049 };
2050 let db = state.db.clone();
2051 let report: BackupReport = match tokio::task::spawn_blocking(move || {
2052 let g = db.read();
2055 g.backup_to(&dest)
2056 })
2057 .await
2058 {
2059 Ok(Ok(r)) => r,
2060 Ok(Err(e)) => return graph_err(e),
2061 Err(_) => return err_response("backup task panicked"),
2062 };
2063
2064 let body = match serde_json::to_value(BackupReportJson::from(&report)) {
2065 Ok(v) => v,
2066 Err(e) => return err_response(e.to_string()),
2067 };
2068
2069 if report.verified {
2070 json_ok(body)
2071 } else {
2072 (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response()
2073 }
2074}
2075
2076#[derive(serde::Serialize)]
2078struct BackupReportJson<'a> {
2079 files: &'a [String],
2080 bytes: u64,
2081 verified: bool,
2082}
2083
2084impl<'a> From<&'a BackupReport> for BackupReportJson<'a> {
2085 fn from(r: &'a BackupReport) -> Self {
2086 Self {
2087 files: &r.files,
2088 bytes: r.bytes,
2089 verified: r.verified,
2090 }
2091 }
2092}
2093
2094#[cfg(test)]
2095mod tests {
2096 use super::*;
2097 use crate::json::result_set_json;
2098 use core_api::{DegreeConfig, PageRankConfig, ResultSet, Value, WccConfig};
2099
2100 #[test]
2101 fn nan_float_cell_serializes_as_null() {
2102 let mut rs = ResultSet::new(vec!["n".into()]);
2103 rs.push_row(vec![Some(Value::Float(f64::NAN))]);
2104 let j = result_set_json(&rs);
2105 assert_eq!(j["rows"][0][0], Js::Null);
2106 }
2107
2108 #[test]
2111 fn pagerank_config_empty_body_uses_defaults() {
2112 let config: PageRankConfig = serde_json::from_str("{}").unwrap();
2113 let default = PageRankConfig::default();
2114 assert_eq!(config.damping, default.damping);
2115 assert_eq!(config.max_iters, default.max_iters);
2116 assert_eq!(config.tol, default.tol);
2117 assert_eq!(config.budget_ms, default.budget_ms);
2118 assert_eq!(config.edge_type, default.edge_type);
2119 }
2120
2121 #[test]
2123 fn wcc_config_empty_body_uses_defaults() {
2124 let config: WccConfig = serde_json::from_str("{}").unwrap();
2125 let default = WccConfig::default();
2126 assert_eq!(config.budget_ms, default.budget_ms);
2127 assert_eq!(config.edge_type, default.edge_type);
2128 }
2129
2130 #[test]
2132 fn degree_config_empty_body_uses_defaults() {
2133 let config: DegreeConfig = serde_json::from_str("{}").unwrap();
2134 let default = DegreeConfig::default();
2135 assert_eq!(config.budget_ms, default.budget_ms);
2136 assert_eq!(config.edge_type, default.edge_type);
2137 }
2138
2139 #[test]
2140 fn backup_dest_rejects_empty() {
2141 assert!(confine_backup_dest("", std::path::Path::new("/srv/backups")).is_err());
2142 }
2143
2144 #[test]
2145 fn constant_time_eq_matches_equality() {
2146 assert!(constant_time_eq(b"secret-token", b"secret-token"));
2147 assert!(constant_time_eq(b"", b""));
2148 assert!(!constant_time_eq(b"secret-token", b"secret-toke")); assert!(!constant_time_eq(b"secret-token", b"secret-tokex")); assert!(!constant_time_eq(b"secret-token", b"Xecret-token")); assert!(!constant_time_eq(b"", b"x"));
2152 }
2153
2154 #[test]
2155 fn depth_defaults_to_one_when_absent() {
2156 assert_eq!(resolve_neighborhood_depth(None).unwrap(), 1);
2157 }
2158
2159 #[test]
2160 fn depth_within_cap_is_accepted() {
2161 assert_eq!(resolve_neighborhood_depth(Some("10")).unwrap(), 10);
2162 assert_eq!(
2163 resolve_neighborhood_depth(Some(&MAX_NEIGHBORHOOD_DEPTH.to_string())).unwrap(),
2164 MAX_NEIGHBORHOOD_DEPTH
2165 );
2166 }
2167
2168 #[test]
2169 fn depth_over_cap_is_rejected() {
2170 assert!(resolve_neighborhood_depth(Some("65")).is_err());
2171 assert!(resolve_neighborhood_depth(Some("4294967295")).is_err());
2172 }
2173
2174 #[test]
2175 fn depth_non_integer_is_rejected() {
2176 assert!(resolve_neighborhood_depth(Some("abc")).is_err());
2177 }
2178
2179 #[test]
2180 fn backup_dest_rejects_parent_traversal() {
2181 let root = std::path::Path::new("/srv/backups");
2182 assert!(confine_backup_dest("../../etc/cron.d", root).is_err());
2183 assert!(confine_backup_dest("ok/../../../etc", root).is_err());
2184 }
2185
2186 #[test]
2187 fn backup_dest_rejects_absolute_outside_root() {
2188 let root = std::path::Path::new("/srv/backups");
2189 assert!(confine_backup_dest("/etc/cron.d", root).is_err());
2190 assert!(confine_backup_dest("/root/.ssh/authorized_keys", root).is_err());
2191 }
2192
2193 #[test]
2194 fn backup_dest_allows_relative_within_root() {
2195 let root = std::path::Path::new("/srv/backups");
2196 assert_eq!(
2197 confine_backup_dest("nightly", root).unwrap(),
2198 PathBuf::from("/srv/backups/nightly")
2199 );
2200 assert_eq!(
2201 confine_backup_dest("2026/aug", root).unwrap(),
2202 PathBuf::from("/srv/backups/2026/aug")
2203 );
2204 }
2205
2206 #[test]
2207 fn backup_dest_allows_absolute_within_root() {
2208 let root = std::path::Path::new("/srv/backups");
2209 assert_eq!(
2210 confine_backup_dest("/srv/backups/x", root).unwrap(),
2211 PathBuf::from("/srv/backups/x")
2212 );
2213 }
2214}