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