systemprompt-api 0.1.18

HTTP API server and gateway for systemprompt.io OS
Documentation
use axum::Router;
use systemprompt_models::modules::ApiPaths;
use systemprompt_oauth::OAuthState;
use systemprompt_runtime::AppContext;

use crate::services::middleware::{
    ContextMiddleware, JwtContextExtractor, RouterExt, ip_ban_middleware, site_auth_gate,
};
use crate::services::static_content::{
    StaticContentMatcher, StaticContentState, serve_homepage, smart_fallback_handler,
};
use axum::routing::get;
use std::sync::Arc;
use systemprompt_extension::LoaderError;
use systemprompt_models::AppPaths;
use systemprompt_traits::{AppContext as AppContextTrait, StartupEvent, StartupEventSender};
use systemprompt_users::BannedIpRepository;

fn create_oauth_state(ctx: &AppContext) -> Option<OAuthState> {
    let analytics = ctx.analytics_provider()?;
    let users = ctx.user_provider()?;
    let state = OAuthState::new(ctx.db_pool().clone(), analytics, users);
    Some(state)
}

pub fn configure_routes(
    ctx: &AppContext,
    events: Option<&StartupEventSender>,
) -> Result<Router, LoaderError> {
    let mut router = Router::new();

    let rate_config = &ctx.config().rate_limits;

    let jwt_extractor = {
        let extractor = JwtContextExtractor::new(
            systemprompt_models::SecretsBootstrap::jwt_secret().map_err(|e| {
                LoaderError::InitializationFailed {
                    extension: "jwt".to_string(),
                    message: e.to_string(),
                }
            })?,
            ctx.db_pool(),
        );
        match ctx.analytics_provider() {
            Some(analytics) => extractor.with_analytics_provider(analytics),
            None => extractor,
        }
    };

    let public_middleware = ContextMiddleware::public(jwt_extractor.clone());
    let user_middleware = ContextMiddleware::user_only(jwt_extractor.clone());
    let full_middleware = ContextMiddleware::full(jwt_extractor.clone());
    let mcp_middleware = ContextMiddleware::mcp(jwt_extractor.clone());

    if let Some(oauth_state) = create_oauth_state(ctx) {
        router = router.nest(
            ApiPaths::OAUTH_BASE,
            crate::routes::oauth::public_router()
                .with_state(oauth_state.clone())
                .with_rate_limit(rate_config, rate_config.oauth_public_per_second)
                .with_auth_middleware(public_middleware.clone()),
        );

        router = router.nest(
            ApiPaths::OAUTH_BASE,
            crate::routes::oauth::authenticated_router()
                .with_state(oauth_state)
                .with_rate_limit(rate_config, rate_config.oauth_auth_per_second)
                .with_auth_middleware(user_middleware.clone()),
        );
    }

    router = router.nest(
        ApiPaths::CORE_CONTEXTS,
        crate::routes::agent::contexts_router()
            .with_state(ctx.clone())
            .with_rate_limit(rate_config, rate_config.contexts_per_second)
            .with_auth_middleware(user_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::WEBHOOK,
        crate::routes::agent::webhook_router()
            .with_state(ctx.clone())
            .with_auth_middleware(user_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::CORE_TASKS,
        crate::routes::agent::tasks_router()
            .with_state(ctx.clone())
            .with_rate_limit(rate_config, rate_config.tasks_per_second)
            .with_auth_middleware(user_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::CORE_ARTIFACTS,
        crate::routes::agent::artifacts_router()
            .with_state(ctx.clone())
            .with_rate_limit(rate_config, rate_config.artifacts_per_second)
            .with_auth_middleware(user_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::AGENTS_REGISTRY,
        crate::routes::agent::registry_router(ctx)
            .with_rate_limit(rate_config, rate_config.agent_registry_per_second)
            .with_auth_middleware(public_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::AGENTS_BASE,
        crate::routes::proxy::agents::router(ctx)
            .with_rate_limit(rate_config, rate_config.agents_per_second)
            .with_auth_middleware(full_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::MCP_REGISTRY,
        crate::routes::mcp::registry_router()
            .with_rate_limit(rate_config, rate_config.mcp_registry_per_second)
            .with_auth_middleware(public_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::MCP_BASE,
        crate::routes::proxy::mcp::router(ctx)
            .with_rate_limit(rate_config, rate_config.mcp_per_second)
            .with_auth_middleware(mcp_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::STREAM_BASE,
        crate::routes::stream::stream_router(ctx)
            .with_rate_limit(rate_config, rate_config.stream_per_second)
            .with_auth_middleware(user_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::CONTENT_BASE,
        crate::routes::content::router(ctx)
            .with_rate_limit(rate_config, rate_config.content_per_second)
            .with_auth_middleware(public_middleware.clone()),
    );

    router = router.merge(
        crate::routes::content::redirect_router(ctx.db_pool())
            .with_rate_limit(rate_config, rate_config.content_per_second)
            .with_auth_middleware(public_middleware.clone()),
    );

    router = router.nest(
        "/api/v1/sync",
        crate::routes::sync::router().with_state(ctx.clone()),
    );

    router = router.nest(
        ApiPaths::MARKETPLACE_BASE,
        crate::routes::marketplace::router()
            .with_state(ctx.clone())
            .with_auth_middleware(public_middleware.clone()),
    );

    router = router.nest(
        "/api/v1/analytics",
        crate::routes::analytics::router(ctx)
            .map_err(|e| LoaderError::InitializationFailed {
                extension: "analytics".to_string(),
                message: e.to_string(),
            })?
            .with_rate_limit(rate_config, rate_config.content_per_second)
            .with_auth_middleware(user_middleware.clone()),
    );

    router = router.nest(
        ApiPaths::TRACK_ENGAGEMENT,
        crate::routes::engagement::router(ctx)
            .map_err(|e| LoaderError::InitializationFailed {
                extension: "engagement".to_string(),
                message: e.to_string(),
            })?
            .with_rate_limit(rate_config, rate_config.content_per_second)
            .with_auth_middleware(public_middleware.clone()),
    );

    router = router.nest(
        "/api/v1/admin",
        crate::routes::admin::router()
            .with_state(ctx.clone())
            .with_rate_limit(rate_config, 10)
            .with_auth_middleware(user_middleware.clone()),
    );

    router = mount_extension_routes(router, ctx, &user_middleware, events)?;

    let paths = match AppPaths::get() {
        Ok(p) => p,
        Err(e) => {
            if let Some(tx) = events {
                if tx
                    .unbounded_send(StartupEvent::Warning {
                        message: format!("Failed to load paths: {e}"),
                        context: Some("Static content matching will be disabled".to_string()),
                    })
                    .is_err()
                {
                    tracing::debug!("Startup event receiver dropped");
                }
            }
            return Ok(router);
        },
    };
    let path = paths.system().content_config().to_path_buf();
    let content_matcher = if let Some(path_str) = path.to_str() {
        match StaticContentMatcher::from_config(path_str) {
            Ok(matcher) => Arc::new(matcher),
            Err(e) => {
                if let Some(tx) = events {
                    if tx
                        .unbounded_send(StartupEvent::Warning {
                            message: format!("Failed to load content config: {e}"),
                            context: Some("Static content matching will be disabled".to_string()),
                        })
                        .is_err()
                    {
                        tracing::debug!("Startup event receiver dropped");
                    }
                }
                Arc::new(StaticContentMatcher::empty())
            },
        }
    } else {
        if let Some(tx) = events {
            if tx
                .unbounded_send(StartupEvent::Warning {
                    message: "CONTENT_CONFIG_PATH contains invalid UTF-8".to_string(),
                    context: None,
                })
                .is_err()
            {
                tracing::debug!("Startup event receiver dropped");
            }
        }
        Arc::new(StaticContentMatcher::empty())
    };

    let static_state = StaticContentState {
        ctx: Arc::new(ctx.clone()),
        matcher: content_matcher,
        route_classifier: ctx.route_classifier().clone(),
    };

    router = router.merge(discovery_router(ctx).with_auth_middleware(public_middleware.clone()));

    router = router.merge(wellknown_router(ctx).with_auth_middleware(public_middleware.clone()));

    router = router.route(
        "/auth/link-passkey",
        get(crate::routes::oauth::webauthn::link::link_passkey_page),
    );

    let static_router = Router::new()
        .route("/", get(serve_homepage))
        .fallback(smart_fallback_handler)
        .with_state(static_state)
        .with_auth_middleware(public_middleware.clone());

    let site_auth_config = ctx
        .extension_registry()
        .extensions()
        .iter()
        .find_map(|ext| ext.site_auth());

    let static_router = if let Some(auth_config) = site_auth_config {
        let secret = systemprompt_models::SecretsBootstrap::jwt_secret()
            .unwrap_or_else(|e| {
                tracing::warn!(error = %e, "JWT secret not available for site auth gate");
                ""
            })
            .to_string();
        static_router.layer(axum::middleware::from_fn(move |req, next| {
            let config = auth_config;
            let secret = secret.clone();
            async move { site_auth_gate(req, next, config, secret).await }
        }))
    } else {
        static_router
    };

    router = router.merge(static_router);

    let banned_ip_repo = Arc::new(BannedIpRepository::new(ctx.db_pool()).map_err(|e| {
        LoaderError::InitializationFailed {
            extension: "ip_ban_middleware".to_string(),
            message: e.to_string(),
        }
    })?);

    Ok(router.layer(axum::middleware::from_fn(move |req, next| {
        let repo = banned_ip_repo.clone();
        async move { ip_ban_middleware(req, next, repo).await }
    })))
}

fn discovery_router(ctx: &AppContext) -> Router {
    super::builder::discovery_router(ctx)
}

fn wellknown_router(ctx: &AppContext) -> Router {
    crate::routes::oauth::wellknown_routes().merge(crate::routes::wellknown_router(ctx))
}

fn mount_extension_routes(
    mut router: Router,
    ctx: &AppContext,
    user_middleware: &ContextMiddleware<JwtContextExtractor>,
    events: Option<&StartupEventSender>,
) -> Result<Router, LoaderError> {
    let api_extensions = ctx.extension_registry().api_extensions(ctx);

    if api_extensions.is_empty() {
        return Ok(router);
    }

    let profile = systemprompt_models::ProfileBootstrap::get().map_err(|e| {
        LoaderError::InitializationFailed {
            extension: "profile".to_string(),
            message: e.to_string(),
        }
    })?;

    let config_json = serde_json::json!({
        "paths": profile.paths,
    });

    for ext in api_extensions {
        let ext_id = ext.metadata().id;
        let ext_name = ext.metadata().name;

        ext.validate_config(&config_json)
            .map_err(|e| LoaderError::ConfigValidationFailed {
                extension: ext_id.to_string(),
                message: e.to_string(),
            })?;

        let Some(ext_router_config) = ext.router(ctx) else {
            continue;
        };

        let base_path = ext_router_config.base_path;
        let requires_auth = ext_router_config.requires_auth;

        let ext_router = if requires_auth {
            ext_router_config
                .router
                .with_auth_middleware(user_middleware.clone())
        } else {
            ext_router_config.router
        };

        if let Some(tx) = events {
            if tx
                .unbounded_send(StartupEvent::ExtensionRouteMounted {
                    name: ext_name.to_string(),
                    path: base_path.to_string(),
                    auth_required: requires_auth,
                })
                .is_err()
            {
                tracing::debug!("Startup event receiver dropped");
            }
        }

        if base_path == "/" {
            router = router.merge(ext_router);
        } else {
            router = router.nest(base_path, ext_router);
        }
    }

    Ok(router)
}