use std::path::{Path, PathBuf}; use std::sync::Arc; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use tracing::warn; use crate::actors::ActorRegistry; use crate::cache::NclCache; use crate::notifications::NotificationStore; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Role { Admin, Viewer, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeyEntry { pub role: Role, /// Argon2id PHC string — produced by `ontoref-daemon --hash-password ` pub hash: String, } /// Serialisable entry used for TOML read/write. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryEntry { pub slug: String, pub root: PathBuf, #[serde(default)] pub keys: Vec, } #[derive(Debug, Serialize, Deserialize)] struct RegistryFile { projects: Vec, } /// Per-project runtime state owned by the registry. pub struct ProjectContext { pub slug: String, pub root: PathBuf, pub import_path: Option, pub cache: Arc, pub actors: Arc, pub notifications: Arc, /// Stored for TOML serialisation round-trips. pub keys: Vec, } impl ProjectContext { pub fn auth_enabled(&self) -> bool { !self.keys.is_empty() } /// Returns the role of the first key whose argon2id hash matches /// `password`. pub fn verify_key(&self, password: &str) -> Option { use argon2::{Argon2, PasswordHash, PasswordVerifier}; for key in &self.keys { let Ok(parsed) = PasswordHash::new(&key.hash) else { continue; }; if Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() { return Some(key.role); } } None } } pub struct ProjectRegistry { contexts: DashMap>, pub path: PathBuf, stale_actor_timeout: u64, max_notifications: usize, } impl ProjectRegistry { /// Load and parse a TOML registry file, creating per-project runtime state. pub fn load( path: &Path, stale_actor_timeout: u64, max_notifications: usize, ) -> anyhow::Result { let registry = Self { contexts: DashMap::new(), path: path.to_path_buf(), stale_actor_timeout, max_notifications, }; registry.reload_from_file(path)?; Ok(registry) } /// Hot-reload from the registry file; preserves existing caches for /// unchanged slugs. pub fn reload(&self) -> anyhow::Result<()> { self.reload_from_file(&self.path.clone()) } fn reload_from_file(&self, path: &Path) -> anyhow::Result<()> { let contents = std::fs::read_to_string(path)?; let file: RegistryFile = toml::from_str(&contents)?; let new_slugs: std::collections::HashSet = file.projects.iter().map(|p| p.slug.clone()).collect(); // Remove projects no longer in the file. self.contexts .retain(|slug, _| new_slugs.contains(slug.as_str())); for entry in file.projects { let root = entry .root .canonicalize() .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?; if let Some(existing) = self.contexts.get(&entry.slug) { // Project already loaded — update keys only, reuse warm cache/actors. if existing.root == root { // Re-insert a new Arc with updated keys but same internals. let ctx = ProjectContext { slug: existing.slug.clone(), root: existing.root.clone(), import_path: existing.import_path.clone(), cache: Arc::clone(&existing.cache), actors: Arc::clone(&existing.actors), notifications: Arc::clone(&existing.notifications), keys: entry.keys, }; drop(existing); self.contexts.insert(entry.slug, Arc::new(ctx)); continue; } drop(existing); } // New project — create fresh context. let import_path = load_import_path(&root); let ctx = make_context( entry.slug.clone(), root, import_path, entry.keys, self.stale_actor_timeout, self.max_notifications, ); self.contexts.insert(entry.slug, Arc::new(ctx)); } Ok(()) } /// Add a project at runtime and persist to the TOML file. pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> { if self.contexts.contains_key(&entry.slug) { anyhow::bail!("project '{}' already exists", entry.slug); } let root = entry .root .canonicalize() .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?; let import_path = load_import_path(&root); let ctx = make_context( entry.slug.clone(), root, import_path, entry.keys, self.stale_actor_timeout, self.max_notifications, ); self.contexts.insert(entry.slug, Arc::new(ctx)); self.write_toml() } /// Remove a project by slug and persist to the TOML file. pub fn remove_project(&self, slug: &str) -> anyhow::Result<()> { self.contexts.remove(slug); self.write_toml() } /// Serialise current in-memory state back to the registry TOML file. pub fn write_toml(&self) -> anyhow::Result<()> { let mut projects: Vec = self .contexts .iter() .map(|r| RegistryEntry { slug: r.slug.clone(), root: r.root.clone(), keys: r.keys.clone(), }) .collect(); projects.sort_by(|a, b| a.slug.cmp(&b.slug)); let file = RegistryFile { projects }; let toml = toml::to_string_pretty(&file)?; if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&self.path, toml)?; Ok(()) } pub fn get(&self, slug: &str) -> Option> { self.contexts.get(slug).map(|r| Arc::clone(&*r)) } pub fn all(&self) -> Vec> { let mut list: Vec<_> = self.contexts.iter().map(|r| Arc::clone(&*r)).collect(); list.sort_by(|a, b| a.slug.cmp(&b.slug)); list } pub fn count(&self) -> usize { self.contexts.len() } } fn make_context( slug: String, root: PathBuf, import_path: Option, keys: Vec, stale_actor_timeout: u64, max_notifications: usize, ) -> ProjectContext { let sessions_dir = root.join(".ontoref").join("sessions"); let actors = Arc::new(ActorRegistry::new(stale_actor_timeout).with_persist_dir(sessions_dir)); actors.load_persisted(); ProjectContext { slug, root, import_path, cache: Arc::new(NclCache::new()), actors, notifications: Arc::new(NotificationStore::new( max_notifications, vec![".ontology".into(), "adrs".into()], )), keys, } } fn load_import_path(root: &Path) -> Option { use std::process::Command; let config_path = root.join(".ontoref").join("config.ncl"); if !config_path.exists() { return None; } let output = Command::new("nickel") .arg("export") .arg(&config_path) .output() .ok()?; if !output.status.success() { warn!(path = %config_path.display(), "nickel export failed for registry project config"); return None; } let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; let paths = json.get("nickel_import_paths")?.as_array()?; // Resolve each path against the project root so that relative entries like // "." or "reflection/schemas" work correctly in multi-project mode where the // daemon CWD is not the project root. let joined = paths .iter() .filter_map(|v| v.as_str()) .map(|p| { let candidate = std::path::Path::new(p); if candidate.is_absolute() { p.to_string() } else { root.join(candidate).display().to_string() } }) .collect::>() .join(":"); if joined.is_empty() { None } else { Some(joined) } }