slave 0.4.0

Laravel-inspired CLI for Rust MCP servers: scaffold apps whose business operations are MCP tools over Streamable HTTP, with built-in auth, users, roles and Turso migrations
use axum::extract::FromRef;
use slave_framework::auth;
use slave_framework::database::Database;
use slave_framework::turso::Connection;
use slave_framework::turso::transaction::TransactionBehavior;

use crate::base::auth::{ADMIN_ROLE, USER_ROLE};
use crate::base::error::{AppError, AppResult};
use crate::base::state::AppState;
use crate::modules::auth::dto::auth::{LoginRequest, RegisterRequest};
use crate::modules::auth::repositories::token_repository;
use crate::modules::role::role_repository;
use crate::modules::user::{User, user_repository};

const MIN_PASSWORD_LEN: usize = 8;
const MAX_PASSWORD_LEN: usize = 256;

/// A freshly issued bearer token and the user it belongs to.
#[derive(Debug)]
pub struct Session {
    pub user: User,
    pub roles: Vec<String>,
    pub token: String,
    pub expires_at: String,
}

/// Registration, login and logout.
#[derive(Debug, Clone)]
pub struct AuthService {
    database: Database,
    token_ttl_days: u32,
}

impl FromRef<AppState> for AuthService {
    fn from_ref(state: &AppState) -> Self {
        Self {
            database: state.database.clone(),
            token_ttl_days: state.config.token_ttl_days,
        }
    }
}

impl AuthService {
    /// Creates an account and signs it in. The first account becomes admin.
    pub async fn register(&self, input: RegisterRequest) -> AppResult<Session> {
        let name = input.name.trim().to_owned();
        if name.is_empty() || name.chars().count() > 100 {
            return Err(AppError::Validation(
                "name must be 1 to 100 characters".into(),
            ));
        }
        let email = normalize_email(&input.email)?;
        validate_password(&input.password)?;
        let password_hash = hash_password(input.password).await?;

        let mut conn = self.database.connect().await?;
        // IMMEDIATE takes the write lock up front, so two concurrent first
        // registrations cannot both see an empty table and both become admin.
        let tx = conn
            .transaction_with_behavior(TransactionBehavior::Immediate)
            .await?;
        if user_repository::find_by_email(&tx, &email).await?.is_some() {
            return Err(AppError::Conflict("email is already registered".into()));
        }
        let is_first = user_repository::count(&tx).await? == 0;
        let user = user_repository::insert(&tx, &name, &email, &password_hash).await?;

        let role_name = if is_first { ADMIN_ROLE } else { USER_ROLE };
        let role = role_repository::find_by_name(&tx, role_name)
            .await?
            .ok_or_else(|| {
                AppError::Internal(format!(
                    "role `{role_name}` is missing; run `slave migrate`"
                ))
            })?;
        role_repository::assign(&tx, user.id, role.id).await?;

        let session = self.issue(&tx, user).await?;
        tx.commit().await?;
        Ok(session)
    }

    /// Exchanges an email and password for a new bearer token.
    pub async fn login(&self, input: LoginRequest) -> AppResult<Session> {
        let email = input.email.trim().to_lowercase();
        let conn = self.database.connect().await?;
        let user = user_repository::find_by_email(&conn, &email).await?;

        let stored = user.as_ref().map(|u| u.password_hash.clone());
        let password = input.password;
        let valid = tokio::task::spawn_blocking(move || {
            auth::verify_password_or_dummy(&password, stored.as_deref())
        })
        .await
        .map_err(|err| AppError::Internal(format!("password check failed: {err}")))?;

        match user {
            Some(user) if valid => {
                token_repository::delete_expired_for_user(&conn, user.id).await?;
                self.issue(&conn, user).await
            }
            _ => Err(AppError::InvalidCredentials),
        }
    }

    /// Revokes the token identified by `token_hash`.
    pub async fn logout(&self, token_hash: &str) -> AppResult<()> {
        let conn = self.database.connect().await?;
        token_repository::delete(&conn, token_hash).await?;
        Ok(())
    }

    async fn issue(&self, conn: &Connection, user: User) -> AppResult<Session> {
        let token = auth::generate_token()?;
        let expires_at = token_repository::insert(
            conn,
            user.id,
            &auth::hash_token(&token),
            self.token_ttl_days,
        )
        .await?;
        let roles = role_repository::for_user(conn, user.id)
            .await?
            .into_iter()
            .map(|role| role.name)
            .collect();
        Ok(Session {
            user,
            roles,
            token,
            expires_at,
        })
    }
}

/// Argon2 is deliberately slow, so hashing runs off the async worker threads.
async fn hash_password(password: String) -> AppResult<String> {
    tokio::task::spawn_blocking(move || auth::hash_password(&password))
        .await
        .map_err(|err| AppError::Internal(format!("password hashing failed: {err}")))?
        .map_err(AppError::from)
}

fn normalize_email(email: &str) -> AppResult<String> {
    let email = email.trim().to_lowercase();
    let valid = email.len() <= 254
        && email.split_once('@').is_some_and(|(local, domain)| {
            !local.is_empty() && domain.contains('.') && !domain.starts_with('.')
        });
    if valid {
        Ok(email)
    } else {
        Err(AppError::Validation("email must be a valid address".into()))
    }
}

fn validate_password(password: &str) -> AppResult<()> {
    let len = password.chars().count();
    if (MIN_PASSWORD_LEN..=MAX_PASSWORD_LEN).contains(&len) {
        Ok(())
    } else {
        Err(AppError::Validation(format!(
            "password must be {MIN_PASSWORD_LEN} to {MAX_PASSWORD_LEN} characters"
        )))
    }
}