1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
//! `/v1/capabilities` — feature discovery for clients.
//!
//! Local mode (`pensieve serve`, embedded SQLite) deliberately omits the
//! control-plane surfaces (data sources, credentials, OAuth, saved Discover
//! views). The web UI gates those pages on these flags so it can explain
//! "runs on the control plane" instead of discovering missing routes via
//! 404s. Servers that predate this endpoint 404 here too — clients should
//! treat that as "assume everything" (the hosted server has it all).
use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Clone, Copy, Serialize)]
pub struct Capabilities {
/// `"local"` (embedded SQLite, single binary) or `"server"` (control plane).
pub mode: &'static str,
/// Whether data-source management routes are available (control-plane only).
pub data_sources: bool,
pub credentials: bool,
pub oauth: bool,
/// Saved Discover views (write side is Postgres-backed today).
pub saved_views: bool,
pub users_admin: bool,
/// Live-tail WebSocket (`GET /v1/explore/live`).
pub explore_live: bool,
/// Inline agent surface (`POST /v1/agent/ask` + sessions/skills/memory).
/// Embedded clients gate the chat component on this so servers without
/// the agent degrade to a "not available" card instead of a 404.
pub agent: bool,
}
impl Capabilities {
/// Hosted control plane — everything on.
pub const SERVER: Self = Self {
mode: "server",
data_sources: true,
credentials: true,
oauth: true,
saved_views: true,
users_admin: true,
explore_live: true,
agent: true,
};
/// Local single binary — full feature parity with the server except OAuth
/// and saved Discover views (those require Postgres).
pub const LOCAL: Self = Self {
mode: "local",
data_sources: true,
credentials: true,
oauth: false,
saved_views: false,
users_admin: true,
explore_live: true,
agent: true,
};
}
/// Build the capabilities router — caller wraps with auth middleware.
pub fn router(caps: Capabilities) -> Router {
Router::new().route("/v1/capabilities", get(move || async move { Json(caps) }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_serialize_explore_live() {
let json = serde_json::to_value(Capabilities::SERVER).unwrap();
assert_eq!(json["explore_live"], true);
let json = serde_json::to_value(Capabilities::LOCAL).unwrap();
assert_eq!(json["explore_live"], true);
}
#[test]
fn capabilities_serialize_agent() {
let json = serde_json::to_value(Capabilities::SERVER).unwrap();
assert_eq!(json["agent"], true);
let json = serde_json::to_value(Capabilities::LOCAL).unwrap();
assert_eq!(json["agent"], true);
}
#[test]
fn capabilities_serialize_data_sources() {
let json = serde_json::to_value(Capabilities::SERVER).unwrap();
assert_eq!(json["data_sources"], true);
assert!(json.get("connectors").is_none());
let json = serde_json::to_value(Capabilities::LOCAL).unwrap();
assert_eq!(json["data_sources"], true);
}
}