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 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 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub storage_quota_bytes: Option<u64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub roi_webhook_url: Option<String>,
92 #[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 pub sha256_hex: String,
132 #[serde(default)]
134 pub scopes: Vec<TeamScope>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub role: Option<roles::TeamRole>,
140}
141
142impl TeamTokenConfig {
143 #[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 #[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 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 pub storage_roots: super::team_billing::StorageRoots,
285 pub storage_cache: Arc<tokio::sync::Mutex<super::team_billing::StorageCache>>,
287 pub connectors: Arc<Vec<connectors::ConnectorConfig>>,
290 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 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 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 "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 "ctx_pack" => {
591 s.insert(TeamScope::Search);
592 s.insert(TeamScope::Graph);
593 Some(s)
594 }
595 "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 "ctx_session" | "ctx_handoff" | "ctx_workflow" | "ctx_compress" | "ctx_share" => {
637 s.insert(TeamScope::SessionMutations);
638 Some(s)
639 }
640 "ctx_knowledge" | "ctx_knowledge_relations" => {
642 s.insert(TeamScope::Knowledge);
643 Some(s)
644 }
645 "ctx_artifacts" | "ctx_proof" | "ctx_verify" => {
647 s.insert(TeamScope::Artifacts);
648 Some(s)
649 }
650 _ => None,
651 }
652}
653
654async 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 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 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 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
979async 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 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 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 #[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 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 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 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 .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}