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