better-auth-api 1.0.0-alpha.5

Plugin implementations for better-auth
use better_auth_core::{AuthError, AuthRequest, AuthResult, FieldMap, FieldValue};
use serde_json::Value;

#[cfg(test)]
#[path = "json_body/native_record_tests.rs"]
mod native_record_tests;

pub(crate) fn native_object(value: &FieldValue) -> bool {
    matches!(value, FieldValue::Object(_) | FieldValue::Date(_))
}

pub(crate) fn native_record(value: &FieldValue) -> AuthResult<Option<FieldMap>> {
    if !native_object(value) {
        return Ok(None);
    }
    let constructor = value.model_property("constructor")?;
    // shortcut: FieldDate does not synthesize Date.prototype.constructor.
    // Remove this branch when FieldDate models that inherited constructor.
    // Explicit undefined must still follow Zod's modified-constructor path.
    if let FieldValue::Date(date) = value
        && constructor.is_undefined()
        && !date.properties().has_property("constructor")?
    {
        return Ok(None);
    }
    if matches!(constructor, FieldValue::Function(_)) {
        let prototype = constructor.model_property("prototype")?;
        let properties = match &prototype {
            FieldValue::Object(object) => object,
            FieldValue::Date(date) => date.properties(),
            _ => return Ok(None),
        };
        if properties
            .own_property_descriptor("isPrototypeOf")?
            .is_none()
        {
            return Ok(None);
        }
    }
    value.enumerable_fields_except(&["__proto__"]).map(Some)
}

pub(crate) fn is_truthy(value: &Value) -> bool {
    match value {
        Value::Null => false,
        Value::Bool(value) => *value,
        Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
        Value::String(value) => !value.is_empty(),
        Value::Array(_) | Value::Object(_) => true,
    }
}

pub(crate) fn type_name(value: Option<&Value>) -> &'static str {
    match value {
        None => "undefined",
        Some(Value::Null) => "null",
        Some(Value::Bool(_)) => "boolean",
        Some(Value::Number(_)) => "number",
        Some(Value::String(_)) => "string",
        Some(Value::Array(_)) => "array",
        Some(Value::Object(_)) => "object",
    }
}

pub(crate) fn invalid_type(location: &str, expected: &str, value: Option<&Value>) -> String {
    format!(
        "[{location}] Invalid input: expected {expected}, received {}",
        type_name(value)
    )
}

pub(crate) fn native_invalid_type(
    location: &str,
    expected: &str,
    value: Option<&FieldValue>,
) -> String {
    let actual = native_type_name(value);
    format!("[{location}] Invalid input: expected {expected}, received {actual}")
}

pub(crate) fn native_type_name(value: Option<&FieldValue>) -> &'static str {
    match value {
        None | Some(FieldValue::Undefined) => "undefined",
        Some(FieldValue::Null) => "null",
        Some(FieldValue::Bool(_)) => "boolean",
        Some(FieldValue::Number(value)) if value.is_nan() => "NaN",
        Some(FieldValue::Number(value)) if *value == f64::INFINITY => "Infinity",
        Some(FieldValue::Number(value)) if *value == f64::NEG_INFINITY => "-Infinity",
        Some(FieldValue::Number(_)) => "number",
        Some(FieldValue::String(_) | FieldValue::Utf16String(_)) => "string",
        Some(FieldValue::Date(_)) => "Date",
        Some(FieldValue::Array(_)) => "array",
        Some(FieldValue::Object(_)) => "object",
        Some(FieldValue::Function(_)) => "function",
    }
}

pub(crate) fn validation_error(message: &str) -> AuthError {
    AuthError::RequestValidation {
        message: message.into(),
    }
}

#[derive(Clone, Default)]
pub(crate) struct SignOutBody {
    pub callback_url: Option<String>,
    pub disable_redirect: Option<bool>,
    pub state: Option<String>,
}

fn parse_sign_out(req: &AuthRequest) -> better_auth_core::AuthResult<(SignOutBody, Option<Value>)> {
    let input = req.input_body()?;
    let Some(body) = input else {
        return Ok((SignOutBody::default(), None));
    };
    let object = body
        .as_object()
        .ok_or_else(|| validation_error(&invalid_type("body", "object", Some(&body))))?;
    let mut errors = Vec::new();
    let mut output = serde_json::Map::new();
    for (field, expected) in [
        ("callbackURL", "string"),
        ("disableRedirect", "boolean"),
        ("state", "string"),
    ] {
        if let Some(value) = object.get(field) {
            if type_name(Some(value)) != expected {
                errors.push(invalid_type(
                    &format!("body.{field}"),
                    expected,
                    Some(value),
                ));
            } else {
                let _ = output.insert(field.to_owned(), value.clone());
            }
        }
    }
    if !errors.is_empty() {
        return Err(validation_error(&errors.join("; ")));
    }
    let typed = SignOutBody {
        callback_url: output
            .get("callbackURL")
            .and_then(Value::as_str)
            .map(str::to_owned),
        disable_redirect: output.get("disableRedirect").and_then(Value::as_bool),
        state: output
            .get("state")
            .and_then(Value::as_str)
            .map(str::to_owned),
    };
    Ok((typed, Some(Value::Object(output))))
}

pub(crate) fn sign_out_body(
    req: &AuthRequest,
) -> better_auth_core::AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
    let (typed, projection) = parse_sign_out(req)?;
    Ok(better_auth_core::endpoint_input::ValidatedBody::new(
        projection, typed,
    ))
}

pub(crate) fn sign_out(req: &AuthRequest) -> AuthResult<SignOutBody> {
    match req.validated_body::<SignOutBody>() {
        Some(body) => Ok(body.clone()),
        None => parse_sign_out(req).map(|(body, _)| body),
    }
}

pub(crate) fn valid_email(email: &str) -> better_auth_core::AuthResult<bool> {
    static EMAIL: std::sync::LazyLock<Result<regex::Regex, regex::Error>> =
        std::sync::LazyLock::new(|| {
            regex::Regex::new(
                r"\A(?:[A-Za-z0-9_'+\-]+\.)*[A-Za-z0-9_'+\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}\z",
            )
        });
    let expression = EMAIL.as_ref().map_err(|error| {
        better_auth_core::AuthError::internal(format!("Invalid email schema: {error}"))
    })?;
    Ok(expression.is_match(email))
}

pub(crate) fn string_input<T: serde::de::DeserializeOwned>(
    req: &AuthRequest,
    fields: &[(&str, bool)],
) -> better_auth_core::AuthResult<(T, Value)> {
    string_fields_input(req, fields, None)
}

pub(crate) fn email_input<T: serde::de::DeserializeOwned>(
    req: &AuthRequest,
    email: &str,
    optional: &str,
) -> better_auth_core::AuthResult<(T, Value)> {
    string_fields_input(req, &[(email, true), (optional, false)], Some(email))
}

fn string_fields_input<T: serde::de::DeserializeOwned>(
    req: &AuthRequest,
    fields: &[(&str, bool)],
    email: Option<&str>,
) -> better_auth_core::AuthResult<(T, Value)> {
    let body = req.input_body()?;
    let object = body
        .as_ref()
        .and_then(Value::as_object)
        .ok_or_else(|| validation_error(&invalid_type("body", "object", body.as_ref())))?;
    let mut output = serde_json::Map::new();
    let mut errors = Vec::new();
    for &(name, required) in fields {
        match object.get(name) {
            Some(value @ Value::String(text)) => {
                if email == Some(name) && !valid_email(text)? {
                    errors.push(format!("[body.{name}] Invalid email address"));
                }
                let _ = output.insert(name.into(), value.clone());
            }
            None if !required => {}
            value => errors.push(invalid_type(&format!("body.{name}"), "string", value)),
        }
    }
    if !errors.is_empty() {
        return Err(validation_error(&errors.join("; ")));
    }
    let projection = Value::Object(output);
    Ok((serde_json::from_value(projection.clone())?, projection))
}