AkurAI Build
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),
    }
}