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