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