use crate::config::Config;
use crate::types::{BlockHeader, MemoryQueryOptions, ProjectId};
use crate::{MicroscopeReader, LAYER_NAMES};
use axum::{
extract::{Extension, Json, Query, State},
http::StatusCode,
middleware,
response::IntoResponse,
routing::{get, post},
Router,
};
use hmac::Mac;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::Sha256;
use std::fs;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, Mutex};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
const API_KEY_HEADER: &str = "x-api-key";
#[derive(Clone)]
pub struct AuthenticatedUser(pub Option<String>);
fn is_loopback(host: &str) -> bool {
matches!(host, "127.0.0.1" | "localhost" | "::1")
}
fn cors_layer(configured: &Option<String>) -> CorsLayer {
let allow_origin = match configured.as_deref() {
None | Some("*") => tower_http::cors::AllowOrigin::any(),
Some(origin) => match origin.parse::<HeaderValue>() {
Ok(v) => tower_http::cors::AllowOrigin::exact(v),
Err(_) => tower_http::cors::AllowOrigin::any(),
},
};
CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods(Any)
.allow_headers(Any)
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
pub fn user_token(master: &str, user_id: &str) -> Result<String, String> {
if master.is_empty() {
return Err("set [server] api_key in config.toml first".to_string());
}
if user_id.is_empty()
|| !user_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!(
"invalid user_id '{user_id}': only alphanumeric, '-' and '_' allowed"
));
}
let mut mac = hmac::Hmac::<Sha256>::new_from_slice(master.as_bytes())
.map_err(|e| format!("hmac init: {e}"))?;
mac.update(user_id.as_bytes());
Ok(format!(
"{user_id}.{}",
hex::encode(mac.finalize().into_bytes())
))
}
fn verify_user_token(master: &str, token: &str) -> Option<String> {
let (uid, mac_hex) = token.rsplit_once('.')?;
if uid.is_empty()
|| !uid
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return None;
}
let mut mac = hmac::Hmac::<Sha256>::new_from_slice(master.as_bytes()).ok()?;
mac.update(uid.as_bytes());
let expected = mac.finalize().into_bytes();
let provided = hex::decode(mac_hex).ok()?;
if provided.len() != expected.len() || !ct_eq(&provided, &expected) {
return None;
}
Some(uid.to_string())
}
#[allow(clippy::result_large_err)]
async fn api_key_auth(
State(state): State<Arc<AppState>>,
mut req: axum::extract::Request,
next: middleware::Next,
) -> Result<axum::response::Response, axum::response::Response> {
let master = state.config.server.api_key.as_deref().unwrap_or("");
let mut authed_user: Option<String> = None;
if let Some(authz) = req
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
let Some(token) = authz.strip_prefix("Bearer ") else {
return Err(
(StatusCode::UNAUTHORIZED, "unsupported Authorization scheme").into_response(),
);
};
if master.is_empty() {
return Err((
StatusCode::UNAUTHORIZED,
"api_key not configured; cannot verify user tokens",
)
.into_response());
}
match verify_user_token(master, token) {
Some(uid) => authed_user = Some(uid),
None => {
return Err(
(StatusCode::UNAUTHORIZED, "invalid or forged user token").into_response()
)
}
}
} else if !master.is_empty() {
let provided = req
.headers()
.get(API_KEY_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if provided != master {
return Err((
StatusCode::UNAUTHORIZED,
"missing or invalid X-API-Key header",
)
.into_response());
}
}
req.extensions_mut().insert(AuthenticatedUser(authed_user));
Ok(next.run(req).await)
}
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub active_project: Arc<Mutex<Option<ProjectId>>>,
}
fn active_project_or_default(state: &AppState) -> ProjectId {
state
.active_project
.lock()
.ok()
.and_then(|g| *g)
.unwrap_or(state.config.project_id)
}
fn project_aware_config(state: &AppState) -> Config {
let mut cfg = state.config.clone();
cfg.project_id = active_project_or_default(state);
cfg
}
#[derive(Deserialize)]
pub struct RecallQuery {
pub q: String,
pub k: Option<usize>,
pub user_id: Option<String>,
pub memory_backend: Option<String>,
pub memory_scope: Option<String>,
}
#[derive(Serialize, Clone)]
pub struct MemoryResponse {
pub text: String,
pub depth: u8,
pub layer: String,
pub distance: f32,
pub memory_scope: String,
#[serde(default)]
pub dedup_depths: Vec<u8>,
}
#[derive(Deserialize)]
pub struct RememberRequest {
pub text: String,
pub layer: Option<String>,
pub importance: Option<u8>,
pub user_id: Option<String>,
pub memory_backend: Option<String>,
pub memory_scope: Option<String>,
pub class: Option<String>,
pub supports: Option<String>,
}
#[derive(Deserialize)]
pub struct SessionQuery {
pub user_id: Option<String>,
pub memory_backend: Option<String>,
pub memory_scope: Option<String>,
}
#[derive(Serialize)]
pub struct StatusResponse {
pub version: String,
pub blocks: usize,
pub append_log: usize,
pub layers: Vec<String>,
}
#[derive(Serialize)]
pub struct SessionResponse {
pub user_id: String,
pub memory_backend: String,
pub memory_scope: String,
pub namespace_dir: String,
pub personal_namespace_dir: String,
pub shared_namespace_dir: String,
}
#[derive(Clone, Copy)]
enum MemoryScope {
Personal,
Shared,
Both,
}
impl MemoryScope {
fn as_str(self) -> &'static str {
match self {
Self::Personal => "personal",
Self::Shared => "shared",
Self::Both => "both",
}
}
}
fn sanitize_user_id(raw: Option<&str>) -> String {
let src = raw.unwrap_or("guest");
let cleaned: String = src
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
if cleaned.is_empty() {
"guest".to_string()
} else {
cleaned
}
}
fn sanitize_memory_backend(raw: Option<&str>) -> String {
match raw.unwrap_or("local").to_ascii_lowercase().as_str() {
"cloud" => "cloud".to_string(),
_ => "local".to_string(),
}
}
fn sanitize_memory_scope(raw: Option<&str>) -> MemoryScope {
match raw.unwrap_or("both").to_ascii_lowercase().as_str() {
"personal" => MemoryScope::Personal,
"shared" => MemoryScope::Shared,
_ => MemoryScope::Both,
}
}
fn namespace_dir(config: &Config, user_id: &str, memory_backend: &str) -> PathBuf {
Path::new(&config.paths.output_dir)
.join("namespaces")
.join(memory_backend)
.join(user_id)
}
fn shared_namespace_dir(config: &Config, memory_backend: &str) -> PathBuf {
Path::new(&config.paths.output_dir)
.join("namespaces")
.join(memory_backend)
.join("_shared")
}
fn scoped_config(
config: &Config,
user_id: Option<&str>,
memory_backend: Option<&str>,
) -> Result<(Config, String, String, PathBuf), (StatusCode, String)> {
let user = sanitize_user_id(user_id);
let backend = sanitize_memory_backend(memory_backend);
let ns_dir = namespace_dir(config, &user, &backend);
fs::create_dir_all(&ns_dir).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("create namespace dir: {}", e),
)
})?;
let mut scoped = config.clone();
scoped.paths.output_dir = ns_dir.to_string_lossy().to_string();
Ok((scoped, user, backend, ns_dir))
}
fn shared_config(
config: &Config,
memory_backend: Option<&str>,
) -> Result<(Config, String, PathBuf), (StatusCode, String)> {
let backend = sanitize_memory_backend(memory_backend);
let ns_dir = shared_namespace_dir(config, &backend);
fs::create_dir_all(&ns_dir).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("create shared namespace dir: {}", e),
)
})?;
let mut scoped = config.clone();
scoped.paths.output_dir = ns_dir.to_string_lossy().to_string();
Ok((scoped, backend, ns_dir))
}
#[derive(Deserialize)]
pub struct MobileRecallRequest {
pub user_id: String,
pub query: String,
pub k: Option<usize>,
}
#[derive(Deserialize)]
pub struct MobileRememberRequest {
pub user_id: String,
pub text: String,
pub layer: Option<String>,
pub importance: Option<u8>,
}
#[derive(Deserialize)]
pub struct MobileChatRequest {
pub user_id: String,
pub message: String,
pub provider: String, pub model: String,
pub api_base: Option<String>,
pub recall_k: Option<usize>,
pub system_prompt: Option<String>,
pub layer: Option<String>,
pub importance: Option<u8>,
pub remember_user: Option<bool>,
pub remember_assistant: Option<bool>,
pub temperature: Option<f32>,
pub extra_headers: Option<std::collections::HashMap<String, String>>,
}
#[derive(Serialize)]
pub struct MobileChatResponse {
pub provider: String,
pub model: String,
pub reply: String,
pub recalled: Vec<MemoryResponse>,
pub stored: Vec<String>,
}
fn user_prefix(user_id: &str) -> String {
format!("[user:{}] ", user_id.trim())
}
fn scope_user_text(user_id: &str, text: &str) -> String {
format!("{}{}", user_prefix(user_id), text)
}
fn strip_user_scope(text: &str, user_id: &str) -> Option<String> {
let prefix = user_prefix(user_id);
text.strip_prefix(&prefix).map(str::to_string)
}
fn effective_user_id<'a>(auth: &'a AuthenticatedUser, claimed: &'a str) -> &'a str {
auth.0.as_deref().unwrap_or(claimed)
}
#[derive(Deserialize)]
pub struct SwitchProjectRequest {
pub project_path: String,
pub project_id: Option<String>,
}
#[derive(Serialize)]
pub struct SwitchProjectResponse {
pub status: String,
pub active_project: String,
pub hot_blocks_count: usize,
pub delta_synced_entries: usize,
}
#[derive(Deserialize)]
pub struct RecallRequest {
pub query: String,
pub project_id: Option<String>,
pub top_k: Option<usize>,
}
async fn switch_project(
State(state): State<Arc<AppState>>,
Json(payload): Json<SwitchProjectRequest>,
) -> Result<Json<SwitchProjectResponse>, (StatusCode, String)> {
let pid = match payload.project_id {
Some(s) => ProjectId::from_str(&s).map_err(|e| (StatusCode::BAD_REQUEST, e))?,
None => ProjectId::from_path(&payload.project_path),
};
let (hot_blocks_count, delta_synced_entries) = {
let output_dir = Path::new(&state.config.paths.output_dir);
let meta_path = output_dir.join("meta.bin");
if meta_path.exists() {
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let hot = (0..reader.block_count)
.filter(|&i| {
let h = reader.header(i);
h.project_id == pid || h.project_id.is_global()
})
.count();
let append_path = output_dir.join("append.bin");
let appended = crate::read_append_log(&append_path);
let delta = appended
.iter()
.filter(|e| e.project_id == pid || e.project_id.is_global())
.count();
(hot, delta)
} else {
(0, 0)
}
};
*state.active_project.lock().map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"active project lock poisoned".to_string(),
)
})? = Some(pid);
Ok(Json(SwitchProjectResponse {
status: "OK".to_string(),
active_project: pid.to_string(),
hot_blocks_count,
delta_synced_entries,
}))
}
async fn recall_request_handler(
State(state): State<Arc<AppState>>,
Json(payload): Json<RecallRequest>,
) -> Result<Json<Vec<MemoryResponse>>, (StatusCode, String)> {
let k = payload.top_k.unwrap_or(10);
let active = match payload.project_id {
Some(s) => ProjectId::from_str(&s).map_err(|e| (StatusCode::BAD_REQUEST, e))?,
None => active_project_or_default(&state),
};
let opts = MemoryQueryOptions {
active_project: active,
include_global: true,
min_importance: 0,
max_results: k,
};
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (qx, qy, qz) = crate::content_coords_blended(
&payload.query,
"long_term",
state.config.search.semantic_weight,
);
let depth = crate::auto_depth(&payload.query);
let results =
reader.radial_search_with_options(&state.config, qx, qy, qz, depth, 0.5, k, Some(&opts));
let append_path = Path::new(&state.config.paths.output_dir).join("append.bin");
let appended = crate::read_append_log(&append_path);
let mut response = Vec::new();
for res in results.all() {
if !res.is_main {
continue;
}
let raw_text = if res.block_idx >= 1_000_000 {
appended
.get(res.block_idx - 1_000_000)
.map(|e| e.text.clone())
.unwrap_or_default()
} else {
reader.text(res.block_idx).to_string()
};
if raw_text.is_empty() {
continue;
}
let h = if res.block_idx >= 1_000_000 {
appended
.get(res.block_idx - 1_000_000)
.map(|e| BlockHeader {
x: e.x,
y: e.y,
z: e.z,
zoom: e.depth as f32 / 8.0,
depth: e.depth,
layer_id: e.layer_id,
data_offset: 0,
data_len: 0,
parent_idx: u32::MAX,
child_count: 0,
crc16: [0; 2],
project_id: e.project_id,
importance: e.importance,
flags: 0,
})
.unwrap_or_else(|| reader.header(0))
} else {
reader.header(res.block_idx)
};
let layer = LAYER_NAMES
.get(h.layer_id as usize)
.copied()
.unwrap_or("long_term")
.to_string();
response.push(MemoryResponse {
text: raw_text,
depth: h.depth,
layer,
distance: res.dist_sq.sqrt(),
memory_scope: "active".to_string(),
dedup_depths: Vec::new(),
});
if response.len() >= k {
break;
}
}
let response = dedup_by_text(response, k);
Ok(Json(response))
}
fn dedup_by_text(items: Vec<MemoryResponse>, k: usize) -> Vec<MemoryResponse> {
let mut grouped: std::collections::HashMap<String, MemoryResponse> =
std::collections::HashMap::new();
let mut order: Vec<String> = Vec::new();
for mut item in items {
let key = normalise_for_dedup(&item.text);
if key.is_empty() {
continue;
}
match grouped.get_mut(&key) {
Some(existing) => {
let mut depths = std::mem::take(&mut existing.dedup_depths);
depths.push(existing.depth);
depths.push(item.depth);
if item.distance < existing.distance {
item.dedup_depths = depths;
*existing = item;
} else {
existing.dedup_depths = depths;
}
}
None => {
order.push(key.clone());
grouped.insert(key, item);
}
}
}
let mut out: Vec<MemoryResponse> = order
.iter()
.filter_map(|key| grouped.get(key).cloned())
.collect();
for item in &mut out {
item.dedup_depths.push(item.depth);
item.dedup_depths.sort_unstable();
item.dedup_depths.dedup();
}
out.sort_by(|a, b| a.distance.total_cmp(&b.distance));
out.truncate(k);
out
}
fn normalise_for_dedup(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[cfg(test)]
mod dedup_tests {
use super::{dedup_by_text, normalise_for_dedup, MemoryResponse};
fn item(text: &str, depth: u8, distance: f32) -> MemoryResponse {
MemoryResponse {
text: text.to_string(),
depth,
layer: "long_term".to_string(),
distance,
memory_scope: "shared".to_string(),
dedup_depths: Vec::new(),
}
}
#[test]
fn same_fact_at_three_depths_returns_once() {
let items = vec![
item("The user has a pine nut allergy", 3, 0.10),
item("The user has a pine nut allergy", 4, 0.30),
item("The user has a pine nut allergy", 5, 0.50),
];
let out = dedup_by_text(items, 5);
assert_eq!(out.len(), 1, "expected 1 hit, got {}", out.len());
assert_eq!(out[0].dedup_depths, vec![3, 4, 5]);
}
#[test]
fn dedup_frees_top_k_for_other_facts() {
let items = vec![
item("pine nut allergy", 3, 0.10),
item("pine nut allergy", 4, 0.11),
item("pine nut allergy", 5, 0.12),
item("lives in Budapest", 3, 0.20),
item("prefers short check-ins", 4, 0.30),
];
let out = dedup_by_text(items, 3);
assert_eq!(out.len(), 3, "top-3 must hold 3 distinct facts");
}
#[test]
fn nearest_copy_becomes_representative() {
let items = vec![
item("pine nut allergy", 5, 0.90),
item("pine nut allergy", 3, 0.10),
];
let out = dedup_by_text(items, 5);
assert_eq!(out[0].depth, 3, "nearest copy should represent the fact");
assert_eq!(out[0].dedup_depths, vec![3, 5]);
}
#[test]
fn different_facts_are_not_merged() {
let items = vec![
item("has a nut allergy", 3, 0.1),
item("has no nut allergy", 4, 0.2),
];
let out = dedup_by_text(items, 5);
assert_eq!(out.len(), 2, "negation must not be collapsed away");
}
#[test]
fn whitespace_and_case_variants_collapse() {
let items = vec![
item("Pine Nut Allergy", 3, 0.1),
item("pine nut allergy", 4, 0.2),
];
assert_eq!(dedup_by_text(items, 5).len(), 1);
}
#[test]
fn normalisation_is_stable() {
assert_eq!(normalise_for_dedup(" A B "), "a b");
assert_eq!(normalise_for_dedup("\n\tx\n\n"), "x");
}
}
fn recall_internal(
state: &AppState,
query: &str,
k: usize,
user_scope: Option<&str>,
) -> Result<Vec<MemoryResponse>, (StatusCode, String)> {
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let query_lower = query.to_lowercase();
let (qx, qy, qz) =
crate::content_coords_blended(query, "long_term", state.config.search.semantic_weight);
let depth = crate::auto_depth(query);
let results = reader.radial_search(&state.config, qx, qy, qz, depth, 0.5, k);
let mut response = Vec::new();
for res in results.all() {
if !res.is_main {
continue;
}
let h = reader.header(res.block_idx);
let raw_text = reader.text(res.block_idx).to_string();
let text = if let Some(uid) = user_scope {
match strip_user_scope(&raw_text, uid) {
Some(t) => t,
None => continue,
}
} else {
raw_text
};
let layer = LAYER_NAMES
.get(h.layer_id as usize)
.copied()
.unwrap_or("unknown")
.to_string();
response.push(MemoryResponse {
text,
depth: h.depth,
layer,
distance: res.dist_sq.sqrt(),
memory_scope: if user_scope.is_some() {
"personal"
} else {
"shared"
}
.to_string(),
dedup_depths: Vec::new(),
});
}
let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
let appended = crate::read_append_log(&append_path);
for entry in &appended {
if response.len() >= k {
break;
}
if !entry.text.to_lowercase().contains(&query_lower) {
continue;
}
let text = if let Some(uid) = user_scope {
match strip_user_scope(&entry.text, uid) {
Some(t) => t,
None => continue,
}
} else {
entry.text.clone()
};
let layer = LAYER_NAMES
.get(entry.layer_id as usize)
.copied()
.unwrap_or("long_term")
.to_string();
response.push(MemoryResponse {
text,
depth: 4,
layer,
distance: 0.1,
memory_scope: if user_scope.is_some() {
"personal"
} else {
"shared"
}
.to_string(),
dedup_depths: Vec::new(),
});
}
Ok(dedup_by_text(response, k))
}
async fn get_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<StatusResponse>, (StatusCode, String)> {
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
let appended = crate::read_append_log(&append_path);
Ok(Json(StatusResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
blocks: reader.block_count,
append_log: appended.len(),
layers: LAYER_NAMES.iter().map(|&s| s.to_string()).collect(),
}))
}
async fn recall_memory(
State(state): State<Arc<AppState>>,
Query(params): Query<RecallQuery>,
) -> Result<Json<Vec<MemoryResponse>>, (StatusCode, String)> {
let k = params.k.unwrap_or(10);
Ok(Json(recall_internal(&state, ¶ms.q, k, None)?))
}
async fn remember_memory(
State(state): State<Arc<AppState>>,
Json(payload): Json<RememberRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let layer = payload.layer.unwrap_or_else(|| "long_term".to_string());
let importance = payload.importance.unwrap_or(5);
let scope = sanitize_memory_scope(payload.memory_scope.as_deref());
let (user_config, user_id, memory_backend, personal_ns) = scoped_config(
&state.config,
payload.user_id.as_deref(),
payload.memory_backend.as_deref(),
)?;
let (shared_cfg, _backend2, shared_ns) = shared_config(&state.config, Some(&memory_backend))?;
let mut written_scopes = Vec::new();
if matches!(scope, MemoryScope::Personal | MemoryScope::Both) {
crate::store_memory(&user_config, &payload.text, &layer, importance)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
written_scopes.push("personal");
}
if matches!(scope, MemoryScope::Shared | MemoryScope::Both) {
crate::store_memory(&shared_cfg, &payload.text, &layer, importance)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
written_scopes.push("shared");
}
let mut epistemic_info = serde_json::json!({});
if let Some(class_str) = &payload.class {
if let Ok(class) = class_str.parse::<crate::epistemic::EpistemicClass>() {
let output_dir = std::path::Path::new(&state.config.paths.output_dir);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let mut ledger = crate::epistemic::EvidenceLedger::load_or_init(output_dir);
let mut audit = crate::epistemic::AuditChain::load_or_init(output_dir);
let claim_hash = crate::epistemic::content_hash(&payload.text);
ledger.get_or_create(claim_hash, class, 0, now);
let provider = crate::embeddings::provider_from_config(
&state.config.embedding,
state.config.embedding.dim,
);
if let Some(supports_str) = &payload.supports {
for sup in supports_str.split(',') {
let sup = sup.trim();
if sup.is_empty() {
continue;
}
let sup_hash = crate::epistemic::content_hash(sup);
let _ = crate::epistemic::link_evidence(
&mut ledger,
&mut audit,
claim_hash,
sup_hash,
crate::epistemic::EpistemicClass::Observation,
0,
now,
Some(sup),
Some(&payload.text),
Some(provider.as_ref()),
);
}
}
let conf = ledger
.records
.get(&claim_hash)
.map(|r| r.confidence)
.unwrap_or(0);
let _ = ledger.save(output_dir);
let _ = audit.save(output_dir);
epistemic_info = serde_json::json!({
"class": class_str,
"confidence": conf,
"supports": payload.supports.as_deref().unwrap_or(""),
});
}
}
Ok(Json(serde_json::json!({
"status": "committed",
"message": "Memory stored in append log",
"user_id": user_id,
"memory_backend": memory_backend,
"memory_scope": scope.as_str(),
"written_scopes": written_scopes,
"namespace_dir": personal_ns.to_string_lossy(),
"personal_namespace_dir": personal_ns.to_string_lossy(),
"shared_namespace_dir": shared_ns.to_string_lossy(),
"epistemic": epistemic_info,
})))
}
async fn get_session(
State(state): State<Arc<AppState>>,
Query(params): Query<SessionQuery>,
) -> Result<Json<SessionResponse>, (StatusCode, String)> {
let (_cfg, user_id, memory_backend, namespace) = scoped_config(
&state.config,
params.user_id.as_deref(),
params.memory_backend.as_deref(),
)?;
let (_shared_cfg, _backend2, shared_ns) = shared_config(&state.config, Some(&memory_backend))?;
let scope = sanitize_memory_scope(params.memory_scope.as_deref());
Ok(Json(SessionResponse {
user_id,
memory_backend,
memory_scope: scope.as_str().to_string(),
namespace_dir: namespace.to_string_lossy().to_string(),
personal_namespace_dir: namespace.to_string_lossy().to_string(),
shared_namespace_dir: shared_ns.to_string_lossy().to_string(),
}))
}
async fn mobile_recall(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthenticatedUser>,
Json(payload): Json<MobileRecallRequest>,
) -> Result<Json<Vec<MemoryResponse>>, (StatusCode, String)> {
let k = payload.k.unwrap_or(10);
let uid = effective_user_id(&auth, &payload.user_id);
Ok(Json(recall_internal(&state, &payload.query, k, Some(uid))?))
}
async fn mobile_remember(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthenticatedUser>,
Json(payload): Json<MobileRememberRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let layer = payload.layer.unwrap_or_else(|| "long_term".to_string());
let importance = payload.importance.unwrap_or(7);
let uid = effective_user_id(&auth, &payload.user_id);
let scoped_text = scope_user_text(uid, &payload.text);
let cfg = project_aware_config(&state);
crate::store_memory(&cfg, &scoped_text, &layer, importance)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({
"status": "committed",
"message": "User-scoped memory stored"
})))
}
fn build_header_map(
extra_headers: Option<&std::collections::HashMap<String, String>>,
auth: Option<String>,
) -> Result<HeaderMap, (StatusCode, String)> {
let mut headers = HeaderMap::new();
if let Some(token) = auth {
let value = HeaderValue::from_str(&token).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid auth header: {}", e),
)
})?;
headers.insert(AUTHORIZATION, value);
}
const ALLOWED_HEADERS: &[&str] = &[
"accept",
"content-type",
"user-agent",
"x-request-id",
"x-api-key",
"x-client-version",
];
if let Some(map) = extra_headers {
for (k, v) in map {
let lower_k = k.to_lowercase();
if !lower_k.starts_with("x-") && !ALLOWED_HEADERS.contains(&lower_k.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
format!("Header '{}' is not allowed. Only 'x-*' headers or whitelisted headers are permitted.", k),
));
}
let name = HeaderName::from_bytes(k.as_bytes()).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid header name '{}': {}", k, e),
)
})?;
let value = HeaderValue::from_str(v).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid header value for '{}': {}", k, e),
)
})?;
headers.insert(name, value);
}
}
Ok(headers)
}
async fn call_provider(
req: &MobileChatRequest,
memory_context: &str,
config: &Config,
) -> Result<String, (StatusCode, String)> {
let provider = req.provider.to_lowercase();
let client = reqwest::Client::new();
let temperature = req.temperature.unwrap_or(0.2);
let system_prompt = req.system_prompt.clone().unwrap_or_else(|| {
"You are a helpful assistant. Use recalled memories as trusted user context.".to_string()
});
let combined_system = format!(
"{}\n\nRecalled Memories:\n{}",
system_prompt, memory_context
);
let headers = build_header_map(req.extra_headers.as_ref(), None)?;
let response_json: Value = match provider.as_str() {
"ollama" => {
let api_base = req
.api_base
.clone()
.unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
let url = format!("{}/api/chat", api_base.trim_end_matches('/'));
let payload = serde_json::json!({
"model": req.model,
"stream": false,
"messages": [
{"role": "system", "content": combined_system},
{"role": "user", "content": req.message}
],
"options": {"temperature": temperature}
});
client
.post(url)
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("Ollama request failed: {}", e),
)
})?
.json()
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("Ollama invalid JSON: {}", e),
)
})?
}
"openai" => {
let api_base = req
.api_base
.clone()
.unwrap_or_else(|| "https://api.openai.com".to_string());
let api_key = config.server.openai_api_key.as_ref().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"OpenAI API key not configured on server".to_string(),
))?;
let url = format!("{}/v1/chat/completions", api_base.trim_end_matches('/'));
let mut headers = headers;
let bearer = format!("Bearer {}", api_key);
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&bearer).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid OpenAI api_key: {}", e),
)
})?,
);
let payload = serde_json::json!({
"model": req.model,
"temperature": temperature,
"messages": [
{"role": "system", "content": combined_system},
{"role": "user", "content": req.message}
]
});
client
.post(url)
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("OpenAI request failed: {}", e),
)
})?
.json()
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("OpenAI invalid JSON: {}", e),
)
})?
}
"gemini" => {
let api_base = req
.api_base
.clone()
.unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string());
let api_key = config.server.gemini_api_key.as_ref().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Gemini API key not configured on server".to_string(),
))?;
let url = format!(
"{}/v1beta/models/{}:generateContent?key={}",
api_base.trim_end_matches('/'),
req.model,
api_key
);
let payload = serde_json::json!({
"contents": [
{
"role": "user",
"parts": [
{ "text": format!("{}\n\nUser: {}", combined_system, req.message) }
]
}
],
"generationConfig": {
"temperature": temperature
}
});
client
.post(url)
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("Gemini request failed: {}", e),
)
})?
.json()
.await
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("Gemini invalid JSON: {}", e),
)
})?
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Unsupported provider. Use: ollama | openai | gemini".to_string(),
));
}
};
let reply = match provider.as_str() {
"ollama" => response_json["message"]["content"]
.as_str()
.unwrap_or("")
.to_string(),
"openai" => response_json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string(),
"gemini" => response_json["candidates"][0]["content"]["parts"][0]["text"]
.as_str()
.unwrap_or("")
.to_string(),
_ => String::new(),
};
if reply.is_empty() {
return Err((
StatusCode::BAD_GATEWAY,
format!("{} returned an empty response", req.provider),
));
}
Ok(reply)
}
async fn mobile_chat(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthenticatedUser>,
Json(payload): Json<MobileChatRequest>,
) -> Result<Json<MobileChatResponse>, (StatusCode, String)> {
let k = payload.recall_k.unwrap_or(8);
let uid = effective_user_id(&auth, &payload.user_id);
let recalled = recall_internal(&state, &payload.message, k, Some(uid))?;
let memory_context = if recalled.is_empty() {
"(no prior memory)".to_string()
} else {
recalled
.iter()
.enumerate()
.map(|(i, m)| {
format!(
"{}. [{} D{} dist={:.3}] {}",
i + 1,
m.layer,
m.depth,
m.distance,
m.text
)
})
.collect::<Vec<String>>()
.join("\n")
};
let reply = call_provider(&payload, &memory_context, &state.config).await?;
let layer = payload
.layer
.clone()
.unwrap_or_else(|| "long_term".to_string());
let importance = payload.importance.unwrap_or(7);
let mut stored = Vec::new();
if payload.remember_user.unwrap_or(true) {
let text = scope_user_text(&payload.user_id, &payload.message);
crate::store_memory(&state.config, &text, &layer, importance)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
stored.push("user_message".to_string());
}
if payload.remember_assistant.unwrap_or(true) {
let text = scope_user_text(&payload.user_id, &reply);
crate::store_memory(&state.config, &text, &layer, importance)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
stored.push("assistant_reply".to_string());
}
Ok(Json(MobileChatResponse {
provider: payload.provider,
model: payload.model,
reply,
recalled,
stored,
}))
}
async fn get_openapi() -> Json<serde_json::Value> {
static SPEC: &str = include_str!("../openapi.json");
Json(
serde_json::from_str(SPEC)
.unwrap_or_else(|_| serde_json::json!({"error": "spec parse failed"})),
)
}
async fn get_root() -> axum::response::Html<&'static str> {
axum::response::Html(
r#"<!DOCTYPE html>
<html><head><title>Microscope Memory — Spine Bridge API</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; max-width: 800px; margin: 40px auto; line-height: 1.6; background: #0f1115; color: #e0e0e0; }
h1 { color: #00f2ff; border-bottom: 1px solid #333; padding-bottom: 10px; }
code { background: #1a1d23; padding: 2px 5px; border-radius: 4px; color: #ff9d00; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; background: #16181d; }
th, td { border: 1px solid #333; padding: 12px; text-align: left; }
th { background: #1e2229; color: #00f2ff; }
a { color: #00f2ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.status-ok { color: #00ff88; font-weight: bold; }
pre { background: #1a1d23; padding: 15px; border-radius: 8px; border: 1px solid #333; overflow-x: auto; }
</style>
</head>
<body>
<h1>🔬 Microscope Memory — Spine Bridge API <span style="font-size: 0.5em; vertical-align: middle; background: #00f2ff; color: #000; padding: 2px 8px; border-radius: 10px;">v1.0</span></h1>
<p>Sub-microsecond cognitive memory for AI models. <span class="status-ok">● Engine Active</span></p>
<h3>API Endpoints (v1)</h3>
<table>
<tr><th>Method</th><th>Endpoint</th><th>Description</th></tr>
<tr><td>GET</td><td><code>/v1/status</code></td><td>Engine health & stats</td></tr>
<tr><td>GET</td><td><code>/v1/session?user_id=u1&memory_backend=cloud&memory_scope=both</code></td><td>Resolve personal+shared namespaces</td></tr>
<tr><td>GET</td><td><code>/v1/recall?q=...&k=10</code></td><td>Recall memories by query</td></tr>
<tr><td>POST</td><td><code>/v1/remember</code></td><td>Store a new memory</td></tr>
<tr><td>POST</td><td><code>/v1/mobile/chat</code></td><td>Provider-agnostic mobile chat (Ollama/OpenAI/Gemini)</td></tr>
<tr><td>GET</td><td><a href="/openapi.json">/openapi.json</a></td><td>OpenAPI spec</td></tr>
</table>
<h3>Quick Start</h3>
<pre># Recall via v1 API
curl "http://localhost:6060/v1/recall?q=hello&k=3&user_id=alice&memory_backend=cloud&memory_scope=both"
# Store via v1 API
curl -X POST http://localhost:6060/v1/remember \
-H "Content-Type: application/json" \
-d '{"text":"Hello world","layer":"long_term","user_id":"alice","memory_backend":"cloud","memory_scope":"both"}'
# Mobile unified chat (user-scoped persistent memory)
curl -X POST http://localhost:6060/v1/mobile/chat \
-H "Content-Type: application/json" \
-d '{"user_id":"demo-user","message":"what did we discuss?","provider":"ollama","model":"llama3"}'</pre>
<p style="font-size: 0.9em; color: #888;">Note: Legacy routes (<code>/status</code>, <code>/recall</code>, <code>/remember</code>) are supported but deprecated.</p>
</body></html>"#,
)
}
#[derive(Deserialize)]
pub struct FindQuery {
pub q: String,
pub k: Option<usize>,
}
#[derive(Deserialize)]
pub struct LookQuery {
pub x: f32,
pub y: f32,
pub z: f32,
pub zoom: u8,
pub k: Option<usize>,
}
#[derive(Deserialize)]
pub struct MqlQuery {
pub mql: String,
}
#[derive(Deserialize)]
pub struct BuildRequest {
pub force: Option<bool>,
}
#[derive(Deserialize)]
pub struct SessionLogQuery {
pub n: Option<usize>,
}
#[derive(Serialize)]
pub struct FindResponse {
pub text: String,
pub depth: u8,
pub layer: String,
}
#[derive(Serialize)]
pub struct LookResponse {
pub text: String,
pub depth: u8,
pub layer: String,
pub distance: f32,
}
#[derive(Serialize)]
pub struct MqlResponse {
pub text: String,
pub depth: u8,
pub layer: String,
pub score: f32,
}
async fn find_memory(
State(state): State<Arc<AppState>>,
Query(params): Query<FindQuery>,
) -> Result<Json<Vec<FindResponse>>, (StatusCode, String)> {
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let k = params.k.unwrap_or(10);
let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
let appended = crate::read_append_log(&append_path);
let results = reader.find_text_all(&state.config, ¶ms.q, k);
let mut response = Vec::new();
for (depth, idx, is_main) in &results {
let (text, layer) = if *is_main {
let h = reader.header(*idx);
(
reader.text(*idx).to_string(),
LAYER_NAMES
.get(h.layer_id as usize)
.copied()
.unwrap_or("unknown")
.to_string(),
)
} else if let Some(entry) = appended.get(idx.saturating_sub(1_000_000)) {
(
entry.text.clone(),
LAYER_NAMES
.get(entry.layer_id as usize)
.copied()
.unwrap_or("unknown")
.to_string(),
)
} else {
continue;
};
response.push(FindResponse {
text: crate::safe_truncate(&text, 200),
depth: *depth,
layer,
});
}
Ok(Json(response))
}
async fn look_memory(
State(state): State<Arc<AppState>>,
Query(params): Query<LookQuery>,
) -> Result<Json<Vec<LookResponse>>, (StatusCode, String)> {
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let k = params.k.unwrap_or(10);
let results = reader.look(&state.config, params.x, params.y, params.z, params.zoom, k);
let mut response = Vec::new();
for (dist, idx, is_main) in &results {
if !is_main {
continue;
}
let h = reader.header(*idx);
let text = reader.text(*idx).to_string();
let layer = LAYER_NAMES
.get(h.layer_id as usize)
.copied()
.unwrap_or("unknown")
.to_string();
response.push(LookResponse {
text: crate::safe_truncate(&text, 200),
depth: h.depth,
layer,
distance: *dist,
});
}
Ok(Json(response))
}
async fn mql_query(
State(state): State<Arc<AppState>>,
Query(params): Query<MqlQuery>,
) -> Result<Json<Vec<MqlResponse>>, (StatusCode, String)> {
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let append_path = std::path::Path::new(&state.config.paths.output_dir).join("append.bin");
let appended = crate::read_append_log(&append_path);
let q = crate::query::parse(¶ms.mql);
let results = crate::query::execute(&q, &reader, &appended);
let mut response = Vec::new();
for r in &results {
let (text, depth, layer) = if r.is_main {
let h = reader.header(r.block_idx);
let t = reader.text(r.block_idx).to_string();
let l = LAYER_NAMES
.get(h.layer_id as usize)
.copied()
.unwrap_or("unknown")
.to_string();
(t, h.depth, l)
} else if let Some(entry) = appended.get(r.block_idx) {
let l = LAYER_NAMES
.get(entry.layer_id as usize)
.copied()
.unwrap_or("unknown")
.to_string();
(entry.text.clone(), 4u8, l)
} else {
continue;
};
response.push(MqlResponse {
text: crate::safe_truncate(&text, 200),
depth,
layer,
score: r.score,
});
}
Ok(Json(response))
}
async fn build_index(
State(state): State<Arc<AppState>>,
Json(payload): Json<BuildRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let force = payload.force.unwrap_or(false);
if force {
crate::build::rebuild_pending(&state.config, true, true)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
} else {
crate::build::build(&state.config, false, true)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let depths = reader.depth_ranges.iter().filter(|&&(_, c)| c > 0).count();
Ok(Json(serde_json::json!({
"status": "ok",
"blocks": reader.block_count,
"depths": depths
})))
}
async fn session_log(
State(state): State<Arc<AppState>>,
Query(params): Query<SessionLogQuery>,
) -> Result<Json<Vec<String>>, (StatusCode, String)> {
let n = params.n.unwrap_or(50);
let file_path = std::path::Path::new(&state.config.paths.layers_dir).join("session.txt");
if !file_path.exists() {
return Ok(Json(Vec::new()));
}
let content = std::fs::read_to_string(&file_path).unwrap_or_default();
let entries: Vec<&str> = content
.split("\n\n")
.filter(|s| !s.trim().is_empty())
.collect();
let total = entries.len();
let start = total.saturating_sub(n);
let recent: Vec<String> = entries[start..]
.iter()
.map(|e| crate::safe_truncate(crate::reader::parse_imp_marker(e).0, 300))
.collect();
Ok(Json(recent))
}
async fn consolidate_sessions(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let file_path = std::path::Path::new(&state.config.paths.layers_dir).join("session.txt");
let content = if file_path.exists() {
std::fs::read_to_string(&file_path).unwrap_or_default()
} else {
return Ok(Json(serde_json::json!({"consolidated": 0, "groups": []})));
};
let entries: Vec<&str> = content
.split("\n\n")
.filter(|s| !s.trim().is_empty())
.collect();
if entries.len() < 3 {
return Ok(Json(serde_json::json!({"consolidated": 0, "groups": []})));
}
let mut groups: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for raw_entry in &entries {
let (entry, _imp) = crate::reader::parse_imp_marker(raw_entry);
let sid = if entry.contains("[sid-") {
entry
.split("[sid-")
.nth(1)
.and_then(|s| s.split(']').next())
.map(|s| format!("sid-{}", s))
.unwrap_or_else(|| "nosid".to_string())
} else {
"nosid".to_string()
};
groups.entry(sid).or_default().push(entry.to_string());
}
let mut summaries = Vec::new();
for (sid, group) in &groups {
if group.len() < 2 {
continue;
}
let topics: Vec<String> = group
.iter()
.take(5)
.map(|e| {
let parts: Vec<&str> = e.split("] ").collect();
crate::safe_truncate(parts.last().unwrap_or(&""), 50)
})
.collect();
let summary = format!(
"[{}] CONSOLIDATED: {} interactions. Topics: {}",
sid,
group.len(),
topics.join(" | ")
);
crate::store_memory(&state.config, &summary, "long_term", 8)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
summaries.push(summary);
}
Ok(Json(serde_json::json!({
"consolidated": summaries.len(),
"groups": summaries
})))
}
async fn dream_consolidation(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let output_dir = std::path::Path::new(&state.config.paths.output_dir);
let reader = MicroscopeReader::open(&state.config)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let block_count = reader.block_count;
drop(reader);
match crate::dream::dream_consolidate(
output_dir,
block_count,
state.config.index.max_blocks,
state.config.index.protect_min_importance,
) {
Ok(cycle) => Ok(Json(serde_json::json!({
"status": "ok",
"duration_ms": cycle.duration_ms,
"replayed_fingerprints": cycle.replayed_fingerprints,
"strengthened_pairs": cycle.strengthened_pairs,
"pruned_pairs": cycle.pruned_pairs,
"pruned_activations": cycle.pruned_activations,
"consolidated_patterns": cycle.consolidated_patterns,
"energy_before": cycle.energy_before,
"energy_after": cycle.energy_after
}))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
}
}
pub async fn run(
config: Config,
host: String,
port: u16,
) -> Result<(), Box<dyn std::error::Error>> {
if !is_loopback(&host) && config.server.api_key.as_deref().is_none_or(str::is_empty) {
return Err(
"bridge would be exposed on a non-loopback host without [server] api_key — \
refusing to start"
.into(),
);
}
let state = Arc::new(AppState {
config,
active_project: Arc::new(Mutex::new(None)),
});
let cors = cors_layer(&state.config.server.cors_origin);
let v1_routes = Router::new()
.route("/status", get(get_status))
.route("/session", get(get_session))
.route("/recall", get(recall_memory))
.route("/remember", post(remember_memory))
.route("/find", get(find_memory))
.route("/look", get(look_memory))
.route("/mql", get(mql_query))
.route("/build", post(build_index))
.route("/session_log", get(session_log))
.route("/consolidate", post(consolidate_sessions))
.route("/dream", post(dream_consolidation))
.route("/mobile/recall", post(mobile_recall))
.route("/mobile/remember", post(mobile_remember))
.route("/mobile/chat", post(mobile_chat))
.route("/project/switch", post(switch_project))
.route("/project/recall", post(recall_request_handler));
let app = Router::new()
.route("/", get(get_root))
.nest("/v1", v1_routes)
.route("/status", get(get_status))
.route("/session", get(get_session))
.route("/recall", get(recall_memory))
.route("/remember", post(remember_memory))
.route("/find", get(find_memory))
.route("/look", get(look_memory))
.route("/mql", get(mql_query))
.route("/build", post(build_index))
.route("/session_log", get(session_log))
.route("/consolidate", post(consolidate_sessions))
.route("/dream", post(dream_consolidation))
.route("/project/switch", post(switch_project))
.route("/project/recall", post(recall_request_handler))
.route("/openapi.json", get(get_openapi))
.layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state);
let addr_str = format!("{}:{}", host, port);
let addr: SocketAddr = addr_str.parse()?;
eprintln!(
"Microscope Memory Spine Bridge API starting on http://{}",
addr
);
eprintln!(" OpenAPI spec: http://{}/openapi.json", addr);
eprintln!(" Import URL into ChatGPT/Claude for tool access.");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
fn state_with_key(key: Option<&str>) -> Arc<AppState> {
let mut config = Config::default();
config.server.api_key = key.map(|s| s.to_string());
Arc::new(AppState {
config,
active_project: Arc::new(Mutex::new(None)),
})
}
async fn whoami(Extension(auth): Extension<AuthenticatedUser>) -> String {
auth.0.unwrap_or_else(|| "shared".to_string())
}
#[test]
fn user_token_roundtrip_and_forgery() {
let master = "master";
let token = user_token(master, "alice-1").unwrap();
assert!(token.starts_with("alice-1."));
assert_eq!(
verify_user_token(master, &token).as_deref(),
Some("alice-1")
);
assert!(
verify_user_token("other-master", &token).is_none(),
"wrong master key must fail"
);
let last = token.chars().last().unwrap();
let replaced = if last == '0' { '1' } else { '0' };
let tampered = format!("{}{}", &token[..token.len() - 1], replaced);
assert!(
verify_user_token(master, &tampered).is_none(),
"tampered MAC must fail"
);
assert!(user_token(master, "bad user!").is_err());
assert!(user_token("", "alice").is_err());
}
#[test]
fn effective_user_id_prefers_verified_identity() {
assert_eq!(
effective_user_id(&AuthenticatedUser(Some("alice".to_string())), "bob"),
"alice"
);
assert_eq!(effective_user_id(&AuthenticatedUser(None), "bob"), "bob");
}
#[tokio::test]
async fn bearer_token_binds_identity() {
let master = "test-master-secret";
let token = user_token(master, "alice").unwrap();
let state = state_with_key(Some(master));
let app = Router::new()
.route("/whoami", get(whoami))
.layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
.with_state(state);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/whoami")
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
assert_eq!(&body[..], b"alice");
let forged = user_token("different-master", "alice").unwrap();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/whoami")
.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {forged}"),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = app
.oneshot(
Request::builder()
.uri("/whoami")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn bearer_identity_overrides_body_user_id_end_to_end() {
let tmp = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.paths.layers_dir = tmp.path().join("layers").to_string_lossy().to_string();
config.paths.output_dir = tmp.path().join("data").to_string_lossy().to_string();
config.memory_layers.layers = vec!["long_term".to_string()];
config.embedding.provider = "none".to_string();
std::fs::create_dir_all(&config.paths.layers_dir).unwrap();
crate::build::build(&config, true, true).unwrap();
let stored = "alice private note";
crate::store_memory(&config, &scope_user_text("alice", stored), "long_term", 5).unwrap();
crate::build::build(&config, true, true).unwrap();
let master = "master-key";
let token = user_token(master, "alice").unwrap();
let mut cfg = config.clone();
cfg.server.api_key = Some(master.to_string());
let state = Arc::new(AppState {
config: cfg,
active_project: Arc::new(Mutex::new(None)),
});
let app = Router::new()
.route("/mobile/recall", post(mobile_recall))
.layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
.with_state(state);
let body = serde_json::json!({
"user_id": "bob",
"query": format!("[user:alice] {stored}"),
"k": 10
})
.to_string();
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/mobile/recall")
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
text.contains(stored),
"verified bearer identity must override the body user_id: {text}"
);
}
#[tokio::test]
async fn api_key_middleware_rejects_missing_and_accepts_valid_key() {
let state = state_with_key(Some("secret"));
let app = Router::new()
.route("/ping", get(|| async { "pong" }))
.layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
.with_state(state);
let resp = app
.clone()
.oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/ping")
.header(API_KEY_HEADER, "wrong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = app
.oneshot(
Request::builder()
.uri("/ping")
.header(API_KEY_HEADER, "secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn api_key_middleware_passes_through_without_configured_key() {
let state = state_with_key(None);
let app = Router::new()
.route("/ping", get(|| async { "pong" }))
.layer(middleware::from_fn_with_state(state.clone(), api_key_auth))
.with_state(state);
let resp = app
.oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn loopback_detection() {
assert!(is_loopback("127.0.0.1"));
assert!(is_loopback("localhost"));
assert!(is_loopback("::1"));
assert!(!is_loopback("0.0.0.0"));
assert!(!is_loopback("192.168.1.10"));
}
#[tokio::test]
async fn cors_honors_configured_origin() {
let origin = "https://app.example".to_string();
let app = Router::new()
.route("/ping", get(|| async { "pong" }))
.layer(cors_layer(&Some(origin)));
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/ping")
.header("origin", "https://app.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://app.example")
);
let resp = app
.oneshot(
Request::builder()
.uri("/ping")
.header("origin", "https://evil.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(
resp.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
!= Some("https://evil.example"),
"the requester's origin must never be reflected"
);
}
}