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