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