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
//! Pages of the `{{ app }}` app. Handlers gather data and render a view from
//! `templates/` (see `views.rs`); the HTML lives in the templates.
//!
//! Signing in (`/login`, `/logout`, passkeys) lives in `sign_in.rs`.

use axum::Router;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;

use crate::sign_in::token;
use crate::views::Views;
use crate::{AppState, TITLE};

/// The app's pages, under its base path (see [`AppState::url`]).
pub fn router(state: AppState) -> Router {
    let mut router = Router::new()
        .route(&state.url("/"), get(home))
        .route("/health", get(health));
    if !state.base.is_empty() {
        // `/admin/` as well as `/admin`.
        router = router.route(&format!("{}/", state.base), get(home));
    }
    router.with_state(state)
}

/// Result of the backend's `auth_me` tool.
#[derive(Deserialize)]
struct Me {
    name: String,
    email: Option<String>,
    roles: Vec<String>,
}

/// What every view receives: the app's name and the page title, plus the
/// page's own data (flattened).
#[derive(Serialize)]
struct Page<'a, T: Serialize> {
    app: &'static str,
    title: &'a str,
    #[serde(flatten)]
    data: T,
}

#[derive(Serialize)]
struct Param {
    name: String,
    value: String,
}

#[derive(Serialize)]
struct UserView {
    name: String,
    email: Option<String>,
    roles: String,
}

/// Data of the `home` view.
#[derive(Serialize)]
struct Home {
    /// Domain parameters from the runtime, e.g. `{toko}` in `{toko}.localhost`.
    params: Vec<Param>,
    user: Option<UserView>,
    error: Option<String>,
    login_url: String,
    logout_url: String,
    /// For the "Add a passkey" button (see `sign_in.rs`).
    passkey_url: String,
    script_url: String,
}

/// Renders view `name`. Debug builds reload the templates on every request,
/// so edits show without restarting, and show template errors in the page.
pub(crate) fn render<T: Serialize>(
    app: &AppState,
    status: StatusCode,
    name: &str,
    title: &str,
    data: T,
) -> Response {
    let page = Page {
        app: TITLE,
        title,
        data,
    };
    let rendered = if cfg!(debug_assertions) {
        Views::load(&app.templates_dir).and_then(|views| views.render(name, &page))
    } else {
        app.views.render(name, &page)
    };
    match rendered {
        Ok(html) => (status, Html(html)).into_response(),
        Err(err) => {
            tracing::error!("view `{name}`: {err}");
            let detail = if cfg!(debug_assertions) {
                err
            } else {
                String::new()
            };
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("template error {detail}"),
            )
                .into_response()
        }
    }
}

async fn home(State(app): State<AppState>, headers: HeaderMap) -> Response {
    let params = route_params(&headers)
        .into_iter()
        .map(|(name, value)| Param { name, value })
        .collect();
    let me = match token(&headers) {
        Some(token) => Some(app.mcp.call::<Me>("auth_me", json!({}), Some(&token)).await),
        None => None,
    };
    let (user, error) = match me {
        Some(Ok(me)) => (
            Some(UserView {
                name: me.name,
                email: me.email,
                roles: me.roles.join(", "),
            }),
            None,
        ),
        Some(Err(err)) if err.code != "unauthenticated" => (None, Some(err.message)),
        _ => (None, None),
    };
    let data = Home {
        params,
        user,
        error,
        login_url: app.url("/login"),
        logout_url: app.url("/logout"),
        passkey_url: app.url("/passkey"),
        script_url: app.url("/sign-in.js"),
    };
    render(&app, StatusCode::OK, "home", "Home", data)
}

/// Liveness probe for orchestrators.
async fn health() -> &'static str {
    "ok"
}

/// `(name, value)` for every `X-Slave-Param-<Name>` header set by the runtime.
fn route_params(headers: &HeaderMap) -> Vec<(String, String)> {
    let mut params: Vec<(String, String)> = headers
        .iter()
        .filter_map(|(name, value)| {
            let name = name.as_str().strip_prefix("x-slave-param-")?;
            Some((name.to_owned(), value.to_str().ok()?.to_owned()))
        })
        .collect();
    params.sort();
    params
}