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`]: config-aware Linked Module loader that honors the
10//!   selected composition profile.
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_core::error::ErrorDetail;
22use platform_core::{
23    ActorContext, AppContext, AppError, CorrelationId, ErrorCode, EventHandlerRegistry, Migration,
24    PLATFORM_MIGRATIONS, RuntimeConfigDescriptor, RuntimeConfigGroupDescriptor, RuntimeConfigScope,
25    RuntimeConfigType, StoryDisplayDescriptor, StoryDisplaySource, TraceContext,
26};
27use platform_http::ApiOpenApiRouter;
28use platform_module::CronSchedule;
29pub use platform_module::HostLinkedModule;
30use platform_module::{
31    EventHandlerRegistrationContext, LifecycleActivationRunPolicy, LifecycleStartupCheckKind,
32    LinkedBinding, Module, ModuleHttpMethod, ModuleLoadStatus, ModuleManifest, ModuleSource,
33};
34use platform_provider::{ProviderRuntimeAdapter, ProviderRuntimeAdapters};
35use platform_runtime::{
36    EnqueueFunctionRequest, FunctionRegistry, RUNTIME_MIGRATIONS, RuntimeClient,
37    ScheduledFunctionDefinition,
38};
39use std::path::Path;
40use std::sync::Arc;
41
42#[derive(Clone)]
43pub struct HostSystemPlaneConfig {
44    pub service_id: String,
45    pub service_principal: String,
46    pub service_revision: String,
47    pub audience: String,
48    pub workspace_root: std::path::PathBuf,
49    pub workload_identity: Arc<dyn lenso_service::WorkloadIdentityProvider>,
50    pub enrollment_authorizer: Arc<dyn platform_system_plane::EnrollmentAuthorizer>,
51    pub runtime_observability:
52        Option<Arc<platform_runtime_observability::RuntimeObservabilityProvider>>,
53    pub runtime_operations: Option<Arc<platform_runtime_operations::RuntimeOperationsProvider>>,
54}
55
56impl std::fmt::Debug for HostSystemPlaneConfig {
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        formatter
59            .debug_struct("HostSystemPlaneConfig")
60            .field("service_id", &self.service_id)
61            .field("service_principal", &self.service_principal)
62            .field("service_revision", &self.service_revision)
63            .field("audience", &self.audience)
64            .field("workspace_root", &self.workspace_root)
65            .field("workload_identity", &self.workload_identity)
66            .field("enrollment_authorizer", &self.enrollment_authorizer)
67            .field("runtime_observability", &self.runtime_observability)
68            .field("runtime_operations", &self.runtime_operations)
69            .finish()
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct HostSystemPlaneRuntime {
75    pub core: Arc<platform_system_plane::SystemPlaneRuntime>,
76    pub service_installations: Arc<platform_module_management::ServiceInstallationsProvider>,
77    pub runtime_observability:
78        Option<Arc<platform_runtime_observability::RuntimeObservabilityProvider>>,
79    pub runtime_operations: Option<Arc<platform_runtime_operations::RuntimeOperationsProvider>>,
80}
81
82pub fn compose_host_system_plane_runtime(
83    config: HostSystemPlaneConfig,
84) -> platform_core::AppResult<HostSystemPlaneRuntime> {
85    let service_installations = Arc::new(
86        platform_module_management::ServiceInstallationsProvider::new(config.workspace_root),
87    );
88    let mut registry = platform_system_plane::SystemPlaneRegistryBuilder::new(
89        &config.service_id,
90        &config.service_principal,
91        &config.service_revision,
92    )
93    .register(platform_module_management::ServiceInstallationsProvider::advertisement());
94    if config.runtime_observability.is_some() {
95        registry = registry.register(
96            platform_runtime_observability::RuntimeObservabilityProvider::advertisement(),
97        );
98    }
99    if config.runtime_operations.is_some() {
100        registry = registry
101            .register(platform_runtime_operations::RuntimeOperationsProvider::advertisement());
102    }
103    let registry = registry.build().map_err(|issues| {
104        AppError::new(
105            ErrorCode::Validation,
106            format!("Host System Plane registry is invalid: {issues:?}"),
107        )
108    })?;
109    let access = platform_system_plane::SystemPlaneAccess::new(
110        config.workload_identity,
111        config.audience,
112        config.enrollment_authorizer,
113    );
114    Ok(HostSystemPlaneRuntime {
115        core: Arc::new(platform_system_plane::SystemPlaneRuntime::new(
116            registry, access,
117        )),
118        service_installations,
119        runtime_observability: config.runtime_observability,
120        runtime_operations: config.runtime_operations,
121    })
122}
123
124struct LinkedModuleEntry {
125    module_name: &'static str,
126    manifest: fn() -> ModuleManifest,
127    load: fn(&AppContext) -> Module,
128    http_binding: Option<fn() -> LinkedBinding>,
129}
130
131const MODULES_CONFIG_GROUP: RuntimeConfigGroupDescriptor = RuntimeConfigGroupDescriptor {
132    id: "modules",
133    label: "Modules",
134    description: "Module load toggles applied on service startup.",
135    order: 10,
136};
137
138#[derive(Debug, Clone)]
139pub struct HostComposition {
140    linked_modules: Vec<HostLinkedModule>,
141    provider_runtime_adapters: ProviderRuntimeAdapters,
142}
143
144impl Default for HostComposition {
145    fn default() -> Self {
146        Self {
147            linked_modules: Vec::new(),
148            provider_runtime_adapters: ProviderRuntimeAdapters::production_defaults(),
149        }
150    }
151}
152
153impl HostComposition {
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    #[must_use]
160    pub fn with_linked_module(mut self, module: HostLinkedModule) -> Self {
161        self.add_linked_module(module);
162        self
163    }
164
165    pub fn add_linked_module(&mut self, module: HostLinkedModule) {
166        self.linked_modules.push(module);
167    }
168
169    #[must_use]
170    pub fn linked_modules(&self) -> &[HostLinkedModule] {
171        &self.linked_modules
172    }
173
174    #[must_use]
175    pub fn with_provider_runtime_adapters(mut self, adapters: ProviderRuntimeAdapters) -> Self {
176        self.provider_runtime_adapters = adapters;
177        self
178    }
179
180    #[must_use]
181    pub fn provider_runtime_adapters(&self) -> &ProviderRuntimeAdapters {
182        &self.provider_runtime_adapters
183    }
184}
185
186#[derive(Debug, Clone)]
187pub struct HostWiring {
188    auth_session_policy: auth::session_policy::AuthSessionPolicyHandle,
189}
190
191impl HostWiring {
192    #[must_use]
193    pub fn auth_session_policy(&self) -> auth::session_policy::AuthSessionPolicyHandle {
194        self.auth_session_policy.clone()
195    }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum CompositionProfile {
200    Core,
201    Demo,
202}
203
204impl CompositionProfile {
205    pub fn parse(value: &str) -> platform_core::AppResult<Self> {
206        match value.trim().to_ascii_lowercase().as_str() {
207            "core" => Ok(Self::Core),
208            "demo" => Ok(Self::Demo),
209            other => Err(AppError::validation(
210                "Invalid Lenso composition profile",
211                vec![ErrorDetail {
212                    field: Some("module_sources.linked_profile".to_owned()),
213                    reason: format!("expected `core` or `demo`, got `{other}`"),
214                }],
215            )),
216        }
217    }
218
219    pub fn from_config(config: &platform_core::AppConfig) -> platform_core::AppResult<Self> {
220        Self::parse(&config.module_sources.linked_profile)
221    }
222}
223
224impl Default for CompositionProfile {
225    fn default() -> Self {
226        Self::Demo
227    }
228}
229
230const CORE_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[];
231
232const DEMO_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[
233    LinkedModuleEntry {
234        module_name: "auth",
235        manifest: auth::module::manifest,
236        load: auth::module::module,
237        http_binding: Some(auth::module::binding),
238    },
239    LinkedModuleEntry {
240        module_name: "auth-anonymous",
241        manifest: auth_anonymous::module::manifest,
242        load: auth_anonymous::module::module,
243        http_binding: Some(auth_anonymous::module::binding),
244    },
245    LinkedModuleEntry {
246        module_name: "auth-oauth",
247        manifest: auth_oauth::module::manifest,
248        load: auth_oauth::module::module,
249        http_binding: None,
250    },
251    LinkedModuleEntry {
252        module_name: "auth-password",
253        manifest: auth_password::module::manifest,
254        load: auth_password::module::module,
255        http_binding: Some(auth_password::module::binding),
256    },
257    LinkedModuleEntry {
258        module_name: "auth-phone",
259        manifest: auth_phone::module::manifest,
260        load: auth_phone::module::module,
261        http_binding: Some(auth_phone::module::binding),
262    },
263    LinkedModuleEntry {
264        module_name: "auth-github",
265        manifest: auth_github::module::manifest,
266        load: auth_github::module::module,
267        http_binding: Some(auth_github::module::binding),
268    },
269    LinkedModuleEntry {
270        module_name: "auth-google",
271        manifest: auth_google::module::manifest,
272        load: auth_google::module::module,
273        http_binding: Some(auth_google::module::binding),
274    },
275    LinkedModuleEntry {
276        module_name: "auth-oidc",
277        manifest: auth_oidc::module::manifest,
278        load: auth_oidc::module::module,
279        http_binding: Some(auth_oidc::module::binding),
280    },
281];
282
283fn linked_module_entries(profile: CompositionProfile) -> &'static [LinkedModuleEntry] {
284    match profile {
285        CompositionProfile::Core => CORE_LINKED_MODULE_ENTRIES,
286        CompositionProfile::Demo => DEMO_LINKED_MODULE_ENTRIES,
287    }
288}
289
290#[must_use]
291pub fn auth_linked_module() -> HostLinkedModule {
292    HostLinkedModule::linked(
293        auth::module::MODULE_NAME,
294        auth::module::manifest,
295        auth::module::module,
296        auth::migrations::AUTH_MIGRATIONS,
297    )
298    .with_http_binding(auth::module::binding)
299}
300
301#[must_use]
302pub fn auth_anonymous_linked_module() -> HostLinkedModule {
303    HostLinkedModule::linked(
304        auth_anonymous::module::MODULE_NAME,
305        auth_anonymous::module::manifest,
306        auth_anonymous::module::module,
307        auth_anonymous::migrations::AUTH_ANONYMOUS_MIGRATIONS,
308    )
309    .with_http_binding(auth_anonymous::module::binding)
310}
311
312#[must_use]
313pub fn auth_password_linked_module() -> HostLinkedModule {
314    HostLinkedModule::linked(
315        auth_password::module::MODULE_NAME,
316        auth_password::module::manifest,
317        auth_password::module::module,
318        auth_password::migrations::AUTH_PASSWORD_MIGRATIONS,
319    )
320    .with_http_binding(auth_password::module::binding)
321}
322
323#[must_use]
324pub fn auth_phone_linked_module() -> HostLinkedModule {
325    HostLinkedModule::linked(
326        auth_phone::module::MODULE_NAME,
327        auth_phone::module::manifest,
328        auth_phone::module::module,
329        auth_phone::migrations::AUTH_PHONE_MIGRATIONS,
330    )
331    .with_http_binding(auth_phone::module::binding)
332}
333
334#[must_use]
335pub fn auth_oauth_linked_module() -> HostLinkedModule {
336    HostLinkedModule::linked(
337        auth_oauth::module::MODULE_NAME,
338        auth_oauth::module::manifest,
339        auth_oauth::module::module,
340        auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS,
341    )
342}
343
344#[must_use]
345pub fn auth_github_linked_module() -> HostLinkedModule {
346    HostLinkedModule::linked(
347        auth_github::module::MODULE_NAME,
348        auth_github::module::manifest,
349        auth_github::module::module,
350        auth_github::migrations::AUTH_GITHUB_MIGRATIONS,
351    )
352    .with_http_binding(auth_github::module::binding)
353}
354
355#[must_use]
356pub fn auth_google_linked_module() -> HostLinkedModule {
357    HostLinkedModule::linked(
358        auth_google::module::MODULE_NAME,
359        auth_google::module::manifest,
360        auth_google::module::module,
361        auth_google::migrations::AUTH_GOOGLE_MIGRATIONS,
362    )
363    .with_http_binding(auth_google::module::binding)
364}
365
366#[must_use]
367pub fn auth_oidc_linked_module() -> HostLinkedModule {
368    HostLinkedModule::linked(
369        auth_oidc::module::MODULE_NAME,
370        auth_oidc::module::manifest,
371        auth_oidc::module::module,
372        auth_oidc::migrations::AUTH_OIDC_MIGRATIONS,
373    )
374    .with_http_binding(auth_oidc::module::binding)
375}
376
377fn linked_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
378    config
379        .modules
380        .get(module_name)
381        .is_none_or(platform_core::ModuleConfig::is_enabled)
382}
383
384fn module_enabled_config_key(module_name: &str) -> String {
385    format!("modules.{module_name}.enabled")
386}
387
388fn linked_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
389    ctx.runtime_config
390        .snapshot()
391        .raw(&module_enabled_config_key(module_name))
392        .and_then(serde_json::Value::as_bool)
393        .unwrap_or_else(|| linked_module_enabled_from_config(&ctx.config, module_name))
394}
395
396fn first_disabled_dependency(ctx: &AppContext, manifest: fn() -> ModuleManifest) -> Option<String> {
397    (manifest)()
398        .requires
399        .into_iter()
400        .map(|requirement| requirement.module_id)
401        .find(|module_id| {
402            !linked_module_enabled(ctx, module_id.rsplit('/').next().unwrap_or(module_id))
403        })
404}
405
406fn first_disabled_dependency_from_config(
407    config: &platform_core::AppConfig,
408    manifest: fn() -> ModuleManifest,
409) -> Option<String> {
410    (manifest)()
411        .requires
412        .into_iter()
413        .map(|requirement| requirement.module_id)
414        .find(|module_id| {
415            !linked_module_enabled_from_config(
416                config,
417                module_id.rsplit('/').next().unwrap_or(module_id),
418            )
419        })
420}
421
422fn linked_module_with_dependencies_enabled(
423    ctx: &AppContext,
424    module_name: &str,
425    manifest: fn() -> ModuleManifest,
426) -> bool {
427    linked_module_enabled(ctx, module_name) && first_disabled_dependency(ctx, manifest).is_none()
428}
429
430fn linked_module_with_dependencies_enabled_from_config(
431    config: &platform_core::AppConfig,
432    module_name: &str,
433    manifest: fn() -> ModuleManifest,
434) -> bool {
435    linked_module_enabled_from_config(config, module_name)
436        && first_disabled_dependency_from_config(config, manifest).is_none()
437}
438
439pub fn auth_actor_resolver_for_context(
440    ctx: &AppContext,
441) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
442    auth_actor_resolver_for_context_with_composition(ctx, &HostComposition::default())
443}
444
445pub fn auth_actor_resolver_for_context_with_composition(
446    ctx: &AppContext,
447    composition: &HostComposition,
448) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
449    let profile = CompositionProfile::from_config(&ctx.config)?;
450    let auth_in_profile = linked_module_entries(profile)
451        .iter()
452        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
453    let auth_in_composition = composition
454        .linked_modules()
455        .iter()
456        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
457    if (!auth_in_profile && !auth_in_composition)
458        || !linked_module_enabled(ctx, auth::module::MODULE_NAME)
459    {
460        return Ok(None);
461    }
462
463    let auth_config = auth::config::AuthRuntimeConfig::from_context(ctx);
464    if auth_config.session_cache == auth::config::SessionCacheMode::Redis && ctx.redis.is_none() {
465        return Err(AppError::validation(
466            "Redis auth session cache is not configured",
467            vec![ErrorDetail {
468                field: Some("auth.session_cache".to_owned()),
469                reason: "set REDIS_URL when auth.session_cache is redis".to_owned(),
470            }],
471        ));
472    }
473    let auth_resolver: Arc<dyn platform_core::ActorResolver> =
474        Arc::new(auth::resolver::AuthActorResolver::new_with_session_cache(
475            ctx.db.clone(),
476            ctx.actor_resolver.clone(),
477            auth::redis_cache::session_cache_from_context(ctx),
478        ));
479
480    let auth_password_enabled = linked_module_with_dependencies_enabled(
481        ctx,
482        auth_password::module::MODULE_NAME,
483        auth_password::module::manifest,
484    );
485    if auth_password_enabled {
486        if let Some(jwt_resolver) =
487            auth_password::module::jwt_actor_resolver(ctx, auth_resolver.clone())?
488        {
489            return Ok(Some(jwt_resolver));
490        }
491    }
492
493    Ok(Some(auth_resolver))
494}
495fn linked_module_entries_for_context(
496    ctx: &AppContext,
497) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
498    Ok(
499        linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
500            .iter()
501            .filter(|entry| {
502                linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
503            })
504            .collect(),
505    )
506}
507
508fn linked_module_entries_for_config(
509    config: &platform_core::AppConfig,
510) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
511    Ok(
512        linked_module_entries(CompositionProfile::from_config(config)?)
513            .iter()
514            .filter(|entry| {
515                linked_module_with_dependencies_enabled_from_config(
516                    config,
517                    entry.module_name,
518                    entry.manifest,
519                )
520            })
521            .collect(),
522    )
523}
524
525fn linked_profile_has_module(profile: CompositionProfile, module_name: &str) -> bool {
526    linked_module_entries(profile)
527        .iter()
528        .any(|entry| entry.module_name == module_name)
529}
530
531fn host_linked_modules_not_in_profile(
532    composition: &HostComposition,
533    profile: CompositionProfile,
534) -> impl Iterator<Item = HostLinkedModule> + '_ {
535    composition
536        .linked_modules()
537        .iter()
538        .cloned()
539        .filter(move |entry| !linked_profile_has_module(profile, entry.module_name))
540}
541
542fn host_linked_modules_for_config(
543    config: &platform_core::AppConfig,
544    composition: &HostComposition,
545    profile: CompositionProfile,
546) -> Vec<HostLinkedModule> {
547    host_linked_modules_not_in_profile(composition, profile)
548        .filter(|entry| {
549            linked_module_with_dependencies_enabled_from_config(
550                config,
551                entry.module_name,
552                entry.manifest,
553            )
554        })
555        .collect()
556}
557
558fn host_linked_modules_for_context(
559    ctx: &AppContext,
560    composition: &HostComposition,
561    profile: CompositionProfile,
562) -> Vec<HostLinkedModule> {
563    host_linked_modules_not_in_profile(composition, profile)
564        .filter(|entry| {
565            linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
566        })
567        .collect()
568}
569
570pub fn host_wiring_for_context(ctx: &AppContext) -> platform_core::AppResult<HostWiring> {
571    host_wiring_for_context_with_composition(ctx, &HostComposition::default())
572}
573
574pub fn host_wiring_for_context_with_composition(
575    ctx: &AppContext,
576    composition: &HostComposition,
577) -> platform_core::AppResult<HostWiring> {
578    let profile = CompositionProfile::from_config(&ctx.config)?;
579    let mut session_policies = Vec::new();
580    for module in host_linked_modules_for_context(ctx, composition, profile) {
581        for extension in module.contributions::<auth::session_policy::AuthHostExtension>() {
582            if let Some(factory) = extension.session_policy_factory() {
583                session_policies.push(factory(ctx));
584            }
585        }
586    }
587
588    Ok(HostWiring {
589        auth_session_policy: auth::session_policy::AuthSessionPolicyChain::handle(session_policies),
590    })
591}
592
593fn load_host_linked_module(ctx: &AppContext, entry: HostLinkedModule) -> Module {
594    match entry.load {
595        Some(load) => load(ctx),
596        None => Module::linked((entry.manifest)(), LinkedBinding::builder().build()),
597    }
598}
599
600/// Demo-default linked modules helper (context-bound: builds bindings).
601///
602/// Startup and config-aware paths should use [`modules_for_config`] or
603/// [`load_modules`] so `module_sources.linked_profile` is honored.
604#[must_use]
605pub fn modules(ctx: &AppContext) -> Vec<Module> {
606    modules_for_profile(ctx, CompositionProfile::default())
607}
608
609pub fn modules_for_config(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
610    Ok(linked_module_entries_for_context(ctx)?
611        .into_iter()
612        .map(|entry| (entry.load)(ctx))
613        .collect())
614}
615
616pub fn modules_for_config_with_composition(
617    ctx: &AppContext,
618    composition: &HostComposition,
619) -> platform_core::AppResult<Vec<Module>> {
620    let profile = CompositionProfile::from_config(&ctx.config)?;
621    let mut modules = modules_for_config(ctx)?;
622    modules.extend(
623        host_linked_modules_for_context(ctx, composition, profile)
624            .into_iter()
625            .map(|entry| load_host_linked_module(ctx, entry)),
626    );
627    Ok(modules)
628}
629
630#[must_use]
631pub fn modules_for_profile(ctx: &AppContext, profile: CompositionProfile) -> Vec<Module> {
632    linked_module_entries(profile)
633        .iter()
634        .map(|entry| (entry.load)(ctx))
635        .collect()
636}
637
638/// Loads a target-owned Provider Runtime Plan when Module management artifacts
639/// exist. A workspace with neither artifact is a Linked-only Host; a partial
640/// or inconsistent management state fails closed.
641pub fn provider_runtime_plan_from_workspace(
642    root: impl AsRef<Path>,
643) -> platform_core::AppResult<Option<lenso_module_management::ProviderRuntimePlan>> {
644    let root = root.as_ref();
645    let lock = root.join("lenso.modules.lock.json");
646    let planning = root.join(".lenso/module-planning-context.json");
647    if !lock.exists() && !planning.exists() {
648        return Ok(None);
649    }
650    lenso_module_management::WorkspaceModuleManagement::new(root)
651        .provider_runtime_plan()
652        .map(Some)
653        .map_err(|error| {
654            AppError::new(
655                ErrorCode::Validation,
656                format!("Provider runtime workspace is invalid: {error}"),
657            )
658        })
659}
660
661pub async fn load_modules_with_composition_and_provider_plan(
662    ctx: &AppContext,
663    composition: &HostComposition,
664    plan: Option<&lenso_module_management::ProviderRuntimePlan>,
665) -> platform_core::AppResult<Vec<Module>> {
666    let mut loaded = modules_for_config_with_composition(ctx, composition)?;
667    if let Some(runtime) = load_provider_runtime_with_composition(ctx, composition, plan).await? {
668        loaded.extend(runtime.into_modules());
669    }
670    Ok(loaded)
671}
672
673pub async fn load_provider_runtime_with_composition(
674    ctx: &AppContext,
675    composition: &HostComposition,
676    plan: Option<&lenso_module_management::ProviderRuntimePlan>,
677) -> platform_core::AppResult<Option<platform_provider::LoadedProviderRuntime>> {
678    let Some(plan) = plan else {
679        return Ok(None);
680    };
681    ProviderRuntimeAdapter::with_adapters(
682        plan.clone(),
683        composition.provider_runtime_adapters.clone(),
684    )?
685    .with_effect_coordinator(platform_provider::ProviderHostEffectCoordinator::new(
686        ctx.db.clone(),
687    ))
688    .load_verified()
689    .await
690    .map(Some)
691}
692
693pub fn migrations_for_config(
694    config: &platform_core::AppConfig,
695) -> platform_core::AppResult<Vec<Migration>> {
696    migrations_for_config_with_composition(config, &HostComposition::default())
697}
698
699pub fn migrations_for_config_with_composition(
700    config: &platform_core::AppConfig,
701    composition: &HostComposition,
702) -> platform_core::AppResult<Vec<Migration>> {
703    let mut migrations = PLATFORM_MIGRATIONS
704        .iter()
705        .chain(RUNTIME_MIGRATIONS)
706        .chain(platform_system_plane::SYSTEM_PLANE_MIGRATIONS)
707        .chain(platform_runtime_observability::RUNTIME_OBSERVABILITY_MIGRATIONS)
708        .chain(platform_runtime_operations::RUNTIME_OPERATIONS_MIGRATIONS)
709        .copied()
710        .collect::<Vec<_>>();
711
712    let profile = CompositionProfile::from_config(config)?;
713    if profile == CompositionProfile::Demo {
714        if linked_module_enabled_from_config(config, "auth") {
715            migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
716        }
717        if linked_module_with_dependencies_enabled_from_config(
718            config,
719            "auth-oauth",
720            auth_oauth::module::manifest,
721        ) {
722            migrations.extend(
723                auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS
724                    .iter()
725                    .copied(),
726            );
727        }
728        if linked_module_with_dependencies_enabled_from_config(
729            config,
730            "auth-password",
731            auth_password::module::manifest,
732        ) {
733            migrations.extend(
734                auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
735                    .iter()
736                    .copied(),
737            );
738        }
739        if linked_module_with_dependencies_enabled_from_config(
740            config,
741            "auth-phone",
742            auth_phone::module::manifest,
743        ) {
744            migrations.extend(
745                auth_phone::migrations::AUTH_PHONE_MIGRATIONS
746                    .iter()
747                    .copied(),
748            );
749        }
750        if linked_module_with_dependencies_enabled_from_config(
751            config,
752            "auth-github",
753            auth_github::module::manifest,
754        ) {
755            migrations.extend(
756                auth_github::migrations::AUTH_GITHUB_MIGRATIONS
757                    .iter()
758                    .copied(),
759            );
760        }
761        if linked_module_with_dependencies_enabled_from_config(
762            config,
763            "auth-google",
764            auth_google::module::manifest,
765        ) {
766            migrations.extend(
767                auth_google::migrations::AUTH_GOOGLE_MIGRATIONS
768                    .iter()
769                    .copied(),
770            );
771        }
772        if linked_module_with_dependencies_enabled_from_config(
773            config,
774            "auth-oidc",
775            auth_oidc::module::manifest,
776        ) {
777            migrations.extend(auth_oidc::migrations::AUTH_OIDC_MIGRATIONS.iter().copied());
778        }
779    }
780
781    for module in host_linked_modules_for_config(config, composition, profile) {
782        migrations.extend(module.migrations.iter().copied());
783    }
784
785    Ok(migrations)
786}
787
788#[must_use]
789pub fn migrations_for_profile(profile: CompositionProfile) -> Vec<Migration> {
790    let mut migrations = PLATFORM_MIGRATIONS
791        .iter()
792        .chain(RUNTIME_MIGRATIONS)
793        .copied()
794        .collect::<Vec<_>>();
795
796    if profile == CompositionProfile::Demo {
797        migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
798        migrations.extend(
799            auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS
800                .iter()
801                .copied(),
802        );
803        migrations.extend(
804            auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
805                .iter()
806                .copied(),
807        );
808        migrations.extend(
809            auth_phone::migrations::AUTH_PHONE_MIGRATIONS
810                .iter()
811                .copied(),
812        );
813        migrations.extend(
814            auth_github::migrations::AUTH_GITHUB_MIGRATIONS
815                .iter()
816                .copied(),
817        );
818        migrations.extend(
819            auth_google::migrations::AUTH_GOOGLE_MIGRATIONS
820                .iter()
821                .copied(),
822        );
823        migrations.extend(auth_oidc::migrations::AUTH_OIDC_MIGRATIONS.iter().copied());
824    }
825
826    migrations
827}
828
829/// Context-free module manifests for read-only / OpenAPI paths that have no
830/// [`AppContext`]. Kept in sync with [`modules`] by listing the same modules.
831#[must_use]
832pub fn module_manifests() -> Vec<ModuleManifest> {
833    module_manifests_for_profile(CompositionProfile::default())
834}
835
836#[must_use]
837pub fn module_manifests_for_profile(profile: CompositionProfile) -> Vec<ModuleManifest> {
838    linked_module_entries(profile)
839        .iter()
840        .map(|entry| (entry.manifest)())
841        .collect()
842}
843
844/// Runtime function declaration sources for context-free linked modules.
845#[must_use]
846pub fn linked_runtime_function_declaration_sources() -> Vec<(
847    String,
848    ModuleSource,
849    Option<platform_module::RuntimeSurface>,
850)> {
851    linked_runtime_function_declaration_sources_for_profile(CompositionProfile::default())
852}
853
854#[must_use]
855pub fn linked_runtime_function_declaration_sources_for_profile(
856    profile: CompositionProfile,
857) -> Vec<(
858    String,
859    ModuleSource,
860    Option<platform_module::RuntimeSurface>,
861)> {
862    module_manifests_for_profile(profile)
863        .into_iter()
864        .map(|manifest| (manifest.module_id, ModuleSource::Linked, manifest.runtime))
865        .collect()
866}
867
868pub fn linked_runtime_function_declaration_sources_for_config(
869    config: &platform_core::AppConfig,
870) -> platform_core::AppResult<
871    Vec<(
872        String,
873        ModuleSource,
874        Option<platform_module::RuntimeSurface>,
875    )>,
876> {
877    Ok(linked_module_entries_for_config(config)?
878        .into_iter()
879        .map(|entry| {
880            let manifest = (entry.manifest)();
881            (manifest.module_id, ModuleSource::Linked, manifest.runtime)
882        })
883        .collect())
884}
885
886pub fn linked_runtime_function_declaration_sources_for_context(
887    ctx: &AppContext,
888) -> platform_core::AppResult<
889    Vec<(
890        String,
891        ModuleSource,
892        Option<platform_module::RuntimeSurface>,
893    )>,
894> {
895    Ok(linked_module_entries_for_context(ctx)?
896        .into_iter()
897        .map(|entry| {
898            let manifest = (entry.manifest)();
899            (manifest.module_id, ModuleSource::Linked, manifest.runtime)
900        })
901        .collect())
902}
903
904pub fn linked_runtime_function_declaration_sources_for_context_with_composition(
905    ctx: &AppContext,
906    composition: &HostComposition,
907) -> platform_core::AppResult<
908    Vec<(
909        String,
910        ModuleSource,
911        Option<platform_module::RuntimeSurface>,
912    )>,
913> {
914    let profile = CompositionProfile::from_config(&ctx.config)?;
915    let mut sources = linked_runtime_function_declaration_sources_for_context(ctx)?;
916    sources.extend(
917        host_linked_modules_for_context(ctx, composition, profile)
918            .into_iter()
919            .map(|entry| {
920                let manifest = (entry.manifest)();
921                (manifest.module_id, ModuleSource::Linked, manifest.runtime)
922            }),
923    );
924    Ok(sources)
925}
926
927/// Public HTTP path ownership for linked modules.
928///
929/// Projected from context-free linked modules so OpenAPI guards and router
930/// assembly consume the same source-specific binding data.
931#[derive(Debug, Clone, PartialEq, Eq)]
932pub struct LinkedHttpRouteOwner {
933    pub module_name: String,
934    pub public_prefixes: &'static [&'static str],
935}
936
937#[must_use]
938pub fn linked_http_route_owners() -> Vec<LinkedHttpRouteOwner> {
939    linked_http_route_owners_for_profile(CompositionProfile::default())
940}
941
942#[must_use]
943pub fn linked_http_route_owners_for_profile(
944    profile: CompositionProfile,
945) -> Vec<LinkedHttpRouteOwner> {
946    linked_module_entries(profile)
947        .iter()
948        .filter_map(|entry| {
949            let http = entry.http_binding?().http?;
950            Some(LinkedHttpRouteOwner {
951                module_name: (entry.manifest)().module_id,
952                public_prefixes: http.public_prefixes,
953            })
954        })
955        .collect()
956}
957
958/// Context-free linked modules that contribute Axum/OpenAPI HTTP routers.
959#[must_use]
960pub fn linked_http_modules() -> Vec<Module> {
961    linked_http_modules_for_profile(CompositionProfile::default())
962}
963
964#[must_use]
965pub fn linked_http_modules_for_profile(profile: CompositionProfile) -> Vec<Module> {
966    linked_module_entries(profile)
967        .iter()
968        .filter_map(|entry| {
969            let http_binding = entry.http_binding?;
970            Some(Module::linked((entry.manifest)(), http_binding()))
971        })
972        .collect()
973}
974
975pub fn linked_http_modules_for_config(
976    config: &platform_core::AppConfig,
977) -> platform_core::AppResult<Vec<Module>> {
978    Ok(linked_module_entries_for_config(config)?
979        .into_iter()
980        .filter_map(|entry| {
981            let http_binding = entry.http_binding?;
982            Some(Module::linked((entry.manifest)(), http_binding()))
983        })
984        .collect())
985}
986
987pub fn linked_http_modules_for_context(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
988    Ok(linked_module_entries_for_context(ctx)?
989        .into_iter()
990        .filter_map(|entry| {
991            let http_binding = entry.http_binding?;
992            Some(Module::linked((entry.manifest)(), http_binding()))
993        })
994        .collect())
995}
996
997pub fn linked_http_modules_for_context_with_composition(
998    ctx: &AppContext,
999    composition: &HostComposition,
1000) -> platform_core::AppResult<Vec<Module>> {
1001    let profile = CompositionProfile::from_config(&ctx.config)?;
1002    let mut modules = linked_http_modules_for_context(ctx)?;
1003    modules.extend(
1004        host_linked_modules_for_context(ctx, composition, profile)
1005            .into_iter()
1006            .filter_map(|entry| {
1007                let http_binding = entry.http_binding?;
1008                Some(Module::linked((entry.manifest)(), http_binding()))
1009            }),
1010    );
1011    Ok(modules)
1012}
1013
1014/// Build a [`FunctionRegistry`] from every module's binding.
1015#[must_use]
1016pub fn function_registry(modules: &[Module]) -> FunctionRegistry {
1017    let mut registry = FunctionRegistry::default();
1018    for module in modules {
1019        module.binding.register_functions(&mut registry);
1020    }
1021    registry
1022}
1023
1024/// Validate and enqueue every startup activation job declared by loaded modules.
1025///
1026/// Lifecycle activation is host-owned: module manifests declare the work, and
1027/// the Lenso bootstrap validates those declarations against the runtime registry
1028/// before scheduling function runs.
1029pub async fn enqueue_lifecycle_activation_jobs(
1030    ctx: &AppContext,
1031    modules: &[Module],
1032    registry: &FunctionRegistry,
1033) -> platform_core::AppResult<Vec<String>> {
1034    validate_lifecycle_activation_jobs(modules, registry)?;
1035
1036    let client = RuntimeClient::new(ctx.db.clone());
1037    let mut run_ids = Vec::new();
1038
1039    for module in modules {
1040        let Some(lifecycle) = &module.manifest.lifecycle else {
1041            continue;
1042        };
1043
1044        for job in &lifecycle.activation_jobs {
1045            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1046                continue;
1047            }
1048            if !module_declares_runtime_function(module, &job.function_name) {
1049                continue;
1050            }
1051
1052            let Some(definition) = registry.get(&job.function_name) else {
1053                continue;
1054            };
1055
1056            let enqueue_result = client
1057                .enqueue_function(EnqueueFunctionRequest {
1058                    function_name: job.function_name.clone(),
1059                    input_json: job.input.clone(),
1060                    correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1061                    actor: ActorContext::Service {
1062                        service_id: "worker".to_owned(),
1063                        scopes: vec!["runtime.functions.enqueue".to_owned()],
1064                    },
1065                    tenant_id: None,
1066                    tenancy_mode: platform_runtime::FunctionTenancyMode::None,
1067                    trace: TraceContext::default(),
1068                    causation_id: Some(format!(
1069                        "module_lifecycle:{}:{}",
1070                        module.manifest.module_id, job.name
1071                    )),
1072                    max_attempts: Some(runtime_max_attempts_for_enqueue(
1073                        definition.retry_policy.max_attempts,
1074                    )),
1075                })
1076                .await;
1077
1078            match enqueue_result {
1079                Ok(run_id) => run_ids.push(run_id),
1080                Err(error) if job.required => return Err(error),
1081                Err(error) => warn_optional_lifecycle_enqueue_failure(
1082                    &module.manifest.module_id,
1083                    &job.name,
1084                    &job.function_name,
1085                    &error,
1086                ),
1087            }
1088        }
1089    }
1090
1091    Ok(run_ids)
1092}
1093
1094fn validate_lifecycle_activation_jobs(
1095    modules: &[Module],
1096    registry: &FunctionRegistry,
1097) -> platform_core::AppResult<()> {
1098    for module in modules {
1099        let Some(lifecycle) = &module.manifest.lifecycle else {
1100            continue;
1101        };
1102
1103        for check in &lifecycle.startup_checks {
1104            match &check.check {
1105                LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1106                    if !module_declares_runtime_function(module, function_name) {
1107                        let reason = format!(
1108                            "startup check `{}` references function `{}` not declared by module `{}`",
1109                            check.name, function_name, module.manifest.module_id
1110                        );
1111                        if !check.required {
1112                            warn_optional_lifecycle_skip(
1113                                &module.manifest.module_id,
1114                                "startup_checks",
1115                                &check.name,
1116                                &reason,
1117                            );
1118                            continue;
1119                        }
1120                        return Err(lifecycle_validation_error(
1121                            &module.manifest.module_id,
1122                            "startup_checks",
1123                            &check.name,
1124                            format!("required {reason}"),
1125                        ));
1126                    }
1127                    if registry.get(function_name).is_none() {
1128                        let reason = format!(
1129                            "startup check `{}` references missing function `{}`",
1130                            check.name, function_name
1131                        );
1132                        if !check.required {
1133                            warn_optional_lifecycle_skip(
1134                                &module.manifest.module_id,
1135                                "startup_checks",
1136                                &check.name,
1137                                &reason,
1138                            );
1139                            continue;
1140                        }
1141                        return Err(lifecycle_validation_error(
1142                            &module.manifest.module_id,
1143                            "startup_checks",
1144                            &check.name,
1145                            format!("required {reason}"),
1146                        ));
1147                    }
1148                }
1149                LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1150                    if !module.manifest.capabilities.contains(capability) {
1151                        let reason = format!(
1152                            "startup check `{}` references missing capability `{}`",
1153                            check.name, capability
1154                        );
1155                        if !check.required {
1156                            warn_optional_lifecycle_skip(
1157                                &module.manifest.module_id,
1158                                "startup_checks",
1159                                &check.name,
1160                                &reason,
1161                            );
1162                            continue;
1163                        }
1164                        return Err(lifecycle_validation_error(
1165                            &module.manifest.module_id,
1166                            "startup_checks",
1167                            &check.name,
1168                            format!("required {reason}"),
1169                        ));
1170                    }
1171                }
1172                _ => {
1173                    let reason = format!(
1174                        "startup check `{}` uses an unsupported lifecycle check kind",
1175                        check.name
1176                    );
1177                    if !check.required {
1178                        warn_optional_lifecycle_skip(
1179                            &module.manifest.module_id,
1180                            "startup_checks",
1181                            &check.name,
1182                            &reason,
1183                        );
1184                        continue;
1185                    }
1186                    return Err(lifecycle_validation_error(
1187                        &module.manifest.module_id,
1188                        "startup_checks",
1189                        &check.name,
1190                        format!("required {reason}"),
1191                    ));
1192                }
1193            }
1194        }
1195
1196        for job in &lifecycle.activation_jobs {
1197            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1198                continue;
1199            }
1200
1201            if !module_declares_runtime_function(module, &job.function_name) {
1202                let reason = format!(
1203                    "activation job `{}` references function `{}` not declared by module `{}`",
1204                    job.name, job.function_name, module.manifest.module_id
1205                );
1206                if !job.required {
1207                    warn_optional_lifecycle_skip(
1208                        &module.manifest.module_id,
1209                        "activation_jobs",
1210                        &job.name,
1211                        &reason,
1212                    );
1213                    continue;
1214                }
1215                return Err(lifecycle_validation_error(
1216                    &module.manifest.module_id,
1217                    "activation_jobs",
1218                    &job.name,
1219                    format!("required {reason}"),
1220                ));
1221            }
1222            if registry.get(&job.function_name).is_none() {
1223                let reason = format!(
1224                    "activation job `{}` references missing function `{}`",
1225                    job.name, job.function_name
1226                );
1227                if !job.required {
1228                    warn_optional_lifecycle_skip(
1229                        &module.manifest.module_id,
1230                        "activation_jobs",
1231                        &job.name,
1232                        &reason,
1233                    );
1234                    continue;
1235                }
1236                return Err(lifecycle_validation_error(
1237                    &module.manifest.module_id,
1238                    "activation_jobs",
1239                    &job.name,
1240                    format!("required {reason}"),
1241                ));
1242            }
1243        }
1244    }
1245
1246    Ok(())
1247}
1248
1249fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
1250    module.manifest.runtime.as_ref().is_some_and(|runtime| {
1251        runtime
1252            .functions
1253            .iter()
1254            .any(|function| function.name == function_name)
1255    })
1256}
1257
1258fn lifecycle_validation_error(
1259    module_name: &str,
1260    collection: &str,
1261    item_name: &str,
1262    reason: String,
1263) -> AppError {
1264    AppError::validation(
1265        "Module lifecycle declaration failed validation",
1266        vec![ErrorDetail {
1267            field: Some(format!(
1268                "module.{module_name}.lifecycle.{collection}.{item_name}"
1269            )),
1270            reason,
1271        }],
1272    )
1273}
1274
1275fn warn_optional_lifecycle_skip(
1276    module_name: &str,
1277    collection: &str,
1278    item_name: &str,
1279    reason: &str,
1280) {
1281    tracing::warn!(
1282        module_name = %module_name,
1283        lifecycle_collection = %collection,
1284        lifecycle_item = %item_name,
1285        reason = %reason,
1286        "optional module lifecycle declaration skipped"
1287    );
1288}
1289
1290fn warn_optional_lifecycle_enqueue_failure(
1291    module_name: &str,
1292    job_name: &str,
1293    function_name: &str,
1294    error: &AppError,
1295) {
1296    tracing::warn!(
1297        module_name = %module_name,
1298        lifecycle_collection = "activation_jobs",
1299        lifecycle_item = %job_name,
1300        function_name = %function_name,
1301        error_code = %error.code.as_str(),
1302        error_message = %error.public_message,
1303        "optional module lifecycle activation enqueue failed"
1304    );
1305}
1306
1307fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
1308    i32::try_from(max_attempts).unwrap_or(i32::MAX)
1309}
1310
1311/// Build host-owned runtime schedules declared by loaded modules.
1312pub fn scheduled_functions(
1313    modules: &[Module],
1314    registry: &FunctionRegistry,
1315) -> platform_core::AppResult<Vec<ScheduledFunctionDefinition>> {
1316    let mut schedules = Vec::new();
1317
1318    for module in modules {
1319        if !matches!(module.load_status, ModuleLoadStatus::Loaded) {
1320            continue;
1321        }
1322        let Some(runtime) = &module.manifest.runtime else {
1323            continue;
1324        };
1325
1326        for schedule in &runtime.schedules {
1327            if schedule.name.trim().is_empty() {
1328                return Err(AppError::new(
1329                    ErrorCode::Validation,
1330                    format!(
1331                        "scheduled runtime function for module {} is missing a name",
1332                        module.manifest.module_id
1333                    ),
1334                ));
1335            }
1336            if !module_declares_runtime_function(module, &schedule.function_name) {
1337                return Err(AppError::new(
1338                    ErrorCode::Validation,
1339                    format!(
1340                        "scheduled runtime function {}:{} references function {} not declared by module {}",
1341                        module.manifest.module_id,
1342                        schedule.name,
1343                        schedule.function_name,
1344                        module.manifest.module_id
1345                    ),
1346                ));
1347            }
1348            let Some(function) = registry.get(&schedule.function_name) else {
1349                return Err(AppError::new(
1350                    ErrorCode::Validation,
1351                    format!(
1352                        "scheduled runtime function {}:{} references missing function {}",
1353                        module.manifest.module_id, schedule.name, schedule.function_name
1354                    ),
1355                ));
1356            };
1357            let parsed_schedule = CronSchedule::parse(&schedule.cron).map_err(|error| {
1358                AppError::new(
1359                    ErrorCode::Validation,
1360                    format!(
1361                        "scheduled runtime function {}:{} has invalid cron expression: {error}",
1362                        module.manifest.module_id, schedule.name
1363                    ),
1364                )
1365            })?;
1366            schedules.push(ScheduledFunctionDefinition {
1367                schedule_key: format!("{}:{}", module.manifest.module_id, schedule.name),
1368                module_name: module.manifest.module_id.clone(),
1369                schedule_name: schedule.name.clone(),
1370                function_name: schedule.function_name.clone(),
1371                cron: schedule.cron.clone(),
1372                schedule: parsed_schedule,
1373                input_json: schedule.input.clone(),
1374                max_attempts: runtime_max_attempts_for_enqueue(function.retry_policy.max_attempts),
1375            });
1376        }
1377    }
1378
1379    Ok(schedules)
1380}
1381
1382/// Build an [`EventHandlerRegistry`] from every module's binding.
1383#[must_use]
1384pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
1385    event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
1386}
1387
1388/// Build an [`EventHandlerRegistry`] with host runtime actions enabled for
1389/// provider event-handler result actions.
1390#[must_use]
1391pub fn event_handlers_with_runtime_actions(
1392    ctx: &AppContext,
1393    modules: &[Module],
1394    function_registry: Arc<FunctionRegistry>,
1395) -> EventHandlerRegistry {
1396    let context = EventHandlerRegistrationContext::with_runtime(
1397        RuntimeClient::new(ctx.db.clone()),
1398        function_registry,
1399    );
1400    event_handlers_with_context(modules, &context)
1401}
1402
1403fn event_handlers_with_context(
1404    modules: &[Module],
1405    context: &EventHandlerRegistrationContext,
1406) -> EventHandlerRegistry {
1407    let mut registry = EventHandlerRegistry::new();
1408    for module in modules {
1409        module
1410            .binding
1411            .register_event_handlers(&mut registry, context);
1412    }
1413    registry
1414}
1415
1416/// Merge every linked module's HTTP routes (and their `OpenAPI` docs) onto `base`.
1417///
1418/// Linked route builders are context-free, so this assembles the HTTP surface
1419/// without constructing the full module set (which requires an [`AppContext`])
1420/// — usable both for serving and for standalone `OpenAPI` document assembly.
1421/// This is the single source for linked API routes until HTTP joins the
1422/// [`platform_module::ModuleBinding`] seam.
1423pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
1424    merge_linked_http_for_profile(base, CompositionProfile::default())
1425}
1426
1427pub fn merge_linked_http_for_profile(
1428    base: ApiOpenApiRouter,
1429    profile: CompositionProfile,
1430) -> ApiOpenApiRouter {
1431    linked_http_modules_for_profile(profile)
1432        .into_iter()
1433        .filter_map(|module| module.linked_http)
1434        .fold(base, |router, contribution| (contribution.merge)(router))
1435}
1436
1437pub fn merge_linked_http_for_config(
1438    base: ApiOpenApiRouter,
1439    config: &platform_core::AppConfig,
1440) -> platform_core::AppResult<ApiOpenApiRouter> {
1441    Ok(linked_http_modules_for_config(config)?
1442        .into_iter()
1443        .filter_map(|module| module.linked_http)
1444        .fold(base, |router, contribution| (contribution.merge)(router)))
1445}
1446
1447pub fn merge_linked_http_for_context(
1448    base: ApiOpenApiRouter,
1449    ctx: &AppContext,
1450) -> platform_core::AppResult<ApiOpenApiRouter> {
1451    Ok(linked_http_modules_for_context(ctx)?
1452        .into_iter()
1453        .filter_map(|module| module.linked_http)
1454        .fold(base, |router, contribution| (contribution.merge)(router)))
1455}
1456
1457pub fn merge_linked_http_for_context_with_composition(
1458    base: ApiOpenApiRouter,
1459    ctx: &AppContext,
1460    composition: &HostComposition,
1461) -> platform_core::AppResult<ApiOpenApiRouter> {
1462    Ok(
1463        linked_http_modules_for_context_with_composition(ctx, composition)?
1464            .into_iter()
1465            .filter_map(|module| module.linked_http)
1466            .fold(base, |router, contribution| (contribution.merge)(router)),
1467    )
1468}
1469
1470/// Story-display descriptors for every module. Sourced from context-free
1471/// manifests so the `OpenAPI` path stays pure (no [`AppContext`]).
1472#[must_use]
1473pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
1474    story_display_descriptors_for_profile(CompositionProfile::default())
1475}
1476
1477#[must_use]
1478pub fn story_display_descriptors_for_profile(
1479    profile: CompositionProfile,
1480) -> Vec<StoryDisplayDescriptor> {
1481    module_manifests_for_profile(profile)
1482        .into_iter()
1483        .flat_map(story_display_descriptors_from_manifest)
1484        .collect()
1485}
1486
1487pub fn story_display_descriptors_for_config(
1488    config: &platform_core::AppConfig,
1489) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
1490    Ok(linked_module_entries_for_config(config)?
1491        .into_iter()
1492        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
1493        .collect())
1494}
1495
1496pub fn story_display_descriptors_for_context(
1497    ctx: &AppContext,
1498) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
1499    Ok(linked_module_entries_for_context(ctx)?
1500        .into_iter()
1501        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
1502        .collect())
1503}
1504
1505fn story_display_descriptors_from_manifest(
1506    manifest: ModuleManifest,
1507) -> Vec<StoryDisplayDescriptor> {
1508    let mut descriptors = manifest.story_display;
1509    let existing_http = descriptors
1510        .iter()
1511        .filter_map(|descriptor| match &descriptor.source {
1512            StoryDisplaySource::HttpRequest { method, path } => {
1513                Some((method.clone(), path.clone()))
1514            }
1515            StoryDisplaySource::ExecutionName { .. } => None,
1516        })
1517        .collect::<Vec<_>>();
1518
1519    descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
1520        let display_name = route.display_name?;
1521        let method = http_method_label(route.method)?;
1522        if existing_http
1523            .iter()
1524            .any(|(existing_method, existing_path)| {
1525                existing_method == method && existing_path == &route.path
1526            })
1527        {
1528            return None;
1529        }
1530        Some(StoryDisplayDescriptor {
1531            source: StoryDisplaySource::HttpRequest {
1532                method: method.to_owned(),
1533                path: route.path,
1534            },
1535            display_name,
1536            story_title: route.story_title,
1537        })
1538    }));
1539    descriptors
1540}
1541
1542fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
1543    Some(match method {
1544        ModuleHttpMethod::Get => "GET",
1545        ModuleHttpMethod::Post => "POST",
1546        ModuleHttpMethod::Put => "PUT",
1547        ModuleHttpMethod::Patch => "PATCH",
1548        ModuleHttpMethod::Delete => "DELETE",
1549        _ => return None,
1550    })
1551}
1552
1553/// Every module's setting descriptors.
1554///
1555/// The single source for the editable configuration registry. Apps build a
1556/// `RuntimeConfigRegistry` from this list at startup.
1557pub fn runtime_config_descriptors(
1558    ctx: &AppContext,
1559) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
1560    runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
1561}
1562
1563pub fn runtime_config_descriptors_with_composition(
1564    ctx: &AppContext,
1565    composition: &HostComposition,
1566) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
1567    let profile = CompositionProfile::from_config(&ctx.config)?;
1568    let module_enabled_descriptors =
1569        linked_module_entries(profile)
1570            .iter()
1571            .map(|entry| RuntimeConfigDescriptor {
1572                key: module_enabled_config_key(entry.module_name),
1573                scope: RuntimeConfigScope::Shared,
1574                group: Some("modules"),
1575                section: None,
1576                order: 10,
1577                visible_when: None,
1578                generated: None,
1579                value_type: RuntimeConfigType::Bool,
1580                default: serde_json::json!(linked_module_enabled_from_config(
1581                    &ctx.config,
1582                    entry.module_name
1583                )),
1584                editable: true,
1585                restart_only: true,
1586                description: "Whether this linked module is loaded on service startup.",
1587            });
1588    let host_module_enabled_descriptors = host_linked_modules_not_in_profile(composition, profile)
1589        .map(|entry| RuntimeConfigDescriptor {
1590            key: module_enabled_config_key(entry.module_name),
1591            scope: RuntimeConfigScope::Shared,
1592            group: Some("modules"),
1593            section: None,
1594            order: 10,
1595            visible_when: None,
1596            generated: None,
1597            value_type: RuntimeConfigType::Bool,
1598            default: serde_json::json!(linked_module_enabled_from_config(
1599                &ctx.config,
1600                entry.module_name
1601            )),
1602            editable: true,
1603            restart_only: true,
1604            description: "Whether this host linked module is loaded on service startup.",
1605        });
1606    let module_descriptors = linked_module_entries(profile)
1607        .iter()
1608        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
1609        .map(|entry| (entry.load)(ctx))
1610        .chain(
1611            host_linked_modules_for_config(&ctx.config, composition, profile)
1612                .into_iter()
1613                .map(|entry| load_host_linked_module(ctx, entry)),
1614        )
1615        .flat_map(|module| module.runtime_config.iter().cloned())
1616        .collect::<Vec<_>>();
1617    // Platform-owned descriptors (e.g. worker knobs) plus every module's; keys
1618    // are globally unique, so chain order is presentation-only.
1619    Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
1620        .iter()
1621        .cloned()
1622        .chain(module_enabled_descriptors)
1623        .chain(host_module_enabled_descriptors)
1624        .chain(module_descriptors)
1625        .collect())
1626}
1627
1628/// Every config presentation group known to the current composition.
1629pub fn runtime_config_group_descriptors(
1630    ctx: &AppContext,
1631) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
1632    runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
1633}
1634
1635pub fn runtime_config_group_descriptors_with_composition(
1636    ctx: &AppContext,
1637    composition: &HostComposition,
1638) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
1639    let profile = CompositionProfile::from_config(&ctx.config)?;
1640    let module_groups = linked_module_entries(profile)
1641        .iter()
1642        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
1643        .map(|entry| (entry.load)(ctx))
1644        .chain(
1645            host_linked_modules_for_config(&ctx.config, composition, profile)
1646                .into_iter()
1647                .map(|entry| load_host_linked_module(ctx, entry)),
1648        )
1649        .flat_map(|module| module.runtime_config_groups.iter().cloned())
1650        .collect::<Vec<_>>();
1651
1652    Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
1653        .chain(
1654            platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
1655                .iter()
1656                .cloned(),
1657        )
1658        .chain(module_groups)
1659        .collect())
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664    use super::*;
1665    use async_trait::async_trait;
1666    use auth::models::AuthUserId;
1667    use auth::session_policy::{
1668        AuthHostExtension, AuthSessionPolicy, SessionCreateDecision, SessionCreateInput,
1669    };
1670    use platform_core::{
1671        AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
1672        LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig,
1673        RuntimeConfigProvider, RuntimeConfigRegistry, RuntimeConfigSnapshot, ServiceConfig,
1674        TelemetryConfig, apply_migrations,
1675    };
1676    use platform_module::{
1677        LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration, LifecycleSurface,
1678        RuntimeFunctionDeclaration, RuntimeSurface,
1679    };
1680    use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
1681    use platform_testing::{SequentialIdGenerator, TestDatabase};
1682    use serde_json::{Value, json};
1683    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
1684    use std::collections::BTreeMap;
1685    use std::sync::Arc;
1686    use std::time::Duration;
1687
1688    #[derive(Debug)]
1689    struct TestRuntimeConfigProvider {
1690        snapshot: Arc<RuntimeConfigSnapshot>,
1691    }
1692
1693    impl RuntimeConfigProvider for TestRuntimeConfigProvider {
1694        fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
1695            Arc::clone(&self.snapshot)
1696        }
1697    }
1698
1699    #[test]
1700    fn linked_module_entry_names_match_manifests() {
1701        for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
1702            for entry in linked_module_entries(profile) {
1703                assert_eq!(
1704                    Some(entry.module_name),
1705                    (entry.manifest)().module_id.rsplit('/').next(),
1706                    "linked module entry slug must match the local ModuleManifest ID segment"
1707                );
1708            }
1709        }
1710    }
1711
1712    #[test]
1713    fn core_profile_excludes_demo_linked_modules() {
1714        let names = module_manifests_for_profile(CompositionProfile::Core)
1715            .into_iter()
1716            .map(|manifest| manifest.module_id)
1717            .collect::<Vec<_>>();
1718
1719        assert!(
1720            names.is_empty(),
1721            "framework core must not implicitly install Console-owned modules"
1722        );
1723    }
1724
1725    #[test]
1726    fn demo_profile_includes_fixture_linked_modules() {
1727        let names = module_manifests_for_profile(CompositionProfile::Demo)
1728            .into_iter()
1729            .map(|manifest| manifest.module_id)
1730            .collect::<Vec<_>>();
1731
1732        assert_eq!(
1733            names,
1734            vec![
1735                "lenso/auth",
1736                "lenso/auth-anonymous",
1737                "lenso/auth-oauth",
1738                "lenso/auth-password",
1739                "lenso/auth-phone",
1740                "lenso/auth-github",
1741                "lenso/auth-google",
1742                "lenso/auth-oidc",
1743            ]
1744        );
1745    }
1746
1747    #[test]
1748    fn http_route_metadata_contributes_story_display_descriptors() {
1749        let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
1750
1751        assert!(descriptors.iter().any(|descriptor| {
1752            matches!(
1753                &descriptor.source,
1754                StoryDisplaySource::HttpRequest { method, path }
1755                    if method == "POST" && path == "/v1/auth/dev/sessions"
1756            ) && descriptor.display_name == "Create Development Session"
1757        }));
1758    }
1759
1760    #[test]
1761    fn core_profile_migrations_exclude_demo_module_migrations() {
1762        let names = migrations_for_profile(CompositionProfile::Core)
1763            .into_iter()
1764            .map(|migration| migration.name)
1765            .collect::<Vec<_>>();
1766
1767        assert!(names.iter().any(|name| name.starts_with("platform/")));
1768        assert!(names.iter().any(|name| name.starts_with("runtime/")));
1769        assert!(!names.iter().any(|name| name.starts_with("story/")));
1770        assert!(!names.iter().any(|name| name.starts_with("auth/")));
1771        assert!(!names.iter().any(|name| name.starts_with("auth-oauth/")));
1772        assert!(!names.iter().any(|name| name.starts_with("auth-github/")));
1773        assert!(!names.iter().any(|name| name.starts_with("auth-google/")));
1774        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
1775        assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
1776    }
1777
1778    #[test]
1779    fn demo_profile_migrations_include_fixture_module_migrations() {
1780        let names = migrations_for_profile(CompositionProfile::Demo)
1781            .into_iter()
1782            .map(|migration| migration.name)
1783            .collect::<Vec<_>>();
1784
1785        assert!(
1786            names
1787                .iter()
1788                .any(|name| name == &"auth/0001_create_auth_schema")
1789        );
1790        assert!(
1791            names
1792                .iter()
1793                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
1794        );
1795        assert!(
1796            names
1797                .iter()
1798                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
1799        );
1800        assert!(
1801            names
1802                .iter()
1803                .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
1804        );
1805        assert!(
1806            names
1807                .iter()
1808                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
1809        );
1810        assert!(
1811            names
1812                .iter()
1813                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
1814        );
1815        assert!(
1816            names
1817                .iter()
1818                .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
1819        );
1820    }
1821
1822    #[test]
1823    fn host_composition_migrations_include_enabled_host_linked_modules() {
1824        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1825        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
1826
1827        let names = migrations_for_config_with_composition(&config, &composition)
1828            .expect("host composition migrations should load")
1829            .into_iter()
1830            .map(|migration| migration.name)
1831            .collect::<Vec<_>>();
1832
1833        assert!(names.iter().any(|name| name == &"billing/0001_init"));
1834    }
1835
1836    #[test]
1837    fn host_composition_can_install_auth_modules() {
1838        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
1839        config.module_sources.linked_profile = "core".to_owned();
1840        let composition = HostComposition::new()
1841            .with_linked_module(auth_linked_module())
1842            .with_linked_module(auth_oauth_linked_module())
1843            .with_linked_module(auth_password_linked_module())
1844            .with_linked_module(auth_phone_linked_module())
1845            .with_linked_module(auth_github_linked_module())
1846            .with_linked_module(auth_google_linked_module())
1847            .with_linked_module(auth_oidc_linked_module());
1848
1849        let names = migrations_for_config_with_composition(&config, &composition)
1850            .expect("host composition migrations should load")
1851            .into_iter()
1852            .map(|migration| migration.name)
1853            .collect::<Vec<_>>();
1854
1855        assert!(
1856            names
1857                .iter()
1858                .any(|name| name == &"auth/0001_create_auth_schema")
1859        );
1860        assert!(
1861            names
1862                .iter()
1863                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
1864        );
1865        assert!(
1866            names
1867                .iter()
1868                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
1869        );
1870        assert!(
1871            names
1872                .iter()
1873                .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
1874        );
1875        assert!(
1876            names
1877                .iter()
1878                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
1879        );
1880        assert!(
1881            names
1882                .iter()
1883                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
1884        );
1885        assert!(
1886            names
1887                .iter()
1888                .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn auth_phone_linked_module_declares_routes_runtime_config_and_migrations() {
1894        let linked = auth_phone_linked_module();
1895        let manifest = (linked.manifest)();
1896        let binding = linked
1897            .http_binding
1898            .expect("auth-phone should expose HTTP binding")();
1899        let module =
1900            (linked
1901                .load
1902                .expect("auth-phone should load as linked module"))(&AppContext::new(
1903                test_config_with_database_url("postgres://localhost/lenso_test"),
1904                platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1905                    .expect("lazy pool should build"),
1906                Arc::new(LoggingEventPublisher),
1907            ));
1908
1909        assert_eq!(linked.module_name, auth_phone::module::MODULE_NAME);
1910        assert_eq!(manifest.module_id, "lenso/auth-phone");
1911        assert_eq!(
1912            manifest
1913                .requires
1914                .iter()
1915                .map(|requirement| requirement.module_id.as_str())
1916                .collect::<Vec<_>>(),
1917            vec!["lenso/auth", "lenso/auth-password",]
1918        );
1919        assert!(
1920            manifest
1921                .http_routes
1922                .iter()
1923                .any(|route| route.path == "/v1/auth/phone/otp/start")
1924        );
1925        assert!(
1926            manifest
1927                .http_routes
1928                .iter()
1929                .any(|route| route.path == "/v1/auth/phone/password/login")
1930        );
1931        assert_eq!(
1932            binding
1933                .http
1934                .expect("auth-phone HTTP contribution")
1935                .public_prefixes,
1936            &["/v1/auth/phone/"]
1937        );
1938        assert!(
1939            linked
1940                .migrations
1941                .iter()
1942                .any(|migration| migration.name == "auth-phone/0001_create_auth_phone_schema")
1943        );
1944        assert!(
1945            module
1946                .runtime_config
1947                .iter()
1948                .any(|descriptor| descriptor.key == "auth-phone.otp_code_length")
1949        );
1950        assert!(
1951            module
1952                .runtime_config_groups
1953                .iter()
1954                .any(|group| group.id == "auth-phone.otp")
1955        );
1956    }
1957
1958    #[tokio::test]
1959    async fn host_composition_runtime_config_includes_host_module_toggle() {
1960        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1961            .expect("lazy pool should build");
1962        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1963        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
1964        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
1965
1966        let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
1967            .expect("host composition descriptors should load")
1968            .into_iter()
1969            .map(|descriptor| descriptor.key)
1970            .collect::<Vec<_>>();
1971
1972        assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
1973    }
1974
1975    #[tokio::test]
1976    async fn host_composition_skips_modules_already_in_linked_profile() {
1977        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1978            .expect("lazy pool should build");
1979        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1980        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
1981        let composition = HostComposition::new().with_linked_module(auth_linked_module());
1982
1983        let descriptors = runtime_config_descriptors_with_composition(&ctx, &composition)
1984            .expect("host composition descriptors should load");
1985        let auth_toggle_count = descriptors
1986            .iter()
1987            .filter(|descriptor| descriptor.key == "modules.auth.enabled")
1988            .count();
1989
1990        assert_eq!(auth_toggle_count, 1);
1991        RuntimeConfigRegistry::try_new(descriptors).expect("descriptors should be unique");
1992    }
1993
1994    #[tokio::test]
1995    async fn host_composition_modules_include_manifest_only_modules() {
1996        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1997            .expect("lazy pool should build");
1998        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1999        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2000        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2001
2002        let names = modules_for_config_with_composition(&ctx, &composition)
2003            .expect("host composition modules should load")
2004            .into_iter()
2005            .map(|module| module.manifest.module_id)
2006            .collect::<Vec<_>>();
2007
2008        assert!(names.iter().any(|name| name == "fixture/billing"));
2009    }
2010
2011    #[tokio::test]
2012    async fn host_wiring_collects_auth_session_policy_contributions() {
2013        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2014            .expect("lazy pool should build");
2015        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2016        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2017        let composition = HostComposition::new().with_linked_module(
2018            test_host_linked_module()
2019                .with_contribution(AuthHostExtension::session_policy(test_session_policy)),
2020        );
2021
2022        let wiring = host_wiring_for_context_with_composition(&ctx, &composition)
2023            .expect("host wiring should compose");
2024        let now = ctx.clock.now();
2025        let decision = wiring
2026            .auth_session_policy()
2027            .policy()
2028            .before_session_create(&SessionCreateInput {
2029                user_id: AuthUserId("usr_wiring".to_owned()),
2030                session_id: "sess_wiring".to_owned(),
2031                proposed_device_id: Some("device_wiring".to_owned()),
2032                created_at: now,
2033                expires_at: now,
2034                client: Default::default(),
2035            })
2036            .await
2037            .expect("wired policy should allow session");
2038
2039        assert_eq!(decision.device_id.as_deref(), Some("device_from_wiring"));
2040    }
2041
2042    fn test_session_policy(_ctx: &AppContext) -> Arc<dyn AuthSessionPolicy> {
2043        Arc::new(TestSessionPolicy)
2044    }
2045
2046    #[derive(Debug)]
2047    struct TestSessionPolicy;
2048
2049    #[async_trait]
2050    impl AuthSessionPolicy for TestSessionPolicy {
2051        async fn before_session_create(
2052            &self,
2053            input: &SessionCreateInput,
2054        ) -> platform_core::AppResult<SessionCreateDecision> {
2055            assert_eq!(input.proposed_device_id.as_deref(), Some("device_wiring"));
2056            Ok(SessionCreateDecision {
2057                device_id: Some("device_from_wiring".to_owned()),
2058            })
2059        }
2060    }
2061
2062    #[test]
2063    fn demo_profile_includes_every_core_entry() {
2064        let demo_names = linked_module_entries(CompositionProfile::Demo)
2065            .iter()
2066            .map(|entry| entry.module_name)
2067            .collect::<Vec<_>>();
2068
2069        for core_entry in linked_module_entries(CompositionProfile::Core) {
2070            assert!(
2071                demo_names.contains(&core_entry.module_name),
2072                "demo profile should include core linked module `{}`",
2073                core_entry.module_name
2074            );
2075        }
2076    }
2077
2078    #[test]
2079    fn default_module_manifests_use_demo_profile() {
2080        let names = module_manifests()
2081            .into_iter()
2082            .map(|manifest| manifest.module_id)
2083            .collect::<Vec<_>>();
2084
2085        assert_eq!(
2086            names,
2087            vec![
2088                "lenso/auth",
2089                "lenso/auth-anonymous",
2090                "lenso/auth-oauth",
2091                "lenso/auth-password",
2092                "lenso/auth-phone",
2093                "lenso/auth-github",
2094                "lenso/auth-google",
2095                "lenso/auth-oidc",
2096            ]
2097        );
2098    }
2099
2100    #[test]
2101    fn linked_http_route_owners_are_profile_aware() {
2102        assert!(linked_http_route_owners_for_profile(CompositionProfile::Core).is_empty());
2103        assert_eq!(
2104            linked_http_route_owners_for_profile(CompositionProfile::Demo),
2105            vec![
2106                LinkedHttpRouteOwner {
2107                    module_name: "lenso/auth".to_owned(),
2108                    public_prefixes: &["/v1/auth/console/", "/v1/auth/dev/", "/v1/auth/sessions/",],
2109                },
2110                LinkedHttpRouteOwner {
2111                    module_name: "lenso/auth-anonymous".to_owned(),
2112                    public_prefixes: &["/v1/auth/anonymous/"],
2113                },
2114                LinkedHttpRouteOwner {
2115                    module_name: "lenso/auth-password".to_owned(),
2116                    public_prefixes: &["/v1/auth/password/"],
2117                },
2118                LinkedHttpRouteOwner {
2119                    module_name: "lenso/auth-phone".to_owned(),
2120                    public_prefixes: &["/v1/auth/phone/"],
2121                },
2122                LinkedHttpRouteOwner {
2123                    module_name: "lenso/auth-github".to_owned(),
2124                    public_prefixes: &["/v1/auth/github/"],
2125                },
2126                LinkedHttpRouteOwner {
2127                    module_name: "lenso/auth-google".to_owned(),
2128                    public_prefixes: &["/v1/auth/google/"],
2129                },
2130                LinkedHttpRouteOwner {
2131                    module_name: "lenso/auth-oidc".to_owned(),
2132                    public_prefixes: &["/.well-known/", "/oauth/"],
2133                },
2134            ]
2135        );
2136    }
2137
2138    #[tokio::test]
2139    async fn modules_for_config_uses_core_linked_profile() {
2140        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2141            .expect("lazy pool should build");
2142        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2143        config.module_sources.linked_profile = "core".to_owned();
2144        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2145
2146        let names = modules_for_config(&ctx)
2147            .expect("core linked profile should parse")
2148            .into_iter()
2149            .map(|module| module.manifest.module_id)
2150            .collect::<Vec<_>>();
2151
2152        assert!(names.is_empty());
2153    }
2154
2155    #[tokio::test]
2156    async fn auth_actor_resolver_is_profile_and_composition_aware() {
2157        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2158            .expect("lazy pool should build");
2159        let demo_ctx = AppContext::new(
2160            test_config_with_database_url("postgres://localhost/lenso_test"),
2161            db.clone(),
2162            Arc::new(LoggingEventPublisher),
2163        );
2164        assert!(
2165            auth_actor_resolver_for_context(&demo_ctx)
2166                .expect("demo profile")
2167                .is_some()
2168        );
2169
2170        let mut composition_config =
2171            test_config_with_database_url("postgres://localhost/lenso_test");
2172        composition_config.module_sources.linked_profile = "core".to_owned();
2173        let composition_ctx = AppContext::new(
2174            composition_config,
2175            db.clone(),
2176            Arc::new(LoggingEventPublisher),
2177        );
2178        let composition = HostComposition::new().with_linked_module(auth_linked_module());
2179        assert!(
2180            auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
2181                .expect("auth composition")
2182                .is_some()
2183        );
2184
2185        let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
2186        core_config.module_sources.linked_profile = "core".to_owned();
2187        let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
2188        assert!(
2189            auth_actor_resolver_for_context(&core_ctx)
2190                .expect("core profile")
2191                .is_none()
2192        );
2193    }
2194
2195    #[tokio::test]
2196    async fn auth_actor_resolver_respects_disabled_auth_module() {
2197        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2198            .expect("lazy pool should build");
2199        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2200        config.modules.insert(
2201            auth::module::MODULE_NAME.to_owned(),
2202            ModuleConfig {
2203                enabled: Some(false),
2204                values: BTreeMap::new(),
2205            },
2206        );
2207        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2208
2209        assert!(
2210            auth_actor_resolver_for_context(&ctx)
2211                .expect("demo profile")
2212                .is_none()
2213        );
2214    }
2215
2216    #[tokio::test]
2217    async fn auth_linked_providers_require_auth_module() {
2218        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2219            .expect("lazy pool should build");
2220        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2221        config.modules.insert(
2222            auth::module::MODULE_NAME.to_owned(),
2223            ModuleConfig {
2224                enabled: Some(false),
2225                values: BTreeMap::new(),
2226            },
2227        );
2228        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2229
2230        let names = modules_for_config(&ctx)
2231            .expect("demo profile")
2232            .into_iter()
2233            .map(|module| module.manifest.module_id)
2234            .collect::<Vec<_>>();
2235
2236        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2237        assert!(!names.iter().any(|name| name == "lenso/auth-anonymous"));
2238        assert!(!names.iter().any(|name| name == "lenso/auth-password"));
2239        assert!(!names.iter().any(|name| name == "lenso/auth-phone"));
2240        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2241        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2242        assert!(!names.iter().any(|name| name == "lenso/auth-oidc"));
2243    }
2244
2245    #[tokio::test]
2246    async fn auth_github_requires_oauth_substrate() {
2247        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2248            .expect("lazy pool should build");
2249        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2250        config.modules.insert(
2251            auth_oauth::module::MODULE_NAME.to_owned(),
2252            ModuleConfig {
2253                enabled: Some(false),
2254                values: BTreeMap::new(),
2255            },
2256        );
2257        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2258
2259        let names = modules_for_config(&ctx)
2260            .expect("demo profile")
2261            .into_iter()
2262            .map(|module| module.manifest.module_id)
2263            .collect::<Vec<_>>();
2264
2265        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2266        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2267        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2268        assert!(names.iter().any(|name| name == "lenso/auth-password"));
2269        assert!(names.iter().any(|name| name == "lenso/auth-phone"));
2270        assert!(names.iter().any(|name| name == "lenso/auth-oidc"));
2271    }
2272
2273    #[tokio::test]
2274    async fn auth_google_requires_oauth_substrate() {
2275        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2276            .expect("lazy pool should build");
2277        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2278        config.modules.insert(
2279            auth_oauth::module::MODULE_NAME.to_owned(),
2280            ModuleConfig {
2281                enabled: Some(false),
2282                values: BTreeMap::new(),
2283            },
2284        );
2285        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2286
2287        let names = modules_for_config(&ctx)
2288            .expect("demo profile")
2289            .into_iter()
2290            .map(|module| module.manifest.module_id)
2291            .collect::<Vec<_>>();
2292
2293        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2294        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2295        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2296        assert!(names.iter().any(|name| name == "lenso/auth-password"));
2297        assert!(names.iter().any(|name| name == "lenso/auth-phone"));
2298        assert!(names.iter().any(|name| name == "lenso/auth-oidc"));
2299    }
2300
2301    #[tokio::test]
2302    async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
2303        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2304            .expect("lazy pool should build");
2305        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2306        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2307        let registry =
2308            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2309                .expect("registry");
2310        let mut stored = BTreeMap::new();
2311        stored.insert(
2312            ("*".to_owned(), "auth-password.token_strategy".to_owned()),
2313            json!("jwt"),
2314        );
2315        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2316        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2317            snapshot: Arc::new(snapshot),
2318        }));
2319
2320        assert!(
2321            auth_actor_resolver_for_context(&ctx)
2322                .expect("JWT resolver should be skipped until jwt_secret is configured")
2323                .is_some()
2324        );
2325    }
2326
2327    #[tokio::test]
2328    async fn auth_actor_resolver_requires_redis_when_session_cache_is_redis() {
2329        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2330            .expect("lazy pool should build");
2331        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2332        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2333        let registry =
2334            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2335                .expect("registry");
2336        let mut stored = BTreeMap::new();
2337        stored.insert(
2338            ("*".to_owned(), "auth.session_cache".to_owned()),
2339            json!("redis"),
2340        );
2341        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2342        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2343            snapshot: Arc::new(snapshot),
2344        }));
2345
2346        let error =
2347            auth_actor_resolver_for_context(&ctx).expect_err("redis cache should require Redis");
2348
2349        assert_eq!(error.code, ErrorCode::Validation);
2350    }
2351
2352    #[tokio::test]
2353    async fn auth_session_cache_factory_returns_no_cache_in_database_mode() {
2354        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2355            .expect("lazy pool should build");
2356        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2357        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2358
2359        assert!(auth::redis_cache::session_cache_from_context(&ctx).is_none());
2360    }
2361
2362    #[tokio::test]
2363    async fn modules_for_config_skips_disabled_linked_modules() {
2364        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2365            .expect("lazy pool should build");
2366        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2367        config.modules.insert(
2368            "auth-password".to_owned(),
2369            ModuleConfig {
2370                enabled: Some(false),
2371                values: BTreeMap::new(),
2372            },
2373        );
2374        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2375
2376        let names = modules_for_config(&ctx)
2377            .expect("demo linked profile should parse")
2378            .into_iter()
2379            .map(|module| module.manifest.module_id)
2380            .collect::<Vec<_>>();
2381
2382        assert_eq!(
2383            names,
2384            vec![
2385                "lenso/auth",
2386                "lenso/auth-anonymous",
2387                "lenso/auth-oauth",
2388                "lenso/auth-github",
2389                "lenso/auth-google",
2390                "lenso/auth-oidc"
2391            ]
2392        );
2393    }
2394
2395    #[tokio::test]
2396    async fn modules_for_config_uses_runtime_config_enabled_flag() {
2397        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2398            .expect("lazy pool should build");
2399        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2400        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2401        let registry =
2402            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2403                .expect("registry");
2404        let mut stored = BTreeMap::new();
2405        stored.insert(
2406            ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
2407            json!(false),
2408        );
2409        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2410        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2411            snapshot: Arc::new(snapshot),
2412        }));
2413
2414        let names = modules_for_config(&ctx)
2415            .expect("demo linked profile should parse")
2416            .into_iter()
2417            .map(|module| module.manifest.module_id)
2418            .collect::<Vec<_>>();
2419
2420        assert_eq!(
2421            names,
2422            vec![
2423                "lenso/auth",
2424                "lenso/auth-anonymous",
2425                "lenso/auth-oauth",
2426                "lenso/auth-github",
2427                "lenso/auth-google",
2428                "lenso/auth-oidc"
2429            ]
2430        );
2431        let linked_http_names = linked_http_modules_for_context(&ctx)
2432            .expect("linked HTTP modules should load")
2433            .into_iter()
2434            .map(|module| module.manifest.module_id)
2435            .collect::<Vec<_>>();
2436
2437        assert_eq!(
2438            linked_http_names,
2439            vec![
2440                "lenso/auth",
2441                "lenso/auth-anonymous",
2442                "lenso/auth-github",
2443                "lenso/auth-google",
2444                "lenso/auth-oidc"
2445            ]
2446        );
2447    }
2448
2449    #[tokio::test]
2450    async fn runtime_config_descriptors_include_module_enabled_flags() {
2451        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2452            .expect("lazy pool should build");
2453        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2454        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2455
2456        let keys = runtime_config_descriptors(&ctx)
2457            .expect("descriptors should load")
2458            .into_iter()
2459            .map(|descriptor| {
2460                (
2461                    descriptor.key,
2462                    descriptor.group,
2463                    descriptor.restart_only,
2464                    descriptor.default,
2465                )
2466            })
2467            .collect::<Vec<_>>();
2468
2469        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2470            key == "modules.auth.enabled"
2471                && *group == Some("modules")
2472                && *restart_only
2473                && default == &json!(true)
2474        }));
2475        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2476            key == "modules.auth-anonymous.enabled"
2477                && *group == Some("modules")
2478                && *restart_only
2479                && default == &json!(true)
2480        }));
2481        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2482            key == "modules.auth-password.enabled"
2483                && *group == Some("modules")
2484                && *restart_only
2485                && default == &json!(true)
2486        }));
2487        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2488            key == "modules.auth-phone.enabled"
2489                && *group == Some("modules")
2490                && *restart_only
2491                && default == &json!(true)
2492        }));
2493        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2494            key == "modules.auth-oauth.enabled"
2495                && *group == Some("modules")
2496                && *restart_only
2497                && default == &json!(true)
2498        }));
2499        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2500            key == "modules.auth-github.enabled"
2501                && *group == Some("modules")
2502                && *restart_only
2503                && default == &json!(true)
2504        }));
2505        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2506            key == "modules.auth-google.enabled"
2507                && *group == Some("modules")
2508                && *restart_only
2509                && default == &json!(true)
2510        }));
2511        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2512            key == "modules.auth-oidc.enabled"
2513                && *group == Some("modules")
2514                && *restart_only
2515                && default == &json!(true)
2516        }));
2517    }
2518
2519    #[tokio::test]
2520    async fn runtime_config_groups_include_module_owned_groups() {
2521        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2522            .expect("lazy pool should build");
2523        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2524        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2525
2526        let groups = runtime_config_group_descriptors(&ctx)
2527            .expect("groups should load")
2528            .into_iter()
2529            .map(|group| (group.id, group.label))
2530            .collect::<Vec<_>>();
2531
2532        assert!(groups.contains(&("modules", "Modules")));
2533        assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
2534        assert!(groups.contains(&("auth-password.tokens", "Tokens")));
2535        assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
2536        assert!(groups.contains(&("auth-phone.otp", "Phone OTP")));
2537        assert!(!groups.iter().any(|(id, _)| *id == "auth-phone.password"));
2538    }
2539
2540    #[test]
2541    fn migrations_for_config_skip_disabled_linked_module_migrations() {
2542        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2543        config.modules.insert(
2544            "auth-password".to_owned(),
2545            ModuleConfig {
2546                enabled: Some(false),
2547                values: BTreeMap::new(),
2548            },
2549        );
2550
2551        let names = migrations_for_config(&config)
2552            .expect("demo linked profile should parse")
2553            .into_iter()
2554            .map(|migration| migration.name)
2555            .collect::<Vec<_>>();
2556
2557        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2558        assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
2559        assert!(
2560            names
2561                .iter()
2562                .any(|name| name == &"auth/0001_create_auth_schema")
2563        );
2564        assert!(
2565            names
2566                .iter()
2567                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
2568        );
2569        assert!(
2570            names
2571                .iter()
2572                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
2573        );
2574        assert!(
2575            names
2576                .iter()
2577                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
2578        );
2579    }
2580
2581    #[test]
2582    fn linked_http_modules_for_config_skip_disabled_linked_routes() {
2583        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2584        config.modules.insert(
2585            "auth-password".to_owned(),
2586            ModuleConfig {
2587                enabled: Some(false),
2588                values: BTreeMap::new(),
2589            },
2590        );
2591
2592        let names = linked_http_modules_for_config(&config)
2593            .expect("demo linked profile should parse")
2594            .into_iter()
2595            .map(|module| module.manifest.module_id)
2596            .collect::<Vec<_>>();
2597
2598        assert_eq!(
2599            names,
2600            vec![
2601                "lenso/auth",
2602                "lenso/auth-anonymous",
2603                "lenso/auth-github",
2604                "lenso/auth-google",
2605                "lenso/auth-oidc"
2606            ]
2607        );
2608    }
2609
2610    #[test]
2611    fn composition_profile_rejects_unknown_values() {
2612        let error = CompositionProfile::parse("fixture")
2613            .expect_err("fixture is not a supported linked module profile");
2614
2615        assert_eq!(error.code, ErrorCode::Validation);
2616        assert!(
2617            error
2618                .details
2619                .iter()
2620                .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
2621        );
2622    }
2623
2624    #[test]
2625    fn linked_http_route_owners_are_projected_from_modules() {
2626        assert_eq!(
2627            linked_http_route_owners(),
2628            vec![
2629                LinkedHttpRouteOwner {
2630                    module_name: "lenso/auth".to_owned(),
2631                    public_prefixes: &["/v1/auth/console/", "/v1/auth/dev/", "/v1/auth/sessions/",],
2632                },
2633                LinkedHttpRouteOwner {
2634                    module_name: "lenso/auth-anonymous".to_owned(),
2635                    public_prefixes: &["/v1/auth/anonymous/"],
2636                },
2637                LinkedHttpRouteOwner {
2638                    module_name: "lenso/auth-password".to_owned(),
2639                    public_prefixes: &["/v1/auth/password/"],
2640                },
2641                LinkedHttpRouteOwner {
2642                    module_name: "lenso/auth-phone".to_owned(),
2643                    public_prefixes: &["/v1/auth/phone/"],
2644                },
2645                LinkedHttpRouteOwner {
2646                    module_name: "lenso/auth-github".to_owned(),
2647                    public_prefixes: &["/v1/auth/github/"],
2648                },
2649                LinkedHttpRouteOwner {
2650                    module_name: "lenso/auth-google".to_owned(),
2651                    public_prefixes: &["/v1/auth/google/"],
2652                },
2653                LinkedHttpRouteOwner {
2654                    module_name: "lenso/auth-oidc".to_owned(),
2655                    public_prefixes: &["/.well-known/", "/oauth/"],
2656                },
2657            ]
2658        );
2659    }
2660
2661    #[test]
2662    fn linked_http_bindings_are_declared_in_manifests() {
2663        for module in linked_http_modules() {
2664            let http = module
2665                .linked_http
2666                .expect("linked HTTP module should carry HTTP contribution");
2667            assert!(
2668                !module.manifest.http_routes.is_empty(),
2669                "linked HTTP module `{}` must declare ModuleManifest::http_routes",
2670                module.manifest.module_id
2671            );
2672            for route in &module.manifest.http_routes {
2673                assert!(
2674                    http.public_prefixes
2675                        .iter()
2676                        .any(|prefix| route.path.starts_with(prefix)),
2677                    "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
2678                    module.manifest.module_id,
2679                    route.path
2680                );
2681            }
2682        }
2683    }
2684
2685    #[test]
2686    fn linked_http_modules_are_registered_modules() {
2687        let manifests = module_manifests();
2688
2689        for module in linked_http_modules() {
2690            let registered_manifest = manifests
2691                .iter()
2692                .find(|manifest| manifest.module_id == module.manifest.module_id)
2693                .unwrap_or_else(|| {
2694                    panic!(
2695                        "linked HTTP module `{}` is missing from module_manifests",
2696                        module.manifest.module_id
2697                    )
2698                });
2699            assert_eq!(
2700                registered_manifest, &module.manifest,
2701                "linked HTTP module `{}` must use the registered ModuleManifest",
2702                module.manifest.module_id
2703            );
2704        }
2705    }
2706
2707    #[tokio::test]
2708    async fn lifecycle_activation_enqueue_creates_function_run() {
2709        let Some(db) = TestDatabase::create().await else {
2710            return;
2711        };
2712        apply_runtime_stack_migrations(&db).await;
2713
2714        let mut ctx = AppContext::new(
2715            test_config(&db),
2716            db.pool.clone(),
2717            Arc::new(LoggingEventPublisher),
2718        );
2719        ctx.ids = Arc::new(SequentialIdGenerator::default());
2720        let modules = vec![
2721            test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
2722                .into(),
2723        ];
2724        let registry = registry_with_lifecycle_function(7);
2725
2726        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2727            .await
2728            .expect("lifecycle activation job should enqueue");
2729
2730        assert_eq!(run_ids.len(), 1);
2731        let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
2732            r#"
2733            select function_name, input_json, max_attempts, correlation_id, actor
2734            from runtime.function_runs
2735            where id = $1
2736            "#,
2737        )
2738        .bind(&run_ids[0])
2739        .fetch_one(&db.pool)
2740        .await
2741        .expect("function run should exist");
2742
2743        assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
2744        assert_eq!(row.1["warm"], "cache");
2745        assert_eq!(
2746            row.1["_lenso_runtime"]["correlation_id"],
2747            "corr_lifecycle_1"
2748        );
2749        assert_eq!(
2750            row.1["_lenso_runtime"]["causation_id"],
2751            "module_lifecycle:fixture/test-module:warm cache"
2752        );
2753        assert_eq!(row.2, 7);
2754        assert_eq!(row.3, "corr_lifecycle_1");
2755        assert_eq!(row.4["kind"], "service");
2756        assert_eq!(row.4["service_id"], "worker");
2757        assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
2758
2759        db.cleanup().await;
2760    }
2761
2762    #[test]
2763    fn lifecycle_activation_validation_rejects_required_missing_function() {
2764        let modules =
2765            vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
2766        let registry = FunctionRegistry::default();
2767
2768        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2769            .expect_err("required missing activation function should fail validation");
2770
2771        assert_eq!(error.code, ErrorCode::Validation);
2772        assert_eq!(
2773            error.details[0].field.as_deref(),
2774            Some("module.fixture/test-module.lifecycle.activation_jobs.warm cache")
2775        );
2776        assert!(
2777            error.details[0].reason.contains("missing function"),
2778            "validation detail should name the missing registry function"
2779        );
2780    }
2781
2782    #[test]
2783    fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
2784        let modules = vec![test_lifecycle_module_with_lifecycle(
2785            LifecycleSurface {
2786                startup_checks: vec![LifecycleStartupCheckDeclaration {
2787                    name: "function registered".to_owned(),
2788                    required: true,
2789                    check: LifecycleStartupCheckKind::FunctionRegistered {
2790                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2791                    },
2792                }],
2793                activation_jobs: Vec::new(),
2794            },
2795            true,
2796            Vec::new(),
2797        )];
2798        let registry = FunctionRegistry::default();
2799
2800        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2801            .expect_err("required startup check should fail when function is missing");
2802
2803        assert_eq!(error.code, ErrorCode::Validation);
2804        assert_eq!(
2805            error.details[0].field.as_deref(),
2806            Some("module.fixture/test-module.lifecycle.startup_checks.function registered")
2807        );
2808        assert!(
2809            error.details[0].reason.contains("missing function"),
2810            "validation detail should name the missing registry function"
2811        );
2812    }
2813
2814    #[test]
2815    fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
2816        let modules = vec![test_lifecycle_module_with_lifecycle(
2817            LifecycleSurface {
2818                startup_checks: vec![LifecycleStartupCheckDeclaration {
2819                    name: "function registered".to_owned(),
2820                    required: true,
2821                    check: LifecycleStartupCheckKind::FunctionRegistered {
2822                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2823                    },
2824                }],
2825                activation_jobs: Vec::new(),
2826            },
2827            false,
2828            Vec::new(),
2829        )];
2830        let registry = registry_with_lifecycle_function(3);
2831
2832        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2833            .expect_err("required startup check should fail when manifest does not declare it");
2834
2835        assert_eq!(error.code, ErrorCode::Validation);
2836        assert_eq!(
2837            error.details[0].field.as_deref(),
2838            Some("module.fixture/test-module.lifecycle.startup_checks.function registered")
2839        );
2840        assert!(
2841            error.details[0].reason.contains("not declared"),
2842            "validation detail should name the missing module runtime declaration"
2843        );
2844    }
2845
2846    #[test]
2847    fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
2848        let modules = vec![test_lifecycle_module_with_lifecycle(
2849            LifecycleSurface {
2850                startup_checks: vec![LifecycleStartupCheckDeclaration {
2851                    name: "capability declared".to_owned(),
2852                    required: true,
2853                    check: LifecycleStartupCheckKind::CapabilityDeclared {
2854                        capability: "test.cache.warm".to_owned(),
2855                    },
2856                }],
2857                activation_jobs: Vec::new(),
2858            },
2859            false,
2860            Vec::new(),
2861        )];
2862        let registry = FunctionRegistry::default();
2863
2864        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2865            .expect_err("required startup check should fail when capability is missing");
2866
2867        assert_eq!(error.code, ErrorCode::Validation);
2868        assert_eq!(
2869            error.details[0].field.as_deref(),
2870            Some("module.fixture/test-module.lifecycle.startup_checks.capability declared")
2871        );
2872        assert!(
2873            error.details[0].reason.contains("missing capability"),
2874            "validation detail should name the missing capability"
2875        );
2876    }
2877
2878    #[test]
2879    fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
2880        let modules = vec![test_lifecycle_module_with_lifecycle(
2881            LifecycleSurface {
2882                startup_checks: vec![
2883                    LifecycleStartupCheckDeclaration {
2884                        name: "optional function".to_owned(),
2885                        required: false,
2886                        check: LifecycleStartupCheckKind::FunctionRegistered {
2887                            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2888                        },
2889                    },
2890                    LifecycleStartupCheckDeclaration {
2891                        name: "optional capability".to_owned(),
2892                        required: false,
2893                        check: LifecycleStartupCheckKind::CapabilityDeclared {
2894                            capability: "test.cache.warm".to_owned(),
2895                        },
2896                    },
2897                ],
2898                activation_jobs: Vec::new(),
2899            },
2900            false,
2901            Vec::new(),
2902        )];
2903        let registry = FunctionRegistry::default();
2904
2905        validate_lifecycle_activation_jobs(&modules, &registry)
2906            .expect("optional startup checks should not fail validation");
2907    }
2908
2909    #[test]
2910    fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
2911        let modules = vec![
2912            test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
2913                .without_runtime_declaration()
2914                .into(),
2915        ];
2916        let registry = registry_with_lifecycle_function(3);
2917
2918        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2919            .expect_err("required activation job should fail when manifest does not declare it");
2920
2921        assert_eq!(error.code, ErrorCode::Validation);
2922        assert_eq!(
2923            error.details[0].field.as_deref(),
2924            Some("module.fixture/test-module.lifecycle.activation_jobs.warm cache")
2925        );
2926        assert!(
2927            error.details[0].reason.contains("not declared"),
2928            "validation detail should name the missing module runtime declaration"
2929        );
2930    }
2931
2932    #[tokio::test]
2933    async fn optional_missing_lifecycle_activation_is_skipped() {
2934        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2935            .expect("lazy pool should build");
2936        let ctx = AppContext::new(
2937            test_config_with_database_url("postgres://localhost/lenso_test"),
2938            db,
2939            Arc::new(LoggingEventPublisher),
2940        );
2941        let modules =
2942            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
2943        let registry = FunctionRegistry::default();
2944
2945        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2946            .await
2947            .expect("optional missing activation function should be skipped");
2948
2949        assert!(run_ids.is_empty());
2950    }
2951
2952    #[tokio::test]
2953    async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
2954        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2955            .expect("lazy pool should build");
2956        let ctx = AppContext::new(
2957            test_config_with_database_url("postgres://localhost/lenso_test"),
2958            db,
2959            Arc::new(LoggingEventPublisher),
2960        );
2961        let modules = vec![
2962            test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
2963                .without_runtime_declaration()
2964                .into(),
2965        ];
2966        let registry = registry_with_lifecycle_function(3);
2967
2968        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2969            .await
2970            .expect("optional undeclared activation function should be skipped");
2971
2972        assert!(run_ids.is_empty());
2973    }
2974
2975    #[tokio::test]
2976    async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
2977        let db = PgPoolOptions::new()
2978            .max_connections(1)
2979            .acquire_timeout(Duration::from_millis(50))
2980            .connect_lazy_with(
2981                PgConnectOptions::new()
2982                    .host("127.0.0.1")
2983                    .port(1)
2984                    .username("postgres")
2985                    .database("lenso_test"),
2986            );
2987        let ctx = AppContext::new(
2988            test_config_with_database_url("postgres://localhost:1/lenso_test"),
2989            db,
2990            Arc::new(LoggingEventPublisher),
2991        );
2992        let modules =
2993            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
2994        let registry = registry_with_lifecycle_function(3);
2995
2996        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2997            .await
2998            .expect("optional enqueue failure should be skipped");
2999
3000        assert!(run_ids.is_empty());
3001    }
3002
3003    #[test]
3004    fn lifecycle_activation_max_attempts_conversion_saturates() {
3005        assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
3006        assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
3007    }
3008
3009    const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
3010
3011    #[derive(Debug)]
3012    struct NoopFunctionHandler;
3013
3014    #[async_trait]
3015    impl FunctionHandler for NoopFunctionHandler {
3016        async fn call(
3017            &self,
3018            _ctx: ExecutionContext,
3019            _input: Value,
3020        ) -> platform_core::AppResult<Value> {
3021            Ok(Value::Null)
3022        }
3023    }
3024
3025    fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
3026        LifecycleActivationJobDeclaration {
3027            name: "warm cache".to_owned(),
3028            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3029            run_policy: LifecycleActivationRunPolicy::EveryStartup,
3030            input,
3031            required,
3032        }
3033    }
3034
3035    struct TestLifecycleModuleBuilder {
3036        lifecycle: LifecycleSurface,
3037        declare_runtime_function: bool,
3038        capabilities: Vec<String>,
3039    }
3040
3041    impl TestLifecycleModuleBuilder {
3042        fn without_runtime_declaration(mut self) -> Self {
3043            self.declare_runtime_function = false;
3044            self
3045        }
3046    }
3047
3048    impl From<TestLifecycleModuleBuilder> for Module {
3049        fn from(builder: TestLifecycleModuleBuilder) -> Self {
3050            let mut manifest =
3051                ModuleManifest::builder("fixture/test-module").lifecycle(builder.lifecycle);
3052            if builder.declare_runtime_function {
3053                manifest = manifest.runtime(RuntimeSurface {
3054                    functions: vec![RuntimeFunctionDeclaration {
3055                        name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3056                        version: 1,
3057                        queue: "test".to_owned(),
3058                        input_schema: None,
3059                        retry_policy: None,
3060                        operation: None,
3061                    }],
3062                    schedules: vec![],
3063                    workflows: vec![],
3064                });
3065            }
3066            if !builder.capabilities.is_empty() {
3067                manifest = manifest.capabilities(builder.capabilities);
3068            }
3069            Module::linked(manifest.build(), LinkedBinding::builder().build())
3070        }
3071    }
3072
3073    fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
3074        TestLifecycleModuleBuilder {
3075            lifecycle: LifecycleSurface {
3076                startup_checks: Vec::new(),
3077                activation_jobs: vec![job],
3078            },
3079            declare_runtime_function: true,
3080            capabilities: Vec::new(),
3081        }
3082    }
3083
3084    fn test_lifecycle_module_with_lifecycle(
3085        lifecycle: LifecycleSurface,
3086        declare_runtime_function: bool,
3087        capabilities: Vec<String>,
3088    ) -> Module {
3089        TestLifecycleModuleBuilder {
3090            lifecycle,
3091            declare_runtime_function,
3092            capabilities,
3093        }
3094        .into()
3095    }
3096
3097    fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
3098        let mut registry = FunctionRegistry::default();
3099        registry.register(FunctionDefinition {
3100            name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3101            version: 1,
3102            queue: "test".to_owned(),
3103            retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
3104            handler: Arc::new(NoopFunctionHandler),
3105        });
3106        registry
3107    }
3108
3109    const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
3110        name: "billing/0001_init",
3111        sql: "select 1;",
3112    }];
3113
3114    fn test_host_manifest() -> ModuleManifest {
3115        ModuleManifest::builder("fixture/billing").build()
3116    }
3117
3118    fn test_host_linked_module() -> HostLinkedModule {
3119        HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
3120    }
3121
3122    fn test_config(db: &TestDatabase) -> AppConfig {
3123        test_config_with_database_url(db.url.clone())
3124    }
3125
3126    fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
3127        AppConfig {
3128            service: ServiceConfig::default(),
3129            database: DatabaseConfig {
3130                url: database_url.into(),
3131                max_connections: 5,
3132            },
3133            redis: RedisConfig::default(),
3134            http: HttpConfig::default(),
3135            telemetry: TelemetryConfig::default(),
3136            auth: AuthConfig::default(),
3137            module_sources: ModuleSourcesConfig::default(),
3138            modules: BTreeMap::new(),
3139        }
3140    }
3141
3142    async fn apply_runtime_stack_migrations(db: &TestDatabase) {
3143        let migrations = PLATFORM_MIGRATIONS
3144            .iter()
3145            .chain(RUNTIME_MIGRATIONS)
3146            .copied()
3147            .collect::<Vec<_>>();
3148        apply_migrations(&db.pool, &migrations)
3149            .await
3150            .expect("platform and runtime migrations should apply");
3151    }
3152}