slave 0.9.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles and Toasty models and migrations, add server-rendered web apps, and route them by domain or path
//! The `{{ app }}` app: pages rendered on the server from the templates in
//! `templates/` ({{ engine_name }}, see [`views`]), served with Axum.
//!
//! The project's runtime (`runtime/`) mounts [`embedded`] next to the MCP
//! backend in one process, routed by `config/apps.toml`; with `embed = false`
//! `src/main.rs` runs it as a server of its own ([`serve`]), proxied by the
//! runtime.
//! Business data comes from the MCP backend through [`mcp::McpClient`]; the
//! app never touches the database. Users sign in at the OpenID Connect
//! provider (see `pages.rs`).

mod mcp;
mod pages;
mod views;

use std::path::PathBuf;
use std::sync::Arc;

use slave_framework::env;
use slave_framework::oidc::OidcClient;
use tokio::sync::OnceCell;

/// The app's name, shown by the templates as `app`.
pub const TITLE: &str = "{{ title }}";

/// Port used when `PORT` is not set; `slave serve` sets it from `config/apps.toml`.
const DEFAULT_PORT: u16 = {{ port }};

/// State shared by every page.
#[derive(Clone)]
pub struct AppState {
    pub mcp: mcp::McpClient,
    /// Path prefix the app is served under, e.g. `/admin`, or `""` at the root.
    pub base: String,
    pub sign_in: Arc<SignInSettings>,
    /// The templates, loaded at start-up (and reloaded per request in debug
    /// builds, so template edits show without restarting).
    pub views: Arc<views::Views>,
    pub templates_dir: Arc<PathBuf>,
    /// The OpenID Connect client, discovered on first sign-in.
    oidc: Arc<OnceCell<OidcClient>>,
}

/// How this app signs users in at the OpenID Connect provider.
pub struct SignInSettings {
    /// `OIDC_ISSUER_URL`, shared with the backend.
    pub issuer: Option<String>,
    /// `{{ env }}_OIDC_CLIENT_ID`: this app's client ID at the provider (also
    /// listed in the backend's `OIDC_AUDIENCES`).
    pub client_id: Option<String>,
    /// `{{ env }}_OIDC_CLIENT_SECRET`, if the provider gave this app one.
    pub client_secret: Option<String>,
    /// `{{ env }}_PUBLIC_URL`, e.g. `https://shop.example.com`; otherwise the
    /// callback URL is derived from the request (and the runtime's headers).
    pub public_url: Option<String>,
}

impl SignInSettings {
    fn from_env() -> Self {
        let var = |key: &str| {
            std::env::var(key)
                .ok()
                .filter(|value| !value.trim().is_empty())
        };
        Self {
            issuer: var("OIDC_ISSUER_URL"),
            client_id: var("{{ env }}_OIDC_CLIENT_ID"),
            client_secret: var("{{ env }}_OIDC_CLIENT_SECRET"),
            public_url: var("{{ env }}_PUBLIC_URL"),
        }
    }
}

impl AppState {
    /// The OpenID Connect client, or why sign-in is unavailable.
    pub async fn oidc(&self) -> Result<&OidcClient, String> {
        let settings = &self.sign_in;
        let (Some(issuer), Some(client_id)) = (&settings.issuer, &settings.client_id) else {
            return Err(
                "Sign-in is not configured: set OIDC_ISSUER_URL and {{ env }}_OIDC_CLIENT_ID in .env."
                    .to_owned(),
            );
        };
        self.oidc
            .get_or_try_init(|| {
                OidcClient::discover(issuer, client_id, settings.client_secret.as_deref())
            })
            .await
            .map_err(|err| {
                tracing::warn!("{err}");
                "The sign-in provider is not reachable right now.".to_owned()
            })
    }

    /// `path` (starting with `/`) under the app's base path.
    pub fn url(&self, path: &str) -> String {
        match (self.base.as_str(), path) {
            ("", path) => path.to_owned(),
            (base, "/") => base.to_owned(),
            (base, path) => format!("{base}{path}"),
        }
    }
}

/// The app's pages for the single runtime, under `base` (e.g. `/admin`, or
/// `""` at the root). Tool calls go straight to `backend`, the MCP backend's
/// router in the same process: the same requests as over HTTP, no socket.
pub fn embedded(backend: axum::Router, base: &str) -> slave_framework::Result<axum::Router> {
    let mcp = mcp::McpClient::local(backend);
    router(mcp, normalize_base(base))
}

/// Runs the app as a server of its own (`src/main.rs`, with `embed = false`),
/// reaching the MCP backend over HTTP.
pub async fn serve() -> slave_framework::Result<()> {
    env::load_dotenv()?;
    slave_framework::logging::init()?;

    let host = env::var_or("HOST", "127.0.0.1");
    let port = env::parse_or("PORT", DEFAULT_PORT)?;
    let mcp = mcp::McpClient::new(mcp_url());
    tracing::info!("{{ app }} uses the MCP backend at {}", mcp.url());
    let router = router(mcp, base_path())?;
    slave_framework::server::serve(router, &host, port).await
}

fn router(mcp: mcp::McpClient, base: String) -> slave_framework::Result<axum::Router> {
    let templates_dir = templates_dir();
    let views = views::Views::load(&templates_dir).map_err(|err| {
        slave_framework::Error::Server(std::io::Error::other(format!(
            "templates in {}: {err}",
            templates_dir.display()
        )))
    })?;
    let state = AppState {
        mcp,
        views: Arc::new(views),
        templates_dir: Arc::new(templates_dir),
        base,
        sign_in: Arc::new(SignInSettings::from_env()),
        oidc: Arc::new(OnceCell::new()),
    };
    Ok(pages::router(state))
}

/// `{{ env }}_TEMPLATES_DIR`, or this app's `templates/` directory.
fn templates_dir() -> PathBuf {
    std::env::var("{{ env }}_TEMPLATES_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")))
}

/// `SLAVE_BASE_PATH` (set when the runtime routes a path prefix such as
/// `/admin` to this app), normalized to `/admin`, or `""` for the root.
fn base_path() -> String {
    normalize_base(&env::var_or("SLAVE_BASE_PATH", ""))
}

/// `admin`, `/admin/` → `/admin`; `""` or `/` → `""`.
fn normalize_base(base: &str) -> String {
    let base = base.trim_matches('/');
    if base.is_empty() {
        String::new()
    } else {
        format!("/{base}")
    }
}

/// `SLAVE_MCP_URL`, or the backend's `APP_HOST:APP_PORT` from `.env`.
fn mcp_url() -> String {
    std::env::var("SLAVE_MCP_URL").unwrap_or_else(|_| {
        let host = match env::var_or("APP_HOST", "127.0.0.1").as_str() {
            "0.0.0.0" | "" => "127.0.0.1".to_owned(),
            host => host.to_owned(),
        };
        let port = env::var_or("APP_PORT", "3000");
        format!("http://{host}:{port}/mcp")
    })
}