Skip to main content

systemprompt_cloud/cli_session/
session.rs

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