mod api;
mod auth;
mod backup;
mod cli;
mod config;
mod db;
mod error;
mod export;
mod mcp;
mod oauth;
mod ratelimit;
use std::sync::Arc;
use axum::{
body::Body,
extract::Request,
http::{HeaderName, HeaderValue, Method, StatusCode, header},
middleware,
response::IntoResponse,
routing::{any, get},
};
use tower_http::cors::{Any, CorsLayer};
use clap::Parser;
use cli::{Cli, Command, KeyAction, UserAction};
use config::Config;
fn is_crud_command(cmd: &Command) -> bool {
matches!(cmd,
Command::Issue { .. } | Command::Project { .. } | Command::Page { .. } |
Command::Export { .. } |
Command::Search { .. } | Command::Comment { .. } | Command::Module { .. } |
Command::Label { .. } | Command::Folder { .. }
)
}
use rmcp::{
ServiceExt,
transport::streamable_http_server::{
session::local::LocalSessionManager,
tower::{StreamableHttpServerConfig, StreamableHttpService},
},
};
use rust_embed::Embed;
use tracing::info;
#[derive(Embed)]
#[folder = "web/dist/"]
#[allow(dead_code)]
struct WebAssets;
async fn serve_frontend(uri: axum::http::Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
if let Some(file) = WebAssets::get(path) {
let mime = mime_guess::from_path(path)
.first_or_octet_stream()
.to_string();
return (
StatusCode::OK,
[(header::CONTENT_TYPE, mime)],
file.data.to_vec(),
)
.into_response();
}
match WebAssets::get("index.html") {
Some(file) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html".to_string())],
file.data.to_vec(),
)
.into_response(),
None => (
StatusCode::NOT_FOUND,
"Frontend not built. Run: cd web && bun run build",
)
.into_response(),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut cfg = Config::load(cli.config.as_deref());
if let Some(ref db) = cli.db {
cfg.database.path = db.clone();
}
if is_crud_command(&cli.command) {
let pool = db::open(&cfg.database.path)?;
return cli::exec::run(&pool, &cli.command, cli.json);
}
match cli.command {
Command::Init => {
let config_path = std::path::Path::new("lific.toml");
if config_path.exists() {
eprintln!("lific.toml already exists in current directory");
std::process::exit(1);
}
std::fs::write(config_path, Config::default_toml())?;
println!("Created lific.toml with default settings");
return Ok(());
}
Command::Key { action } => {
let pool = db::open(&cfg.database.path)?;
let manager =
auth::create_key_manager().map_err(|e| format!("key manager init failed: {e}"))?;
match action {
KeyAction::Create { name, user } => {
let key = auth::create_api_key(&pool, &manager, &name)?;
if let Some(ref username) = user {
let conn = pool.read()?;
let u = db::queries::users::get_user_by_username(&conn, username)?;
drop(conn);
let conn = pool.write()?;
db::queries::users::assign_key_to_user(&conn, &name, u.id)?;
println!();
println!(" API Key created: {name} (assigned to {username})");
} else {
println!();
println!(" API Key created: {name}");
}
println!();
println!(" {key}");
println!();
println!(" Save this key now. It will never be shown again.");
println!(" Use as: Authorization: Bearer {key}");
println!();
}
KeyAction::List => {
let keys = auth::list_api_keys(&pool)?;
if keys.is_empty() {
println!("No API keys configured.");
} else {
println!("{} API key(s):", keys.len());
for k in &keys {
let status = if k.revoked { "REVOKED" } else { "active" };
let expiry = k.expires_at.as_deref().unwrap_or("never");
println!(
" {} | {} | created {} | expires {}",
k.name, status, k.created_at, expiry
);
}
}
}
KeyAction::Revoke { name } => {
auth::revoke_api_key(&pool, &name)?;
println!("Revoked key: {name}");
}
KeyAction::Rotate { name } => {
let key = auth::rotate_api_key(&pool, &manager, &name)?;
println!();
println!(" Key rotated: {name}");
println!();
println!(" {key}");
println!();
println!(" Save this key now. It will never be shown again.");
println!();
}
KeyAction::Assign { name, user } => {
let conn = pool.read()?;
let u = db::queries::users::get_user_by_username(&conn, &user)?;
drop(conn);
let conn = pool.write()?;
db::queries::users::assign_key_to_user(&conn, &name, u.id)?;
println!("Assigned key '{name}' to user '{user}'");
}
}
return Ok(());
}
Command::User { action } => {
let pool = db::open(&cfg.database.path)?;
match action {
UserAction::Create {
username,
email,
password,
admin,
bot,
} => {
let pw = match password {
Some(p) => p,
None => {
eprint!("Password: ");
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
buf.trim().to_string()
}
};
let conn = pool.write()?;
let user = db::queries::users::create_user(
&conn,
&db::models::CreateUser {
username: username.clone(),
email: email.clone(),
password: pw,
display_name: None,
is_admin: admin,
is_bot: bot,
},
)?;
let role = if user.is_admin { " (admin)" } else { "" };
println!("User created: {}{role}", user.username);
println!(" email: {}", user.email);
println!(" display_name: {}", user.display_name);
}
UserAction::List => {
let conn = pool.read()?;
let users = db::queries::users::list_users(&conn)?;
if users.is_empty() {
println!("No users.");
} else {
println!("{} user(s):", users.len());
for u in &users {
let flags = match (u.is_admin, u.is_bot) {
(true, true) => " [admin, bot]",
(true, false) => " [admin]",
(false, true) => " [bot]",
(false, false) => "",
};
println!(
" {} | {} | {}{} | created {}",
u.id, u.username, u.email, flags, u.created_at
);
}
}
}
UserAction::Promote { username } => {
let conn = pool.write()?;
db::queries::users::set_admin(&conn, &username, true)?;
println!("Promoted '{username}' to admin.");
}
UserAction::Demote { username } => {
let conn = pool.write()?;
db::queries::users::set_admin(&conn, &username, false)?;
println!("Demoted '{username}' from admin.");
}
}
return Ok(());
}
Command::Start { port, host } => {
if let Some(p) = port {
cfg.server.port = p;
}
if let Some(h) = host {
cfg.server.host = h;
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| format!("lific={}", cfg.log.level).into()),
)
.init();
let pool = db::open(&cfg.database.path)?;
info!(path = %cfg.database.path.display(), "database ready");
let manager =
auth::create_key_manager().map_err(|e| format!("key manager init failed: {e}"))?;
if !auth::has_any_keys(&pool) {
let key = auth::create_api_key(&pool, &manager, "default")?;
info!("no API keys found, auto-generated initial key");
println!();
println!(" ┌─────────────────────────────────────────────────────┐");
println!(" │ No API keys found. Generated initial key: │");
println!(" │ │");
println!(" │ {key}");
println!(" │ │");
println!(" │ Save this key now. It will never be shown again. │");
println!(" │ Use as: Authorization: Bearer <key> │");
println!(" └─────────────────────────────────────────────────────┘");
println!();
} else {
let count = auth::list_api_keys(&pool)?
.iter()
.filter(|k| !k.revoked)
.count();
info!(active_keys = count, "API key auth enabled");
}
let issuer = cfg
.server
.public_url
.clone()
.unwrap_or_else(|| format!("http://{}:{}", cfg.server.host, cfg.server.port));
let manager_ext = Arc::new(manager.clone());
let auth_state = auth::AuthState {
db: pool.clone(),
manager,
public_url: issuer.clone(),
};
if cfg.backup.enabled {
let pool_arc = Arc::new(pool.clone());
backup::start_backup_task(pool_arc, cfg.database.path.clone(), cfg.backup.clone());
info!(
dir = %cfg.backup_dir().display(),
interval = %format!("{}m", cfg.backup.interval_minutes),
retain = cfg.backup.retain,
"automatic backups enabled"
);
}
let db_for_mcp = pool.clone();
let mut mcp_allowed_hosts: Vec<String> =
vec!["localhost".into(), "127.0.0.1".into(), "::1".into()];
if let Some(ref url) = cfg.server.public_url
&& let Ok(parsed) = url.parse::<axum::http::Uri>()
&& let Some(authority) = parsed.authority() {
let host: String = authority.host().to_string();
mcp_allowed_hosts.push(host);
}
let mcp_config = StreamableHttpServerConfig::default()
.with_stateful_mode(false)
.with_json_response(true)
.with_allowed_hosts(mcp_allowed_hosts);
let mcp_service = StreamableHttpService::new(
move || Ok(mcp::LificMcp::new(db_for_mcp.clone())),
Arc::new(LocalSessionManager::default()),
mcp_config,
);
let login_limiter = Arc::new(ratelimit::RateLimiter::new(
5,
std::time::Duration::from_secs(15 * 60),
));
let authed_routes = api::router(pool.clone(), &cfg.server.cors_origins)
.route(
"/mcp",
any(move |request: Request<Body>| async move {
let auth_user = request
.extensions()
.get::<Option<db::models::AuthUser>>()
.cloned()
.flatten();
mcp::with_request_user(auth_user, || async {
mcp_service.handle(request).await.into_response()
})
.await
}),
)
.layer(axum::Extension(login_limiter))
.layer(axum::Extension(cfg.auth.clone()))
.layer(axum::Extension(manager_ext))
.layer(middleware::from_fn_with_state(
auth_state,
auth_middleware_wrapper,
));
let oauth_register_limiter = Arc::new(ratelimit::RateLimiter::new(
10,
std::time::Duration::from_secs(60 * 60),
));
let oauth_state = oauth::OAuthState {
db: pool.clone(),
issuer,
register_limiter: oauth_register_limiter,
};
let app = authed_routes
.merge(oauth::router(oauth_state))
.fallback(get(serve_frontend))
.layer(build_global_cors(&cfg.server.cors_origins))
.layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024));
let addr = format!("{}:{}", cfg.server.host, cfg.server.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
info!(addr = %addr, "lific server started (REST + MCP + OAuth at /mcp)");
let shutdown_pool = pool.clone();
let server =
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal(shutdown_pool));
server.await?;
}
Command::Mcp => {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| format!("lific={}", cfg.log.level).into()),
)
.with_writer(std::io::stderr)
.init();
let pool = db::open(&cfg.database.path)?;
info!(path = %cfg.database.path.display(), "database ready");
let server = mcp::LificMcp::new(pool);
let transport = rmcp::transport::io::stdio();
info!("lific MCP server started (stdio)");
let handle = server.serve(transport).await?;
handle.waiting().await?;
}
Command::Issue { .. } | Command::Project { .. } | Command::Page { .. } |
Command::Export { .. } |
Command::Search { .. } | Command::Comment { .. } | Command::Module { .. } |
Command::Label { .. } | Command::Folder { .. } => unreachable!(),
}
Ok(())
}
fn build_global_cors(cors_origins: &[String]) -> CorsLayer {
let layer = CorsLayer::new()
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([
header::AUTHORIZATION,
header::CONTENT_TYPE,
header::ACCEPT,
HeaderName::from_static("mcp-protocol-version"),
HeaderName::from_static("mcp-session-id"),
HeaderName::from_static("last-event-id"),
])
.expose_headers([
header::WWW_AUTHENTICATE,
HeaderName::from_static("mcp-session-id"),
])
.max_age(std::time::Duration::from_secs(86400));
if cors_origins.is_empty() {
layer.allow_origin(Any)
} else {
let origins: Vec<HeaderValue> = cors_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect();
layer.allow_origin(origins)
}
}
async fn auth_middleware_wrapper(
state: axum::extract::State<auth::AuthState>,
request: Request<Body>,
next: middleware::Next,
) -> axum::response::Response {
let path = request.uri().path();
if path == "/api/health"
|| path == "/api/auth/signup"
|| path == "/api/auth/login"
|| path.starts_with("/.well-known/")
|| path.starts_with("/oauth/")
|| path == "/register"
|| path == "/authorize"
|| path == "/token"
|| path == "/revoke"
{
return next.run(request).await;
}
auth::require_api_key(state, request, next).await
}
async fn shutdown_signal(pool: db::DbPool) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
info!("shutdown signal received, checkpointing WAL...");
backup::checkpoint_wal(&pool);
info!("shutdown complete");
}
#[cfg(test)]
mod cors_tests {
use super::*;
use axum::Router;
use axum::routing::post;
use http_body_util::BodyExt;
use tower::ServiceExt;
fn app_with_cors(origins: &[String]) -> Router {
let inner = Router::new().route(
"/mcp",
post(|headers: axum::http::HeaderMap| async move {
if headers.get(header::AUTHORIZATION).is_none() {
return (StatusCode::UNAUTHORIZED, "missing auth").into_response();
}
(StatusCode::OK, "ok").into_response()
}),
);
inner.layer(build_global_cors(origins))
}
#[tokio::test]
async fn cors_preflight_to_mcp_bypasses_auth() {
let app = app_with_cors(&[]);
let req = Request::builder()
.method(Method::OPTIONS)
.uri("/mcp")
.header("origin", "https://claude.ai")
.header("access-control-request-method", "POST")
.header("access-control-request-headers", "authorization,content-type")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(
res.status().is_success(),
"preflight should succeed without auth, got {}",
res.status()
);
let headers = res.headers();
assert_eq!(
headers
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("*"),
"empty cors_origins should allow any origin"
);
let allow_methods = headers
.get("access-control-allow-methods")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
allow_methods.contains("POST"),
"POST must be in allowed methods, got: {allow_methods}"
);
let allow_headers = headers
.get("access-control-allow-headers")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
assert!(
allow_headers.contains("authorization"),
"authorization must be allowed, got: {allow_headers}"
);
assert!(
allow_headers.contains("mcp-session-id"),
"mcp-session-id must be allowed, got: {allow_headers}"
);
}
#[tokio::test]
async fn cors_does_not_bypass_auth_on_real_request() {
let app = app_with_cors(&[]);
let req = Request::builder()
.method(Method::POST)
.uri("/mcp")
.header("origin", "https://claude.ai")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn explicit_origins_are_allowlisted() {
let app = app_with_cors(&["https://claude.ai".to_string()]);
let req = Request::builder()
.method(Method::OPTIONS)
.uri("/mcp")
.header("origin", "https://claude.ai")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(res.status().is_success());
assert_eq!(
res.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://claude.ai")
);
}
#[tokio::test]
async fn mcp_session_id_is_exposed() {
let app = app_with_cors(&[]);
let req = Request::builder()
.method(Method::OPTIONS)
.uri("/mcp")
.header("origin", "https://claude.ai")
.header("access-control-request-method", "POST")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
let _ = res.into_body().collect().await;
let app = app_with_cors(&[]);
let req = Request::builder()
.method(Method::POST)
.uri("/mcp")
.header("origin", "https://claude.ai")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
let expose = res
.headers()
.get("access-control-expose-headers")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
assert!(
expose.contains("mcp-session-id"),
"mcp-session-id must be exposed, got: {expose}"
);
assert!(
expose.contains("www-authenticate"),
"www-authenticate must be exposed, got: {expose}"
);
}
}