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