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