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