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