Skip to main content

lenso_bootstrap/
lib.rs

1//! Composition root: the single place that knows which modules exist.
2//!
3//! Both the API and the worker assemble their module wiring from this crate, so
4//! a module is registered here once rather than in scattered per-app edits.
5//!
6//! A module's contributions are split by how they are consumed:
7//! - [`modules`]: context-bound bindings (runtime functions + event handlers)
8//!   and runtime config (API + worker), demo-default for context-local callers.
9//! - [`modules_for_config`] / [`load_modules`]: config-aware module loaders that
10//!   honor profile entry lists and configured remote sources for runtime apps.
11//! - [`module_manifests`]: context-free manifest data (no [`AppContext`]) for
12//!   read-only / `OpenAPI` paths, with profile-aware variants for runtime use.
13//! - [`merge_linked_http`]: context-free HTTP routes and their OpenAPI docs
14//!   (API only), assembled without a live [`AppContext`].
15//! - [`story_display_descriptors`]: console display metadata, sourced from the
16//!   context-free [`module_manifests`].
17//!
18//! When adding a module, register it in the appropriate profile entry lists and
19//! expose its config-aware loader contributions from this crate.
20
21use platform_admin_data::{
22    AdminModule, AdminModuleMetadata, AdminModuleSourceDiagnostics, AdminRemoteModuleDiagnostics,
23};
24use platform_core::error::ErrorDetail;
25use platform_core::{
26    ActorContext, AppContext, AppError, CorrelationId, ErrorCode, EventHandlerRegistry, Migration,
27    PLATFORM_MIGRATIONS, RuntimeConfigDescriptor, RuntimeConfigGroupDescriptor, RuntimeConfigScope,
28    RuntimeConfigType, StoryDisplayDescriptor, StoryDisplaySource, TraceContext,
29};
30use platform_http::ApiOpenApiRouter;
31pub use platform_module::HostLinkedModule;
32use platform_module::{
33    AdminSchema, AdminSurface, EventHandlerRegistrationContext, LifecycleActivationRunPolicy,
34    LifecycleStartupCheckKind, LinkedBinding, Module, ModuleHttpMethod, ModuleLoadStatus,
35    ModuleManifest, ModuleSource,
36};
37use platform_module_remote::{RemoteHttpProxyRegistry, RemoteModuleConfig, RemoteModuleSource};
38use platform_runtime::{
39    EnqueueFunctionRequest, FunctionRegistry, RUNTIME_MIGRATIONS, RuntimeClient,
40};
41use std::fs::{self, OpenOptions};
42use std::io::Write as _;
43use std::path::{Path, PathBuf};
44use std::process::{Child, Command};
45use std::sync::Arc;
46use std::thread;
47use std::time::{Duration, Instant};
48
49const DEFAULT_MODULE_SERVICES_FILE: &str = ".lenso/module-services.json";
50const DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS: u64 = 10_000;
51const REMOTE_SERVICE_TERMINATE_GRACE_MS: u64 = 800;
52const AUTH_SESSION_CACHE_MAX_TTL: Duration = Duration::from_secs(12 * 60 * 60);
53
54struct LinkedModuleEntry {
55    module_name: &'static str,
56    manifest: fn() -> ModuleManifest,
57    load: fn(&AppContext) -> Module,
58    http_binding: Option<fn() -> LinkedBinding>,
59}
60
61const MODULES_CONFIG_GROUP: RuntimeConfigGroupDescriptor = RuntimeConfigGroupDescriptor {
62    id: "modules",
63    label: "Modules",
64    description: "Module load toggles applied on service startup.",
65    order: 10,
66};
67
68#[derive(Debug, Clone, Default)]
69pub struct HostComposition {
70    linked_modules: Vec<HostLinkedModule>,
71}
72
73impl HostComposition {
74    #[must_use]
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    #[must_use]
80    pub fn with_linked_module(mut self, module: HostLinkedModule) -> Self {
81        self.add_linked_module(module);
82        self
83    }
84
85    pub fn add_linked_module(&mut self, module: HostLinkedModule) {
86        self.linked_modules.push(module);
87    }
88
89    #[must_use]
90    pub fn linked_modules(&self) -> &[HostLinkedModule] {
91        &self.linked_modules
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct HostWiring {
97    auth_session_policy: auth::session_policy::AuthSessionPolicyHandle,
98}
99
100impl HostWiring {
101    #[must_use]
102    pub fn auth_session_policy(&self) -> auth::session_policy::AuthSessionPolicyHandle {
103        self.auth_session_policy.clone()
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum CompositionProfile {
109    Core,
110    Demo,
111}
112
113impl CompositionProfile {
114    pub fn parse(value: &str) -> platform_core::AppResult<Self> {
115        match value.trim().to_ascii_lowercase().as_str() {
116            "core" => Ok(Self::Core),
117            "demo" => Ok(Self::Demo),
118            other => Err(AppError::validation(
119                "Invalid Lenso composition profile",
120                vec![ErrorDetail {
121                    field: Some("module_sources.linked_profile".to_owned()),
122                    reason: format!("expected `core` or `demo`, got `{other}`"),
123                }],
124            )),
125        }
126    }
127
128    pub fn from_config(config: &platform_core::AppConfig) -> platform_core::AppResult<Self> {
129        Self::parse(&config.module_sources.linked_profile)
130    }
131}
132
133impl Default for CompositionProfile {
134    fn default() -> Self {
135        Self::Demo
136    }
137}
138
139const CORE_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[LinkedModuleEntry {
140    module_name: "platform-story",
141    manifest: story::module::manifest,
142    load: story::module::module,
143    http_binding: Some(story::module::binding),
144}];
145
146const DEMO_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[
147    LinkedModuleEntry {
148        module_name: "auth",
149        manifest: auth::module::manifest,
150        load: auth::module::module,
151        http_binding: Some(auth::module::binding),
152    },
153    LinkedModuleEntry {
154        module_name: "auth-password",
155        manifest: auth_password::module::manifest,
156        load: auth_password::module::module,
157        http_binding: Some(auth_password::module::binding),
158    },
159    LinkedModuleEntry {
160        module_name: "platform-story",
161        manifest: story::module::manifest,
162        load: story::module::module,
163        http_binding: Some(story::module::binding),
164    },
165];
166
167fn linked_module_entries(profile: CompositionProfile) -> &'static [LinkedModuleEntry] {
168    match profile {
169        CompositionProfile::Core => CORE_LINKED_MODULE_ENTRIES,
170        CompositionProfile::Demo => DEMO_LINKED_MODULE_ENTRIES,
171    }
172}
173
174#[must_use]
175pub fn auth_linked_module() -> HostLinkedModule {
176    HostLinkedModule::linked(
177        auth::module::MODULE_NAME,
178        auth::module::manifest,
179        auth::module::module,
180        auth::migrations::AUTH_MIGRATIONS,
181    )
182    .with_http_binding(auth::module::binding)
183}
184
185#[must_use]
186pub fn auth_password_linked_module() -> HostLinkedModule {
187    HostLinkedModule::linked(
188        auth_password::module::MODULE_NAME,
189        auth_password::module::manifest,
190        auth_password::module::module,
191        auth_password::migrations::AUTH_PASSWORD_MIGRATIONS,
192    )
193    .with_http_binding(auth_password::module::binding)
194}
195
196fn linked_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
197    config
198        .modules
199        .get(module_name)
200        .is_none_or(platform_core::ModuleConfig::is_enabled)
201}
202
203fn module_enabled_config_key(module_name: &str) -> String {
204    format!("modules.{module_name}.enabled")
205}
206
207fn linked_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
208    ctx.runtime_config
209        .snapshot()
210        .raw(&module_enabled_config_key(module_name))
211        .and_then(serde_json::Value::as_bool)
212        .unwrap_or_else(|| linked_module_enabled_from_config(&ctx.config, module_name))
213}
214
215fn first_disabled_dependency(ctx: &AppContext, manifest: fn() -> ModuleManifest) -> Option<String> {
216    (manifest)()
217        .dependencies
218        .into_iter()
219        .find(|dependency| !linked_module_enabled(ctx, dependency))
220}
221
222fn first_disabled_dependency_from_config(
223    config: &platform_core::AppConfig,
224    manifest: fn() -> ModuleManifest,
225) -> Option<String> {
226    (manifest)()
227        .dependencies
228        .into_iter()
229        .find(|dependency| !linked_module_enabled_from_config(config, dependency))
230}
231
232fn linked_module_with_dependencies_enabled(
233    ctx: &AppContext,
234    module_name: &str,
235    manifest: fn() -> ModuleManifest,
236) -> bool {
237    linked_module_enabled(ctx, module_name) && first_disabled_dependency(ctx, manifest).is_none()
238}
239
240fn linked_module_with_dependencies_enabled_from_config(
241    config: &platform_core::AppConfig,
242    module_name: &str,
243    manifest: fn() -> ModuleManifest,
244) -> bool {
245    linked_module_enabled_from_config(config, module_name)
246        && first_disabled_dependency_from_config(config, manifest).is_none()
247}
248
249fn linked_module_disabled_reason(
250    ctx: &AppContext,
251    module_name: &str,
252    manifest: fn() -> ModuleManifest,
253) -> Option<String> {
254    if !linked_module_enabled(ctx, module_name) {
255        return Some("module disabled by configuration".to_owned());
256    }
257    if let Some(dependency) = first_disabled_dependency(ctx, manifest) {
258        return Some(format!("module dependency disabled: {dependency}"));
259    }
260    None
261}
262
263fn remote_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
264    config
265        .modules
266        .get(module_name)
267        .is_none_or(platform_core::ModuleConfig::is_enabled)
268}
269
270fn remote_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
271    ctx.runtime_config
272        .snapshot()
273        .raw(&module_enabled_config_key(module_name))
274        .and_then(serde_json::Value::as_bool)
275        .unwrap_or_else(|| remote_module_enabled_from_config(&ctx.config, module_name))
276}
277
278pub fn auth_actor_resolver_for_context(
279    ctx: &AppContext,
280) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
281    auth_actor_resolver_for_context_with_composition(ctx, &HostComposition::default())
282}
283
284pub fn auth_actor_resolver_for_context_with_composition(
285    ctx: &AppContext,
286    composition: &HostComposition,
287) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
288    let profile = CompositionProfile::from_config(&ctx.config)?;
289    let auth_in_profile = linked_module_entries(profile)
290        .iter()
291        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
292    let auth_in_composition = composition
293        .linked_modules()
294        .iter()
295        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
296    if (!auth_in_profile && !auth_in_composition)
297        || !linked_module_enabled(ctx, auth::module::MODULE_NAME)
298    {
299        return Ok(None);
300    }
301
302    let auth_resolver: Arc<dyn platform_core::ActorResolver> =
303        Arc::new(auth::resolver::AuthActorResolver::new_with_session_cache(
304            ctx.db.clone(),
305            ctx.actor_resolver.clone(),
306            auth_session_cache(ctx)?,
307        ));
308
309    let auth_password_enabled = linked_module_with_dependencies_enabled(
310        ctx,
311        auth_password::module::MODULE_NAME,
312        auth_password::module::manifest,
313    );
314    if auth_password_enabled {
315        if let Some(jwt_resolver) =
316            auth_password::module::jwt_actor_resolver(ctx, auth_resolver.clone())?
317        {
318            return Ok(Some(jwt_resolver));
319        }
320    }
321
322    Ok(Some(auth_resolver))
323}
324
325fn auth_session_cache(
326    ctx: &AppContext,
327) -> platform_core::AppResult<Option<Arc<dyn auth::resolver::SessionCache>>> {
328    match auth::config::AuthRuntimeConfig::from_context(ctx).session_cache {
329        auth::config::SessionCacheMode::Database => Ok(None),
330        auth::config::SessionCacheMode::Redis => {
331            let Some(redis) = ctx.redis.clone() else {
332                return Err(AppError::validation(
333                    "Redis auth session cache is not configured",
334                    vec![ErrorDetail {
335                        field: Some("auth.session_cache".to_owned()),
336                        reason: "set REDIS_URL when auth.session_cache is redis".to_owned(),
337                    }],
338                ));
339            };
340            Ok(Some(Arc::new(auth::redis_cache::RedisSessionCache::new(
341                redis,
342                AUTH_SESSION_CACHE_MAX_TTL,
343            ))))
344        }
345    }
346}
347
348fn linked_module_entries_for_context(
349    ctx: &AppContext,
350) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
351    Ok(
352        linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
353            .iter()
354            .filter(|entry| {
355                linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
356            })
357            .collect(),
358    )
359}
360
361fn linked_module_entries_for_config(
362    config: &platform_core::AppConfig,
363) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
364    Ok(
365        linked_module_entries(CompositionProfile::from_config(config)?)
366            .iter()
367            .filter(|entry| {
368                linked_module_with_dependencies_enabled_from_config(
369                    config,
370                    entry.module_name,
371                    entry.manifest,
372                )
373            })
374            .collect(),
375    )
376}
377
378fn disabled_linked_module_entries_for_context(
379    ctx: &AppContext,
380) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
381    Ok(
382        linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
383            .iter()
384            .filter(|entry| {
385                linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
386            })
387            .collect(),
388    )
389}
390
391fn host_linked_modules_for_config(
392    config: &platform_core::AppConfig,
393    composition: &HostComposition,
394) -> Vec<HostLinkedModule> {
395    composition
396        .linked_modules()
397        .iter()
398        .cloned()
399        .filter(|entry| {
400            linked_module_with_dependencies_enabled_from_config(
401                config,
402                entry.module_name,
403                entry.manifest,
404            )
405        })
406        .collect()
407}
408
409fn host_linked_modules_for_context(
410    ctx: &AppContext,
411    composition: &HostComposition,
412) -> Vec<HostLinkedModule> {
413    composition
414        .linked_modules()
415        .iter()
416        .cloned()
417        .filter(|entry| {
418            linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
419        })
420        .collect()
421}
422
423fn disabled_host_linked_modules_for_context(
424    ctx: &AppContext,
425    composition: &HostComposition,
426) -> Vec<HostLinkedModule> {
427    composition
428        .linked_modules()
429        .iter()
430        .cloned()
431        .filter(|entry| {
432            linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
433        })
434        .collect()
435}
436
437pub fn host_wiring_for_context(ctx: &AppContext) -> platform_core::AppResult<HostWiring> {
438    host_wiring_for_context_with_composition(ctx, &HostComposition::default())
439}
440
441pub fn host_wiring_for_context_with_composition(
442    ctx: &AppContext,
443    composition: &HostComposition,
444) -> platform_core::AppResult<HostWiring> {
445    let mut session_policies = Vec::new();
446    for module in host_linked_modules_for_context(ctx, composition) {
447        for extension in module.contributions::<auth::session_policy::AuthHostExtension>() {
448            if let Some(factory) = extension.session_policy_factory() {
449                session_policies.push(factory(ctx));
450            }
451        }
452    }
453
454    Ok(HostWiring {
455        auth_session_policy: auth::session_policy::AuthSessionPolicyChain::handle(session_policies),
456    })
457}
458
459fn load_host_linked_module(ctx: &AppContext, entry: HostLinkedModule) -> Module {
460    match entry.load {
461        Some(load) => load(ctx),
462        None => Module::linked((entry.manifest)(), LinkedBinding::builder().build()),
463    }
464}
465
466/// Demo-default linked modules helper (context-bound: builds bindings).
467///
468/// Startup and config-aware paths should use [`modules_for_config`] or
469/// [`load_modules`] so `module_sources.linked_profile` is honored.
470#[must_use]
471pub fn modules(ctx: &AppContext) -> Vec<Module> {
472    modules_for_profile(ctx, CompositionProfile::default())
473}
474
475pub fn modules_for_config(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
476    Ok(linked_module_entries_for_context(ctx)?
477        .into_iter()
478        .map(|entry| (entry.load)(ctx))
479        .collect())
480}
481
482pub fn modules_for_config_with_composition(
483    ctx: &AppContext,
484    composition: &HostComposition,
485) -> platform_core::AppResult<Vec<Module>> {
486    let mut modules = modules_for_config(ctx)?;
487    modules.extend(
488        host_linked_modules_for_context(ctx, composition)
489            .into_iter()
490            .map(|entry| load_host_linked_module(ctx, entry)),
491    );
492    Ok(modules)
493}
494
495#[must_use]
496pub fn modules_for_profile(ctx: &AppContext, profile: CompositionProfile) -> Vec<Module> {
497    linked_module_entries(profile)
498        .iter()
499        .map(|entry| (entry.load)(ctx))
500        .collect()
501}
502
503/// Load every configured module, including out-of-process remote modules.
504///
505/// The synchronous [`modules`] function remains Linked-only for call sites that
506/// must stay context-local and infallible. Startup paths that can perform IO
507/// should use this async loader.
508pub async fn load_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
509    load_modules_with_composition(ctx, &HostComposition::default()).await
510}
511
512pub async fn load_modules_with_composition(
513    ctx: &AppContext,
514    composition: &HostComposition,
515) -> platform_core::AppResult<Vec<Module>> {
516    let mut loaded = modules_for_config_with_composition(ctx, composition)?;
517
518    for remote in &ctx.config.module_sources.remote {
519        if !remote_module_enabled(ctx, &remote.name) {
520            continue;
521        }
522        let source = RemoteModuleSource::new(remote_module_config(remote))?;
523        loaded.push(source.load().await?);
524    }
525
526    Ok(loaded)
527}
528
529pub fn migrations_for_config(
530    config: &platform_core::AppConfig,
531) -> platform_core::AppResult<Vec<Migration>> {
532    migrations_for_config_with_composition(config, &HostComposition::default())
533}
534
535pub fn migrations_for_config_with_composition(
536    config: &platform_core::AppConfig,
537    composition: &HostComposition,
538) -> platform_core::AppResult<Vec<Migration>> {
539    let mut migrations = PLATFORM_MIGRATIONS
540        .iter()
541        .chain(RUNTIME_MIGRATIONS)
542        .copied()
543        .collect::<Vec<_>>();
544
545    if CompositionProfile::from_config(config)? == CompositionProfile::Demo {
546        if linked_module_enabled_from_config(config, "auth") {
547            migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
548        }
549        if linked_module_with_dependencies_enabled_from_config(
550            config,
551            "auth-password",
552            auth_password::module::manifest,
553        ) {
554            migrations.extend(
555                auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
556                    .iter()
557                    .copied(),
558            );
559        }
560    }
561
562    for module in host_linked_modules_for_config(config, composition) {
563        migrations.extend(module.migrations.iter().copied());
564    }
565
566    Ok(migrations)
567}
568
569#[must_use]
570pub fn migrations_for_profile(profile: CompositionProfile) -> Vec<Migration> {
571    let mut migrations = PLATFORM_MIGRATIONS
572        .iter()
573        .chain(RUNTIME_MIGRATIONS)
574        .copied()
575        .collect::<Vec<_>>();
576
577    if profile == CompositionProfile::Demo {
578        migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
579        migrations.extend(
580            auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
581                .iter()
582                .copied(),
583        );
584    }
585
586    migrations
587}
588
589/// Context-free module manifests for read-only / OpenAPI paths that have no
590/// [`AppContext`]. Kept in sync with [`modules`] by listing the same modules.
591#[must_use]
592pub fn module_manifests() -> Vec<ModuleManifest> {
593    module_manifests_for_profile(CompositionProfile::default())
594}
595
596#[must_use]
597pub fn module_manifests_for_profile(profile: CompositionProfile) -> Vec<ModuleManifest> {
598    linked_module_entries(profile)
599        .iter()
600        .map(|entry| (entry.manifest)())
601        .collect()
602}
603
604/// Runtime function declaration sources for context-free linked modules.
605#[must_use]
606pub fn linked_runtime_function_declaration_sources() -> Vec<(
607    String,
608    ModuleSource,
609    Option<platform_module::RuntimeSurface>,
610)> {
611    linked_runtime_function_declaration_sources_for_profile(CompositionProfile::default())
612}
613
614#[must_use]
615pub fn linked_runtime_function_declaration_sources_for_profile(
616    profile: CompositionProfile,
617) -> Vec<(
618    String,
619    ModuleSource,
620    Option<platform_module::RuntimeSurface>,
621)> {
622    module_manifests_for_profile(profile)
623        .into_iter()
624        .map(|manifest| (manifest.name, ModuleSource::Linked, manifest.runtime))
625        .collect()
626}
627
628pub fn linked_runtime_function_declaration_sources_for_config(
629    config: &platform_core::AppConfig,
630) -> platform_core::AppResult<
631    Vec<(
632        String,
633        ModuleSource,
634        Option<platform_module::RuntimeSurface>,
635    )>,
636> {
637    Ok(linked_module_entries_for_config(config)?
638        .into_iter()
639        .map(|entry| {
640            let manifest = (entry.manifest)();
641            (manifest.name, ModuleSource::Linked, manifest.runtime)
642        })
643        .collect())
644}
645
646pub fn linked_runtime_function_declaration_sources_for_context(
647    ctx: &AppContext,
648) -> platform_core::AppResult<
649    Vec<(
650        String,
651        ModuleSource,
652        Option<platform_module::RuntimeSurface>,
653    )>,
654> {
655    Ok(linked_module_entries_for_context(ctx)?
656        .into_iter()
657        .map(|entry| {
658            let manifest = (entry.manifest)();
659            (manifest.name, ModuleSource::Linked, manifest.runtime)
660        })
661        .collect())
662}
663
664pub fn linked_runtime_function_declaration_sources_for_context_with_composition(
665    ctx: &AppContext,
666    composition: &HostComposition,
667) -> platform_core::AppResult<
668    Vec<(
669        String,
670        ModuleSource,
671        Option<platform_module::RuntimeSurface>,
672    )>,
673> {
674    let mut sources = linked_runtime_function_declaration_sources_for_context(ctx)?;
675    sources.extend(
676        host_linked_modules_for_context(ctx, composition)
677            .into_iter()
678            .map(|entry| {
679                let manifest = (entry.manifest)();
680                (manifest.name, ModuleSource::Linked, manifest.runtime)
681            }),
682    );
683    Ok(sources)
684}
685
686/// Runtime function declaration sources from loaded module metadata, including
687/// configured remote modules.
688#[must_use]
689pub fn runtime_function_declaration_sources_from_metadata(
690    modules: &[AdminModuleMetadata],
691) -> Vec<(
692    String,
693    ModuleSource,
694    Option<platform_module::RuntimeSurface>,
695)> {
696    modules
697        .iter()
698        .filter(|module| matches!(module.load_status, ModuleLoadStatus::Loaded))
699        .map(|module| {
700            (
701                module.module_name.clone(),
702                module.source,
703                module.runtime.clone(),
704            )
705        })
706        .collect()
707}
708
709/// Public HTTP path ownership for linked modules.
710///
711/// Projected from context-free linked modules so OpenAPI guards and router
712/// assembly consume the same source-specific binding data.
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct LinkedHttpRouteOwner {
715    pub module_name: String,
716    pub public_prefixes: &'static [&'static str],
717}
718
719#[must_use]
720pub fn linked_http_route_owners() -> Vec<LinkedHttpRouteOwner> {
721    linked_http_route_owners_for_profile(CompositionProfile::default())
722}
723
724#[must_use]
725pub fn linked_http_route_owners_for_profile(
726    profile: CompositionProfile,
727) -> Vec<LinkedHttpRouteOwner> {
728    linked_module_entries(profile)
729        .iter()
730        .filter_map(|entry| {
731            let http = entry.http_binding?().http?;
732            Some(LinkedHttpRouteOwner {
733                module_name: entry.module_name.to_owned(),
734                public_prefixes: http.public_prefixes,
735            })
736        })
737        .collect()
738}
739
740/// Context-free linked modules that contribute Axum/OpenAPI HTTP routers.
741#[must_use]
742pub fn linked_http_modules() -> Vec<Module> {
743    linked_http_modules_for_profile(CompositionProfile::default())
744}
745
746#[must_use]
747pub fn linked_http_modules_for_profile(profile: CompositionProfile) -> Vec<Module> {
748    linked_module_entries(profile)
749        .iter()
750        .filter_map(|entry| {
751            let http_binding = entry.http_binding?;
752            Some(Module::linked((entry.manifest)(), http_binding()))
753        })
754        .collect()
755}
756
757pub fn linked_http_modules_for_config(
758    config: &platform_core::AppConfig,
759) -> platform_core::AppResult<Vec<Module>> {
760    Ok(linked_module_entries_for_config(config)?
761        .into_iter()
762        .filter_map(|entry| {
763            let http_binding = entry.http_binding?;
764            Some(Module::linked((entry.manifest)(), http_binding()))
765        })
766        .collect())
767}
768
769pub fn linked_http_modules_for_context(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
770    Ok(linked_module_entries_for_context(ctx)?
771        .into_iter()
772        .filter_map(|entry| {
773            let http_binding = entry.http_binding?;
774            Some(Module::linked((entry.manifest)(), http_binding()))
775        })
776        .collect())
777}
778
779pub fn linked_http_modules_for_context_with_composition(
780    ctx: &AppContext,
781    composition: &HostComposition,
782) -> platform_core::AppResult<Vec<Module>> {
783    let mut modules = linked_http_modules_for_context(ctx)?;
784    modules.extend(
785        host_linked_modules_for_context(ctx, composition)
786            .into_iter()
787            .filter_map(|entry| {
788                let http_binding = entry.http_binding?;
789                Some(Module::linked((entry.manifest)(), http_binding()))
790            }),
791    );
792    Ok(modules)
793}
794
795/// Aggregate admin-capable modules: those declaring an admin surface and
796/// providing either an `AdminDataSource` or an `AdminActionSource`. Modules
797/// without an admin behavior source are filtered out — "optional capability"
798/// semantics.
799#[must_use]
800pub fn admin_modules(ctx: &AppContext) -> Vec<AdminModule> {
801    admin_modules_from_modules(modules(ctx))
802}
803
804/// Load schema-admin capable modules, including configured remotes.
805pub async fn load_admin_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<AdminModule>> {
806    load_admin_modules_with_composition(ctx, &HostComposition::default()).await
807}
808
809pub async fn load_admin_modules_with_composition(
810    ctx: &AppContext,
811    composition: &HostComposition,
812) -> platform_core::AppResult<Vec<AdminModule>> {
813    let mut admin_modules =
814        admin_modules_from_modules(modules_for_config_with_composition(ctx, composition)?);
815
816    for remote in &ctx.config.module_sources.remote {
817        if !remote_module_enabled(ctx, &remote.name) {
818            continue;
819        }
820        let source = RemoteModuleSource::new(remote_module_config(remote))?;
821        match source.load().await {
822            Ok(module) => admin_modules.extend(admin_modules_from_modules(vec![module])),
823            Err(error) => admin_modules.push(failed_remote_admin_module(
824                remote.name.clone(),
825                error.public_message,
826            )),
827        }
828    }
829
830    Ok(admin_modules)
831}
832
833/// Load registry metadata for every configured module, including modules with
834/// no admin surface and custom surfaces not consumable by schema-admin
835/// list/detail.
836pub async fn load_admin_module_metadata(
837    ctx: &AppContext,
838) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
839    load_admin_module_metadata_with_composition(ctx, &HostComposition::default()).await
840}
841
842pub async fn load_admin_module_metadata_with_composition(
843    ctx: &AppContext,
844    composition: &HostComposition,
845) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
846    let mut metadata =
847        admin_metadata_from_modules(modules_for_config_with_composition(ctx, composition)?);
848    metadata.extend(disabled_linked_admin_metadata(ctx)?);
849    metadata.extend(disabled_host_linked_admin_metadata(ctx, composition));
850
851    for remote in &ctx.config.module_sources.remote {
852        let config = remote_module_config(remote);
853        if !remote_module_enabled(ctx, &remote.name) {
854            metadata.push(disabled_remote_admin_metadata(&config));
855            continue;
856        }
857        let checked_at = current_timestamp();
858        let source = RemoteModuleSource::new(config.clone())?;
859        let load_started = Instant::now();
860        match source.load().await {
861            Ok(module) => metadata.extend(remote_admin_metadata_from_module(
862                module,
863                &config,
864                checked_at,
865                Some(duration_ms(load_started)),
866                None,
867            )),
868            Err(error) => metadata.push(failed_remote_admin_metadata(
869                &config,
870                Some(checked_at),
871                Some(duration_ms(load_started)),
872                error.public_message,
873            )),
874        }
875    }
876
877    Ok(metadata)
878}
879
880pub async fn load_remote_http_proxy_registry(
881    ctx: &AppContext,
882) -> platform_core::AppResult<RemoteHttpProxyRegistry> {
883    let mut remote_modules = Vec::new();
884    let mut remote_configs = Vec::new();
885
886    for remote in &ctx.config.module_sources.remote {
887        if !remote_module_enabled(ctx, &remote.name) {
888            continue;
889        }
890        let config = remote_module_config(remote);
891        let source = RemoteModuleSource::new(config.clone())?;
892        if let Ok(module) = source.load().await {
893            remote_modules.push(module);
894            remote_configs.push(config);
895        }
896    }
897
898    Ok(RemoteHttpProxyRegistry::from_modules(
899        &remote_modules,
900        &remote_configs,
901    ))
902}
903
904fn admin_modules_from_modules(modules: Vec<Module>) -> Vec<AdminModule> {
905    modules
906        .into_iter()
907        .filter_map(|module| {
908            // `modules(ctx)` yields owned Modules — move the fields out.
909            let data_source = module.admin_data;
910            let action_source = module.admin_actions;
911            let query_source = module.admin_queries;
912            if data_source.is_none() && action_source.is_none() && query_source.is_none() {
913                return None;
914            }
915            let ModuleManifest { name, admin, .. } = module.manifest;
916            let admin = admin?;
917            let (schema, listed_in_schema) = match &admin {
918                AdminSurface::Schema(schema) => (schema.clone(), true),
919                AdminSurface::DeclarativeCustom(surface) => (
920                    surface.fallback_schema.clone().unwrap_or(AdminSchema {
921                        entities: Vec::new(),
922                    }),
923                    false,
924                ),
925                AdminSurface::EmbeddedCustom(_) => return None,
926                _ => return None,
927            };
928            Some(AdminModule {
929                module_name: name,
930                source: module.source,
931                load_status: module.load_status,
932                schema,
933                admin: Some(admin),
934                listed_in_schema,
935                data_source,
936                action_source,
937                query_source,
938            })
939        })
940        .collect()
941}
942
943fn admin_metadata_from_modules(modules: Vec<Module>) -> Vec<AdminModuleMetadata> {
944    modules
945        .into_iter()
946        .map(|module| {
947            let ModuleManifest {
948                name,
949                admin,
950                http_routes,
951                runtime,
952                events,
953                lifecycle,
954                console,
955                story_display,
956                capabilities,
957                dependencies,
958                ..
959            } = module.manifest;
960            AdminModuleMetadata {
961                module_name: name,
962                source: module.source,
963                load_status: module.load_status,
964                http_routes,
965                runtime,
966                events,
967                lifecycle,
968                console,
969                story_display,
970                capabilities,
971                dependencies,
972                admin,
973                source_diagnostics: None,
974            }
975        })
976        .collect()
977}
978
979fn failed_remote_admin_module(name: String, message: String) -> AdminModule {
980    AdminModule {
981        module_name: name,
982        source: ModuleSource::Remote,
983        load_status: ModuleLoadStatus::Error { message },
984        schema: AdminSchema {
985            entities: Vec::new(),
986        },
987        admin: None,
988        listed_in_schema: true,
989        data_source: None,
990        action_source: None,
991        query_source: None,
992    }
993}
994
995fn remote_admin_metadata_from_module(
996    module: Module,
997    config: &RemoteModuleConfig,
998    checked_at: String,
999    load_duration_ms: Option<u64>,
1000    load_error: Option<String>,
1001) -> Vec<AdminModuleMetadata> {
1002    admin_metadata_from_modules(vec![module])
1003        .into_iter()
1004        .map(|mut metadata| {
1005            metadata.source_diagnostics = Some(remote_source_diagnostics(
1006                config,
1007                Some(checked_at.clone()),
1008                load_duration_ms,
1009                load_error.clone(),
1010            ));
1011            metadata
1012        })
1013        .collect()
1014}
1015
1016fn failed_remote_admin_metadata(
1017    config: &RemoteModuleConfig,
1018    checked_at: Option<String>,
1019    load_duration_ms: Option<u64>,
1020    message: String,
1021) -> AdminModuleMetadata {
1022    AdminModuleMetadata {
1023        module_name: config.name.clone(),
1024        source: ModuleSource::Remote,
1025        load_status: ModuleLoadStatus::Error {
1026            message: message.clone(),
1027        },
1028        http_routes: Vec::new(),
1029        runtime: None,
1030        events: None,
1031        lifecycle: None,
1032        console: Vec::new(),
1033        story_display: Vec::new(),
1034        capabilities: Vec::new(),
1035        dependencies: Vec::new(),
1036        admin: None,
1037        source_diagnostics: Some(remote_source_diagnostics(
1038            config,
1039            checked_at,
1040            load_duration_ms,
1041            Some(message),
1042        )),
1043    }
1044}
1045
1046fn disabled_remote_admin_metadata(config: &RemoteModuleConfig) -> AdminModuleMetadata {
1047    AdminModuleMetadata {
1048        module_name: config.name.clone(),
1049        source: ModuleSource::Remote,
1050        load_status: ModuleLoadStatus::Error {
1051            message: "module disabled by configuration".to_owned(),
1052        },
1053        http_routes: Vec::new(),
1054        runtime: None,
1055        events: None,
1056        lifecycle: None,
1057        console: Vec::new(),
1058        story_display: Vec::new(),
1059        capabilities: Vec::new(),
1060        dependencies: Vec::new(),
1061        admin: None,
1062        source_diagnostics: Some(remote_source_diagnostics(config, None, None, None)),
1063    }
1064}
1065
1066fn disabled_linked_admin_metadata(
1067    ctx: &AppContext,
1068) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1069    Ok(disabled_linked_module_entries_for_context(ctx)?
1070        .into_iter()
1071        .map(|entry| {
1072            let ModuleManifest {
1073                name,
1074                admin,
1075                http_routes,
1076                runtime,
1077                events,
1078                lifecycle,
1079                console,
1080                story_display,
1081                capabilities,
1082                dependencies,
1083                ..
1084            } = (entry.manifest)();
1085            AdminModuleMetadata {
1086                module_name: name,
1087                source: ModuleSource::Linked,
1088                load_status: ModuleLoadStatus::Error {
1089                    message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1090                        .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1091                },
1092                http_routes,
1093                runtime,
1094                events,
1095                lifecycle,
1096                console,
1097                story_display,
1098                capabilities,
1099                dependencies,
1100                admin,
1101                source_diagnostics: None,
1102            }
1103        })
1104        .collect())
1105}
1106
1107fn disabled_host_linked_admin_metadata(
1108    ctx: &AppContext,
1109    composition: &HostComposition,
1110) -> Vec<AdminModuleMetadata> {
1111    disabled_host_linked_modules_for_context(ctx, composition)
1112        .into_iter()
1113        .map(|entry| {
1114            let ModuleManifest {
1115                name,
1116                admin,
1117                http_routes,
1118                runtime,
1119                events,
1120                lifecycle,
1121                console,
1122                story_display,
1123                capabilities,
1124                dependencies,
1125                ..
1126            } = (entry.manifest)();
1127            AdminModuleMetadata {
1128                module_name: name,
1129                source: ModuleSource::Linked,
1130                load_status: ModuleLoadStatus::Error {
1131                    message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1132                        .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1133                },
1134                http_routes,
1135                runtime,
1136                events,
1137                lifecycle,
1138                console,
1139                story_display,
1140                capabilities,
1141                dependencies,
1142                admin,
1143                source_diagnostics: None,
1144            }
1145        })
1146        .collect()
1147}
1148
1149fn remote_source_diagnostics(
1150    config: &RemoteModuleConfig,
1151    checked_at: Option<String>,
1152    load_duration_ms: Option<u64>,
1153    load_error: Option<String>,
1154) -> AdminModuleSourceDiagnostics {
1155    let (transport, manifest_url) = match config.transport {
1156        platform_module_remote::RemoteModuleTransport::HttpJson => {
1157            ("http_json", format!("{}/manifest", config.base_url))
1158        }
1159        platform_module_remote::RemoteModuleTransport::Grpc => (
1160            "grpc",
1161            format!(
1162                "{}#lenso.remote.v1.RemoteModule/GetManifest",
1163                config.base_url
1164            ),
1165        ),
1166    };
1167    AdminModuleSourceDiagnostics::Remote(AdminRemoteModuleDiagnostics {
1168        transport: transport.to_owned(),
1169        base_url: config.base_url.clone(),
1170        manifest_url,
1171        timeout_ms: config.timeout_ms,
1172        auth_configured: config.auth_token.is_some(),
1173        load_duration_ms,
1174        last_checked_at: checked_at,
1175        last_load_error: load_error,
1176    })
1177}
1178
1179fn remote_module_config(source: &platform_core::RemoteModuleSourceConfig) -> RemoteModuleConfig {
1180    let mut config = RemoteModuleConfig::new(source.name.clone(), source.base_url.clone())
1181        .with_timeout_ms(source.timeout_ms);
1182
1183    if let Some(env_name) = &source.auth_token_env {
1184        if let Ok(token) = std::env::var(env_name) {
1185            config = config.with_auth_token(token);
1186        }
1187    }
1188
1189    config
1190}
1191
1192#[derive(Debug)]
1193pub struct RemoteModuleServiceSupervisor {
1194    services: Vec<RemoteModuleServiceHandle>,
1195}
1196
1197impl RemoteModuleServiceSupervisor {
1198    #[must_use]
1199    pub fn is_empty(&self) -> bool {
1200        self.services.is_empty()
1201    }
1202}
1203
1204impl Drop for RemoteModuleServiceSupervisor {
1205    fn drop(&mut self) {
1206        for service in &mut self.services {
1207            terminate_remote_module_service(&mut service.child);
1208            release_remote_module_service_state(&service.lock_file_path, &service.pid_file_path);
1209        }
1210    }
1211}
1212
1213#[derive(Debug)]
1214struct RemoteModuleServiceHandle {
1215    child: Child,
1216    lock_file_path: PathBuf,
1217    pid_file_path: PathBuf,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq)]
1221struct RemoteModuleServiceSpec {
1222    module_name: String,
1223    service_name: String,
1224    command: String,
1225    cwd: Option<PathBuf>,
1226    ready_url: String,
1227    ready_timeout_ms: u64,
1228    auto_start: bool,
1229}
1230
1231pub async fn start_installed_remote_module_services(
1232    ctx: &AppContext,
1233) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1234    start_installed_remote_module_services_from_path(ctx, Path::new(DEFAULT_MODULE_SERVICES_FILE))
1235        .await
1236}
1237
1238pub async fn start_installed_remote_module_services_from_path(
1239    ctx: &AppContext,
1240    services_file_path: &Path,
1241) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1242    let specs = read_remote_module_service_specs(services_file_path)?;
1243    let services_state_dir = services_file_path
1244        .parent()
1245        .unwrap_or_else(|| Path::new("."));
1246    let client = reqwest::Client::builder()
1247        .timeout(Duration::from_millis(800))
1248        .build()
1249        .map_err(|source| {
1250            AppError::new(ErrorCode::Internal, "failed to build HTTP client").with_source(source)
1251        })?;
1252    let mut services = Vec::new();
1253
1254    for spec in specs {
1255        if !spec.auto_start || !remote_service_module_enabled(ctx, &spec.module_name) {
1256            continue;
1257        }
1258        if remote_service_ready(&client, &spec.ready_url).await {
1259            tracing::info!(
1260                module = %spec.module_name,
1261                service = %spec.service_name,
1262                ready_url = %spec.ready_url,
1263                "remote module service already ready"
1264            );
1265            continue;
1266        }
1267        let lock_file_path = remote_module_service_state_path(services_state_dir, &spec, "lock");
1268        let pid_file_path = remote_module_service_state_path(services_state_dir, &spec, "pid");
1269        if !claim_remote_module_service_lock(&client, &spec, &lock_file_path, &pid_file_path)
1270            .await?
1271        {
1272            continue;
1273        }
1274        let mut child = match spawn_remote_module_service(&spec) {
1275            Ok(child) => child,
1276            Err(error) => {
1277                release_remote_module_service_state(&lock_file_path, &pid_file_path);
1278                return Err(error);
1279            }
1280        };
1281        if let Err(error) = write_remote_module_service_pid(&pid_file_path, child.id()) {
1282            terminate_remote_module_service(&mut child);
1283            release_remote_module_service_state(&lock_file_path, &pid_file_path);
1284            return Err(error);
1285        }
1286        if let Err(error) = wait_for_remote_module_service(&client, &spec, &mut child).await {
1287            terminate_remote_module_service(&mut child);
1288            release_remote_module_service_state(&lock_file_path, &pid_file_path);
1289            return Err(error);
1290        }
1291        tracing::info!(
1292            module = %spec.module_name,
1293            service = %spec.service_name,
1294            ready_url = %spec.ready_url,
1295            "started remote module service"
1296        );
1297        services.push(RemoteModuleServiceHandle {
1298            child,
1299            lock_file_path,
1300            pid_file_path,
1301        });
1302    }
1303
1304    Ok(RemoteModuleServiceSupervisor { services })
1305}
1306
1307fn remote_service_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
1308    ctx.config
1309        .module_sources
1310        .remote
1311        .iter()
1312        .any(|remote| remote.name == module_name)
1313        && remote_module_enabled(ctx, module_name)
1314}
1315
1316fn read_remote_module_service_specs(
1317    services_file_path: &Path,
1318) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1319    let source = match std::fs::read_to_string(services_file_path) {
1320        Ok(source) => source,
1321        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1322        Err(source) => {
1323            return Err(AppError::new(
1324                ErrorCode::ExternalDependency,
1325                format!("remote module services file could not be read: {source}"),
1326            ));
1327        }
1328    };
1329    let value = serde_json::from_str::<serde_json::Value>(&source).map_err(|source| {
1330        AppError::new(
1331            ErrorCode::Validation,
1332            format!("remote module services file could not be parsed: {source}"),
1333        )
1334    })?;
1335    parse_remote_module_service_specs(&value)
1336}
1337
1338fn parse_remote_module_service_specs(
1339    value: &serde_json::Value,
1340) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1341    let modules = value
1342        .get("modules")
1343        .and_then(serde_json::Value::as_array)
1344        .ok_or_else(|| {
1345            AppError::new(
1346                ErrorCode::Validation,
1347                "remote module services file modules must be an array",
1348            )
1349        })?;
1350    let mut specs = Vec::new();
1351    for module in modules {
1352        let module_name = json_string(module, "moduleName")?;
1353        let services = module
1354            .get("services")
1355            .and_then(serde_json::Value::as_array)
1356            .ok_or_else(|| {
1357                AppError::new(
1358                    ErrorCode::Validation,
1359                    format!("{module_name} services must be an array"),
1360                )
1361            })?;
1362        for service in services {
1363            let command = json_string(service, "command")?;
1364            let ready_url = json_string(service, "readyUrl")?;
1365            specs.push(RemoteModuleServiceSpec {
1366                module_name: module_name.clone(),
1367                service_name: service
1368                    .get("name")
1369                    .and_then(serde_json::Value::as_str)
1370                    .unwrap_or(&module_name)
1371                    .to_owned(),
1372                command,
1373                cwd: service
1374                    .get("cwd")
1375                    .and_then(serde_json::Value::as_str)
1376                    .map(PathBuf::from),
1377                ready_url,
1378                ready_timeout_ms: service
1379                    .get("readyTimeoutMs")
1380                    .and_then(serde_json::Value::as_u64)
1381                    .unwrap_or(DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS),
1382                auto_start: service
1383                    .get("autoStart")
1384                    .and_then(serde_json::Value::as_bool)
1385                    .unwrap_or(true),
1386            });
1387        }
1388    }
1389    Ok(specs)
1390}
1391
1392fn json_string(value: &serde_json::Value, key: &str) -> platform_core::AppResult<String> {
1393    value
1394        .get(key)
1395        .and_then(serde_json::Value::as_str)
1396        .map(str::to_owned)
1397        .ok_or_else(|| AppError::new(ErrorCode::Validation, format!("{key} must be a string")))
1398}
1399
1400fn spawn_remote_module_service(spec: &RemoteModuleServiceSpec) -> platform_core::AppResult<Child> {
1401    let cwd = spec
1402        .cwd
1403        .clone()
1404        .unwrap_or(std::env::current_dir().map_err(|source| {
1405            AppError::new(ErrorCode::Internal, "failed to resolve current directory")
1406                .with_source(source)
1407        })?);
1408    let mut command = shell_command(&spec.command);
1409    command.current_dir(cwd);
1410    configure_remote_module_service_process(&mut command);
1411    command.spawn().map_err(|source| {
1412        AppError::new(
1413            ErrorCode::ExternalDependency,
1414            format!(
1415                "failed to start remote module service {}: {}",
1416                spec.module_name, spec.service_name
1417            ),
1418        )
1419        .with_source(source)
1420    })
1421}
1422
1423async fn wait_for_remote_module_service(
1424    client: &reqwest::Client,
1425    spec: &RemoteModuleServiceSpec,
1426    child: &mut Child,
1427) -> platform_core::AppResult<()> {
1428    let started = Instant::now();
1429    let timeout = Duration::from_millis(spec.ready_timeout_ms);
1430    loop {
1431        if remote_service_ready(client, &spec.ready_url).await {
1432            return Ok(());
1433        }
1434        if let Some(status) = child.try_wait().map_err(|source| {
1435            AppError::new(
1436                ErrorCode::ExternalDependency,
1437                format!(
1438                    "remote module service {} status could not be checked",
1439                    spec.service_name
1440                ),
1441            )
1442            .with_source(source)
1443        })? {
1444            return Err(AppError::new(
1445                ErrorCode::ExternalDependency,
1446                format!(
1447                    "remote module service {} exited before it became ready: {status}",
1448                    spec.service_name
1449                ),
1450            ));
1451        }
1452        if started.elapsed() >= timeout {
1453            return Err(AppError::new(
1454                ErrorCode::ExternalDependency,
1455                format!(
1456                    "remote module service {} did not become ready at {}",
1457                    spec.service_name, spec.ready_url
1458                ),
1459            ));
1460        }
1461        tokio::time::sleep(Duration::from_millis(200)).await;
1462    }
1463}
1464
1465async fn remote_service_ready(client: &reqwest::Client, ready_url: &str) -> bool {
1466    client
1467        .get(ready_url)
1468        .send()
1469        .await
1470        .is_ok_and(|response| response.status().is_success())
1471}
1472
1473async fn claim_remote_module_service_lock(
1474    client: &reqwest::Client,
1475    spec: &RemoteModuleServiceSpec,
1476    lock_file_path: &Path,
1477    pid_file_path: &Path,
1478) -> platform_core::AppResult<bool> {
1479    match create_remote_module_service_lock(lock_file_path) {
1480        Ok(()) => return Ok(true),
1481        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1482        Err(source) => {
1483            return Err(AppError::new(
1484                ErrorCode::ExternalDependency,
1485                format!(
1486                    "remote module service {} lock could not be created: {source}",
1487                    spec.service_name
1488                ),
1489            ));
1490        }
1491    }
1492
1493    tracing::info!(
1494        module = %spec.module_name,
1495        service = %spec.service_name,
1496        ready_url = %spec.ready_url,
1497        "remote module service startup already claimed"
1498    );
1499    if wait_for_remote_module_service_ready(
1500        client,
1501        &spec.ready_url,
1502        Duration::from_millis(spec.ready_timeout_ms),
1503    )
1504    .await
1505    {
1506        return Ok(false);
1507    }
1508
1509    tracing::warn!(
1510        module = %spec.module_name,
1511        service = %spec.service_name,
1512        lock_file = %lock_file_path.display(),
1513        "remote module service lock did not become ready before timeout; treating it as stale"
1514    );
1515    terminate_stale_remote_module_service(pid_file_path);
1516    release_remote_module_service_state(lock_file_path, pid_file_path);
1517    match create_remote_module_service_lock(lock_file_path) {
1518        Ok(()) => Ok(true),
1519        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1520        Err(source) => Err(AppError::new(
1521            ErrorCode::ExternalDependency,
1522            format!(
1523                "stale remote module service {} lock could not be replaced: {source}",
1524                spec.service_name
1525            ),
1526        )),
1527    }
1528}
1529
1530fn create_remote_module_service_lock(lock_file_path: &Path) -> std::io::Result<()> {
1531    if let Some(parent) = lock_file_path.parent() {
1532        fs::create_dir_all(parent)?;
1533    }
1534    let mut file = OpenOptions::new()
1535        .write(true)
1536        .create_new(true)
1537        .open(lock_file_path)?;
1538    writeln!(file, "owner_pid={}", std::process::id())?;
1539    Ok(())
1540}
1541
1542fn write_remote_module_service_pid(
1543    pid_file_path: &Path,
1544    child_pid: u32,
1545) -> platform_core::AppResult<()> {
1546    if let Some(parent) = pid_file_path.parent() {
1547        fs::create_dir_all(parent).map_err(|source| {
1548            AppError::new(
1549                ErrorCode::ExternalDependency,
1550                format!("remote module service pid directory could not be created: {source}"),
1551            )
1552        })?;
1553    }
1554    fs::write(pid_file_path, format!("{child_pid}\n")).map_err(|source| {
1555        AppError::new(
1556            ErrorCode::ExternalDependency,
1557            format!("remote module service pid file could not be written: {source}"),
1558        )
1559    })
1560}
1561
1562fn release_remote_module_service_state(lock_file_path: &Path, pid_file_path: &Path) {
1563    let _ = fs::remove_file(pid_file_path);
1564    let _ = fs::remove_file(lock_file_path);
1565}
1566
1567#[cfg(unix)]
1568fn terminate_stale_remote_module_service(pid_file_path: &Path) {
1569    let Ok(source) = fs::read_to_string(pid_file_path) else {
1570        return;
1571    };
1572    let Ok(pid) = source.trim().parse::<u32>() else {
1573        return;
1574    };
1575
1576    let _ = Command::new("kill")
1577        .arg("-TERM")
1578        .arg(format!("-{pid}"))
1579        .status();
1580    thread::sleep(Duration::from_millis(100));
1581}
1582
1583#[cfg(not(unix))]
1584fn terminate_stale_remote_module_service(_pid_file_path: &Path) {}
1585
1586async fn wait_for_remote_module_service_ready(
1587    client: &reqwest::Client,
1588    ready_url: &str,
1589    timeout: Duration,
1590) -> bool {
1591    let started = Instant::now();
1592    loop {
1593        if remote_service_ready(client, ready_url).await {
1594            return true;
1595        }
1596        if started.elapsed() >= timeout {
1597            return false;
1598        }
1599        tokio::time::sleep(Duration::from_millis(200)).await;
1600    }
1601}
1602
1603fn remote_module_service_state_path(
1604    services_state_dir: &Path,
1605    spec: &RemoteModuleServiceSpec,
1606    extension: &str,
1607) -> PathBuf {
1608    services_state_dir.join(format!(
1609        "remote-{}-{}.{}",
1610        remote_module_service_state_segment(&spec.module_name),
1611        remote_module_service_state_segment(&spec.service_name),
1612        extension
1613    ))
1614}
1615
1616fn remote_module_service_state_segment(value: &str) -> String {
1617    let mut segment = String::new();
1618    let mut previous_dash = false;
1619    for character in value.chars() {
1620        if character.is_ascii_alphanumeric() {
1621            segment.push(character.to_ascii_lowercase());
1622            previous_dash = false;
1623        } else if !segment.is_empty() && !previous_dash {
1624            segment.push('-');
1625            previous_dash = true;
1626        }
1627    }
1628    while segment.ends_with('-') {
1629        segment.pop();
1630    }
1631    if segment.is_empty() {
1632        "service".to_owned()
1633    } else {
1634        segment
1635    }
1636}
1637
1638fn terminate_remote_module_service(child: &mut Child) {
1639    if matches!(child.try_wait(), Ok(Some(_))) {
1640        return;
1641    }
1642
1643    #[cfg(unix)]
1644    {
1645        let process_group_id = child.id();
1646        let _ = Command::new("kill")
1647            .arg("-TERM")
1648            .arg(format!("-{process_group_id}"))
1649            .status();
1650        if wait_for_remote_module_service_exit(
1651            child,
1652            Duration::from_millis(REMOTE_SERVICE_TERMINATE_GRACE_MS),
1653        ) {
1654            return;
1655        }
1656    }
1657
1658    let _ = child.kill();
1659    let _ = child.wait();
1660}
1661
1662fn wait_for_remote_module_service_exit(child: &mut Child, timeout: Duration) -> bool {
1663    let started = Instant::now();
1664    loop {
1665        match child.try_wait() {
1666            Ok(Some(_)) => return true,
1667            Ok(None) => {}
1668            Err(_) => return true,
1669        }
1670        if started.elapsed() >= timeout {
1671            return false;
1672        }
1673        thread::sleep(Duration::from_millis(50));
1674    }
1675}
1676
1677#[cfg(unix)]
1678fn configure_remote_module_service_process(command: &mut Command) {
1679    use std::os::unix::process::CommandExt;
1680    command.process_group(0);
1681}
1682
1683#[cfg(not(unix))]
1684fn configure_remote_module_service_process(_command: &mut Command) {}
1685
1686fn shell_command(command: &str) -> Command {
1687    if cfg!(windows) {
1688        let mut process = Command::new("cmd");
1689        process.arg("/C").arg(command);
1690        process
1691    } else {
1692        let mut process = Command::new("sh");
1693        process.arg("-c").arg(command);
1694        process
1695    }
1696}
1697
1698fn current_timestamp() -> String {
1699    use platform_core::Clock;
1700    platform_core::SystemClock.now().to_rfc3339()
1701}
1702
1703fn duration_ms(started: Instant) -> u64 {
1704    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1705}
1706
1707/// Build a [`FunctionRegistry`] from every module's binding.
1708#[must_use]
1709pub fn function_registry(modules: &[Module]) -> FunctionRegistry {
1710    let mut registry = FunctionRegistry::default();
1711    for module in modules {
1712        module.binding.register_functions(&mut registry);
1713    }
1714    registry
1715}
1716
1717/// Validate and enqueue every startup activation job declared by loaded modules.
1718///
1719/// Lifecycle activation is host-owned: module manifests declare the work, and
1720/// the Lenso bootstrap validates those declarations against the runtime registry
1721/// before scheduling function runs.
1722pub async fn enqueue_lifecycle_activation_jobs(
1723    ctx: &AppContext,
1724    modules: &[Module],
1725    registry: &FunctionRegistry,
1726) -> platform_core::AppResult<Vec<String>> {
1727    validate_lifecycle_activation_jobs(modules, registry)?;
1728
1729    let client = RuntimeClient::new(ctx.db.clone());
1730    let mut run_ids = Vec::new();
1731
1732    for module in modules {
1733        let Some(lifecycle) = &module.manifest.lifecycle else {
1734            continue;
1735        };
1736
1737        for job in &lifecycle.activation_jobs {
1738            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1739                continue;
1740            }
1741            if !module_declares_runtime_function(module, &job.function_name) {
1742                continue;
1743            }
1744
1745            let Some(definition) = registry.get(&job.function_name) else {
1746                continue;
1747            };
1748
1749            let enqueue_result = client
1750                .enqueue_function(EnqueueFunctionRequest {
1751                    function_name: job.function_name.clone(),
1752                    input_json: job.input.clone(),
1753                    correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1754                    actor: ActorContext::Service {
1755                        service_id: "worker".to_owned(),
1756                        scopes: vec!["runtime.functions.enqueue".to_owned()],
1757                    },
1758                    trace: TraceContext::default(),
1759                    causation_id: Some(format!(
1760                        "module_lifecycle:{}:{}",
1761                        module.manifest.name, job.name
1762                    )),
1763                    max_attempts: Some(runtime_max_attempts_for_enqueue(
1764                        definition.retry_policy.max_attempts,
1765                    )),
1766                })
1767                .await;
1768
1769            match enqueue_result {
1770                Ok(run_id) => run_ids.push(run_id),
1771                Err(error) if job.required => return Err(error),
1772                Err(error) => warn_optional_lifecycle_enqueue_failure(
1773                    &module.manifest.name,
1774                    &job.name,
1775                    &job.function_name,
1776                    &error,
1777                ),
1778            }
1779        }
1780    }
1781
1782    Ok(run_ids)
1783}
1784
1785fn validate_lifecycle_activation_jobs(
1786    modules: &[Module],
1787    registry: &FunctionRegistry,
1788) -> platform_core::AppResult<()> {
1789    for module in modules {
1790        let Some(lifecycle) = &module.manifest.lifecycle else {
1791            continue;
1792        };
1793
1794        for check in &lifecycle.startup_checks {
1795            match &check.check {
1796                LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1797                    if !module_declares_runtime_function(module, function_name) {
1798                        let reason = format!(
1799                            "startup check `{}` references function `{}` not declared by module `{}`",
1800                            check.name, function_name, module.manifest.name
1801                        );
1802                        if !check.required {
1803                            warn_optional_lifecycle_skip(
1804                                &module.manifest.name,
1805                                "startup_checks",
1806                                &check.name,
1807                                &reason,
1808                            );
1809                            continue;
1810                        }
1811                        return Err(lifecycle_validation_error(
1812                            &module.manifest.name,
1813                            "startup_checks",
1814                            &check.name,
1815                            format!("required {reason}"),
1816                        ));
1817                    }
1818                    if registry.get(function_name).is_none() {
1819                        let reason = format!(
1820                            "startup check `{}` references missing function `{}`",
1821                            check.name, function_name
1822                        );
1823                        if !check.required {
1824                            warn_optional_lifecycle_skip(
1825                                &module.manifest.name,
1826                                "startup_checks",
1827                                &check.name,
1828                                &reason,
1829                            );
1830                            continue;
1831                        }
1832                        return Err(lifecycle_validation_error(
1833                            &module.manifest.name,
1834                            "startup_checks",
1835                            &check.name,
1836                            format!("required {reason}"),
1837                        ));
1838                    }
1839                }
1840                LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1841                    if !module.manifest.capabilities.contains(capability) {
1842                        let reason = format!(
1843                            "startup check `{}` references missing capability `{}`",
1844                            check.name, capability
1845                        );
1846                        if !check.required {
1847                            warn_optional_lifecycle_skip(
1848                                &module.manifest.name,
1849                                "startup_checks",
1850                                &check.name,
1851                                &reason,
1852                            );
1853                            continue;
1854                        }
1855                        return Err(lifecycle_validation_error(
1856                            &module.manifest.name,
1857                            "startup_checks",
1858                            &check.name,
1859                            format!("required {reason}"),
1860                        ));
1861                    }
1862                }
1863                _ => {
1864                    let reason = format!(
1865                        "startup check `{}` uses an unsupported lifecycle check kind",
1866                        check.name
1867                    );
1868                    if !check.required {
1869                        warn_optional_lifecycle_skip(
1870                            &module.manifest.name,
1871                            "startup_checks",
1872                            &check.name,
1873                            &reason,
1874                        );
1875                        continue;
1876                    }
1877                    return Err(lifecycle_validation_error(
1878                        &module.manifest.name,
1879                        "startup_checks",
1880                        &check.name,
1881                        format!("required {reason}"),
1882                    ));
1883                }
1884            }
1885        }
1886
1887        for job in &lifecycle.activation_jobs {
1888            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1889                continue;
1890            }
1891
1892            if !module_declares_runtime_function(module, &job.function_name) {
1893                let reason = format!(
1894                    "activation job `{}` references function `{}` not declared by module `{}`",
1895                    job.name, job.function_name, module.manifest.name
1896                );
1897                if !job.required {
1898                    warn_optional_lifecycle_skip(
1899                        &module.manifest.name,
1900                        "activation_jobs",
1901                        &job.name,
1902                        &reason,
1903                    );
1904                    continue;
1905                }
1906                return Err(lifecycle_validation_error(
1907                    &module.manifest.name,
1908                    "activation_jobs",
1909                    &job.name,
1910                    format!("required {reason}"),
1911                ));
1912            }
1913            if registry.get(&job.function_name).is_none() {
1914                let reason = format!(
1915                    "activation job `{}` references missing function `{}`",
1916                    job.name, job.function_name
1917                );
1918                if !job.required {
1919                    warn_optional_lifecycle_skip(
1920                        &module.manifest.name,
1921                        "activation_jobs",
1922                        &job.name,
1923                        &reason,
1924                    );
1925                    continue;
1926                }
1927                return Err(lifecycle_validation_error(
1928                    &module.manifest.name,
1929                    "activation_jobs",
1930                    &job.name,
1931                    format!("required {reason}"),
1932                ));
1933            }
1934        }
1935    }
1936
1937    Ok(())
1938}
1939
1940fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
1941    module.manifest.runtime.as_ref().is_some_and(|runtime| {
1942        runtime
1943            .functions
1944            .iter()
1945            .any(|function| function.name == function_name)
1946    })
1947}
1948
1949fn lifecycle_validation_error(
1950    module_name: &str,
1951    collection: &str,
1952    item_name: &str,
1953    reason: String,
1954) -> AppError {
1955    AppError::validation(
1956        "Module lifecycle declaration failed validation",
1957        vec![ErrorDetail {
1958            field: Some(format!(
1959                "module.{module_name}.lifecycle.{collection}.{item_name}"
1960            )),
1961            reason,
1962        }],
1963    )
1964}
1965
1966fn warn_optional_lifecycle_skip(
1967    module_name: &str,
1968    collection: &str,
1969    item_name: &str,
1970    reason: &str,
1971) {
1972    tracing::warn!(
1973        module_name = %module_name,
1974        lifecycle_collection = %collection,
1975        lifecycle_item = %item_name,
1976        reason = %reason,
1977        "optional module lifecycle declaration skipped"
1978    );
1979}
1980
1981fn warn_optional_lifecycle_enqueue_failure(
1982    module_name: &str,
1983    job_name: &str,
1984    function_name: &str,
1985    error: &AppError,
1986) {
1987    tracing::warn!(
1988        module_name = %module_name,
1989        lifecycle_collection = "activation_jobs",
1990        lifecycle_item = %job_name,
1991        function_name = %function_name,
1992        error_code = %error.code.as_str(),
1993        error_message = %error.public_message,
1994        "optional module lifecycle activation enqueue failed"
1995    );
1996}
1997
1998fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
1999    i32::try_from(max_attempts).unwrap_or(i32::MAX)
2000}
2001
2002/// Build an [`EventHandlerRegistry`] from every module's binding.
2003#[must_use]
2004pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
2005    event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
2006}
2007
2008/// Build an [`EventHandlerRegistry`] with host runtime actions enabled for
2009/// remote event-handler result actions.
2010#[must_use]
2011pub fn event_handlers_with_runtime_actions(
2012    ctx: &AppContext,
2013    modules: &[Module],
2014    function_registry: Arc<FunctionRegistry>,
2015) -> EventHandlerRegistry {
2016    let context = EventHandlerRegistrationContext::with_runtime(
2017        RuntimeClient::new(ctx.db.clone()),
2018        function_registry,
2019    );
2020    event_handlers_with_context(modules, &context)
2021}
2022
2023fn event_handlers_with_context(
2024    modules: &[Module],
2025    context: &EventHandlerRegistrationContext,
2026) -> EventHandlerRegistry {
2027    let mut registry = EventHandlerRegistry::new();
2028    for module in modules {
2029        module
2030            .binding
2031            .register_event_handlers(&mut registry, context);
2032    }
2033    registry
2034}
2035
2036/// Merge every linked module's HTTP routes (and their `OpenAPI` docs) onto `base`.
2037///
2038/// Linked route builders are context-free, so this assembles the HTTP surface
2039/// without constructing the full module set (which requires an [`AppContext`])
2040/// — usable both for serving and for standalone `OpenAPI` document assembly.
2041/// This is the single source for linked API routes until HTTP joins the
2042/// [`platform_module::ModuleBinding`] seam.
2043pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
2044    merge_linked_http_for_profile(base, CompositionProfile::default())
2045}
2046
2047pub fn merge_linked_http_for_profile(
2048    base: ApiOpenApiRouter,
2049    profile: CompositionProfile,
2050) -> ApiOpenApiRouter {
2051    linked_http_modules_for_profile(profile)
2052        .into_iter()
2053        .filter_map(|module| module.linked_http)
2054        .fold(base, |router, contribution| (contribution.merge)(router))
2055}
2056
2057pub fn merge_linked_http_for_config(
2058    base: ApiOpenApiRouter,
2059    config: &platform_core::AppConfig,
2060) -> platform_core::AppResult<ApiOpenApiRouter> {
2061    Ok(linked_http_modules_for_config(config)?
2062        .into_iter()
2063        .filter_map(|module| module.linked_http)
2064        .fold(base, |router, contribution| (contribution.merge)(router)))
2065}
2066
2067pub fn merge_linked_http_for_context(
2068    base: ApiOpenApiRouter,
2069    ctx: &AppContext,
2070) -> platform_core::AppResult<ApiOpenApiRouter> {
2071    Ok(linked_http_modules_for_context(ctx)?
2072        .into_iter()
2073        .filter_map(|module| module.linked_http)
2074        .fold(base, |router, contribution| (contribution.merge)(router)))
2075}
2076
2077pub fn merge_linked_http_for_context_with_composition(
2078    base: ApiOpenApiRouter,
2079    ctx: &AppContext,
2080    composition: &HostComposition,
2081) -> platform_core::AppResult<ApiOpenApiRouter> {
2082    Ok(
2083        linked_http_modules_for_context_with_composition(ctx, composition)?
2084            .into_iter()
2085            .filter_map(|module| module.linked_http)
2086            .fold(base, |router, contribution| (contribution.merge)(router)),
2087    )
2088}
2089
2090/// Story-display descriptors for every module. Sourced from context-free
2091/// manifests so the `OpenAPI` path stays pure (no [`AppContext`]).
2092#[must_use]
2093pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
2094    story_display_descriptors_for_profile(CompositionProfile::default())
2095}
2096
2097#[must_use]
2098pub fn story_display_descriptors_for_profile(
2099    profile: CompositionProfile,
2100) -> Vec<StoryDisplayDescriptor> {
2101    module_manifests_for_profile(profile)
2102        .into_iter()
2103        .flat_map(story_display_descriptors_from_manifest)
2104        .collect()
2105}
2106
2107pub fn story_display_descriptors_for_config(
2108    config: &platform_core::AppConfig,
2109) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2110    Ok(linked_module_entries_for_config(config)?
2111        .into_iter()
2112        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2113        .collect())
2114}
2115
2116pub fn story_display_descriptors_for_context(
2117    ctx: &AppContext,
2118) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2119    Ok(linked_module_entries_for_context(ctx)?
2120        .into_iter()
2121        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2122        .collect())
2123}
2124
2125pub fn install_default_story_display_catalog(ctx: &AppContext) -> platform_core::AppResult<()> {
2126    install_default_story_display_catalog_with_composition(ctx, &HostComposition::default())
2127}
2128
2129pub fn install_default_story_display_catalog_with_composition(
2130    ctx: &AppContext,
2131    composition: &HostComposition,
2132) -> platform_core::AppResult<()> {
2133    if !linked_module_enabled(ctx, story::module::MODULE_NAME) {
2134        story::backend::install_default_story_display(Vec::new());
2135        return Ok(());
2136    }
2137    let mut descriptors = story_display_descriptors_for_context(ctx)?;
2138    descriptors.extend(
2139        host_linked_modules_for_context(ctx, composition)
2140            .into_iter()
2141            .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)())),
2142    );
2143    story::backend::install_default_story_display(descriptors);
2144    Ok(())
2145}
2146
2147pub fn install_story_display_catalog(metadata: &[AdminModuleMetadata]) {
2148    story::backend::install_story_display(
2149        metadata
2150            .iter()
2151            .flat_map(story_display_descriptors_from_metadata)
2152            .collect(),
2153    );
2154}
2155
2156fn story_display_descriptors_from_metadata(
2157    module: &AdminModuleMetadata,
2158) -> Vec<StoryDisplayDescriptor> {
2159    story_display_descriptors_from_manifest(
2160        ModuleManifest::builder(module.module_name.clone())
2161            .story_display(module.story_display.clone())
2162            .http_routes(module.http_routes.clone())
2163            .build(),
2164    )
2165}
2166
2167fn story_display_descriptors_from_manifest(
2168    manifest: ModuleManifest,
2169) -> Vec<StoryDisplayDescriptor> {
2170    let mut descriptors = manifest.story_display;
2171    let existing_http = descriptors
2172        .iter()
2173        .filter_map(|descriptor| match &descriptor.source {
2174            StoryDisplaySource::HttpRequest { method, path } => {
2175                Some((method.clone(), path.clone()))
2176            }
2177            StoryDisplaySource::ExecutionName { .. } => None,
2178        })
2179        .collect::<Vec<_>>();
2180
2181    descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
2182        let display_name = route.display_name?;
2183        let method = http_method_label(route.method)?;
2184        if existing_http
2185            .iter()
2186            .any(|(existing_method, existing_path)| {
2187                existing_method == method && existing_path == &route.path
2188            })
2189        {
2190            return None;
2191        }
2192        Some(StoryDisplayDescriptor {
2193            source: StoryDisplaySource::HttpRequest {
2194                method: method.to_owned(),
2195                path: route.path,
2196            },
2197            display_name,
2198            story_title: route.story_title,
2199        })
2200    }));
2201    descriptors
2202}
2203
2204fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
2205    Some(match method {
2206        ModuleHttpMethod::Get => "GET",
2207        ModuleHttpMethod::Post => "POST",
2208        ModuleHttpMethod::Put => "PUT",
2209        ModuleHttpMethod::Patch => "PATCH",
2210        ModuleHttpMethod::Delete => "DELETE",
2211        _ => return None,
2212    })
2213}
2214
2215/// Every module's setting descriptors.
2216///
2217/// The single source for the editable configuration registry. Apps build a
2218/// `RuntimeConfigRegistry` from this list at startup.
2219pub fn runtime_config_descriptors(
2220    ctx: &AppContext,
2221) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2222    runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
2223}
2224
2225pub fn runtime_config_descriptors_with_composition(
2226    ctx: &AppContext,
2227    composition: &HostComposition,
2228) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2229    let profile = CompositionProfile::from_config(&ctx.config)?;
2230    let module_enabled_descriptors =
2231        linked_module_entries(profile)
2232            .iter()
2233            .map(|entry| RuntimeConfigDescriptor {
2234                key: module_enabled_config_key(entry.module_name),
2235                scope: RuntimeConfigScope::Shared,
2236                group: Some("modules"),
2237                section: None,
2238                order: 10,
2239                visible_when: None,
2240                generated: None,
2241                value_type: RuntimeConfigType::Bool,
2242                default: serde_json::json!(linked_module_enabled_from_config(
2243                    &ctx.config,
2244                    entry.module_name
2245                )),
2246                editable: true,
2247                restart_only: true,
2248                description: "Whether this linked module is loaded on service startup.",
2249            });
2250    let host_module_enabled_descriptors =
2251        composition
2252            .linked_modules()
2253            .iter()
2254            .map(|entry| RuntimeConfigDescriptor {
2255                key: module_enabled_config_key(entry.module_name),
2256                scope: RuntimeConfigScope::Shared,
2257                group: Some("modules"),
2258                section: None,
2259                order: 10,
2260                visible_when: None,
2261                generated: None,
2262                value_type: RuntimeConfigType::Bool,
2263                default: serde_json::json!(linked_module_enabled_from_config(
2264                    &ctx.config,
2265                    entry.module_name
2266                )),
2267                editable: true,
2268                restart_only: true,
2269                description: "Whether this host linked module is loaded on service startup.",
2270            });
2271    let remote_module_enabled_descriptors =
2272        ctx.config
2273            .module_sources
2274            .remote
2275            .iter()
2276            .map(|source| RuntimeConfigDescriptor {
2277                key: module_enabled_config_key(&source.name),
2278                scope: RuntimeConfigScope::Shared,
2279                group: Some("modules"),
2280                section: None,
2281                order: 10,
2282                visible_when: None,
2283                generated: None,
2284                value_type: RuntimeConfigType::Bool,
2285                default: serde_json::json!(remote_module_enabled_from_config(
2286                    &ctx.config,
2287                    &source.name
2288                )),
2289                editable: true,
2290                restart_only: true,
2291                description: "Whether this remote module is loaded on service startup.",
2292            });
2293    let module_descriptors = linked_module_entries(profile)
2294        .iter()
2295        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2296        .map(|entry| (entry.load)(ctx))
2297        .chain(
2298            host_linked_modules_for_config(&ctx.config, composition)
2299                .into_iter()
2300                .map(|entry| load_host_linked_module(ctx, entry)),
2301        )
2302        .flat_map(|module| module.runtime_config.iter().cloned())
2303        .collect::<Vec<_>>();
2304    // Platform-owned descriptors (e.g. worker knobs) plus every module's; keys
2305    // are globally unique, so chain order is presentation-only.
2306    Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
2307        .iter()
2308        .cloned()
2309        .chain(module_enabled_descriptors)
2310        .chain(host_module_enabled_descriptors)
2311        .chain(remote_module_enabled_descriptors)
2312        .chain(module_descriptors)
2313        .collect())
2314}
2315
2316/// Every config presentation group known to the current composition.
2317pub fn runtime_config_group_descriptors(
2318    ctx: &AppContext,
2319) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2320    runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
2321}
2322
2323pub fn runtime_config_group_descriptors_with_composition(
2324    ctx: &AppContext,
2325    composition: &HostComposition,
2326) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2327    let profile = CompositionProfile::from_config(&ctx.config)?;
2328    let module_groups = linked_module_entries(profile)
2329        .iter()
2330        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2331        .map(|entry| (entry.load)(ctx))
2332        .chain(
2333            host_linked_modules_for_config(&ctx.config, composition)
2334                .into_iter()
2335                .map(|entry| load_host_linked_module(ctx, entry)),
2336        )
2337        .flat_map(|module| module.runtime_config_groups.iter().cloned())
2338        .collect::<Vec<_>>();
2339
2340    Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
2341        .chain(
2342            platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
2343                .iter()
2344                .cloned(),
2345        )
2346        .chain(module_groups)
2347        .collect())
2348}
2349
2350#[cfg(test)]
2351mod tests {
2352    use super::*;
2353    use async_trait::async_trait;
2354    use auth::models::AuthUserId;
2355    use auth::session_policy::{
2356        AuthHostExtension, AuthSessionPolicy, SessionCreateDecision, SessionCreateInput,
2357    };
2358    use platform_core::{
2359        AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
2360        LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig,
2361        RemoteModuleSourceConfig, RuntimeConfigProvider, RuntimeConfigRegistry,
2362        RuntimeConfigSnapshot, ServiceConfig, TelemetryConfig, apply_migrations,
2363    };
2364    use platform_module::{
2365        ConsoleArea, LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration,
2366        LifecycleSurface, ModuleManifestLintSeverity, RuntimeFunctionDeclaration, RuntimeSurface,
2367        lint_module_manifest,
2368    };
2369    use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
2370    use platform_testing::{SequentialIdGenerator, TestDatabase};
2371    use serde_json::{Value, json};
2372    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
2373    use std::collections::BTreeMap;
2374    use std::sync::Arc;
2375    use std::time::Duration;
2376
2377    #[derive(Debug)]
2378    struct TestRuntimeConfigProvider {
2379        snapshot: Arc<RuntimeConfigSnapshot>,
2380    }
2381
2382    impl RuntimeConfigProvider for TestRuntimeConfigProvider {
2383        fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
2384            Arc::clone(&self.snapshot)
2385        }
2386    }
2387
2388    #[test]
2389    fn linked_module_entry_names_match_manifests() {
2390        for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
2391            for entry in linked_module_entries(profile) {
2392                assert_eq!(
2393                    entry.module_name,
2394                    (entry.manifest)().name,
2395                    "linked module entry name must match ModuleManifest::name"
2396                );
2397            }
2398        }
2399    }
2400
2401    #[test]
2402    fn core_profile_excludes_demo_linked_modules() {
2403        let names = module_manifests_for_profile(CompositionProfile::Core)
2404            .into_iter()
2405            .map(|manifest| manifest.name)
2406            .collect::<Vec<_>>();
2407
2408        assert_eq!(names, vec!["platform-story"]);
2409    }
2410
2411    #[test]
2412    fn demo_profile_includes_fixture_linked_modules() {
2413        let names = module_manifests_for_profile(CompositionProfile::Demo)
2414            .into_iter()
2415            .map(|manifest| manifest.name)
2416            .collect::<Vec<_>>();
2417
2418        assert_eq!(names, vec!["auth", "auth-password", "platform-story"]);
2419    }
2420
2421    #[test]
2422    fn http_route_metadata_contributes_story_display_descriptors() {
2423        let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
2424
2425        assert!(descriptors.iter().any(|descriptor| {
2426            matches!(
2427                &descriptor.source,
2428                StoryDisplaySource::HttpRequest { method, path }
2429                    if method == "POST" && path == "/v1/auth/dev/sessions"
2430            ) && descriptor.display_name == "Create Development Session"
2431        }));
2432    }
2433
2434    #[test]
2435    fn core_profile_migrations_exclude_demo_module_migrations() {
2436        let names = migrations_for_profile(CompositionProfile::Core)
2437            .into_iter()
2438            .map(|migration| migration.name)
2439            .collect::<Vec<_>>();
2440
2441        assert!(names.iter().any(|name| name.starts_with("platform/")));
2442        assert!(names.iter().any(|name| name.starts_with("runtime/")));
2443        assert!(!names.iter().any(|name| name.starts_with("auth/")));
2444        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2445    }
2446
2447    #[test]
2448    fn demo_profile_migrations_include_fixture_module_migrations() {
2449        let names = migrations_for_profile(CompositionProfile::Demo)
2450            .into_iter()
2451            .map(|migration| migration.name)
2452            .collect::<Vec<_>>();
2453
2454        assert!(
2455            names
2456                .iter()
2457                .any(|name| name == &"auth/0001_create_auth_schema")
2458        );
2459        assert!(
2460            names
2461                .iter()
2462                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2463        );
2464    }
2465
2466    #[test]
2467    fn host_composition_migrations_include_enabled_host_linked_modules() {
2468        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2469        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2470
2471        let names = migrations_for_config_with_composition(&config, &composition)
2472            .expect("host composition migrations should load")
2473            .into_iter()
2474            .map(|migration| migration.name)
2475            .collect::<Vec<_>>();
2476
2477        assert!(names.iter().any(|name| name == &"billing/0001_init"));
2478    }
2479
2480    #[test]
2481    fn host_composition_can_install_auth_modules() {
2482        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2483        config.module_sources.linked_profile = "core".to_owned();
2484        let composition = HostComposition::new()
2485            .with_linked_module(auth_linked_module())
2486            .with_linked_module(auth_password_linked_module());
2487
2488        let names = migrations_for_config_with_composition(&config, &composition)
2489            .expect("host composition migrations should load")
2490            .into_iter()
2491            .map(|migration| migration.name)
2492            .collect::<Vec<_>>();
2493
2494        assert!(
2495            names
2496                .iter()
2497                .any(|name| name == &"auth/0001_create_auth_schema")
2498        );
2499        assert!(
2500            names
2501                .iter()
2502                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2503        );
2504    }
2505
2506    #[tokio::test]
2507    async fn host_composition_runtime_config_includes_host_module_toggle() {
2508        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2509            .expect("lazy pool should build");
2510        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2511        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2512        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2513
2514        let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
2515            .expect("host composition descriptors should load")
2516            .into_iter()
2517            .map(|descriptor| descriptor.key)
2518            .collect::<Vec<_>>();
2519
2520        assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
2521    }
2522
2523    #[tokio::test]
2524    async fn host_composition_modules_include_manifest_only_modules() {
2525        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2526            .expect("lazy pool should build");
2527        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2528        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2529        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2530
2531        let names = modules_for_config_with_composition(&ctx, &composition)
2532            .expect("host composition modules should load")
2533            .into_iter()
2534            .map(|module| module.manifest.name)
2535            .collect::<Vec<_>>();
2536
2537        assert!(names.iter().any(|name| name == "billing"));
2538    }
2539
2540    #[tokio::test]
2541    async fn host_wiring_collects_auth_session_policy_contributions() {
2542        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2543            .expect("lazy pool should build");
2544        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2545        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2546        let composition = HostComposition::new().with_linked_module(
2547            test_host_linked_module()
2548                .with_contribution(AuthHostExtension::session_policy(test_session_policy)),
2549        );
2550
2551        let wiring = host_wiring_for_context_with_composition(&ctx, &composition)
2552            .expect("host wiring should compose");
2553        let now = ctx.clock.now();
2554        let decision = wiring
2555            .auth_session_policy()
2556            .policy()
2557            .before_session_create(&SessionCreateInput {
2558                user_id: AuthUserId("usr_wiring".to_owned()),
2559                session_id: "sess_wiring".to_owned(),
2560                proposed_device_id: Some("device_wiring".to_owned()),
2561                created_at: now,
2562                expires_at: now,
2563            })
2564            .await
2565            .expect("wired policy should allow session");
2566
2567        assert_eq!(decision.device_id.as_deref(), Some("device_from_wiring"));
2568    }
2569
2570    fn test_session_policy(_ctx: &AppContext) -> Arc<dyn AuthSessionPolicy> {
2571        Arc::new(TestSessionPolicy)
2572    }
2573
2574    #[derive(Debug)]
2575    struct TestSessionPolicy;
2576
2577    #[async_trait]
2578    impl AuthSessionPolicy for TestSessionPolicy {
2579        async fn before_session_create(
2580            &self,
2581            input: &SessionCreateInput,
2582        ) -> platform_core::AppResult<SessionCreateDecision> {
2583            assert_eq!(input.proposed_device_id.as_deref(), Some("device_wiring"));
2584            Ok(SessionCreateDecision {
2585                device_id: Some("device_from_wiring".to_owned()),
2586            })
2587        }
2588    }
2589
2590    #[test]
2591    fn demo_profile_includes_every_core_entry() {
2592        let demo_names = linked_module_entries(CompositionProfile::Demo)
2593            .iter()
2594            .map(|entry| entry.module_name)
2595            .collect::<Vec<_>>();
2596
2597        for core_entry in linked_module_entries(CompositionProfile::Core) {
2598            assert!(
2599                demo_names.contains(&core_entry.module_name),
2600                "demo profile should include core linked module `{}`",
2601                core_entry.module_name
2602            );
2603        }
2604    }
2605
2606    #[test]
2607    fn default_module_manifests_use_demo_profile() {
2608        let names = module_manifests()
2609            .into_iter()
2610            .map(|manifest| manifest.name)
2611            .collect::<Vec<_>>();
2612
2613        assert_eq!(names, vec!["auth", "auth-password", "platform-story"]);
2614    }
2615
2616    #[test]
2617    fn linked_http_route_owners_are_profile_aware() {
2618        assert_eq!(
2619            linked_http_route_owners_for_profile(CompositionProfile::Core),
2620            vec![LinkedHttpRouteOwner {
2621                module_name: "platform-story".to_owned(),
2622                public_prefixes: &["/admin/runtime/stories"],
2623            }]
2624        );
2625        assert_eq!(
2626            linked_http_route_owners_for_profile(CompositionProfile::Demo),
2627            vec![
2628                LinkedHttpRouteOwner {
2629                    module_name: "auth".to_owned(),
2630                    public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
2631                },
2632                LinkedHttpRouteOwner {
2633                    module_name: "auth-password".to_owned(),
2634                    public_prefixes: &["/v1/auth/password/"],
2635                },
2636                LinkedHttpRouteOwner {
2637                    module_name: "platform-story".to_owned(),
2638                    public_prefixes: &["/admin/runtime/stories"],
2639                },
2640            ]
2641        );
2642    }
2643
2644    #[tokio::test]
2645    async fn modules_for_config_uses_core_linked_profile() {
2646        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2647            .expect("lazy pool should build");
2648        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2649        config.module_sources.linked_profile = "core".to_owned();
2650        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2651
2652        let names = modules_for_config(&ctx)
2653            .expect("core linked profile should parse")
2654            .into_iter()
2655            .map(|module| module.manifest.name)
2656            .collect::<Vec<_>>();
2657
2658        assert_eq!(names, vec!["platform-story"]);
2659    }
2660
2661    #[tokio::test]
2662    async fn auth_actor_resolver_is_profile_and_composition_aware() {
2663        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2664            .expect("lazy pool should build");
2665        let demo_ctx = AppContext::new(
2666            test_config_with_database_url("postgres://localhost/lenso_test"),
2667            db.clone(),
2668            Arc::new(LoggingEventPublisher),
2669        );
2670        assert!(
2671            auth_actor_resolver_for_context(&demo_ctx)
2672                .expect("demo profile")
2673                .is_some()
2674        );
2675
2676        let mut composition_config =
2677            test_config_with_database_url("postgres://localhost/lenso_test");
2678        composition_config.module_sources.linked_profile = "core".to_owned();
2679        let composition_ctx = AppContext::new(
2680            composition_config,
2681            db.clone(),
2682            Arc::new(LoggingEventPublisher),
2683        );
2684        let composition = HostComposition::new().with_linked_module(auth_linked_module());
2685        assert!(
2686            auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
2687                .expect("auth composition")
2688                .is_some()
2689        );
2690
2691        let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
2692        core_config.module_sources.linked_profile = "core".to_owned();
2693        let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
2694        assert!(
2695            auth_actor_resolver_for_context(&core_ctx)
2696                .expect("core profile")
2697                .is_none()
2698        );
2699    }
2700
2701    #[tokio::test]
2702    async fn auth_actor_resolver_respects_disabled_auth_module() {
2703        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2704            .expect("lazy pool should build");
2705        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2706        config.modules.insert(
2707            auth::module::MODULE_NAME.to_owned(),
2708            ModuleConfig {
2709                enabled: Some(false),
2710                values: BTreeMap::new(),
2711            },
2712        );
2713        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2714
2715        assert!(
2716            auth_actor_resolver_for_context(&ctx)
2717                .expect("demo profile")
2718                .is_none()
2719        );
2720    }
2721
2722    #[tokio::test]
2723    async fn auth_password_requires_auth_module() {
2724        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2725            .expect("lazy pool should build");
2726        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2727        config.modules.insert(
2728            auth::module::MODULE_NAME.to_owned(),
2729            ModuleConfig {
2730                enabled: Some(false),
2731                values: BTreeMap::new(),
2732            },
2733        );
2734        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2735
2736        let names = modules_for_config(&ctx)
2737            .expect("demo profile")
2738            .into_iter()
2739            .map(|module| module.manifest.name)
2740            .collect::<Vec<_>>();
2741
2742        assert!(!names.iter().any(|name| name == "auth-password"));
2743    }
2744
2745    #[tokio::test]
2746    async fn auth_password_dependency_status_is_visible_in_metadata() {
2747        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2748            .expect("lazy pool should build");
2749        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2750        config.modules.insert(
2751            auth::module::MODULE_NAME.to_owned(),
2752            ModuleConfig {
2753                enabled: Some(false),
2754                values: BTreeMap::new(),
2755            },
2756        );
2757        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2758
2759        let metadata = load_admin_module_metadata(&ctx)
2760            .await
2761            .expect("module metadata should load");
2762        let auth_password = metadata
2763            .iter()
2764            .find(|module| module.module_name == "auth-password")
2765            .expect("dependency-disabled provider should remain visible in metadata");
2766
2767        assert_eq!(
2768            auth_password.dependencies,
2769            vec![auth::module::MODULE_NAME.to_owned()]
2770        );
2771        assert!(matches!(
2772            &auth_password.load_status,
2773            ModuleLoadStatus::Error { message }
2774                if message == "module dependency disabled: auth"
2775        ));
2776    }
2777
2778    #[tokio::test]
2779    async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
2780        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2781            .expect("lazy pool should build");
2782        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2783        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2784        let registry =
2785            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2786                .expect("registry");
2787        let mut stored = BTreeMap::new();
2788        stored.insert(
2789            ("*".to_owned(), "auth-password.token_strategy".to_owned()),
2790            json!("jwt"),
2791        );
2792        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2793        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2794            snapshot: Arc::new(snapshot),
2795        }));
2796
2797        assert!(
2798            auth_actor_resolver_for_context(&ctx)
2799                .expect("JWT resolver should be skipped until jwt_secret is configured")
2800                .is_some()
2801        );
2802    }
2803
2804    #[tokio::test]
2805    async fn auth_actor_resolver_requires_redis_when_session_cache_is_redis() {
2806        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2807            .expect("lazy pool should build");
2808        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2809        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2810        let registry =
2811            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2812                .expect("registry");
2813        let mut stored = BTreeMap::new();
2814        stored.insert(
2815            ("*".to_owned(), "auth.session_cache".to_owned()),
2816            json!("redis"),
2817        );
2818        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2819        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2820            snapshot: Arc::new(snapshot),
2821        }));
2822
2823        let error =
2824            auth_actor_resolver_for_context(&ctx).expect_err("redis cache should require Redis");
2825
2826        assert_eq!(error.code, ErrorCode::Validation);
2827    }
2828
2829    #[tokio::test]
2830    async fn modules_for_config_skips_disabled_linked_modules() {
2831        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2832            .expect("lazy pool should build");
2833        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2834        config.modules.insert(
2835            "auth-password".to_owned(),
2836            ModuleConfig {
2837                enabled: Some(false),
2838                values: BTreeMap::new(),
2839            },
2840        );
2841        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2842
2843        let names = modules_for_config(&ctx)
2844            .expect("demo linked profile should parse")
2845            .into_iter()
2846            .map(|module| module.manifest.name)
2847            .collect::<Vec<_>>();
2848
2849        assert_eq!(names, vec!["auth", "platform-story"]);
2850    }
2851
2852    #[tokio::test]
2853    async fn modules_for_config_uses_runtime_config_enabled_flag() {
2854        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2855            .expect("lazy pool should build");
2856        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2857        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2858        let registry =
2859            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2860                .expect("registry");
2861        let mut stored = BTreeMap::new();
2862        stored.insert(
2863            ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
2864            json!(false),
2865        );
2866        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2867        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2868            snapshot: Arc::new(snapshot),
2869        }));
2870
2871        let names = modules_for_config(&ctx)
2872            .expect("demo linked profile should parse")
2873            .into_iter()
2874            .map(|module| module.manifest.name)
2875            .collect::<Vec<_>>();
2876
2877        assert_eq!(names, vec!["auth", "platform-story"]);
2878        let linked_http_names = linked_http_modules_for_context(&ctx)
2879            .expect("linked HTTP modules should load")
2880            .into_iter()
2881            .map(|module| module.manifest.name)
2882            .collect::<Vec<_>>();
2883
2884        assert_eq!(linked_http_names, vec!["auth", "platform-story"]);
2885    }
2886
2887    #[tokio::test]
2888    async fn story_module_runtime_config_disables_backend_metadata() {
2889        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2890            .expect("lazy pool should build");
2891        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2892        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2893        let registry =
2894            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2895                .expect("registry");
2896        let mut stored = BTreeMap::new();
2897        stored.insert(
2898            ("*".to_owned(), "modules.platform-story.enabled".to_owned()),
2899            json!(false),
2900        );
2901        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2902        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2903            snapshot: Arc::new(snapshot),
2904        }));
2905
2906        let linked_http_names = linked_http_modules_for_context(&ctx)
2907            .expect("linked HTTP modules should load")
2908            .into_iter()
2909            .map(|module| module.manifest.name)
2910            .collect::<Vec<_>>();
2911        assert_eq!(linked_http_names, vec!["auth", "auth-password"]);
2912
2913        let metadata = load_admin_module_metadata(&ctx)
2914            .await
2915            .expect("module metadata should load");
2916        let story = metadata
2917            .iter()
2918            .find(|module| module.module_name == "platform-story")
2919            .expect("disabled story module should remain visible in metadata");
2920
2921        assert!(matches!(
2922            &story.load_status,
2923            ModuleLoadStatus::Error { message }
2924                if message == "module disabled by configuration"
2925        ));
2926        assert_eq!(story.console.len(), 1);
2927        assert_eq!(story.http_routes.len(), story::module::http_routes().len());
2928    }
2929
2930    #[tokio::test]
2931    async fn runtime_config_descriptors_include_module_enabled_flags() {
2932        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2933            .expect("lazy pool should build");
2934        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2935        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2936
2937        let keys = runtime_config_descriptors(&ctx)
2938            .expect("descriptors should load")
2939            .into_iter()
2940            .map(|descriptor| {
2941                (
2942                    descriptor.key,
2943                    descriptor.group,
2944                    descriptor.restart_only,
2945                    descriptor.default,
2946                )
2947            })
2948            .collect::<Vec<_>>();
2949
2950        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2951            key == "modules.auth.enabled"
2952                && *group == Some("modules")
2953                && *restart_only
2954                && default == &json!(true)
2955        }));
2956        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2957            key == "modules.auth-password.enabled"
2958                && *group == Some("modules")
2959                && *restart_only
2960                && default == &json!(true)
2961        }));
2962        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2963            key == "modules.platform-story.enabled"
2964                && *group == Some("modules")
2965                && *restart_only
2966                && default == &json!(true)
2967        }));
2968    }
2969
2970    #[tokio::test]
2971    async fn runtime_config_groups_include_module_owned_groups() {
2972        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2973            .expect("lazy pool should build");
2974        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2975        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2976
2977        let groups = runtime_config_group_descriptors(&ctx)
2978            .expect("groups should load")
2979            .into_iter()
2980            .map(|group| (group.id, group.label))
2981            .collect::<Vec<_>>();
2982
2983        assert!(groups.contains(&("modules", "Modules")));
2984        assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
2985        assert!(groups.contains(&("auth-password.tokens", "Tokens")));
2986        assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
2987    }
2988
2989    #[tokio::test]
2990    async fn runtime_config_descriptors_include_remote_module_enabled_flags() {
2991        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2992            .expect("lazy pool should build");
2993        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2994        config.module_sources.remote.push(RemoteModuleSourceConfig {
2995            name: "remote-crm".to_owned(),
2996            base_url: "http://127.0.0.1:65535".to_owned(),
2997            auth_token_env: None,
2998            timeout_ms: 1,
2999        });
3000        config.modules.insert(
3001            "remote-crm".to_owned(),
3002            ModuleConfig {
3003                enabled: Some(false),
3004                values: BTreeMap::new(),
3005            },
3006        );
3007        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3008
3009        let keys = runtime_config_descriptors(&ctx)
3010            .expect("descriptors should load")
3011            .into_iter()
3012            .map(|descriptor| (descriptor.key, descriptor.restart_only, descriptor.default))
3013            .collect::<Vec<_>>();
3014
3015        assert!(keys.iter().any(|(key, restart_only, default)| {
3016            key == "modules.remote-crm.enabled" && *restart_only && default == &json!(false)
3017        }));
3018    }
3019
3020    #[tokio::test]
3021    async fn load_modules_skips_runtime_disabled_remote_modules() {
3022        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3023            .expect("lazy pool should build");
3024        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3025        config.module_sources.remote.push(RemoteModuleSourceConfig {
3026            name: "remote-crm".to_owned(),
3027            base_url: "http://127.0.0.1:65535".to_owned(),
3028            auth_token_env: None,
3029            timeout_ms: 1,
3030        });
3031        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3032        let registry =
3033            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3034                .expect("registry");
3035        let mut stored = BTreeMap::new();
3036        stored.insert(
3037            ("*".to_owned(), "modules.remote-crm.enabled".to_owned()),
3038            json!(false),
3039        );
3040        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
3041        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3042            snapshot: Arc::new(snapshot),
3043        }));
3044
3045        let names = load_modules(&ctx)
3046            .await
3047            .expect("disabled remote should not be loaded")
3048            .into_iter()
3049            .map(|module| module.manifest.name)
3050            .collect::<Vec<_>>();
3051
3052        assert!(!names.iter().any(|name| name == "remote-crm"));
3053    }
3054
3055    #[tokio::test]
3056    async fn module_metadata_reports_disabled_remote_modules() {
3057        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3058            .expect("lazy pool should build");
3059        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3060        config.module_sources.remote.push(RemoteModuleSourceConfig {
3061            name: "remote-grpc-crm".to_owned(),
3062            base_url: "grpc://127.0.0.1:65535".to_owned(),
3063            auth_token_env: None,
3064            timeout_ms: 1,
3065        });
3066        config.modules.insert(
3067            "remote-grpc-crm".to_owned(),
3068            ModuleConfig {
3069                enabled: Some(false),
3070                values: BTreeMap::new(),
3071            },
3072        );
3073        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3074
3075        let metadata = load_admin_module_metadata(&ctx)
3076            .await
3077            .expect("module metadata should load");
3078        let remote = metadata
3079            .iter()
3080            .find(|module| module.module_name == "remote-grpc-crm")
3081            .expect("disabled remote module should remain visible in metadata");
3082
3083        assert_eq!(remote.source, ModuleSource::Remote);
3084        assert!(matches!(
3085            &remote.load_status,
3086            ModuleLoadStatus::Error { message }
3087                if message == "module disabled by configuration"
3088        ));
3089        assert!(matches!(
3090            &remote.source_diagnostics,
3091            Some(AdminModuleSourceDiagnostics::Remote(diagnostics))
3092                if diagnostics.transport == "grpc"
3093                    && diagnostics.base_url == "http://127.0.0.1:65535"
3094        ));
3095    }
3096
3097    #[test]
3098    fn migrations_for_config_skip_disabled_linked_module_migrations() {
3099        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3100        config.modules.insert(
3101            "auth-password".to_owned(),
3102            ModuleConfig {
3103                enabled: Some(false),
3104                values: BTreeMap::new(),
3105            },
3106        );
3107
3108        let names = migrations_for_config(&config)
3109            .expect("demo linked profile should parse")
3110            .into_iter()
3111            .map(|migration| migration.name)
3112            .collect::<Vec<_>>();
3113
3114        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
3115        assert!(
3116            names
3117                .iter()
3118                .any(|name| name == &"auth/0001_create_auth_schema")
3119        );
3120    }
3121
3122    #[test]
3123    fn linked_http_modules_for_config_skip_disabled_linked_routes() {
3124        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3125        config.modules.insert(
3126            "auth-password".to_owned(),
3127            ModuleConfig {
3128                enabled: Some(false),
3129                values: BTreeMap::new(),
3130            },
3131        );
3132
3133        let names = linked_http_modules_for_config(&config)
3134            .expect("demo linked profile should parse")
3135            .into_iter()
3136            .map(|module| module.manifest.name)
3137            .collect::<Vec<_>>();
3138
3139        assert_eq!(names, vec!["auth", "platform-story"]);
3140    }
3141
3142    #[test]
3143    fn linked_http_modules_for_config_skip_disabled_story_routes() {
3144        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3145        config.modules.insert(
3146            "platform-story".to_owned(),
3147            ModuleConfig {
3148                enabled: Some(false),
3149                values: BTreeMap::new(),
3150            },
3151        );
3152
3153        let names = linked_http_modules_for_config(&config)
3154            .expect("demo linked profile should parse")
3155            .into_iter()
3156            .map(|module| module.manifest.name)
3157            .collect::<Vec<_>>();
3158
3159        assert_eq!(names, vec!["auth", "auth-password"]);
3160    }
3161
3162    #[tokio::test]
3163    async fn disabled_story_module_omits_default_story_display_catalog() {
3164        story::backend::reset_catalogs_for_test();
3165        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3166        config.modules.insert(
3167            "platform-story".to_owned(),
3168            ModuleConfig {
3169                enabled: Some(false),
3170                values: BTreeMap::new(),
3171            },
3172        );
3173        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3174            .expect("lazy pool should build");
3175        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3176
3177        install_default_story_display_catalog(&ctx)
3178            .expect("story display catalog installation should succeed");
3179
3180        assert!(story::backend::story_display_catalog_snapshot().is_empty());
3181    }
3182
3183    #[tokio::test]
3184    async fn module_metadata_reports_disabled_linked_modules() {
3185        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3186            .expect("lazy pool should build");
3187        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3188        config.modules.insert(
3189            "auth-password".to_owned(),
3190            ModuleConfig {
3191                enabled: Some(false),
3192                values: BTreeMap::new(),
3193            },
3194        );
3195        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3196
3197        let metadata = load_admin_module_metadata(&ctx)
3198            .await
3199            .expect("module metadata should load");
3200        let auth_password = metadata
3201            .iter()
3202            .find(|module| module.module_name == "auth-password")
3203            .expect("disabled module should remain visible in metadata");
3204
3205        assert!(matches!(
3206            &auth_password.load_status,
3207            ModuleLoadStatus::Error { message }
3208                if message == "module disabled by configuration"
3209        ));
3210    }
3211
3212    #[test]
3213    fn composition_profile_rejects_unknown_values() {
3214        let error = CompositionProfile::parse("fixture")
3215            .expect_err("fixture is not a supported linked module profile");
3216
3217        assert_eq!(error.code, ErrorCode::Validation);
3218        assert!(
3219            error
3220                .details
3221                .iter()
3222                .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
3223        );
3224    }
3225
3226    #[test]
3227    fn linked_http_route_owners_are_projected_from_modules() {
3228        assert_eq!(
3229            linked_http_route_owners(),
3230            vec![
3231                LinkedHttpRouteOwner {
3232                    module_name: "auth".to_owned(),
3233                    public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
3234                },
3235                LinkedHttpRouteOwner {
3236                    module_name: "auth-password".to_owned(),
3237                    public_prefixes: &["/v1/auth/password/"],
3238                },
3239                LinkedHttpRouteOwner {
3240                    module_name: "platform-story".to_owned(),
3241                    public_prefixes: &["/admin/runtime/stories"],
3242                },
3243            ]
3244        );
3245    }
3246
3247    #[test]
3248    fn linked_http_bindings_are_declared_in_manifests() {
3249        for module in linked_http_modules() {
3250            let http = module
3251                .linked_http
3252                .expect("linked HTTP module should carry HTTP contribution");
3253            assert!(
3254                !module.manifest.http_routes.is_empty(),
3255                "linked HTTP module `{}` must declare ModuleManifest::http_routes",
3256                module.manifest.name
3257            );
3258            for route in &module.manifest.http_routes {
3259                assert!(
3260                    http.public_prefixes
3261                        .iter()
3262                        .any(|prefix| route.path.starts_with(prefix)),
3263                    "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
3264                    module.manifest.name,
3265                    route.path
3266                );
3267            }
3268        }
3269    }
3270
3271    #[test]
3272    fn linked_http_modules_are_registered_modules() {
3273        let manifests = module_manifests();
3274
3275        for module in linked_http_modules() {
3276            let registered_manifest = manifests
3277                .iter()
3278                .find(|manifest| manifest.name == module.manifest.name)
3279                .unwrap_or_else(|| {
3280                    panic!(
3281                        "linked HTTP module `{}` is missing from module_manifests",
3282                        module.manifest.name
3283                    )
3284                });
3285            assert_eq!(
3286                registered_manifest, &module.manifest,
3287                "linked HTTP module `{}` must use the registered ModuleManifest",
3288                module.manifest.name
3289            );
3290        }
3291    }
3292
3293    #[test]
3294    fn linked_http_routes_include_story_module_routes() {
3295        let document = merge_linked_http(platform_http::OpenApiRouter::new()).to_openapi();
3296        let value = serde_json::to_value(document).expect("OpenAPI document should serialize");
3297        let paths = value["paths"].as_object().expect("OpenAPI paths object");
3298
3299        assert!(paths.contains_key("/admin/runtime/stories"));
3300        assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}"));
3301        assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/heatmap"));
3302        assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/technical-operations"));
3303    }
3304
3305    #[test]
3306    fn platform_story_manifest_declares_story_console_surface() {
3307        let manifest = module_manifests()
3308            .into_iter()
3309            .find(|manifest| manifest.name == "platform-story")
3310            .expect("platform-story manifest should be registered");
3311        let console_surface_contract: Value = serde_json::from_str(include_str!(
3312            "../../../modules/story/console/console-surface.json"
3313        ))
3314        .expect("story console surface contract should be valid json");
3315
3316        assert_eq!(manifest.admin, None);
3317        assert_eq!(manifest.console.len(), 1);
3318        let surface = &manifest.console[0];
3319        let surface_json =
3320            serde_json::to_value(surface).expect("platform-story console surface should serialize");
3321
3322        assert_eq!(
3323            manifest.capabilities,
3324            required_capabilities_from_contract(&console_surface_contract)
3325        );
3326        assert_eq!(manifest.name, console_surface_contract["id"]);
3327        assert_eq!(surface.name, console_surface_contract["surfaceName"]);
3328        assert_eq!(surface.label, console_surface_contract["label"]);
3329        assert_eq!(surface.area, ConsoleArea::Runtime);
3330        assert_eq!(surface_json["area"], console_surface_contract["area"]);
3331        assert_eq!(surface.route, console_surface_contract["route"]);
3332        assert_eq!(
3333            surface.package.name,
3334            console_surface_contract["packageName"]
3335        );
3336        assert_eq!(
3337            surface.package.export,
3338            console_surface_contract["exportName"]
3339        );
3340        assert_eq!(surface_json["icon"], console_surface_contract["icon"]);
3341        assert_eq!(surface.navigation, None);
3342        assert!(console_surface_contract.get("navigation").is_none());
3343        assert_eq!(
3344            surface.required_capabilities,
3345            required_capabilities_from_contract(&console_surface_contract)
3346        );
3347
3348        let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
3349        assert!(
3350            lints
3351                .iter()
3352                .all(|lint| lint.severity == ModuleManifestLintSeverity::Ok),
3353            "platform-story manifest should not have warning/error lints: {lints:?}"
3354        );
3355    }
3356
3357    fn required_capabilities_from_contract(contract: &Value) -> Vec<String> {
3358        contract["requiredCapabilities"]
3359            .as_array()
3360            .expect("requiredCapabilities should be an array")
3361            .iter()
3362            .map(|capability| {
3363                capability
3364                    .as_str()
3365                    .expect("requiredCapabilities should contain strings")
3366                    .to_owned()
3367            })
3368            .collect()
3369    }
3370
3371    #[tokio::test]
3372    async fn lifecycle_activation_enqueue_creates_function_run() {
3373        let Some(db) = TestDatabase::create().await else {
3374            return;
3375        };
3376        apply_runtime_stack_migrations(&db).await;
3377
3378        let mut ctx = AppContext::new(
3379            test_config(&db),
3380            db.pool.clone(),
3381            Arc::new(LoggingEventPublisher),
3382        );
3383        ctx.ids = Arc::new(SequentialIdGenerator::default());
3384        let modules = vec![
3385            test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
3386                .into(),
3387        ];
3388        let registry = registry_with_lifecycle_function(7);
3389
3390        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3391            .await
3392            .expect("lifecycle activation job should enqueue");
3393
3394        assert_eq!(run_ids.len(), 1);
3395        let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
3396            r#"
3397            select function_name, input_json, max_attempts, correlation_id, actor
3398            from runtime.function_runs
3399            where id = $1
3400            "#,
3401        )
3402        .bind(&run_ids[0])
3403        .fetch_one(&db.pool)
3404        .await
3405        .expect("function run should exist");
3406
3407        assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
3408        assert_eq!(row.1["warm"], "cache");
3409        assert_eq!(
3410            row.1["_lenso_runtime"]["correlation_id"],
3411            "corr_lifecycle_1"
3412        );
3413        assert_eq!(
3414            row.1["_lenso_runtime"]["causation_id"],
3415            "module_lifecycle:test-module:warm cache"
3416        );
3417        assert_eq!(row.2, 7);
3418        assert_eq!(row.3, "corr_lifecycle_1");
3419        assert_eq!(row.4["kind"], "service");
3420        assert_eq!(row.4["service_id"], "worker");
3421        assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
3422
3423        db.cleanup().await;
3424    }
3425
3426    #[test]
3427    fn lifecycle_activation_validation_rejects_required_missing_function() {
3428        let modules =
3429            vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
3430        let registry = FunctionRegistry::default();
3431
3432        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3433            .expect_err("required missing activation function should fail validation");
3434
3435        assert_eq!(error.code, ErrorCode::Validation);
3436        assert_eq!(
3437            error.details[0].field.as_deref(),
3438            Some("module.test-module.lifecycle.activation_jobs.warm cache")
3439        );
3440        assert!(
3441            error.details[0].reason.contains("missing function"),
3442            "validation detail should name the missing registry function"
3443        );
3444    }
3445
3446    #[test]
3447    fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
3448        let modules = vec![test_lifecycle_module_with_lifecycle(
3449            LifecycleSurface {
3450                startup_checks: vec![LifecycleStartupCheckDeclaration {
3451                    name: "function registered".to_owned(),
3452                    required: true,
3453                    check: LifecycleStartupCheckKind::FunctionRegistered {
3454                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3455                    },
3456                }],
3457                activation_jobs: Vec::new(),
3458            },
3459            true,
3460            Vec::new(),
3461        )];
3462        let registry = FunctionRegistry::default();
3463
3464        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3465            .expect_err("required startup check should fail when function is missing");
3466
3467        assert_eq!(error.code, ErrorCode::Validation);
3468        assert_eq!(
3469            error.details[0].field.as_deref(),
3470            Some("module.test-module.lifecycle.startup_checks.function registered")
3471        );
3472        assert!(
3473            error.details[0].reason.contains("missing function"),
3474            "validation detail should name the missing registry function"
3475        );
3476    }
3477
3478    #[test]
3479    fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
3480        let modules = vec![test_lifecycle_module_with_lifecycle(
3481            LifecycleSurface {
3482                startup_checks: vec![LifecycleStartupCheckDeclaration {
3483                    name: "function registered".to_owned(),
3484                    required: true,
3485                    check: LifecycleStartupCheckKind::FunctionRegistered {
3486                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3487                    },
3488                }],
3489                activation_jobs: Vec::new(),
3490            },
3491            false,
3492            Vec::new(),
3493        )];
3494        let registry = registry_with_lifecycle_function(3);
3495
3496        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3497            .expect_err("required startup check should fail when manifest does not declare it");
3498
3499        assert_eq!(error.code, ErrorCode::Validation);
3500        assert_eq!(
3501            error.details[0].field.as_deref(),
3502            Some("module.test-module.lifecycle.startup_checks.function registered")
3503        );
3504        assert!(
3505            error.details[0].reason.contains("not declared"),
3506            "validation detail should name the missing module runtime declaration"
3507        );
3508    }
3509
3510    #[test]
3511    fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
3512        let modules = vec![test_lifecycle_module_with_lifecycle(
3513            LifecycleSurface {
3514                startup_checks: vec![LifecycleStartupCheckDeclaration {
3515                    name: "capability declared".to_owned(),
3516                    required: true,
3517                    check: LifecycleStartupCheckKind::CapabilityDeclared {
3518                        capability: "test.cache.warm".to_owned(),
3519                    },
3520                }],
3521                activation_jobs: Vec::new(),
3522            },
3523            false,
3524            Vec::new(),
3525        )];
3526        let registry = FunctionRegistry::default();
3527
3528        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3529            .expect_err("required startup check should fail when capability is missing");
3530
3531        assert_eq!(error.code, ErrorCode::Validation);
3532        assert_eq!(
3533            error.details[0].field.as_deref(),
3534            Some("module.test-module.lifecycle.startup_checks.capability declared")
3535        );
3536        assert!(
3537            error.details[0].reason.contains("missing capability"),
3538            "validation detail should name the missing capability"
3539        );
3540    }
3541
3542    #[test]
3543    fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
3544        let modules = vec![test_lifecycle_module_with_lifecycle(
3545            LifecycleSurface {
3546                startup_checks: vec![
3547                    LifecycleStartupCheckDeclaration {
3548                        name: "optional function".to_owned(),
3549                        required: false,
3550                        check: LifecycleStartupCheckKind::FunctionRegistered {
3551                            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3552                        },
3553                    },
3554                    LifecycleStartupCheckDeclaration {
3555                        name: "optional capability".to_owned(),
3556                        required: false,
3557                        check: LifecycleStartupCheckKind::CapabilityDeclared {
3558                            capability: "test.cache.warm".to_owned(),
3559                        },
3560                    },
3561                ],
3562                activation_jobs: Vec::new(),
3563            },
3564            false,
3565            Vec::new(),
3566        )];
3567        let registry = FunctionRegistry::default();
3568
3569        validate_lifecycle_activation_jobs(&modules, &registry)
3570            .expect("optional startup checks should not fail validation");
3571    }
3572
3573    #[test]
3574    fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
3575        let modules = vec![
3576            test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
3577                .without_runtime_declaration()
3578                .into(),
3579        ];
3580        let registry = registry_with_lifecycle_function(3);
3581
3582        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3583            .expect_err("required activation job should fail when manifest does not declare it");
3584
3585        assert_eq!(error.code, ErrorCode::Validation);
3586        assert_eq!(
3587            error.details[0].field.as_deref(),
3588            Some("module.test-module.lifecycle.activation_jobs.warm cache")
3589        );
3590        assert!(
3591            error.details[0].reason.contains("not declared"),
3592            "validation detail should name the missing module runtime declaration"
3593        );
3594    }
3595
3596    #[tokio::test]
3597    async fn optional_missing_lifecycle_activation_is_skipped() {
3598        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3599            .expect("lazy pool should build");
3600        let ctx = AppContext::new(
3601            test_config_with_database_url("postgres://localhost/lenso_test"),
3602            db,
3603            Arc::new(LoggingEventPublisher),
3604        );
3605        let modules =
3606            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
3607        let registry = FunctionRegistry::default();
3608
3609        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3610            .await
3611            .expect("optional missing activation function should be skipped");
3612
3613        assert!(run_ids.is_empty());
3614    }
3615
3616    #[tokio::test]
3617    async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
3618        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3619            .expect("lazy pool should build");
3620        let ctx = AppContext::new(
3621            test_config_with_database_url("postgres://localhost/lenso_test"),
3622            db,
3623            Arc::new(LoggingEventPublisher),
3624        );
3625        let modules = vec![
3626            test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
3627                .without_runtime_declaration()
3628                .into(),
3629        ];
3630        let registry = registry_with_lifecycle_function(3);
3631
3632        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3633            .await
3634            .expect("optional undeclared activation function should be skipped");
3635
3636        assert!(run_ids.is_empty());
3637    }
3638
3639    #[tokio::test]
3640    async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
3641        let db = PgPoolOptions::new()
3642            .max_connections(1)
3643            .acquire_timeout(Duration::from_millis(50))
3644            .connect_lazy_with(
3645                PgConnectOptions::new()
3646                    .host("127.0.0.1")
3647                    .port(1)
3648                    .username("postgres")
3649                    .database("lenso_test"),
3650            );
3651        let ctx = AppContext::new(
3652            test_config_with_database_url("postgres://localhost:1/lenso_test"),
3653            db,
3654            Arc::new(LoggingEventPublisher),
3655        );
3656        let modules =
3657            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
3658        let registry = registry_with_lifecycle_function(3);
3659
3660        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3661            .await
3662            .expect("optional enqueue failure should be skipped");
3663
3664        assert!(run_ids.is_empty());
3665    }
3666
3667    #[test]
3668    fn lifecycle_activation_max_attempts_conversion_saturates() {
3669        assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
3670        assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
3671    }
3672
3673    const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
3674
3675    #[derive(Debug)]
3676    struct NoopFunctionHandler;
3677
3678    #[async_trait]
3679    impl FunctionHandler for NoopFunctionHandler {
3680        async fn call(
3681            &self,
3682            _ctx: ExecutionContext,
3683            _input: Value,
3684        ) -> platform_core::AppResult<Value> {
3685            Ok(Value::Null)
3686        }
3687    }
3688
3689    fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
3690        LifecycleActivationJobDeclaration {
3691            name: "warm cache".to_owned(),
3692            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3693            run_policy: LifecycleActivationRunPolicy::EveryStartup,
3694            input,
3695            required,
3696        }
3697    }
3698
3699    struct TestLifecycleModuleBuilder {
3700        lifecycle: LifecycleSurface,
3701        declare_runtime_function: bool,
3702        capabilities: Vec<String>,
3703    }
3704
3705    impl TestLifecycleModuleBuilder {
3706        fn without_runtime_declaration(mut self) -> Self {
3707            self.declare_runtime_function = false;
3708            self
3709        }
3710    }
3711
3712    impl From<TestLifecycleModuleBuilder> for Module {
3713        fn from(builder: TestLifecycleModuleBuilder) -> Self {
3714            let mut manifest = ModuleManifest::builder("test-module").lifecycle(builder.lifecycle);
3715            if builder.declare_runtime_function {
3716                manifest = manifest.runtime(RuntimeSurface {
3717                    functions: vec![RuntimeFunctionDeclaration {
3718                        name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3719                        version: 1,
3720                        queue: "test".to_owned(),
3721                        input_schema: None,
3722                        retry_policy: None,
3723                    }],
3724                });
3725            }
3726            if !builder.capabilities.is_empty() {
3727                manifest = manifest.capabilities(builder.capabilities);
3728            }
3729            Module::linked(manifest.build(), LinkedBinding::builder().build())
3730        }
3731    }
3732
3733    fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
3734        TestLifecycleModuleBuilder {
3735            lifecycle: LifecycleSurface {
3736                startup_checks: Vec::new(),
3737                activation_jobs: vec![job],
3738            },
3739            declare_runtime_function: true,
3740            capabilities: Vec::new(),
3741        }
3742    }
3743
3744    fn test_lifecycle_module_with_lifecycle(
3745        lifecycle: LifecycleSurface,
3746        declare_runtime_function: bool,
3747        capabilities: Vec<String>,
3748    ) -> Module {
3749        TestLifecycleModuleBuilder {
3750            lifecycle,
3751            declare_runtime_function,
3752            capabilities,
3753        }
3754        .into()
3755    }
3756
3757    fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
3758        let mut registry = FunctionRegistry::default();
3759        registry.register(FunctionDefinition {
3760            name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3761            version: 1,
3762            queue: "test".to_owned(),
3763            retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
3764            handler: Arc::new(NoopFunctionHandler),
3765        });
3766        registry
3767    }
3768
3769    #[test]
3770    fn remote_module_service_specs_parse() {
3771        let specs = parse_remote_module_service_specs(&serde_json::json!({
3772            "modules": [
3773                {
3774                    "moduleName": "crm",
3775                    "services": [
3776                        {
3777                            "name": "crm-api",
3778                            "command": "pnpm dev",
3779                            "cwd": "../crm",
3780                            "readyUrl": "http://127.0.0.1:4100/lenso/module/v1/manifest",
3781                            "readyTimeoutMs": 12000,
3782                            "autoStart": true
3783                        }
3784                    ]
3785                }
3786            ],
3787            "version": 1
3788        }))
3789        .expect("service specs parse");
3790
3791        assert_eq!(specs.len(), 1);
3792        assert_eq!(specs[0].module_name, "crm");
3793        assert_eq!(specs[0].service_name, "crm-api");
3794        assert_eq!(specs[0].ready_timeout_ms, 12000);
3795    }
3796
3797    #[test]
3798    fn remote_module_service_state_path_sanitizes_names() {
3799        let spec = RemoteModuleServiceSpec {
3800            module_name: "CRM Module".to_owned(),
3801            service_name: "API Worker!".to_owned(),
3802            command: "pnpm dev".to_owned(),
3803            cwd: None,
3804            ready_url: "http://127.0.0.1:4100/lenso/module/v1/manifest".to_owned(),
3805            ready_timeout_ms: 12000,
3806            auto_start: true,
3807        };
3808
3809        let path = remote_module_service_state_path(Path::new(".lenso"), &spec, "lock");
3810
3811        assert_eq!(
3812            path,
3813            PathBuf::from(".lenso/remote-crm-module-api-worker.lock")
3814        );
3815    }
3816
3817    #[test]
3818    fn remote_module_service_lock_is_exclusive_and_released() {
3819        let unique = std::time::SystemTime::now()
3820            .duration_since(std::time::UNIX_EPOCH)
3821            .expect("system time should be after Unix epoch")
3822            .as_nanos();
3823        let dir = std::env::temp_dir().join(format!(
3824            "lenso-bootstrap-service-lock-{}-{unique}",
3825            std::process::id()
3826        ));
3827        let lock_file_path = dir.join("service.lock");
3828        let pid_file_path = dir.join("service.pid");
3829
3830        let _ = std::fs::remove_dir_all(&dir);
3831        create_remote_module_service_lock(&lock_file_path)
3832            .expect("first lock claim should create the lock");
3833        let second_claim = create_remote_module_service_lock(&lock_file_path)
3834            .expect_err("second lock claim should fail while the file exists");
3835        assert_eq!(second_claim.kind(), std::io::ErrorKind::AlreadyExists);
3836        std::fs::write(&pid_file_path, "123\n").expect("pid file should write");
3837
3838        release_remote_module_service_state(&lock_file_path, &pid_file_path);
3839
3840        assert!(!lock_file_path.exists());
3841        assert!(!pid_file_path.exists());
3842        let _ = std::fs::remove_dir_all(&dir);
3843    }
3844
3845    const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
3846        name: "billing/0001_init",
3847        sql: "select 1;",
3848    }];
3849
3850    fn test_host_manifest() -> ModuleManifest {
3851        ModuleManifest::builder("billing").build()
3852    }
3853
3854    fn test_host_linked_module() -> HostLinkedModule {
3855        HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
3856    }
3857
3858    fn test_config(db: &TestDatabase) -> AppConfig {
3859        test_config_with_database_url(db.url.clone())
3860    }
3861
3862    fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
3863        AppConfig {
3864            service: ServiceConfig::default(),
3865            database: DatabaseConfig {
3866                url: database_url.into(),
3867                max_connections: 5,
3868            },
3869            redis: RedisConfig::default(),
3870            http: HttpConfig::default(),
3871            telemetry: TelemetryConfig::default(),
3872            auth: AuthConfig::default(),
3873            console: Default::default(),
3874            module_sources: ModuleSourcesConfig::default(),
3875            modules: BTreeMap::new(),
3876        }
3877    }
3878
3879    async fn apply_runtime_stack_migrations(db: &TestDatabase) {
3880        let migrations = PLATFORM_MIGRATIONS
3881            .iter()
3882            .chain(RUNTIME_MIGRATIONS)
3883            .copied()
3884            .collect::<Vec<_>>();
3885        apply_migrations(&db.pool, &migrations)
3886            .await
3887            .expect("platform and runtime migrations should apply");
3888    }
3889}