flatland3 0.2.28

Flatland3 terminal play client and account CLI
Documentation
//! Shared login / register / session probe for CLI and gfx clients.

use crate::api::ControlPlaneClient;
use crate::auth_context::{credentials_available, load_play_auth, PlayAuthKind};
use crate::config::{default_api_base, SavedLogin, SessionConfig};

#[derive(serde::Deserialize)]
struct LoginData {
    session_token: String,
}

/// `POST /v1/auth/login` and save [`SessionConfig`] + [`SavedLogin`].
pub async fn login(email: &str, password: &str, api: &str) -> anyhow::Result<()> {
    if email.trim().is_empty() || password.is_empty() {
        anyhow::bail!("email and password are required");
    }
    let client = ControlPlaneClient::new(api);
    let data: LoginData = client
        .post(
            "/v1/auth/login",
            &serde_json::json!({ "email": email.trim(), "password": password }),
            None,
        )
        .await?;
    // Keep last character across re-login (Redis/dev restarts wipe the server session).
    let last_character_id = SessionConfig::load().ok().and_then(|s| s.last_character_id);
    let api_base = api.trim_end_matches('/').to_string();
    let config = SessionConfig {
        api_base: api_base.clone(),
        session_token: data.session_token,
        last_character_id,
    };
    config.save()?;
    // One-shot --host must not rewrite login.json's remembered API base (local
    // play still uses client.json / default after this process exits).
    let saved_api_base = if flatland_client_lib::host_override_active() {
        SavedLogin::load().and_then(|s| s.api_base)
    } else {
        Some(api_base)
    };
    SavedLogin {
        email: email.trim().to_string(),
        password: password.to_string(),
        api_base: saved_api_base,
    }
    .save()?;
    Ok(())
}

/// `POST /v1/auth/register`, then login.
pub async fn register(
    email: &str,
    password: &str,
    display_name: &str,
    api: &str,
) -> anyhow::Result<()> {
    if email.trim().is_empty() || password.is_empty() || display_name.trim().is_empty() {
        anyhow::bail!("email, password, and display name are required");
    }
    let client = ControlPlaneClient::new(api);
    let _: serde_json::Value = client
        .post(
            "/v1/auth/register",
            &serde_json::json!({
                "email": email.trim(),
                "password": password,
                "display_name": display_name.trim(),
            }),
            None,
        )
        .await?;
    login(email, password, api).await
}

/// Login using the default API base from client config / env.
pub async fn login_default(email: &str, password: &str) -> anyhow::Result<()> {
    let api = SavedLogin::load()
        .and_then(|s| s.api_base)
        .unwrap_or_else(default_api_base);
    login(email, password, &api).await
}

/// Register using the default API base from client config / env.
pub async fn register_default(
    email: &str,
    password: &str,
    display_name: &str,
) -> anyhow::Result<()> {
    let api = SavedLogin::load()
        .and_then(|s| s.api_base)
        .unwrap_or_else(default_api_base);
    register(email, password, display_name, &api).await
}

/// True when an API token is set (skip interactive onboarding).
pub fn has_api_token() -> bool {
    std::env::var("FLATLAND_API_TOKEN")
        .map(|t| !t.is_empty())
        .unwrap_or(false)
}

/// Probe control plane: env API token, or `GET /v1/me` with the saved session.
pub async fn session_is_valid() -> bool {
    if has_api_token() {
        return true;
    }
    if !credentials_available() {
        return false;
    }
    let Ok(auth) = load_play_auth() else {
        return false;
    };
    if auth.kind == PlayAuthKind::ApiToken {
        return true;
    }
    let client = ControlPlaneClient::new(&auth.api_base);
    client
        .get::<serde_json::Value>("/v1/me", &auth.bearer)
        .await
        .is_ok()
}

/// When `session.json` exists but the server rejected the token (common after
/// Redis/dev restarts), re-login with [`SavedLogin`] credentials.
pub async fn try_restore_expired_session() -> bool {
    if session_is_valid().await {
        return true;
    }
    if !SessionConfig::exists() {
        return false;
    }
    let Some(saved) = SavedLogin::load() else {
        return false;
    };
    if saved.email.trim().is_empty() || saved.password.is_empty() {
        return false;
    }
    let api = saved
        .api_base
        .clone()
        .unwrap_or_else(default_api_base);
    login(&saved.email, &saved.password, &api).await.is_ok()
}

/// Interactive login/register is needed when there is no usable session/API token.
pub async fn needs_interactive_login() -> bool {
    if has_api_token() {
        return false;
    }
    if session_is_valid().await {
        return false;
    }
    // Stale session + remembered password → silent restore, no form.
    if try_restore_expired_session().await {
        return false;
    }
    true
}

/// Revoke the control-plane session (best-effort) and delete local `session.json`.
/// Keeps [`SavedLogin`] so the next login form can autofill.
pub async fn logout() -> anyhow::Result<()> {
    if let Ok(session) = SessionConfig::load() {
        let client = ControlPlaneClient::new(&session.api_base);
        let _ = client
            .post::<serde_json::Value, _>(
                "/v1/auth/logout",
                &serde_json::json!({}),
                Some(&session.session_token),
            )
            .await;
    }
    SessionConfig::clear()?;
    Ok(())
}