Skip to main content

tuitbot_server/routes/vault/
mod.rs

1//! Vault API endpoints for searching notes, previewing fragments,
2//! and resolving selected references from the dashboard.
3//!
4//! All endpoints are account-scoped via `AccountContext` and return
5//! privacy-safe responses (no raw note bodies — only titles, paths,
6//! tags, heading paths, and truncated snippets).
7
8pub mod selections;
9
10use std::sync::Arc;
11
12use axum::extract::{Path, Query, State};
13use axum::Json;
14use serde::{Deserialize, Serialize};
15
16use tuitbot_core::context::graph_expansion::{self, GraphState};
17use tuitbot_core::context::retrieval::{self, VaultCitation};
18use tuitbot_core::storage::watchtower;
19
20use crate::account::AccountContext;
21use crate::error::ApiError;
22use crate::state::AppState;
23
24/// Maximum snippet length returned in API responses (characters).
25const SNIPPET_MAX_LEN: usize = 120;
26
27/// Default result limit for search endpoints.
28const DEFAULT_LIMIT: u32 = 20;
29
30/// Maximum result limit for search endpoints.
31const MAX_LIMIT: u32 = 100;
32
33fn clamp_limit(limit: Option<u32>) -> u32 {
34    limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT)
35}
36
37fn truncate_snippet(text: &str, max_len: usize) -> String {
38    if text.len() <= max_len {
39        text.to_string()
40    } else {
41        let mut end = max_len.saturating_sub(3);
42        while end > 0 && !text.is_char_boundary(end) {
43            end -= 1;
44        }
45        format!("{}...", &text[..end])
46    }
47}
48
49// ---------------------------------------------------------------------------
50// GET /api/vault/sources
51// ---------------------------------------------------------------------------
52
53#[derive(Serialize)]
54pub struct VaultSourcesResponse {
55    pub sources: Vec<VaultSourceStatusItem>,
56    pub deployment_mode: String,
57    pub privacy_envelope: String,
58}
59
60#[derive(Serialize)]
61pub struct VaultSourceStatusItem {
62    pub id: i64,
63    pub source_type: String,
64    pub status: String,
65    pub error_message: Option<String>,
66    pub node_count: i64,
67    pub updated_at: String,
68    /// For `local_fs` sources, the configured vault path.  Used by the
69    /// desktop frontend to construct `obsidian://` deep-link URIs.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub path: Option<String>,
72}
73
74pub async fn vault_sources(
75    State(state): State<Arc<AppState>>,
76    ctx: AccountContext,
77) -> Result<Json<VaultSourcesResponse>, ApiError> {
78    let sources = watchtower::get_all_source_contexts_for(&state.db, &ctx.account_id).await?;
79
80    let is_cloud = matches!(
81        state.deployment_mode,
82        tuitbot_core::config::DeploymentMode::Cloud
83    );
84
85    let mut items = Vec::with_capacity(sources.len());
86    for src in sources {
87        let count = watchtower::count_nodes_for_source(&state.db, &ctx.account_id, src.id)
88            .await
89            .unwrap_or(0);
90        // Only expose local path for non-Cloud modes (defense in depth).
91        let path = if src.source_type == "local_fs" && !is_cloud {
92            serde_json::from_str::<serde_json::Value>(&src.config_json)
93                .ok()
94                .and_then(|v| v.get("path").and_then(|p| p.as_str().map(String::from)))
95        } else {
96            None
97        };
98        items.push(VaultSourceStatusItem {
99            id: src.id,
100            source_type: src.source_type,
101            status: src.status,
102            error_message: src.error_message,
103            node_count: count,
104            updated_at: src.updated_at,
105            path,
106        });
107    }
108
109    Ok(Json(VaultSourcesResponse {
110        sources: items,
111        deployment_mode: state.deployment_mode.to_string(),
112        privacy_envelope: state.deployment_mode.privacy_envelope().to_string(),
113    }))
114}
115
116// ---------------------------------------------------------------------------
117// GET /api/vault/notes?q=&source_id=&limit=
118// ---------------------------------------------------------------------------
119
120#[derive(Deserialize)]
121pub struct SearchNotesQuery {
122    pub q: Option<String>,
123    pub source_id: Option<i64>,
124    pub limit: Option<u32>,
125}
126
127#[derive(Serialize)]
128pub struct SearchNotesResponse {
129    pub notes: Vec<VaultNoteItem>,
130}
131
132#[derive(Serialize)]
133pub struct VaultNoteItem {
134    pub node_id: i64,
135    pub source_id: i64,
136    pub title: Option<String>,
137    pub relative_path: String,
138    pub tags: Option<String>,
139    pub status: String,
140    pub chunk_count: i64,
141    pub updated_at: String,
142}
143
144pub async fn search_notes(
145    State(state): State<Arc<AppState>>,
146    ctx: AccountContext,
147    Query(params): Query<SearchNotesQuery>,
148) -> Result<Json<SearchNotesResponse>, ApiError> {
149    let limit = clamp_limit(params.limit);
150
151    let nodes = match (&params.q, params.source_id) {
152        (Some(q), _) if !q.is_empty() => {
153            watchtower::search_nodes_for(&state.db, &ctx.account_id, q, limit).await?
154        }
155        (_, Some(sid)) => {
156            watchtower::get_nodes_for_source_for(&state.db, &ctx.account_id, sid, limit).await?
157        }
158        _ => {
159            // No query and no source_id — return recent nodes.
160            watchtower::search_nodes_for(&state.db, &ctx.account_id, "", limit).await?
161        }
162    };
163
164    let mut notes = Vec::with_capacity(nodes.len());
165    for node in nodes {
166        let chunk_count =
167            watchtower::count_chunks_for_node(&state.db, &ctx.account_id, node.id).await?;
168        notes.push(VaultNoteItem {
169            node_id: node.id,
170            source_id: node.source_id,
171            title: node.title,
172            relative_path: node.relative_path,
173            tags: node.tags,
174            status: node.status,
175            chunk_count,
176            updated_at: node.updated_at,
177        });
178    }
179
180    Ok(Json(SearchNotesResponse { notes }))
181}
182
183// ---------------------------------------------------------------------------
184// GET /api/vault/notes/{id}
185// ---------------------------------------------------------------------------
186
187#[derive(Serialize)]
188pub struct VaultNoteDetail {
189    pub node_id: i64,
190    pub source_id: i64,
191    pub title: Option<String>,
192    pub relative_path: String,
193    pub tags: Option<String>,
194    pub status: String,
195    pub ingested_at: String,
196    pub updated_at: String,
197    pub chunks: Vec<VaultChunkSummary>,
198}
199
200#[derive(Serialize)]
201pub struct VaultChunkSummary {
202    pub chunk_id: i64,
203    pub heading_path: String,
204    pub snippet: String,
205    pub retrieval_boost: f64,
206}
207
208pub async fn note_detail(
209    State(state): State<Arc<AppState>>,
210    ctx: AccountContext,
211    Path(id): Path<i64>,
212) -> Result<Json<VaultNoteDetail>, ApiError> {
213    let node = watchtower::get_content_node_for(&state.db, &ctx.account_id, id)
214        .await?
215        .ok_or_else(|| ApiError::NotFound(format!("note {id} not found")))?;
216
217    let chunks = watchtower::get_chunks_for_node(&state.db, &ctx.account_id, id).await?;
218
219    let chunk_summaries: Vec<VaultChunkSummary> = chunks
220        .into_iter()
221        .map(|c| VaultChunkSummary {
222            chunk_id: c.id,
223            heading_path: c.heading_path,
224            snippet: truncate_snippet(&c.chunk_text, SNIPPET_MAX_LEN),
225            retrieval_boost: c.retrieval_boost,
226        })
227        .collect();
228
229    Ok(Json(VaultNoteDetail {
230        node_id: node.id,
231        source_id: node.source_id,
232        title: node.title,
233        relative_path: node.relative_path,
234        tags: node.tags,
235        status: node.status,
236        ingested_at: node.ingested_at,
237        updated_at: node.updated_at,
238        chunks: chunk_summaries,
239    }))
240}
241
242// ---------------------------------------------------------------------------
243// GET /api/vault/notes/{id}/neighbors?max=8
244// ---------------------------------------------------------------------------
245
246#[derive(Deserialize)]
247pub struct NoteNeighborsQuery {
248    pub max: Option<u32>,
249}
250
251#[derive(Serialize)]
252pub struct NoteNeighborsResponse {
253    pub node_id: i64,
254    pub neighbors: Vec<NeighborItem>,
255    pub total_edges: u32,
256    pub graph_state: GraphState,
257}
258
259#[derive(Serialize)]
260pub struct NeighborItem {
261    pub node_id: i64,
262    pub node_title: Option<String>,
263    pub reason: String,
264    pub reason_label: String,
265    pub intent: String,
266    pub matched_tags: Vec<String>,
267    pub score: f64,
268    pub snippet: Option<String>,
269    pub best_chunk_id: Option<i64>,
270    pub heading_path: Option<String>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub relative_path: Option<String>,
273}
274
275impl NeighborItem {
276    fn from_graph_neighbor(n: graph_expansion::GraphNeighbor, is_cloud: bool) -> Self {
277        Self {
278            node_id: n.node_id,
279            node_title: n.node_title,
280            reason: serde_json::to_value(&n.reason)
281                .ok()
282                .and_then(|v| v.as_str().map(String::from))
283                .unwrap_or_else(|| "related".to_string()),
284            reason_label: n.reason_label,
285            intent: serde_json::to_value(&n.intent)
286                .ok()
287                .and_then(|v| v.as_str().map(String::from))
288                .unwrap_or_else(|| "related".to_string()),
289            matched_tags: n.matched_tags,
290            score: n.score,
291            snippet: n.snippet,
292            best_chunk_id: n.best_chunk_id,
293            heading_path: n.heading_path,
294            relative_path: if is_cloud {
295                None
296            } else {
297                Some(n.relative_path)
298            },
299        }
300    }
301}
302
303pub async fn note_neighbors(
304    State(state): State<Arc<AppState>>,
305    ctx: AccountContext,
306    Path(id): Path<i64>,
307    Query(params): Query<NoteNeighborsQuery>,
308) -> Result<Json<NoteNeighborsResponse>, ApiError> {
309    let max = params
310        .max
311        .unwrap_or(graph_expansion::DEFAULT_MAX_NEIGHBORS)
312        .min(MAX_LIMIT);
313    let is_cloud = matches!(
314        state.deployment_mode,
315        tuitbot_core::config::DeploymentMode::Cloud
316    );
317
318    // Verify node exists and is account-scoped.
319    let node = watchtower::get_content_node_for(&state.db, &ctx.account_id, id).await?;
320    if node.is_none() {
321        return Ok(Json(NoteNeighborsResponse {
322            node_id: id,
323            neighbors: Vec::new(),
324            total_edges: 0,
325            graph_state: GraphState::NodeNotIndexed,
326        }));
327    }
328
329    // Expand graph neighbors (fail-open).
330    let result =
331        crate::routes::rag_helpers::resolve_graph_suggestions(&state, &ctx.account_id, id, max)
332            .await;
333
334    let total_edges: u32 = result.neighbors.iter().map(|n| n.edge_count).sum();
335    let items: Vec<NeighborItem> = result
336        .neighbors
337        .into_iter()
338        .map(|n| NeighborItem::from_graph_neighbor(n, is_cloud))
339        .collect();
340
341    Ok(Json(NoteNeighborsResponse {
342        node_id: id,
343        neighbors: items,
344        total_edges,
345        graph_state: result.graph_state,
346    }))
347}
348
349// ---------------------------------------------------------------------------
350// GET /api/vault/search?q=&limit=
351// ---------------------------------------------------------------------------
352
353#[derive(Deserialize)]
354pub struct SearchFragmentsQuery {
355    pub q: String,
356    pub limit: Option<u32>,
357}
358
359#[derive(Serialize)]
360pub struct SearchFragmentsResponse {
361    pub fragments: Vec<VaultCitation>,
362}
363
364pub async fn search_fragments(
365    State(state): State<Arc<AppState>>,
366    ctx: AccountContext,
367    Query(params): Query<SearchFragmentsQuery>,
368) -> Result<Json<SearchFragmentsResponse>, ApiError> {
369    let limit = clamp_limit(params.limit);
370
371    if params.q.is_empty() {
372        return Ok(Json(SearchFragmentsResponse { fragments: vec![] }));
373    }
374
375    let keywords: Vec<String> = params.q.split_whitespace().map(|s| s.to_string()).collect();
376
377    let fragments =
378        retrieval::retrieve_vault_fragments(&state.db, &ctx.account_id, &keywords, None, limit)
379            .await?;
380
381    let citations = retrieval::build_citations(&fragments);
382
383    Ok(Json(SearchFragmentsResponse {
384        fragments: citations,
385    }))
386}
387
388// ---------------------------------------------------------------------------
389// POST /api/vault/resolve-refs
390// ---------------------------------------------------------------------------
391
392#[derive(Deserialize)]
393pub struct ResolveRefsRequest {
394    pub node_ids: Vec<i64>,
395}
396
397#[derive(Serialize)]
398pub struct ResolveRefsResponse {
399    pub citations: Vec<VaultCitation>,
400}
401
402pub async fn resolve_refs(
403    State(state): State<Arc<AppState>>,
404    ctx: AccountContext,
405    Json(body): Json<ResolveRefsRequest>,
406) -> Result<Json<ResolveRefsResponse>, ApiError> {
407    if body.node_ids.is_empty() {
408        return Ok(Json(ResolveRefsResponse { citations: vec![] }));
409    }
410
411    let fragments = retrieval::retrieve_vault_fragments(
412        &state.db,
413        &ctx.account_id,
414        &[],
415        Some(&body.node_ids),
416        MAX_LIMIT,
417    )
418    .await?;
419
420    let citations = retrieval::build_citations(&fragments);
421
422    Ok(Json(ResolveRefsResponse { citations }))
423}
424
425// ---------------------------------------------------------------------------
426// Tests
427// ---------------------------------------------------------------------------
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    use std::collections::HashMap;
434    use std::path::PathBuf;
435
436    use axum::body::Body;
437    use axum::http::{Request, StatusCode};
438    use axum::routing::{get, post};
439    use axum::Router;
440    use tokio::sync::{broadcast, Mutex, RwLock};
441    use tower::ServiceExt;
442
443    use crate::ws::AccountWsEvent;
444
445    async fn test_state() -> Arc<AppState> {
446        let db = tuitbot_core::storage::init_test_db()
447            .await
448            .expect("init test db");
449        let (event_tx, _) = broadcast::channel::<AccountWsEvent>(16);
450        Arc::new(AppState {
451            db,
452            config_path: PathBuf::from("/tmp/test-config.toml"),
453            data_dir: PathBuf::from("/tmp"),
454            event_tx,
455            api_token: "test-token".to_string(),
456            passphrase_hash: RwLock::new(None),
457            passphrase_hash_mtime: RwLock::new(None),
458            bind_host: "127.0.0.1".to_string(),
459            bind_port: 3001,
460            login_attempts: Mutex::new(HashMap::new()),
461            runtimes: Mutex::new(HashMap::new()),
462            content_generators: Mutex::new(HashMap::new()),
463            circuit_breaker: None,
464            scraper_health: None,
465            watchtower_cancel: RwLock::new(None),
466            content_sources: RwLock::new(Default::default()),
467            connector_config: Default::default(),
468            deployment_mode: Default::default(),
469            pending_oauth: Mutex::new(HashMap::new()),
470            token_managers: Mutex::new(HashMap::new()),
471            x_client_id: String::new(),
472        })
473    }
474
475    fn test_router(state: Arc<AppState>) -> Router {
476        Router::new()
477            .route("/vault/sources", get(vault_sources))
478            .route("/vault/notes", get(search_notes))
479            .route("/vault/notes/{id}/neighbors", get(note_neighbors))
480            .route("/vault/notes/{id}", get(note_detail))
481            .route("/vault/search", get(search_fragments))
482            .route("/vault/resolve-refs", post(resolve_refs))
483            .with_state(state)
484    }
485
486    #[tokio::test]
487    async fn vault_sources_returns_empty_when_no_sources() {
488        let state = test_state().await;
489        let app = test_router(state);
490
491        let resp = app
492            .oneshot(
493                Request::builder()
494                    .uri("/vault/sources")
495                    .body(Body::empty())
496                    .unwrap(),
497            )
498            .await
499            .unwrap();
500
501        assert_eq!(resp.status(), StatusCode::OK);
502        let body: serde_json::Value = serde_json::from_slice(
503            &axum::body::to_bytes(resp.into_body(), 1024 * 64)
504                .await
505                .unwrap(),
506        )
507        .unwrap();
508        assert_eq!(body["sources"].as_array().unwrap().len(), 0);
509    }
510
511    #[tokio::test]
512    async fn search_notes_returns_empty_for_no_matches() {
513        let state = test_state().await;
514        let app = test_router(state);
515
516        let resp = app
517            .oneshot(
518                Request::builder()
519                    .uri("/vault/notes?q=nonexistent")
520                    .body(Body::empty())
521                    .unwrap(),
522            )
523            .await
524            .unwrap();
525
526        assert_eq!(resp.status(), StatusCode::OK);
527        let body: serde_json::Value = serde_json::from_slice(
528            &axum::body::to_bytes(resp.into_body(), 1024 * 64)
529                .await
530                .unwrap(),
531        )
532        .unwrap();
533        assert_eq!(body["notes"].as_array().unwrap().len(), 0);
534    }
535
536    #[tokio::test]
537    async fn note_detail_returns_404_for_missing_node() {
538        let state = test_state().await;
539        let app = test_router(state);
540
541        let resp = app
542            .oneshot(
543                Request::builder()
544                    .uri("/vault/notes/999")
545                    .body(Body::empty())
546                    .unwrap(),
547            )
548            .await
549            .unwrap();
550
551        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
552    }
553
554    #[tokio::test]
555    async fn search_fragments_returns_empty_for_no_chunks() {
556        let state = test_state().await;
557        let app = test_router(state);
558
559        let resp = app
560            .oneshot(
561                Request::builder()
562                    .uri("/vault/search?q=nonexistent")
563                    .body(Body::empty())
564                    .unwrap(),
565            )
566            .await
567            .unwrap();
568
569        assert_eq!(resp.status(), StatusCode::OK);
570        let body: serde_json::Value = serde_json::from_slice(
571            &axum::body::to_bytes(resp.into_body(), 1024 * 64)
572                .await
573                .unwrap(),
574        )
575        .unwrap();
576        assert_eq!(body["fragments"].as_array().unwrap().len(), 0);
577    }
578
579    // --- clamp_limit tests ---
580
581    #[test]
582    fn clamp_limit_default() {
583        assert_eq!(clamp_limit(None), DEFAULT_LIMIT);
584    }
585
586    #[test]
587    fn clamp_limit_under_max() {
588        assert_eq!(clamp_limit(Some(50)), 50);
589    }
590
591    #[test]
592    fn clamp_limit_at_max() {
593        assert_eq!(clamp_limit(Some(MAX_LIMIT)), MAX_LIMIT);
594    }
595
596    #[test]
597    fn clamp_limit_over_max() {
598        assert_eq!(clamp_limit(Some(500)), MAX_LIMIT);
599    }
600
601    // --- truncate_snippet tests ---
602
603    #[test]
604    fn truncate_snippet_short_text() {
605        assert_eq!(truncate_snippet("hello", 120), "hello");
606    }
607
608    #[test]
609    fn truncate_snippet_at_limit() {
610        let text = "a".repeat(120);
611        assert_eq!(truncate_snippet(&text, 120), text);
612    }
613
614    #[test]
615    fn truncate_snippet_over_limit() {
616        let text = "a".repeat(200);
617        let result = truncate_snippet(&text, 120);
618        assert!(result.ends_with("..."));
619        assert!(result.len() <= 120);
620    }
621
622    #[test]
623    fn truncate_snippet_unicode_safe() {
624        // Test with multi-byte chars
625        let text = "a".repeat(115) + "\u{1F600}\u{1F600}\u{1F600}";
626        let result = truncate_snippet(&text, 120);
627        assert!(result.ends_with("..."));
628        // Should not panic on char boundary
629    }
630
631    // --- deserialization tests ---
632
633    #[test]
634    fn search_notes_query_defaults() {
635        let json = "{}";
636        let q: SearchNotesQuery = serde_json::from_str(json).expect("deser");
637        assert!(q.q.is_none());
638        assert!(q.source_id.is_none());
639        assert!(q.limit.is_none());
640    }
641
642    #[test]
643    fn search_fragments_query_deserializes() {
644        let json = r#"{"q":"rust","limit":10}"#;
645        let q: SearchFragmentsQuery = serde_json::from_str(json).expect("deser");
646        assert_eq!(q.q, "rust");
647        assert_eq!(q.limit, Some(10));
648    }
649
650    #[test]
651    fn resolve_refs_request_deserializes() {
652        let json = r#"{"node_ids":[1,2,3]}"#;
653        let req: ResolveRefsRequest = serde_json::from_str(json).expect("deser");
654        assert_eq!(req.node_ids.len(), 3);
655    }
656
657    #[test]
658    fn resolve_refs_request_empty_ids() {
659        let json = r#"{"node_ids":[]}"#;
660        let req: ResolveRefsRequest = serde_json::from_str(json).expect("deser");
661        assert!(req.node_ids.is_empty());
662    }
663
664    #[tokio::test]
665    async fn resolve_refs_returns_empty_for_empty_ids() {
666        let state = test_state().await;
667        let app = test_router(state);
668
669        let resp = app
670            .oneshot(
671                Request::builder()
672                    .method("POST")
673                    .uri("/vault/resolve-refs")
674                    .header("content-type", "application/json")
675                    .body(Body::from(r#"{"node_ids":[]}"#))
676                    .unwrap(),
677            )
678            .await
679            .unwrap();
680
681        assert_eq!(resp.status(), StatusCode::OK);
682        let body: serde_json::Value = serde_json::from_slice(
683            &axum::body::to_bytes(resp.into_body(), 1024 * 64)
684                .await
685                .unwrap(),
686        )
687        .unwrap();
688        assert_eq!(body["citations"].as_array().unwrap().len(), 0);
689    }
690
691    #[tokio::test]
692    async fn vault_sources_includes_privacy_envelope() {
693        let state = test_state().await;
694        let app = test_router(state);
695
696        let resp = app
697            .oneshot(
698                Request::builder()
699                    .uri("/vault/sources")
700                    .body(Body::empty())
701                    .unwrap(),
702            )
703            .await
704            .unwrap();
705
706        assert_eq!(resp.status(), StatusCode::OK);
707        let body: serde_json::Value = serde_json::from_slice(
708            &axum::body::to_bytes(resp.into_body(), 1024 * 64)
709                .await
710                .unwrap(),
711        )
712        .unwrap();
713        // Default deployment_mode is Desktop
714        assert_eq!(body["deployment_mode"], "desktop");
715        assert_eq!(body["privacy_envelope"], "local_first");
716    }
717
718    async fn test_state_with_mode(mode: tuitbot_core::config::DeploymentMode) -> Arc<AppState> {
719        let db = tuitbot_core::storage::init_test_db()
720            .await
721            .expect("init test db");
722        let (event_tx, _) = broadcast::channel::<AccountWsEvent>(16);
723        Arc::new(AppState {
724            db,
725            config_path: PathBuf::from("/tmp/test-config.toml"),
726            data_dir: PathBuf::from("/tmp"),
727            event_tx,
728            api_token: "test-token".to_string(),
729            passphrase_hash: RwLock::new(None),
730            passphrase_hash_mtime: RwLock::new(None),
731            bind_host: "127.0.0.1".to_string(),
732            bind_port: 3001,
733            login_attempts: Mutex::new(HashMap::new()),
734            runtimes: Mutex::new(HashMap::new()),
735            content_generators: Mutex::new(HashMap::new()),
736            circuit_breaker: None,
737            scraper_health: None,
738            watchtower_cancel: RwLock::new(None),
739            content_sources: RwLock::new(Default::default()),
740            connector_config: Default::default(),
741            deployment_mode: mode,
742            pending_oauth: Mutex::new(HashMap::new()),
743            token_managers: Mutex::new(HashMap::new()),
744            x_client_id: String::new(),
745        })
746    }
747
748    #[tokio::test]
749    async fn vault_sources_cloud_mode_privacy_envelope() {
750        let state = test_state_with_mode(tuitbot_core::config::DeploymentMode::Cloud).await;
751        let app = test_router(state);
752
753        let resp = app
754            .oneshot(
755                Request::builder()
756                    .uri("/vault/sources")
757                    .body(Body::empty())
758                    .unwrap(),
759            )
760            .await
761            .unwrap();
762
763        assert_eq!(resp.status(), StatusCode::OK);
764        let body: serde_json::Value = serde_json::from_slice(
765            &axum::body::to_bytes(resp.into_body(), 1024 * 64)
766                .await
767                .unwrap(),
768        )
769        .unwrap();
770        assert_eq!(body["deployment_mode"], "cloud");
771        assert_eq!(body["privacy_envelope"], "provider_controlled");
772    }
773}