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