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> {
50 let git_email = crate::vcs::default_vcs().user_email()?;
51 let project = load_project_optional(root);
52 let project_id = crate::auth::session::project_id(root).ok();
53
54 if let Some(sid) = std::env::var("JOY_SESSION").ok().filter(|s| !s.is_empty()) {
58 if let Ok(Some(sess)) = crate::auth::session::load_session_by_id(&sid) {
59 if sess.claims.expires > chrono::Utc::now() && is_ai_member(&sess.claims.member) {
60 let session_matches_project = project_id
62 .as_ref()
63 .map(|pid| sess.claims.project_id == *pid)
64 .unwrap_or(false);
65 if session_matches_project {
66 if let Some(ref project) = project {
67 if project.members.contains_key(&sess.claims.member) {
68 return Ok(Identity {
69 member: sess.claims.member.clone(),
70 delegated_by: crate::vcs::default_vcs().user_email().ok(),
71 authenticated: true,
72 });
73 }
74 }
75 }
76 }
77 }
78 }
79
80 if let Some(token_str) = std::env::var("JOY_TOKEN").ok().filter(|s| !s.is_empty()) {
83 if let Ok(token) = crate::auth::token::decode_token(&token_str) {
84 let ai_member = &token.claims.ai_member;
85 if let Some(ref pid) = project_id {
86 if let Some(id) = session_identity(root, ai_member, pid, &project) {
87 return Ok(id);
88 }
89 }
90 }
91 }
92
93 if let Some(ref pid) = project_id {
95 if let Some(session_identity) = session_identity(root, &git_email, pid, &project) {
96 return Ok(session_identity);
97 }
98 }
99
100 Ok(Identity {
102 member: git_email,
103 delegated_by: None,
104 authenticated: false,
105 })
106}
107
108fn session_identity(
110 root: &Path,
111 member: &str,
112 project_id: &str,
113 project: &Option<Project>,
114) -> Option<Identity> {
115 if !check_session(root, member, project) {
116 return None;
117 }
118
119 let delegated_by = crate::auth::session::load_session(project_id, member)
121 .ok()
122 .flatten()
123 .and_then(|_sess| {
124 if is_ai_member(member) {
126 crate::vcs::default_vcs().user_email().ok()
129 } else {
130 None
131 }
132 });
133
134 Some(Identity {
135 member: member.to_string(),
136 delegated_by,
137 authenticated: true,
138 })
139}
140
141pub fn has_ai_members(root: &Path) -> bool {
143 let project = load_project_optional(root);
144 match project {
145 Some(p) => p.members.keys().any(|k| is_ai_member(k)),
146 None => false,
147 }
148}
149
150fn check_session(root: &Path, member: &str, project: &Option<Project>) -> bool {
152 let Some(project) = project else {
153 return false;
154 };
155 if !project.members.contains_key(member) {
156 return false;
157 };
158 let Ok(project_id) = crate::auth::session::project_id(root) else {
159 return false;
160 };
161 let Ok(Some(sess)) = crate::auth::session::load_session(&project_id, member) else {
162 return false;
163 };
164
165 if sess.claims.expires <= chrono::Utc::now() || sess.claims.member != member {
167 return false;
168 }
169
170 if !is_ai_member(member) {
172 let m = project.members.get(member).unwrap();
173 let Some(ref pk_hex) = m.public_key else {
174 return false;
175 };
176 let Ok(pk) = crate::auth::sign::PublicKey::from_hex(pk_hex) else {
177 return false;
178 };
179 if crate::auth::session::validate_session(&sess, &pk, &project_id).is_err() {
180 return false;
181 }
182 let current_tty = crate::auth::session::current_tty();
186 if sess.claims.tty != current_tty {
187 return false;
188 }
189 return true;
190 }
191
192 true
195}
196
197fn load_project_optional(root: &Path) -> Option<Project> {
198 let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
199 store::read_yaml(&project_path).ok()
200}
201
202#[allow(dead_code)]
203fn validate_member(member: &str, project: &Option<Project>) -> Result<(), JoyError> {
204 let Some(project) = project else {
205 return Ok(());
206 };
207 if project.members.is_empty() {
208 return Ok(());
209 }
210 if !project.members.contains_key(member) {
211 return Err(JoyError::Other(format!(
212 "'{}' is not a registered project member. \
213 Use `joy member add {}` to register.",
214 member, member
215 )));
216 }
217 Ok(())
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn identity_log_user_simple() {
226 let id = Identity {
227 member: "alice@example.com".into(),
228 delegated_by: None,
229 authenticated: false,
230 };
231 assert_eq!(id.log_user(), "alice@example.com");
232 }
233
234 #[test]
235 fn identity_log_user_delegated() {
236 let id = Identity {
237 member: "ai:claude@joy".into(),
238 delegated_by: Some("horst@joydev.com".into()),
239 authenticated: false,
240 };
241 assert_eq!(id.log_user(), "ai:claude@joy delegated-by:horst@joydev.com");
242 }
243}