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 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub storage_quota_bytes: Option<u64>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub roi_webhook_url: Option<String>,
87 #[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 pub sha256_hex: String,
127 #[serde(default)]
129 pub scopes: Vec<TeamScope>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub role: Option<roles::TeamRole>,
135}
136
137impl TeamTokenConfig {
138 #[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 #[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 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 pub storage_roots: super::team_billing::StorageRoots,
280 pub storage_cache: Arc<tokio::sync::Mutex<super::team_billing::StorageCache>>,
282 pub connectors: Arc<Vec<connectors::ConnectorConfig>>,
285 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 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 "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 "ctx_pack" => {
574 s.insert(TeamScope::Search);
575 s.insert(TeamScope::Graph);
576 Some(s)
577 }
578 "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 "ctx_session" | "ctx_handoff" | "ctx_workflow" | "ctx_compress" | "ctx_share" => {
620 s.insert(TeamScope::SessionMutations);
621 Some(s)
622 }
623 "ctx_knowledge" | "ctx_knowledge_relations" => {
625 s.insert(TeamScope::Knowledge);
626 Some(s)
627 }
628 "ctx_artifacts" | "ctx_proof" | "ctx_verify" => {
630 s.insert(TeamScope::Artifacts);
631 Some(s)
632 }
633 _ => None,
634 }
635}
636
637async 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 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 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 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
962async 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 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 #[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 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 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 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 .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}