Skip to main content

mur_core/
auth.rs

1//! Authentication and plan management — mur.run API token verification.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5
6/// Membership tiers.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum Plan {
10    Free,
11    Pro,
12    Team,
13}
14
15impl std::fmt::Display for Plan {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            Plan::Free => write!(f, "Free"),
19            Plan::Pro => write!(f, "Pro ($9/mo)"),
20            Plan::Team => write!(f, "Team ($49/mo)"),
21        }
22    }
23}
24
25/// User/team identity from mur.run.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Identity {
28    pub user_id: String,
29    pub email: String,
30    pub plan: Plan,
31    pub team_id: Option<String>,
32    pub team_name: Option<String>,
33}
34
35/// API token for mur.run authentication.
36#[derive(Debug, Clone)]
37pub struct AuthToken {
38    token: String,
39    identity: Option<Identity>,
40}
41
42impl AuthToken {
43    /// Create from a token string.
44    pub fn new(token: String) -> Self {
45        Self {
46            token,
47            identity: None,
48        }
49    }
50
51    /// Load token from environment or config.
52    pub fn from_env() -> Option<Self> {
53        std::env::var("MUR_API_TOKEN")
54            .ok()
55            .map(Self::new)
56    }
57
58    /// Verify the token against mur.run API.
59    pub async fn verify(&mut self) -> Result<&Identity> {
60        let client = reqwest::Client::new();
61        let response = client
62            .get("https://mur.run/api/v1/auth/me")
63            .header("Authorization", format!("Bearer {}", self.token))
64            .send()
65            .await
66            .context("Verifying token with mur.run")?;
67
68        if !response.status().is_success() {
69            anyhow::bail!("Authentication failed: {}", response.status());
70        }
71
72        let identity: Identity = response.json().await.context("Parsing identity")?;
73        self.identity = Some(identity);
74        Ok(self.identity.as_ref().expect("identity set after successful verify"))
75    }
76
77    /// Get cached identity (must call verify first).
78    pub fn identity(&self) -> Option<&Identity> {
79        self.identity.as_ref()
80    }
81
82    /// Get the plan (defaults to Free if not verified).
83    pub fn plan(&self) -> Plan {
84        self.identity
85            .as_ref()
86            .map(|i| i.plan.clone())
87            .unwrap_or(Plan::Free)
88    }
89
90    pub fn token(&self) -> &str {
91        &self.token
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_plan_display() {
101        assert_eq!(Plan::Free.to_string(), "Free");
102        assert_eq!(Plan::Pro.to_string(), "Pro ($9/mo)");
103        assert_eq!(Plan::Team.to_string(), "Team ($49/mo)");
104    }
105
106    #[test]
107    fn test_plan_serialization() {
108        let p = Plan::Pro;
109        let json = serde_json::to_string(&p).unwrap();
110        assert_eq!(json, "\"pro\"");
111    }
112
113    #[test]
114    fn test_auth_token_default_plan() {
115        let token = AuthToken::new("test".into());
116        assert_eq!(token.plan(), Plan::Free);
117        assert!(token.identity().is_none());
118    }
119}