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