Skip to main content

lean_ctx/http_server/team/
config.rs

1#[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    /// Hosted-storage quota in bytes (`storageQuotaBytes` in `team.json`),
33    /// rendered per plan by the control plane's provisioning bridge (#282).
34    /// Omitted ⇒ the server defaults to the Team tier's 5 GiB; the
35    /// `LEANCTX_TEAM_STORAGE_QUOTA_BYTES` env var overrides both.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub storage_quota_bytes: Option<u64>,
38    /// Slack/Discord/generic webhook for the weekly team-ROI summary
39    /// (`roiWebhookUrl` in `team.json`, GL #388). HTTPS only — the server
40    /// refuses to start with a plaintext URL. Omitted ⇒ no webhook posts.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub roi_webhook_url: Option<String>,
43    /// Managed connectors (#281): scheduled hosted source syncs, rendered into
44    /// `team.json` by the control plane (which enforces the `managed_connectors`
45    /// entitlement count and encrypts each `secret` at rest). Omitted ⇒ none.
46    #[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    /// Stored as lowercase hex of SHA-256(token).
82    pub sha256_hex: String,
83    /// Explicitly granted scopes. May be empty when a [`role`](Self::role) is set.
84    #[serde(default)]
85    pub scopes: Vec<TeamScope>,
86    /// Optional RBAC role (EPIC 13.2). Expands to a scope set that is unioned
87    /// with `scopes`. Lets admins grant `viewer`/`member`/`admin`/`owner`
88    /// instead of hand-picking scopes.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub role: Option<roles::TeamRole>,
91}
92
93impl TeamTokenConfig {
94    /// The effective scopes for this token: explicit scopes ∪ role-derived
95    /// scopes. This is what authorization is evaluated against (EPIC 13.2).
96    #[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    /// Every scope, used by role expansion (EPIC 13.2) to grant full access.
121    #[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            // A token must grant access via explicit scopes and/or a role
188            // (EPIC 13.2). An empty effective scope set is a misconfiguration.
189            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}