Skip to main content

lean_ctx/http_server/team/
mod.rs

1use std::collections::{BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicI64, Ordering};
5
6use anyhow::{Context, Result, anyhow};
7use axum::{
8    Router,
9    body::{self, Body},
10    extract::{Extension, Json, Query, State},
11    http::{Request, StatusCode, header},
12    middleware::{self, Next},
13    response::sse::{Event as SseEvent, KeepAlive, Sse},
14    response::{IntoResponse, Response},
15    routing::get,
16};
17use futures::Stream;
18use md5::{Digest, Md5};
19use rmcp::{
20    handler::server::ServerHandler,
21    model::{
22        CallToolRequest, CallToolRequestParams, CallToolResult, ClientJsonRpcMessage,
23        ClientRequest, JsonRpcRequest, NumberOrString, ServerJsonRpcMessage, ServerResult,
24    },
25    service::{RequestContext, RoleServer, serve_directly},
26    transport::{OneshotTransport, StreamableHttpService},
27};
28use serde::{Deserialize, Serialize};
29use serde_json::{Map, Value, json};
30use sha2::Sha256;
31use tokio::io::AsyncWriteExt;
32use tokio::sync::broadcast;
33use tokio::time::Duration;
34
35use crate::tools::LeanCtxServer;
36
37pub mod connectors;
38pub mod roles;
39pub use roles::TeamRole;
40
41#[cfg(test)]
42mod tests;
43
44const WORKSPACE_ARG_KEY: &str = "workspaceId";
45const CHANNEL_ARG_KEY: &str = "channelId";
46/// Per-call agent identity (enterprise#28). On the `/v1` REST surface the
47/// server overwrites this with `team:<token_id>` — identity is auth-derived,
48/// never client-claimed. Raw MCP clients may set it cooperatively (same trust
49/// model as local `ctx_agent register`).
50const AGENT_ARG_KEY: &str = "agentId";
51const WORKSPACE_HEADER: &str = "x-leanctx-workspace";
52
53#[derive(Clone, Debug, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct TeamServerConfig {
56    pub host: String,
57    pub port: u16,
58    pub default_workspace_id: String,
59    pub workspaces: Vec<TeamWorkspaceConfig>,
60    #[serde(default)]
61    pub tokens: Vec<TeamTokenConfig>,
62    pub audit_log_path: PathBuf,
63    #[serde(default)]
64    pub disable_host_check: bool,
65    #[serde(default)]
66    pub allowed_hosts: Vec<String>,
67    #[serde(default = "default_max_body_bytes")]
68    pub max_body_bytes: usize,
69    #[serde(default = "default_max_concurrency")]
70    pub max_concurrency: usize,
71    #[serde(default = "default_max_rps")]
72    pub max_rps: u32,
73    #[serde(default = "default_rate_burst")]
74    pub rate_burst: u32,
75    #[serde(default = "default_request_timeout_ms")]
76    pub request_timeout_ms: u64,
77    #[serde(default)]
78    pub stateful_mode: bool,
79    #[serde(default = "default_true")]
80    pub json_response: bool,
81    /// Hosted-storage quota in bytes (`storageQuotaBytes` in `team.json`),
82    /// rendered per plan by the control plane's provisioning bridge (#282).
83    /// Omitted ⇒ the server defaults to the Team tier's 5 GiB; the
84    /// `LEANCTX_TEAM_STORAGE_QUOTA_BYTES` env var overrides both.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub storage_quota_bytes: Option<u64>,
87    /// Slack/Discord/generic webhook for the weekly team-ROI summary
88    /// (`roiWebhookUrl` in `team.json`, GL #388). HTTPS only — the server
89    /// refuses to start with a plaintext URL. Omitted ⇒ no webhook posts.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub roi_webhook_url: Option<String>,
92    /// Managed connectors (#281): scheduled hosted source syncs, rendered into
93    /// `team.json` by the control plane (which enforces the `managed_connectors`
94    /// entitlement count and encrypts each `secret` at rest). Omitted ⇒ none.
95    #[serde(default)]
96    pub connectors: Vec<connectors::ConnectorConfig>,
97}
98
99fn default_true() -> bool {
100    true
101}
102fn default_max_body_bytes() -> usize {
103    2 * 1024 * 1024
104}
105fn default_max_concurrency() -> usize {
106    32
107}
108fn default_max_rps() -> u32 {
109    50
110}
111fn default_rate_burst() -> u32 {
112    100
113}
114fn default_request_timeout_ms() -> u64 {
115    30_000
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct TeamWorkspaceConfig {
121    pub id: String,
122    pub label: Option<String>,
123    pub root: PathBuf,
124}
125
126#[derive(Clone, Debug, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct TeamTokenConfig {
129    pub id: String,
130    /// Stored as lowercase hex of SHA-256(token).
131    pub sha256_hex: String,
132    /// Explicitly granted scopes. May be empty when a [`role`](Self::role) is set.
133    #[serde(default)]
134    pub scopes: Vec<TeamScope>,
135    /// Optional RBAC role (EPIC 13.2). Expands to a scope set that is unioned
136    /// with `scopes`. Lets admins grant `viewer`/`member`/`admin`/`owner`
137    /// instead of hand-picking scopes.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub role: Option<roles::TeamRole>,
140}
141
142impl TeamTokenConfig {
143    /// The effective scopes for this token: explicit scopes ∪ role-derived
144    /// scopes. This is what authorization is evaluated against (EPIC 13.2).
145    #[must_use]
146    pub fn effective_scopes(&self) -> BTreeSet<TeamScope> {
147        let mut s: BTreeSet<TeamScope> = self.scopes.iter().copied().collect();
148        if let Some(role) = self.role {
149            s.extend(role.scopes());
150        }
151        s
152    }
153}
154
155#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
156#[serde(rename_all = "lowercase")]
157pub enum TeamScope {
158    Search,
159    Graph,
160    Artifacts,
161    Index,
162    Events,
163    SessionMutations,
164    Knowledge,
165    Audit,
166}
167
168impl TeamScope {
169    /// Every scope, used by role expansion (EPIC 13.2) to grant full access.
170    #[must_use]
171    pub fn all() -> &'static [TeamScope] {
172        &[
173            TeamScope::Search,
174            TeamScope::Graph,
175            TeamScope::Artifacts,
176            TeamScope::Index,
177            TeamScope::Events,
178            TeamScope::SessionMutations,
179            TeamScope::Knowledge,
180            TeamScope::Audit,
181        ]
182    }
183}
184
185impl TeamServerConfig {
186    pub fn load(path: &Path) -> Result<Self> {
187        let s =
188            std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
189        let cfg: Self =
190            serde_json::from_str(&s).with_context(|| format!("parse {}", path.display()))?;
191        cfg.validate()?;
192        Ok(cfg)
193    }
194
195    pub fn save(&self, path: &Path) -> Result<()> {
196        let s = serde_json::to_string_pretty(self).context("serialize TeamServerConfig")?;
197        std::fs::write(path, format!("{s}\n")).with_context(|| format!("write {}", path.display()))
198    }
199
200    pub fn validate(&self) -> Result<()> {
201        if self.workspaces.is_empty() {
202            return Err(anyhow!("team server requires at least 1 workspace"));
203        }
204        let mut ws_ids = BTreeSet::new();
205        for ws in &self.workspaces {
206            let id = ws.id.trim();
207            if id.is_empty() {
208                return Err(anyhow!("workspace id must be non-empty"));
209            }
210            if !ws_ids.insert(id.to_string()) {
211                return Err(anyhow!("duplicate workspace id: {id}"));
212            }
213            if !ws.root.exists() {
214                return Err(anyhow!(
215                    "workspace root does not exist: {}",
216                    ws.root.display()
217                ));
218            }
219        }
220        if !ws_ids.contains(self.default_workspace_id.trim()) {
221            return Err(anyhow!(
222                "defaultWorkspaceId '{}' not found in workspaces",
223                self.default_workspace_id
224            ));
225        }
226
227        let mut token_ids = BTreeSet::new();
228        for t in &self.tokens {
229            let id = t.id.trim();
230            if id.is_empty() {
231                return Err(anyhow!("token id must be non-empty"));
232            }
233            if !token_ids.insert(id.to_string()) {
234                return Err(anyhow!("duplicate token id: {id}"));
235            }
236            // A token must grant access via explicit scopes and/or a role
237            // (EPIC 13.2). An empty effective scope set is a misconfiguration.
238            if t.effective_scopes().is_empty() {
239                return Err(anyhow!("token '{id}' must have at least 1 scope or a role"));
240            }
241            parse_sha256_hex(&t.sha256_hex)
242                .with_context(|| format!("token '{id}' invalid sha256Hex"))?;
243        }
244
245        if let Some(parent) = self.audit_log_path.parent()
246            && !parent.as_os_str().is_empty()
247            && !parent.exists()
248        {
249            return Err(anyhow!(
250                "auditLogPath parent does not exist: {}",
251                parent.display()
252            ));
253        }
254        Ok(())
255    }
256
257    pub fn validate_for_serve(&self) -> Result<()> {
258        self.validate()?;
259        if self.tokens.is_empty() {
260            return Err(anyhow!("team server requires at least 1 token"));
261        }
262        Ok(())
263    }
264}
265
266#[derive(Clone)]
267struct TeamAuthContext {
268    token_id: String,
269    scopes: BTreeSet<TeamScope>,
270}
271
272#[derive(Clone)]
273pub struct TeamRequestContext {
274    pub workspace_id: String,
275}
276
277#[derive(Clone)]
278pub struct TeamState {
279    auth: Arc<Vec<TeamTokenConfig>>,
280    engine: Arc<TeamContextEngine>,
281    audit: Arc<tokio::sync::Mutex<tokio::fs::File>>,
282    pub savings_store_dir: Arc<tokio::sync::Mutex<std::path::PathBuf>>,
283    /// Measurement roots for the billing-plane storage report (GL #463).
284    pub storage_roots: super::team_billing::StorageRoots,
285    /// 60 s cache for the measured storage report.
286    pub storage_cache: Arc<tokio::sync::Mutex<super::team_billing::StorageCache>>,
287    /// Configured managed connectors (#281), secret-bearing — never serialized
288    /// back out; [`connectors::v1_connectors`] exposes a secret-free view.
289    pub connectors: Arc<Vec<connectors::ConnectorConfig>>,
290    /// Directory holding each connector's persisted run state (one file per id).
291    pub connectors_state_dir: Arc<std::path::PathBuf>,
292}
293
294#[derive(Clone)]
295pub struct TeamAppState {
296    concurrency: Arc<tokio::sync::Semaphore>,
297    rate: Arc<super::RateLimiter>,
298    timeout: Duration,
299    pub team: Arc<TeamState>,
300    max_body_bytes: usize,
301}
302
303#[derive(Debug, Deserialize)]
304#[serde(rename_all = "camelCase")]
305struct ToolCallBody {
306    name: String,
307    #[serde(default)]
308    arguments: Option<Value>,
309    #[serde(default)]
310    workspace_id: Option<String>,
311    #[serde(default)]
312    channel_id: Option<String>,
313}
314
315#[derive(Debug, Deserialize)]
316#[serde(rename_all = "camelCase")]
317struct ToolsQuery {
318    #[serde(default)]
319    offset: Option<usize>,
320    #[serde(default)]
321    limit: Option<usize>,
322}
323
324#[derive(Debug, Deserialize)]
325#[serde(rename_all = "camelCase")]
326struct EventsQuery {
327    #[serde(default)]
328    since: Option<i64>,
329    #[serde(default)]
330    limit: Option<usize>,
331    #[serde(default)]
332    channel_id: Option<String>,
333}
334
335#[derive(Clone)]
336struct TeamCtxServer {
337    default_workspace_id: String,
338    roots: Arc<HashMap<String, String>>,
339}
340
341impl TeamCtxServer {
342    fn default_root(&self) -> &str {
343        self.roots
344            .get(&self.default_workspace_id)
345            .expect("default workspace root")
346    }
347
348    fn rewrite_dot_paths(args: &mut Map<String, Value>, root: &str) {
349        for k in ["path", "target_directory", "targetDirectory"] {
350            let Some(Value::String(s)) = args.get(k) else {
351                continue;
352            };
353            let t = s.trim();
354            if t.is_empty() || t == "." {
355                args.insert(k.to_string(), Value::String(root.to_string()));
356            }
357        }
358    }
359
360    fn pick_workspace(
361        &self,
362        args: &mut Map<String, Value>,
363    ) -> std::result::Result<(String, String), rmcp::ErrorData> {
364        let ws = args
365            .get(WORKSPACE_ARG_KEY)
366            .and_then(|v| v.as_str())
367            .unwrap_or(self.default_workspace_id.as_str())
368            .to_string();
369        args.remove(WORKSPACE_ARG_KEY);
370
371        let root = self
372            .roots
373            .get(&ws)
374            .cloned()
375            .ok_or_else(|| rmcp::ErrorData::invalid_params("unknown workspaceId", None))?;
376        Self::rewrite_dot_paths(args, &root);
377        Ok((ws, root))
378    }
379
380    fn make_server(&self, workspace_id: &str, channel_id: &str) -> LeanCtxServer {
381        let root = self
382            .roots
383            .get(workspace_id)
384            .cloned()
385            .unwrap_or_else(|| self.default_root().to_string());
386        LeanCtxServer::new_shared_with_context(&root, workspace_id, channel_id)
387    }
388}
389
390impl ServerHandler for TeamCtxServer {
391    fn get_info(&self) -> rmcp::model::ServerInfo {
392        let s = self.make_server(&self.default_workspace_id, "default");
393        <LeanCtxServer as ServerHandler>::get_info(&s)
394    }
395
396    async fn initialize(
397        &self,
398        request: rmcp::model::InitializeRequestParams,
399        context: RequestContext<RoleServer>,
400    ) -> std::result::Result<rmcp::model::InitializeResult, rmcp::ErrorData> {
401        let s = self.make_server(&self.default_workspace_id, "default");
402        <LeanCtxServer as ServerHandler>::initialize(&s, request, context).await
403    }
404
405    async fn list_tools(
406        &self,
407        request: Option<rmcp::model::PaginatedRequestParams>,
408        context: RequestContext<RoleServer>,
409    ) -> std::result::Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
410        let s = self.make_server(&self.default_workspace_id, "default");
411        <LeanCtxServer as ServerHandler>::list_tools(&s, request, context).await
412    }
413
414    async fn call_tool(
415        &self,
416        mut request: CallToolRequestParams,
417        context: RequestContext<RoleServer>,
418    ) -> std::result::Result<CallToolResult, rmcp::ErrorData> {
419        let mut args = request.arguments.take().unwrap_or_default();
420        let (ws, root) = self.pick_workspace(&mut args)?;
421        let channel = args
422            .get(CHANNEL_ARG_KEY)
423            .and_then(|v| v.as_str())
424            .unwrap_or("default")
425            .to_string();
426        args.remove(CHANNEL_ARG_KEY);
427        // Per-call agent identity (enterprise#28): the per-call server instance
428        // starts with no registered agent, so identity-dependent tools
429        // (ctx_share, ctx_agent post/read) receive it via argument — on the
430        // REST surface it is the authenticated token id, injected server-side.
431        let agent = args
432            .get(AGENT_ARG_KEY)
433            .and_then(|v| v.as_str())
434            .map(str::to_string);
435        args.remove(AGENT_ARG_KEY);
436        // Re-apply dot path rewriting against the resolved root.
437        Self::rewrite_dot_paths(&mut args, &root);
438        request.arguments = Some(args);
439        let s = LeanCtxServer::new_shared_with_context(&root, &ws, &channel);
440        if let Some(agent) = agent {
441            *s.agent_id.write().await = Some(agent);
442        }
443        <LeanCtxServer as ServerHandler>::call_tool(&s, request, context).await
444    }
445}
446
447struct TeamContextEngine {
448    server: TeamCtxServer,
449    next_id: AtomicI64,
450}
451
452impl TeamContextEngine {
453    fn new(server: TeamCtxServer) -> Self {
454        Self {
455            server,
456            next_id: AtomicI64::new(1),
457        }
458    }
459
460    fn manifest_value() -> Value {
461        crate::core::mcp_manifest::manifest_value()
462    }
463
464    async fn call_tool_value(&self, name: &str, arguments: Option<Value>) -> Result<Value> {
465        let result = self.call_tool_result(name, arguments).await?;
466        serde_json::to_value(result).map_err(|e| anyhow!("serialize CallToolResult: {e}"))
467    }
468
469    async fn call_tool_result(
470        &self,
471        name: &str,
472        arguments: Option<Value>,
473    ) -> Result<CallToolResult> {
474        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
475        let req_id = NumberOrString::Number(id);
476
477        let args_obj: Map<String, Value> = match arguments {
478            None => Map::new(),
479            Some(Value::Object(m)) => m,
480            Some(other) => {
481                return Err(anyhow!(
482                    "tool arguments must be a JSON object (got {other})"
483                ));
484            }
485        };
486
487        let params = CallToolRequestParams::new(name.to_string()).with_arguments(args_obj);
488        let call: CallToolRequest = CallToolRequest::new(params);
489        let client_req = ClientRequest::CallToolRequest(call);
490        let msg = ClientJsonRpcMessage::Request(JsonRpcRequest::new(req_id, client_req));
491
492        let (transport, mut rx) = OneshotTransport::<RoleServer>::new(msg);
493        let service = serve_directly(self.server.clone(), transport, None);
494        tokio::spawn(async move {
495            let _ = service.waiting().await;
496        });
497
498        let Some(server_msg) = rx.recv().await else {
499            return Err(anyhow!("no response from tool call"));
500        };
501
502        match server_msg {
503            ServerJsonRpcMessage::Response(r) => match r.result {
504                ServerResult::CallToolResult(result) => Ok(result),
505                other => Err(anyhow!("unexpected server result: {other:?}")),
506            },
507            ServerJsonRpcMessage::Error(e) => Err(anyhow!("{e:?}")).context("tool call error"),
508            ServerJsonRpcMessage::Notification(_) => Err(anyhow!("unexpected notification")),
509            ServerJsonRpcMessage::Request(_) => Err(anyhow!("unexpected request")),
510        }
511    }
512}
513
514fn sha256_hex(bytes: &[u8]) -> String {
515    let mut h = Sha256::new();
516    h.update(bytes);
517    let digest = h.finalize();
518    hex_lower(&digest)
519}
520
521fn hex_lower(bytes: &[u8]) -> String {
522    const HEX: &[u8; 16] = b"0123456789abcdef";
523    let mut out = Vec::with_capacity(bytes.len() * 2);
524    for &b in bytes {
525        out.push(HEX[(b >> 4) as usize]);
526        out.push(HEX[(b & 0x0f) as usize]);
527    }
528    String::from_utf8(out).unwrap_or_default()
529}
530
531fn parse_sha256_hex(s: &str) -> Result<Vec<u8>> {
532    let s = s.trim();
533    if s.len() != 64 {
534        return Err(anyhow!("sha256 hex must be 64 chars"));
535    }
536    let mut out = Vec::with_capacity(32);
537    let bytes = s.as_bytes();
538    let to_nibble = |c: u8| -> Option<u8> {
539        match c {
540            b'0'..=b'9' => Some(c - b'0'),
541            b'a'..=b'f' => Some(c - b'a' + 10),
542            b'A'..=b'F' => Some(c - b'A' + 10),
543            _ => None,
544        }
545    };
546    for i in (0..64).step_by(2) {
547        let hi = to_nibble(bytes[i]).ok_or_else(|| anyhow!("invalid hex"))?;
548        let lo = to_nibble(bytes[i + 1]).ok_or_else(|| anyhow!("invalid hex"))?;
549        out.push((hi << 4) | lo);
550    }
551    Ok(out)
552}
553
554fn required_scopes(tool_name: &str, args: Option<&Value>) -> Option<BTreeSet<TeamScope>> {
555    if matches!(tool_name, "ctx_shell" | "ctx_execute" | "ctx_edit") {
556        return None;
557    }
558
559    if tool_name == "ctx" {
560        let Value::Object(m) = args? else {
561            return None;
562        };
563        let sub = m.get("tool")?.as_str()?.trim();
564        if sub.is_empty() {
565            return None;
566        }
567        let canonical = if sub.starts_with("ctx_") {
568            sub.to_string()
569        } else {
570            format!("ctx_{sub}")
571        };
572        let mut m2 = m.clone();
573        m2.remove("tool");
574        return required_scopes(&canonical, Some(&Value::Object(m2)));
575    }
576
577    let mut s = BTreeSet::new();
578    match tool_name {
579        // Search scope (read/discovery/analysis)
580        "ctx_read" | "ctx_multi_read" | "ctx_smart_read" | "ctx_search" | "ctx_tree"
581        | "ctx_outline" | "ctx_expand" | "ctx_delta" | "ctx_dedup" | "ctx_prefetch"
582        | "ctx_preload" | "ctx_review" | "ctx_response" | "ctx_task" | "ctx_overview"
583        | "ctx_architecture" | "ctx_benchmark" | "ctx_cost" | "ctx_intent" | "ctx_heatmap"
584        | "ctx_gain" | "ctx_analyze" | "ctx_discover_tools" | "ctx_discover" | "ctx_symbol"
585        | "ctx_index" | "ctx_metrics" | "ctx_cache" | "ctx_agent" => {
586            s.insert(TeamScope::Search);
587            Some(s)
588        }
589        // Pack needs search + graph (it includes impact/graph-derived context)
590        "ctx_pack" => {
591            s.insert(TeamScope::Search);
592            s.insert(TeamScope::Graph);
593            Some(s)
594        }
595        // Graph scope
596        "ctx_graph" | "ctx_impact" | "ctx_callgraph" | "ctx_routes" => {
597            s.insert(TeamScope::Graph);
598
599            if tool_name == "ctx_graph" {
600                let action = args
601                    .and_then(|v| v.get("action"))
602                    .and_then(|v| v.as_str())
603                    .unwrap_or("");
604                if matches!(
605                    action,
606                    "index-build"
607                        | "index-build-full"
608                        | "index-build-background"
609                        | "index-build-full-background"
610                ) {
611                    s.insert(TeamScope::Index);
612                }
613            }
614
615            Some(s)
616        }
617        "ctx_semantic_search" => {
618            s.insert(TeamScope::Search);
619            if args
620                .and_then(|v| v.get("artifacts"))
621                .and_then(Value::as_bool)
622                .unwrap_or(false)
623            {
624                s.insert(TeamScope::Artifacts);
625            }
626            if args
627                .and_then(|v| v.get("action"))
628                .and_then(|v| v.as_str())
629                .is_some_and(|v| v.eq_ignore_ascii_case("reindex"))
630            {
631                s.insert(TeamScope::Index);
632            }
633            Some(s)
634        }
635        // Session-mutating tools
636        "ctx_session" | "ctx_handoff" | "ctx_workflow" | "ctx_compress" | "ctx_share" => {
637            s.insert(TeamScope::SessionMutations);
638            Some(s)
639        }
640        // Knowledge tools
641        "ctx_knowledge" | "ctx_knowledge_relations" => {
642            s.insert(TeamScope::Knowledge);
643            Some(s)
644        }
645        // Artifact + proof tools
646        "ctx_artifacts" | "ctx_proof" | "ctx_verify" => {
647            s.insert(TeamScope::Artifacts);
648            Some(s)
649        }
650        _ => None,
651    }
652}
653
654/// Records latency and server-error outcome of every team API request into
655/// the process-global SLO store (GL #391). Runs as the outermost layer so the
656/// measured latency matches what a client (or the synthetic probe) observes —
657/// auth, rate limiting and the handler itself are all included. `/health` and
658/// MCP fallback traffic stay unmeasured: the SLO gate is defined over the
659/// `/v1` HTTP surface.
660async fn team_slo_middleware(req: Request<Body>, next: Next) -> Response {
661    let measured = {
662        let p = req.uri().path();
663        p.starts_with("/v1/") || p.starts_with("/api/v1/")
664    };
665    let start = std::time::Instant::now();
666    let res = next.run(req).await;
667    if measured {
668        crate::core::team_slo::global().record_request(
669            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
670            !res.status().is_server_error(),
671        );
672    }
673    res
674}
675
676async fn team_rate_limit_middleware(
677    State(state): State<TeamAppState>,
678    req: Request<Body>,
679    next: Next,
680) -> Response {
681    if req.uri().path() == "/health" {
682        return next.run(req).await;
683    }
684    if !state.rate.allow().await {
685        return StatusCode::TOO_MANY_REQUESTS.into_response();
686    }
687    next.run(req).await
688}
689
690async fn team_concurrency_middleware(
691    State(state): State<TeamAppState>,
692    req: Request<Body>,
693    next: Next,
694) -> Response {
695    if req.uri().path() == "/health" {
696        return next.run(req).await;
697    }
698    let Ok(permit) = state.concurrency.clone().try_acquire_owned() else {
699        return StatusCode::TOO_MANY_REQUESTS.into_response();
700    };
701    let resp = next.run(req).await;
702    drop(permit);
703    resp
704}
705
706async fn team_auth_middleware(
707    State(state): State<TeamAppState>,
708    mut req: Request<Body>,
709    next: Next,
710) -> Response {
711    if req.uri().path() == "/health" {
712        return next.run(req).await;
713    }
714
715    let Some(h) = req.headers().get(header::AUTHORIZATION) else {
716        return super::json_error(
717            StatusCode::UNAUTHORIZED,
718            "unauthorized",
719            "missing Authorization header",
720        );
721    };
722    let Ok(s) = h.to_str() else {
723        return super::json_error(
724            StatusCode::UNAUTHORIZED,
725            "unauthorized",
726            "malformed Authorization header",
727        );
728    };
729    let Some(token) = s
730        .strip_prefix("Bearer ")
731        .or_else(|| s.strip_prefix("bearer "))
732    else {
733        return super::json_error(
734            StatusCode::UNAUTHORIZED,
735            "unauthorized",
736            "Authorization must use the Bearer scheme",
737        );
738    };
739
740    let token_hash = sha256_hex(token.as_bytes());
741    let mut matched: Option<TeamTokenConfig> = None;
742    for t in state.team.auth.iter() {
743        if super::constant_time_eq(token_hash.as_bytes(), t.sha256_hex.as_bytes()) {
744            matched = Some(t.clone());
745            break;
746        }
747    }
748    let Some(tok) = matched else {
749        return super::json_error(
750            StatusCode::UNAUTHORIZED,
751            "unauthorized",
752            "invalid bearer token",
753        );
754    };
755    let tok_scopes: BTreeSet<TeamScope> = tok.effective_scopes();
756
757    let workspace_id = req
758        .headers()
759        .get(WORKSPACE_HEADER)
760        .and_then(|h| h.to_str().ok())
761        .map(|s| s.trim().to_string())
762        .filter(|s| !s.is_empty())
763        .unwrap_or_else(|| state.team.engine.server.default_workspace_id.clone());
764    if !state.team.engine.server.roots.contains_key(&workspace_id) {
765        return super::json_error(
766            StatusCode::BAD_REQUEST,
767            "unknown_workspace",
768            "unknown workspace",
769        );
770    }
771    let workspace_id_for_audit = workspace_id.clone();
772
773    req.extensions_mut().insert(TeamAuthContext {
774        token_id: tok.id.clone(),
775        scopes: tok_scopes.clone(),
776    });
777    req.extensions_mut()
778        .insert(TeamRequestContext { workspace_id });
779
780    // Endpoint-level authz (non-tool endpoints).
781    let path0 = req.uri().path();
782    if path0 == "/v1/events" {
783        let allow = tok_scopes.contains(&TeamScope::Events);
784        let _ = audit_write(
785            &state.team.audit,
786            &tok.id,
787            &workspace_id_for_audit,
788            None,
789            Some("events"),
790            allow,
791            if allow { None } else { Some("scope_denied") },
792            None,
793        )
794        .await;
795        if !allow {
796            return super::json_error(
797                StatusCode::FORBIDDEN,
798                "scope_denied",
799                "token lacks required scope: events",
800            );
801        }
802    }
803
804    if path0 == "/v1/metrics" {
805        let allow = tok_scopes.contains(&TeamScope::Audit);
806        let _ = audit_write(
807            &state.team.audit,
808            &tok.id,
809            &workspace_id_for_audit,
810            None,
811            Some("metrics"),
812            allow,
813            if allow { None } else { Some("scope_denied") },
814            None,
815        )
816        .await;
817        if !allow {
818            return super::json_error(
819                StatusCode::FORBIDDEN,
820                "scope_denied",
821                "token lacks required scope: audit",
822            );
823        }
824    }
825
826    // Billing-plane reads (savings roll-up, storage/usage reports) share the
827    // audit sensitivity class: owner/admin + the control plane's audit token.
828    let audit_gated = match path0 {
829        "/v1/savings/summary" => Some("savings_summary"),
830        "/v1/storage" => Some("storage"),
831        "/v1/usage" => Some("usage"),
832        "/v1/connectors" => Some("connectors"),
833        p if p.starts_with("/v1/savings/member/") => Some("savings_member"),
834        _ => None,
835    };
836    if let Some(action) = audit_gated {
837        let allow = tok_scopes.contains(&TeamScope::Audit);
838        let _ = audit_write(
839            &state.team.audit,
840            &tok.id,
841            &workspace_id_for_audit,
842            None,
843            Some(action),
844            allow,
845            if allow { None } else { Some("scope_denied") },
846            None,
847        )
848        .await;
849        if !allow {
850            return super::json_error(
851                StatusCode::FORBIDDEN,
852                "scope_denied",
853                "token lacks required scope: audit",
854            );
855        }
856    }
857
858    // Tool-level authz for MCP fallback (tools/call).
859    let path = req.uri().path().to_string();
860    if path != "/v1/tools/call"
861        && path != "/v1/tools"
862        && path != "/v1/manifest"
863        && path != "/health"
864    {
865        if req.method() != axum::http::Method::POST {
866            return next.run(req).await;
867        }
868
869        let (parts, body0) = req.into_parts();
870        let Ok(bytes) = body::to_bytes(body0, state.max_body_bytes).await else {
871            return super::json_error(
872                StatusCode::BAD_REQUEST,
873                "invalid_request",
874                "could not read request body",
875            );
876        };
877
878        let mut allow = false;
879        let mut denied_reason: Option<String> = None;
880        if let Ok(v) = serde_json::from_slice::<Value>(&bytes) {
881            if v.is_array() {
882                denied_reason = Some("batch_requests_not_supported".to_string());
883                let _ = audit_write(
884                    &state.team.audit,
885                    &tok.id,
886                    &workspace_id_for_audit,
887                    None,
888                    None,
889                    false,
890                    denied_reason.as_deref(),
891                    None,
892                )
893                .await;
894            } else {
895                let method = v.get("method").and_then(|m| m.as_str()).unwrap_or("");
896                if method.eq_ignore_ascii_case("tools/call") {
897                    let tool = v
898                        .pointer("/params/name")
899                        .and_then(|x| x.as_str())
900                        .unwrap_or("");
901                    let args = v.pointer("/params/arguments");
902                    let req_scopes = required_scopes(tool, args);
903                    allow = match req_scopes {
904                        None => false,
905                        Some(reqs) => reqs.is_subset(&tok_scopes),
906                    };
907                    if !allow {
908                        denied_reason = Some("scope_denied".to_string());
909                    }
910                    let _ = audit_write(
911                        &state.team.audit,
912                        &tok.id,
913                        &workspace_id_for_audit,
914                        Some(tool),
915                        Some(method),
916                        allow,
917                        denied_reason.as_deref(),
918                        args,
919                    )
920                    .await;
921                } else {
922                    allow = true;
923                }
924            }
925        }
926
927        if !allow {
928            return super::json_error(
929                StatusCode::FORBIDDEN,
930                "scope_denied",
931                "token lacks required scope for this tool",
932            );
933        }
934
935        req = Request::from_parts(parts, Body::from(bytes));
936    }
937
938    next.run(req).await
939}
940
941async fn audit_write(
942    file: &tokio::sync::Mutex<tokio::fs::File>,
943    token_id: &str,
944    workspace_id: &str,
945    tool: Option<&str>,
946    method: Option<&str>,
947    allowed: bool,
948    denied_reason: Option<&str>,
949    args: Option<&Value>,
950) -> Result<()> {
951    let args_hash = args
952        .map(|a| {
953            let s = a.to_string();
954            let mut hasher = Md5::new();
955            hasher.update(s.as_bytes());
956            crate::core::agent_identity::hex_encode(&hasher.finalize())
957        })
958        .unwrap_or_default();
959
960    let ts = chrono::Local::now().to_rfc3339();
961    let rec = json!({
962        "ts": ts,
963        "tokenId": token_id,
964        "workspaceId": workspace_id,
965        "tool": tool,
966        "method": method,
967        "allowed": allowed,
968        "deniedReason": denied_reason,
969        "argumentsMd5": args_hash,
970    });
971
972    let mut guard = file.lock().await;
973    guard.write_all(rec.to_string().as_bytes()).await?;
974    guard.write_all(b"\n").await?;
975    guard.flush().await?;
976    Ok(())
977}
978
979/// Event-level audit entry: records who triggered which Context OS event.
980async fn audit_event(
981    file: &tokio::sync::Mutex<tokio::fs::File>,
982    token_id: &str,
983    workspace_id: &str,
984    channel_id: &str,
985    event_kind: &str,
986    actor: Option<&str>,
987    event_id: i64,
988) -> Result<()> {
989    let ts = chrono::Local::now().to_rfc3339();
990    let rec = json!({
991        "ts": ts,
992        "type": "context_event",
993        "tokenId": token_id,
994        "workspaceId": workspace_id,
995        "channelId": channel_id,
996        "eventKind": event_kind,
997        "actor": actor,
998        "eventId": event_id,
999    });
1000
1001    let mut guard = file.lock().await;
1002    guard.write_all(rec.to_string().as_bytes()).await?;
1003    guard.write_all(b"\n").await?;
1004    guard.flush().await?;
1005    Ok(())
1006}
1007
1008async fn v1_manifest(State(_state): State<TeamAppState>) -> impl IntoResponse {
1009    let v = TeamContextEngine::manifest_value();
1010    (StatusCode::OK, Json(v))
1011}
1012
1013async fn v1_tools(
1014    State(_state): State<TeamAppState>,
1015    Query(q): Query<ToolsQuery>,
1016) -> impl IntoResponse {
1017    let v = TeamContextEngine::manifest_value();
1018    let tools = v
1019        .get("tools")
1020        .and_then(|t| t.get("granular"))
1021        .cloned()
1022        .unwrap_or(Value::Array(vec![]));
1023
1024    let all = tools.as_array().cloned().unwrap_or_default();
1025    let total = all.len();
1026    let offset = q.offset.unwrap_or(0).min(total);
1027    let limit = q.limit.unwrap_or(200).min(500);
1028    let page = all.into_iter().skip(offset).take(limit).collect::<Vec<_>>();
1029
1030    (
1031        StatusCode::OK,
1032        Json(json!({
1033            "tools": page,
1034            "total": total,
1035            "offset": offset,
1036            "limit": limit,
1037        })),
1038    )
1039}
1040
1041async fn v1_tool_call(
1042    State(state): State<TeamAppState>,
1043    Extension(auth): Extension<TeamAuthContext>,
1044    Extension(ctx): Extension<TeamRequestContext>,
1045    Json(body): Json<ToolCallBody>,
1046) -> impl IntoResponse {
1047    let workspace_id = body
1048        .workspace_id
1049        .clone()
1050        .unwrap_or_else(|| ctx.workspace_id.clone());
1051    if !state.team.engine.server.roots.contains_key(&workspace_id) {
1052        let _ = audit_write(
1053            &state.team.audit,
1054            &auth.token_id,
1055            &workspace_id,
1056            Some(&body.name),
1057            Some("/v1/tools/call"),
1058            false,
1059            Some("unknown_workspace"),
1060            body.arguments.as_ref(),
1061        )
1062        .await;
1063        return super::json_error(
1064            StatusCode::BAD_REQUEST,
1065            "unknown_workspace",
1066            "unknown workspace",
1067        );
1068    }
1069
1070    let mut args = match body.arguments {
1071        None => Value::Object(Map::new()),
1072        Some(Value::Object(m)) => Value::Object(m),
1073        Some(other) => {
1074            let _ = audit_write(
1075                &state.team.audit,
1076                &auth.token_id,
1077                &workspace_id,
1078                Some(&body.name),
1079                Some("/v1/tools/call"),
1080                false,
1081                Some("invalid_arguments"),
1082                Some(&other),
1083            )
1084            .await;
1085            return super::json_error(
1086                StatusCode::BAD_REQUEST,
1087                "invalid_arguments",
1088                &format!("tool arguments must be a JSON object (got {other})"),
1089            );
1090        }
1091    };
1092
1093    if let Value::Object(ref mut m) = args {
1094        m.insert(
1095            WORKSPACE_ARG_KEY.to_string(),
1096            Value::String(workspace_id.clone()),
1097        );
1098        if let Some(ch) = body.channel_id.as_deref()
1099            && !ch.trim().is_empty()
1100        {
1101            m.insert(
1102                CHANNEL_ARG_KEY.to_string(),
1103                Value::String(ch.trim().to_string()),
1104            );
1105        }
1106        // Auth-derived agent identity (enterprise#28): overwrite unconditionally
1107        // so a REST client can never impersonate another token's agent.
1108        m.insert(
1109            AGENT_ARG_KEY.to_string(),
1110            Value::String(format!("team:{}", auth.token_id)),
1111        );
1112    }
1113
1114    let required = required_scopes(&body.name, Some(&args));
1115    // Index-mutating calls (anything requiring the Index scope) reset the
1116    // hosted-index freshness baseline once they succeed (GL #391).
1117    let mutates_index = required
1118        .as_ref()
1119        .is_some_and(|reqs| reqs.contains(&TeamScope::Index));
1120    let allowed = match required {
1121        None => false,
1122        Some(reqs) => reqs.is_subset(&auth.scopes),
1123    };
1124    if !allowed {
1125        let _ = audit_write(
1126            &state.team.audit,
1127            &auth.token_id,
1128            &workspace_id,
1129            Some(&body.name),
1130            Some("/v1/tools/call"),
1131            false,
1132            Some("scope_denied"),
1133            Some(&args),
1134        )
1135        .await;
1136        return super::json_error(
1137            StatusCode::FORBIDDEN,
1138            "scope_denied",
1139            "token lacks required scope for this tool",
1140        );
1141    }
1142
1143    let tool_name = body.name.clone();
1144    let call = tokio::time::timeout(
1145        state.timeout,
1146        state
1147            .team
1148            .engine
1149            .call_tool_value(&tool_name, Some(args.clone())),
1150    )
1151    .await;
1152
1153    match call {
1154        Ok(Ok(v)) => {
1155            if mutates_index {
1156                crate::core::team_slo::global().record_index_write();
1157            }
1158            let _ = audit_write(
1159                &state.team.audit,
1160                &auth.token_id,
1161                &workspace_id,
1162                Some(&tool_name),
1163                Some("/v1/tools/call"),
1164                true,
1165                None,
1166                Some(&args),
1167            )
1168            .await;
1169            (StatusCode::OK, Json(json!({ "result": v }))).into_response()
1170        }
1171        Ok(Err(e)) => {
1172            let _ = audit_write(
1173                &state.team.audit,
1174                &auth.token_id,
1175                &workspace_id,
1176                Some(&tool_name),
1177                Some("/v1/tools/call"),
1178                true,
1179                Some("tool_error"),
1180                Some(&args),
1181            )
1182            .await;
1183            {
1184                tracing::warn!("team tool call error: {e}");
1185                super::json_error(
1186                    StatusCode::BAD_REQUEST,
1187                    "tool_error",
1188                    "tool execution failed",
1189                )
1190            }
1191        }
1192        Err(_) => {
1193            let _ = audit_write(
1194                &state.team.audit,
1195                &auth.token_id,
1196                &workspace_id,
1197                Some(&tool_name),
1198                Some("/v1/tools/call"),
1199                true,
1200                Some("request_timeout"),
1201                Some(&args),
1202            )
1203            .await;
1204            super::json_error(
1205                StatusCode::GATEWAY_TIMEOUT,
1206                "request_timeout",
1207                "tool call timed out",
1208            )
1209        }
1210    }
1211}
1212
1213async fn v1_events(
1214    State(state): State<TeamAppState>,
1215    Extension(auth): Extension<TeamAuthContext>,
1216    Extension(ctx): Extension<TeamRequestContext>,
1217    Query(q): Query<EventsQuery>,
1218) -> Sse<impl Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
1219    let ws = ctx.workspace_id;
1220    let ch = q.channel_id.unwrap_or_else(|| "default".to_string());
1221    let since = q.since.unwrap_or(0);
1222    let limit = q.limit.unwrap_or(200).min(1000);
1223
1224    let _ = audit_event(
1225        &state.team.audit,
1226        &auth.token_id,
1227        &ws,
1228        &ch,
1229        "sse_subscribe",
1230        None,
1231        since,
1232    )
1233    .await;
1234
1235    let rt = crate::core::context_os::runtime();
1236    let replay = rt.bus.read(&ws, &ch, since, limit);
1237    let rx = if let Some(rx) = rt.bus.subscribe(&ws, &ch) {
1238        rx
1239    } else {
1240        tracing::warn!("SSE subscriber limit reached for {ws}/{ch}");
1241        let (_, rx) = tokio::sync::broadcast::channel::<crate::core::context_os::ContextEventV1>(1);
1242        rx
1243    };
1244    rt.metrics.record_sse_connect();
1245    rt.metrics.record_events_replayed(replay.len() as u64);
1246    rt.metrics.record_workspace_active(&ws);
1247
1248    let bus = rt.bus.clone();
1249    let metrics = rt.metrics.clone();
1250    let pending: std::collections::VecDeque<crate::core::context_os::ContextEventV1> =
1251        replay.into();
1252
1253    use crate::core::context_os::{RedactionLevel, redact_event_payload};
1254    let redaction = RedactionLevel::RefsOnly;
1255
1256    let stream = futures::stream::unfold(
1257        (
1258            pending,
1259            rx,
1260            ws.clone(),
1261            ch.clone(),
1262            since,
1263            redaction,
1264            bus,
1265            metrics,
1266        ),
1267        |(mut pending, mut rx, ws, ch, mut last_id, redaction, bus, metrics)| async move {
1268            if let Some(mut ev) = pending.pop_front() {
1269                last_id = ev.id;
1270                redact_event_payload(&mut ev, redaction);
1271                let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".to_string());
1272                let evt = SseEvent::default()
1273                    .id(ev.id.to_string())
1274                    .event(ev.kind)
1275                    .data(data);
1276                return Some((
1277                    Ok(evt),
1278                    (pending, rx, ws, ch, last_id, redaction, bus, metrics),
1279                ));
1280            }
1281
1282            loop {
1283                match rx.recv().await {
1284                    Ok(mut ev) if ev.id > last_id => {
1285                        last_id = ev.id;
1286                        redact_event_payload(&mut ev, redaction);
1287                        let data = serde_json::to_string(&ev).unwrap_or_else(|_| "{}".to_string());
1288                        let evt = SseEvent::default()
1289                            .id(ev.id.to_string())
1290                            .event(ev.kind)
1291                            .data(data);
1292                        return Some((
1293                            Ok(evt),
1294                            (pending, rx, ws, ch, last_id, redaction, bus, metrics),
1295                        ));
1296                    }
1297                    Ok(_) => {}
1298                    Err(broadcast::error::RecvError::Closed) => return None,
1299                    Err(broadcast::error::RecvError::Lagged(skipped)) => {
1300                        let missed = bus.read(&ws, &ch, last_id, skipped as usize);
1301                        metrics.record_events_replayed(missed.len() as u64);
1302                        for ev in missed {
1303                            last_id = last_id.max(ev.id);
1304                            pending.push_back(ev);
1305                        }
1306                    }
1307                }
1308            }
1309        },
1310    );
1311
1312    let metrics_ref = rt.metrics.clone();
1313    let guarded = super::SseDisconnectGuard {
1314        inner: Box::pin(stream),
1315        metrics: metrics_ref,
1316    };
1317
1318    Sse::new(guarded).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
1319}
1320
1321#[derive(Debug, Deserialize)]
1322struct MetricsQuery {
1323    /// `?format=prometheus` switches to text exposition for scrape agents
1324    /// (Datadog openmetrics check, Prometheus, Grafana Alloy …).
1325    #[serde(default)]
1326    format: Option<String>,
1327}
1328
1329async fn v1_team_metrics(
1330    State(_state): State<TeamAppState>,
1331    Query(q): Query<MetricsQuery>,
1332) -> Response {
1333    let slo = crate::core::team_slo::global().snapshot();
1334
1335    if q.format.as_deref() == Some("prometheus") {
1336        return (
1337            StatusCode::OK,
1338            [(
1339                axum::http::header::CONTENT_TYPE,
1340                "text/plain; version=0.0.4",
1341            )],
1342            slo.to_prometheus(),
1343        )
1344            .into_response();
1345    }
1346
1347    let rt = crate::core::context_os::runtime();
1348    let snap = rt.metrics.snapshot();
1349    let mut v = serde_json::to_value(snap).unwrap_or_default();
1350    if let Value::Object(ref mut m) = v {
1351        m.insert(
1352            "slo".to_string(),
1353            serde_json::to_value(&slo).unwrap_or_default(),
1354        );
1355    }
1356    (StatusCode::OK, Json(v)).into_response()
1357}
1358
1359fn streamable_http_config(cfg: &TeamServerConfig) -> rmcp::transport::StreamableHttpServerConfig {
1360    let mut out = rmcp::transport::StreamableHttpServerConfig::default()
1361        .with_stateful_mode(cfg.stateful_mode)
1362        .with_json_response(cfg.json_response);
1363
1364    if cfg.disable_host_check {
1365        out = out.disable_allowed_hosts();
1366        return out;
1367    }
1368    if !cfg.allowed_hosts.is_empty() {
1369        out = out.with_allowed_hosts(cfg.allowed_hosts.clone());
1370        return out;
1371    }
1372    let host = cfg.host.trim();
1373    if host == "127.0.0.1" || host == "localhost" || host == "::1" {
1374        out.allowed_hosts.push(host.to_string());
1375    }
1376    out
1377}
1378
1379pub async fn serve_team(cfg: TeamServerConfig) -> Result<()> {
1380    cfg.validate_for_serve()?;
1381
1382    let addr: std::net::SocketAddr = format!("{}:{}", cfg.host, cfg.port)
1383        .parse()
1384        .context("invalid host/port")?;
1385
1386    let team_server = TeamCtxServer {
1387        default_workspace_id: cfg.default_workspace_id.clone(),
1388        roots: Arc::new(
1389            cfg.workspaces
1390                .iter()
1391                .map(|w| (w.id.clone(), w.root.to_string_lossy().to_string()))
1392                .collect(),
1393        ),
1394    };
1395    let engine = Arc::new(TeamContextEngine::new(team_server.clone()));
1396
1397    let audit_file = tokio::fs::OpenOptions::new()
1398        .create(true)
1399        .append(true)
1400        .open(&cfg.audit_log_path)
1401        .await
1402        .with_context(|| format!("open audit log {}", cfg.audit_log_path.display()))?;
1403
1404    let savings_dir = cfg
1405        .audit_log_path
1406        .parent()
1407        .unwrap_or(std::path::Path::new("."))
1408        .join("savings");
1409    let workspace_roots: Vec<(String, std::path::PathBuf)> = cfg
1410        .workspaces
1411        .iter()
1412        .map(|w| (w.id.clone(), w.root.clone()))
1413        .collect();
1414    let storage_roots = super::team_billing::storage_roots_from_config(
1415        &cfg.audit_log_path,
1416        &workspace_roots,
1417        cfg.storage_quota_bytes,
1418    );
1419    // Connector run state lives next to the audit log / savings store on the
1420    // persistent data volume, so per-connector cursors survive redeploys.
1421    let connectors_state_dir = cfg
1422        .audit_log_path
1423        .parent()
1424        .unwrap_or(std::path::Path::new("."))
1425        .join("connectors");
1426    let connectors = Arc::new(cfg.connectors.clone());
1427
1428    // Hosted managed-connector scheduler (#281): scheduled in-process syncs of
1429    // each configured source into the workspace's BM25/graph/knowledge stores,
1430    // paused by the storage quota backstop (#282). A no-op with no connectors.
1431    connectors::spawn_scheduler(
1432        connectors.clone(),
1433        team_server.roots.clone(),
1434        cfg.default_workspace_id.clone(),
1435        connectors_state_dir.clone(),
1436        storage_roots.data_root.clone(),
1437        storage_roots.quota_bytes,
1438        Duration::from_mins(1),
1439    );
1440
1441    let team = Arc::new(TeamState {
1442        auth: Arc::new(cfg.tokens.clone()),
1443        engine,
1444        audit: Arc::new(tokio::sync::Mutex::new(audit_file)),
1445        savings_store_dir: Arc::new(tokio::sync::Mutex::new(savings_dir)),
1446        storage_roots,
1447        storage_cache: Arc::new(tokio::sync::Mutex::new(
1448            super::team_billing::StorageCache::default(),
1449        )),
1450        connectors,
1451        connectors_state_dir: Arc::new(connectors_state_dir),
1452    });
1453
1454    let state = TeamAppState {
1455        concurrency: Arc::new(tokio::sync::Semaphore::new(cfg.max_concurrency.max(1))),
1456        rate: Arc::new(super::RateLimiter::new(cfg.max_rps, cfg.rate_burst)),
1457        timeout: Duration::from_millis(cfg.request_timeout_ms.max(1)),
1458        team,
1459        max_body_bytes: cfg.max_body_bytes,
1460    };
1461
1462    let service_factory =
1463        move || -> std::result::Result<TeamCtxServer, std::io::Error> { Ok(team_server.clone()) };
1464    let mcp_http = StreamableHttpService::new(
1465        service_factory,
1466        Arc::new(
1467            rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
1468        ),
1469        streamable_http_config(&cfg),
1470    );
1471
1472    // Weekly team-ROI webhook (GL #388): validated at boot so a bad URL is a
1473    // loud startup error, not a silent weekly no-op.
1474    if let Some(url) = &cfg.roi_webhook_url {
1475        super::roi_webhook::validate_webhook_url(url)
1476            .map_err(|e| anyhow!("invalid roiWebhookUrl in team config: {e}"))?;
1477        super::roi_webhook::spawn_weekly_roi_webhook(state.clone(), url.clone());
1478        tracing::info!("team ROI webhook enabled (weekly)");
1479    }
1480
1481    let app = Router::new()
1482        .route("/health", get(super::health))
1483        .route("/v1/manifest", get(v1_manifest))
1484        .route("/v1/tools", get(v1_tools))
1485        .route("/v1/tools/call", axum::routing::post(v1_tool_call))
1486        .route("/v1/events", get(v1_events))
1487        .route(
1488            "/v1/context/summary",
1489            get(super::context_views::v1_context_summary),
1490        )
1491        .route(
1492            "/v1/events/search",
1493            get(super::context_views::v1_events_search),
1494        )
1495        .route(
1496            "/v1/events/lineage",
1497            get(super::context_views::v1_event_lineage),
1498        )
1499        .route("/v1/metrics", get(v1_team_metrics))
1500        .route(
1501            "/v1/savings/summary",
1502            get(super::savings_summary::v1_savings_summary),
1503        )
1504        .route(
1505            "/v1/savings/member/{signer}",
1506            get(super::savings_summary::v1_savings_member),
1507        )
1508        .route("/v1/storage", get(super::team_billing::v1_storage))
1509        .route("/v1/usage", get(super::team_billing::v1_usage))
1510        .route("/v1/connectors", get(connectors::v1_connectors))
1511        .route(
1512            "/api/v1/savings/ingest",
1513            axum::routing::post(super::savings_ingest::v1_savings_ingest),
1514        )
1515        .fallback_service(mcp_http)
1516        .layer(axum::extract::DefaultBodyLimit::max(cfg.max_body_bytes))
1517        .layer(middleware::from_fn_with_state(
1518            state.clone(),
1519            team_rate_limit_middleware,
1520        ))
1521        .layer(middleware::from_fn_with_state(
1522            state.clone(),
1523            team_concurrency_middleware,
1524        ))
1525        .layer(middleware::from_fn_with_state(
1526            state.clone(),
1527            team_auth_middleware,
1528        ))
1529        // Outermost: SLO measurement sees the full client-observed latency.
1530        .layer(middleware::from_fn(team_slo_middleware))
1531        .with_state(state);
1532
1533    crate::core::team_slo::global().mark_started();
1534
1535    let listener = tokio::net::TcpListener::bind(addr)
1536        .await
1537        .with_context(|| format!("bind {addr}"))?;
1538
1539    tracing::info!(
1540        "lean-ctx TEAM server listening on http://{addr} (workspaces={}, audit={})",
1541        cfg.workspaces.len(),
1542        cfg.audit_log_path.display()
1543    );
1544
1545    axum::serve(listener, app)
1546        .with_graceful_shutdown(async move {
1547            let _ = tokio::signal::ctrl_c().await;
1548        })
1549        .await
1550        .context("team http server")?;
1551    Ok(())
1552}
1553
1554pub fn create_token() -> Result<(String, String)> {
1555    let mut bytes = [0u8; 32];
1556    getrandom::fill(&mut bytes).map_err(|e| anyhow!("getrandom: {e}"))?;
1557    let token = hex_lower(&bytes);
1558    let hash = sha256_hex(token.as_bytes());
1559    Ok((token, hash))
1560}