slave 0.13.1

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB 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 with a code sent to their
//! email or a passkey (see [`sign_in`]).

mod mcp;
mod pages;
mod sign_in;
mod views;

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

use slave_framework::env;

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

/// A customer-facing app (a storefront, `slave make:app --customer`): its
/// sessions reach only the tools meant for customers, and signing in makes
/// the user a customer of the store (see [`sign_in`]).
pub const CUSTOMER: bool = {{ customer }};

/// 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,
    /// `{{ env }}_PUBLIC_URL`, e.g. `https://shop.example.com`; otherwise
    /// derived from the request (and the runtime's headers).
    pub public_url: Option<Arc<str>>,
    /// 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>,
}

impl AppState {
    /// The app's public origin, e.g. `https://shop.example.com`:
    /// `{{ env }}_PUBLIC_URL`, or derived from the request (and the runtime's
    /// `X-Forwarded-*` headers).
    pub fn origin(&self, headers: &axum::http::HeaderMap) -> String {
        self.public_url
            .as_deref()
            .map(str::to_owned)
            .unwrap_or_else(|| {
                let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
                let scheme = header("x-forwarded-proto").unwrap_or("http");
                let host = header("x-forwarded-host")
                    .or_else(|| header("host"))
                    .unwrap_or("127.0.0.1");
                format!("{scheme}://{host}")
            })
    }

    /// Attributes of the sign-in cookies: never readable by scripts, and only
    /// sent over TLS when the app is served over https.
    pub fn cookie_flags(&self, headers: &axum::http::HeaderMap) -> &'static str {
        if self.origin(headers).starts_with("https://") {
            "HttpOnly; SameSite=Lax; Secure"
        } else {
            "HttpOnly; SameSite=Lax"
        }
    }

    /// `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,
        public_url: std::env::var("{{ env }}_PUBLIC_URL")
            .ok()
            .filter(|url| !url.trim().is_empty())
            .map(Into::into),
    };
    Ok(pages::router(state.clone())
        .merge(sign_in::routes(state))
        .layer(axum::middleware::from_fn(same_origin_posts)))
}

/// Refuses form posts from other origins (CSRF). `SameSite=Lax` alone is not
/// enough: browsers treat sibling subdomains (`a.example.com` and
/// `b.example.com`) as the same site and send the cookie along.
async fn same_origin_posts(
    request: axum::extract::Request,
    next: axum::middleware::Next,
) -> axum::response::Response {
    use axum::response::IntoResponse;
    let method = request.method();
    if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
        let header = |name: &str| request.headers().get(name).and_then(|v| v.to_str().ok());
        let cross_origin = match (header("sec-fetch-site"), header("origin")) {
            (Some(site), _) => site != "same-origin" && site != "none",
            (None, Some(origin)) => {
                let host = header("x-forwarded-host").or_else(|| header("host"));
                origin.split_once("://").map(|(_, host)| host) != host
            }
            (None, None) => false,
        };
        if cross_origin {
            return axum::http::StatusCode::FORBIDDEN.into_response();
        }
    }
    next.run(request).await
}

/// `{{ 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")
    })
}