systemprompt-api 0.63.1

Axum-based HTTP server and API gateway for systemprompt.io AI governance infrastructure. Exposes governed agents, MCP, A2A, and admin endpoints with rate limiting and RBAC.
Documentation
//! Router assembly for the API server.
//!
//! `configure_routes` composes the route tree for this node's role: protocol
//! surfaces (OAuth, agent, MCP, stream, content, gateway), extension-mounted
//! routes, discovery and well-known endpoints, static content, and the global
//! IP-ban and metrics layers. [`role::route_groups`] decides which groups a
//! role mounts. Each surface is gated with its `AuthzPolicy` at mount time.
//!
//! The static router is merged after the IP-ban layer is applied, so public
//! pages and assets are served without a ban-list lookup; only the API routes
//! are gated. Metrics stay outermost and still measure static responses.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

mod error;
mod extension_mount;
mod gateway;
mod managed;
mod protocol;
pub mod role;
mod static_setup;

use axum::Router;
use std::sync::Arc;
use systemprompt_extension::LoaderError;
use systemprompt_identifiers::ExtensionId;
use systemprompt_manifest::profile::NodeRole;

pub(super) use error::RouteMountError;
use role::RouteGroup;
use systemprompt_runtime::AppContext;
use systemprompt_traits::{AppContext as AppContextTrait, StartupEventSender};

use crate::services::middleware::authz::AuthzPolicy;
use crate::services::middleware::{
    A2AContextMiddleware, JtiRevocationChecker, JwtContextExtractor, McpContextMiddleware,
    PublicContextMiddleware, RateLimitState, RouterExt, UserOnlyContextMiddleware,
    ip_ban_middleware,
};

pub(super) fn configure_routes(
    ctx: &AppContext,
    events: Option<&StartupEventSender>,
) -> Result<Router, RouteMountError> {
    build_routes(ctx, ctx.config().role, events)
}

pub fn configure_routes_for_role(
    ctx: &AppContext,
    role: NodeRole,
    events: Option<&StartupEventSender>,
) -> anyhow::Result<Router> {
    Ok(build_routes(ctx, role, events)?)
}

fn build_routes(
    ctx: &AppContext,
    role: NodeRole,
    events: Option<&StartupEventSender>,
) -> Result<Router, RouteMountError> {
    let groups = role::route_groups(role);
    let serves = |group: RouteGroup| groups.contains(&group);
    let mut router = Router::new();

    super::metrics::install_recorder(&ctx.config().instance_id)
        .map_err(|source| RouteMountError::initialization("prometheus_metrics", source))?;

    let jwt_extractor = build_jwt_extractor(ctx)?;
    let limits = RateLimitState::from_context(ctx);

    let public_middleware = PublicContextMiddleware::new();
    let user_middleware = UserOnlyContextMiddleware::new(jwt_extractor.clone());
    let a2a_middleware = A2AContextMiddleware::new(jwt_extractor.clone());
    let mcp_middleware = McpContextMiddleware::new(jwt_extractor);

    let mount = protocol::MountCtx {
        ctx,
        limits: &limits,
        public_middleware: &public_middleware,
        user_middleware: &user_middleware,
    };
    if serves(RouteGroup::Oauth) {
        router = protocol::mount_oauth(router, &mount)?;
    }
    if serves(RouteGroup::Agent) {
        router = protocol::mount_agent(router, &mount, a2a_middleware)?;
    }
    if serves(RouteGroup::McpAndStream) {
        router = protocol::mount_mcp_and_stream(router, &mount, mcp_middleware)?;
    }
    if serves(RouteGroup::ContentAndMisc) {
        router = protocol::mount_content_and_misc(router, &mount)?;
    }
    if serves(RouteGroup::Managed) {
        router = managed::mount(router, &mount)?;
    }
    if serves(RouteGroup::Gateway) {
        router = gateway::mount_gateway(router, &mount)?;
    }
    if serves(RouteGroup::Messaging) {
        router = protocol::mount_messaging(router, &mount)?;
    }
    if serves(RouteGroup::Extensions) {
        router = extension_mount::mount_extension_routes(router, ctx, &user_middleware, events)?;
    }
    if serves(RouteGroup::Discovery) {
        router =
            router.merge(discovery_router(ctx).with_auth(public_middleware, AuthzPolicy::public()));
    }
    if serves(RouteGroup::AuthenticatedDiscovery) {
        router = router.merge(
            authenticated_discovery_router(ctx)
                .with_auth(user_middleware, AuthzPolicy::authenticated()),
        );
    }
    if serves(RouteGroup::WellKnown) {
        router = router
            .merge(wellknown_router(ctx)?.with_auth(public_middleware, AuthzPolicy::public()));
    }
    if serves(RouteGroup::Oauth) {
        router = router.merge(link_passkey_router(ctx, &limits, public_middleware)?);
    }

    let banned_ip_repo = crate::repository::banned_ips(ctx.db_pool());
    let trusted_proxies = Arc::new(ctx.config().trusted_proxies.clone());

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

    if serves(RouteGroup::Static) {
        router = router.merge(static_setup::build_static_router(
            ctx,
            public_middleware,
            events,
        ));
    }

    Ok(router.layer(axum::middleware::from_fn(super::metrics::track_metrics)))
}

fn link_passkey_router(
    ctx: &AppContext,
    limits: &RateLimitState,
    public_middleware: PublicContextMiddleware,
) -> Result<Router, LoaderError> {
    Ok(Router::new()
        .route(
            "/auth/link-passkey",
            axum::routing::get(crate::routes::oauth::webauthn::link::link_passkey_page),
        )
        .with_rate_limit(
            limits,
            ctx.config().rate_limits.oauth_public_per_second,
            "oauth_public",
        )?
        .with_auth(public_middleware, AuthzPolicy::public()))
}

fn build_jwt_extractor(ctx: &AppContext) -> Result<JwtContextExtractor, LoaderError> {
    let analytics = ctx
        .session_provider()
        .ok_or_else(|| LoaderError::InitializationFailed {
            extension: ExtensionId::new("jwt"),
            message: "SessionProvider is required for JWT session enforcement".to_owned(),
        })?;
    let user_provider = ctx
        .user_provider()
        .ok_or_else(|| LoaderError::InitializationFailed {
            extension: ExtensionId::new("jwt"),
            message: "UserProvider is required for JWT validation".to_owned(),
        })?;
    let jti_revocation =
        JtiRevocationChecker::from_repository(ctx.oauth_repositories().oauth.clone());
    Ok(JwtContextExtractor::new(
        analytics,
        user_provider,
        jti_revocation,
        ctx.config().jwt_issuer.clone(),
    ))
}

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

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

fn wellknown_router(ctx: &AppContext) -> Result<Router, LoaderError> {
    Ok(crate::routes::oauth::wellknown_routes(ctx).merge(crate::routes::wellknown_router(ctx)?))
}