use std::io::{Cursor, Read as IoRead};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use tracing::{error, info, warn};
use uteke_core::Uteke;
#[derive(Deserialize)]
struct DocCreateRequest {
slug: String,
title: Option<String>,
content: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
parent: Option<String>,
}
#[derive(Deserialize)]
struct DocGetRequest {
id: Option<String>,
slug: Option<String>,
#[serde(default)]
namespace: Option<String>,
}
#[derive(Deserialize)]
struct DocListParams {
#[serde(default)]
namespace: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default)]
roots_only: bool,
#[serde(default)]
parent: Option<String>,
}
#[derive(Deserialize)]
struct DocSearchRequest {
query: String,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default)]
namespace: Option<String>,
#[serde(default = "default_search_mode")]
mode: String,
}
#[derive(Deserialize)]
struct DocMoveRequest {
id: Option<String>,
slug: Option<String>,
#[serde(default)]
new_parent: Option<String>,
#[serde(default)]
namespace: Option<String>,
}
fn default_search_mode() -> String {
"hybrid".to_string()
}
fn resolve_doc_id(req: &DocGetRequest) -> Result<&str, &'static str> {
match (&req.id, &req.slug) {
(Some(id), _) => Ok(id),
(_, Some(slug)) => Ok(slug),
_ => Err("provide either 'id' or 'slug'"),
}
}
fn resolve_doc_id_move(req: &DocMoveRequest) -> Result<&str, &'static str> {
match (&req.id, &req.slug) {
(Some(id), _) => Ok(id),
(_, Some(slug)) => Ok(slug),
_ => Err("provide either 'id' or 'slug'"),
}
}
#[derive(Deserialize)]
struct RememberRequest {
content: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
valid_from: Option<String>,
#[serde(default)]
valid_until: Option<String>,
#[serde(default)]
detect_contradiction: bool,
}
#[derive(Deserialize)]
struct RecallRequest {
query: String,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
entity: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
min_score: Option<f32>,
#[serde(default)]
strict: bool,
#[serde(default)]
at: Option<String>,
}
#[derive(Deserialize)]
struct SearchRequest {
query: String,
#[serde(default = "default_limit_search")]
limit: usize,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
namespace: Option<String>,
}
#[derive(Deserialize)]
struct ListParams {
#[serde(default)]
tag: Option<String>,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default)]
offset: usize,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
at: Option<String>,
}
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
memories: usize,
namespaces: usize,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
fn default_limit() -> usize {
5
}
#[derive(Deserialize)]
struct RoomRecallRequest {
room_id: String,
query: String,
#[serde(default = "default_limit")]
limit: usize,
#[serde(default)]
author: Option<String>,
#[serde(default)]
min_score: Option<f32>,
}
fn default_limit_search() -> usize {
10
}
fn json_header() -> Header {
Header::from_bytes("Content-Type", "application/json").unwrap()
}
fn unauthorized_response(
ctx: &ReqCtx,
req: &Request,
error_msg: &str,
) -> Response<Cursor<Vec<u8>>> {
let mut hdrs = ctx.cors_headers_for(req);
hdrs.push(Header::from_bytes("WWW-Authenticate", "Bearer realm=\"uteke\"").unwrap());
hdrs.push(json_header());
let body = ErrorResponse {
error: error_msg.to_string(),
};
let data = serde_json::to_string(&body).unwrap();
Response::new(
StatusCode::from(401),
hdrs,
Cursor::new(data.into_bytes()),
None,
None,
)
}
fn check_auth(req: &Request, ctx: &ReqCtx) -> Result<AuthResult, Response<Cursor<Vec<u8>>>> {
if ctx.auth_token_hash.is_none() && ctx.read_only_token_hash.is_none() {
return Ok(AuthResult::Disabled);
}
let auth_header = req
.headers()
.iter()
.find(|h| h.field.equiv("Authorization"));
let token = match auth_header {
Some(h) => {
let val = h.value.as_str().trim();
let parts: Vec<&str> = val.split_whitespace().collect();
if parts.len() != 2 {
return Err(unauthorized_response(
ctx,
req,
"Invalid auth header format. Use: Authorization: Bearer <token>",
));
}
if !parts[0].eq_ignore_ascii_case("Bearer") {
return Err(unauthorized_response(
ctx,
req,
"Invalid auth scheme. Use: Authorization: Bearer <token>",
));
}
parts[1]
}
None => {
return Err(unauthorized_response(
ctx,
req,
"Authentication required. Provide Authorization: Bearer <token>",
));
}
};
let provided_hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
if let Some(admin_hash) = &ctx.auth_token_hash {
if constant_time_eq_digest(&provided_hash, admin_hash) {
return Ok(AuthResult::Authenticated(ApiRole::Admin));
}
}
if let Some(ro_hash) = &ctx.read_only_token_hash {
if constant_time_eq_digest(&provided_hash, ro_hash) {
return Ok(AuthResult::Authenticated(ApiRole::ReadOnly));
}
}
Err(unauthorized_response(ctx, req, "Invalid or expired token"))
}
fn constant_time_eq_digest(a: &[u8; 32], b: &[u8; 32]) -> bool {
let mut result: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
result |= x ^ y;
}
result == 0
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ApiRole {
Admin,
ReadOnly,
}
enum AuthResult {
Disabled,
Authenticated(ApiRole),
}
#[derive(Clone)]
struct ReqCtx {
auth_token_hash: Option<[u8; 32]>,
read_only_token_hash: Option<[u8; 32]>,
cors_origins: Vec<String>,
recall_config: Option<RecallFileSection>,
}
impl ReqCtx {
fn resolve_origin_for(&self, req: &Request) -> String {
if self.cors_origins.is_empty() {
return "*".to_string();
}
if let Some(origin_header) = req.headers().iter().find(|h| h.field.equiv("Origin")) {
let origin = origin_header.value.as_str();
if self.cors_origins.iter().any(|o| o == origin) {
return origin.to_string();
}
}
String::new()
}
fn cors_headers_for(&self, req: &Request) -> Vec<Header> {
let origin = self.resolve_origin_for(req);
if origin.is_empty() {
return vec![];
}
let allowed_headers = if self.auth_token_hash.is_some() && self.cors_origins.is_empty() {
"Content-Type"
} else {
"Content-Type, Authorization"
};
vec![
Header::from_bytes("Access-Control-Allow-Origin", origin).unwrap(),
Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
.unwrap(),
Header::from_bytes("Access-Control-Allow-Headers", allowed_headers).unwrap(),
]
}
fn preflight_headers(&self, req: &Request) -> Vec<Header> {
let origin = self.resolve_origin_for(req);
if origin.is_empty() {
return vec![];
}
let allowed_headers_set: &[&str] =
if self.auth_token_hash.is_some() && self.cors_origins.is_empty() {
&["Content-Type", "Accept", "X-Requested-With"]
} else {
&[
"Content-Type",
"Authorization",
"Accept",
"X-Requested-With",
]
};
let allow_headers = req
.headers()
.iter()
.find(|h| h.field.equiv("Access-Control-Request-Headers"))
.map(|h| {
h.value
.as_str()
.split(',')
.map(|s| s.trim())
.filter(|s| {
allowed_headers_set
.iter()
.any(|a| a.eq_ignore_ascii_case(s))
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(String::new);
vec![
Header::from_bytes("Access-Control-Allow-Origin", origin).unwrap(),
Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
.unwrap(),
Header::from_bytes("Access-Control-Allow-Headers", allow_headers).unwrap(),
]
}
fn error_response_for(
&self,
req: &Request,
status: u16,
msg: impl Into<String>,
) -> Response<Cursor<Vec<u8>>> {
let body = ErrorResponse { error: msg.into() };
let data = serde_json::to_string(&body).unwrap();
let mut headers = self.cors_headers_for(req);
headers.push(json_header());
Response::new(
StatusCode::from(status),
headers,
Cursor::new(data.into_bytes()),
None,
None,
)
}
fn ok_response_for<T: Serialize>(&self, req: &Request, body: &T) -> Response<Cursor<Vec<u8>>> {
let data = serde_json::to_string(body)
.unwrap_or_else(|e| serde_json::json!({"error": e.to_string()}).to_string());
let mut headers = self.cors_headers_for(req);
headers.push(json_header());
Response::new(
StatusCode::from(200),
headers,
Cursor::new(data.into_bytes()),
None,
None,
)
}
}
fn read_body<T: serde::de::DeserializeOwned>(reader: &mut dyn IoRead) -> Result<T, String> {
let mut limited = reader.take(uteke_core::MAX_PAYLOAD_SIZE as u64 + 1);
let mut body = String::new();
limited
.read_to_string(&mut body)
.map_err(|e| format!("Failed to read body: {e}"))?;
if body.len() > uteke_core::MAX_PAYLOAD_SIZE {
return Err(format!(
"Payload too large: {} bytes (max {})",
body.len(),
uteke_core::MAX_PAYLOAD_SIZE
));
}
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {e}"))
}
fn url_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut decoded: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
decoded.push(b' ');
}
b'%' if i + 2 < bytes.len() => {
let hex = &s[i + 1..i + 3];
if let Ok(byte) = u8::from_str_radix(hex, 16) {
decoded.push(byte);
i += 2;
} else {
decoded.push(b'%');
}
}
c => decoded.push(c),
}
i += 1;
}
String::from_utf8(decoded).unwrap_or_else(|_| s.to_string())
}
fn parse_query_param(query: &str, key: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let mut kv = pair.splitn(2, '=');
if kv.next()? == key {
Some(url_decode(kv.next()?))
} else {
None
}
})
}
fn parse_query_namespace(path: &str) -> Option<String> {
let query = path.split('?').nth(1)?;
parse_query_param(query, "namespace")
}
fn default_namespace() -> String {
"default".to_string()
}
fn ns(ns: &Option<String>) -> Option<&str> {
ns.as_deref()
}
fn route(uteke: &Mutex<Uteke>, ctx: &ReqCtx, req: &mut Request) -> Response<Cursor<Vec<u8>>> {
let method = req.method().clone();
let path = req.url().to_string();
if method == Method::Options {
return Response::new(
StatusCode::from(204),
ctx.preflight_headers(req),
Cursor::new(Vec::new()),
None,
None,
);
}
let is_health = matches!((&method, path.as_str()), (Method::Get, "/health"));
let auth_role = if !is_health {
match check_auth(req, ctx) {
Ok(role) => role,
Err(resp) => return resp,
}
} else {
AuthResult::Disabled
};
if let AuthResult::Authenticated(ApiRole::ReadOnly) = auth_role {
if method != Method::Get {
return ctx.error_response_for(
req,
403,
"Read-only token cannot perform write operations",
);
}
}
let uteke = match uteke.lock() {
Ok(u) => u,
Err(e) => {
return ctx.error_response_for(req, 500, format!("Internal error: {e}").as_str());
}
};
match (method, path.as_str()) {
(Method::Get, "/health") => {
let total = uteke.count(None).unwrap_or(0);
let namespaces = uteke.list_namespaces().unwrap_or_default().len();
ctx.ok_response_for(
req,
&HealthResponse {
status: "ok",
memories: total,
namespaces,
},
)
}
(Method::Post, "/remember") => match read_body::<RememberRequest>(req.as_reader()) {
Ok(req_data) => {
if let Err(e) = uteke_core::validate_input(&req_data.content, &req_data.tags) {
return ctx.error_response_for(req, 400, e.to_string());
}
let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect();
let mut meta = serde_json::Map::new();
if let Some(t) = &req_data.r#type {
meta.insert("type".into(), serde_json::Value::String(t.clone()));
}
if let Some(vf) = &req_data.valid_from {
meta.insert("valid_from".into(), serde_json::Value::String(vf.clone()));
}
if let Some(vu) = &req_data.valid_until {
meta.insert("valid_until".into(), serde_json::Value::String(vu.clone()));
}
let metadata = if meta.is_empty() {
None
} else {
Some(serde_json::Value::Object(meta))
};
let result = if req_data.detect_contradiction {
uteke
.remember_with_contradiction(
&req_data.content,
&tag_refs,
ns(&req_data.namespace),
req_data.r#type.as_deref(),
true,
0.65,
)
.map(|(id, _)| id)
} else {
uteke.remember(
&req_data.content,
&tag_refs,
metadata,
ns(&req_data.namespace),
)
};
match result {
Ok(id) => ctx.ok_response_for(req, &serde_json::json!({"id": id})),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/recall") => match read_body::<RecallRequest>(req.as_reader()) {
Ok(req_data) => {
let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect();
let tags_filter = if tag_refs.is_empty() {
None
} else {
Some(tag_refs.as_slice())
};
let min_score = if req_data.strict {
req_data.min_score.unwrap_or(
ctx.recall_config
.as_ref()
.and_then(|r| r.min_score_strict)
.unwrap_or(STRICT_THRESHOLD as f64) as f32,
)
} else {
req_data.min_score.unwrap_or(
ctx.recall_config
.as_ref()
.and_then(|r| r.min_score)
.unwrap_or(DEFAULT_MIN_SCORE as f64) as f32,
)
};
let has_meta_filter = req_data.entity.is_some() || req_data.category.is_some();
let fetch_min_score = if has_meta_filter { 0.0 } else { min_score };
let fetch_limit = if has_meta_filter {
(req_data.limit * 10).min(200)
} else {
req_data.limit
};
let point_in_time = match req_data.at.as_deref() {
Some(at_str) => match chrono::DateTime::parse_from_rfc3339(at_str) {
Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
Err(_) => {
return ctx.error_response_for(
req,
400,
format!(
"Invalid 'at' timestamp: {at_str}. Use RFC3339 format (e.g. 2026-06-01T12:00:00Z)"
),
);
}
},
None => None,
};
let recall_result = if let Some(pit) = point_in_time {
uteke.recall_at_time(
&req_data.query,
fetch_limit,
tags_filter,
ns(&req_data.namespace),
pit,
fetch_min_score,
)
} else {
uteke.recall(
&req_data.query,
fetch_limit,
tags_filter,
ns(&req_data.namespace),
fetch_min_score,
)
};
match recall_result {
Ok(raw_results) => {
let mut results: Vec<_> = raw_results
.into_iter()
.filter(|sr| {
if let Some(ent) = &req_data.entity {
let matches = sr
.memory
.metadata
.get("entity")
.and_then(|v| v.as_str())
.is_some_and(|e| e == ent);
if !matches {
return false;
}
}
if let Some(cat) = &req_data.category {
let matches = sr
.memory
.metadata
.get("category")
.and_then(|v| v.as_str())
.is_some_and(|c| c == cat);
if !matches {
return false;
}
}
true
})
.collect::<Vec<_>>();
if min_score > 0.0 {
results.retain(|sr| sr.score >= min_score);
}
results.truncate(req_data.limit);
if results.is_empty() && min_score > 0.0 {
ctx.ok_response_for(
req,
&serde_json::json!({
"results": [],
"total": 0,
"threshold": min_score,
"message": "No memories above similarity threshold"
}),
)
} else {
ctx.ok_response_for(req, &results)
}
}
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/search") => match read_body::<SearchRequest>(req.as_reader()) {
Ok(req_data) => {
let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect();
let tags_filter = if tag_refs.is_empty() {
None
} else {
Some(tag_refs.as_slice())
};
match uteke.search(
&req_data.query,
req_data.limit,
tags_filter,
ns(&req_data.namespace),
) {
Ok(results) => ctx.ok_response_for(req, &results),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/list") => match read_body::<ListParams>(req.as_reader()) {
Ok(req_data) => {
let list_result = match req_data.at.as_deref() {
Some(at_str) => match chrono::DateTime::parse_from_rfc3339(at_str) {
Ok(dt) => {
let pit = dt.with_timezone(&chrono::Utc);
uteke.list_at_time(
req_data.tag.as_deref(),
req_data.limit,
req_data.offset,
ns(&req_data.namespace),
pit,
)
}
Err(_) => {
return ctx.error_response_for(
req,
400,
format!(
"Invalid 'at' timestamp: {at_str}. Use RFC3339 format (e.g. 2026-06-01T12:00:00Z)"
),
);
}
},
None => uteke.list(
req_data.tag.as_deref(),
req_data.limit,
req_data.offset,
ns(&req_data.namespace),
),
};
match list_result {
Ok(memories) => ctx.ok_response_for(req, &memories),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Delete, p) if p == "/forget" || p.starts_with("/forget?") => {
let query = p.split('?').nth(1).unwrap_or("");
let params: std::collections::HashMap<String, String> = query
.split('&')
.filter_map(|pair| {
let mut kv = pair.splitn(2, '=');
Some((kv.next()?.to_string(), kv.next()?.to_string()))
})
.collect();
if let Some(id) = params.get("id") {
if uuid::Uuid::parse_str(id).is_err() {
return ctx.error_response_for(req, 400, format!("Invalid UUID format: {id}"));
}
match uteke.forget(id) {
Ok(()) => ctx.ok_response_for(req, &serde_json::json!({"forgotten": id})),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
} else if let Some(tag) = params.get("tag") {
let namespace = params.get("namespace").map(|s| s.as_str());
match uteke.bulk_forget_by_tag(tag, namespace) {
Ok(result) => ctx.ok_response_for(req, &result),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
} else {
ctx.error_response_for(req, 400, "Provide ?id= or ?tag= parameter")
}
}
(Method::Get, "/stats") => {
let query = req.url().split('?').nth(1).unwrap_or("");
let params: std::collections::HashMap<String, String> = query
.split('&')
.filter_map(|pair| {
let mut kv = pair.splitn(2, '=');
Some((kv.next()?.to_string(), kv.next()?.to_string()))
})
.collect();
let ns_param = params.get("namespace").map(|s| s.as_str());
match uteke.stats(ns_param) {
Ok(stats) => ctx.ok_response_for(req, &stats),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
(Method::Post, "/stats") => {
#[derive(Deserialize)]
struct StatsReq {
namespace: Option<String>,
}
match read_body::<StatsReq>(req.as_reader()) {
Ok(req_data) => match uteke.stats(ns(&req_data.namespace)) {
Ok(stats) => ctx.ok_response_for(req, &stats),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
},
Err(e) => ctx.error_response_for(req, 400, e),
}
}
(Method::Get, "/namespaces") => match uteke.list_namespaces() {
Ok(namespaces) => ctx.ok_response_for(req, &namespaces),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
},
(Method::Get, p) if p == "/graph" || p.starts_with("/graph?") => {
let ns = parse_query_namespace(&path);
match uteke.graph_data(ns.as_deref()) {
Ok(data) => ctx.ok_response_for(req, &data),
Err(e) => {
error!("Graph data error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
(Method::Post, "/room/summary") => {
#[derive(Deserialize)]
struct RoomSummaryRequest {
room_id: String,
}
match read_body::<RoomSummaryRequest>(req.as_reader()) {
Ok(req_data) => match uteke.room_summary(&req_data.room_id) {
Ok(Some(summary)) => ctx.ok_response_for(req, &summary),
Ok(None) => ctx.error_response_for(
req,
404,
format!("Room not found: {}", req_data.room_id),
),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
},
Err(e) => ctx.error_response_for(req, 400, e),
}
}
(Method::Post, "/room/document") => {
#[derive(Deserialize)]
struct RoomDocumentRequest {
room_id: String,
}
match read_body::<RoomDocumentRequest>(req.as_reader()) {
Ok(req_data) => match uteke.room_document(&req_data.room_id) {
Ok(Some(doc)) => ctx.ok_response_for(req, &doc),
Ok(None) => ctx.error_response_for(
req,
404,
format!("Room not found: {}", req_data.room_id),
),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
},
Err(e) => ctx.error_response_for(req, 400, e),
}
}
(Method::Get, p) if p.starts_with("/memory?id=") => {
let id = p.trim_start_matches("/memory?id=");
if uuid::Uuid::parse_str(id).is_err() {
return ctx.error_response_for(req, 400, format!("Invalid UUID format: {id}"));
}
match uteke.get_by_id(id) {
Ok(Some(memory)) => ctx.ok_response_for(req, &memory),
Ok(None) => ctx.error_response_for(req, 404, format!("Memory not found: {id}")),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
(Method::Post, "/room/recall") => match read_body::<RoomRecallRequest>(req.as_reader()) {
Ok(req_data) => {
let min_score = req_data.min_score.unwrap_or(
ctx.recall_config
.as_ref()
.and_then(|r| r.min_score)
.unwrap_or(DEFAULT_MIN_SCORE as f64) as f32,
);
match uteke.recall_room_semantic(
&req_data.room_id,
&req_data.query,
req_data.limit,
req_data.author.as_deref(),
min_score,
) {
Ok(results) => ctx.ok_response_for(req, &results),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/room/create") => {
#[derive(Deserialize)]
struct RoomCreateRequest {
room_id: String,
#[serde(default)]
title: Option<String>,
#[serde(default = "default_namespace")]
namespace: String,
}
match read_body::<RoomCreateRequest>(req.as_reader()) {
Ok(req_data) => {
match uteke.create_room(
&req_data.room_id,
req_data.title.as_deref(),
&req_data.namespace,
) {
Ok(()) => ctx.ok_response_for(
req,
&serde_json::json!({
"created": req_data.room_id,
"namespace": req_data.namespace
}),
),
Err(e) => {
let msg = format!("Failed to create room: {e}");
ctx.error_response_for(req, 400, &msg)
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
}
}
(Method::Get, "/room/list") => {
let ns_param = parse_query_namespace(&path);
match uteke.list_rooms(ns_param.as_deref()) {
Ok(rooms) => ctx.ok_response_for(req, &rooms),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
(Method::Post, "/room/stats") => {
#[derive(Deserialize)]
struct RoomStatsRequest {
room_id: String,
}
match read_body::<RoomStatsRequest>(req.as_reader()) {
Ok(req_data) => match uteke.room_stats(&req_data.room_id) {
Ok(Some(stats)) => ctx.ok_response_for(req, &stats),
Ok(None) => ctx.error_response_for(
req,
404,
format!("Room not found: {}", req_data.room_id),
),
Err(e) => {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
},
Err(e) => ctx.error_response_for(req, 400, e),
}
}
(Method::Delete, p) if p == "/room/delete" || p.starts_with("/room/delete?") => {
let room_id = if let Some(q) = p.strip_prefix("/room/delete?") {
parse_query_param(q, "room_id")
} else {
None
};
let room_id = match room_id {
Some(id) => id,
None => {
#[derive(Deserialize)]
struct RoomDeleteRequest {
room_id: String,
}
match read_body::<RoomDeleteRequest>(req.as_reader()) {
Ok(data) => data.room_id,
Err(_) => {
return ctx.error_response_for(req, 400, "Missing 'room_id' parameter")
}
}
}
};
match uteke.delete_room(&room_id) {
Ok(()) => ctx.ok_response_for(req, &serde_json::json!({"deleted": room_id})),
Err(e) => {
let msg = format!("{e}");
if msg.contains("not found") {
ctx.error_response_for(req, 404, &msg)
} else {
error!("Internal error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
}
(Method::Post, "/context") => match read_body::<serde_json::Value>(req.as_reader()) {
Ok(body) => {
let ns = body.get("namespace").and_then(|v| v.as_str());
match uteke.build_context(ns) {
Ok(context) => {
let resp = serde_json::json!({ "context": context });
ctx.ok_response_for(req, &resp)
}
Err(e) => {
error!("Context error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(_) => ctx.error_response_for(req, 400, "Invalid JSON body"),
},
(Method::Post, "/dream") => match read_body::<serde_json::Value>(req.as_reader()) {
Ok(body) => {
let ns = body.get("namespace").and_then(|v| v.as_str());
let dry_run = body
.get("dry_run")
.and_then(|v| v.as_bool())
.unwrap_or(false);
match uteke.dream(ns, dry_run, &[]) {
Ok(report) => ctx.ok_response_for(req, &report),
Err(e) => {
error!("Dream error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(_) => ctx.error_response_for(req, 400, "Invalid JSON body"),
},
(Method::Post, "/mcp") => {
const MAX_MCP_BODY: u64 = 1024 * 1024;
let content_length = req
.headers()
.iter()
.find(|h| h.field.as_str() == "content-length")
.and_then(|h| h.value.as_str().parse::<u64>().ok())
.unwrap_or(0);
if content_length > MAX_MCP_BODY {
return ctx.error_response_for(req, 413, "Payload too large");
}
let mut body = String::new();
if let Err(e) = req.as_reader().take(MAX_MCP_BODY).read_to_string(&mut body) {
return ctx.error_response_for(req, 400, format!("Failed to read body: {e}"));
}
let response = uteke_mcp::handle_jsonrpc(&uteke, &body);
tiny_http::Response::from_string(response)
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
.unwrap(),
)
.with_header(
tiny_http::Header::from_bytes(&b"MCP-Protocol-Version"[..], &b"2025-06-18"[..])
.unwrap(),
)
}
(Method::Post, "/doc/create") => match read_body::<DocCreateRequest>(req.as_reader()) {
Ok(req_data) => {
let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect();
let parent = req_data.parent.as_deref();
match uteke.doc_upsert_with_parent(
&req_data.slug,
req_data.title.as_deref().unwrap_or(""),
&req_data.content,
&tag_refs,
ns(&req_data.namespace),
parent,
) {
Ok(id) => ctx.ok_response_for(
req,
&serde_json::json!({"id": id, "slug": req_data.slug}),
),
Err(e) => {
if e.to_string().contains("already exists") {
ctx.error_response_for(
req,
409,
format!("document slug '{}' already exists", req_data.slug),
)
} else if e.to_string().contains("maximum")
|| e.to_string().contains("parent")
{
ctx.error_response_for(req, 400, e.to_string())
} else {
error!("doc create error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/doc/get") => match read_body::<DocGetRequest>(req.as_reader()) {
Ok(req_data) => match resolve_doc_id(&req_data) {
Ok(id_or_slug) => match uteke.doc_get(id_or_slug, ns(&req_data.namespace)) {
Ok(Some(doc)) => ctx.ok_response_for(req, &doc),
Ok(None) => ctx.error_response_for(
req,
404,
format!("document not found: {id_or_slug}"),
),
Err(e) => {
error!("doc get error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
},
Err(e) => ctx.error_response_for(req, 400, e),
},
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/doc/list") => match read_body::<DocListParams>(req.as_reader()) {
Ok(params) => {
let result = if params.roots_only {
uteke.doc_list_roots(ns(¶ms.namespace), params.limit)
} else if let Some(ref parent) = params.parent {
uteke.doc_list_children(parent, ns(¶ms.namespace), params.limit)
} else {
uteke.doc_list(ns(¶ms.namespace), params.limit)
};
match result {
Ok(docs) => ctx.ok_response_for(req, &docs),
Err(e) => {
error!("doc list error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/doc/search") => match read_body::<DocSearchRequest>(req.as_reader()) {
Ok(req_data) => {
match uteke.doc_search(
&req_data.query,
ns(&req_data.namespace),
req_data.limit,
&req_data.mode,
) {
Ok(results) => ctx.ok_response_for(req, &results),
Err(e) => {
if e.to_string().contains("embed") {
ctx.error_response_for(
req,
503,
"embedding model not available for semantic search",
)
} else {
error!("doc search error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Post, "/doc/move") => match read_body::<DocMoveRequest>(req.as_reader()) {
Ok(req_data) => match resolve_doc_id_move(&req_data) {
Ok(id_or_slug) => {
let new_parent = req_data.new_parent.as_deref();
match uteke.doc_move(id_or_slug, new_parent, ns(&req_data.namespace)) {
Ok(moved) => ctx.ok_response_for(req, &serde_json::json!({"moved": moved})),
Err(e) => {
if e.to_string().contains("not found") {
ctx.error_response_for(req, 404, e.to_string())
} else {
error!("doc move error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
}
Err(e) => ctx.error_response_for(req, 400, e),
},
Err(e) => ctx.error_response_for(req, 400, e),
},
(Method::Delete, p) if p == "/doc/delete" || p.starts_with("/doc/delete?") => {
let url = req.url().to_string();
let ns_param = parse_query_param(&url, "namespace");
let id = parse_query_param(&url, "id");
let slug = parse_query_param(&url, "slug");
let id_or_slug = match (&id, &slug) {
(Some(id), _) => id.as_str(),
(_, Some(slug)) => slug.as_str(),
_ => {
return ctx.error_response_for(
req,
400,
"provide either 'id' or 'slug' query parameter",
);
}
};
match uteke.doc_delete(id_or_slug, ns_param.as_deref()) {
Ok((deleted, subtree)) => ctx.ok_response_for(
req,
&serde_json::json!({"deleted": deleted, "subtree_size": subtree}),
),
Err(e) => {
error!("doc delete error: {e}");
ctx.error_response_for(req, 500, "Internal server error")
}
}
}
_ => ctx.error_response_for(req, 404, "Not found"),
}
}
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
const STRICT_THRESHOLD: f32 = 0.5;
const DEFAULT_MIN_SCORE: f32 = 0.0;
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut cli_host: Option<String> = None;
let mut cli_port: Option<u16> = None;
let mut cli_auth_token: Option<String> = None;
let mut cli_read_only_token: Option<String> = None;
let mut cli_cors_origins: Vec<String> = Vec::new();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--host" => {
i += 1;
if i < args.len() {
cli_host = Some(args[i].clone());
} else {
eprintln!("Error: --host requires a value");
std::process::exit(1);
}
}
"--port" => {
i += 1;
if i < args.len() {
cli_port = Some(args[i].parse().unwrap_or_else(|e| {
eprintln!("Invalid port: {e}");
std::process::exit(1);
}));
} else {
eprintln!("Error: --port requires a value");
std::process::exit(1);
}
}
"--auth-token" => {
i += 1;
if i < args.len() {
cli_auth_token = Some(args[i].clone());
} else {
eprintln!("Error: --auth-token requires a value");
std::process::exit(1);
}
}
"--read-only-token" => {
i += 1;
if i < args.len() {
cli_read_only_token = Some(args[i].clone());
} else {
eprintln!("Error: --read-only-token requires a value");
std::process::exit(1);
}
}
"--cors-origin" => {
i += 1;
if i < args.len() {
cli_cors_origins.push(args[i].clone());
} else {
eprintln!("Error: --cors-origin requires a value");
std::process::exit(1);
}
}
"--help" | "-h" => {
println!("uteke-serve — persistent warm memory server");
println!();
println!("Usage: uteke-serve [OPTIONS]");
println!();
println!("Options:");
println!(" --host <HOST> Bind address (default: 127.0.0.1)");
println!(" --port <PORT> Port number (default: 8767)");
println!(" --auth-token <TOKEN> Bearer token for API auth");
println!(" --cors-origin <URL> Allowed CORS origin (repeatable)");
println!(" --read-only-token <T> Read-only API token (GET endpoints only) (#409)");
println!(" -h, --help Show this help");
println!();
println!("Config: reads [server] section from uteke.toml");
println!(" CLI args override config values.");
println!();
println!("Environment:");
println!(" UTEKE_HOME Data directory (default: ~/.uteke)");
println!(" UTEKE_AUTH_TOKEN Bearer token (alternative to --auth-token)");
println!(
" UTEKE_READ_ONLY_TOKEN Read-only token (alternative to --read-only-token)"
);
println!();
println!("Security:");
println!(" If --auth-token or UTEKE_AUTH_TOKEN is set, all endpoints");
println!(" (except GET /health) require Authorization: Bearer <TOKEN>.");
println!(" --read-only-token grants GET-only access (recall, search, list, stats, graph).");
println!(" Configure CORS origins in uteke.toml [server].cors_origins.");
println!();
println!("API:");
println!(" GET /health → {{ status, memories }}");
println!(" POST /remember → {{ content, tags? }} → {{ id }}");
println!(" POST /recall → {{ query, limit? }} → {{ results }}");
println!(" POST /search → {{ query, limit? }} → {{ results }}");
println!(
" POST /list → {{ tag?, limit?, offset? }} → {{ memories }}"
);
println!(" DELETE /forget?id=UUID → {{ forgotten }}");
println!(" DELETE /forget?tag=TAG → {{ deleted }}");
println!(" GET /memory?id=UUID → {{ memory }}");
println!(" GET /stats → {{ stats }}");
println!(" GET /namespaces → {{ namespaces }}");
println!(" POST /room/create → {{ room_id, title, namespace }} → {{ created }}");
println!(" GET /room/list → [?namespace=] → [rooms]");
println!(" POST /room/recall → {{ room_id, query }} → ranked memories");
println!(" POST /room/summary → {{ room_id }} → {{ summary }}");
println!(" POST /room/document → {{ room_id }} → {{ document }}");
println!(" POST /room/stats → {{ room_id }} → room stats");
println!(" DEL /room/delete → {{ room_id }} → {{ deleted }}");
println!();
println!(" Document endpoints:");
println!(" POST /doc/create → {{ slug, content, title?, tags?, parent? }} → {{ id, slug }}");
println!(" POST /doc/get → {{ id | slug }} → {{ document }}");
println!(" POST /doc/list → {{ namespace?, limit?, roots_only?, parent? }} → [documents]");
println!(" POST /doc/search → {{ query, mode?, namespace?, limit? }} → [results]");
println!(
" POST /doc/move → {{ id | slug, new_parent? }} → {{ moved }}"
);
println!(" DEL /doc/delete?id=UUID → {{ deleted, subtree_size }}");
std::process::exit(0);
}
_ => {
eprintln!("Unknown argument: {}. Use --help.", args[i]);
std::process::exit(1);
}
}
i += 1;
}
let config = load_uteke_toml();
let config_host = config
.server
.as_ref()
.and_then(|s| s.host.clone())
.unwrap_or_else(|| "127.0.0.1".to_string());
let config_port = config.server.as_ref().and_then(|s| s.port).unwrap_or(8767);
let config_auth_token = config.server.as_ref().and_then(|s| s.auth_token.clone());
let config_cors_origins = config
.server
.as_ref()
.and_then(|s| s.cors_origins.clone())
.unwrap_or_default();
let cors_origins = if !cli_cors_origins.is_empty() {
cli_cors_origins
} else {
config_cors_origins
};
let host = cli_host.unwrap_or(config_host);
let port = cli_port.unwrap_or(config_port);
let auth_token = cli_auth_token
.or_else(|| std::env::var("UTEKE_AUTH_TOKEN").ok())
.or(config_auth_token);
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.init();
let home = match uteke_core::uteke_home() {
Ok(h) => h,
Err(e) => {
error!("Failed to determine home directory: {e}");
std::process::exit(1);
}
};
let db_path = home.join("uteke.db").to_string_lossy().to_string();
info!("Opening store at: {db_path}");
let uteke = match Uteke::open(&db_path) {
Ok(u) => Arc::new(Mutex::new(u)),
Err(e) => {
error!("Failed to open store: {e}");
std::process::exit(1);
}
};
let auth_token_hash = auth_token.as_deref().map(|t| Sha256::digest(t).into());
let read_only_token =
cli_read_only_token.or_else(|| std::env::var("UTEKE_READ_ONLY_TOKEN").ok());
let read_only_token_hash = read_only_token.as_deref().map(|t| Sha256::digest(t).into());
if auth_token_hash.is_some() && cors_origins.is_empty() {
warn!("Security: auth token is set but cors_origins is not configured.");
warn!(" For browser access, set cors_origins in uteke.toml or --cors-origin.");
warn!(" Non-browser clients (curl, agents) are unaffected by CORS.");
}
let ctx = ReqCtx {
auth_token_hash,
read_only_token_hash,
cors_origins: cors_origins.clone(),
recall_config: config.recall.clone(),
};
let addr = format!("{host}:{port}");
let server = Server::http(&addr).unwrap_or_else(|e| {
error!("Failed to bind {addr}: {e}");
std::process::exit(1);
});
info!("Uteke server listening on http://{addr}");
info!("Embedding model warm. Ready for <50ms recall.");
if auth_token.is_some() {
info!("Authentication: enabled (Bearer token)");
} else {
warn!("Authentication: disabled — set --auth-token or UTEKE_AUTH_TOKEN for production");
}
if read_only_token.is_some() {
info!("Read-only token: enabled (GET-only access, #409)");
}
if cors_origins.is_empty() {
warn!("CORS: wildcard (*) — restrict cors_origins in uteke.toml for production");
} else {
info!("CORS: allowing origins: {:?}", cors_origins);
}
let aging_enabled = config
.maintenance
.as_ref()
.and_then(|m| m.auto_aging_enabled)
.unwrap_or(true);
let aging_hours = config
.maintenance
.as_ref()
.and_then(|m| m.auto_aging_interval_hours)
.unwrap_or(6)
.max(1); let aging_uteke = Arc::clone(&uteke);
if aging_enabled {
info!("Auto-aging: enabled (every {aging_hours}h)");
std::thread::spawn(move || {
let interval = std::time::Duration::from_secs(aging_hours * 60 * 60);
loop {
std::thread::sleep(interval);
if SHUTDOWN.load(Ordering::SeqCst) {
break;
}
match aging_uteke.lock() {
Ok(u) => {
let age_days = config
.aging
.as_ref()
.and_then(|a| a.max_age_days)
.unwrap_or(365);
let max_access = config
.aging
.as_ref()
.and_then(|a| a.max_access_count)
.unwrap_or(10);
match u.aging_cleanup(age_days, max_access, None) {
Ok(result) => {
if result.deleted > 0 {
info!("Auto-aging: cleaned up {} stale memories (age>{age_days}d, access<{max_access})", result.deleted);
}
}
Err(e) => {
warn!("Auto-aging failed: {e}");
}
}
}
Err(_) => {
tracing::debug!("Auto-aging: lock busy, skipping cycle");
}
}
}
});
} else {
info!("Auto-aging: disabled");
}
let dream_enabled = config
.maintenance
.as_ref()
.and_then(|m| m.auto_dream_enabled)
.unwrap_or(true);
let dream_days = config
.maintenance
.as_ref()
.and_then(|m| m.auto_dream_interval_days)
.unwrap_or(3)
.max(1); let dream_uteke = Arc::clone(&uteke);
if dream_enabled {
info!("Auto-dream: enabled (every {dream_days}d)");
std::thread::spawn(move || {
let interval = std::time::Duration::from_secs(dream_days * 24 * 60 * 60);
loop {
std::thread::sleep(interval);
if SHUTDOWN.load(Ordering::SeqCst) {
break;
}
match dream_uteke.lock() {
Ok(u) => match u.dream(None, false, &[]) {
Ok(report) => {
if report.total_changes > 0 {
info!(
"Auto-dream: {} changes, {} warnings ({}ms)",
report.total_changes, report.total_warnings, report.duration_ms
);
}
}
Err(e) => {
warn!("Auto-dream failed: {e}");
}
},
Err(_) => {
tracing::debug!("Auto-dream: lock busy, skipping cycle");
}
}
}
});
} else {
info!("Auto-dream: disabled");
}
ctrlc::set_handler(|| {
if SHUTDOWN.load(Ordering::SeqCst) {
eprintln!("\nForce exit.");
std::process::exit(130);
}
SHUTDOWN.store(true, Ordering::SeqCst);
eprintln!("\nShutting down gracefully... (Ctrl+C again to force)");
})
.expect("Failed to set SIGINT handler");
for mut req in server.incoming_requests() {
if SHUTDOWN.load(Ordering::SeqCst) {
info!("Shutdown requested, stopping.");
break;
}
let method = req.method().clone();
let url = req.url().to_string();
info!("{method} {url}");
let uteke = Arc::clone(&uteke);
let ctx = ctx.clone();
std::thread::spawn(move || {
let response = route(&uteke, &ctx, &mut req);
if let Err(e) = req.respond(response) {
warn!("Response error: {e}");
}
});
}
info!("Saving index and closing DB...");
if let Err(e) = uteke.lock().expect("shutdown lock").shutdown() {
error!("Shutdown error: {e}");
}
info!("Goodbye.");
}
#[derive(serde::Deserialize, Default)]
struct ServerFileConfig {
server: Option<ServerFileSection>,
recall: Option<RecallFileSection>,
maintenance: Option<MaintenanceFileSection>,
aging: Option<AgingFileSection>,
}
#[derive(serde::Deserialize, Default, Clone)]
struct AgingFileSection {
max_age_days: Option<u32>,
max_access_count: Option<u32>,
}
#[derive(serde::Deserialize, Default, Clone)]
struct MaintenanceFileSection {
auto_aging_enabled: Option<bool>,
auto_aging_interval_hours: Option<u64>,
auto_dream_enabled: Option<bool>,
auto_dream_interval_days: Option<u64>,
}
#[derive(serde::Deserialize, Default, Clone)]
struct RecallFileSection {
min_score: Option<f64>,
min_score_strict: Option<f64>,
}
#[derive(serde::Deserialize, Default)]
struct ServerFileSection {
host: Option<String>,
port: Option<u16>,
auth_token: Option<String>,
cors_origins: Option<Vec<String>>,
}
fn load_uteke_toml() -> ServerFileConfig {
let mut config = ServerFileConfig::default();
let mut paths: Vec<PathBuf> = vec![match uteke_core::uteke_home() {
Ok(h) => h.join("uteke.toml"),
Err(_) => PathBuf::new(),
}];
if let Ok(cwd) = std::env::current_dir() {
paths.push(cwd.join(".uteke").join("uteke.toml"));
}
for path in paths {
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(parsed) = toml::from_str::<ServerFileConfig>(&content) {
config = parsed;
}
}
}
}
config
}