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