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::path::PathBuf;
12
13use chrono::{DateTime, Duration, Utc};
14use serde::{Deserialize, Serialize};
15use systemprompt_identifiers::{
16    ContextId, Email, ProfileName, SessionId, SessionToken, TenantId, UserId,
17};
18use systemprompt_models::auth::UserType;
19
20use super::{LOCAL_SESSION_KEY, SessionKey};
21
22pub(super) const CURRENT_VERSION: u32 = 6;
23pub(super) const MIN_SUPPORTED_VERSION: u32 = 6;
24const SESSION_DURATION_HOURS: i64 = 24;
25
26/// The profile a session belongs to, paired with the issuer its token was
27/// minted under. The two always travel together — a session is only reusable
28/// when both still match the loaded profile.
29#[derive(Debug, Clone)]
30pub struct SessionBinding {
31    pub profile_name: ProfileName,
32    pub issuer: String,
33}
34
35impl SessionBinding {
36    #[must_use]
37    pub const fn new(profile_name: ProfileName, issuer: String) -> Self {
38        Self {
39            profile_name,
40            issuer,
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct SessionIdentity {
47    pub user_id: UserId,
48    pub email: Email,
49    pub user_type: UserType,
50}
51
52impl SessionIdentity {
53    #[must_use]
54    pub const fn new(user_id: UserId, email: Email, user_type: UserType) -> Self {
55        Self {
56            user_id,
57            email,
58            user_type,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct CliSession {
65    pub version: u32,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub tenant_key: Option<TenantId>,
68    pub profile_name: ProfileName,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub profile_path: Option<PathBuf>,
71    pub session_token: SessionToken,
72    pub issuer: String,
73    pub session_id: SessionId,
74    pub context_id: ContextId,
75    pub user_id: UserId,
76    pub user_email: Email,
77    pub user_type: UserType,
78    #[serde(default = "Utc::now")]
79    pub created_at: DateTime<Utc>,
80    pub expires_at: DateTime<Utc>,
81    #[serde(default = "Utc::now")]
82    pub last_used: DateTime<Utc>,
83}
84
85#[derive(Debug)]
86pub struct CliSessionBuilder {
87    tenant_key: Option<TenantId>,
88    profile_name: ProfileName,
89    profile_path: Option<PathBuf>,
90    session_token: SessionToken,
91    issuer: String,
92    session_id: SessionId,
93    context_id: ContextId,
94    user_id: UserId,
95    user_email: Email,
96    user_type: UserType,
97    ttl: Duration,
98}
99
100impl CliSessionBuilder {
101    pub fn new(
102        binding: SessionBinding,
103        session_token: SessionToken,
104        session_id: SessionId,
105        context_id: ContextId,
106        identity: SessionIdentity,
107    ) -> Self {
108        Self {
109            tenant_key: None,
110            profile_name: binding.profile_name,
111            profile_path: None,
112            session_token,
113            issuer: binding.issuer,
114            ttl: Duration::hours(SESSION_DURATION_HOURS),
115            session_id,
116            context_id,
117            user_id: identity.user_id,
118            user_email: identity.email,
119            user_type: identity.user_type,
120        }
121    }
122
123    #[must_use]
124    pub fn with_tenant_key(mut self, tenant_key: TenantId) -> Self {
125        self.tenant_key = Some(tenant_key);
126        self
127    }
128
129    #[must_use]
130    pub fn with_session_key(mut self, key: &SessionKey) -> Self {
131        self.tenant_key = match key {
132            SessionKey::Local => Some(TenantId::new(LOCAL_SESSION_KEY)),
133            SessionKey::Tenant(id) => Some(id.clone()),
134        };
135        self
136    }
137
138    #[must_use]
139    pub fn with_profile_path(mut self, profile_path: impl Into<PathBuf>) -> Self {
140        self.profile_path = Some(profile_path.into());
141        self
142    }
143
144    #[must_use]
145    pub const fn with_ttl(mut self, ttl: Duration) -> Self {
146        self.ttl = ttl;
147        self
148    }
149
150    #[must_use]
151    pub fn build(self) -> CliSession {
152        let now = Utc::now();
153        let expires_at = now + self.ttl;
154        CliSession {
155            version: CURRENT_VERSION,
156            tenant_key: self.tenant_key,
157            profile_name: self.profile_name,
158            profile_path: self.profile_path,
159            session_token: self.session_token,
160            issuer: self.issuer,
161            session_id: self.session_id,
162            context_id: self.context_id,
163            user_id: self.user_id,
164            user_email: self.user_email,
165            user_type: self.user_type,
166            created_at: now,
167            expires_at,
168            last_used: now,
169        }
170    }
171}
172
173impl CliSession {
174    pub fn builder(
175        binding: SessionBinding,
176        session_token: SessionToken,
177        session_id: SessionId,
178        context_id: ContextId,
179        identity: SessionIdentity,
180    ) -> CliSessionBuilder {
181        CliSessionBuilder::new(binding, session_token, session_id, context_id, identity)
182    }
183
184    #[must_use]
185    pub fn matches_issuer(&self, issuer: &str) -> bool {
186        self.issuer == issuer
187    }
188
189    pub const fn context_id(&self) -> &ContextId {
190        &self.context_id
191    }
192
193    pub fn touch(&mut self) {
194        self.last_used = Utc::now();
195    }
196
197    pub fn set_context_id(&mut self, context_id: ContextId) {
198        self.context_id = context_id;
199        self.last_used = Utc::now();
200    }
201
202    pub fn update_profile_path(&mut self, profile_path: PathBuf) {
203        self.profile_path = Some(profile_path);
204        self.last_used = Utc::now();
205    }
206
207    #[must_use]
208    pub fn is_expired(&self) -> bool {
209        Utc::now() >= self.expires_at
210    }
211
212    #[must_use]
213    pub fn is_valid_for_profile(&self, profile_name: &str) -> bool {
214        self.profile_name.as_str() == profile_name && !self.is_expired()
215    }
216
217    #[must_use]
218    pub fn has_valid_credentials(&self) -> bool {
219        !self.session_token.as_str().is_empty()
220    }
221
222    #[must_use]
223    pub fn is_valid_for_tenant(&self, key: &SessionKey) -> bool {
224        if self.is_expired() || !self.has_valid_credentials() {
225            return false;
226        }
227
228        match (key, &self.tenant_key) {
229            (SessionKey::Local, None) => true,
230            (SessionKey::Local, Some(k)) => k.as_str() == LOCAL_SESSION_KEY,
231            (SessionKey::Tenant(id), Some(k)) => k == id,
232            (SessionKey::Tenant(_), None) => false,
233        }
234    }
235
236    #[must_use]
237    pub fn session_key(&self) -> SessionKey {
238        match &self.tenant_key {
239            None => SessionKey::Local,
240            Some(k) if k.as_str() == LOCAL_SESSION_KEY => SessionKey::Local,
241            Some(k) => SessionKey::Tenant(k.clone()),
242        }
243    }
244}