Skip to main content

systemprompt_cloud/cli_session/
session.rs

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