Menu
Public repository / master
AkurAI-Notes / crates/notes-cli/src/cmd_serve.rs
Latest change 4fac5c7826068a0d615a09851633816b41204468 - serve: fall back to AKURAI_PUBLIC_URL like the deployed build by Ólafur Búi Ólafsson
use akurai_http::{Method, MiddlewareStack, Request, Response, SecurityHeaders, Server};
use notes_api::auth::idp_client::IdpConfig;
use notes_api::{build_router, NotesState, Route};
use notes_mcp::oauth::{HttpIdp, OAuth, OAuthConfig};
use notes_mcp::server::{self, McpConfig, McpServer};
use std::io;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub struct Config {
pub host: String,
pub port: u16,
pub dir: PathBuf,
pub db: String,
}
pub fn run(cfg: Config) -> io::Result<()> {
let idp_cfg = IdpConfig::from_env();
if idp_cfg.client_id.is_empty() {
return Err(io::Error::other("AKURAI_NOTES_IDP_CLIENT_ID is required"));
}
let oauth = mcp_oauth(&idp_cfg)?;
let state = NotesState::new(&cfg.db, cfg.dir.clone(), idp_cfg).map_err(io::Error::other)?;
let mcp_token = std::env::var("AKURAI_NOTES_MCP_TOKEN").unwrap_or_default();
let mcp_owner = std::env::var("AKURAI_NOTES_MCP_OWNER_SUB").unwrap_or_default();
if mcp_token.is_empty() || mcp_owner.is_empty() {
return Err(io::Error::other(
"AKURAI_NOTES_MCP_TOKEN and AKURAI_NOTES_MCP_OWNER_SUB are required",
));
}
let mcp = Arc::new(
McpServer::with_db(
McpConfig {
host: cfg.host.clone(),
port: cfg.port,
db: cfg.db.clone(),
token: mcp_token,
embed_url: state.embed_url.clone(),
embed_model: state.embed_model.clone(),
embed_api_key: state.embed_api_key.clone(),
owner_sub: mcp_owner,
},
Arc::clone(&state.db),
)
.with_oauth(oauth),
);
let state = Arc::new(Mutex::new(state));
let routes = build_router(state);
let addr = format!("{}:{}", cfg.host, cfg.port);
let server = Server::bind(&addr)?;
let local = server.local_addr()?;
println!("AkurAI-Notes");
println!(" database: {}", cfg.db);
println!(" frontend: {}", cfg.dir.display());
println!(" → http://{local}");
let stack = MiddlewareStack::new().push(SecurityHeaders);
server.run_with(stack, move |req: &Request| -> Response {
if server::owns_path(&req.path) {
mcp.handle_http(req)
} else {
dispatch(&routes, req)
}
})
}
/// IdP access tokens for the MCP endpoint, validated via the loopback IdP.
fn mcp_oauth(idp: &IdpConfig) -> io::Result<OAuth> {
let env = |key: &str, default: &str| std::env::var(key).unwrap_or_else(|_| default.into());
let config = OAuthConfig {
public_url: std::env::var("AKURAI_NOTES_PUBLIC_URL")
.or_else(|_| std::env::var("AKURAI_PUBLIC_URL"))
.unwrap_or_else(|_| "https://akurai-notes.olibuijr.com".into()),
issuer: idp.issuer.clone(),
audience: env("AKURAI_NOTES_MCP_AUDIENCE", "akurai-notes"),
allow: idp.allow.clone(),
};
let client = HttpIdp::new(&idp.internal_url).map_err(io::Error::other)?;
Ok(OAuth::new(config, Box::new(client)))
}
fn dispatch(routes: &[Route], req: &Request) -> Response {
let mut best: Option<(Response, (usize, usize, usize))> = None;
for route in routes {
if !method_matches(route.method, &req.method) {
continue;
}
let pattern = akurai_router::Pattern::parse(route.pattern);
if pattern.match_path(&req.path).is_some() {
let score = pattern.specificity();
if best.as_ref().is_none_or(|(_, b)| score > *b) {
let resp = (route.handler)(req);
best = Some((resp, score));
}
}
}
match best {
Some((resp, _)) => resp,
None => Response::not_found(),
}
}
fn method_matches(pattern: &str, method: &Method) -> bool {
match method {
Method::Get => pattern.eq_ignore_ascii_case("GET"),
Method::Post => pattern.eq_ignore_ascii_case("POST"),
Method::Put => pattern.eq_ignore_ascii_case("PUT"),
Method::Patch => pattern.eq_ignore_ascii_case("PATCH"),
Method::Delete => pattern.eq_ignore_ascii_case("DELETE"),
Method::Head => pattern.eq_ignore_ascii_case("HEAD"),
Method::Options => pattern.eq_ignore_ascii_case("OPTIONS"),
Method::Other(s) => pattern.eq_ignore_ascii_case(s),
}
}