2026-08-22 21:43:23 -07:00
|
|
|
use actix_web::{web, App, HttpServer, HttpResponse, HttpRequest, middleware::Logger};
|
|
|
|
|
use anyhow::Result;
|
2026-08-24 01:58:39 +00:00
|
|
|
use chrono;
|
2026-08-24 01:37:16 +00:00
|
|
|
use mem_llm::{EmbeddingsClient, RerankClient};
|
|
|
|
|
use mem_store::{init_schema, VectorStore};
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::time::Instant;
|
|
|
|
|
use crate::endpoints::IngestRequest;
|
|
|
|
|
use crate::ingest_worker::IngestWorker;
|
|
|
|
|
use crate::query_worker::QueryWorker;
|
2026-08-26 13:35:50 -07:00
|
|
|
use crate::rate_limiter::{RateLimiter, LimitConfig};
|
|
|
|
|
use crate::idempotency::IdempotencyStore;
|
2026-08-27 12:29:23 -07:00
|
|
|
use crate::jwt_validator::{JwtValidator, JwtClaims};
|
2026-08-27 21:05:09 -07:00
|
|
|
use crate::opensearch_client::{OpenSearchClient, HybridWeights};
|
2026-08-28 13:14:39 -07:00
|
|
|
use crate::dual_write_indexer::DualWriteIndexer;
|
|
|
|
|
use crate::gateway_queue_adapter::GatewayQueueAdapter;
|
|
|
|
|
use crate::queue_worker::{QueueWorker, QueueWorkerConfig};
|
|
|
|
|
use crate::queue_adapter::QueueAdapter;
|
2026-09-01 08:41:21 -07:00
|
|
|
use crate::rbac::{AccessGuard, Claims as RbacClaims, builtin_role_provider, ResourceMeta, ResourceType, Verb, Visibility};
|
2026-09-03 09:15:35 -07:00
|
|
|
use crate::handlers::{QueryParams, QueryParamsError, SearchMethod, build_search_response, LearnParams, LearnParamsError, build_learn_response};
|
2026-08-22 21:43:23 -07:00
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// Server state with database and workers
|
2026-08-22 21:43:23 -07:00
|
|
|
pub struct AppState {
|
|
|
|
|
pub api_key: String,
|
|
|
|
|
pub start_time: Instant,
|
2026-08-24 01:37:16 +00:00
|
|
|
pub pool: PgPool,
|
|
|
|
|
pub vector_store: Arc<VectorStore>,
|
|
|
|
|
pub embeddings: Arc<EmbeddingsClient>,
|
|
|
|
|
pub ingest_worker: Arc<IngestWorker>,
|
|
|
|
|
pub query_worker: Arc<QueryWorker>,
|
2026-08-26 13:35:50 -07:00
|
|
|
pub rate_limiter: Arc<RateLimiter>,
|
|
|
|
|
pub idempotency_store: Arc<IdempotencyStore>,
|
2026-08-27 12:29:23 -07:00
|
|
|
pub jwt_validator: Option<Arc<JwtValidator>>,
|
|
|
|
|
pub auth_mode: AuthMode,
|
2026-08-27 21:05:09 -07:00
|
|
|
pub opensearch_client: Option<Arc<OpenSearchClient>>,
|
2026-08-28 12:49:34 -07:00
|
|
|
/// M3.8 Query Optimizer (optional, from environment)
|
|
|
|
|
pub optimizer_service: Option<Arc<mem_core::optimizer::OptimizerService>>,
|
2026-09-01 08:41:21 -07:00
|
|
|
/// RBAC Access Guard (optional, for fine-grained access control)
|
|
|
|
|
pub access_guard: Option<Arc<AccessGuard>>,
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-27 12:29:23 -07:00
|
|
|
/// Authentication mode
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
pub enum AuthMode {
|
|
|
|
|
Jwt, // Validate JWT from Authentik
|
|
|
|
|
ApiKey, // Fallback to static API key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Auth extractor — validates JWT or fallback to apikey
|
|
|
|
|
async fn validate_auth(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, String), HttpResponse> {
|
|
|
|
|
match state.auth_mode {
|
|
|
|
|
AuthMode::Jwt => validate_jwt_token(req, state).await,
|
|
|
|
|
AuthMode::ApiKey => validate_apikey(req, state),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Validate JWT token from Authorization header
|
|
|
|
|
async fn validate_jwt_token(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, String), HttpResponse> {
|
|
|
|
|
let validator = state
|
|
|
|
|
.jwt_validator
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or_else(|| HttpResponse::InternalServerError().json(json!({"error": "jwt_validator_not_configured"})))?;
|
|
|
|
|
|
|
|
|
|
let auth_header = req
|
|
|
|
|
.headers()
|
|
|
|
|
.get("Authorization")
|
|
|
|
|
.and_then(|h| h.to_str().ok())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
HttpResponse::Unauthorized().json(json!({
|
|
|
|
|
"error": "unauthorized",
|
|
|
|
|
"reason": "missing Authorization header"
|
|
|
|
|
}))
|
|
|
|
|
})?
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let token = crate::jwt_validator::JwtValidator::extract_bearer_token(&auth_header)
|
|
|
|
|
.map_err(|_| {
|
|
|
|
|
HttpResponse::Unauthorized().json(json!({
|
|
|
|
|
"error": "unauthorized",
|
|
|
|
|
"reason": "invalid Authorization header format"
|
|
|
|
|
}))
|
|
|
|
|
})?
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
let claims = validator
|
|
|
|
|
.validate_token(&token)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| {
|
|
|
|
|
tracing::warn!("JWT validation failed: {}", e);
|
|
|
|
|
HttpResponse::Unauthorized().json(json!({
|
|
|
|
|
"error": "unauthorized",
|
|
|
|
|
"reason": format!("JWT validation failed: {}", e)
|
|
|
|
|
}))
|
|
|
|
|
})?
|
|
|
|
|
.clone();
|
|
|
|
|
|
|
|
|
|
Ok((claims, token))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fallback: validate apikey header
|
|
|
|
|
fn validate_apikey(req: &HttpRequest, state: &AppState) -> Result<(JwtClaims, String), HttpResponse> {
|
2026-08-22 21:43:23 -07:00
|
|
|
let api_key = req
|
|
|
|
|
.headers()
|
|
|
|
|
.get("apikey")
|
|
|
|
|
.and_then(|h| h.to_str().ok())
|
|
|
|
|
.map(|s| s.to_string());
|
|
|
|
|
|
|
|
|
|
if api_key.as_ref() != Some(&state.api_key) {
|
2026-08-27 12:29:23 -07:00
|
|
|
return Err(HttpResponse::Unauthorized().json(json!({
|
|
|
|
|
"error": "unauthorized",
|
|
|
|
|
"reason": "missing or invalid apikey header"
|
|
|
|
|
})));
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
2026-08-27 12:29:23 -07:00
|
|
|
|
|
|
|
|
// Create a synthetic JWT claims for apikey mode (all permissions)
|
|
|
|
|
let claims = JwtClaims {
|
|
|
|
|
sub: "apikey-user".to_string(),
|
|
|
|
|
iss: "internal".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: chrono::Utc::now().timestamp(),
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: Some(vec!["*".to_string()]),
|
|
|
|
|
groups: None,
|
2026-09-01 09:44:52 -07:00
|
|
|
roles: Some(vec!["admin".to_string()]), // API key gets admin role
|
2026-08-27 12:29:23 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok((claims, "apikey".to_string()))
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-27 12:29:23 -07:00
|
|
|
/// Check if claims have required capability
|
|
|
|
|
fn has_capability(claims: &JwtClaims, required_capability: &str) -> bool {
|
|
|
|
|
if let Some(perms) = &claims.permissions {
|
|
|
|
|
// Wildcard permission grants everything
|
|
|
|
|
if perms.contains(&"*".to_string()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
perms.contains(&required_capability.to_string())
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extract client identifier from claims for rate limiting
|
|
|
|
|
fn extract_rate_limit_key(claims: &JwtClaims) -> String {
|
|
|
|
|
// Use subject (user/service ID) as rate limit key
|
|
|
|
|
claims.sub.clone()
|
2026-08-26 13:35:50 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-01 08:41:21 -07:00
|
|
|
/// Convert JWT claims to RBAC claims for AccessGuard
|
|
|
|
|
fn to_rbac_claims(jwt: &JwtClaims) -> RbacClaims {
|
|
|
|
|
RbacClaims::new(&jwt.sub)
|
2026-09-01 09:44:52 -07:00
|
|
|
.with_roles(jwt.roles.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect())
|
2026-09-01 08:41:21 -07:00
|
|
|
.with_groups(jwt.groups.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect())
|
|
|
|
|
.with_permissions(jwt.permissions.clone().unwrap_or_default().iter().map(|s| s.as_str()).collect())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert QueryResult to ResourceMeta for RBAC filtering
|
|
|
|
|
fn query_result_to_resource_meta(result: &crate::query_worker::QueryResult, project: &str) -> ResourceMeta {
|
|
|
|
|
let source = result.source.as_deref().unwrap_or("unknown");
|
|
|
|
|
|
|
|
|
|
// Determine resource type from source path
|
|
|
|
|
let resource_type = if source.contains("SKILL-") || source.contains("/skills/") {
|
|
|
|
|
ResourceType::Skill
|
|
|
|
|
} else if result.level == "corpus" || result.level == "R" {
|
|
|
|
|
ResourceType::Wiki // Reference docs are wiki-like
|
|
|
|
|
} else {
|
|
|
|
|
ResourceType::Embedding // L0, L1, L2 are learned embeddings
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Determine visibility - private if source path suggests it
|
|
|
|
|
let visibility = if source.contains("/private/") || source.contains("-private") {
|
|
|
|
|
Visibility::Private
|
|
|
|
|
} else {
|
|
|
|
|
Visibility::Public
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ResourceMeta::new(source, resource_type, project)
|
|
|
|
|
.with_visibility(visibility)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-26 13:35:50 -07:00
|
|
|
/// Rate limit guard — call this in handlers to check rate limit
|
2026-08-27 12:29:23 -07:00
|
|
|
fn check_rate_limit(claims: &JwtClaims, state: &AppState, endpoint: &str) -> Result<(), HttpResponse> {
|
|
|
|
|
let key = extract_rate_limit_key(claims);
|
2026-08-26 13:35:50 -07:00
|
|
|
|
2026-08-27 12:29:23 -07:00
|
|
|
match state.rate_limiter.check(&key, endpoint) {
|
2026-08-26 13:35:50 -07:00
|
|
|
Ok(_) => Ok(()),
|
|
|
|
|
Err(rate_limit_err) => {
|
|
|
|
|
let retry_after = rate_limit_err.retry_after_seconds.to_string();
|
|
|
|
|
Err(HttpResponse::TooManyRequests()
|
|
|
|
|
.insert_header(("Retry-After", retry_after))
|
|
|
|
|
.json(json!({
|
|
|
|
|
"error": "rate_limit_exceeded",
|
|
|
|
|
"reason": rate_limit_err.reason.clone(),
|
|
|
|
|
"retry_after_seconds": rate_limit_err.retry_after_seconds,
|
|
|
|
|
"limit_window": format!("{}s", rate_limit_err.limit_window_secs),
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// Start HTTP server with database initialization
|
|
|
|
|
pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Result<()> {
|
|
|
|
|
// Create connection pool
|
|
|
|
|
let pool = PgPool::connect(database_url).await?;
|
|
|
|
|
tracing::info!("Connected to database");
|
|
|
|
|
|
|
|
|
|
// Initialize schema
|
|
|
|
|
init_schema(&pool).await?;
|
|
|
|
|
tracing::info!("Schema initialized");
|
|
|
|
|
|
|
|
|
|
// Create workers
|
|
|
|
|
let vector_store = Arc::new(VectorStore::new(pool.clone()));
|
|
|
|
|
let embeddings = Arc::new(EmbeddingsClient::from_env()?);
|
|
|
|
|
let ingest_worker = Arc::new(IngestWorker::new(pool.clone(), (*embeddings).clone()));
|
|
|
|
|
let reranker = RerankClient::from_env()?;
|
|
|
|
|
let query_worker = Arc::new(QueryWorker::new(VectorStore::new(pool.clone()), (*embeddings).clone(), reranker));
|
|
|
|
|
|
2026-08-26 13:35:50 -07:00
|
|
|
// Initialize rate limiter and idempotency store
|
|
|
|
|
let limit_config = LimitConfig {
|
|
|
|
|
ingest_per_hour: std::env::var("MEM_RATE_LIMIT_INGEST")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(100.0),
|
|
|
|
|
query_per_hour: std::env::var("MEM_RATE_LIMIT_QUERY")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(1000.0),
|
|
|
|
|
projects_per_hour: std::env::var("MEM_RATE_LIMIT_PROJECTS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(100.0),
|
|
|
|
|
burst_per_second: std::env::var("MEM_RATE_LIMIT_BURST")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(10.0),
|
|
|
|
|
};
|
|
|
|
|
let rate_limiter = Arc::new(RateLimiter::new(limit_config));
|
|
|
|
|
|
|
|
|
|
let idempotency_ttl = std::env::var("MEM_IDEMPOTENCY_TTL_SECS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(86400); // 24 hours default
|
|
|
|
|
let idempotency_store = Arc::new(IdempotencyStore::new(idempotency_ttl));
|
|
|
|
|
|
2026-08-27 12:29:23 -07:00
|
|
|
// Determine auth mode
|
|
|
|
|
let auth_mode = std::env::var("MEM_AUTH_MODE")
|
|
|
|
|
.unwrap_or_else(|_| "apikey".to_string())
|
|
|
|
|
.to_lowercase();
|
|
|
|
|
let auth_mode = match auth_mode.as_str() {
|
|
|
|
|
"jwt" => AuthMode::Jwt,
|
|
|
|
|
"apikey" => AuthMode::ApiKey,
|
|
|
|
|
_ => {
|
|
|
|
|
tracing::warn!("Unknown auth mode: {}, defaulting to apikey", auth_mode);
|
|
|
|
|
AuthMode::ApiKey
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Setup JWT validator if in JWT mode
|
|
|
|
|
let jwt_validator = if matches!(auth_mode, AuthMode::Jwt) {
|
|
|
|
|
let issuer = std::env::var("AUTHENTIK_ISSUER").map_err(|e| {
|
|
|
|
|
anyhow::anyhow!("AUTHENTIK_ISSUER env var required for JWT auth: {}", e)
|
|
|
|
|
})?;
|
|
|
|
|
let audience = std::env::var("AUTHENTIK_AUDIENCE").map_err(|e| {
|
|
|
|
|
anyhow::anyhow!("AUTHENTIK_AUDIENCE env var required for JWT auth: {}", e)
|
|
|
|
|
})?;
|
|
|
|
|
let cache_ttl = std::env::var("JWT_CACHE_TTL_SECS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(3600); // 1 hour default
|
|
|
|
|
Some(Arc::new(crate::jwt_validator::JwtValidator::new(
|
|
|
|
|
issuer,
|
|
|
|
|
audience,
|
|
|
|
|
cache_ttl,
|
|
|
|
|
)))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-27 21:05:09 -07:00
|
|
|
// Initialize OpenSearch client if configured
|
|
|
|
|
let opensearch_client = if let Ok(hosts_str) = std::env::var("OPENSEARCH_HOSTS") {
|
|
|
|
|
let hosts: Vec<String> = hosts_str
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(|h| h.trim().to_string())
|
|
|
|
|
.collect();
|
|
|
|
|
Some(Arc::new(OpenSearchClient::new(hosts)))
|
|
|
|
|
} else {
|
|
|
|
|
tracing::warn!("OPENSEARCH_HOSTS not set, hybrid search disabled");
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-28 12:49:34 -07:00
|
|
|
// Initialize M3.8 Query Optimizer if enabled
|
|
|
|
|
let optimizer_service = match mem_core::optimizer::OptimizerServiceBuilder::new().build() {
|
|
|
|
|
Ok(service) => {
|
|
|
|
|
tracing::info!("M3.8 Query Optimizer enabled");
|
|
|
|
|
Some(Arc::new(service))
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::debug!("M3.8 Query Optimizer not available: {}", e);
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-28 13:14:39 -07:00
|
|
|
// Initialize M8.2 Queue Adapter and Dual-Write Indexer
|
|
|
|
|
let queue_adapter: Arc<dyn QueueAdapter> = if let Ok(gateway_url) = std::env::var("GATEWAY_URL") {
|
|
|
|
|
let adapter = GatewayQueueAdapter::with_authentik(
|
|
|
|
|
gateway_url,
|
|
|
|
|
std::env::var("AUTHENTIK_ISSUER").unwrap_or_default(),
|
|
|
|
|
std::env::var("AUTHENTIK_CLIENT_ID").unwrap_or_default(),
|
|
|
|
|
std::env::var("AUTHENTIK_CLIENT_SECRET").unwrap_or_default(),
|
|
|
|
|
);
|
|
|
|
|
tracing::info!("M8.2 Gateway Queue Adapter initialized");
|
|
|
|
|
Arc::new(adapter)
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to in-memory adapter for development
|
|
|
|
|
tracing::warn!("GATEWAY_URL not set, using in-memory queue adapter (development only)");
|
|
|
|
|
Arc::new(crate::queue_adapter::InMemoryQueueAdapter::new())
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let dual_write_indexer = Arc::new(DualWriteIndexer::new(
|
|
|
|
|
pool.clone(),
|
|
|
|
|
opensearch_client.clone(),
|
|
|
|
|
queue_adapter.clone(),
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
// Start queue worker in background (only if queue operations are enabled)
|
|
|
|
|
let enable_queue_worker = std::env::var("ENABLE_QUEUE_WORKER")
|
|
|
|
|
.unwrap_or_else(|_| "true".to_string())
|
|
|
|
|
.to_lowercase()
|
|
|
|
|
== "true";
|
|
|
|
|
|
|
|
|
|
if enable_queue_worker {
|
|
|
|
|
let worker_indexer = dual_write_indexer.clone();
|
|
|
|
|
let worker_embeddings = embeddings.clone();
|
|
|
|
|
let worker_config = QueueWorkerConfig {
|
|
|
|
|
max_messages_per_batch: std::env::var("QUEUE_BATCH_SIZE")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(10),
|
|
|
|
|
visibility_timeout_secs: std::env::var("QUEUE_VISIBILITY_TIMEOUT")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(300),
|
|
|
|
|
wait_time_secs: std::env::var("QUEUE_WAIT_TIME")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(20),
|
|
|
|
|
project: std::env::var("QUEUE_PROJECT").ok(),
|
|
|
|
|
max_retries: std::env::var("QUEUE_MAX_RETRIES")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| s.parse().ok())
|
|
|
|
|
.unwrap_or(3),
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let worker = QueueWorker::new(worker_indexer, worker_embeddings, worker_config);
|
|
|
|
|
if let Err(e) = worker.start().await {
|
|
|
|
|
tracing::error!("Queue worker error: {}", e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tracing::info!("M8.2 Queue Worker started (background task)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 08:41:21 -07:00
|
|
|
// Initialize RBAC AccessGuard with built-in roles
|
|
|
|
|
let access_guard = {
|
|
|
|
|
let role_provider = Arc::new(builtin_role_provider());
|
|
|
|
|
Some(Arc::new(AccessGuard::new(role_provider)))
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-22 21:43:23 -07:00
|
|
|
let state = web::Data::new(AppState {
|
|
|
|
|
api_key,
|
|
|
|
|
start_time: Instant::now(),
|
2026-08-24 01:37:16 +00:00
|
|
|
pool,
|
|
|
|
|
vector_store,
|
|
|
|
|
embeddings,
|
|
|
|
|
ingest_worker,
|
|
|
|
|
query_worker,
|
2026-08-26 13:35:50 -07:00
|
|
|
rate_limiter,
|
|
|
|
|
idempotency_store,
|
2026-08-27 12:29:23 -07:00
|
|
|
jwt_validator,
|
|
|
|
|
auth_mode,
|
2026-08-27 21:05:09 -07:00
|
|
|
opensearch_client,
|
2026-08-28 12:49:34 -07:00
|
|
|
optimizer_service,
|
2026-09-01 08:41:21 -07:00
|
|
|
access_guard,
|
2026-08-22 21:43:23 -07:00
|
|
|
});
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
tracing::info!("Starting HTTP server on port {}", port);
|
|
|
|
|
|
2026-08-22 21:43:23 -07:00
|
|
|
HttpServer::new(move || {
|
|
|
|
|
App::new()
|
|
|
|
|
.app_data(state.clone())
|
|
|
|
|
.wrap(Logger::default())
|
|
|
|
|
.route("/health", web::get().to(health_check))
|
|
|
|
|
.route("/memory/ingest", web::post().to(ingest_handler))
|
2026-08-24 01:37:16 +00:00
|
|
|
.route("/memory/ingest/{ingest_id}", web::get().to(ingest_status))
|
2026-08-22 21:43:23 -07:00
|
|
|
.route("/memory/query", web::get().to(query_handler))
|
2026-08-28 13:50:32 -07:00
|
|
|
.route("/memory/context", web::post().to(context_handler))
|
2026-08-22 21:43:23 -07:00
|
|
|
.route("/memory/projects", web::get().to(projects_handler))
|
2026-08-24 01:37:16 +00:00
|
|
|
.route("/memory/skills", web::get().to(skills_handler))
|
2026-08-30 13:21:07 -07:00
|
|
|
.route("/memory/learn", web::post().to(learn_handler))
|
2026-08-24 13:44:28 -07:00
|
|
|
.route("/memory/vault/generate", web::post().to(vault_generate_handler))
|
|
|
|
|
.route("/memory/vault", web::get().to(vault_browser_handler))
|
|
|
|
|
.route("/memory/vault/{project}", web::get().to(vault_project_handler))
|
|
|
|
|
.route("/memory/vault/{project}/{file}", web::get().to(vault_file_handler))
|
2026-08-22 21:43:23 -07:00
|
|
|
})
|
2026-08-23 00:01:30 -07:00
|
|
|
.bind(("0.0.0.0", port))?
|
2026-08-22 21:43:23 -07:00
|
|
|
.run()
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// Health check (no auth)
|
2026-08-22 21:43:23 -07:00
|
|
|
pub async fn health_check(state: web::Data<AppState>) -> HttpResponse {
|
|
|
|
|
let uptime = state.start_time.elapsed().as_secs();
|
2026-08-24 01:37:16 +00:00
|
|
|
HttpResponse::Ok().json(json!({"status": "ok", "uptime_seconds": uptime}))
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// POST /memory/ingest — queue an ingest job
|
2026-08-22 21:43:23 -07:00
|
|
|
pub async fn ingest_handler(
|
|
|
|
|
req: HttpRequest,
|
|
|
|
|
body: web::Json<IngestRequest>,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-09-03 09:15:35 -07:00
|
|
|
// Auth + capability check
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
if !has_capability(&claims, "memory:write") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:write"
|
|
|
|
|
}));
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
2026-08-27 12:29:23 -07:00
|
|
|
if let Err(e) = check_rate_limit(&claims, &state, "/memory/ingest") {
|
2026-08-26 13:35:50 -07:00
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 08:43:49 -07:00
|
|
|
// RBAC: Check project-level write access
|
2026-09-03 09:15:35 -07:00
|
|
|
if let Err(e) = check_project_write_access(&state, &claims, &body.project).await {
|
|
|
|
|
return e;
|
2026-09-01 08:43:49 -07:00
|
|
|
}
|
2026-09-03 09:15:35 -07:00
|
|
|
|
|
|
|
|
// Check idempotency
|
|
|
|
|
if let Some(cached) = state.idempotency_store.get(&body.ingest_id) {
|
|
|
|
|
tracing::info!("Returning cached response for ingest_id: {}", body.ingest_id);
|
|
|
|
|
return HttpResponse::Accepted().json(cached);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Execute ingest
|
|
|
|
|
execute_ingest(&state, &body).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check RBAC project write access
|
|
|
|
|
async fn check_project_write_access(
|
|
|
|
|
state: &web::Data<AppState>,
|
|
|
|
|
claims: &JwtClaims,
|
|
|
|
|
project: &str,
|
|
|
|
|
) -> Result<(), HttpResponse> {
|
|
|
|
|
let Some(guard) = &state.access_guard else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rbac_claims = to_rbac_claims(claims);
|
|
|
|
|
let resource = ResourceMeta::new(project, ResourceType::Project, project);
|
|
|
|
|
|
|
|
|
|
if !guard.can_write(&rbac_claims, &resource).await {
|
|
|
|
|
tracing::warn!("RBAC denied write access to project '{}' for user '{}'", project, claims.sub);
|
|
|
|
|
return Err(HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": format!("write access denied to project '{}'", project)
|
|
|
|
|
})));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Execute ingest job creation and spawn worker
|
|
|
|
|
async fn execute_ingest(
|
|
|
|
|
state: &web::Data<AppState>,
|
|
|
|
|
body: &IngestRequest,
|
|
|
|
|
) -> HttpResponse {
|
|
|
|
|
let records: Vec<(String, String)> = body.records
|
2026-08-24 01:37:16 +00:00
|
|
|
.iter()
|
|
|
|
|
.map(|r| (r.text.clone(), body.source.clone()))
|
|
|
|
|
.collect();
|
2026-08-22 21:43:23 -07:00
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
let job_result = sqlx::query(
|
|
|
|
|
"INSERT INTO ingest_jobs (id, project, ingest_id, status, created_at)
|
|
|
|
|
VALUES ($1, $2, $3, 'pending', NOW())
|
|
|
|
|
ON CONFLICT (ingest_id) DO NOTHING
|
|
|
|
|
RETURNING id",
|
|
|
|
|
)
|
|
|
|
|
.bind(uuid::Uuid::new_v4())
|
2026-09-03 09:15:35 -07:00
|
|
|
.bind(&body.project)
|
|
|
|
|
.bind(&body.ingest_id)
|
2026-08-24 01:37:16 +00:00
|
|
|
.fetch_optional(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
2026-09-03 09:15:35 -07:00
|
|
|
let response = json!({
|
|
|
|
|
"ingest_id": body.ingest_id,
|
|
|
|
|
"status": "pending",
|
|
|
|
|
"status_url": format!("/memory/ingest/{}", body.ingest_id)
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
match job_result {
|
|
|
|
|
Ok(Some(_)) => {
|
|
|
|
|
// Spawn async ingest task
|
|
|
|
|
let worker = state.ingest_worker.clone();
|
2026-09-03 09:15:35 -07:00
|
|
|
let project = body.project.clone();
|
|
|
|
|
let ingest_id = body.ingest_id.clone();
|
2026-08-24 01:37:16 +00:00
|
|
|
tokio::spawn(async move {
|
2026-09-03 09:15:35 -07:00
|
|
|
if let Err(e) = worker.process_ingest(&project, &ingest_id, records).await {
|
2026-08-24 01:37:16 +00:00
|
|
|
tracing::error!("Ingest failed: {}", e);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-09-03 09:15:35 -07:00
|
|
|
state.idempotency_store.set(body.ingest_id.clone(), response.clone());
|
2026-08-26 13:35:50 -07:00
|
|
|
HttpResponse::Accepted().json(response)
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
2026-09-03 09:15:35 -07:00
|
|
|
// Already exists (concurrent insert)
|
|
|
|
|
state.idempotency_store.set(body.ingest_id.clone(), response.clone());
|
2026-08-26 13:35:50 -07:00
|
|
|
HttpResponse::Accepted().json(response)
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("DB error: {}", e);
|
2026-09-03 09:15:35 -07:00
|
|
|
HttpResponse::InternalServerError().json(json!({"error": "database_error"}))
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// GET /memory/ingest/{ingest_id} — check ingest status
|
2026-08-22 21:43:23 -07:00
|
|
|
pub async fn ingest_status(
|
|
|
|
|
req: HttpRequest,
|
2026-08-24 01:37:16 +00:00
|
|
|
ingest_id: web::Path<String>,
|
2026-08-22 21:43:23 -07:00
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
let id = ingest_id.into_inner();
|
|
|
|
|
let result = sqlx::query_as::<_, (String, String, Option<String>)>(
|
|
|
|
|
"SELECT ingest_id, status, error FROM ingest_jobs WHERE ingest_id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(&id)
|
|
|
|
|
.fetch_optional(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(Some((ingest_id, status, error))) => {
|
|
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"ingest_id": ingest_id,
|
|
|
|
|
"status": status,
|
|
|
|
|
"error": error
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => {
|
|
|
|
|
HttpResponse::NotFound().json(json!({"error": "not_found"}))
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
HttpResponse::InternalServerError().json(json!({"error": "database_error"}))
|
|
|
|
|
}
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 12:49:34 -07:00
|
|
|
/// M3.8: Optimize search results using pluggable OptimizerService
|
|
|
|
|
///
|
|
|
|
|
/// If optimizer_service is available, optimizes chunk text before returning.
|
|
|
|
|
/// Gracefully falls back to original on any error.
|
|
|
|
|
///
|
|
|
|
|
/// For LLM integration, use build_cache_aligned_async from PromptBuilder:
|
|
|
|
|
/// ```ignore
|
|
|
|
|
/// let msgs = PromptBuilder::build_cache_aligned_async(
|
|
|
|
|
/// &query,
|
|
|
|
|
/// previous_memory.as_deref(),
|
|
|
|
|
/// &chunk,
|
|
|
|
|
/// &optimizer_service,
|
|
|
|
|
/// ).await?;
|
|
|
|
|
/// ```
|
|
|
|
|
async fn optimize_search_results(
|
|
|
|
|
mut results: Vec<crate::query_worker::QueryResult>,
|
|
|
|
|
optimizer: Option<&Arc<mem_core::optimizer::OptimizerService>>,
|
|
|
|
|
) -> Vec<crate::query_worker::QueryResult> {
|
|
|
|
|
if optimizer.is_none() {
|
|
|
|
|
return results; // Optimizer not enabled, return as-is
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let svc = optimizer.unwrap();
|
|
|
|
|
let mut optimized = Vec::new();
|
|
|
|
|
|
|
|
|
|
for mut result in results {
|
|
|
|
|
match svc.optimize(&result.text, "text/plain", Some("raw")).await {
|
|
|
|
|
Ok(optimized_bytes) => {
|
|
|
|
|
if let Ok(optimized_text) = String::from_utf8(optimized_bytes) {
|
|
|
|
|
let orig_len = result.text.len();
|
|
|
|
|
let opt_len = optimized_text.len();
|
|
|
|
|
result.text = optimized_text;
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"M3.8 optimized chunk: {} bytes → {} bytes ({:.1}% compression)",
|
|
|
|
|
orig_len,
|
|
|
|
|
opt_len,
|
|
|
|
|
(opt_len as f32 / orig_len as f32) * 100.0
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
// Graceful fallback: use original on optimization error
|
|
|
|
|
tracing::warn!("M3.8 optimization failed, using original: {}", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
optimized.push(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
optimized
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 13:21:07 -07:00
|
|
|
/// POST /memory/learn — Ingest knowledge via gated loop (LLM evaluates + compacts)
|
|
|
|
|
///
|
|
|
|
|
/// OpenAI-compatible style endpoint. Accepts markdown text, chunks it,
|
|
|
|
|
/// runs each chunk through the gated loop where the LLM decides whether
|
|
|
|
|
/// to accept/reject and rewrites memory to stay compact.
|
|
|
|
|
///
|
|
|
|
|
/// Request:
|
|
|
|
|
/// POST /memory/learn
|
|
|
|
|
/// { "project": "knowledge", "text": "## Rust\n- ownership...", "query": "What are key Rust patterns?" }
|
|
|
|
|
///
|
|
|
|
|
/// Response:
|
|
|
|
|
/// { "project": "knowledge", "chunks_seen": 5, "chunks_used": 3,
|
|
|
|
|
/// "memory": "compacted memory text...", "status": "completed" }
|
|
|
|
|
pub async fn learn_handler(
|
|
|
|
|
req: HttpRequest,
|
|
|
|
|
body: web::Json<serde_json::Value>,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-09-03 09:15:35 -07:00
|
|
|
// Auth + capability check
|
2026-08-30 13:21:07 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
if !has_capability(&claims, "memory:write") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:write"
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = check_rate_limit(&claims, &state, "/memory/ingest") {
|
|
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 09:15:35 -07:00
|
|
|
// Parse + validate params
|
|
|
|
|
let params = match LearnParams::from_body(&body) {
|
|
|
|
|
Ok(p) => p,
|
|
|
|
|
Err(e) => return e.to_response(),
|
|
|
|
|
};
|
2026-09-01 08:43:49 -07:00
|
|
|
|
|
|
|
|
// RBAC: Check project-level write access
|
2026-09-03 09:15:35 -07:00
|
|
|
if let Err(e) = check_project_write_access(&state, &claims, ¶ms.project).await {
|
|
|
|
|
return e;
|
2026-09-01 08:43:49 -07:00
|
|
|
}
|
2026-08-30 13:21:07 -07:00
|
|
|
|
|
|
|
|
// Chunk the markdown
|
2026-09-03 09:15:35 -07:00
|
|
|
let chunks = chunk_markdown_text(¶ms.text, params.chunk_size);
|
2026-08-30 13:21:07 -07:00
|
|
|
if chunks.is_empty() {
|
|
|
|
|
return HttpResponse::BadRequest().json(json!({
|
|
|
|
|
"error": "bad_request",
|
|
|
|
|
"reason": "text produced no chunks"
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build domain chunks
|
|
|
|
|
let domain_chunks: Vec<mem_core::domain::Chunk> = chunks
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, text)| {
|
|
|
|
|
let record = mem_core::Record {
|
|
|
|
|
role: mem_core::Role::User,
|
|
|
|
|
text: text.clone(),
|
|
|
|
|
timestamp: time::OffsetDateTime::now_utc(),
|
|
|
|
|
provenance: mem_core::Provenance {
|
2026-09-03 09:15:35 -07:00
|
|
|
source_id: format!("learn://{}:{}", params.project, i),
|
2026-08-30 13:21:07 -07:00
|
|
|
offset: i as u64,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
mem_core::domain::Chunk::new((i + 1) as u32, vec![record], text.len() / 4)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Build query for gated loop
|
|
|
|
|
let query = mem_core::query::Query {
|
|
|
|
|
id: format!("learn-{}", uuid::Uuid::new_v4()),
|
2026-09-03 09:15:35 -07:00
|
|
|
question: params.question.clone(),
|
2026-08-30 13:21:07 -07:00
|
|
|
exit_gate: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let config = mem_core::gated_loop::LoopConfig {
|
|
|
|
|
level: mem_core::Level::L1,
|
|
|
|
|
query,
|
2026-09-03 09:15:35 -07:00
|
|
|
memory_budget: params.memory_budget,
|
2026-08-30 13:21:07 -07:00
|
|
|
use_exit_gate: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Create LLM client
|
|
|
|
|
let llm_base = std::env::var("LLM_API_BASE")
|
|
|
|
|
.unwrap_or_else(|_| "https://api.riotpiao.com".to_string());
|
|
|
|
|
let llm_key = std::env::var("LLM_API_KEY")
|
|
|
|
|
.or_else(|_| std::env::var("MEM_API_KEY"))
|
|
|
|
|
.unwrap_or_default();
|
2026-09-03 09:15:35 -07:00
|
|
|
let llm = match mem_llm::ChatClient::new(&llm_base, &llm_key, ¶ms.model) {
|
2026-08-30 13:21:07 -07:00
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return HttpResponse::InternalServerError().json(json!({
|
|
|
|
|
"error": "llm_init_failed",
|
|
|
|
|
"reason": e.to_string()
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Run gated loop
|
|
|
|
|
let outcome = match mem_core::gated_loop::run_loop(config, domain_chunks, &llm) {
|
|
|
|
|
Ok(o) => o,
|
|
|
|
|
Err(e) => return HttpResponse::InternalServerError().json(json!({
|
|
|
|
|
"error": "gated_loop_failed",
|
|
|
|
|
"reason": e.to_string()
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-03 09:15:35 -07:00
|
|
|
// Store compacted memory
|
|
|
|
|
let stored = store_compacted_memory(&state, ¶ms.project, &outcome.final_memory).await;
|
2026-08-30 13:21:07 -07:00
|
|
|
|
2026-09-03 09:15:35 -07:00
|
|
|
build_learn_response(
|
|
|
|
|
¶ms.project,
|
|
|
|
|
¶ms.model,
|
|
|
|
|
outcome.chunks_seen,
|
|
|
|
|
outcome.chunks_used,
|
|
|
|
|
&outcome.final_memory,
|
|
|
|
|
stored,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Store compacted memory in pgvector
|
|
|
|
|
async fn store_compacted_memory(
|
|
|
|
|
state: &web::Data<AppState>,
|
|
|
|
|
project: &str,
|
|
|
|
|
memory: &str,
|
|
|
|
|
) -> bool {
|
|
|
|
|
if memory.is_empty() {
|
|
|
|
|
return false;
|
2026-08-30 13:21:07 -07:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 09:15:35 -07:00
|
|
|
let embedding = match state.embeddings.embed_one(memory).await {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("Failed to embed compacted memory: {}", e);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let sha = {
|
|
|
|
|
use sha2::{Digest, Sha256};
|
|
|
|
|
let mut h = Sha256::new();
|
|
|
|
|
h.update(memory.as_bytes());
|
|
|
|
|
format!("{:x}", h.finalize())
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let result = sqlx::query(
|
|
|
|
|
"INSERT INTO memory_chunks (id, project, level, text, embedding, sha256, source, created_at)
|
|
|
|
|
VALUES ($1, $2, 'L1', $3, $4, $5, $6, NOW())
|
|
|
|
|
ON CONFLICT (sha256) DO UPDATE SET text = $3, embedding = $4",
|
|
|
|
|
)
|
|
|
|
|
.bind(uuid::Uuid::new_v4())
|
|
|
|
|
.bind(project)
|
|
|
|
|
.bind(memory)
|
|
|
|
|
.bind(embedding.to_vec())
|
|
|
|
|
.bind(&sha)
|
|
|
|
|
.bind(format!("learn://{}", project))
|
|
|
|
|
.fetch_optional(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(_) => true,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("Failed to store compacted memory: {}", e);
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-30 13:21:07 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Split markdown on ## headings for learn endpoint.
|
|
|
|
|
fn chunk_markdown_text(content: &str, max_chunk: usize) -> Vec<String> {
|
|
|
|
|
let mut chunks = Vec::new();
|
|
|
|
|
let mut current = String::new();
|
|
|
|
|
for line in content.lines() {
|
|
|
|
|
if line.starts_with("## ") && !current.is_empty() {
|
|
|
|
|
let trimmed = current.trim().to_string();
|
|
|
|
|
if !trimmed.is_empty() { chunks.push(trimmed); }
|
|
|
|
|
current = String::new();
|
|
|
|
|
}
|
|
|
|
|
current.push_str(line);
|
|
|
|
|
current.push('\n');
|
|
|
|
|
if current.len() > max_chunk {
|
|
|
|
|
let trimmed = current.trim().to_string();
|
|
|
|
|
if !trimmed.is_empty() { chunks.push(trimmed); }
|
|
|
|
|
current = String::new();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let trimmed = current.trim().to_string();
|
|
|
|
|
if !trimmed.is_empty() { chunks.push(trimmed); }
|
|
|
|
|
chunks
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// GET /memory/query — semantic search across memories
|
2026-08-22 21:43:23 -07:00
|
|
|
pub async fn query_handler(
|
|
|
|
|
req: HttpRequest,
|
2026-08-24 01:37:16 +00:00
|
|
|
query: web::Query<std::collections::HashMap<String, String>>,
|
2026-08-22 21:43:23 -07:00
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-09-03 09:12:38 -07:00
|
|
|
// Auth + capability check
|
2026-08-27 21:05:09 -07:00
|
|
|
let (claims, token) = match validate_auth(&req, &state).await {
|
2026-08-27 12:29:23 -07:00
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
2026-08-27 12:29:23 -07:00
|
|
|
if let Err(e) = check_rate_limit(&claims, &state, "/memory/query") {
|
2026-08-26 13:35:50 -07:00
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
// Parse + validate params
|
|
|
|
|
let params = match QueryParams::from_query(&query) {
|
|
|
|
|
Ok(p) => p,
|
|
|
|
|
Err(e) => return e.to_response(),
|
2026-08-24 01:37:16 +00:00
|
|
|
};
|
2026-08-22 21:43:23 -07:00
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
// Execute semantic search
|
|
|
|
|
let mut results = match state.query_worker.query(¶ms.project, ¶ms.question, Some(50)).await {
|
|
|
|
|
Ok(r) => r,
|
2026-08-27 21:05:09 -07:00
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("Semantic search failed: {}", e);
|
|
|
|
|
return HttpResponse::InternalServerError().json(json!({"error": "semantic_search_failed"}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
// M3.8: Optimize results
|
|
|
|
|
results = optimize_search_results(results, state.optimizer_service.as_ref()).await;
|
2026-08-28 12:49:34 -07:00
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
// RBAC: Filter by access control
|
|
|
|
|
results = apply_rbac_filter(&state, &claims, results, ¶ms.project).await;
|
|
|
|
|
|
|
|
|
|
// Route by search method
|
|
|
|
|
match params.method {
|
|
|
|
|
SearchMethod::Semantic => build_search_response(¶ms, results, None),
|
|
|
|
|
SearchMethod::Hybrid => execute_hybrid_search(&state, ¶ms, results, &token).await,
|
2026-09-01 08:41:21 -07:00
|
|
|
}
|
2026-09-03 09:12:38 -07:00
|
|
|
}
|
2026-09-01 08:41:21 -07:00
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
/// Apply RBAC filtering to search results
|
|
|
|
|
async fn apply_rbac_filter(
|
|
|
|
|
state: &web::Data<AppState>,
|
|
|
|
|
claims: &JwtClaims,
|
|
|
|
|
results: Vec<crate::query_worker::QueryResult>,
|
|
|
|
|
project: &str,
|
|
|
|
|
) -> Vec<crate::query_worker::QueryResult> {
|
|
|
|
|
let Some(guard) = &state.access_guard else {
|
|
|
|
|
return results;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rbac_claims = to_rbac_claims(claims);
|
|
|
|
|
let resources: Vec<ResourceMeta> = results
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|r| query_result_to_resource_meta(r, project))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let decisions = guard.check_access_batch(&rbac_claims, &resources, Verb::Read).await;
|
|
|
|
|
|
|
|
|
|
let filtered: Vec<_> = results
|
|
|
|
|
.into_iter()
|
|
|
|
|
.zip(decisions.iter())
|
|
|
|
|
.filter(|(_, d)| d.is_allowed())
|
|
|
|
|
.map(|(r, _)| r)
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"RBAC filtered {} results for user {}",
|
|
|
|
|
decisions.iter().filter(|d| d.is_denied()).count(),
|
|
|
|
|
claims.sub
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
filtered
|
|
|
|
|
}
|
2026-08-27 21:05:09 -07:00
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
/// Execute hybrid search with OpenSearch fallback
|
|
|
|
|
async fn execute_hybrid_search(
|
|
|
|
|
state: &web::Data<AppState>,
|
|
|
|
|
params: &QueryParams,
|
|
|
|
|
results: Vec<crate::query_worker::QueryResult>,
|
|
|
|
|
token: &str,
|
|
|
|
|
) -> HttpResponse {
|
|
|
|
|
let Some(os_client) = &state.opensearch_client else {
|
|
|
|
|
tracing::info!("OpenSearch not configured, using semantic search only");
|
|
|
|
|
return build_search_response(params, results, Some("semantic_only"));
|
|
|
|
|
};
|
2026-08-27 21:05:09 -07:00
|
|
|
|
2026-09-03 09:12:38 -07:00
|
|
|
let sem_results: Vec<(String, f32, String, String, Vec<String>)> = results
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(i, r)| (
|
|
|
|
|
format!("sem-{}", i),
|
|
|
|
|
r.score,
|
|
|
|
|
r.text.clone(),
|
|
|
|
|
r.source.clone().unwrap_or_default(),
|
|
|
|
|
r.provenance.clone(),
|
|
|
|
|
))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let weights = HybridWeights { semantic: 0.6, lexical: 0.4 };
|
|
|
|
|
|
|
|
|
|
match os_client.hybrid_search(¶ms.question, sem_results, token, params.limit as usize, &weights).await {
|
|
|
|
|
Ok(_) => build_search_response(params, results, Some("hybrid")),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!("Hybrid search failed, falling back to semantic: {}", e);
|
|
|
|
|
build_search_response(params, results, Some("semantic_fallback"))
|
2026-08-24 01:37:16 +00:00
|
|
|
}
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// GET /memory/projects — list projects with memory
|
2026-08-22 21:43:23 -07:00
|
|
|
pub async fn projects_handler(
|
|
|
|
|
req: HttpRequest,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-27 12:29:23 -07:00
|
|
|
if let Err(e) = check_rate_limit(&claims, &state, "/memory/projects") {
|
2026-08-26 13:35:50 -07:00
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
let result = sqlx::query_as::<_, (String,)>(
|
|
|
|
|
"SELECT DISTINCT project FROM memories_l2 ORDER BY project",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(rows) => {
|
2026-09-01 08:43:49 -07:00
|
|
|
let mut projects: Vec<String> = rows.into_iter().map(|(p,)| p).collect();
|
|
|
|
|
|
|
|
|
|
// RBAC: Filter projects by access
|
|
|
|
|
if let Some(guard) = &state.access_guard {
|
|
|
|
|
let rbac_claims = to_rbac_claims(&claims);
|
|
|
|
|
let mut allowed_projects = Vec::new();
|
|
|
|
|
|
|
|
|
|
for project in projects {
|
|
|
|
|
let resource = ResourceMeta::new(&project, ResourceType::Project, &project);
|
|
|
|
|
if guard.can_read(&rbac_claims, &resource).await {
|
|
|
|
|
allowed_projects.push(project);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
projects = allowed_projects;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"projects": projects,
|
|
|
|
|
"count": projects.len()
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
HttpResponse::InternalServerError().json(json!({"error": "database_error"}))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
/// GET /memory/skills — list extracted skills
|
|
|
|
|
pub async fn skills_handler(
|
2026-08-22 21:43:23 -07:00
|
|
|
req: HttpRequest,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:37:16 +00:00
|
|
|
let result = sqlx::query_as::<_, (String, String, String)>(
|
|
|
|
|
"SELECT name, description, when_to_use FROM skills ORDER BY created_at DESC LIMIT 50",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(rows) => {
|
|
|
|
|
let skills: Vec<serde_json::Value> = rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(name, desc, when_to_use)| {
|
|
|
|
|
json!({
|
|
|
|
|
"name": name,
|
|
|
|
|
"description": desc,
|
|
|
|
|
"when_to_use": when_to_use
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"skills": skills,
|
|
|
|
|
"count": skills.len()
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
HttpResponse::InternalServerError().json(json!({"error": "database_error"}))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-22 21:43:23 -07:00
|
|
|
}
|
2026-08-24 01:58:39 +00:00
|
|
|
|
2026-08-28 13:50:32 -07:00
|
|
|
/// POST /memory/context — three-tier context lookup for failure diagnosis
|
|
|
|
|
pub async fn context_handler(
|
|
|
|
|
req: HttpRequest,
|
|
|
|
|
body: web::Json<crate::context_endpoint::ContextRequest>,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
|
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Err(e) = check_rate_limit(&claims, &state, "/memory/context") {
|
|
|
|
|
return e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let project = body.project.clone().unwrap_or_else(|| "all".to_string());
|
|
|
|
|
let scope = body.scope.clone().unwrap_or_else(|| "project".to_string());
|
|
|
|
|
let budget = body.budget.unwrap_or(6000);
|
|
|
|
|
|
2026-09-01 08:41:21 -07:00
|
|
|
// RBAC: Check project-level access
|
|
|
|
|
if let Some(guard) = &state.access_guard {
|
|
|
|
|
let rbac_claims = to_rbac_claims(&claims);
|
|
|
|
|
let project_resource = ResourceMeta::new(&project, ResourceType::Project, &project);
|
|
|
|
|
|
|
|
|
|
if !guard.can_read(&rbac_claims, &project_resource).await {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"RBAC denied access to project '{}' for user '{}'",
|
|
|
|
|
project, claims.sub
|
|
|
|
|
);
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": format!("access denied to project '{}'", project)
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 13:50:32 -07:00
|
|
|
let lookup = crate::context_endpoint::ContextLookup::new(budget, project, scope);
|
|
|
|
|
|
|
|
|
|
match lookup.lookup(body.into_inner()).await {
|
|
|
|
|
Ok(response) => {
|
|
|
|
|
tracing::info!(
|
|
|
|
|
tier = response.tier,
|
|
|
|
|
lessons = response.lessons.len(),
|
|
|
|
|
skills = response.skills.len(),
|
|
|
|
|
"context lookup successful"
|
|
|
|
|
);
|
|
|
|
|
HttpResponse::Ok().json(response)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("context lookup error: {}", e);
|
|
|
|
|
HttpResponse::BadRequest().json(json!({
|
|
|
|
|
"error": "lookup_failed",
|
|
|
|
|
"reason": e.to_string()
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 13:44:28 -07:00
|
|
|
/// POST /memory/vault/generate — generate Obsidian vault from memories
|
|
|
|
|
pub async fn vault_generate_handler(
|
2026-08-24 01:58:39 +00:00
|
|
|
req: HttpRequest,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check write capability
|
|
|
|
|
if !has_capability(&claims, "memory:write") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:write"
|
|
|
|
|
}));
|
2026-08-24 01:58:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string());
|
|
|
|
|
|
|
|
|
|
// Get all projects from database
|
|
|
|
|
let projects_result = sqlx::query_as::<_, (String,)>(
|
|
|
|
|
"SELECT DISTINCT project FROM memories_l1 ORDER BY project",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match projects_result {
|
|
|
|
|
Ok(projects) => {
|
|
|
|
|
let mut generated = 0;
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
|
|
|
|
|
for (project,) in projects {
|
|
|
|
|
let project_vault_dir = format!("{}/vault/{}", vault_dir, project);
|
|
|
|
|
|
|
|
|
|
// Create project directory
|
|
|
|
|
if let Err(e) = std::fs::create_dir_all(&project_vault_dir) {
|
|
|
|
|
errors.push(format!("Failed to create {}: {}", project_vault_dir, e));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get all L1 memories for this project
|
|
|
|
|
let l1s_result = sqlx::query_as::<_, (String, String, String)>(
|
|
|
|
|
"SELECT id, query_id, content FROM memories_l1 WHERE project = $1 ORDER BY updated_at DESC",
|
|
|
|
|
)
|
|
|
|
|
.bind(&project)
|
|
|
|
|
.fetch_all(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match l1s_result {
|
|
|
|
|
Ok(l1s) => {
|
|
|
|
|
for (id, query_id, content) in l1s {
|
|
|
|
|
let filename = format!("{}/{}.md", project_vault_dir, query_id);
|
|
|
|
|
let note = format!(
|
|
|
|
|
"---\nproject: {}\nlevel: L1\nquery_id: {}\nid: {}\nupdated: {}\n---\n\n{}",
|
|
|
|
|
project,
|
|
|
|
|
query_id,
|
|
|
|
|
id,
|
|
|
|
|
chrono::Utc::now().to_rfc3339(),
|
|
|
|
|
content
|
|
|
|
|
);
|
|
|
|
|
if std::fs::write(&filename, note).is_ok() {
|
|
|
|
|
generated += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
errors.push(format!("Failed to fetch L1s for {}: {}", project, e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get L2 synthesis
|
|
|
|
|
let l2_result = sqlx::query_as::<_, (String,)>(
|
|
|
|
|
"SELECT content FROM memories_l2 WHERE project = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(&project)
|
|
|
|
|
.fetch_optional(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
if let Ok(Some((content,))) = l2_result {
|
|
|
|
|
let filename = format!("{}/index.md", project_vault_dir);
|
|
|
|
|
let note = format!(
|
|
|
|
|
"---\nproject: {}\nlevel: L2\ntitle: {} Synthesis\nupdated: {}\n---\n\n{}",
|
|
|
|
|
project,
|
|
|
|
|
project,
|
|
|
|
|
chrono::Utc::now().to_rfc3339(),
|
|
|
|
|
content
|
|
|
|
|
);
|
|
|
|
|
if std::fs::write(&filename, note).is_ok() {
|
|
|
|
|
generated += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"status": "generated",
|
|
|
|
|
"vault_dir": format!("{}/vault", vault_dir),
|
|
|
|
|
"notes_created": generated,
|
|
|
|
|
"errors": errors
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
Err(_) => {
|
|
|
|
|
HttpResponse::InternalServerError().json(json!({"error": "database_error"}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-24 13:44:28 -07:00
|
|
|
|
|
|
|
|
/// GET /memory/vault — list all projects with vault browser UI
|
|
|
|
|
pub async fn vault_browser_handler(
|
|
|
|
|
req: HttpRequest,
|
2026-08-27 21:05:09 -07:00
|
|
|
query: web::Query<std::collections::HashMap<String, String>>,
|
2026-08-24 13:44:28 -07:00
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-27 21:05:09 -07:00
|
|
|
// If project parameter provided, return file tree for that project
|
|
|
|
|
if let Some(project) = query.get("project") {
|
|
|
|
|
return vault_project_tree(project, &state).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise return all projects
|
2026-08-24 13:44:28 -07:00
|
|
|
let result = sqlx::query_as::<_, (String,)>(
|
|
|
|
|
"SELECT DISTINCT project FROM memories_l1 ORDER BY project",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&state.pool)
|
|
|
|
|
.await;
|
|
|
|
|
|
|
|
|
|
match result {
|
|
|
|
|
Ok(rows) => {
|
2026-08-27 21:05:09 -07:00
|
|
|
let projects: Vec<serde_json::Value> = rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|(p,)| json!({"name": p}))
|
|
|
|
|
.collect();
|
|
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"projects": projects
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
tracing::error!("Error fetching projects: {}", e);
|
|
|
|
|
HttpResponse::InternalServerError().json(json!({"error": "database_error"}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Helper: Build file tree for a project
|
|
|
|
|
async fn vault_project_tree(
|
|
|
|
|
project: &str,
|
|
|
|
|
state: &web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
|
|
|
|
let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string());
|
|
|
|
|
let project_path = format!("{}/vault/{}", vault_dir, project);
|
|
|
|
|
|
|
|
|
|
match std::fs::read_dir(&project_path) {
|
|
|
|
|
Ok(entries) => {
|
|
|
|
|
let mut files = Vec::new();
|
|
|
|
|
|
|
|
|
|
for entry in entries {
|
|
|
|
|
if let Ok(e) = entry {
|
|
|
|
|
if let Ok(metadata) = e.metadata() {
|
|
|
|
|
if let Some(name) = e.file_name().to_str() {
|
|
|
|
|
if name.ends_with(".md") && !name.starts_with(".") {
|
|
|
|
|
let path = format!("{}/{}", project, name);
|
|
|
|
|
let title = name.replace(".md", "").replace("-", " ");
|
|
|
|
|
let updated = metadata
|
|
|
|
|
.modified()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|t| {
|
|
|
|
|
let elapsed = t.elapsed().ok()?;
|
|
|
|
|
Some(chrono::Local::now() - chrono::Duration::from_std(elapsed).ok()?)
|
|
|
|
|
})
|
|
|
|
|
.map(|t| t.to_rfc3339())
|
|
|
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
|
|
|
|
|
|
files.push(json!({
|
|
|
|
|
"path": path,
|
|
|
|
|
"name": name,
|
|
|
|
|
"title": title,
|
|
|
|
|
"updated_at": updated
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
files.sort_by(|a, b| {
|
|
|
|
|
let a_name = a["name"].as_str().unwrap_or("");
|
|
|
|
|
let b_name = b["name"].as_str().unwrap_or("");
|
|
|
|
|
a_name.cmp(b_name)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"project": project,
|
|
|
|
|
"files": files
|
|
|
|
|
}))
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
Err(_) => {
|
2026-08-27 21:05:09 -07:00
|
|
|
HttpResponse::NotFound().json(json!({
|
|
|
|
|
"error": "project_not_found",
|
|
|
|
|
"project": project
|
|
|
|
|
}))
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// GET /memory/vault/{project} — list files in project vault
|
|
|
|
|
pub async fn vault_project_handler(
|
|
|
|
|
req: HttpRequest,
|
|
|
|
|
project: web::Path<String>,
|
|
|
|
|
state: web::Data<AppState>,
|
|
|
|
|
) -> HttpResponse {
|
2026-08-27 12:29:23 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let proj = project.into_inner();
|
2026-08-27 21:05:09 -07:00
|
|
|
vault_project_tree(&proj, &state).await
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// GET /memory/vault/{project}/{file} — view markdown file
|
|
|
|
|
pub async fn vault_file_handler(
|
|
|
|
|
req: HttpRequest,
|
|
|
|
|
path: web::Path<(String, String)>,
|
2026-08-27 21:05:09 -07:00
|
|
|
state: web::Data<AppState>,
|
2026-08-24 13:44:28 -07:00
|
|
|
) -> HttpResponse {
|
2026-08-27 21:05:09 -07:00
|
|
|
let (claims, _token) = match validate_auth(&req, &state).await {
|
|
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => return e,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check read capability
|
|
|
|
|
if !has_capability(&claims, "memory:read") {
|
|
|
|
|
return HttpResponse::Forbidden().json(json!({
|
|
|
|
|
"error": "forbidden",
|
|
|
|
|
"reason": "missing capability: memory:read"
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 13:44:28 -07:00
|
|
|
let (project, file) = path.into_inner();
|
|
|
|
|
let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string());
|
|
|
|
|
let file_path = format!("{}/vault/{}/{}.md", vault_dir, project, file);
|
|
|
|
|
|
|
|
|
|
// Security: prevent path traversal
|
|
|
|
|
if file.contains("..") || file.contains("/") {
|
2026-08-27 21:05:09 -07:00
|
|
|
return HttpResponse::BadRequest().json(json!({
|
|
|
|
|
"error": "invalid_filename"
|
|
|
|
|
}));
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match std::fs::read_to_string(&file_path) {
|
|
|
|
|
Ok(content) => {
|
2026-08-27 21:05:09 -07:00
|
|
|
// Parse frontmatter and body
|
2026-08-24 13:44:28 -07:00
|
|
|
let (frontmatter, body) = if content.starts_with("---") {
|
|
|
|
|
let parts: Vec<&str> = content.split("---").collect();
|
|
|
|
|
if parts.len() >= 3 {
|
2026-08-26 13:35:50 -07:00
|
|
|
(parts[1].to_string(), parts[2..].join("---"))
|
2026-08-24 13:44:28 -07:00
|
|
|
} else {
|
2026-08-26 13:35:50 -07:00
|
|
|
("".to_string(), content.clone())
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-08-26 13:35:50 -07:00
|
|
|
("".to_string(), content.clone())
|
2026-08-24 13:44:28 -07:00
|
|
|
};
|
|
|
|
|
|
2026-08-27 21:05:09 -07:00
|
|
|
// Parse YAML frontmatter into JSON
|
|
|
|
|
let metadata = if !frontmatter.is_empty() {
|
|
|
|
|
frontmatter
|
2026-08-24 13:44:28 -07:00
|
|
|
.lines()
|
2026-08-27 21:05:09 -07:00
|
|
|
.filter_map(|line| {
|
|
|
|
|
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
|
|
|
|
if parts.len() == 2 {
|
|
|
|
|
Some((parts[0].trim().to_string(), parts[1].trim().to_string()))
|
2026-08-24 13:44:28 -07:00
|
|
|
} else {
|
2026-08-27 21:05:09 -07:00
|
|
|
None
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
})
|
2026-08-27 21:05:09 -07:00
|
|
|
.collect::<std::collections::HashMap<_, _>>()
|
|
|
|
|
} else {
|
|
|
|
|
std::collections::HashMap::new()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut meta_json = json!({});
|
|
|
|
|
for (k, v) in metadata {
|
|
|
|
|
meta_json[&k] = json!(v);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HttpResponse::Ok().json(json!({
|
|
|
|
|
"project": project,
|
|
|
|
|
"file": format!("{}.md", file),
|
|
|
|
|
"path": format!("{}/{}.md", project, file),
|
|
|
|
|
"title": file.replace("-", " "),
|
|
|
|
|
"metadata": meta_json,
|
|
|
|
|
"content": body.trim()
|
|
|
|
|
}))
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
Err(_) => {
|
2026-08-27 21:05:09 -07:00
|
|
|
HttpResponse::NotFound().json(json!({
|
|
|
|
|
"error": "file_not_found",
|
|
|
|
|
"project": project,
|
|
|
|
|
"file": file
|
|
|
|
|
}))
|
2026-08-24 13:44:28 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-01 09:20:16 -07:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-09-01 09:44:52 -07:00
|
|
|
#[test]
|
|
|
|
|
fn test_to_rbac_claims_with_roles() {
|
|
|
|
|
let jwt = JwtClaims {
|
|
|
|
|
sub: "alice".to_string(),
|
|
|
|
|
iss: "authentik".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: 0,
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: Some(vec!["memory:read".to_string()]),
|
|
|
|
|
groups: Some(vec!["engineering".to_string()]),
|
|
|
|
|
roles: Some(vec!["authenticated-user".to_string(), "homelab-team".to_string()]),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rbac = to_rbac_claims(&jwt);
|
|
|
|
|
|
|
|
|
|
assert_eq!(rbac.sub, "alice");
|
|
|
|
|
assert!(rbac.has_role("authenticated-user"));
|
|
|
|
|
assert!(rbac.has_role("homelab-team"));
|
|
|
|
|
assert!(!rbac.has_role("admin"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 09:20:16 -07:00
|
|
|
#[test]
|
|
|
|
|
fn test_to_rbac_claims_basic() {
|
|
|
|
|
let jwt = JwtClaims {
|
|
|
|
|
sub: "alice".to_string(),
|
|
|
|
|
iss: "test".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: 0,
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: Some(vec!["memory:read".to_string(), "memory:write".to_string()]),
|
|
|
|
|
groups: Some(vec!["engineering".to_string(), "ml-team".to_string()]),
|
2026-09-01 09:44:52 -07:00
|
|
|
roles: Some(vec!["authenticated-user".to_string()]),
|
2026-09-01 09:20:16 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rbac = to_rbac_claims(&jwt);
|
|
|
|
|
|
|
|
|
|
assert_eq!(rbac.sub, "alice");
|
|
|
|
|
assert!(rbac.in_group("engineering"));
|
|
|
|
|
assert!(rbac.in_group("ml-team"));
|
|
|
|
|
assert!(rbac.has_permission("memory:read"));
|
|
|
|
|
assert!(rbac.has_permission("memory:write"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_to_rbac_claims_empty() {
|
|
|
|
|
let jwt = JwtClaims {
|
|
|
|
|
sub: "anonymous".to_string(),
|
|
|
|
|
iss: "test".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: 0,
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: None,
|
|
|
|
|
groups: None,
|
2026-09-01 09:44:52 -07:00
|
|
|
roles: None,
|
2026-09-01 09:20:16 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let rbac = to_rbac_claims(&jwt);
|
|
|
|
|
|
|
|
|
|
assert_eq!(rbac.sub, "anonymous");
|
|
|
|
|
assert!(!rbac.in_group("any"));
|
|
|
|
|
assert!(!rbac.has_permission("any"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_query_result_to_resource_meta_wiki() {
|
|
|
|
|
let result = crate::query_worker::QueryResult {
|
|
|
|
|
level: "corpus".to_string(),
|
|
|
|
|
score: 0.9,
|
|
|
|
|
text: "Some wiki content".to_string(),
|
|
|
|
|
source: Some("docs/kubernetes.md".to_string()),
|
|
|
|
|
provenance: vec![],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let meta = query_result_to_resource_meta(&result, "homelab");
|
|
|
|
|
|
|
|
|
|
assert_eq!(meta.resource_type, ResourceType::Wiki);
|
|
|
|
|
assert_eq!(meta.project, "homelab");
|
|
|
|
|
assert_eq!(meta.visibility, Visibility::Public);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_query_result_to_resource_meta_skill() {
|
|
|
|
|
let result = crate::query_worker::QueryResult {
|
|
|
|
|
level: "L1".to_string(),
|
|
|
|
|
score: 0.8,
|
|
|
|
|
text: "Skill content".to_string(),
|
|
|
|
|
source: Some("shared/skills/SKILL-debug/SKILL.md".to_string()),
|
|
|
|
|
provenance: vec![],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let meta = query_result_to_resource_meta(&result, "homelab");
|
|
|
|
|
|
|
|
|
|
assert_eq!(meta.resource_type, ResourceType::Skill);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_query_result_to_resource_meta_private() {
|
|
|
|
|
let result = crate::query_worker::QueryResult {
|
|
|
|
|
level: "L2".to_string(),
|
|
|
|
|
score: 0.7,
|
|
|
|
|
text: "Private content".to_string(),
|
|
|
|
|
source: Some("docs/private/secrets.md".to_string()),
|
|
|
|
|
provenance: vec![],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let meta = query_result_to_resource_meta(&result, "homelab");
|
|
|
|
|
|
|
|
|
|
assert_eq!(meta.visibility, Visibility::Private);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_query_result_to_resource_meta_embedding() {
|
|
|
|
|
let result = crate::query_worker::QueryResult {
|
|
|
|
|
level: "L1".to_string(),
|
|
|
|
|
score: 0.85,
|
|
|
|
|
text: "Learned fact".to_string(),
|
|
|
|
|
source: Some("memory-123".to_string()),
|
|
|
|
|
provenance: vec![],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let meta = query_result_to_resource_meta(&result, "portfolio");
|
|
|
|
|
|
|
|
|
|
assert_eq!(meta.resource_type, ResourceType::Embedding);
|
|
|
|
|
assert_eq!(meta.project, "portfolio");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_rbac_integration_admin_access() {
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use crate::rbac::{builtin_role_provider, AccessGuard};
|
|
|
|
|
|
|
|
|
|
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
|
|
|
|
|
2026-09-01 09:44:52 -07:00
|
|
|
// Admin JWT with roles from Authentik
|
2026-09-01 09:20:16 -07:00
|
|
|
let jwt = JwtClaims {
|
|
|
|
|
sub: "admin-user".to_string(),
|
|
|
|
|
iss: "test".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: 0,
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: Some(vec!["*".to_string()]),
|
|
|
|
|
groups: None,
|
2026-09-01 09:44:52 -07:00
|
|
|
roles: Some(vec!["admin".to_string()]),
|
2026-09-01 09:20:16 -07:00
|
|
|
};
|
2026-09-01 09:44:52 -07:00
|
|
|
let rbac_claims = to_rbac_claims(&jwt);
|
2026-09-01 09:20:16 -07:00
|
|
|
|
|
|
|
|
// Admin can access any project
|
|
|
|
|
let project = ResourceMeta::new("secret-project", ResourceType::Project, "secret-project");
|
|
|
|
|
assert!(guard.can_read(&rbac_claims, &project).await);
|
|
|
|
|
assert!(guard.can_write(&rbac_claims, &project).await);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_rbac_integration_portfolio_agent() {
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use crate::rbac::{builtin_role_provider, AccessGuard};
|
|
|
|
|
|
|
|
|
|
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
|
|
|
|
|
2026-09-01 09:44:52 -07:00
|
|
|
// Portfolio agent JWT with roles from Authentik
|
2026-09-01 09:20:16 -07:00
|
|
|
let jwt = JwtClaims {
|
|
|
|
|
sub: "visitor-123".to_string(),
|
|
|
|
|
iss: "test".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: 0,
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: Some(vec!["memory:read".to_string()]),
|
|
|
|
|
groups: None,
|
2026-09-01 09:44:52 -07:00
|
|
|
roles: Some(vec!["portfolio-agent".to_string()]),
|
2026-09-01 09:20:16 -07:00
|
|
|
};
|
2026-09-01 09:44:52 -07:00
|
|
|
let rbac_claims = to_rbac_claims(&jwt);
|
2026-09-01 09:20:16 -07:00
|
|
|
|
|
|
|
|
// Can read public wiki in allowed project
|
|
|
|
|
let public_wiki = ResourceMeta::wiki("doc-1", "homelab")
|
|
|
|
|
.with_visibility(Visibility::Public);
|
|
|
|
|
assert!(guard.can_read(&rbac_claims, &public_wiki).await);
|
|
|
|
|
|
|
|
|
|
// Cannot read private wiki
|
|
|
|
|
let private_wiki = ResourceMeta::wiki("secret", "homelab")
|
|
|
|
|
.with_visibility(Visibility::Private);
|
|
|
|
|
assert!(!guard.can_read(&rbac_claims, &private_wiki).await);
|
|
|
|
|
|
|
|
|
|
// Cannot write to any project
|
|
|
|
|
let project = ResourceMeta::new("homelab", ResourceType::Project, "homelab");
|
|
|
|
|
assert!(!guard.can_write(&rbac_claims, &project).await);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_rbac_integration_no_role() {
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use crate::rbac::{builtin_role_provider, AccessGuard};
|
|
|
|
|
|
|
|
|
|
let guard = AccessGuard::new(Arc::new(builtin_role_provider()));
|
|
|
|
|
|
2026-09-01 09:44:52 -07:00
|
|
|
// JWT with no roles (anonymous user)
|
2026-09-01 09:20:16 -07:00
|
|
|
let jwt = JwtClaims {
|
|
|
|
|
sub: "anonymous".to_string(),
|
|
|
|
|
iss: "test".to_string(),
|
|
|
|
|
aud: "memory".to_string(),
|
|
|
|
|
exp: i64::MAX,
|
|
|
|
|
iat: 0,
|
|
|
|
|
nbf: None,
|
|
|
|
|
permissions: None,
|
|
|
|
|
groups: None,
|
2026-09-01 09:44:52 -07:00
|
|
|
roles: None, // No roles assigned
|
2026-09-01 09:20:16 -07:00
|
|
|
};
|
2026-09-01 09:44:52 -07:00
|
|
|
let rbac_claims = to_rbac_claims(&jwt);
|
2026-09-01 09:20:16 -07:00
|
|
|
|
|
|
|
|
// Cannot read anything without a role
|
|
|
|
|
let wiki = ResourceMeta::wiki("doc", "homelab")
|
|
|
|
|
.with_visibility(Visibility::Public);
|
|
|
|
|
assert!(!guard.can_read(&rbac_claims, &wiki).await);
|
|
|
|
|
}
|
|
|
|
|
}
|