use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicU64; use std::sync::{Arc, RwLock}; use dashmap::DashMap; use serde::{Deserialize, Serialize}; use tokio::sync::Semaphore; use tracing::warn; use crate::actors::ActorRegistry; use crate::cache::NclCache; use crate::notifications::NotificationStore; // ── Config surface // ──────────────────────────────────────────────────────────── /// Which process reads a config section. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConsumerKind { RustStruct, NuScript, CiPipeline, External, } /// A single process that reads fields from a config section. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigConsumer { /// Identifier for this consumer (e.g. "vapora-backend", "deploy-script"). pub id: String, pub kind: ConsumerKind, /// Rust fully-qualified type or script path. pub reference: String, /// Fields this consumer reads. Empty = reads all fields. pub fields: Vec, } /// Describes one NCL config file and all processes that read it. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigSection { pub id: String, /// Path to the NCL file, relative to `config_root`. pub file: String, /// Path to the NCL contract file. Relative to `contracts_path` or project /// root. pub contract: String, pub description: String, /// Why this section exists and why current values were chosen. pub rationale: String, /// When false, ontoref will only read this section, never write. pub mutable: bool, pub consumers: Vec, } /// How the project's config files are organised. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConfigKind { /// Multiple .ncl files merged via & operator. NclMerge, /// .typedialog/ structure with form.toml + validators + fragments. TypeDialog, /// Single monolithic .ncl file. SingleFile, } /// Project-level config surface metadata — loaded once from manifest.ncl at /// registration time and stored in `ProjectContext`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigSurface { /// Directory containing config NCL files, relative to project root. pub config_root: PathBuf, /// Main NCL file (entry point for `nickel export`). pub entry_point: String, pub kind: ConfigKind, /// Directory added to NICKEL_IMPORT_PATH when exporting config. pub contracts_path: String, /// Where ontoref writes `{section}.overrides.ncl` files. /// Defaults to `config_root` when empty. pub overrides_dir: PathBuf, pub sections: Vec, } impl ConfigSurface { /// Resolve the directory where override files are written. /// Returns `overrides_dir` if set, otherwise `config_root`. pub fn resolved_overrides_dir(&self) -> &Path { if self.overrides_dir == PathBuf::new() { &self.config_root } else { &self.overrides_dir } } /// Resolve the absolute path to the config entry point given the project /// root. pub fn entry_point_path(&self, project_root: &Path) -> PathBuf { project_root.join(&self.config_root).join(&self.entry_point) } /// Find a section by id. pub fn section(&self, id: &str) -> Option<&ConfigSection> { self.sections.iter().find(|s| s.id == id) } } // ── Auth / keys // ─────────────────────────────────────────────────────────────── #[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, /// Human-readable label identifying which key this is (e.g. "developer", /// "agent", "ci"). Recorded in sessions for audit tracing. Optional — /// defaults to empty string for keys created before this field existed. #[serde(default)] pub label: String, } /// Result of a successful key verification — role and the label of the /// matched key for audit trail purposes. #[derive(Debug, Clone)] pub struct KeyMatch { pub role: Role, pub label: String, } /// Verify `password` against a slice of `KeyEntry`s using argon2id. /// /// Returns the `KeyMatch` (role + label) of the first matching key, or `None` /// if no key matches. Unparseable PHC hashes are logged and skipped. pub fn verify_keys_list(keys: &[KeyEntry], password: &str) -> Option { use argon2::{Argon2, PasswordHash, PasswordVerifier}; for key in keys { let Ok(parsed) = PasswordHash::new(&key.hash) else { warn!(role = ?key.role, label = %key.label, "skipping key entry with unparseable PHC hash — key is inactive"); continue; }; if Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() { return Some(KeyMatch { role: key.role, label: key.label.clone(), }); } } None } /// Entry as it arrives from the JSON config pipe (ADR-004). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryEntry { pub slug: String, /// Absolute local path. Empty for push_only projects. #[serde(default)] pub root: PathBuf, /// Import paths for nickel export (local projects only). #[serde(default)] pub nickel_import_paths: Vec, #[serde(default)] pub keys: Vec, /// Git/SSH/HTTPS URL. Informational — daemon never fetches from it. #[serde(default)] pub remote_url: String, /// When true: no local file watch, no NCL import. Project pushes via POST /// /sync. #[serde(default)] pub push_only: bool, } /// 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, /// Mutable at runtime via `PUT /projects/{slug}/keys`. pub keys: RwLock>, /// Git/SSH/HTTPS remote URL. Informational. pub remote_url: String, /// No local file watch, no NCL import — project pushes via POST /sync. pub push_only: bool, /// Ensures only one `seed_ontology` runs at a time per project. /// Prevents concurrent re-seeds caused by rapid filesystem events. pub seed_lock: Arc, /// Incremented by 1 after each successful `seed_ontology` completion. /// Clients can compare versions to detect stale local state. pub ontology_version: Arc, /// Per-file change counters. Keyed by canonical absolute path; incremented /// on every cache invalidation for that file. Consumers compare snapshots /// to detect which individual files changed between polls. pub file_versions: Arc>, /// Config surface metadata loaded from the project's manifest.ncl at /// registration time. `None` when the project's manifest has no /// `config_surface` field or when the manifest can't be exported. pub config_surface: Option, } impl ProjectContext { pub fn auth_enabled(&self) -> bool { !self .keys .read() .unwrap_or_else(|e| e.into_inner()) .is_empty() } pub fn verify_key(&self, password: &str) -> Option { let keys = self.keys.read().unwrap_or_else(|e| e.into_inner()); verify_keys_list(&keys, password) } /// Replace the current key set. Returns the number of keys now active. pub fn set_keys(&self, new_keys: Vec) -> usize { let mut guard = self.keys.write().unwrap_or_else(|e| e.into_inner()); *guard = new_keys; guard.len() } } pub struct ProjectRegistry { contexts: DashMap>, /// Slug of the primary (local) project — always present in `contexts`. primary_slug: String, stale_actor_timeout: u64, max_notifications: usize, } impl ProjectRegistry { /// Build a registry with a pre-constructed primary project context, plus /// optional additional entries (service mode). The primary context is /// inserted under `primary_slug` and accessible via `primary()`. /// /// Returns `(registry, Arc)` so the caller can extract /// shared `Arc` references (cache, actors, notifications) before consuming /// the primary context into the registry. pub fn with_primary( primary_slug: String, primary_ctx: Arc, additional_entries: Vec, stale_actor_timeout: u64, max_notifications: usize, ) -> Self { let registry = Self { contexts: DashMap::new(), primary_slug: primary_slug.clone(), stale_actor_timeout, max_notifications, }; registry.contexts.insert(primary_slug.clone(), primary_ctx); for entry in additional_entries { if entry.slug == primary_slug { warn!( slug = %entry.slug, "additional registry entry collides with primary slug — skipping" ); continue; } if entry.push_only { let ctx = make_context(ContextSpec { slug: entry.slug.clone(), root: PathBuf::new(), import_path: None, keys: entry.keys, remote_url: entry.remote_url, push_only: true, stale_actor_timeout, max_notifications, ack_required: vec![], config_surface: None, }); registry.contexts.insert(entry.slug, Arc::new(ctx)); continue; } let root = match entry.root.canonicalize() { Ok(p) => p, Err(e) => { warn!( slug = %entry.slug, path = %entry.root.display(), error = %e, "project root not found — skipping" ); continue; } }; let import_path = resolve_import_path(&entry.nickel_import_paths, &root); let config_surface = load_config_surface(&root, import_path.as_deref()); let ctx = make_context(ContextSpec { slug: entry.slug.clone(), root, import_path, keys: entry.keys, remote_url: entry.remote_url, push_only: false, stale_actor_timeout, max_notifications, ack_required: vec![], config_surface, }); registry.contexts.insert(entry.slug, Arc::new(ctx)); } registry } /// The slug of the primary (local) project. pub fn primary_slug(&self) -> &str { &self.primary_slug } /// The primary project context. Panics if not found — invariant guaranteed /// by `with_primary` constructor. pub fn primary(&self) -> Arc { self.contexts .get(&self.primary_slug) .map(|r| Arc::clone(&*r)) .expect("primary project context missing — registry invariant violated") } /// Add a project at runtime (UI-triggered, not from bootstrap). 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, import_path) = if entry.push_only { (PathBuf::new(), None) } else { let r = entry .root .canonicalize() .map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?; let ip = resolve_import_path(&entry.nickel_import_paths, &r); (r, ip) }; let config_surface = if entry.push_only { None } else { load_config_surface(&root, import_path.as_deref()) }; let ctx = make_context(ContextSpec { slug: entry.slug.clone(), root, import_path, keys: entry.keys, remote_url: entry.remote_url, push_only: entry.push_only, stale_actor_timeout: self.stale_actor_timeout, max_notifications: self.max_notifications, ack_required: vec![], config_surface, }); self.contexts.insert(entry.slug, Arc::new(ctx)); Ok(()) } /// Remove a project by slug. pub fn remove_project(&self, slug: &str) { self.contexts.remove(slug); } 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() } /// Replace the key set for a registered project. /// Returns `None` if the slug is not found. pub fn update_keys(&self, slug: &str, keys: Vec) -> Option { self.contexts.get(slug).map(|ctx| ctx.set_keys(keys)) } } /// Resolve nickel_import_paths entries into a colon-separated /// NICKEL_IMPORT_PATH string. Relative paths are resolved against the project /// root. fn resolve_import_path(paths: &[String], root: &Path) -> Option { let joined = paths .iter() .map(|p| { let candidate = Path::new(p); if candidate.is_absolute() { p.clone() } else { root.join(candidate).display().to_string() } }) .collect::>() .join(":"); if joined.is_empty() { None } else { Some(joined) } } pub struct ContextSpec { pub slug: String, pub root: PathBuf, pub import_path: Option, pub keys: Vec, pub remote_url: String, pub push_only: bool, pub stale_actor_timeout: u64, pub max_notifications: usize, /// Directories that require notification acknowledgment. pub ack_required: Vec, /// Pre-loaded config surface from the project's manifest.ncl. /// Pass `None` for push_only projects or when the manifest has no /// `config_surface` field. pub config_surface: Option, } /// Attempt to load `ConfigSurface` from a project's `manifest.ncl` /// synchronously. /// /// Runs `nickel export` on `.ontology/manifest.ncl` and deserialises the /// `config_surface` key. Returns `None` when: /// - the manifest file doesn't exist /// - the manifest has no `config_surface` field /// - nickel export or deserialisation fails (logged at warn level) pub fn load_config_surface(root: &Path, import_path: Option<&str>) -> Option { let manifest = root.join(".ontology").join("manifest.ncl"); if !manifest.exists() { return None; } let mut cmd = std::process::Command::new("nickel"); cmd.args(["export", "--format", "json"]) .arg(&manifest) .current_dir(root); if let Some(ip) = import_path { cmd.env("NICKEL_IMPORT_PATH", ip); } let output = cmd.output().ok()?; if !output.status.success() { warn!( path = %manifest.display(), stderr = %String::from_utf8_lossy(&output.stderr), "nickel export of manifest.ncl failed — config_surface not loaded" ); return None; } let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; let surface_val = json.get("config_surface")?; // Deserialise into an intermediate form that matches the NCL schema, // then convert to the canonical ConfigSurface. #[derive(Deserialize)] struct NclConfigConsumer { id: String, kind: String, #[serde(default, rename = "ref")] reference: String, #[serde(default)] fields: Vec, } #[derive(Deserialize)] struct NclConfigSection { id: String, file: String, #[serde(default)] contract: String, #[serde(default)] description: String, #[serde(default)] rationale: String, #[serde(default = "default_true")] mutable: bool, #[serde(default)] consumers: Vec, } fn default_true() -> bool { true } #[derive(Deserialize)] struct NclConfigSurface { config_root: String, #[serde(default = "default_config_ncl")] entry_point: String, #[serde(default = "default_ncl_merge")] kind: String, #[serde(default)] contracts_path: String, #[serde(default)] overrides_dir: String, #[serde(default)] sections: Vec, } fn default_config_ncl() -> String { "config.ncl".to_string() } fn default_ncl_merge() -> String { "NclMerge".to_string() } let ncl: NclConfigSurface = match serde_json::from_value(surface_val.clone()) { Ok(v) => v, Err(e) => { warn!(error = %e, "failed to deserialise config_surface from manifest.ncl"); return None; } }; let kind = match ncl.kind.as_str() { "TypeDialog" => ConfigKind::TypeDialog, "SingleFile" => ConfigKind::SingleFile, _ => ConfigKind::NclMerge, }; let config_root = root.join(&ncl.config_root); let overrides_dir = if ncl.overrides_dir.is_empty() { PathBuf::new() } else { root.join(&ncl.overrides_dir) }; let sections = ncl .sections .into_iter() .map(|s| { let consumers = s .consumers .into_iter() .map(|c| { let consumer_kind = match c.kind.as_str() { "NuScript" => ConsumerKind::NuScript, "CiPipeline" => ConsumerKind::CiPipeline, "External" => ConsumerKind::External, _ => ConsumerKind::RustStruct, }; ConfigConsumer { id: c.id, kind: consumer_kind, reference: c.reference, fields: c.fields, } }) .collect(); ConfigSection { id: s.id, file: s.file, contract: s.contract, description: s.description, rationale: s.rationale, mutable: s.mutable, consumers, } }) .collect(); Some(ConfigSurface { config_root, entry_point: ncl.entry_point, kind, contracts_path: ncl.contracts_path, overrides_dir, sections, }) } pub fn make_context(spec: ContextSpec) -> ProjectContext { let sessions_dir = spec.root.join(".ontoref").join("sessions"); let actors = Arc::new(ActorRegistry::new(spec.stale_actor_timeout).with_persist_dir(sessions_dir)); actors.load_persisted(); let ack_dirs = if spec.ack_required.is_empty() { vec![".ontology".into(), "adrs".into()] } else { spec.ack_required }; ProjectContext { slug: spec.slug, root: spec.root, import_path: spec.import_path, cache: Arc::new(NclCache::new()), actors, notifications: Arc::new(NotificationStore::new(spec.max_notifications, ack_dirs)), keys: RwLock::new(spec.keys), remote_url: spec.remote_url, push_only: spec.push_only, seed_lock: Arc::new(Semaphore::new(1)), ontology_version: Arc::new(AtomicU64::new(0)), file_versions: Arc::new(DashMap::new()), config_surface: spec.config_surface, } } #[cfg(test)] mod tests { use std::sync::Arc; use super::*; fn phc_hash(password: &str) -> String { use argon2::password_hash::{rand_core::OsRng, SaltString}; use argon2::{Argon2, PasswordHasher}; let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(password.as_bytes(), &salt) .unwrap() .to_string() } fn admin_entry(password: &str) -> KeyEntry { KeyEntry { role: Role::Admin, hash: phc_hash(password), label: String::new(), } } fn viewer_entry(password: &str) -> KeyEntry { KeyEntry { role: Role::Viewer, hash: phc_hash(password), label: String::new(), } } // ── verify_keys_list ────────────────────────────────────────────────── #[test] fn correct_password_returns_role() { let keys = vec![admin_entry("secret")]; assert_eq!( verify_keys_list(&keys, "secret").map(|m| m.role), Some(Role::Admin) ); } #[test] fn wrong_password_returns_none() { let keys = vec![admin_entry("secret")]; assert!(verify_keys_list(&keys, "wrong").is_none()); } #[test] fn empty_keys_returns_none() { assert!(verify_keys_list(&[], "anything").is_none()); } #[test] fn returns_first_matching_role() { let keys = vec![viewer_entry("pw"), admin_entry("pw")]; // Viewer comes first — that role wins. assert_eq!( verify_keys_list(&keys, "pw").map(|m| m.role), Some(Role::Viewer) ); } #[test] fn invalid_phc_hash_is_skipped_not_panicked() { let keys = vec![ KeyEntry { role: Role::Admin, hash: "not-a-phc-string".to_string(), label: String::new(), }, admin_entry("valid"), ]; // Bad entry skipped; valid entry matches. assert_eq!( verify_keys_list(&keys, "valid").map(|m| m.role), Some(Role::Admin) ); } #[test] fn invalid_phc_only_returns_none() { let keys = vec![KeyEntry { role: Role::Admin, hash: "garbage".to_string(), label: String::new(), }]; assert!(verify_keys_list(&keys, "anything").is_none()); } // ── resolve_import_path ─────────────────────────────────────────────── #[test] fn empty_paths_returns_none() { assert_eq!(resolve_import_path(&[], Path::new("/root")), None); } #[test] fn absolute_path_returned_as_is() { let result = resolve_import_path(&["/abs/path".to_string()], Path::new("/root")); assert_eq!(result, Some("/abs/path".to_string())); } #[test] fn relative_path_joined_with_root() { let result = resolve_import_path(&["lib".to_string()], Path::new("/root")); assert_eq!(result, Some("/root/lib".to_string())); } #[test] fn multiple_paths_colon_separated() { let result = resolve_import_path(&["/abs".to_string(), "rel".to_string()], Path::new("/root")); assert_eq!(result, Some("/abs:/root/rel".to_string())); } // ── with_primary slug collision ─────────────────────────────────────── fn minimal_ctx(slug: &str) -> Arc { Arc::new(make_context(ContextSpec { slug: slug.to_string(), root: PathBuf::new(), import_path: None, keys: vec![], remote_url: String::new(), push_only: true, stale_actor_timeout: 300, max_notifications: 64, ack_required: vec![], config_surface: None, })) } #[test] fn primary_is_accessible_after_construction() { let registry = ProjectRegistry::with_primary("main".to_string(), minimal_ctx("main"), vec![], 300, 64); assert_eq!(registry.primary().slug, "main"); assert_eq!(registry.primary_slug(), "main"); } #[test] fn slug_collision_does_not_overwrite_primary() { let primary = minimal_ctx("shared"); let primary_ptr = Arc::as_ptr(&primary); let colliding = RegistryEntry { slug: "shared".to_string(), root: PathBuf::new(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: true, }; let registry = ProjectRegistry::with_primary("shared".to_string(), primary, vec![colliding], 300, 64); // The primary context pointer must survive — not replaced by the colliding // entry. assert_eq!(Arc::as_ptr(®istry.primary()), primary_ptr); assert_eq!(registry.count(), 1); } #[test] fn additional_non_colliding_entry_added() { let registry = ProjectRegistry::with_primary( "primary".to_string(), minimal_ctx("primary"), vec![RegistryEntry { slug: "secondary".to_string(), root: PathBuf::new(), nickel_import_paths: vec![], keys: vec![], remote_url: String::new(), push_only: true, }], 300, 64, ); assert_eq!(registry.count(), 2); assert!(registry.get("secondary").is_some()); } }