2026-03-13 00:18:14 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
use std::sync::atomic::AtomicU64;
|
|
|
|
|
use std::sync::{Arc, RwLock};
|
2026-03-13 00:18:14 +00:00
|
|
|
|
|
|
|
|
use dashmap::DashMap;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
use tokio::sync::Semaphore;
|
2026-03-13 00:18:14 +00:00
|
|
|
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 <pw>`
|
|
|
|
|
pub hash: String,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// 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<KeyMatch> {
|
|
|
|
|
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
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// Entry as it arrives from the JSON config pipe (ADR-004).
|
2026-03-13 00:18:14 +00:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct RegistryEntry {
|
|
|
|
|
pub slug: String,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// Absolute local path. Empty for push_only projects.
|
|
|
|
|
#[serde(default)]
|
2026-03-13 00:18:14 +00:00
|
|
|
pub root: PathBuf,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// Import paths for nickel export (local projects only).
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub nickel_import_paths: Vec<String>,
|
2026-03-13 00:18:14 +00:00
|
|
|
#[serde(default)]
|
|
|
|
|
pub keys: Vec<KeyEntry>,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// 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,
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Per-project runtime state owned by the registry.
|
|
|
|
|
pub struct ProjectContext {
|
|
|
|
|
pub slug: String,
|
|
|
|
|
pub root: PathBuf,
|
|
|
|
|
pub import_path: Option<String>,
|
|
|
|
|
pub cache: Arc<NclCache>,
|
|
|
|
|
pub actors: Arc<ActorRegistry>,
|
|
|
|
|
pub notifications: Arc<NotificationStore>,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// Mutable at runtime via `PUT /projects/{slug}/keys`.
|
|
|
|
|
pub keys: RwLock<Vec<KeyEntry>>,
|
|
|
|
|
/// 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<Semaphore>,
|
|
|
|
|
/// Incremented by 1 after each successful `seed_ontology` completion.
|
|
|
|
|
/// Clients can compare versions to detect stale local state.
|
|
|
|
|
pub ontology_version: Arc<AtomicU64>,
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
/// 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<DashMap<PathBuf, u64>>,
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProjectContext {
|
|
|
|
|
pub fn auth_enabled(&self) -> bool {
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
!self
|
|
|
|
|
.keys
|
|
|
|
|
.read()
|
|
|
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
|
|
|
.is_empty()
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
pub fn verify_key(&self, password: &str) -> Option<KeyMatch> {
|
|
|
|
|
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<KeyEntry>) -> usize {
|
|
|
|
|
let mut guard = self.keys.write().unwrap_or_else(|e| e.into_inner());
|
|
|
|
|
*guard = new_keys;
|
|
|
|
|
guard.len()
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct ProjectRegistry {
|
|
|
|
|
contexts: DashMap<String, Arc<ProjectContext>>,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// Slug of the primary (local) project — always present in `contexts`.
|
|
|
|
|
primary_slug: String,
|
2026-03-13 00:18:14 +00:00
|
|
|
stale_actor_timeout: u64,
|
|
|
|
|
max_notifications: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProjectRegistry {
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// 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<ProjectContext>)` 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<ProjectContext>,
|
|
|
|
|
additional_entries: Vec<RegistryEntry>,
|
2026-03-13 00:18:14 +00:00
|
|
|
stale_actor_timeout: u64,
|
|
|
|
|
max_notifications: usize,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
) -> Self {
|
2026-03-13 00:18:14 +00:00
|
|
|
let registry = Self {
|
|
|
|
|
contexts: DashMap::new(),
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
primary_slug: primary_slug.clone(),
|
2026-03-13 00:18:14 +00:00
|
|
|
stale_actor_timeout,
|
|
|
|
|
max_notifications,
|
|
|
|
|
};
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
registry.contexts.insert(primary_slug.clone(), primary_ctx);
|
2026-03-13 00:18:14 +00:00
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
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![],
|
|
|
|
|
});
|
|
|
|
|
registry.contexts.insert(entry.slug, Arc::new(ctx));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-03-13 00:18:14 +00:00
|
|
|
|
2026-03-13 01:19:53 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-13 00:18:14 +00:00
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
let import_path = resolve_import_path(&entry.nickel_import_paths, &root);
|
|
|
|
|
let ctx = make_context(ContextSpec {
|
|
|
|
|
slug: entry.slug.clone(),
|
2026-03-13 00:18:14 +00:00
|
|
|
root,
|
|
|
|
|
import_path,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
keys: entry.keys,
|
|
|
|
|
remote_url: entry.remote_url,
|
|
|
|
|
push_only: false,
|
|
|
|
|
stale_actor_timeout,
|
|
|
|
|
max_notifications,
|
|
|
|
|
ack_required: vec![],
|
|
|
|
|
});
|
|
|
|
|
registry.contexts.insert(entry.slug, Arc::new(ctx));
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
|
|
|
|
|
registry
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// 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<ProjectContext> {
|
|
|
|
|
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).
|
2026-03-13 00:18:14 +00:00
|
|
|
pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> {
|
|
|
|
|
if self.contexts.contains_key(&entry.slug) {
|
|
|
|
|
anyhow::bail!("project '{}' already exists", entry.slug);
|
|
|
|
|
}
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
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 ctx = make_context(ContextSpec {
|
|
|
|
|
slug: entry.slug.clone(),
|
2026-03-13 00:18:14 +00:00
|
|
|
root,
|
|
|
|
|
import_path,
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
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![],
|
|
|
|
|
});
|
2026-03-13 00:18:14 +00:00
|
|
|
self.contexts.insert(entry.slug, Arc::new(ctx));
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
Ok(())
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// Remove a project by slug.
|
|
|
|
|
pub fn remove_project(&self, slug: &str) {
|
2026-03-13 00:18:14 +00:00
|
|
|
self.contexts.remove(slug);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get(&self, slug: &str) -> Option<Arc<ProjectContext>> {
|
|
|
|
|
self.contexts.get(slug).map(|r| Arc::clone(&*r))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn all(&self) -> Vec<Arc<ProjectContext>> {
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// 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<KeyEntry>) -> Option<usize> {
|
|
|
|
|
self.contexts.get(slug).map(|ctx| ctx.set_keys(keys))
|
2026-03-13 00:18:14 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
/// 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<String> {
|
2026-03-13 00:18:14 +00:00
|
|
|
let joined = paths
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| {
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
let candidate = Path::new(p);
|
2026-03-13 00:18:14 +00:00
|
|
|
if candidate.is_absolute() {
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
p.clone()
|
2026-03-13 00:18:14 +00:00
|
|
|
} else {
|
|
|
|
|
root.join(candidate).display().to_string()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join(":");
|
|
|
|
|
if joined.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(joined)
|
|
|
|
|
}
|
|
|
|
|
}
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
|
|
|
|
|
pub struct ContextSpec {
|
|
|
|
|
pub slug: String,
|
|
|
|
|
pub root: PathBuf,
|
|
|
|
|
pub import_path: Option<String>,
|
|
|
|
|
pub keys: Vec<KeyEntry>,
|
|
|
|
|
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<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)),
|
---
feat: API catalog surface, protocol v2 tooling, MCP expansion, on+re update
## Summary
Session 2026-03-23. Closes the loop between handler code and discoverability
across all three surfaces (browser, CLI, MCP agent) via compile-time inventory
registration. Adds protocol v2 update tooling, extends MCP from 21 to 29 tools,
and brings the self-description up to date.
## API Catalog Surface (#[onto_api] proc-macro)
- crates/ontoref-derive: new proc-macro crate; `#[onto_api(method, path,
description, auth, actors, params, tags)]` emits `inventory::submit!(ApiRouteEntry{...})`
at link time
- crates/ontoref-daemon/src/api_catalog.rs: `catalog()` — pure fn over
`inventory::iter::<ApiRouteEntry>()`, zero runtime allocation
- GET /api/catalog: returns full annotated HTTP surface as JSON
- templates/pages/api_catalog.html: new page with client-side filtering by
method, auth, path/description; detail panel per route (params table,
feature flag); linked from dashboard card and nav
- UI nav: "API" link (</> icon) added to mobile dropdown and desktop bar
- inventory = "0.3" added to workspace.dependencies (MIT, zero transitive deps)
## Protocol Update Mode
- reflection/modes/update_ontoref.ncl: 9-step DAG (5 detect parallel, 2 update
idempotent, 2 validate, 1 report) — brings any project from protocol v1 to v2
by adding manifest.ncl and connections.ncl if absent, scanning ADRs for
deprecated check_hint, validating with nickel export
- reflection/templates/update-ontology-prompt.md: 8-phase reusable prompt for
agent-driven ontology enrichment (infrastructure → audit → core.ncl →
state.ncl → manifest.ncl → connections.ncl → ADR migration → validation)
## CLI — describe group extensions
- reflection/bin/ontoref.nu: `describe diff [--fmt] [--file]` and
`describe api [--actor] [--tag] [--auth] [--fmt]` registered as canonical
subcommands with log-action; aliases `df` and `da` added; QUICK REFERENCE
and ALIASES sections updated
## MCP — two new tools (21 → 29 total)
- ontoref_api_catalog: filters catalog() output by actor/tag/auth; returns
{ routes, total } — no HTTP roundtrip, calls inventory directly
- ontoref_file_versions: reads ProjectContext.file_versions DashMap per slug;
returns BTreeMap<filename, u64> reload counters
- insert_mcp_ctx: audited and updated from 15 to 28 entries in 6 groups
- HelpTool JSON: 8 new entries (validate_adrs, validate, impact, guides,
bookmark_list, bookmark_add, api_catalog, file_versions)
- ServerHandler::get_info instructions updated to mention new tools
## Web UI — dashboard additions
- Dashboard: "API Catalog" card (9th); "Ontology File Versions" section showing
per-file reload counters from file_versions DashMap
- dashboard_mp: builds BTreeMap<String, u64> from ctx.file_versions and injects
into Tera context
## on+re update
- .ontology/core.ncl: describe-query-layer and adopt-ontoref-tooling descriptions
updated; ontoref-daemon updated ("11 pages", "29 tools", API catalog,
per-file versioning, #[onto_api]); new node api-catalog-surface (Yang/Practice)
with 3 edges; artifact_paths extended across 3 nodes
- .ontology/state.ncl: protocol-maturity blocker updated (protocol v2 complete);
self-description-coverage catalyst updated with session 2026-03-23 additions
- ADR-007: "API Surface Discoverability via #[onto_api] Proc-Macro" — Accepted
## Documentation
- README.md: crates table updated (11 pages, 29 MCP tools, ontoref-derive row);
MCP representative table expanded; API Catalog, Semantic Diff, Per-File
Versioning paragraphs added; update_ontoref onboarding section added
- CHANGELOG.md: [Unreleased] section with 4 change groups
- assets/web/src/index.html: tool counts 19→29 (EN+ES), page counts 12→11
(EN+ES), daemon description paragraph updated with API catalog + #[onto_api]
2026-03-23 00:58:27 +01:00
|
|
|
file_versions: Arc::new(DashMap::new()),
|
feat: unified auth model, project onboarding, install pipeline, config management
The full scope across this batch: POST /sessions key→token exchange, SessionStore dual-index with revoke_by_id, CLI Bearer injection (ONTOREF_TOKEN), ontoref setup
--gen-keys, install scripts, daemon config form roundtrip, ADR-004/005, on+re self-description update (fully-self-described), and landing page refresh.
2026-03-13 20:56:31 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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<ProjectContext> {
|
|
|
|
|
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![],
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
}
|