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 }
80 }
81 }
82 }
83 }
84
85 if let Some(ref pid) = project_id {
87 if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
88 return Ok(session_identity);
89 }
90 }
91
92 Ok(Identity {
94 member: git_email,
95 delegated_by: None,
96 authenticated: false,
97 })
98}
99
100fn session_identity(
102 root: &Path,
103 member: &str,
104 project_id: &str,
105 project: &Option<Project>,
106) -> Option<Identity> {
107 if !check_session(root, member, project) {
108 return None;
109 }
110
111 let delegated_by = crate::auth::session::load_session(project_id, member)
113 .ok()
114 .flatten()
115 .and_then(|_sess| {
116 if is_ai_member(member) {
118 crate::vcs::default_vcs().user_email().ok()
121 } else {
122 None
123 }
124 });
125
126 Some(Identity {
127 member: member.to_string(),
128 delegated_by,
129 authenticated: true,
130 })
131}
132
133pub fn has_ai_members(root: &Path) -> bool {
135 let project = load_project_optional(root);
136 match project {
137 Some(p) => p.members.keys().any(|k| is_ai_member(k)),
138 None => false,
139 }
140}
141
142fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
144 let Some(project) = project else {
145 return false;
146 };
147 if !project.members.contains_key(member) {
148 return false;
149 };
150 let Ok(project_id) = crate::auth::session::project_id(root) else {
151 return false;
152 };
153 let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
154 return false;
155 };
156
157 if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
159 return false;
160 }
161
162 if !is_ai_member(member) {
164 let m = project.members.get(member).unwrap();
165 let Some(ref pk_hex) = m.public_key else {
166 return false;
167 };
168 let Ok(pk) = crate::auth::sign::PublicKey::from_hex(pk_hex) else {
169 return false;
170 };
171 if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
172 return false;
173 }
174 let current_tty = crate::auth::session::current_tty();
178 if sess.claims.tty != current_tty {
179 return false;
180 }
181 return true;
182 }
183
184 false
188}
189
190fn ephemeral_public_matches(
194 sess: &crate::auth::session::SessionToken,
195 ephemeral_private: &[u8; 32],
196) -> bool {
197 let Some(ref stored_pk_hex) = sess.claims.session_public_key else {
198 return false;
199 };
200 let kp = crate::auth::sign::IdentityKeypair::from_seed(ephemeral_private);
201 kp.public_key().to_hex() == *stored_pk_hex
202}
203
204fn load_project_optional(root: &Path) -> Option<Project> {
205 let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
206 store::read_yaml(&project_path).ok()
207}
208
209#[allow(dead_code)]
210fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
211 let Some(project) = project else {
212 return Ok(());
213 };
214 if project.members.is_empty() {
215 return Ok(());
216 }
217 if !project.members.contains_key(member) {
218 return Err(JoyError::Other(format!(
219 "'{}' is not a registered project member. \
220 Use `joy member add {}` to register.",
221 member, member
222 )));
223 }
224 Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn identity_log_user_simple() {
233 let id = Identity {
234 member: "alice@example.com".into(),
235 delegated_by: None,
236 authenticated: false,
237 };
238 assert_eq!(id.log_user(), "alice@example.com");
239 }
240
241 #[test]
242 fn identity_log_user_delegated() {
243 let id = Identity {
244 member: "ai:claude@joy".into(),
245 delegated_by: Some("horst@joydev.com".into()),
246 authenticated: false,
247 };
248 assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
249 }
250}