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