Skip to main content

heldar_kernel/routes/
registry.rs

1//! Plugin store registry routes (Phase C).
2//!
3//! `GET /api/v1/registry` serves the merged store catalog (bundled + verified remote, cross-referenced
4//! with installed/compiled state) — readable by any principal, offline-safe, no network. `POST
5//! /api/v1/registry/refresh` re-fetches the remote registries (admin, audited, network). Installing an
6//! entry reuses the Phase B `POST /api/v1/modules` register path; this module only does discovery.
7
8use axum::extract::State;
9use axum::routing::{get, post};
10use axum::{Json, Router};
11use serde_json::json;
12
13use crate::auth::{self, Principal};
14use crate::error::AppResult;
15use crate::registry::RegistryView;
16use crate::services;
17use crate::state::AppState;
18
19pub fn router() -> Router<AppState> {
20    Router::new()
21        .route("/api/v1/registry", get(list))
22        .route("/api/v1/registry/refresh", post(refresh))
23}
24
25/// The merged store view: bundled + remote catalogs, each entry cross-referenced for installed state.
26async fn list(State(st): State<AppState>, principal: Principal) -> AppResult<Json<RegistryView>> {
27    principal.require(principal.can_view(), "view the plugin store")?;
28    let registrations = services::modules::list_registered(&st.pool).await?;
29    Ok(Json(st.catalog.view(&st.modules, &registrations)))
30}
31
32/// Re-fetch the remote registries now (admin; performs outbound requests). Returns the refreshed view.
33async fn refresh(
34    State(st): State<AppState>,
35    principal: Principal,
36) -> AppResult<Json<RegistryView>> {
37    principal.require(principal.can_admin(), "refresh the plugin registry")?;
38    st.catalog.refresh().await;
39    auth::audit(
40        &st.pool,
41        &principal,
42        "refresh_registry",
43        "registry",
44        "remote",
45        json!({}),
46    )
47    .await;
48    let registrations = services::modules::list_registered(&st.pool).await?;
49    Ok(Json(st.catalog.view(&st.modules, &registrations)))
50}
51
52#[cfg(test)]
53mod tests {
54    use crate::config::Config;
55    use crate::modules::{ModuleKind, ModuleManifest, NavEntry};
56    use crate::services::recorder::RecorderManager;
57    use crate::services::registry::CatalogService;
58    use crate::services::sampler::SamplerManager;
59    use crate::state::AppState;
60    use axum::body::Body;
61    use axum::http::Request;
62    use std::sync::Arc;
63    use tower::Service;
64
65    async fn state_with(modules: Vec<ModuleManifest>) -> AppState {
66        let pool = sqlx::sqlite::SqlitePoolOptions::new()
67            .max_connections(1)
68            .connect("sqlite::memory:")
69            .await
70            .unwrap();
71        crate::db::run_migrations(&pool).await.unwrap();
72        let mut cfg = Config::from_env();
73        cfg.auth_enabled = false;
74        let cfg = Arc::new(cfg);
75        AppState {
76            recorder: RecorderManager::new(pool.clone(), cfg.clone()),
77            sampler: SamplerManager::new(pool.clone(), cfg.clone()),
78            mirror: None,
79            consumers: Arc::new(Vec::new()),
80            modules: Arc::new(modules),
81            catalog: Arc::new(CatalogService::new(&cfg)),
82            http: reqwest::Client::new(),
83            started_at: chrono::Utc::now(),
84            pool,
85            cfg,
86        }
87    }
88
89    /// GET /api/v1/registry serves the bundled catalog, cross-referenced: a compiled module shows
90    /// `included`, an uninstalled sidecar shows `available`, and bundled entries are `verified`.
91    #[tokio::test]
92    async fn serves_bundled_catalog_cross_referenced() {
93        let entry = ModuleManifest::new(
94            "entry",
95            "Access Control",
96            "0.0.0",
97            "Heldar",
98            ModuleKind::Core,
99            "d",
100            vec![NavEntry::new("/entry", "Entry", "entry")],
101        );
102        let st = state_with(vec![entry]).await;
103        let mut app = super::router().with_state(st);
104        let res = app
105            .call(
106                Request::builder()
107                    .uri("/api/v1/registry")
108                    .body(Body::empty())
109                    .unwrap(),
110            )
111            .await
112            .unwrap();
113        assert_eq!(res.status(), 200);
114        let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
115            .await
116            .unwrap();
117        let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
118
119        let entries = json["entries"].as_array().unwrap();
120        let find = |id: &str| entries.iter().find(|e| e["id"] == id).cloned().unwrap();
121
122        let entry_v = find("entry");
123        assert_eq!(entry_v["shelf"], "core");
124        assert_eq!(entry_v["state"], "included"); // compiled in
125        assert_eq!(entry_v["verified"], true); // bundled = trusted by construction
126        assert_eq!(entry_v["source"], "bundled");
127
128        // movement is in the bundled catalog but NOT compiled into this minimal build.
129        assert_eq!(find("movement")["state"], "not_in_build");
130
131        // the reference sidecar is community + available (not installed).
132        let hello = find("hello-module");
133        assert_eq!(hello["shelf"], "community");
134        assert_eq!(hello["state"], "available");
135
136        // the bundled source is verified + first-party.
137        let bundled = json["sources"]
138            .as_array()
139            .unwrap()
140            .iter()
141            .find(|s| s["source"] == "bundled")
142            .unwrap();
143        assert_eq!(bundled["verified"], true);
144        assert_eq!(bundled["first_party"], true);
145    }
146}