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
use axum::extract::FromRef;
use slave_framework::database::{Db, Executor};

use crate::base::auth::AuthUser;
use crate::base::error::{AppError, AppResult};
use crate::base::state::AppState;
use crate::modules::app::App;
use crate::modules::team::{Access, Team, require_access};

/// Business rules for apps and their owner teams.
#[derive(Debug, Clone)]
pub struct AppService {
    db: Db,
}

impl FromRef<AppState> for AppService {
    fn from_ref(state: &AppState) -> Self {
        Self {
            db: state.db.clone(),
        }
    }
}

/// An app with its owner team, if any.
pub struct OwnedApp {
    pub app: App,
    pub team: Option<TeamSummary>,
}

/// The owner team of an app.
pub struct TeamSummary {
    pub id: i64,
    pub name: String,
    pub slug: String,
}

impl From<&Team> for TeamSummary {
    fn from(team: &Team) -> Self {
        Self {
            id: team.id,
            name: team.name.clone(),
            slug: team.slug.clone(),
        }
    }
}

impl AppService {
    /// Every app for super admins; otherwise the apps owned by the caller's
    /// teams.
    pub async fn list(&self, user: &AuthUser) -> AppResult<Vec<OwnedApp>> {
        let apps: Vec<App> = self
            .db
            .query(format!("SELECT {} FROM app ORDER BY name", App::FIELDS))
            .await?
            .take(0)?;
        let team_ids: Vec<i64> = if user.is_super_admin() {
            apps.iter().filter_map(|app| app.owner_team_id).collect()
        } else {
            self.db
                .query("SELECT VALUE team_id FROM membership WHERE user_id = $user_id")
                .bind(("user_id", user.id))
                .await?
                .take(0)?
        };
        let teams: Vec<Team> = self
            .db
            .query(format!(
                "SELECT {} FROM team WHERE record::id(id) IN $ids",
                Team::FIELDS
            ))
            .bind(("ids", team_ids.clone()))
            .await?
            .take(0)?;
        Ok(apps
            .into_iter()
            .filter(|app| {
                user.is_super_admin() || app.owner_team_id.is_some_and(|id| team_ids.contains(&id))
            })
            .map(|app| {
                let team = app
                    .owner_team_id
                    .and_then(|id| teams.iter().find(|t| t.id == id))
                    .map(TeamSummary::from);
                OwnedApp { app, team }
            })
            .collect())
    }

    /// Gives app `name` to team `team_id`, or takes it back with `None`
    /// (super admins only).
    pub async fn assign(&self, user: &AuthUser, name: &str, team_id: Option<i64>) -> AppResult<()> {
        user.require_super_admin()?;
        let app = find_app(&self.db, name).await?;
        if let Some(team_id) = team_id {
            find_team(&self.db, team_id)
                .await?
                .ok_or_else(|| AppError::NotFound(format!("team {team_id}")))?;
        }
        self.db
            .query("UPDATE type::record('app', $id) SET owner_team_id = $team_id")
            .bind(("id", app.id))
            .bind(("team_id", team_id))
            .await?
            .check()?;
        Ok(())
    }

    /// The caller's access to app `name`: their role and modules in its owner
    /// team (super admins always pass).
    pub async fn access(&self, user: &AuthUser, name: &str) -> AppResult<(OwnedApp, Access)> {
        let (app, access) = require_app_access(&self.db, user, name, None).await?;
        let team = match app.owner_team_id {
            Some(id) => find_team(&self.db, id)
                .await?
                .as_ref()
                .map(TeamSummary::from),
            None => None,
        };
        Ok((OwnedApp { app, team }, access))
    }
}

/// App `name` and the caller's access to it, failing with `Forbidden` unless
/// they belong to its owner team (or are a super admin) and, if `module` is
/// given, may use that module there.
pub async fn require_app_access(
    db: &impl Executor,
    user: &AuthUser,
    name: &str,
    module: Option<&str>,
) -> AppResult<(App, Access)> {
    let app = find_app(db, name).await?;
    let access = match app.owner_team_id {
        Some(team_id) => require_access(db, user, team_id, module).await?,
        None if user.is_super_admin() => Access {
            role: crate::base::auth::SUPER_ADMIN_ROLE.to_owned(),
            modules: crate::modules::team::MODULES
                .iter()
                .map(|m| (*m).to_owned())
                .collect(),
        },
        None => return Err(AppError::Forbidden),
    };
    Ok((app, access))
}

/// Registers the apps the runtime serves (their folder names, with the slug
/// of their owner team from config/apps.toml). An app without an owner gets
/// that team once it exists; an owner set before (or by `app_assign`) stays.
/// Apps no longer served are left as they are.
pub async fn sync(db: &Db, apps: &[(&str, Option<&str>)]) -> AppResult<()> {
    for (name, team) in apps {
        if find_app(db, name).await.is_ok() {
            if team.is_some() {
                db.query(
                    "UPDATE app SET owner_team_id =
                         (SELECT VALUE record::id(id) FROM ONLY team WHERE slug = $team LIMIT 1)
                     WHERE name = $name AND owner_team_id = NONE",
                )
                .bind(("name", (*name).to_owned()))
                .bind(("team", team.map(str::to_owned)))
                .await?
                .check()?;
            }
            continue;
        }
        let created = db
            .query(
                "CREATE type::record('app', sequence::nextval('app_id')) SET name = $name,
                     owner_team_id = (SELECT VALUE record::id(id) FROM ONLY team WHERE slug = $team LIMIT 1)",
            )
            .bind(("name", (*name).to_owned()))
            .bind(("team", team.map(str::to_owned)))
            .await?
            .check();
        match created {
            // Another instance starting at the same time registered it.
            Err(err) if slave_framework::database::is_already_exists(&err) => {}
            result => {
                result?;
            }
        }
    }
    Ok(())
}

async fn find_app(db: &impl Executor, name: &str) -> AppResult<App> {
    let app: Option<App> = db
        .query(format!(
            "SELECT {} FROM app WHERE name = $name",
            App::FIELDS
        ))
        .bind(("name", name.to_owned()))
        .await?
        .take(0)?;
    app.ok_or_else(|| AppError::NotFound(format!("app `{name}`")))
}

async fn find_team(db: &impl Executor, id: i64) -> AppResult<Option<Team>> {
    let team: Option<Team> = db
        .query(format!(
            "SELECT {} FROM type::record('team', $id)",
            Team::FIELDS
        ))
        .bind(("id", id))
        .await?
        .take(0)?;
    Ok(team)
}