Skip to main content

systemprompt_cloud/cli_session/
session.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Duration, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::{Path, PathBuf};
6use systemprompt_identifiers::{
7    ContextId, Email, ProfileName, SessionId, SessionToken, TenantId, UserId,
8};
9use systemprompt_models::auth::UserType;
10
11use super::{SessionKey, LOCAL_SESSION_KEY};
12use crate::error::CloudError;
13
14const CURRENT_VERSION: u32 = 4;
15const MIN_SUPPORTED_VERSION: u32 = 3;
16const SESSION_DURATION_HOURS: i64 = 24;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CliSession {
20    pub version: u32,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub tenant_key: Option<TenantId>,
23    pub profile_name: ProfileName,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub profile_path: Option<PathBuf>,
26    pub session_token: SessionToken,
27    pub session_id: SessionId,
28    pub context_id: ContextId,
29    pub user_id: UserId,
30    pub user_email: Email,
31    #[serde(default = "default_user_type")]
32    pub user_type: UserType,
33    pub created_at: DateTime<Utc>,
34    pub expires_at: DateTime<Utc>,
35    pub last_used: DateTime<Utc>,
36}
37
38fn default_user_type() -> UserType {
39    UserType::Admin
40}
41
42#[derive(Debug)]
43pub struct CliSessionBuilder {
44    tenant_key: Option<TenantId>,
45    profile_name: ProfileName,
46    profile_path: Option<PathBuf>,
47    session_token: SessionToken,
48    session_id: SessionId,
49    context_id: ContextId,
50    user_id: UserId,
51    user_email: Email,
52    user_type: UserType,
53}
54
55impl CliSessionBuilder {
56    pub fn new(
57        profile_name: ProfileName,
58        session_token: SessionToken,
59        session_id: SessionId,
60        context_id: ContextId,
61    ) -> Self {
62        Self {
63            tenant_key: None,
64            profile_name,
65            profile_path: None,
66            session_token,
67            session_id,
68            context_id,
69            user_id: UserId::system(),
70            user_email: Email::new("system@local.invalid"),
71            user_type: UserType::Admin,
72        }
73    }
74
75    #[must_use]
76    pub fn with_tenant_key(mut self, tenant_key: TenantId) -> Self {
77        self.tenant_key = Some(tenant_key);
78        self
79    }
80
81    #[must_use]
82    pub fn with_session_key(mut self, key: &SessionKey) -> Self {
83        self.tenant_key = match key {
84            SessionKey::Local => Some(TenantId::new(LOCAL_SESSION_KEY)),
85            SessionKey::Tenant(id) => Some(id.clone()),
86        };
87        self
88    }
89
90    #[must_use]
91    pub fn with_profile_path(mut self, profile_path: impl Into<PathBuf>) -> Self {
92        self.profile_path = Some(profile_path.into());
93        self
94    }
95
96    #[must_use]
97    pub fn with_user(mut self, user_id: UserId, user_email: Email) -> Self {
98        self.user_id = user_id;
99        self.user_email = user_email;
100        self
101    }
102
103    #[must_use]
104    pub const fn with_user_type(mut self, user_type: UserType) -> Self {
105        self.user_type = user_type;
106        self
107    }
108
109    #[must_use]
110    pub fn build(self) -> CliSession {
111        let now = Utc::now();
112        let expires_at = now + Duration::hours(SESSION_DURATION_HOURS);
113        CliSession {
114            version: CURRENT_VERSION,
115            tenant_key: self.tenant_key,
116            profile_name: self.profile_name,
117            profile_path: self.profile_path,
118            session_token: self.session_token,
119            session_id: self.session_id,
120            context_id: self.context_id,
121            user_id: self.user_id,
122            user_email: self.user_email,
123            user_type: self.user_type,
124            created_at: now,
125            expires_at,
126            last_used: now,
127        }
128    }
129}
130
131impl CliSession {
132    pub fn builder(
133        profile_name: ProfileName,
134        session_token: SessionToken,
135        session_id: SessionId,
136        context_id: ContextId,
137    ) -> CliSessionBuilder {
138        CliSessionBuilder::new(profile_name, session_token, session_id, context_id)
139    }
140
141    pub fn context_id(&self) -> &ContextId {
142        &self.context_id
143    }
144
145    pub fn touch(&mut self) {
146        self.last_used = Utc::now();
147    }
148
149    pub fn set_context_id(&mut self, context_id: ContextId) {
150        self.context_id = context_id;
151        self.last_used = Utc::now();
152    }
153
154    #[must_use]
155    pub fn is_expired(&self) -> bool {
156        Utc::now() >= self.expires_at
157    }
158
159    #[must_use]
160    pub fn is_valid_for_profile(&self, profile_name: &str) -> bool {
161        self.profile_name.as_str() == profile_name && !self.is_expired()
162    }
163
164    #[must_use]
165    pub fn has_valid_credentials(&self) -> bool {
166        !self.session_token.as_str().is_empty()
167    }
168
169    #[must_use]
170    pub fn is_valid_for_tenant(&self, key: &SessionKey) -> bool {
171        if self.is_expired() || !self.has_valid_credentials() {
172            return false;
173        }
174
175        match (key, &self.tenant_key) {
176            (SessionKey::Local, None) => true,
177            (SessionKey::Local, Some(k)) => k.as_str() == LOCAL_SESSION_KEY,
178            (SessionKey::Tenant(id), Some(k)) => k == id,
179            (SessionKey::Tenant(_), None) => false,
180        }
181    }
182
183    #[must_use]
184    pub fn session_key(&self) -> SessionKey {
185        match &self.tenant_key {
186            None => SessionKey::Local,
187            Some(k) if k.as_str() == LOCAL_SESSION_KEY => SessionKey::Local,
188            Some(k) => SessionKey::Tenant(k.clone()),
189        }
190    }
191
192    pub fn load_from_path(path: &Path) -> Result<Self> {
193        if !path.exists() {
194            return Err(CloudError::NotAuthenticated.into());
195        }
196
197        let content = fs::read_to_string(path)
198            .with_context(|| format!("Failed to read {}", path.display()))?;
199
200        let mut session: Self = serde_json::from_str(&content)
201            .map_err(|e| CloudError::CredentialsCorrupted { source: e })?;
202
203        if session.version < MIN_SUPPORTED_VERSION || session.version > CURRENT_VERSION {
204            return Err(anyhow::anyhow!(
205                "Session file version mismatch: expected {}-{}, got {}. Delete {} and retry.",
206                MIN_SUPPORTED_VERSION,
207                CURRENT_VERSION,
208                session.version,
209                path.display()
210            ));
211        }
212
213        session.version = CURRENT_VERSION;
214        Ok(session)
215    }
216
217    pub fn save_to_path(&self, path: &Path) -> Result<()> {
218        if let Some(dir) = path.parent() {
219            fs::create_dir_all(dir)?;
220
221            let gitignore_path = dir.join(".gitignore");
222            if !gitignore_path.exists() {
223                fs::write(&gitignore_path, "*\n")?;
224            }
225        }
226
227        let content = serde_json::to_string_pretty(self)?;
228        fs::write(path, content)?;
229
230        #[cfg(unix)]
231        {
232            use std::os::unix::fs::PermissionsExt;
233            let mut perms = fs::metadata(path)?.permissions();
234            perms.set_mode(0o600);
235            fs::set_permissions(path, perms)?;
236        }
237
238        Ok(())
239    }
240
241    pub fn delete_from_path(path: &Path) -> Result<()> {
242        if path.exists() {
243            fs::remove_file(path)?;
244        }
245        Ok(())
246    }
247}