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