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