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