lean_ctx/http_server/team/
config.rs1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4#[derive(Clone, Debug, Serialize, Deserialize)]
5#[serde(rename_all = "camelCase")]
6pub struct TeamServerConfig {
7 pub host: String,
8 pub port: u16,
9 pub default_workspace_id: String,
10 pub workspaces: Vec<TeamWorkspaceConfig>,
11 #[serde(default)]
12 pub tokens: Vec<TeamTokenConfig>,
13 pub audit_log_path: PathBuf,
14 #[serde(default)]
15 pub disable_host_check: bool,
16 #[serde(default)]
17 pub allowed_hosts: Vec<String>,
18 #[serde(default = "default_max_body_bytes")]
19 pub max_body_bytes: usize,
20 #[serde(default = "default_max_concurrency")]
21 pub max_concurrency: usize,
22 #[serde(default = "default_max_rps")]
23 pub max_rps: u32,
24 #[serde(default = "default_rate_burst")]
25 pub rate_burst: u32,
26 #[serde(default = "default_request_timeout_ms")]
27 pub request_timeout_ms: u64,
28 #[serde(default)]
29 pub stateful_mode: bool,
30 #[serde(default = "default_true")]
31 pub json_response: bool,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub storage_quota_bytes: Option<u64>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub roi_webhook_url: Option<String>,
43 #[serde(default)]
47 pub connectors: Vec<connectors::ConnectorConfig>,
48}
49
50fn default_true() -> bool {
51 true
52}
53fn default_max_body_bytes() -> usize {
54 2 * 1024 * 1024
55}
56fn default_max_concurrency() -> usize {
57 32
58}
59fn default_max_rps() -> u32 {
60 50
61}
62fn default_rate_burst() -> u32 {
63 100
64}
65fn default_request_timeout_ms() -> u64 {
66 30_000
67}
68
69#[derive(Clone, Debug, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct TeamWorkspaceConfig {
72 pub id: String,
73 pub label: Option<String>,
74 pub root: PathBuf,
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct TeamTokenConfig {
80 pub id: String,
81 pub sha256_hex: String,
83 #[serde(default)]
85 pub scopes: Vec<TeamScope>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub role: Option<roles::TeamRole>,
91}
92
93impl TeamTokenConfig {
94 #[must_use]
97 pub fn effective_scopes(&self) -> BTreeSet<TeamScope> {
98 let mut s: BTreeSet<TeamScope> = self.scopes.iter().copied().collect();
99 if let Some(role) = self.role {
100 s.extend(role.scopes());
101 }
102 s
103 }
104}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum TeamScope {
109 Search,
110 Graph,
111 Artifacts,
112 Index,
113 Events,
114 SessionMutations,
115 Knowledge,
116 Audit,
117}
118
119impl TeamScope {
120 #[must_use]
122 pub fn all() -> &'static [TeamScope] {
123 &[
124 TeamScope::Search,
125 TeamScope::Graph,
126 TeamScope::Artifacts,
127 TeamScope::Index,
128 TeamScope::Events,
129 TeamScope::SessionMutations,
130 TeamScope::Knowledge,
131 TeamScope::Audit,
132 ]
133 }
134}
135
136impl TeamServerConfig {
137 pub fn load(path: &Path) -> Result<Self> {
138 let s =
139 std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
140 let cfg: Self =
141 serde_json::from_str(&s).with_context(|| format!("parse {}", path.display()))?;
142 cfg.validate()?;
143 Ok(cfg)
144 }
145
146 pub fn save(&self, path: &Path) -> Result<()> {
147 let s = serde_json::to_string_pretty(self).context("serialize TeamServerConfig")?;
148 std::fs::write(path, format!("{s}\n")).with_context(|| format!("write {}", path.display()))
149 }
150
151 pub fn validate(&self) -> Result<()> {
152 if self.workspaces.is_empty() {
153 return Err(anyhow!("team server requires at least 1 workspace"));
154 }
155 let mut ws_ids = BTreeSet::new();
156 for ws in &self.workspaces {
157 let id = ws.id.trim();
158 if id.is_empty() {
159 return Err(anyhow!("workspace id must be non-empty"));
160 }
161 if !ws_ids.insert(id.to_string()) {
162 return Err(anyhow!("duplicate workspace id: {id}"));
163 }
164 if !ws.root.exists() {
165 return Err(anyhow!(
166 "workspace root does not exist: {}",
167 ws.root.display()
168 ));
169 }
170 }
171 if !ws_ids.contains(self.default_workspace_id.trim()) {
172 return Err(anyhow!(
173 "defaultWorkspaceId '{}' not found in workspaces",
174 self.default_workspace_id
175 ));
176 }
177
178 let mut token_ids = BTreeSet::new();
179 for t in &self.tokens {
180 let id = t.id.trim();
181 if id.is_empty() {
182 return Err(anyhow!("token id must be non-empty"));
183 }
184 if !token_ids.insert(id.to_string()) {
185 return Err(anyhow!("duplicate token id: {id}"));
186 }
187 if t.effective_scopes().is_empty() {
190 return Err(anyhow!("token '{id}' must have at least 1 scope or a role"));
191 }
192 parse_sha256_hex(&t.sha256_hex)
193 .with_context(|| format!("token '{id}' invalid sha256Hex"))?;
194 }
195
196 if let Some(parent) = self.audit_log_path.parent()
197 && !parent.as_os_str().is_empty()
198 && !parent.exists()
199 {
200 return Err(anyhow!(
201 "auditLogPath parent does not exist: {}",
202 parent.display()
203 ));
204 }
205 Ok(())
206 }
207
208 pub fn validate_for_serve(&self) -> Result<()> {
209 self.validate()?;
210 if self.tokens.is_empty() {
211 return Err(anyhow!("team server requires at least 1 token"));
212 }
213 Ok(())
214 }
215}