1use std::path::Path;
14
15use crate::error::JoyError;
16use crate::model::project::{is_ai_member, Project};
17use crate::store;
18use crate::vcs::Vcs;
19
20#[derive(Debug, Clone, PartialEq)]
22pub struct Identity {
23 pub member: String,
25 pub delegated_by: Option<String>,
27 pub authenticated: bool,
29}
30
31impl Identity {
32 pub fn log_user(&self) -> String {
35 match &self.delegated_by {
36 Some(human) => format!("{} delegated-by:{}", self.member, human),
37 None => self.member.clone(),
38 }
39 }
40}
41
42pub fn resolve_identity(root: &Path) -> Result<Identity, JoyError> {
49 let git_email = crate::vcs::default_vcs().user_email()?;
50 let project = load_project_optional(root);
51 let project_id = crate::auth::session::project_id(root).ok();
52
53 if let Some(env_value) = std::env::var("JOY_SESSION").ok().filter(|s| !s.is_empty()) {
59 if let Some((sid, ephemeral_private)) = crate::auth::session::parse_session_env(&env_value)
60 {
61 if let Ok(Some(sess)) = crate::auth::session::load_session_by_id(&sid) {
62 if sess.claims.expires > chrono::Utc::now() && is_ai_member(&sess.claims.member) {
63 let session_matches_project = project_id
64 .as_ref()
65 .map(|pid| sess.claims.project_id == *pid)
66 .unwrap_or(false);
67 if session_matches_project {
68 if let Some(ref project) = project {
69 if project.members.contains_key(&sess.claims.member)
70 && ephemeral_public_matches(&sess, &ephemeral_private)
71 {
72 return Ok(Identity {
73 member: sess.claims.member.clone(),
74 delegated_by: crate::vcs::default_vcs().user_email().ok(),
75 authenticated: true,
76 });
77 }
78 }
79 } else if let Some(ref current_pid) = project_id {
80 eprintln!(
89 "{}",
90 cross_project_session_warning(
91 &sess.claims.project_id,
92 &sess.claims.member,
93 current_pid,
94 )
95 );
96 }
97 }
98 }
99 }
100 }
101
102 if let Some(ref pid) = project_id {
104 if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
105 return Ok(session_identity);
106 }
107 }
108
109 Ok(Identity {
111 member: git_email,
112 delegated_by: None,
113 authenticated: false,
114 })
115}
116
117fn session_identity(
119 root: &Path,
120 member: &str,
121 project_id: &str,
122 project: &Option<Project>,
123) -> Option<Identity> {
124 if !check_session(root, member, project) {
125 return None;
126 }
127
128 let delegated_by = crate::auth::session::load_session(project_id, member)
130 .ok()
131 .flatten()
132 .and_then(|_sess| {
133 if is_ai_member(member) {
135 crate::vcs::default_vcs().user_email().ok()
138 } else {
139 None
140 }
141 });
142
143 Some(Identity {
144 member: member.to_string(),
145 delegated_by,
146 authenticated: true,
147 })
148}
149
150pub fn has_ai_members(root: &Path) -> bool {
152 let project = load_project_optional(root);
153 match project {
154 Some(p) => p.members.keys().any(|k| is_ai_member(k)),
155 None => false,
156 }
157}
158
159fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
161 let Some(project) = project else {
162 return false;
163 };
164 if !project.members.contains_key(member) {
165 return false;
166 };
167 let Ok(project_id) = crate::auth::session::project_id(root) else {
168 return false;
169 };
170 let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
171 return false;
172 };
173
174 if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
176 return false;
177 }
178
179 if !is_ai_member(member) {
181 let m = project.members.get(member).unwrap();
182 let Some(ref pk_hex) = m.verify_key else {
183 return false;
184 };
185 let Ok(pk) = crate::auth::PublicKey::from_hex(pk_hex) else {
186 return false;
187 };
188 if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
189 return false;
190 }
191 let current_tty = crate::auth::session::current_tty();
195 if sess.claims.tty != current_tty {
196 return false;
197 }
198 return true;
199 }
200
201 false
205}
206
207fn cross_project_session_warning(
212 session_project: &str,
213 session_member: &str,
214 current_project: &str,
215) -> String {
216 format!(
217 "Warning: JOY_SESSION belongs to project {session_project} \
218 (member {session_member}), but the current project is {current_project}. \
219 Ask the human for a delegation in this project: \
220 joy auth token add {session_member}"
221 )
222}
223
224fn ephemeral_public_matches(
228 sess: &crate::auth::session::SessionToken,
229 ephemeral_private: &[u8; 32],
230) -> bool {
231 let Some(ref stored_pk_hex) = sess.claims.session_public_key else {
232 return false;
233 };
234 let kp = crate::auth::IdentityKeypair::from_seed(ephemeral_private);
235 kp.public_key().to_hex() == *stored_pk_hex
236}
237
238fn load_project_optional(root: &Path) -> Option<Project> {
239 let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
240 store::read_project(&project_path).ok()
241}
242
243#[allow(dead_code)]
244fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
245 let Some(project) = project else {
246 return Ok(());
247 };
248 if project.members.is_empty() {
249 return Ok(());
250 }
251 if !project.members.contains_key(member) {
252 return Err(JoyError::Other(format!(
253 "'{}' is not a registered project member. \
254 Use `joy member add {}` to register.",
255 member, member
256 )));
257 }
258 Ok(())
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn identity_log_user_simple() {
267 let id = Identity {
268 member: "alice@example.com".into(),
269 delegated_by: None,
270 authenticated: false,
271 };
272 assert_eq!(id.log_user(), "alice@example.com");
273 }
274
275 #[test]
276 fn identity_log_user_delegated() {
277 let id = Identity {
278 member: "ai:claude@joy".into(),
279 delegated_by: Some("horst@joydev.com".into()),
280 authenticated: false,
281 };
282 assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
283 }
284
285 #[test]
286 fn cross_project_warning_names_session_and_current_projects() {
287 let msg = cross_project_session_warning("JOY", "ai:claude@joy", "JI");
288 assert!(msg.contains("belongs to project JOY"));
289 assert!(msg.contains("member ai:claude@joy"));
290 assert!(msg.contains("current project is JI"));
291 assert!(msg.contains("joy auth token add ai:claude@joy"));
292 }
293}