scout_rs 0.95.64

A Rust client for the synchornizing state with the Scout database
Documentation
//! Client abilities JWT minting (user-scoped, mirrors pubsub token minting).

use std::fmt;

use serde::Deserialize;

use crate::auth::UserAccessConfig;

/// Per-herd ability claim carried by a client abilities token.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesHerdClaim {
    pub herd_id: i64,
    pub slug: String,
    #[serde(default)]
    pub abilities: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ClientAbilitiesTokenMint {
    pub token: String,
    #[serde(default)]
    pub platform_superadmin: bool,
    #[serde(default)]
    pub herds: Vec<ClientAbilitiesHerdClaim>,
    pub iat: i64,
    pub exp: i64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MintClientAbilitiesTokenError {
    MissingUserAccessToken,
    MissingSupabaseApiKey,
    Unauthorized,
    Forbidden,
    Http { status: u16, body: String },
    InvalidResponse,
}

impl fmt::Display for MintClientAbilitiesTokenError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MintClientAbilitiesTokenError::MissingUserAccessToken => {
                write!(f, "user access token required to mint client abilities token")
            }
            MintClientAbilitiesTokenError::MissingSupabaseApiKey => {
                write!(f, "supabase API key required to mint client abilities token")
            }
            MintClientAbilitiesTokenError::Unauthorized => write!(f, "unauthorized"),
            MintClientAbilitiesTokenError::Forbidden => write!(f, "forbidden"),
            MintClientAbilitiesTokenError::Http { status, body } => {
                write!(f, "mint-client-abilities-token failed: HTTP {status} - {body}")
            }
            MintClientAbilitiesTokenError::InvalidResponse => {
                write!(f, "mint-client-abilities-token returned invalid response")
            }
        }
    }
}

/// Mints a client abilities token for the calling user via the `mint-client-abilities-token`
/// edge function. Callers with no active herd membership receive `Forbidden`.
pub fn mint_client_abilities_token(
    config: &UserAccessConfig,
    user_access_token: &str,
) -> Result<ClientAbilitiesTokenMint, MintClientAbilitiesTokenError> {
    let token = user_access_token.trim();
    if token.is_empty() {
        return Err(MintClientAbilitiesTokenError::MissingUserAccessToken);
    }
    if config.supabase_anon_key.is_empty() {
        return Err(MintClientAbilitiesTokenError::MissingSupabaseApiKey);
    }

    let url = format!("{}/mint-client-abilities-token", functions_url(&config.rest_url));

    let response = reqwest::blocking::Client::new()
        .post(url)
        .header("apikey", &config.supabase_anon_key)
        .header("Authorization", format!("Bearer {token}"))
        .send()
        .map_err(|e| MintClientAbilitiesTokenError::Http {
            status: 0,
            body: e.to_string(),
        })?;

    let status = response.status();
    let body = response.text().map_err(|e| MintClientAbilitiesTokenError::Http {
        status: status.as_u16(),
        body: e.to_string(),
    })?;

    match status.as_u16() {
        401 => Err(MintClientAbilitiesTokenError::Unauthorized),
        403 => Err(MintClientAbilitiesTokenError::Forbidden),
        _ if !status.is_success() => Err(MintClientAbilitiesTokenError::Http {
            status: status.as_u16(),
            body,
        }),
        _ => {
            let mint: ClientAbilitiesTokenMint =
                serde_json::from_str(&body).map_err(|_| MintClientAbilitiesTokenError::InvalidResponse)?;
            if mint.token.is_empty() {
                Err(MintClientAbilitiesTokenError::InvalidResponse)
            } else {
                Ok(mint)
            }
        }
    }
}

/// Derives the edge functions base URL from a PostgREST `rest_url` ending in `/rest/v1`.
fn functions_url(rest_url: &str) -> String {
    let rest = rest_url.trim_end_matches('/');
    let base = rest.strip_suffix("/rest/v1").unwrap_or(rest);
    format!("{base}/functions/v1")
}