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