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 =
1037        RuntimeClient::new(ctx.db.clone()).with_service_name(ctx.config.service.name.clone());
1038    let mut run_ids = Vec::new();
1039
1040    for module in modules {
1041        let Some(lifecycle) = &module.manifest.lifecycle else {
1042            continue;
1043        };
1044
1045        for job in &lifecycle.activation_jobs {
1046            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1047                continue;
1048            }
1049            if !module_declares_runtime_function(module, &job.function_name) {
1050                continue;
1051            }
1052
1053            let Some(definition) = registry.get(&job.function_name) else {
1054                continue;
1055            };
1056
1057            let enqueue_result = client
1058                .enqueue_function(EnqueueFunctionRequest {
1059                    function_name: job.function_name.clone(),
1060                    input_json: job.input.clone(),
1061                    correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1062                    actor: ActorContext::Service {
1063                        service_id: "worker".to_owned(),
1064                        scopes: vec!["runtime.functions.enqueue".to_owned()],
1065                    },
1066                    tenant_id: None,
1067                    tenancy_mode: platform_runtime::FunctionTenancyMode::None,
1068                    trace: TraceContext::default(),
1069                    causation_id: Some(format!(
1070                        "module_lifecycle:{}:{}",
1071                        module.manifest.module_id, job.name
1072                    )),
1073                    max_attempts: Some(runtime_max_attempts_for_enqueue(
1074                        definition.retry_policy.max_attempts,
1075                    )),
1076                })
1077                .await;
1078
1079            match enqueue_result {
1080                Ok(run_id) => run_ids.push(run_id),
1081                Err(error) if job.required => return Err(error),
1082                Err(error) => warn_optional_lifecycle_enqueue_failure(
1083                    &module.manifest.module_id,
1084                    &job.name,
1085                    &job.function_name,
1086                    &error,
1087                ),
1088            }
1089        }
1090    }
1091
1092    Ok(run_ids)
1093}
1094
1095fn validate_lifecycle_activation_jobs(
1096    modules: &[Module],
1097    registry: &FunctionRegistry,
1098) -> platform_core::AppResult<()> {
1099    for module in modules {
1100        let Some(lifecycle) = &module.manifest.lifecycle else {
1101            continue;
1102        };
1103
1104        for check in &lifecycle.startup_checks {
1105            match &check.check {
1106                LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1107                    if !module_declares_runtime_function(module, function_name) {
1108                        let reason = format!(
1109                            "startup check `{}` references function `{}` not declared by module `{}`",
1110                            check.name, function_name, module.manifest.module_id
1111                        );
1112                        if !check.required {
1113                            warn_optional_lifecycle_skip(
1114                                &module.manifest.module_id,
1115                                "startup_checks",
1116                                &check.name,
1117                                &reason,
1118                            );
1119                            continue;
1120                        }
1121                        return Err(lifecycle_validation_error(
1122                            &module.manifest.module_id,
1123                            "startup_checks",
1124                            &check.name,
1125                            format!("required {reason}"),
1126                        ));
1127                    }
1128                    if registry.get(function_name).is_none() {
1129                        let reason = format!(
1130                            "startup check `{}` references missing function `{}`",
1131                            check.name, function_name
1132                        );
1133                        if !check.required {
1134                            warn_optional_lifecycle_skip(
1135                                &module.manifest.module_id,
1136                                "startup_checks",
1137                                &check.name,
1138                                &reason,
1139                            );
1140                            continue;
1141                        }
1142                        return Err(lifecycle_validation_error(
1143                            &module.manifest.module_id,
1144                            "startup_checks",
1145                            &check.name,
1146                            format!("required {reason}"),
1147                        ));
1148                    }
1149                }
1150                LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1151                    if !module.manifest.capabilities.contains(capability) {
1152                        let reason = format!(
1153                            "startup check `{}` references missing capability `{}`",
1154                            check.name, capability
1155                        );
1156                        if !check.required {
1157                            warn_optional_lifecycle_skip(
1158                                &module.manifest.module_id,
1159                                "startup_checks",
1160                                &check.name,
1161                                &reason,
1162                            );
1163                            continue;
1164                        }
1165                        return Err(lifecycle_validation_error(
1166                            &module.manifest.module_id,
1167                            "startup_checks",
1168                            &check.name,
1169                            format!("required {reason}"),
1170                        ));
1171                    }
1172                }
1173                _ => {
1174                    let reason = format!(
1175                        "startup check `{}` uses an unsupported lifecycle check kind",
1176                        check.name
1177                    );
1178                    if !check.required {
1179                        warn_optional_lifecycle_skip(
1180                            &module.manifest.module_id,
1181                            "startup_checks",
1182                            &check.name,
1183                            &reason,
1184                        );
1185                        continue;
1186                    }
1187                    return Err(lifecycle_validation_error(
1188                        &module.manifest.module_id,
1189                        "startup_checks",
1190                        &check.name,
1191                        format!("required {reason}"),
1192                    ));
1193                }
1194            }
1195        }
1196
1197        for job in &lifecycle.activation_jobs {
1198            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1199                continue;
1200            }
1201
1202            if !module_declares_runtime_function(module, &job.function_name) {
1203                let reason = format!(
1204                    "activation job `{}` references function `{}` not declared by module `{}`",
1205                    job.name, job.function_name, module.manifest.module_id
1206                );
1207                if !job.required {
1208                    warn_optional_lifecycle_skip(
1209                        &module.manifest.module_id,
1210                        "activation_jobs",
1211                        &job.name,
1212                        &reason,
1213                    );
1214                    continue;
1215                }
1216                return Err(lifecycle_validation_error(
1217                    &module.manifest.module_id,
1218                    "activation_jobs",
1219                    &job.name,
1220                    format!("required {reason}"),
1221                ));
1222            }
1223            if registry.get(&job.function_name).is_none() {
1224                let reason = format!(
1225                    "activation job `{}` references missing function `{}`",
1226                    job.name, job.function_name
1227                );
1228                if !job.required {
1229                    warn_optional_lifecycle_skip(
1230                        &module.manifest.module_id,
1231                        "activation_jobs",
1232                        &job.name,
1233                        &reason,
1234                    );
1235                    continue;
1236                }
1237                return Err(lifecycle_validation_error(
1238                    &module.manifest.module_id,
1239                    "activation_jobs",
1240                    &job.name,
1241                    format!("required {reason}"),
1242                ));
1243            }
1244        }
1245    }
1246
1247    Ok(())
1248}
1249
1250fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
1251    module.manifest.runtime.as_ref().is_some_and(|runtime| {
1252        runtime
1253            .functions
1254            .iter()
1255            .any(|function| function.name == function_name)
1256    })
1257}
1258
1259fn lifecycle_validation_error(
1260    module_name: &str,
1261    collection: &str,
1262    item_name: &str,
1263    reason: String,
1264) -> AppError {
1265    AppError::validation(
1266        "Module lifecycle declaration failed validation",
1267        vec![ErrorDetail {
1268            field: Some(format!(
1269                "module.{module_name}.lifecycle.{collection}.{item_name}"
1270            )),
1271            reason,
1272        }],
1273    )
1274}
1275
1276fn warn_optional_lifecycle_skip(
1277    module_name: &str,
1278    collection: &str,
1279    item_name: &str,
1280    reason: &str,
1281) {
1282    tracing::warn!(
1283        module_name = %module_name,
1284        lifecycle_collection = %collection,
1285        lifecycle_item = %item_name,
1286        reason = %reason,
1287        "optional module lifecycle declaration skipped"
1288    );
1289}
1290
1291fn warn_optional_lifecycle_enqueue_failure(
1292    module_name: &str,
1293    job_name: &str,
1294    function_name: &str,
1295    error: &AppError,
1296) {
1297    tracing::warn!(
1298        module_name = %module_name,
1299        lifecycle_collection = "activation_jobs",
1300        lifecycle_item = %job_name,
1301        function_name = %function_name,
1302        error_code = %error.code.as_str(),
1303        error_message = %error.public_message,
1304        "optional module lifecycle activation enqueue failed"
1305    );
1306}
1307
1308fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
1309    i32::try_from(max_attempts).unwrap_or(i32::MAX)
1310}
1311
1312/// Build host-owned runtime schedules declared by loaded modules.
1313pub fn scheduled_functions(
1314    modules: &[Module],
1315    registry: &FunctionRegistry,
1316) -> platform_core::AppResult<Vec<ScheduledFunctionDefinition>> {
1317    let mut schedules = Vec::new();
1318
1319    for module in modules {
1320        if !matches!(module.load_status, ModuleLoadStatus::Loaded) {
1321            continue;
1322        }
1323        let Some(runtime) = &module.manifest.runtime else {
1324            continue;
1325        };
1326
1327        for schedule in &runtime.schedules {
1328            if schedule.name.trim().is_empty() {
1329                return Err(AppError::new(
1330                    ErrorCode::Validation,
1331                    format!(
1332                        "scheduled runtime function for module {} is missing a name",
1333                        module.manifest.module_id
1334                    ),
1335                ));
1336            }
1337            if !module_declares_runtime_function(module, &schedule.function_name) {
1338                return Err(AppError::new(
1339                    ErrorCode::Validation,
1340                    format!(
1341                        "scheduled runtime function {}:{} references function {} not declared by module {}",
1342                        module.manifest.module_id,
1343                        schedule.name,
1344                        schedule.function_name,
1345                        module.manifest.module_id
1346                    ),
1347                ));
1348            }
1349            let Some(function) = registry.get(&schedule.function_name) else {
1350                return Err(AppError::new(
1351                    ErrorCode::Validation,
1352                    format!(
1353                        "scheduled runtime function {}:{} references missing function {}",
1354                        module.manifest.module_id, schedule.name, schedule.function_name
1355                    ),
1356                ));
1357            };
1358            let parsed_schedule = CronSchedule::parse(&schedule.cron).map_err(|error| {
1359                AppError::new(
1360                    ErrorCode::Validation,
1361                    format!(
1362                        "scheduled runtime function {}:{} has invalid cron expression: {error}",
1363                        module.manifest.module_id, schedule.name
1364                    ),
1365                )
1366            })?;
1367            schedules.push(ScheduledFunctionDefinition {
1368                schedule_key: format!("{}:{}", module.manifest.module_id, schedule.name),
1369                module_name: module.manifest.module_id.clone(),
1370                schedule_name: schedule.name.clone(),
1371                function_name: schedule.function_name.clone(),
1372                cron: schedule.cron.clone(),
1373                schedule: parsed_schedule,
1374                input_json: schedule.input.clone(),
1375                max_attempts: runtime_max_attempts_for_enqueue(function.retry_policy.max_attempts),
1376            });
1377        }
1378    }
1379
1380    Ok(schedules)
1381}
1382
1383/// Build an [`EventHandlerRegistry`] from every module's binding.
1384#[must_use]
1385pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
1386    event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
1387}
1388
1389/// Build an [`EventHandlerRegistry`] with host runtime actions enabled for
1390/// provider event-handler result actions.
1391#[must_use]
1392pub fn event_handlers_with_runtime_actions(
1393    ctx: &AppContext,
1394    modules: &[Module],
1395    function_registry: Arc<FunctionRegistry>,
1396) -> EventHandlerRegistry {
1397    let context = EventHandlerRegistrationContext::with_runtime(
1398        RuntimeClient::new(ctx.db.clone()).with_service_name(ctx.config.service.name.clone()),
1399        function_registry,
1400    );
1401    event_handlers_with_context(modules, &context)
1402}
1403
1404fn event_handlers_with_context(
1405    modules: &[Module],
1406    context: &EventHandlerRegistrationContext,
1407) -> EventHandlerRegistry {
1408    let mut registry = EventHandlerRegistry::new();
1409    for module in modules {
1410        module
1411            .binding
1412            .register_event_handlers(&mut registry, context);
1413    }
1414    registry
1415}
1416
1417/// Merge every linked module's HTTP routes (and their `OpenAPI` docs) onto `base`.
1418///
1419/// Linked route builders are context-free, so this assembles the HTTP surface
1420/// without constructing the full module set (which requires an [`AppContext`])
1421/// — usable both for serving and for standalone `OpenAPI` document assembly.
1422/// This is the single source for linked API routes until HTTP joins the
1423/// [`platform_module::ModuleBinding`] seam.
1424pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
1425    merge_linked_http_for_profile(base, CompositionProfile::default())
1426}
1427
1428pub fn merge_linked_http_for_profile(
1429    base: ApiOpenApiRouter,
1430    profile: CompositionProfile,
1431) -> ApiOpenApiRouter {
1432    linked_http_modules_for_profile(profile)
1433        .into_iter()
1434        .filter_map(|module| module.linked_http)
1435        .fold(base, |router, contribution| (contribution.merge)(router))
1436}
1437
1438pub fn merge_linked_http_for_config(
1439    base: ApiOpenApiRouter,
1440    config: &platform_core::AppConfig,
1441) -> platform_core::AppResult<ApiOpenApiRouter> {
1442    Ok(linked_http_modules_for_config(config)?
1443        .into_iter()
1444        .filter_map(|module| module.linked_http)
1445        .fold(base, |router, contribution| (contribution.merge)(router)))
1446}
1447
1448pub fn merge_linked_http_for_context(
1449    base: ApiOpenApiRouter,
1450    ctx: &AppContext,
1451) -> platform_core::AppResult<ApiOpenApiRouter> {
1452    Ok(linked_http_modules_for_context(ctx)?
1453        .into_iter()
1454        .filter_map(|module| module.linked_http)
1455        .fold(base, |router, contribution| (contribution.merge)(router)))
1456}
1457
1458pub fn merge_linked_http_for_context_with_composition(
1459    base: ApiOpenApiRouter,
1460    ctx: &AppContext,
1461    composition: &HostComposition,
1462) -> platform_core::AppResult<ApiOpenApiRouter> {
1463    Ok(
1464        linked_http_modules_for_context_with_composition(ctx, composition)?
1465            .into_iter()
1466            .filter_map(|module| module.linked_http)
1467            .fold(base, |router, contribution| (contribution.merge)(router)),
1468    )
1469}
1470
1471/// Story-display descriptors for every module. Sourced from context-free
1472/// manifests so the `OpenAPI` path stays pure (no [`AppContext`]).
1473#[must_use]
1474pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
1475    story_display_descriptors_for_profile(CompositionProfile::default())
1476}
1477
1478#[must_use]
1479pub fn story_display_descriptors_for_profile(
1480    profile: CompositionProfile,
1481) -> Vec<StoryDisplayDescriptor> {
1482    module_manifests_for_profile(profile)
1483        .into_iter()
1484        .flat_map(story_display_descriptors_from_manifest)
1485        .collect()
1486}
1487
1488pub fn story_display_descriptors_for_config(
1489    config: &platform_core::AppConfig,
1490) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
1491    Ok(linked_module_entries_for_config(config)?
1492        .into_iter()
1493        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
1494        .collect())
1495}
1496
1497pub fn story_display_descriptors_for_context(
1498    ctx: &AppContext,
1499) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
1500    Ok(linked_module_entries_for_context(ctx)?
1501        .into_iter()
1502        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
1503        .collect())
1504}
1505
1506fn story_display_descriptors_from_manifest(
1507    manifest: ModuleManifest,
1508) -> Vec<StoryDisplayDescriptor> {
1509    let mut descriptors = manifest.story_display;
1510    let existing_http = descriptors
1511        .iter()
1512        .filter_map(|descriptor| match &descriptor.source {
1513            StoryDisplaySource::HttpRequest { method, path } => {
1514                Some((method.clone(), path.clone()))
1515            }
1516            StoryDisplaySource::ExecutionName { .. } => None,
1517        })
1518        .collect::<Vec<_>>();
1519
1520    descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
1521        let display_name = route.display_name?;
1522        let method = http_method_label(route.method)?;
1523        if existing_http
1524            .iter()
1525            .any(|(existing_method, existing_path)| {
1526                existing_method == method && existing_path == &route.path
1527            })
1528        {
1529            return None;
1530        }
1531        Some(StoryDisplayDescriptor {
1532            source: StoryDisplaySource::HttpRequest {
1533                method: method.to_owned(),
1534                path: route.path,
1535            },
1536            display_name,
1537            story_title: route.story_title,
1538        })
1539    }));
1540    descriptors
1541}
1542
1543fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
1544    Some(match method {
1545        ModuleHttpMethod::Get => "GET",
1546        ModuleHttpMethod::Post => "POST",
1547        ModuleHttpMethod::Put => "PUT",
1548        ModuleHttpMethod::Patch => "PATCH",
1549        ModuleHttpMethod::Delete => "DELETE",
1550        _ => return None,
1551    })
1552}
1553
1554/// Every module's setting descriptors.
1555///
1556/// The single source for the editable configuration registry. Apps build a
1557/// `RuntimeConfigRegistry` from this list at startup.
1558pub fn runtime_config_descriptors(
1559    ctx: &AppContext,
1560) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
1561    runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
1562}
1563
1564pub fn runtime_config_descriptors_with_composition(
1565    ctx: &AppContext,
1566    composition: &HostComposition,
1567) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
1568    let profile = CompositionProfile::from_config(&ctx.config)?;
1569    let module_enabled_descriptors =
1570        linked_module_entries(profile)
1571            .iter()
1572            .map(|entry| RuntimeConfigDescriptor {
1573                key: module_enabled_config_key(entry.module_name),
1574                scope: RuntimeConfigScope::Shared,
1575                group: Some("modules"),
1576                section: None,
1577                order: 10,
1578                visible_when: None,
1579                generated: None,
1580                value_type: RuntimeConfigType::Bool,
1581                default: serde_json::json!(linked_module_enabled_from_config(
1582                    &ctx.config,
1583                    entry.module_name
1584                )),
1585                editable: true,
1586                restart_only: true,
1587                description: "Whether this linked module is loaded on service startup.",
1588            });
1589    let host_module_enabled_descriptors = host_linked_modules_not_in_profile(composition, profile)
1590        .map(|entry| RuntimeConfigDescriptor {
1591            key: module_enabled_config_key(entry.module_name),
1592            scope: RuntimeConfigScope::Shared,
1593            group: Some("modules"),
1594            section: None,
1595            order: 10,
1596            visible_when: None,
1597            generated: None,
1598            value_type: RuntimeConfigType::Bool,
1599            default: serde_json::json!(linked_module_enabled_from_config(
1600                &ctx.config,
1601                entry.module_name
1602            )),
1603            editable: true,
1604            restart_only: true,
1605            description: "Whether this host linked module is loaded on service startup.",
1606        });
1607    let module_descriptors = linked_module_entries(profile)
1608        .iter()
1609        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
1610        .map(|entry| (entry.load)(ctx))
1611        .chain(
1612            host_linked_modules_for_config(&ctx.config, composition, profile)
1613                .into_iter()
1614                .map(|entry| load_host_linked_module(ctx, entry)),
1615        )
1616        .flat_map(|module| module.runtime_config.iter().cloned())
1617        .collect::<Vec<_>>();
1618    // Platform-owned descriptors (e.g. worker knobs) plus every module's; keys
1619    // are globally unique, so chain order is presentation-only.
1620    Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
1621        .iter()
1622        .cloned()
1623        .chain(module_enabled_descriptors)
1624        .chain(host_module_enabled_descriptors)
1625        .chain(module_descriptors)
1626        .collect())
1627}
1628
1629/// Every config presentation group known to the current composition.
1630pub fn runtime_config_group_descriptors(
1631    ctx: &AppContext,
1632) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
1633    runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
1634}
1635
1636pub fn runtime_config_group_descriptors_with_composition(
1637    ctx: &AppContext,
1638    composition: &HostComposition,
1639) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
1640    let profile = CompositionProfile::from_config(&ctx.config)?;
1641    let module_groups = linked_module_entries(profile)
1642        .iter()
1643        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
1644        .map(|entry| (entry.load)(ctx))
1645        .chain(
1646            host_linked_modules_for_config(&ctx.config, composition, profile)
1647                .into_iter()
1648                .map(|entry| load_host_linked_module(ctx, entry)),
1649        )
1650        .flat_map(|module| module.runtime_config_groups.iter().cloned())
1651        .collect::<Vec<_>>();
1652
1653    Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
1654        .chain(
1655            platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
1656                .iter()
1657                .cloned(),
1658        )
1659        .chain(module_groups)
1660        .collect())
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665    use super::*;
1666    use async_trait::async_trait;
1667    use auth::models::AuthUserId;
1668    use auth::session_policy::{
1669        AuthHostExtension, AuthSessionPolicy, SessionCreateDecision, SessionCreateInput,
1670    };
1671    use platform_core::{
1672        AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
1673        LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig,
1674        RuntimeConfigProvider, RuntimeConfigRegistry, RuntimeConfigSnapshot, ServiceConfig,
1675        TelemetryConfig, apply_migrations,
1676    };
1677    use platform_module::{
1678        LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration, LifecycleSurface,
1679        RuntimeFunctionDeclaration, RuntimeSurface,
1680    };
1681    use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
1682    use platform_testing::{SequentialIdGenerator, TestDatabase};
1683    use serde_json::{Value, json};
1684    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
1685    use std::collections::BTreeMap;
1686    use std::sync::Arc;
1687    use std::time::Duration;
1688
1689    #[derive(Debug)]
1690    struct TestRuntimeConfigProvider {
1691        snapshot: Arc<RuntimeConfigSnapshot>,
1692    }
1693
1694    impl RuntimeConfigProvider for TestRuntimeConfigProvider {
1695        fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
1696            Arc::clone(&self.snapshot)
1697        }
1698    }
1699
1700    #[test]
1701    fn linked_module_entry_names_match_manifests() {
1702        for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
1703            for entry in linked_module_entries(profile) {
1704                assert_eq!(
1705                    Some(entry.module_name),
1706                    (entry.manifest)().module_id.rsplit('/').next(),
1707                    "linked module entry slug must match the local ModuleManifest ID segment"
1708                );
1709            }
1710        }
1711    }
1712
1713    #[test]
1714    fn core_profile_excludes_demo_linked_modules() {
1715        let names = module_manifests_for_profile(CompositionProfile::Core)
1716            .into_iter()
1717            .map(|manifest| manifest.module_id)
1718            .collect::<Vec<_>>();
1719
1720        assert!(
1721            names.is_empty(),
1722            "framework core must not implicitly install Console-owned modules"
1723        );
1724    }
1725
1726    #[test]
1727    fn demo_profile_includes_fixture_linked_modules() {
1728        let names = module_manifests_for_profile(CompositionProfile::Demo)
1729            .into_iter()
1730            .map(|manifest| manifest.module_id)
1731            .collect::<Vec<_>>();
1732
1733        assert_eq!(
1734            names,
1735            vec![
1736                "lenso/auth",
1737                "lenso/auth-anonymous",
1738                "lenso/auth-oauth",
1739                "lenso/auth-password",
1740                "lenso/auth-phone",
1741                "lenso/auth-github",
1742                "lenso/auth-google",
1743                "lenso/auth-oidc",
1744            ]
1745        );
1746    }
1747
1748    #[test]
1749    fn http_route_metadata_contributes_story_display_descriptors() {
1750        let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
1751
1752        assert!(descriptors.iter().any(|descriptor| {
1753            matches!(
1754                &descriptor.source,
1755                StoryDisplaySource::HttpRequest { method, path }
1756                    if method == "POST" && path == "/v1/auth/dev/sessions"
1757            ) && descriptor.display_name == "Create Development Session"
1758        }));
1759    }
1760
1761    #[test]
1762    fn core_profile_migrations_exclude_demo_module_migrations() {
1763        let names = migrations_for_profile(CompositionProfile::Core)
1764            .into_iter()
1765            .map(|migration| migration.name)
1766            .collect::<Vec<_>>();
1767
1768        assert!(names.iter().any(|name| name.starts_with("platform/")));
1769        assert!(names.iter().any(|name| name.starts_with("runtime/")));
1770        assert!(!names.iter().any(|name| name.starts_with("story/")));
1771        assert!(!names.iter().any(|name| name.starts_with("auth/")));
1772        assert!(!names.iter().any(|name| name.starts_with("auth-oauth/")));
1773        assert!(!names.iter().any(|name| name.starts_with("auth-github/")));
1774        assert!(!names.iter().any(|name| name.starts_with("auth-google/")));
1775        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
1776        assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
1777    }
1778
1779    #[test]
1780    fn demo_profile_migrations_include_fixture_module_migrations() {
1781        let names = migrations_for_profile(CompositionProfile::Demo)
1782            .into_iter()
1783            .map(|migration| migration.name)
1784            .collect::<Vec<_>>();
1785
1786        assert!(
1787            names
1788                .iter()
1789                .any(|name| name == &"auth/0001_create_auth_schema")
1790        );
1791        assert!(
1792            names
1793                .iter()
1794                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
1795        );
1796        assert!(
1797            names
1798                .iter()
1799                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
1800        );
1801        assert!(
1802            names
1803                .iter()
1804                .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
1805        );
1806        assert!(
1807            names
1808                .iter()
1809                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
1810        );
1811        assert!(
1812            names
1813                .iter()
1814                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
1815        );
1816        assert!(
1817            names
1818                .iter()
1819                .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
1820        );
1821    }
1822
1823    #[test]
1824    fn host_composition_migrations_include_enabled_host_linked_modules() {
1825        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1826        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
1827
1828        let names = migrations_for_config_with_composition(&config, &composition)
1829            .expect("host composition migrations should load")
1830            .into_iter()
1831            .map(|migration| migration.name)
1832            .collect::<Vec<_>>();
1833
1834        assert!(names.iter().any(|name| name == &"billing/0001_init"));
1835    }
1836
1837    #[test]
1838    fn host_composition_can_install_auth_modules() {
1839        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
1840        config.module_sources.linked_profile = "core".to_owned();
1841        let composition = HostComposition::new()
1842            .with_linked_module(auth_linked_module())
1843            .with_linked_module(auth_oauth_linked_module())
1844            .with_linked_module(auth_password_linked_module())
1845            .with_linked_module(auth_phone_linked_module())
1846            .with_linked_module(auth_github_linked_module())
1847            .with_linked_module(auth_google_linked_module())
1848            .with_linked_module(auth_oidc_linked_module());
1849
1850        let names = migrations_for_config_with_composition(&config, &composition)
1851            .expect("host composition migrations should load")
1852            .into_iter()
1853            .map(|migration| migration.name)
1854            .collect::<Vec<_>>();
1855
1856        assert!(
1857            names
1858                .iter()
1859                .any(|name| name == &"auth/0001_create_auth_schema")
1860        );
1861        assert!(
1862            names
1863                .iter()
1864                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
1865        );
1866        assert!(
1867            names
1868                .iter()
1869                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
1870        );
1871        assert!(
1872            names
1873                .iter()
1874                .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
1875        );
1876        assert!(
1877            names
1878                .iter()
1879                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
1880        );
1881        assert!(
1882            names
1883                .iter()
1884                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
1885        );
1886        assert!(
1887            names
1888                .iter()
1889                .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
1890        );
1891    }
1892
1893    #[tokio::test]
1894    async fn auth_phone_linked_module_declares_routes_runtime_config_and_migrations() {
1895        let linked = auth_phone_linked_module();
1896        let manifest = (linked.manifest)();
1897        let binding = linked
1898            .http_binding
1899            .expect("auth-phone should expose HTTP binding")();
1900        let module =
1901            (linked
1902                .load
1903                .expect("auth-phone should load as linked module"))(&AppContext::new(
1904                test_config_with_database_url("postgres://localhost/lenso_test"),
1905                platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1906                    .expect("lazy pool should build"),
1907                Arc::new(LoggingEventPublisher),
1908            ));
1909
1910        assert_eq!(linked.module_name, auth_phone::module::MODULE_NAME);
1911        assert_eq!(manifest.module_id, "lenso/auth-phone");
1912        assert_eq!(
1913            manifest
1914                .requires
1915                .iter()
1916                .map(|requirement| requirement.module_id.as_str())
1917                .collect::<Vec<_>>(),
1918            vec!["lenso/auth", "lenso/auth-password",]
1919        );
1920        assert!(
1921            manifest
1922                .http_routes
1923                .iter()
1924                .any(|route| route.path == "/v1/auth/phone/otp/start")
1925        );
1926        assert!(
1927            manifest
1928                .http_routes
1929                .iter()
1930                .any(|route| route.path == "/v1/auth/phone/password/login")
1931        );
1932        assert_eq!(
1933            binding
1934                .http
1935                .expect("auth-phone HTTP contribution")
1936                .public_prefixes,
1937            &["/v1/auth/phone/"]
1938        );
1939        assert!(
1940            linked
1941                .migrations
1942                .iter()
1943                .any(|migration| migration.name == "auth-phone/0001_create_auth_phone_schema")
1944        );
1945        assert!(
1946            module
1947                .runtime_config
1948                .iter()
1949                .any(|descriptor| descriptor.key == "auth-phone.otp_code_length")
1950        );
1951        assert!(
1952            module
1953                .runtime_config_groups
1954                .iter()
1955                .any(|group| group.id == "auth-phone.otp")
1956        );
1957    }
1958
1959    #[tokio::test]
1960    async fn host_composition_runtime_config_includes_host_module_toggle() {
1961        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1962            .expect("lazy pool should build");
1963        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1964        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
1965        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
1966
1967        let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
1968            .expect("host composition descriptors should load")
1969            .into_iter()
1970            .map(|descriptor| descriptor.key)
1971            .collect::<Vec<_>>();
1972
1973        assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
1974    }
1975
1976    #[tokio::test]
1977    async fn host_composition_skips_modules_already_in_linked_profile() {
1978        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1979            .expect("lazy pool should build");
1980        let config = test_config_with_database_url("postgres://localhost/lenso_test");
1981        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
1982        let composition = HostComposition::new().with_linked_module(auth_linked_module());
1983
1984        let descriptors = runtime_config_descriptors_with_composition(&ctx, &composition)
1985            .expect("host composition descriptors should load");
1986        let auth_toggle_count = descriptors
1987            .iter()
1988            .filter(|descriptor| descriptor.key == "modules.auth.enabled")
1989            .count();
1990
1991        assert_eq!(auth_toggle_count, 1);
1992        RuntimeConfigRegistry::try_new(descriptors).expect("descriptors should be unique");
1993    }
1994
1995    #[tokio::test]
1996    async fn host_composition_modules_include_manifest_only_modules() {
1997        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
1998            .expect("lazy pool should build");
1999        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2000        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2001        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2002
2003        let names = modules_for_config_with_composition(&ctx, &composition)
2004            .expect("host composition modules should load")
2005            .into_iter()
2006            .map(|module| module.manifest.module_id)
2007            .collect::<Vec<_>>();
2008
2009        assert!(names.iter().any(|name| name == "fixture/billing"));
2010    }
2011
2012    #[tokio::test]
2013    async fn host_wiring_collects_auth_session_policy_contributions() {
2014        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2015            .expect("lazy pool should build");
2016        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2017        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2018        let composition = HostComposition::new().with_linked_module(
2019            test_host_linked_module()
2020                .with_contribution(AuthHostExtension::session_policy(test_session_policy)),
2021        );
2022
2023        let wiring = host_wiring_for_context_with_composition(&ctx, &composition)
2024            .expect("host wiring should compose");
2025        let now = ctx.clock.now();
2026        let decision = wiring
2027            .auth_session_policy()
2028            .policy()
2029            .before_session_create(&SessionCreateInput {
2030                user_id: AuthUserId("usr_wiring".to_owned()),
2031                session_id: "sess_wiring".to_owned(),
2032                proposed_device_id: Some("device_wiring".to_owned()),
2033                created_at: now,
2034                expires_at: now,
2035                client: Default::default(),
2036            })
2037            .await
2038            .expect("wired policy should allow session");
2039
2040        assert_eq!(decision.device_id.as_deref(), Some("device_from_wiring"));
2041    }
2042
2043    fn test_session_policy(_ctx: &AppContext) -> Arc<dyn AuthSessionPolicy> {
2044        Arc::new(TestSessionPolicy)
2045    }
2046
2047    #[derive(Debug)]
2048    struct TestSessionPolicy;
2049
2050    #[async_trait]
2051    impl AuthSessionPolicy for TestSessionPolicy {
2052        async fn before_session_create(
2053            &self,
2054            input: &SessionCreateInput,
2055        ) -> platform_core::AppResult<SessionCreateDecision> {
2056            assert_eq!(input.proposed_device_id.as_deref(), Some("device_wiring"));
2057            Ok(SessionCreateDecision {
2058                device_id: Some("device_from_wiring".to_owned()),
2059            })
2060        }
2061    }
2062
2063    #[test]
2064    fn demo_profile_includes_every_core_entry() {
2065        let demo_names = linked_module_entries(CompositionProfile::Demo)
2066            .iter()
2067            .map(|entry| entry.module_name)
2068            .collect::<Vec<_>>();
2069
2070        for core_entry in linked_module_entries(CompositionProfile::Core) {
2071            assert!(
2072                demo_names.contains(&core_entry.module_name),
2073                "demo profile should include core linked module `{}`",
2074                core_entry.module_name
2075            );
2076        }
2077    }
2078
2079    #[test]
2080    fn default_module_manifests_use_demo_profile() {
2081        let names = module_manifests()
2082            .into_iter()
2083            .map(|manifest| manifest.module_id)
2084            .collect::<Vec<_>>();
2085
2086        assert_eq!(
2087            names,
2088            vec![
2089                "lenso/auth",
2090                "lenso/auth-anonymous",
2091                "lenso/auth-oauth",
2092                "lenso/auth-password",
2093                "lenso/auth-phone",
2094                "lenso/auth-github",
2095                "lenso/auth-google",
2096                "lenso/auth-oidc",
2097            ]
2098        );
2099    }
2100
2101    #[test]
2102    fn linked_http_route_owners_are_profile_aware() {
2103        assert!(linked_http_route_owners_for_profile(CompositionProfile::Core).is_empty());
2104        assert_eq!(
2105            linked_http_route_owners_for_profile(CompositionProfile::Demo),
2106            vec![
2107                LinkedHttpRouteOwner {
2108                    module_name: "lenso/auth".to_owned(),
2109                    public_prefixes: &["/v1/auth/console/", "/v1/auth/dev/", "/v1/auth/sessions/",],
2110                },
2111                LinkedHttpRouteOwner {
2112                    module_name: "lenso/auth-anonymous".to_owned(),
2113                    public_prefixes: &["/v1/auth/anonymous/"],
2114                },
2115                LinkedHttpRouteOwner {
2116                    module_name: "lenso/auth-password".to_owned(),
2117                    public_prefixes: &["/v1/auth/password/"],
2118                },
2119                LinkedHttpRouteOwner {
2120                    module_name: "lenso/auth-phone".to_owned(),
2121                    public_prefixes: &["/v1/auth/phone/"],
2122                },
2123                LinkedHttpRouteOwner {
2124                    module_name: "lenso/auth-github".to_owned(),
2125                    public_prefixes: &["/v1/auth/github/"],
2126                },
2127                LinkedHttpRouteOwner {
2128                    module_name: "lenso/auth-google".to_owned(),
2129                    public_prefixes: &["/v1/auth/google/"],
2130                },
2131                LinkedHttpRouteOwner {
2132                    module_name: "lenso/auth-oidc".to_owned(),
2133                    public_prefixes: &["/.well-known/", "/oauth/"],
2134                },
2135            ]
2136        );
2137    }
2138
2139    #[tokio::test]
2140    async fn modules_for_config_uses_core_linked_profile() {
2141        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2142            .expect("lazy pool should build");
2143        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2144        config.module_sources.linked_profile = "core".to_owned();
2145        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2146
2147        let names = modules_for_config(&ctx)
2148            .expect("core linked profile should parse")
2149            .into_iter()
2150            .map(|module| module.manifest.module_id)
2151            .collect::<Vec<_>>();
2152
2153        assert!(names.is_empty());
2154    }
2155
2156    #[tokio::test]
2157    async fn auth_actor_resolver_is_profile_and_composition_aware() {
2158        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2159            .expect("lazy pool should build");
2160        let demo_ctx = AppContext::new(
2161            test_config_with_database_url("postgres://localhost/lenso_test"),
2162            db.clone(),
2163            Arc::new(LoggingEventPublisher),
2164        );
2165        assert!(
2166            auth_actor_resolver_for_context(&demo_ctx)
2167                .expect("demo profile")
2168                .is_some()
2169        );
2170
2171        let mut composition_config =
2172            test_config_with_database_url("postgres://localhost/lenso_test");
2173        composition_config.module_sources.linked_profile = "core".to_owned();
2174        let composition_ctx = AppContext::new(
2175            composition_config,
2176            db.clone(),
2177            Arc::new(LoggingEventPublisher),
2178        );
2179        let composition = HostComposition::new().with_linked_module(auth_linked_module());
2180        assert!(
2181            auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
2182                .expect("auth composition")
2183                .is_some()
2184        );
2185
2186        let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
2187        core_config.module_sources.linked_profile = "core".to_owned();
2188        let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
2189        assert!(
2190            auth_actor_resolver_for_context(&core_ctx)
2191                .expect("core profile")
2192                .is_none()
2193        );
2194    }
2195
2196    #[tokio::test]
2197    async fn auth_actor_resolver_respects_disabled_auth_module() {
2198        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2199            .expect("lazy pool should build");
2200        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2201        config.modules.insert(
2202            auth::module::MODULE_NAME.to_owned(),
2203            ModuleConfig {
2204                enabled: Some(false),
2205                values: BTreeMap::new(),
2206            },
2207        );
2208        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2209
2210        assert!(
2211            auth_actor_resolver_for_context(&ctx)
2212                .expect("demo profile")
2213                .is_none()
2214        );
2215    }
2216
2217    #[tokio::test]
2218    async fn auth_linked_providers_require_auth_module() {
2219        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2220            .expect("lazy pool should build");
2221        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2222        config.modules.insert(
2223            auth::module::MODULE_NAME.to_owned(),
2224            ModuleConfig {
2225                enabled: Some(false),
2226                values: BTreeMap::new(),
2227            },
2228        );
2229        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2230
2231        let names = modules_for_config(&ctx)
2232            .expect("demo profile")
2233            .into_iter()
2234            .map(|module| module.manifest.module_id)
2235            .collect::<Vec<_>>();
2236
2237        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2238        assert!(!names.iter().any(|name| name == "lenso/auth-anonymous"));
2239        assert!(!names.iter().any(|name| name == "lenso/auth-password"));
2240        assert!(!names.iter().any(|name| name == "lenso/auth-phone"));
2241        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2242        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2243        assert!(!names.iter().any(|name| name == "lenso/auth-oidc"));
2244    }
2245
2246    #[tokio::test]
2247    async fn auth_github_requires_oauth_substrate() {
2248        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2249            .expect("lazy pool should build");
2250        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2251        config.modules.insert(
2252            auth_oauth::module::MODULE_NAME.to_owned(),
2253            ModuleConfig {
2254                enabled: Some(false),
2255                values: BTreeMap::new(),
2256            },
2257        );
2258        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2259
2260        let names = modules_for_config(&ctx)
2261            .expect("demo profile")
2262            .into_iter()
2263            .map(|module| module.manifest.module_id)
2264            .collect::<Vec<_>>();
2265
2266        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2267        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2268        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2269        assert!(names.iter().any(|name| name == "lenso/auth-password"));
2270        assert!(names.iter().any(|name| name == "lenso/auth-phone"));
2271        assert!(names.iter().any(|name| name == "lenso/auth-oidc"));
2272    }
2273
2274    #[tokio::test]
2275    async fn auth_google_requires_oauth_substrate() {
2276        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2277            .expect("lazy pool should build");
2278        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2279        config.modules.insert(
2280            auth_oauth::module::MODULE_NAME.to_owned(),
2281            ModuleConfig {
2282                enabled: Some(false),
2283                values: BTreeMap::new(),
2284            },
2285        );
2286        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2287
2288        let names = modules_for_config(&ctx)
2289            .expect("demo profile")
2290            .into_iter()
2291            .map(|module| module.manifest.module_id)
2292            .collect::<Vec<_>>();
2293
2294        assert!(!names.iter().any(|name| name == "lenso/auth-oauth"));
2295        assert!(!names.iter().any(|name| name == "lenso/auth-github"));
2296        assert!(!names.iter().any(|name| name == "lenso/auth-google"));
2297        assert!(names.iter().any(|name| name == "lenso/auth-password"));
2298        assert!(names.iter().any(|name| name == "lenso/auth-phone"));
2299        assert!(names.iter().any(|name| name == "lenso/auth-oidc"));
2300    }
2301
2302    #[tokio::test]
2303    async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
2304        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2305            .expect("lazy pool should build");
2306        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2307        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2308        let registry =
2309            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2310                .expect("registry");
2311        let mut stored = BTreeMap::new();
2312        stored.insert(
2313            ("*".to_owned(), "auth-password.token_strategy".to_owned()),
2314            json!("jwt"),
2315        );
2316        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2317        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2318            snapshot: Arc::new(snapshot),
2319        }));
2320
2321        assert!(
2322            auth_actor_resolver_for_context(&ctx)
2323                .expect("JWT resolver should be skipped until jwt_secret is configured")
2324                .is_some()
2325        );
2326    }
2327
2328    #[tokio::test]
2329    async fn auth_actor_resolver_requires_redis_when_session_cache_is_redis() {
2330        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2331            .expect("lazy pool should build");
2332        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2333        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2334        let registry =
2335            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2336                .expect("registry");
2337        let mut stored = BTreeMap::new();
2338        stored.insert(
2339            ("*".to_owned(), "auth.session_cache".to_owned()),
2340            json!("redis"),
2341        );
2342        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2343        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2344            snapshot: Arc::new(snapshot),
2345        }));
2346
2347        let error =
2348            auth_actor_resolver_for_context(&ctx).expect_err("redis cache should require Redis");
2349
2350        assert_eq!(error.code, ErrorCode::Validation);
2351    }
2352
2353    #[tokio::test]
2354    async fn auth_session_cache_factory_returns_no_cache_in_database_mode() {
2355        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2356            .expect("lazy pool should build");
2357        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2358        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2359
2360        assert!(auth::redis_cache::session_cache_from_context(&ctx).is_none());
2361    }
2362
2363    #[tokio::test]
2364    async fn modules_for_config_skips_disabled_linked_modules() {
2365        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2366            .expect("lazy pool should build");
2367        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2368        config.modules.insert(
2369            "auth-password".to_owned(),
2370            ModuleConfig {
2371                enabled: Some(false),
2372                values: BTreeMap::new(),
2373            },
2374        );
2375        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2376
2377        let names = modules_for_config(&ctx)
2378            .expect("demo linked profile should parse")
2379            .into_iter()
2380            .map(|module| module.manifest.module_id)
2381            .collect::<Vec<_>>();
2382
2383        assert_eq!(
2384            names,
2385            vec![
2386                "lenso/auth",
2387                "lenso/auth-anonymous",
2388                "lenso/auth-oauth",
2389                "lenso/auth-github",
2390                "lenso/auth-google",
2391                "lenso/auth-oidc"
2392            ]
2393        );
2394    }
2395
2396    #[tokio::test]
2397    async fn modules_for_config_uses_runtime_config_enabled_flag() {
2398        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2399            .expect("lazy pool should build");
2400        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2401        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2402        let registry =
2403            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2404                .expect("registry");
2405        let mut stored = BTreeMap::new();
2406        stored.insert(
2407            ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
2408            json!(false),
2409        );
2410        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2411        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2412            snapshot: Arc::new(snapshot),
2413        }));
2414
2415        let names = modules_for_config(&ctx)
2416            .expect("demo linked profile should parse")
2417            .into_iter()
2418            .map(|module| module.manifest.module_id)
2419            .collect::<Vec<_>>();
2420
2421        assert_eq!(
2422            names,
2423            vec![
2424                "lenso/auth",
2425                "lenso/auth-anonymous",
2426                "lenso/auth-oauth",
2427                "lenso/auth-github",
2428                "lenso/auth-google",
2429                "lenso/auth-oidc"
2430            ]
2431        );
2432        let linked_http_names = linked_http_modules_for_context(&ctx)
2433            .expect("linked HTTP modules should load")
2434            .into_iter()
2435            .map(|module| module.manifest.module_id)
2436            .collect::<Vec<_>>();
2437
2438        assert_eq!(
2439            linked_http_names,
2440            vec![
2441                "lenso/auth",
2442                "lenso/auth-anonymous",
2443                "lenso/auth-github",
2444                "lenso/auth-google",
2445                "lenso/auth-oidc"
2446            ]
2447        );
2448    }
2449
2450    #[tokio::test]
2451    async fn runtime_config_descriptors_include_module_enabled_flags() {
2452        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2453            .expect("lazy pool should build");
2454        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2455        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2456
2457        let keys = runtime_config_descriptors(&ctx)
2458            .expect("descriptors should load")
2459            .into_iter()
2460            .map(|descriptor| {
2461                (
2462                    descriptor.key,
2463                    descriptor.group,
2464                    descriptor.restart_only,
2465                    descriptor.default,
2466                )
2467            })
2468            .collect::<Vec<_>>();
2469
2470        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2471            key == "modules.auth.enabled"
2472                && *group == Some("modules")
2473                && *restart_only
2474                && default == &json!(true)
2475        }));
2476        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2477            key == "modules.auth-anonymous.enabled"
2478                && *group == Some("modules")
2479                && *restart_only
2480                && default == &json!(true)
2481        }));
2482        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2483            key == "modules.auth-password.enabled"
2484                && *group == Some("modules")
2485                && *restart_only
2486                && default == &json!(true)
2487        }));
2488        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2489            key == "modules.auth-phone.enabled"
2490                && *group == Some("modules")
2491                && *restart_only
2492                && default == &json!(true)
2493        }));
2494        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2495            key == "modules.auth-oauth.enabled"
2496                && *group == Some("modules")
2497                && *restart_only
2498                && default == &json!(true)
2499        }));
2500        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2501            key == "modules.auth-github.enabled"
2502                && *group == Some("modules")
2503                && *restart_only
2504                && default == &json!(true)
2505        }));
2506        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2507            key == "modules.auth-google.enabled"
2508                && *group == Some("modules")
2509                && *restart_only
2510                && default == &json!(true)
2511        }));
2512        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2513            key == "modules.auth-oidc.enabled"
2514                && *group == Some("modules")
2515                && *restart_only
2516                && default == &json!(true)
2517        }));
2518    }
2519
2520    #[tokio::test]
2521    async fn runtime_config_groups_include_module_owned_groups() {
2522        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2523            .expect("lazy pool should build");
2524        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2525        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2526
2527        let groups = runtime_config_group_descriptors(&ctx)
2528            .expect("groups should load")
2529            .into_iter()
2530            .map(|group| (group.id, group.label))
2531            .collect::<Vec<_>>();
2532
2533        assert!(groups.contains(&("modules", "Modules")));
2534        assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
2535        assert!(groups.contains(&("auth-password.tokens", "Tokens")));
2536        assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
2537        assert!(groups.contains(&("auth-phone.otp", "Phone OTP")));
2538        assert!(!groups.iter().any(|(id, _)| *id == "auth-phone.password"));
2539    }
2540
2541    #[test]
2542    fn migrations_for_config_skip_disabled_linked_module_migrations() {
2543        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2544        config.modules.insert(
2545            "auth-password".to_owned(),
2546            ModuleConfig {
2547                enabled: Some(false),
2548                values: BTreeMap::new(),
2549            },
2550        );
2551
2552        let names = migrations_for_config(&config)
2553            .expect("demo linked profile should parse")
2554            .into_iter()
2555            .map(|migration| migration.name)
2556            .collect::<Vec<_>>();
2557
2558        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2559        assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
2560        assert!(
2561            names
2562                .iter()
2563                .any(|name| name == &"auth/0001_create_auth_schema")
2564        );
2565        assert!(
2566            names
2567                .iter()
2568                .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
2569        );
2570        assert!(
2571            names
2572                .iter()
2573                .any(|name| name == &"auth-github/0001_create_auth_github_schema")
2574        );
2575        assert!(
2576            names
2577                .iter()
2578                .any(|name| name == &"auth-google/0001_create_auth_google_schema")
2579        );
2580    }
2581
2582    #[test]
2583    fn linked_http_modules_for_config_skip_disabled_linked_routes() {
2584        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2585        config.modules.insert(
2586            "auth-password".to_owned(),
2587            ModuleConfig {
2588                enabled: Some(false),
2589                values: BTreeMap::new(),
2590            },
2591        );
2592
2593        let names = linked_http_modules_for_config(&config)
2594            .expect("demo linked profile should parse")
2595            .into_iter()
2596            .map(|module| module.manifest.module_id)
2597            .collect::<Vec<_>>();
2598
2599        assert_eq!(
2600            names,
2601            vec![
2602                "lenso/auth",
2603                "lenso/auth-anonymous",
2604                "lenso/auth-github",
2605                "lenso/auth-google",
2606                "lenso/auth-oidc"
2607            ]
2608        );
2609    }
2610
2611    #[test]
2612    fn composition_profile_rejects_unknown_values() {
2613        let error = CompositionProfile::parse("fixture")
2614            .expect_err("fixture is not a supported linked module profile");
2615
2616        assert_eq!(error.code, ErrorCode::Validation);
2617        assert!(
2618            error
2619                .details
2620                .iter()
2621                .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
2622        );
2623    }
2624
2625    #[test]
2626    fn linked_http_route_owners_are_projected_from_modules() {
2627        assert_eq!(
2628            linked_http_route_owners(),
2629            vec![
2630                LinkedHttpRouteOwner {
2631                    module_name: "lenso/auth".to_owned(),
2632                    public_prefixes: &["/v1/auth/console/", "/v1/auth/dev/", "/v1/auth/sessions/",],
2633                },
2634                LinkedHttpRouteOwner {
2635                    module_name: "lenso/auth-anonymous".to_owned(),
2636                    public_prefixes: &["/v1/auth/anonymous/"],
2637                },
2638                LinkedHttpRouteOwner {
2639                    module_name: "lenso/auth-password".to_owned(),
2640                    public_prefixes: &["/v1/auth/password/"],
2641                },
2642                LinkedHttpRouteOwner {
2643                    module_name: "lenso/auth-phone".to_owned(),
2644                    public_prefixes: &["/v1/auth/phone/"],
2645                },
2646                LinkedHttpRouteOwner {
2647                    module_name: "lenso/auth-github".to_owned(),
2648                    public_prefixes: &["/v1/auth/github/"],
2649                },
2650                LinkedHttpRouteOwner {
2651                    module_name: "lenso/auth-google".to_owned(),
2652                    public_prefixes: &["/v1/auth/google/"],
2653                },
2654                LinkedHttpRouteOwner {
2655                    module_name: "lenso/auth-oidc".to_owned(),
2656                    public_prefixes: &["/.well-known/", "/oauth/"],
2657                },
2658            ]
2659        );
2660    }
2661
2662    #[test]
2663    fn linked_http_bindings_are_declared_in_manifests() {
2664        for module in linked_http_modules() {
2665            let http = module
2666                .linked_http
2667                .expect("linked HTTP module should carry HTTP contribution");
2668            assert!(
2669                !module.manifest.http_routes.is_empty(),
2670                "linked HTTP module `{}` must declare ModuleManifest::http_routes",
2671                module.manifest.module_id
2672            );
2673            for route in &module.manifest.http_routes {
2674                assert!(
2675                    http.public_prefixes
2676                        .iter()
2677                        .any(|prefix| route.path.starts_with(prefix)),
2678                    "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
2679                    module.manifest.module_id,
2680                    route.path
2681                );
2682            }
2683        }
2684    }
2685
2686    #[test]
2687    fn linked_http_modules_are_registered_modules() {
2688        let manifests = module_manifests();
2689
2690        for module in linked_http_modules() {
2691            let registered_manifest = manifests
2692                .iter()
2693                .find(|manifest| manifest.module_id == module.manifest.module_id)
2694                .unwrap_or_else(|| {
2695                    panic!(
2696                        "linked HTTP module `{}` is missing from module_manifests",
2697                        module.manifest.module_id
2698                    )
2699                });
2700            assert_eq!(
2701                registered_manifest, &module.manifest,
2702                "linked HTTP module `{}` must use the registered ModuleManifest",
2703                module.manifest.module_id
2704            );
2705        }
2706    }
2707
2708    #[tokio::test]
2709    async fn lifecycle_activation_enqueue_creates_function_run() {
2710        let Some(db) = TestDatabase::create().await else {
2711            return;
2712        };
2713        apply_runtime_stack_migrations(&db).await;
2714
2715        let mut ctx = AppContext::new(
2716            test_config(&db),
2717            db.pool.clone(),
2718            Arc::new(LoggingEventPublisher),
2719        );
2720        ctx.ids = Arc::new(SequentialIdGenerator::default());
2721        let modules = vec![
2722            test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
2723                .into(),
2724        ];
2725        let registry = registry_with_lifecycle_function(7);
2726
2727        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2728            .await
2729            .expect("lifecycle activation job should enqueue");
2730
2731        assert_eq!(run_ids.len(), 1);
2732        let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
2733            r#"
2734            select function_name, input_json, max_attempts, correlation_id, actor
2735            from runtime.function_runs
2736            where id = $1
2737            "#,
2738        )
2739        .bind(&run_ids[0])
2740        .fetch_one(&db.pool)
2741        .await
2742        .expect("function run should exist");
2743
2744        assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
2745        assert_eq!(row.1["warm"], "cache");
2746        assert_eq!(
2747            row.1["_lenso_runtime"]["correlation_id"],
2748            "corr_lifecycle_1"
2749        );
2750        assert_eq!(
2751            row.1["_lenso_runtime"]["causation_id"],
2752            "module_lifecycle:fixture/test-module:warm cache"
2753        );
2754        assert_eq!(row.2, 7);
2755        assert_eq!(row.3, "corr_lifecycle_1");
2756        assert_eq!(row.4["kind"], "service");
2757        assert_eq!(row.4["service_id"], "worker");
2758        assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
2759
2760        db.cleanup().await;
2761    }
2762
2763    #[test]
2764    fn lifecycle_activation_validation_rejects_required_missing_function() {
2765        let modules =
2766            vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
2767        let registry = FunctionRegistry::default();
2768
2769        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2770            .expect_err("required missing activation function should fail validation");
2771
2772        assert_eq!(error.code, ErrorCode::Validation);
2773        assert_eq!(
2774            error.details[0].field.as_deref(),
2775            Some("module.fixture/test-module.lifecycle.activation_jobs.warm cache")
2776        );
2777        assert!(
2778            error.details[0].reason.contains("missing function"),
2779            "validation detail should name the missing registry function"
2780        );
2781    }
2782
2783    #[test]
2784    fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
2785        let modules = vec![test_lifecycle_module_with_lifecycle(
2786            LifecycleSurface {
2787                startup_checks: vec![LifecycleStartupCheckDeclaration {
2788                    name: "function registered".to_owned(),
2789                    required: true,
2790                    check: LifecycleStartupCheckKind::FunctionRegistered {
2791                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2792                    },
2793                }],
2794                activation_jobs: Vec::new(),
2795            },
2796            true,
2797            Vec::new(),
2798        )];
2799        let registry = FunctionRegistry::default();
2800
2801        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2802            .expect_err("required startup check should fail when function is missing");
2803
2804        assert_eq!(error.code, ErrorCode::Validation);
2805        assert_eq!(
2806            error.details[0].field.as_deref(),
2807            Some("module.fixture/test-module.lifecycle.startup_checks.function registered")
2808        );
2809        assert!(
2810            error.details[0].reason.contains("missing function"),
2811            "validation detail should name the missing registry function"
2812        );
2813    }
2814
2815    #[test]
2816    fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
2817        let modules = vec![test_lifecycle_module_with_lifecycle(
2818            LifecycleSurface {
2819                startup_checks: vec![LifecycleStartupCheckDeclaration {
2820                    name: "function registered".to_owned(),
2821                    required: true,
2822                    check: LifecycleStartupCheckKind::FunctionRegistered {
2823                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2824                    },
2825                }],
2826                activation_jobs: Vec::new(),
2827            },
2828            false,
2829            Vec::new(),
2830        )];
2831        let registry = registry_with_lifecycle_function(3);
2832
2833        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2834            .expect_err("required startup check should fail when manifest does not declare it");
2835
2836        assert_eq!(error.code, ErrorCode::Validation);
2837        assert_eq!(
2838            error.details[0].field.as_deref(),
2839            Some("module.fixture/test-module.lifecycle.startup_checks.function registered")
2840        );
2841        assert!(
2842            error.details[0].reason.contains("not declared"),
2843            "validation detail should name the missing module runtime declaration"
2844        );
2845    }
2846
2847    #[test]
2848    fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
2849        let modules = vec![test_lifecycle_module_with_lifecycle(
2850            LifecycleSurface {
2851                startup_checks: vec![LifecycleStartupCheckDeclaration {
2852                    name: "capability declared".to_owned(),
2853                    required: true,
2854                    check: LifecycleStartupCheckKind::CapabilityDeclared {
2855                        capability: "test.cache.warm".to_owned(),
2856                    },
2857                }],
2858                activation_jobs: Vec::new(),
2859            },
2860            false,
2861            Vec::new(),
2862        )];
2863        let registry = FunctionRegistry::default();
2864
2865        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2866            .expect_err("required startup check should fail when capability is missing");
2867
2868        assert_eq!(error.code, ErrorCode::Validation);
2869        assert_eq!(
2870            error.details[0].field.as_deref(),
2871            Some("module.fixture/test-module.lifecycle.startup_checks.capability declared")
2872        );
2873        assert!(
2874            error.details[0].reason.contains("missing capability"),
2875            "validation detail should name the missing capability"
2876        );
2877    }
2878
2879    #[test]
2880    fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
2881        let modules = vec![test_lifecycle_module_with_lifecycle(
2882            LifecycleSurface {
2883                startup_checks: vec![
2884                    LifecycleStartupCheckDeclaration {
2885                        name: "optional function".to_owned(),
2886                        required: false,
2887                        check: LifecycleStartupCheckKind::FunctionRegistered {
2888                            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
2889                        },
2890                    },
2891                    LifecycleStartupCheckDeclaration {
2892                        name: "optional capability".to_owned(),
2893                        required: false,
2894                        check: LifecycleStartupCheckKind::CapabilityDeclared {
2895                            capability: "test.cache.warm".to_owned(),
2896                        },
2897                    },
2898                ],
2899                activation_jobs: Vec::new(),
2900            },
2901            false,
2902            Vec::new(),
2903        )];
2904        let registry = FunctionRegistry::default();
2905
2906        validate_lifecycle_activation_jobs(&modules, &registry)
2907            .expect("optional startup checks should not fail validation");
2908    }
2909
2910    #[test]
2911    fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
2912        let modules = vec![
2913            test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
2914                .without_runtime_declaration()
2915                .into(),
2916        ];
2917        let registry = registry_with_lifecycle_function(3);
2918
2919        let error = validate_lifecycle_activation_jobs(&modules, &registry)
2920            .expect_err("required activation job should fail when manifest does not declare it");
2921
2922        assert_eq!(error.code, ErrorCode::Validation);
2923        assert_eq!(
2924            error.details[0].field.as_deref(),
2925            Some("module.fixture/test-module.lifecycle.activation_jobs.warm cache")
2926        );
2927        assert!(
2928            error.details[0].reason.contains("not declared"),
2929            "validation detail should name the missing module runtime declaration"
2930        );
2931    }
2932
2933    #[tokio::test]
2934    async fn optional_missing_lifecycle_activation_is_skipped() {
2935        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2936            .expect("lazy pool should build");
2937        let ctx = AppContext::new(
2938            test_config_with_database_url("postgres://localhost/lenso_test"),
2939            db,
2940            Arc::new(LoggingEventPublisher),
2941        );
2942        let modules =
2943            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
2944        let registry = FunctionRegistry::default();
2945
2946        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2947            .await
2948            .expect("optional missing activation function should be skipped");
2949
2950        assert!(run_ids.is_empty());
2951    }
2952
2953    #[tokio::test]
2954    async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
2955        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2956            .expect("lazy pool should build");
2957        let ctx = AppContext::new(
2958            test_config_with_database_url("postgres://localhost/lenso_test"),
2959            db,
2960            Arc::new(LoggingEventPublisher),
2961        );
2962        let modules = vec![
2963            test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
2964                .without_runtime_declaration()
2965                .into(),
2966        ];
2967        let registry = registry_with_lifecycle_function(3);
2968
2969        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2970            .await
2971            .expect("optional undeclared activation function should be skipped");
2972
2973        assert!(run_ids.is_empty());
2974    }
2975
2976    #[tokio::test]
2977    async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
2978        let db = PgPoolOptions::new()
2979            .max_connections(1)
2980            .acquire_timeout(Duration::from_millis(50))
2981            .connect_lazy_with(
2982                PgConnectOptions::new()
2983                    .host("127.0.0.1")
2984                    .port(1)
2985                    .username("postgres")
2986                    .database("lenso_test"),
2987            );
2988        let ctx = AppContext::new(
2989            test_config_with_database_url("postgres://localhost:1/lenso_test"),
2990            db,
2991            Arc::new(LoggingEventPublisher),
2992        );
2993        let modules =
2994            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
2995        let registry = registry_with_lifecycle_function(3);
2996
2997        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
2998            .await
2999            .expect("optional enqueue failure should be skipped");
3000
3001        assert!(run_ids.is_empty());
3002    }
3003
3004    #[test]
3005    fn lifecycle_activation_max_attempts_conversion_saturates() {
3006        assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
3007        assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
3008    }
3009
3010    const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
3011
3012    #[derive(Debug)]
3013    struct NoopFunctionHandler;
3014
3015    #[async_trait]
3016    impl FunctionHandler for NoopFunctionHandler {
3017        async fn call(
3018            &self,
3019            _ctx: ExecutionContext,
3020            _input: Value,
3021        ) -> platform_core::AppResult<Value> {
3022            Ok(Value::Null)
3023        }
3024    }
3025
3026    fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
3027        LifecycleActivationJobDeclaration {
3028            name: "warm cache".to_owned(),
3029            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3030            run_policy: LifecycleActivationRunPolicy::EveryStartup,
3031            input,
3032            required,
3033        }
3034    }
3035
3036    struct TestLifecycleModuleBuilder {
3037        lifecycle: LifecycleSurface,
3038        declare_runtime_function: bool,
3039        capabilities: Vec<String>,
3040    }
3041
3042    impl TestLifecycleModuleBuilder {
3043        fn without_runtime_declaration(mut self) -> Self {
3044            self.declare_runtime_function = false;
3045            self
3046        }
3047    }
3048
3049    impl From<TestLifecycleModuleBuilder> for Module {
3050        fn from(builder: TestLifecycleModuleBuilder) -> Self {
3051            let mut manifest =
3052                ModuleManifest::builder("fixture/test-module").lifecycle(builder.lifecycle);
3053            if builder.declare_runtime_function {
3054                manifest = manifest.runtime(RuntimeSurface {
3055                    functions: vec![RuntimeFunctionDeclaration {
3056                        name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3057                        version: 1,
3058                        queue: "test".to_owned(),
3059                        input_schema: None,
3060                        retry_policy: None,
3061                        operation: None,
3062                    }],
3063                    schedules: vec![],
3064                    workflows: vec![],
3065                });
3066            }
3067            if !builder.capabilities.is_empty() {
3068                manifest = manifest.capabilities(builder.capabilities);
3069            }
3070            Module::linked(manifest.build(), LinkedBinding::builder().build())
3071        }
3072    }
3073
3074    fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
3075        TestLifecycleModuleBuilder {
3076            lifecycle: LifecycleSurface {
3077                startup_checks: Vec::new(),
3078                activation_jobs: vec![job],
3079            },
3080            declare_runtime_function: true,
3081            capabilities: Vec::new(),
3082        }
3083    }
3084
3085    fn test_lifecycle_module_with_lifecycle(
3086        lifecycle: LifecycleSurface,
3087        declare_runtime_function: bool,
3088        capabilities: Vec<String>,
3089    ) -> Module {
3090        TestLifecycleModuleBuilder {
3091            lifecycle,
3092            declare_runtime_function,
3093            capabilities,
3094        }
3095        .into()
3096    }
3097
3098    fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
3099        let mut registry = FunctionRegistry::default();
3100        registry.register(FunctionDefinition {
3101            name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3102            version: 1,
3103            queue: "test".to_owned(),
3104            retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
3105            handler: Arc::new(NoopFunctionHandler),
3106        });
3107        registry
3108    }
3109
3110    const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
3111        name: "billing/0001_init",
3112        sql: "select 1;",
3113    }];
3114
3115    fn test_host_manifest() -> ModuleManifest {
3116        ModuleManifest::builder("fixture/billing").build()
3117    }
3118
3119    fn test_host_linked_module() -> HostLinkedModule {
3120        HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
3121    }
3122
3123    fn test_config(db: &TestDatabase) -> AppConfig {
3124        test_config_with_database_url(db.url.clone())
3125    }
3126
3127    fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
3128        AppConfig {
3129            service: ServiceConfig::default(),
3130            database: DatabaseConfig {
3131                url: database_url.into(),
3132                max_connections: 5,
3133            },
3134            redis: RedisConfig::default(),
3135            http: HttpConfig::default(),
3136            telemetry: TelemetryConfig::default(),
3137            auth: AuthConfig::default(),
3138            module_sources: ModuleSourcesConfig::default(),
3139            modules: BTreeMap::new(),
3140        }
3141    }
3142
3143    async fn apply_runtime_stack_migrations(db: &TestDatabase) {
3144        let migrations = PLATFORM_MIGRATIONS
3145            .iter()
3146            .chain(RUNTIME_MIGRATIONS)
3147            .copied()
3148            .collect::<Vec<_>>();
3149        apply_migrations(&db.pool, &migrations)
3150            .await
3151            .expect("platform and runtime migrations should apply");
3152    }
3153}