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