Skip to main content

platform_admin_data/
lib.rs

1//! Schema-admin data API: generic endpoints that render any module's declared
2//! admin entities and invoke manifest-declared admin actions. Depends on NO
3//! concrete module crate: it works only through injected platform-module seams,
4//! mirroring `platform-admin`'s seam-only discipline.
5
6use platform_core::{AppError, ErrorCode, RequestContext, StoryDisplayDescriptor};
7use platform_http::{ApiErrorResponse, ApiOpenApiRouter, OpenApiRouter, routes};
8use platform_module::{
9    AdminActionSource, AdminDataSource, AdminQuerySource, AdminSchema, AdminSurface,
10    ConsoleSurface, EventSurface, LifecycleSurface, ModuleHttpRoute, ModuleLoadStatus,
11    ModuleSource, RuntimeSurface,
12};
13use std::sync::{Arc, OnceLock, RwLock};
14use std::time::Instant;
15
16mod dto;
17mod handlers;
18
19pub use dto::*;
20#[allow(clippy::wildcard_imports)]
21use handlers::*;
22
23/// One module's admin capability: its declared surface plus live data/action
24/// sources.
25#[derive(Clone, Debug)]
26pub struct AdminModule {
27    /// The owning module's stable name, e.g. "identity".
28    pub module_name: String,
29    /// The loading source that produced this module.
30    pub source: ModuleSource,
31    /// Current load state. The first remote slice only installs loaded modules;
32    /// error entries are reserved for degraded loading in a later slice.
33    pub load_status: ModuleLoadStatus,
34    /// The module's schema-admin surface or custom surface fallback schema.
35    pub schema: AdminSchema,
36    /// The full declared admin surface, retained for action validation.
37    pub admin: Option<AdminSurface>,
38    /// Whether this module should appear in the generic schema-admin discovery
39    /// endpoint. Declarative custom surfaces may still be readable through
40    /// fallback schema entities without being advertised as plain schema-admin.
41    pub listed_in_schema: bool,
42    /// Live read access to the module's records. Missing for degraded modules
43    /// whose manifest/data source failed to load.
44    pub data_source: Option<Arc<dyn AdminDataSource>>,
45    /// Live behavior for manifest-declared admin actions.
46    pub action_source: Option<Arc<dyn AdminActionSource>>,
47    /// Live behavior for manifest-declared read-only queries.
48    pub query_source: Option<Arc<dyn AdminQuerySource>>,
49}
50
51/// One module's registry metadata, independent of whether schema-admin
52/// list/detail reads or an admin surface are available.
53#[derive(Clone, Debug)]
54pub struct AdminModuleMetadata {
55    /// The owning module's stable name, e.g. "identity".
56    pub module_name: String,
57    /// The loading source that produced this module.
58    pub source: ModuleSource,
59    /// Current load state.
60    pub load_status: ModuleLoadStatus,
61    /// Declared module-owned HTTP routes. Metadata only; not mounted by
62    /// platform-admin-data.
63    pub http_routes: Vec<ModuleHttpRoute>,
64    /// Declared runtime functions. Metadata only; runtime registration belongs
65    /// to the source-specific binding and worker composition.
66    pub runtime: Option<RuntimeSurface>,
67    /// Declared event handlers. Metadata only; event dispatch registration
68    /// belongs to the source-specific binding and worker composition.
69    pub events: Option<EventSurface>,
70    /// Declared lifecycle checks and activation jobs. Metadata only; worker startup
71    /// owns validation and enqueueing.
72    pub lifecycle: Option<LifecycleSurface>,
73    /// Declared trusted Runtime Console frontend surfaces.
74    pub console: Vec<ConsoleSurface>,
75    /// Declared story-display mappings for runtime story titles and node
76    /// labels.
77    pub story_display: Vec<StoryDisplayDescriptor>,
78    /// Declared capability strings owned by the module.
79    pub capabilities: Vec<String>,
80    /// Declared module dependencies.
81    pub dependencies: Vec<String>,
82    /// The declared admin surface. Missing for modules with no admin surface
83    /// and degraded failed remotes whose manifest could not be loaded.
84    pub admin: Option<AdminSurface>,
85    /// Source-level diagnostics known to the host. Remote modules include
86    /// endpoint and load metadata; linked modules usually leave this empty.
87    pub source_diagnostics: Option<AdminModuleSourceDiagnostics>,
88}
89
90#[derive(Clone, Debug)]
91pub enum AdminModuleSourceDiagnostics {
92    Remote(AdminRemoteModuleDiagnostics),
93}
94
95#[derive(Clone, Debug)]
96pub struct AdminRemoteModuleDiagnostics {
97    pub transport: String,
98    pub base_url: String,
99    pub manifest_url: String,
100    pub timeout_ms: u64,
101    pub auth_configured: bool,
102    pub load_duration_ms: Option<u64>,
103    pub last_checked_at: Option<String>,
104    pub last_load_error: Option<String>,
105}
106
107#[derive(Clone, Debug, Default)]
108struct AdminModuleMetadataSnapshot {
109    modules: Vec<AdminModuleMetadata>,
110    refreshed_at: Option<String>,
111    refresh_error: Option<String>,
112    refresh_history: Vec<AdminModuleMetadataRefreshRecord>,
113}
114
115#[derive(Clone, Debug)]
116pub struct AdminModuleMetadataRefreshRecord {
117    pub id: String,
118    pub status: AdminModuleMetadataRefreshStatus,
119    pub started_at: String,
120    pub completed_at: String,
121    pub duration_ms: u64,
122    pub module_count: usize,
123    pub error: Option<String>,
124    pub module_results: Vec<AdminModuleMetadataRefreshModuleResult>,
125}
126
127#[derive(Clone, Debug)]
128pub struct AdminModuleMetadataRefreshModuleResult {
129    pub module_name: String,
130    pub source: ModuleSource,
131    pub status: AdminModuleMetadataRefreshModuleStatus,
132    pub duration_ms: Option<u64>,
133    pub endpoint: Option<String>,
134    pub error: Option<String>,
135}
136
137#[derive(Clone, Copy, Debug)]
138pub enum AdminModuleMetadataRefreshModuleStatus {
139    Loaded,
140    Error,
141}
142
143#[derive(Clone, Copy, Debug)]
144pub enum AdminModuleMetadataRefreshStatus {
145    Success,
146    Error,
147}
148
149static ADMIN_REGISTRY: OnceLock<RwLock<Vec<AdminModule>>> = OnceLock::new();
150static ADMIN_METADATA_REGISTRY: OnceLock<RwLock<AdminModuleMetadataSnapshot>> = OnceLock::new();
151static ADMIN_REFRESHER: OnceLock<RwLock<Option<Arc<dyn AdminModuleRefresher>>>> = OnceLock::new();
152static ADMIN_METADATA_REFRESHER: OnceLock<RwLock<Option<Arc<dyn AdminModuleMetadataRefresher>>>> =
153    OnceLock::new();
154
155#[async_trait::async_trait]
156pub trait AdminModuleRefresher: Send + Sync {
157    async fn refresh_admin_modules(&self) -> platform_core::AppResult<Vec<AdminModule>>;
158}
159
160#[async_trait::async_trait]
161pub trait AdminModuleMetadataRefresher: Send + Sync {
162    async fn refresh_admin_module_metadata(
163        &self,
164    ) -> platform_core::AppResult<Vec<AdminModuleMetadata>>;
165}
166
167struct StaticAdminModuleRefresher<F>(F);
168struct StaticAdminModuleMetadataRefresher<F>(F);
169
170#[async_trait::async_trait]
171impl<F, Fut> AdminModuleRefresher for StaticAdminModuleRefresher<F>
172where
173    F: Fn() -> Fut + Send + Sync,
174    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModule>>> + Send,
175{
176    async fn refresh_admin_modules(&self) -> platform_core::AppResult<Vec<AdminModule>> {
177        (self.0)().await
178    }
179}
180
181#[async_trait::async_trait]
182impl<F, Fut> AdminModuleMetadataRefresher for StaticAdminModuleMetadataRefresher<F>
183where
184    F: Fn() -> Fut + Send + Sync,
185    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModuleMetadata>>> + Send,
186{
187    async fn refresh_admin_module_metadata(
188        &self,
189    ) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
190        (self.0)().await
191    }
192}
193
194/// Install the admin-capable module registry. Called once by the composition
195/// root before the router serves traffic. Later calls replace the registry,
196/// which keeps tests isolated and leaves room for explicit refresh later.
197pub fn install_admin_modules(modules: Vec<AdminModule>) {
198    let registry = ADMIN_REGISTRY.get_or_init(|| RwLock::new(Vec::new()));
199    *registry.write().expect("admin registry lock poisoned") = modules;
200}
201
202/// Install the metadata registry for every module.
203pub fn install_admin_module_metadata(modules: Vec<AdminModuleMetadata>) {
204    let registry =
205        ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
206    *registry
207        .write()
208        .expect("admin metadata registry lock poisoned") = AdminModuleMetadataSnapshot {
209        modules,
210        refreshed_at: Some(current_timestamp()),
211        refresh_error: None,
212        refresh_history: Vec::new(),
213    };
214}
215
216pub(crate) fn record_admin_module_metadata_refresh_success(
217    modules: Vec<AdminModuleMetadata>,
218    started_at: String,
219    started: Instant,
220) -> AdminModuleMetadataSnapshot {
221    let registry =
222        ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
223    let mut snapshot = registry
224        .write()
225        .expect("admin metadata registry lock poisoned");
226    let completed_at = current_timestamp();
227    let record = AdminModuleMetadataRefreshRecord {
228        id: format!(
229            "module_refresh_{}",
230            completed_at.replace([':', '.', '+'], "_")
231        ),
232        status: AdminModuleMetadataRefreshStatus::Success,
233        started_at,
234        completed_at: completed_at.clone(),
235        duration_ms: duration_ms(started),
236        module_count: modules.len(),
237        error: None,
238        module_results: refresh_module_results(&modules),
239    };
240    snapshot.modules = modules;
241    snapshot.refreshed_at = Some(completed_at);
242    snapshot.refresh_error = None;
243    push_refresh_record(&mut snapshot.refresh_history, record);
244    snapshot.clone()
245}
246
247/// Install the callback used by the explicit admin refresh endpoint.
248///
249/// Kept as an injected seam so this platform crate does not depend on the
250/// composition root that knows how to load linked and remote modules.
251pub fn install_admin_module_refresher(refresher: Arc<dyn AdminModuleRefresher>) {
252    let registry = ADMIN_REFRESHER.get_or_init(|| RwLock::new(None));
253    *registry.write().expect("admin refresher lock poisoned") = Some(refresher);
254}
255
256pub fn install_admin_module_refresh_fn<F, Fut>(refresh: F)
257where
258    F: Fn() -> Fut + Send + Sync + 'static,
259    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModule>>> + Send + 'static,
260{
261    install_admin_module_refresher(Arc::new(StaticAdminModuleRefresher(refresh)));
262}
263
264/// Install the callback used to refresh module registry metadata.
265pub fn install_admin_module_metadata_refresher(refresher: Arc<dyn AdminModuleMetadataRefresher>) {
266    let registry = ADMIN_METADATA_REFRESHER.get_or_init(|| RwLock::new(None));
267    *registry
268        .write()
269        .expect("admin metadata refresher lock poisoned") = Some(refresher);
270}
271
272pub fn install_admin_module_metadata_refresh_fn<F, Fut>(refresh: F)
273where
274    F: Fn() -> Fut + Send + Sync + 'static,
275    Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModuleMetadata>>>
276        + Send
277        + 'static,
278{
279    install_admin_module_metadata_refresher(Arc::new(StaticAdminModuleMetadataRefresher(refresh)));
280}
281
282fn admin_modules() -> Vec<AdminModule> {
283    ADMIN_REGISTRY
284        .get()
285        .map(|registry| {
286            registry
287                .read()
288                .expect("admin registry lock poisoned")
289                .clone()
290        })
291        .unwrap_or_default()
292}
293
294fn admin_module_metadata_snapshot() -> AdminModuleMetadataSnapshot {
295    ADMIN_METADATA_REGISTRY
296        .get()
297        .map(|registry| {
298            registry
299                .read()
300                .expect("admin metadata registry lock poisoned")
301                .clone()
302        })
303        .unwrap_or_default()
304}
305
306pub(crate) fn record_admin_module_metadata_refresh_error(
307    error: String,
308    started_at: String,
309    started: Instant,
310) -> AdminModuleMetadataSnapshot {
311    let registry =
312        ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
313    let mut snapshot = registry
314        .write()
315        .expect("admin metadata registry lock poisoned");
316    snapshot.refresh_error = Some(error);
317    let completed_at = current_timestamp();
318    let record = AdminModuleMetadataRefreshRecord {
319        id: format!(
320            "module_refresh_{}",
321            completed_at.replace([':', '.', '+'], "_")
322        ),
323        status: AdminModuleMetadataRefreshStatus::Error,
324        started_at,
325        completed_at,
326        duration_ms: duration_ms(started),
327        module_count: snapshot.modules.len(),
328        error: snapshot.refresh_error.clone(),
329        module_results: Vec::new(),
330    };
331    push_refresh_record(&mut snapshot.refresh_history, record);
332    snapshot.clone()
333}
334
335pub(crate) fn current_timestamp() -> String {
336    use platform_core::Clock;
337    platform_core::SystemClock.now().to_rfc3339()
338}
339
340fn duration_ms(started: Instant) -> u64 {
341    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
342}
343
344fn push_refresh_record(
345    history: &mut Vec<AdminModuleMetadataRefreshRecord>,
346    record: AdminModuleMetadataRefreshRecord,
347) {
348    history.insert(0, record);
349    history.truncate(10);
350}
351
352fn refresh_module_results(
353    modules: &[AdminModuleMetadata],
354) -> Vec<AdminModuleMetadataRefreshModuleResult> {
355    modules
356        .iter()
357        .map(|module| {
358            let remote = match &module.source_diagnostics {
359                Some(AdminModuleSourceDiagnostics::Remote(remote)) => Some(remote),
360                None => None,
361            };
362            AdminModuleMetadataRefreshModuleResult {
363                module_name: module.module_name.clone(),
364                source: module.source,
365                status: match module.load_status {
366                    ModuleLoadStatus::Loaded => AdminModuleMetadataRefreshModuleStatus::Loaded,
367                    ModuleLoadStatus::Error { .. } => AdminModuleMetadataRefreshModuleStatus::Error,
368                },
369                duration_ms: remote.and_then(|diagnostics| diagnostics.load_duration_ms),
370                endpoint: remote.map(|diagnostics| diagnostics.base_url.clone()),
371                error: match &module.load_status {
372                    ModuleLoadStatus::Loaded => {
373                        remote.and_then(|diagnostics| diagnostics.last_load_error.clone())
374                    }
375                    ModuleLoadStatus::Error { message } => Some(message.clone()),
376                },
377            }
378        })
379        .collect()
380}
381
382fn admin_refresher() -> Option<Arc<dyn AdminModuleRefresher>> {
383    ADMIN_REFRESHER.get().and_then(|registry| {
384        registry
385            .read()
386            .expect("admin refresher lock poisoned")
387            .clone()
388    })
389}
390
391fn admin_metadata_refresher() -> Option<Arc<dyn AdminModuleMetadataRefresher>> {
392    ADMIN_METADATA_REFRESHER.get().and_then(|registry| {
393        registry
394            .read()
395            .expect("admin metadata refresher lock poisoned")
396            .clone()
397    })
398}
399
400fn find_module(module: &str, ctx: &RequestContext) -> Result<AdminModule, ApiErrorResponse> {
401    admin_modules()
402        .into_iter()
403        .find(|m| m.module_name == module)
404        .ok_or_else(|| {
405            ApiErrorResponse::with_context(
406                AppError::new(ErrorCode::NotFound, format!("unknown module: {module}")),
407                ctx,
408            )
409        })
410}
411
412fn find_loaded_module(module: &str, ctx: &RequestContext) -> Result<AdminModule, ApiErrorResponse> {
413    let admin_module = find_module(module, ctx)?;
414    if admin_module.data_source.is_some() {
415        Ok(admin_module)
416    } else {
417        Err(ApiErrorResponse::with_context(
418            AppError::new(
419                ErrorCode::ExternalDependency,
420                format!("module {module} is not loaded"),
421            )
422            .retryable(),
423            ctx,
424        ))
425    }
426}
427
428fn find_loaded_action_module(
429    module: &str,
430    ctx: &RequestContext,
431) -> Result<AdminModule, ApiErrorResponse> {
432    let admin_module = find_module(module, ctx)?;
433    if matches!(admin_module.load_status, ModuleLoadStatus::Loaded) {
434        Ok(admin_module)
435    } else {
436        Err(ApiErrorResponse::with_context(
437            AppError::new(
438                ErrorCode::ExternalDependency,
439                format!("module {module} is not loaded"),
440            )
441            .retryable(),
442            ctx,
443        ))
444    }
445}
446
447fn find_loaded_query_module(
448    module: &str,
449    ctx: &RequestContext,
450) -> Result<AdminModule, ApiErrorResponse> {
451    let admin_module = find_module(module, ctx)?;
452    if matches!(admin_module.load_status, ModuleLoadStatus::Loaded) {
453        Ok(admin_module)
454    } else {
455        Err(ApiErrorResponse::with_context(
456            AppError::new(
457                ErrorCode::ExternalDependency,
458                format!("module {module} is not loaded"),
459            )
460            .retryable(),
461            ctx,
462        ))
463    }
464}
465
466/// The schema-admin router, mounted by the API app.
467pub fn router() -> ApiOpenApiRouter {
468    OpenApiRouter::new()
469        .routes(routes!(list_modules))
470        .routes(routes!(refresh_modules))
471        .routes(routes!(available_modules))
472        .routes(routes!(install_available_module))
473        .routes(routes!(uninstall_available_module))
474        .routes(routes!(module_registry_snapshot))
475        .routes(routes!(list_schemas))
476        .routes(routes!(refresh_schemas))
477        .routes(routes!(invoke_action))
478        .routes(routes!(query_value))
479        .routes(routes!(list_records))
480        .routes(routes!(get_record))
481}