1use platform_admin_data::{
22 AdminModule, AdminModuleMetadata, AdminModuleSourceDiagnostics, AdminRemoteModuleDiagnostics,
23};
24use platform_core::error::ErrorDetail;
25use platform_core::{
26 ActorContext, AppContext, AppError, CorrelationId, ErrorCode, EventHandlerRegistry, Migration,
27 PLATFORM_MIGRATIONS, RuntimeConfigDescriptor, RuntimeConfigGroupDescriptor, RuntimeConfigScope,
28 RuntimeConfigType, StoryDisplayDescriptor, StoryDisplaySource, TraceContext,
29};
30use platform_http::ApiOpenApiRouter;
31use platform_module::CronSchedule;
32pub use platform_module::HostLinkedModule;
33use platform_module::{
34 AdminSchema, AdminSurface, EventHandlerRegistrationContext, LifecycleActivationRunPolicy,
35 LifecycleStartupCheckKind, LinkedBinding, Module, ModuleHttpMethod, ModuleLoadStatus,
36 ModuleManifest, ModuleSource,
37};
38use platform_module_remote::{RemoteHttpProxyRegistry, RemoteModuleConfig, RemoteModuleSource};
39use platform_runtime::{
40 EnqueueFunctionRequest, FunctionRegistry, RUNTIME_MIGRATIONS, RuntimeClient,
41 ScheduledFunctionDefinition,
42};
43use std::fs::{self, OpenOptions};
44use std::io::Write as _;
45use std::path::{Path, PathBuf};
46use std::process::{Child, Command};
47use std::sync::Arc;
48use std::thread;
49use std::time::{Duration, Instant};
50
51const DEFAULT_MODULE_SERVICES_FILE: &str = ".lenso/module-services.json";
52const DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS: u64 = 10_000;
53const REMOTE_SERVICE_TERMINATE_GRACE_MS: u64 = 800;
54const AUTH_SESSION_CACHE_MAX_TTL: Duration = Duration::from_secs(12 * 60 * 60);
55
56struct LinkedModuleEntry {
57 module_name: &'static str,
58 manifest: fn() -> ModuleManifest,
59 load: fn(&AppContext) -> Module,
60 http_binding: Option<fn() -> LinkedBinding>,
61}
62
63const MODULES_CONFIG_GROUP: RuntimeConfigGroupDescriptor = RuntimeConfigGroupDescriptor {
64 id: "modules",
65 label: "Modules",
66 description: "Module load toggles applied on service startup.",
67 order: 10,
68};
69
70#[derive(Debug, Clone, Default)]
71pub struct HostComposition {
72 linked_modules: Vec<HostLinkedModule>,
73}
74
75impl HostComposition {
76 #[must_use]
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 #[must_use]
82 pub fn with_linked_module(mut self, module: HostLinkedModule) -> Self {
83 self.add_linked_module(module);
84 self
85 }
86
87 pub fn add_linked_module(&mut self, module: HostLinkedModule) {
88 self.linked_modules.push(module);
89 }
90
91 #[must_use]
92 pub fn linked_modules(&self) -> &[HostLinkedModule] {
93 &self.linked_modules
94 }
95}
96
97#[derive(Debug, Clone)]
98pub struct HostWiring {
99 auth_session_policy: auth::session_policy::AuthSessionPolicyHandle,
100}
101
102impl HostWiring {
103 #[must_use]
104 pub fn auth_session_policy(&self) -> auth::session_policy::AuthSessionPolicyHandle {
105 self.auth_session_policy.clone()
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum CompositionProfile {
111 Core,
112 Demo,
113}
114
115impl CompositionProfile {
116 pub fn parse(value: &str) -> platform_core::AppResult<Self> {
117 match value.trim().to_ascii_lowercase().as_str() {
118 "core" => Ok(Self::Core),
119 "demo" => Ok(Self::Demo),
120 other => Err(AppError::validation(
121 "Invalid Lenso composition profile",
122 vec![ErrorDetail {
123 field: Some("module_sources.linked_profile".to_owned()),
124 reason: format!("expected `core` or `demo`, got `{other}`"),
125 }],
126 )),
127 }
128 }
129
130 pub fn from_config(config: &platform_core::AppConfig) -> platform_core::AppResult<Self> {
131 Self::parse(&config.module_sources.linked_profile)
132 }
133}
134
135impl Default for CompositionProfile {
136 fn default() -> Self {
137 Self::Demo
138 }
139}
140
141const CORE_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[LinkedModuleEntry {
142 module_name: "platform-story",
143 manifest: story::module::manifest,
144 load: story::module::module,
145 http_binding: Some(story::module::binding),
146}];
147
148const DEMO_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[
149 LinkedModuleEntry {
150 module_name: "auth",
151 manifest: auth::module::manifest,
152 load: auth::module::module,
153 http_binding: Some(auth::module::binding),
154 },
155 LinkedModuleEntry {
156 module_name: "auth-anonymous",
157 manifest: auth_anonymous::module::manifest,
158 load: auth_anonymous::module::module,
159 http_binding: Some(auth_anonymous::module::binding),
160 },
161 LinkedModuleEntry {
162 module_name: "auth-oauth",
163 manifest: auth_oauth::module::manifest,
164 load: auth_oauth::module::module,
165 http_binding: None,
166 },
167 LinkedModuleEntry {
168 module_name: "auth-password",
169 manifest: auth_password::module::manifest,
170 load: auth_password::module::module,
171 http_binding: Some(auth_password::module::binding),
172 },
173 LinkedModuleEntry {
174 module_name: "auth-phone",
175 manifest: auth_phone::module::manifest,
176 load: auth_phone::module::module,
177 http_binding: Some(auth_phone::module::binding),
178 },
179 LinkedModuleEntry {
180 module_name: "auth-github",
181 manifest: auth_github::module::manifest,
182 load: auth_github::module::module,
183 http_binding: Some(auth_github::module::binding),
184 },
185 LinkedModuleEntry {
186 module_name: "auth-google",
187 manifest: auth_google::module::manifest,
188 load: auth_google::module::module,
189 http_binding: Some(auth_google::module::binding),
190 },
191 LinkedModuleEntry {
192 module_name: "auth-oidc",
193 manifest: auth_oidc::module::manifest,
194 load: auth_oidc::module::module,
195 http_binding: Some(auth_oidc::module::binding),
196 },
197 LinkedModuleEntry {
198 module_name: "platform-story",
199 manifest: story::module::manifest,
200 load: story::module::module,
201 http_binding: Some(story::module::binding),
202 },
203];
204
205fn linked_module_entries(profile: CompositionProfile) -> &'static [LinkedModuleEntry] {
206 match profile {
207 CompositionProfile::Core => CORE_LINKED_MODULE_ENTRIES,
208 CompositionProfile::Demo => DEMO_LINKED_MODULE_ENTRIES,
209 }
210}
211
212#[must_use]
213pub fn auth_linked_module() -> HostLinkedModule {
214 HostLinkedModule::linked(
215 auth::module::MODULE_NAME,
216 auth::module::manifest,
217 auth::module::module,
218 auth::migrations::AUTH_MIGRATIONS,
219 )
220 .with_http_binding(auth::module::binding)
221}
222
223#[must_use]
224pub fn auth_anonymous_linked_module() -> HostLinkedModule {
225 HostLinkedModule::linked(
226 auth_anonymous::module::MODULE_NAME,
227 auth_anonymous::module::manifest,
228 auth_anonymous::module::module,
229 auth_anonymous::migrations::AUTH_ANONYMOUS_MIGRATIONS,
230 )
231 .with_http_binding(auth_anonymous::module::binding)
232}
233
234#[must_use]
235pub fn auth_password_linked_module() -> HostLinkedModule {
236 HostLinkedModule::linked(
237 auth_password::module::MODULE_NAME,
238 auth_password::module::manifest,
239 auth_password::module::module,
240 auth_password::migrations::AUTH_PASSWORD_MIGRATIONS,
241 )
242 .with_http_binding(auth_password::module::binding)
243}
244
245#[must_use]
246pub fn auth_phone_linked_module() -> HostLinkedModule {
247 HostLinkedModule::linked(
248 auth_phone::module::MODULE_NAME,
249 auth_phone::module::manifest,
250 auth_phone::module::module,
251 auth_phone::migrations::AUTH_PHONE_MIGRATIONS,
252 )
253 .with_http_binding(auth_phone::module::binding)
254}
255
256#[must_use]
257pub fn auth_oauth_linked_module() -> HostLinkedModule {
258 HostLinkedModule::linked(
259 auth_oauth::module::MODULE_NAME,
260 auth_oauth::module::manifest,
261 auth_oauth::module::module,
262 auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS,
263 )
264}
265
266#[must_use]
267pub fn auth_github_linked_module() -> HostLinkedModule {
268 HostLinkedModule::linked(
269 auth_github::module::MODULE_NAME,
270 auth_github::module::manifest,
271 auth_github::module::module,
272 auth_github::migrations::AUTH_GITHUB_MIGRATIONS,
273 )
274 .with_http_binding(auth_github::module::binding)
275}
276
277#[must_use]
278pub fn auth_google_linked_module() -> HostLinkedModule {
279 HostLinkedModule::linked(
280 auth_google::module::MODULE_NAME,
281 auth_google::module::manifest,
282 auth_google::module::module,
283 auth_google::migrations::AUTH_GOOGLE_MIGRATIONS,
284 )
285 .with_http_binding(auth_google::module::binding)
286}
287
288#[must_use]
289pub fn auth_oidc_linked_module() -> HostLinkedModule {
290 HostLinkedModule::linked(
291 auth_oidc::module::MODULE_NAME,
292 auth_oidc::module::manifest,
293 auth_oidc::module::module,
294 auth_oidc::migrations::AUTH_OIDC_MIGRATIONS,
295 )
296 .with_http_binding(auth_oidc::module::binding)
297}
298
299fn linked_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
300 config
301 .modules
302 .get(module_name)
303 .is_none_or(platform_core::ModuleConfig::is_enabled)
304}
305
306fn module_enabled_config_key(module_name: &str) -> String {
307 format!("modules.{module_name}.enabled")
308}
309
310fn linked_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
311 ctx.runtime_config
312 .snapshot()
313 .raw(&module_enabled_config_key(module_name))
314 .and_then(serde_json::Value::as_bool)
315 .unwrap_or_else(|| linked_module_enabled_from_config(&ctx.config, module_name))
316}
317
318fn first_disabled_dependency(ctx: &AppContext, manifest: fn() -> ModuleManifest) -> Option<String> {
319 (manifest)()
320 .dependencies
321 .into_iter()
322 .find(|dependency| !linked_module_enabled(ctx, dependency))
323}
324
325fn first_disabled_dependency_from_config(
326 config: &platform_core::AppConfig,
327 manifest: fn() -> ModuleManifest,
328) -> Option<String> {
329 (manifest)()
330 .dependencies
331 .into_iter()
332 .find(|dependency| !linked_module_enabled_from_config(config, dependency))
333}
334
335fn linked_module_with_dependencies_enabled(
336 ctx: &AppContext,
337 module_name: &str,
338 manifest: fn() -> ModuleManifest,
339) -> bool {
340 linked_module_enabled(ctx, module_name) && first_disabled_dependency(ctx, manifest).is_none()
341}
342
343fn linked_module_with_dependencies_enabled_from_config(
344 config: &platform_core::AppConfig,
345 module_name: &str,
346 manifest: fn() -> ModuleManifest,
347) -> bool {
348 linked_module_enabled_from_config(config, module_name)
349 && first_disabled_dependency_from_config(config, manifest).is_none()
350}
351
352fn linked_module_disabled_reason(
353 ctx: &AppContext,
354 module_name: &str,
355 manifest: fn() -> ModuleManifest,
356) -> Option<String> {
357 if !linked_module_enabled(ctx, module_name) {
358 return Some("module disabled by configuration".to_owned());
359 }
360 if let Some(dependency) = first_disabled_dependency(ctx, manifest) {
361 return Some(format!("module dependency disabled: {dependency}"));
362 }
363 None
364}
365
366fn remote_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
367 config
368 .modules
369 .get(module_name)
370 .is_none_or(platform_core::ModuleConfig::is_enabled)
371}
372
373fn remote_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
374 ctx.runtime_config
375 .snapshot()
376 .raw(&module_enabled_config_key(module_name))
377 .and_then(serde_json::Value::as_bool)
378 .unwrap_or_else(|| remote_module_enabled_from_config(&ctx.config, module_name))
379}
380
381pub fn auth_actor_resolver_for_context(
382 ctx: &AppContext,
383) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
384 auth_actor_resolver_for_context_with_composition(ctx, &HostComposition::default())
385}
386
387pub fn auth_actor_resolver_for_context_with_composition(
388 ctx: &AppContext,
389 composition: &HostComposition,
390) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
391 let profile = CompositionProfile::from_config(&ctx.config)?;
392 let auth_in_profile = linked_module_entries(profile)
393 .iter()
394 .any(|entry| entry.module_name == auth::module::MODULE_NAME);
395 let auth_in_composition = composition
396 .linked_modules()
397 .iter()
398 .any(|entry| entry.module_name == auth::module::MODULE_NAME);
399 if (!auth_in_profile && !auth_in_composition)
400 || !linked_module_enabled(ctx, auth::module::MODULE_NAME)
401 {
402 return Ok(None);
403 }
404
405 let auth_config = auth::config::AuthRuntimeConfig::from_context(ctx);
406 let auth_resolver: Arc<dyn platform_core::ActorResolver> = Arc::new(
407 auth::resolver::AuthActorResolver::new_with_session_cache(
408 ctx.db.clone(),
409 ctx.actor_resolver.clone(),
410 auth_session_cache(ctx, auth_config.session_cache)?,
411 )
412 .with_user_scopes(auth_config.console_admin_user_scopes),
413 );
414
415 let auth_password_enabled = linked_module_with_dependencies_enabled(
416 ctx,
417 auth_password::module::MODULE_NAME,
418 auth_password::module::manifest,
419 );
420 if auth_password_enabled {
421 if let Some(jwt_resolver) =
422 auth_password::module::jwt_actor_resolver(ctx, auth_resolver.clone())?
423 {
424 return Ok(Some(jwt_resolver));
425 }
426 }
427
428 Ok(Some(auth_resolver))
429}
430
431fn auth_session_cache(
432 ctx: &AppContext,
433 session_cache: auth::config::SessionCacheMode,
434) -> platform_core::AppResult<Option<Arc<dyn auth::resolver::SessionCache>>> {
435 match session_cache {
436 auth::config::SessionCacheMode::Database => Ok(None),
437 auth::config::SessionCacheMode::Redis => {
438 let Some(redis) = ctx.redis.clone() else {
439 return Err(AppError::validation(
440 "Redis auth session cache is not configured",
441 vec![ErrorDetail {
442 field: Some("auth.session_cache".to_owned()),
443 reason: "set REDIS_URL when auth.session_cache is redis".to_owned(),
444 }],
445 ));
446 };
447 Ok(Some(Arc::new(auth::redis_cache::RedisSessionCache::new(
448 redis,
449 AUTH_SESSION_CACHE_MAX_TTL,
450 ))))
451 }
452 }
453}
454
455fn linked_module_entries_for_context(
456 ctx: &AppContext,
457) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
458 Ok(
459 linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
460 .iter()
461 .filter(|entry| {
462 linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
463 })
464 .collect(),
465 )
466}
467
468fn linked_module_entries_for_config(
469 config: &platform_core::AppConfig,
470) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
471 Ok(
472 linked_module_entries(CompositionProfile::from_config(config)?)
473 .iter()
474 .filter(|entry| {
475 linked_module_with_dependencies_enabled_from_config(
476 config,
477 entry.module_name,
478 entry.manifest,
479 )
480 })
481 .collect(),
482 )
483}
484
485fn disabled_linked_module_entries_for_context(
486 ctx: &AppContext,
487) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
488 Ok(
489 linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
490 .iter()
491 .filter(|entry| {
492 linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
493 })
494 .collect(),
495 )
496}
497
498fn linked_profile_has_module(profile: CompositionProfile, module_name: &str) -> bool {
499 linked_module_entries(profile)
500 .iter()
501 .any(|entry| entry.module_name == module_name)
502}
503
504fn host_linked_modules_not_in_profile(
505 composition: &HostComposition,
506 profile: CompositionProfile,
507) -> impl Iterator<Item = HostLinkedModule> + '_ {
508 composition
509 .linked_modules()
510 .iter()
511 .cloned()
512 .filter(move |entry| !linked_profile_has_module(profile, entry.module_name))
513}
514
515fn host_linked_modules_for_config(
516 config: &platform_core::AppConfig,
517 composition: &HostComposition,
518 profile: CompositionProfile,
519) -> Vec<HostLinkedModule> {
520 host_linked_modules_not_in_profile(composition, profile)
521 .filter(|entry| {
522 linked_module_with_dependencies_enabled_from_config(
523 config,
524 entry.module_name,
525 entry.manifest,
526 )
527 })
528 .collect()
529}
530
531fn host_linked_modules_for_context(
532 ctx: &AppContext,
533 composition: &HostComposition,
534 profile: CompositionProfile,
535) -> Vec<HostLinkedModule> {
536 host_linked_modules_not_in_profile(composition, profile)
537 .filter(|entry| {
538 linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
539 })
540 .collect()
541}
542
543fn disabled_host_linked_modules_for_context(
544 ctx: &AppContext,
545 composition: &HostComposition,
546 profile: CompositionProfile,
547) -> Vec<HostLinkedModule> {
548 host_linked_modules_not_in_profile(composition, profile)
549 .filter(|entry| {
550 linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
551 })
552 .collect()
553}
554
555pub fn host_wiring_for_context(ctx: &AppContext) -> platform_core::AppResult<HostWiring> {
556 host_wiring_for_context_with_composition(ctx, &HostComposition::default())
557}
558
559pub fn host_wiring_for_context_with_composition(
560 ctx: &AppContext,
561 composition: &HostComposition,
562) -> platform_core::AppResult<HostWiring> {
563 let profile = CompositionProfile::from_config(&ctx.config)?;
564 let mut session_policies = Vec::new();
565 for module in host_linked_modules_for_context(ctx, composition, profile) {
566 for extension in module.contributions::<auth::session_policy::AuthHostExtension>() {
567 if let Some(factory) = extension.session_policy_factory() {
568 session_policies.push(factory(ctx));
569 }
570 }
571 }
572
573 Ok(HostWiring {
574 auth_session_policy: auth::session_policy::AuthSessionPolicyChain::handle(session_policies),
575 })
576}
577
578fn load_host_linked_module(ctx: &AppContext, entry: HostLinkedModule) -> Module {
579 match entry.load {
580 Some(load) => load(ctx),
581 None => Module::linked((entry.manifest)(), LinkedBinding::builder().build()),
582 }
583}
584
585#[must_use]
590pub fn modules(ctx: &AppContext) -> Vec<Module> {
591 modules_for_profile(ctx, CompositionProfile::default())
592}
593
594pub fn modules_for_config(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
595 Ok(linked_module_entries_for_context(ctx)?
596 .into_iter()
597 .map(|entry| (entry.load)(ctx))
598 .collect())
599}
600
601pub fn modules_for_config_with_composition(
602 ctx: &AppContext,
603 composition: &HostComposition,
604) -> platform_core::AppResult<Vec<Module>> {
605 let profile = CompositionProfile::from_config(&ctx.config)?;
606 let mut modules = modules_for_config(ctx)?;
607 modules.extend(
608 host_linked_modules_for_context(ctx, composition, profile)
609 .into_iter()
610 .map(|entry| load_host_linked_module(ctx, entry)),
611 );
612 Ok(modules)
613}
614
615#[must_use]
616pub fn modules_for_profile(ctx: &AppContext, profile: CompositionProfile) -> Vec<Module> {
617 linked_module_entries(profile)
618 .iter()
619 .map(|entry| (entry.load)(ctx))
620 .collect()
621}
622
623pub async fn load_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
629 load_modules_with_composition(ctx, &HostComposition::default()).await
630}
631
632pub async fn load_modules_with_composition(
633 ctx: &AppContext,
634 composition: &HostComposition,
635) -> platform_core::AppResult<Vec<Module>> {
636 let mut loaded = modules_for_config_with_composition(ctx, composition)?;
637
638 for remote in &ctx.config.module_sources.remote {
639 if !remote_module_enabled(ctx, &remote.name) {
640 continue;
641 }
642 let source = RemoteModuleSource::new(remote_module_config(remote))?;
643 loaded.extend(
644 source
645 .load_all()
646 .await?
647 .into_iter()
648 .map(|loaded| loaded.module),
649 );
650 }
651
652 Ok(loaded)
653}
654
655pub fn migrations_for_config(
656 config: &platform_core::AppConfig,
657) -> platform_core::AppResult<Vec<Migration>> {
658 migrations_for_config_with_composition(config, &HostComposition::default())
659}
660
661pub fn migrations_for_config_with_composition(
662 config: &platform_core::AppConfig,
663 composition: &HostComposition,
664) -> platform_core::AppResult<Vec<Migration>> {
665 let mut migrations = PLATFORM_MIGRATIONS
666 .iter()
667 .chain(RUNTIME_MIGRATIONS)
668 .copied()
669 .collect::<Vec<_>>();
670
671 let profile = CompositionProfile::from_config(config)?;
672 if profile == CompositionProfile::Demo {
673 if linked_module_enabled_from_config(config, "auth") {
674 migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
675 }
676 if linked_module_with_dependencies_enabled_from_config(
677 config,
678 "auth-oauth",
679 auth_oauth::module::manifest,
680 ) {
681 migrations.extend(
682 auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS
683 .iter()
684 .copied(),
685 );
686 }
687 if linked_module_with_dependencies_enabled_from_config(
688 config,
689 "auth-password",
690 auth_password::module::manifest,
691 ) {
692 migrations.extend(
693 auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
694 .iter()
695 .copied(),
696 );
697 }
698 if linked_module_with_dependencies_enabled_from_config(
699 config,
700 "auth-phone",
701 auth_phone::module::manifest,
702 ) {
703 migrations.extend(
704 auth_phone::migrations::AUTH_PHONE_MIGRATIONS
705 .iter()
706 .copied(),
707 );
708 }
709 if linked_module_with_dependencies_enabled_from_config(
710 config,
711 "auth-github",
712 auth_github::module::manifest,
713 ) {
714 migrations.extend(
715 auth_github::migrations::AUTH_GITHUB_MIGRATIONS
716 .iter()
717 .copied(),
718 );
719 }
720 if linked_module_with_dependencies_enabled_from_config(
721 config,
722 "auth-google",
723 auth_google::module::manifest,
724 ) {
725 migrations.extend(
726 auth_google::migrations::AUTH_GOOGLE_MIGRATIONS
727 .iter()
728 .copied(),
729 );
730 }
731 if linked_module_with_dependencies_enabled_from_config(
732 config,
733 "auth-oidc",
734 auth_oidc::module::manifest,
735 ) {
736 migrations.extend(auth_oidc::migrations::AUTH_OIDC_MIGRATIONS.iter().copied());
737 }
738 }
739
740 for module in host_linked_modules_for_config(config, composition, profile) {
741 migrations.extend(module.migrations.iter().copied());
742 }
743
744 Ok(migrations)
745}
746
747#[must_use]
748pub fn migrations_for_profile(profile: CompositionProfile) -> Vec<Migration> {
749 let mut migrations = PLATFORM_MIGRATIONS
750 .iter()
751 .chain(RUNTIME_MIGRATIONS)
752 .copied()
753 .collect::<Vec<_>>();
754
755 if profile == CompositionProfile::Demo {
756 migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
757 migrations.extend(
758 auth_oauth::migrations::AUTH_OAUTH_MIGRATIONS
759 .iter()
760 .copied(),
761 );
762 migrations.extend(
763 auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
764 .iter()
765 .copied(),
766 );
767 migrations.extend(
768 auth_phone::migrations::AUTH_PHONE_MIGRATIONS
769 .iter()
770 .copied(),
771 );
772 migrations.extend(
773 auth_github::migrations::AUTH_GITHUB_MIGRATIONS
774 .iter()
775 .copied(),
776 );
777 migrations.extend(
778 auth_google::migrations::AUTH_GOOGLE_MIGRATIONS
779 .iter()
780 .copied(),
781 );
782 migrations.extend(auth_oidc::migrations::AUTH_OIDC_MIGRATIONS.iter().copied());
783 }
784
785 migrations
786}
787
788#[must_use]
791pub fn module_manifests() -> Vec<ModuleManifest> {
792 module_manifests_for_profile(CompositionProfile::default())
793}
794
795#[must_use]
796pub fn module_manifests_for_profile(profile: CompositionProfile) -> Vec<ModuleManifest> {
797 linked_module_entries(profile)
798 .iter()
799 .map(|entry| (entry.manifest)())
800 .collect()
801}
802
803#[must_use]
805pub fn linked_runtime_function_declaration_sources() -> Vec<(
806 String,
807 ModuleSource,
808 Option<platform_module::RuntimeSurface>,
809)> {
810 linked_runtime_function_declaration_sources_for_profile(CompositionProfile::default())
811}
812
813#[must_use]
814pub fn linked_runtime_function_declaration_sources_for_profile(
815 profile: CompositionProfile,
816) -> Vec<(
817 String,
818 ModuleSource,
819 Option<platform_module::RuntimeSurface>,
820)> {
821 module_manifests_for_profile(profile)
822 .into_iter()
823 .map(|manifest| (manifest.name, ModuleSource::Linked, manifest.runtime))
824 .collect()
825}
826
827pub fn linked_runtime_function_declaration_sources_for_config(
828 config: &platform_core::AppConfig,
829) -> platform_core::AppResult<
830 Vec<(
831 String,
832 ModuleSource,
833 Option<platform_module::RuntimeSurface>,
834 )>,
835> {
836 Ok(linked_module_entries_for_config(config)?
837 .into_iter()
838 .map(|entry| {
839 let manifest = (entry.manifest)();
840 (manifest.name, ModuleSource::Linked, manifest.runtime)
841 })
842 .collect())
843}
844
845pub fn linked_runtime_function_declaration_sources_for_context(
846 ctx: &AppContext,
847) -> platform_core::AppResult<
848 Vec<(
849 String,
850 ModuleSource,
851 Option<platform_module::RuntimeSurface>,
852 )>,
853> {
854 Ok(linked_module_entries_for_context(ctx)?
855 .into_iter()
856 .map(|entry| {
857 let manifest = (entry.manifest)();
858 (manifest.name, ModuleSource::Linked, manifest.runtime)
859 })
860 .collect())
861}
862
863pub fn linked_runtime_function_declaration_sources_for_context_with_composition(
864 ctx: &AppContext,
865 composition: &HostComposition,
866) -> platform_core::AppResult<
867 Vec<(
868 String,
869 ModuleSource,
870 Option<platform_module::RuntimeSurface>,
871 )>,
872> {
873 let profile = CompositionProfile::from_config(&ctx.config)?;
874 let mut sources = linked_runtime_function_declaration_sources_for_context(ctx)?;
875 sources.extend(
876 host_linked_modules_for_context(ctx, composition, profile)
877 .into_iter()
878 .map(|entry| {
879 let manifest = (entry.manifest)();
880 (manifest.name, ModuleSource::Linked, manifest.runtime)
881 }),
882 );
883 Ok(sources)
884}
885
886#[must_use]
889pub fn runtime_function_declaration_sources_from_metadata(
890 modules: &[AdminModuleMetadata],
891) -> Vec<(
892 String,
893 ModuleSource,
894 Option<platform_module::RuntimeSurface>,
895)> {
896 modules
897 .iter()
898 .filter(|module| matches!(module.load_status, ModuleLoadStatus::Loaded))
899 .map(|module| {
900 (
901 module.module_name.clone(),
902 module.source,
903 module.runtime.clone(),
904 )
905 })
906 .collect()
907}
908
909#[derive(Debug, Clone, PartialEq, Eq)]
914pub struct LinkedHttpRouteOwner {
915 pub module_name: String,
916 pub public_prefixes: &'static [&'static str],
917}
918
919#[must_use]
920pub fn linked_http_route_owners() -> Vec<LinkedHttpRouteOwner> {
921 linked_http_route_owners_for_profile(CompositionProfile::default())
922}
923
924#[must_use]
925pub fn linked_http_route_owners_for_profile(
926 profile: CompositionProfile,
927) -> Vec<LinkedHttpRouteOwner> {
928 linked_module_entries(profile)
929 .iter()
930 .filter_map(|entry| {
931 let http = entry.http_binding?().http?;
932 Some(LinkedHttpRouteOwner {
933 module_name: entry.module_name.to_owned(),
934 public_prefixes: http.public_prefixes,
935 })
936 })
937 .collect()
938}
939
940#[must_use]
942pub fn linked_http_modules() -> Vec<Module> {
943 linked_http_modules_for_profile(CompositionProfile::default())
944}
945
946#[must_use]
947pub fn linked_http_modules_for_profile(profile: CompositionProfile) -> Vec<Module> {
948 linked_module_entries(profile)
949 .iter()
950 .filter_map(|entry| {
951 let http_binding = entry.http_binding?;
952 Some(Module::linked((entry.manifest)(), http_binding()))
953 })
954 .collect()
955}
956
957pub fn linked_http_modules_for_config(
958 config: &platform_core::AppConfig,
959) -> platform_core::AppResult<Vec<Module>> {
960 Ok(linked_module_entries_for_config(config)?
961 .into_iter()
962 .filter_map(|entry| {
963 let http_binding = entry.http_binding?;
964 Some(Module::linked((entry.manifest)(), http_binding()))
965 })
966 .collect())
967}
968
969pub fn linked_http_modules_for_context(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
970 Ok(linked_module_entries_for_context(ctx)?
971 .into_iter()
972 .filter_map(|entry| {
973 let http_binding = entry.http_binding?;
974 Some(Module::linked((entry.manifest)(), http_binding()))
975 })
976 .collect())
977}
978
979pub fn linked_http_modules_for_context_with_composition(
980 ctx: &AppContext,
981 composition: &HostComposition,
982) -> platform_core::AppResult<Vec<Module>> {
983 let profile = CompositionProfile::from_config(&ctx.config)?;
984 let mut modules = linked_http_modules_for_context(ctx)?;
985 modules.extend(
986 host_linked_modules_for_context(ctx, composition, profile)
987 .into_iter()
988 .filter_map(|entry| {
989 let http_binding = entry.http_binding?;
990 Some(Module::linked((entry.manifest)(), http_binding()))
991 }),
992 );
993 Ok(modules)
994}
995
996#[must_use]
1001pub fn admin_modules(ctx: &AppContext) -> Vec<AdminModule> {
1002 admin_modules_from_modules(modules(ctx))
1003}
1004
1005pub async fn load_admin_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<AdminModule>> {
1007 load_admin_modules_with_composition(ctx, &HostComposition::default()).await
1008}
1009
1010pub async fn load_admin_modules_with_composition(
1011 ctx: &AppContext,
1012 composition: &HostComposition,
1013) -> platform_core::AppResult<Vec<AdminModule>> {
1014 let mut admin_modules =
1015 admin_modules_from_modules(modules_for_config_with_composition(ctx, composition)?);
1016
1017 for remote in &ctx.config.module_sources.remote {
1018 if !remote_module_enabled(ctx, &remote.name) {
1019 continue;
1020 }
1021 let source = RemoteModuleSource::new(remote_module_config(remote))?;
1022 match source.load_all().await {
1023 Ok(modules) => admin_modules.extend(admin_modules_from_modules(
1024 modules.into_iter().map(|loaded| loaded.module).collect(),
1025 )),
1026 Err(error) => admin_modules.push(failed_remote_admin_module(
1027 remote.name.clone(),
1028 error.public_message,
1029 )),
1030 }
1031 }
1032
1033 Ok(admin_modules)
1034}
1035
1036pub async fn load_admin_module_metadata(
1040 ctx: &AppContext,
1041) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1042 load_admin_module_metadata_with_composition(ctx, &HostComposition::default()).await
1043}
1044
1045pub async fn load_admin_module_metadata_with_composition(
1046 ctx: &AppContext,
1047 composition: &HostComposition,
1048) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1049 let mut metadata =
1050 admin_metadata_from_modules(modules_for_config_with_composition(ctx, composition)?);
1051 metadata.extend(disabled_linked_admin_metadata(ctx)?);
1052 metadata.extend(disabled_host_linked_admin_metadata(ctx, composition)?);
1053
1054 for remote in &ctx.config.module_sources.remote {
1055 let config = remote_module_config(remote);
1056 if !remote_module_enabled(ctx, &remote.name) {
1057 metadata.push(disabled_remote_admin_metadata(&config));
1058 continue;
1059 }
1060 let checked_at = current_timestamp();
1061 let source = RemoteModuleSource::new(config.clone())?;
1062 let load_started = Instant::now();
1063 match source.load_all().await {
1064 Ok(modules) => {
1065 let load_duration_ms = Some(duration_ms(load_started));
1066 for loaded in modules {
1067 metadata.extend(remote_admin_metadata_from_module(
1068 loaded.module,
1069 &loaded.config,
1070 checked_at.clone(),
1071 load_duration_ms,
1072 None,
1073 ));
1074 }
1075 }
1076 Err(error) => metadata.push(failed_remote_admin_metadata(
1077 &config,
1078 Some(checked_at),
1079 Some(duration_ms(load_started)),
1080 error.public_message,
1081 )),
1082 }
1083 }
1084
1085 Ok(metadata)
1086}
1087
1088pub async fn load_remote_http_proxy_registry(
1089 ctx: &AppContext,
1090) -> platform_core::AppResult<RemoteHttpProxyRegistry> {
1091 let mut remote_modules = Vec::new();
1092 let mut remote_configs = Vec::new();
1093
1094 for remote in &ctx.config.module_sources.remote {
1095 if !remote_module_enabled(ctx, &remote.name) {
1096 continue;
1097 }
1098 let config = remote_module_config(remote);
1099 let source = RemoteModuleSource::new(config.clone())?;
1100 if let Ok(modules) = source.load_all().await {
1101 for loaded in modules {
1102 remote_configs.push(loaded.config);
1103 remote_modules.push(loaded.module);
1104 }
1105 }
1106 }
1107
1108 Ok(RemoteHttpProxyRegistry::from_modules(
1109 &remote_modules,
1110 &remote_configs,
1111 ))
1112}
1113
1114fn admin_modules_from_modules(modules: Vec<Module>) -> Vec<AdminModule> {
1115 modules
1116 .into_iter()
1117 .filter_map(|module| {
1118 let data_source = module.admin_data;
1120 let action_source = module.admin_actions;
1121 let query_source = module.admin_queries;
1122 if data_source.is_none() && action_source.is_none() && query_source.is_none() {
1123 return None;
1124 }
1125 let ModuleManifest { name, admin, .. } = module.manifest;
1126 let admin = admin?;
1127 let (schema, listed_in_schema) = match &admin {
1128 AdminSurface::Schema(schema) => (schema.clone(), true),
1129 AdminSurface::DeclarativeCustom(surface) => (
1130 surface.fallback_schema.clone().unwrap_or(AdminSchema {
1131 entities: Vec::new(),
1132 }),
1133 false,
1134 ),
1135 AdminSurface::EmbeddedCustom(_) => return None,
1136 _ => return None,
1137 };
1138 Some(AdminModule {
1139 module_name: name,
1140 source: module.source,
1141 load_status: module.load_status,
1142 schema,
1143 admin: Some(admin),
1144 listed_in_schema,
1145 data_source,
1146 action_source,
1147 query_source,
1148 })
1149 })
1150 .collect()
1151}
1152
1153fn admin_metadata_from_modules(modules: Vec<Module>) -> Vec<AdminModuleMetadata> {
1154 modules
1155 .into_iter()
1156 .map(|module| {
1157 let ModuleManifest {
1158 name,
1159 admin,
1160 http_routes,
1161 runtime,
1162 events,
1163 lifecycle,
1164 console,
1165 console_slots,
1166 console_contributions,
1167 story_display,
1168 capabilities,
1169 dependencies,
1170 ..
1171 } = module.manifest;
1172 AdminModuleMetadata {
1173 module_name: name,
1174 source: module.source,
1175 load_status: module.load_status,
1176 http_routes,
1177 runtime,
1178 events,
1179 lifecycle,
1180 console,
1181 console_slots,
1182 console_contributions,
1183 story_display,
1184 capabilities,
1185 dependencies,
1186 admin,
1187 source_diagnostics: None,
1188 }
1189 })
1190 .collect()
1191}
1192
1193fn failed_remote_admin_module(name: String, message: String) -> AdminModule {
1194 AdminModule {
1195 module_name: name,
1196 source: ModuleSource::Remote,
1197 load_status: ModuleLoadStatus::Error { message },
1198 schema: AdminSchema {
1199 entities: Vec::new(),
1200 },
1201 admin: None,
1202 listed_in_schema: true,
1203 data_source: None,
1204 action_source: None,
1205 query_source: None,
1206 }
1207}
1208
1209fn remote_admin_metadata_from_module(
1210 module: Module,
1211 config: &RemoteModuleConfig,
1212 checked_at: String,
1213 load_duration_ms: Option<u64>,
1214 load_error: Option<String>,
1215) -> Vec<AdminModuleMetadata> {
1216 admin_metadata_from_modules(vec![module])
1217 .into_iter()
1218 .map(|mut metadata| {
1219 metadata.source_diagnostics = Some(remote_source_diagnostics(
1220 config,
1221 Some(checked_at.clone()),
1222 load_duration_ms,
1223 load_error.clone(),
1224 ));
1225 metadata
1226 })
1227 .collect()
1228}
1229
1230fn failed_remote_admin_metadata(
1231 config: &RemoteModuleConfig,
1232 checked_at: Option<String>,
1233 load_duration_ms: Option<u64>,
1234 message: String,
1235) -> AdminModuleMetadata {
1236 AdminModuleMetadata {
1237 module_name: config.name.clone(),
1238 source: ModuleSource::Remote,
1239 load_status: ModuleLoadStatus::Error {
1240 message: message.clone(),
1241 },
1242 http_routes: Vec::new(),
1243 runtime: None,
1244 events: None,
1245 lifecycle: None,
1246 console: Vec::new(),
1247 console_slots: Vec::new(),
1248 console_contributions: Vec::new(),
1249 story_display: Vec::new(),
1250 capabilities: Vec::new(),
1251 dependencies: Vec::new(),
1252 admin: None,
1253 source_diagnostics: Some(remote_source_diagnostics(
1254 config,
1255 checked_at,
1256 load_duration_ms,
1257 Some(message),
1258 )),
1259 }
1260}
1261
1262fn disabled_remote_admin_metadata(config: &RemoteModuleConfig) -> AdminModuleMetadata {
1263 AdminModuleMetadata {
1264 module_name: config.name.clone(),
1265 source: ModuleSource::Remote,
1266 load_status: ModuleLoadStatus::Error {
1267 message: "module disabled by configuration".to_owned(),
1268 },
1269 http_routes: Vec::new(),
1270 runtime: None,
1271 events: None,
1272 lifecycle: None,
1273 console: Vec::new(),
1274 console_slots: Vec::new(),
1275 console_contributions: Vec::new(),
1276 story_display: Vec::new(),
1277 capabilities: Vec::new(),
1278 dependencies: Vec::new(),
1279 admin: None,
1280 source_diagnostics: Some(remote_source_diagnostics(config, None, None, None)),
1281 }
1282}
1283
1284fn disabled_linked_admin_metadata(
1285 ctx: &AppContext,
1286) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1287 Ok(disabled_linked_module_entries_for_context(ctx)?
1288 .into_iter()
1289 .map(|entry| {
1290 let ModuleManifest {
1291 name,
1292 admin,
1293 http_routes,
1294 runtime,
1295 events,
1296 lifecycle,
1297 console,
1298 console_slots,
1299 console_contributions,
1300 story_display,
1301 capabilities,
1302 dependencies,
1303 ..
1304 } = (entry.manifest)();
1305 AdminModuleMetadata {
1306 module_name: name,
1307 source: ModuleSource::Linked,
1308 load_status: ModuleLoadStatus::Error {
1309 message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1310 .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1311 },
1312 http_routes,
1313 runtime,
1314 events,
1315 lifecycle,
1316 console,
1317 console_slots,
1318 console_contributions,
1319 story_display,
1320 capabilities,
1321 dependencies,
1322 admin,
1323 source_diagnostics: None,
1324 }
1325 })
1326 .collect())
1327}
1328
1329fn disabled_host_linked_admin_metadata(
1330 ctx: &AppContext,
1331 composition: &HostComposition,
1332) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1333 let profile = CompositionProfile::from_config(&ctx.config)?;
1334 Ok(
1335 disabled_host_linked_modules_for_context(ctx, composition, profile)
1336 .into_iter()
1337 .map(|entry| {
1338 let ModuleManifest {
1339 name,
1340 admin,
1341 http_routes,
1342 runtime,
1343 events,
1344 lifecycle,
1345 console,
1346 console_slots,
1347 console_contributions,
1348 story_display,
1349 capabilities,
1350 dependencies,
1351 ..
1352 } = (entry.manifest)();
1353 AdminModuleMetadata {
1354 module_name: name,
1355 source: ModuleSource::Linked,
1356 load_status: ModuleLoadStatus::Error {
1357 message: linked_module_disabled_reason(
1358 ctx,
1359 entry.module_name,
1360 entry.manifest,
1361 )
1362 .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1363 },
1364 http_routes,
1365 runtime,
1366 events,
1367 lifecycle,
1368 console,
1369 console_slots,
1370 console_contributions,
1371 story_display,
1372 capabilities,
1373 dependencies,
1374 admin,
1375 source_diagnostics: None,
1376 }
1377 })
1378 .collect(),
1379 )
1380}
1381
1382fn remote_source_diagnostics(
1383 config: &RemoteModuleConfig,
1384 checked_at: Option<String>,
1385 load_duration_ms: Option<u64>,
1386 load_error: Option<String>,
1387) -> AdminModuleSourceDiagnostics {
1388 let (transport, manifest_url) = match config.transport {
1389 platform_module_remote::RemoteModuleTransport::HttpJson => {
1390 ("http_json", format!("{}/manifest", config.base_url))
1391 }
1392 platform_module_remote::RemoteModuleTransport::Grpc => (
1393 "grpc",
1394 format!(
1395 "{}#lenso.remote.v1.RemoteModule/GetManifest",
1396 config.base_url
1397 ),
1398 ),
1399 };
1400 AdminModuleSourceDiagnostics::Remote(AdminRemoteModuleDiagnostics {
1401 transport: transport.to_owned(),
1402 base_url: config.base_url.clone(),
1403 manifest_url,
1404 timeout_ms: config.timeout_ms,
1405 auth_configured: config.auth_token.is_some(),
1406 load_duration_ms,
1407 last_checked_at: checked_at,
1408 last_load_error: load_error,
1409 })
1410}
1411
1412fn remote_module_config(source: &platform_core::RemoteModuleSourceConfig) -> RemoteModuleConfig {
1413 let mut config = RemoteModuleConfig::new(source.name.clone(), source.base_url.clone())
1414 .with_timeout_ms(source.timeout_ms);
1415
1416 if let Some(env_name) = &source.auth_token_env {
1417 if let Ok(token) = std::env::var(env_name) {
1418 config = config.with_auth_token(token);
1419 }
1420 }
1421
1422 config
1423}
1424
1425#[derive(Debug)]
1426pub struct RemoteModuleServiceSupervisor {
1427 services: Vec<RemoteModuleServiceHandle>,
1428}
1429
1430impl RemoteModuleServiceSupervisor {
1431 #[must_use]
1432 pub fn is_empty(&self) -> bool {
1433 self.services.is_empty()
1434 }
1435}
1436
1437impl Drop for RemoteModuleServiceSupervisor {
1438 fn drop(&mut self) {
1439 for service in &mut self.services {
1440 terminate_remote_module_service(&mut service.child);
1441 release_remote_module_service_state(&service.lock_file_path, &service.pid_file_path);
1442 }
1443 }
1444}
1445
1446#[derive(Debug)]
1447struct RemoteModuleServiceHandle {
1448 child: Child,
1449 lock_file_path: PathBuf,
1450 pid_file_path: PathBuf,
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq)]
1454struct RemoteModuleServiceSpec {
1455 module_name: String,
1456 service_name: String,
1457 command: String,
1458 cwd: Option<PathBuf>,
1459 ready_url: String,
1460 ready_timeout_ms: u64,
1461 auto_start: bool,
1462}
1463
1464pub async fn start_installed_remote_module_services(
1465 ctx: &AppContext,
1466) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1467 start_installed_remote_module_services_from_path(ctx, Path::new(DEFAULT_MODULE_SERVICES_FILE))
1468 .await
1469}
1470
1471pub async fn start_installed_remote_module_services_from_path(
1472 ctx: &AppContext,
1473 services_file_path: &Path,
1474) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1475 let specs = read_remote_module_service_specs(services_file_path)?;
1476 let services_state_dir = services_file_path
1477 .parent()
1478 .unwrap_or_else(|| Path::new("."));
1479 let client = reqwest::Client::builder()
1480 .timeout(Duration::from_millis(800))
1481 .build()
1482 .map_err(|source| {
1483 AppError::new(ErrorCode::Internal, "failed to build HTTP client").with_source(source)
1484 })?;
1485 let mut services = Vec::new();
1486
1487 for spec in specs {
1488 if !spec.auto_start || !remote_service_module_enabled(ctx, &spec.module_name) {
1489 continue;
1490 }
1491 if remote_service_ready(&client, &spec.ready_url).await {
1492 tracing::info!(
1493 module = %spec.module_name,
1494 service = %spec.service_name,
1495 ready_url = %spec.ready_url,
1496 "remote module service already ready"
1497 );
1498 continue;
1499 }
1500 let lock_file_path = remote_module_service_state_path(services_state_dir, &spec, "lock");
1501 let pid_file_path = remote_module_service_state_path(services_state_dir, &spec, "pid");
1502 if !claim_remote_module_service_lock(&client, &spec, &lock_file_path, &pid_file_path)
1503 .await?
1504 {
1505 continue;
1506 }
1507 let mut child = match spawn_remote_module_service(&spec) {
1508 Ok(child) => child,
1509 Err(error) => {
1510 release_remote_module_service_state(&lock_file_path, &pid_file_path);
1511 return Err(error);
1512 }
1513 };
1514 if let Err(error) = write_remote_module_service_pid(&pid_file_path, child.id()) {
1515 terminate_remote_module_service(&mut child);
1516 release_remote_module_service_state(&lock_file_path, &pid_file_path);
1517 return Err(error);
1518 }
1519 if let Err(error) = wait_for_remote_module_service(&client, &spec, &mut child).await {
1520 terminate_remote_module_service(&mut child);
1521 release_remote_module_service_state(&lock_file_path, &pid_file_path);
1522 return Err(error);
1523 }
1524 tracing::info!(
1525 module = %spec.module_name,
1526 service = %spec.service_name,
1527 ready_url = %spec.ready_url,
1528 "started remote module service"
1529 );
1530 services.push(RemoteModuleServiceHandle {
1531 child,
1532 lock_file_path,
1533 pid_file_path,
1534 });
1535 }
1536
1537 Ok(RemoteModuleServiceSupervisor { services })
1538}
1539
1540fn remote_service_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
1541 ctx.config
1542 .module_sources
1543 .remote
1544 .iter()
1545 .any(|remote| remote.name == module_name)
1546 && remote_module_enabled(ctx, module_name)
1547}
1548
1549fn read_remote_module_service_specs(
1550 services_file_path: &Path,
1551) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1552 let source = match std::fs::read_to_string(services_file_path) {
1553 Ok(source) => source,
1554 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1555 Err(source) => {
1556 return Err(AppError::new(
1557 ErrorCode::ExternalDependency,
1558 format!("remote module services file could not be read: {source}"),
1559 ));
1560 }
1561 };
1562 let value = serde_json::from_str::<serde_json::Value>(&source).map_err(|source| {
1563 AppError::new(
1564 ErrorCode::Validation,
1565 format!("remote module services file could not be parsed: {source}"),
1566 )
1567 })?;
1568 parse_remote_module_service_specs(&value)
1569}
1570
1571fn parse_remote_module_service_specs(
1572 value: &serde_json::Value,
1573) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1574 let modules = value
1575 .get("modules")
1576 .and_then(serde_json::Value::as_array)
1577 .ok_or_else(|| {
1578 AppError::new(
1579 ErrorCode::Validation,
1580 "remote module services file modules must be an array",
1581 )
1582 })?;
1583 let mut specs = Vec::new();
1584 for module in modules {
1585 let module_name = json_string(module, "moduleName")?;
1586 let services = module
1587 .get("services")
1588 .and_then(serde_json::Value::as_array)
1589 .ok_or_else(|| {
1590 AppError::new(
1591 ErrorCode::Validation,
1592 format!("{module_name} services must be an array"),
1593 )
1594 })?;
1595 for service in services {
1596 let command = json_string(service, "command")?;
1597 let ready_url = json_string(service, "readyUrl")?;
1598 specs.push(RemoteModuleServiceSpec {
1599 module_name: module_name.clone(),
1600 service_name: service
1601 .get("name")
1602 .and_then(serde_json::Value::as_str)
1603 .unwrap_or(&module_name)
1604 .to_owned(),
1605 command,
1606 cwd: service
1607 .get("cwd")
1608 .and_then(serde_json::Value::as_str)
1609 .map(PathBuf::from),
1610 ready_url,
1611 ready_timeout_ms: service
1612 .get("readyTimeoutMs")
1613 .and_then(serde_json::Value::as_u64)
1614 .unwrap_or(DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS),
1615 auto_start: service
1616 .get("autoStart")
1617 .and_then(serde_json::Value::as_bool)
1618 .unwrap_or(true),
1619 });
1620 }
1621 }
1622 Ok(specs)
1623}
1624
1625fn json_string(value: &serde_json::Value, key: &str) -> platform_core::AppResult<String> {
1626 value
1627 .get(key)
1628 .and_then(serde_json::Value::as_str)
1629 .map(str::to_owned)
1630 .ok_or_else(|| AppError::new(ErrorCode::Validation, format!("{key} must be a string")))
1631}
1632
1633fn spawn_remote_module_service(spec: &RemoteModuleServiceSpec) -> platform_core::AppResult<Child> {
1634 let cwd = spec
1635 .cwd
1636 .clone()
1637 .unwrap_or(std::env::current_dir().map_err(|source| {
1638 AppError::new(ErrorCode::Internal, "failed to resolve current directory")
1639 .with_source(source)
1640 })?);
1641 let mut command = shell_command(&spec.command);
1642 command.current_dir(cwd);
1643 configure_remote_module_service_process(&mut command);
1644 command.spawn().map_err(|source| {
1645 AppError::new(
1646 ErrorCode::ExternalDependency,
1647 format!(
1648 "failed to start remote module service {}: {}",
1649 spec.module_name, spec.service_name
1650 ),
1651 )
1652 .with_source(source)
1653 })
1654}
1655
1656async fn wait_for_remote_module_service(
1657 client: &reqwest::Client,
1658 spec: &RemoteModuleServiceSpec,
1659 child: &mut Child,
1660) -> platform_core::AppResult<()> {
1661 let started = Instant::now();
1662 let timeout = Duration::from_millis(spec.ready_timeout_ms);
1663 loop {
1664 if remote_service_ready(client, &spec.ready_url).await {
1665 return Ok(());
1666 }
1667 if let Some(status) = child.try_wait().map_err(|source| {
1668 AppError::new(
1669 ErrorCode::ExternalDependency,
1670 format!(
1671 "remote module service {} status could not be checked",
1672 spec.service_name
1673 ),
1674 )
1675 .with_source(source)
1676 })? {
1677 return Err(AppError::new(
1678 ErrorCode::ExternalDependency,
1679 format!(
1680 "remote module service {} exited before it became ready: {status}",
1681 spec.service_name
1682 ),
1683 ));
1684 }
1685 if started.elapsed() >= timeout {
1686 return Err(AppError::new(
1687 ErrorCode::ExternalDependency,
1688 format!(
1689 "remote module service {} did not become ready at {}",
1690 spec.service_name, spec.ready_url
1691 ),
1692 ));
1693 }
1694 tokio::time::sleep(Duration::from_millis(200)).await;
1695 }
1696}
1697
1698async fn remote_service_ready(client: &reqwest::Client, ready_url: &str) -> bool {
1699 client
1700 .get(ready_url)
1701 .send()
1702 .await
1703 .is_ok_and(|response| response.status().is_success())
1704}
1705
1706async fn claim_remote_module_service_lock(
1707 client: &reqwest::Client,
1708 spec: &RemoteModuleServiceSpec,
1709 lock_file_path: &Path,
1710 pid_file_path: &Path,
1711) -> platform_core::AppResult<bool> {
1712 match create_remote_module_service_lock(lock_file_path) {
1713 Ok(()) => return Ok(true),
1714 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1715 Err(source) => {
1716 return Err(AppError::new(
1717 ErrorCode::ExternalDependency,
1718 format!(
1719 "remote module service {} lock could not be created: {source}",
1720 spec.service_name
1721 ),
1722 ));
1723 }
1724 }
1725
1726 tracing::info!(
1727 module = %spec.module_name,
1728 service = %spec.service_name,
1729 ready_url = %spec.ready_url,
1730 "remote module service startup already claimed"
1731 );
1732 if wait_for_remote_module_service_ready(
1733 client,
1734 &spec.ready_url,
1735 Duration::from_millis(spec.ready_timeout_ms),
1736 )
1737 .await
1738 {
1739 return Ok(false);
1740 }
1741
1742 tracing::warn!(
1743 module = %spec.module_name,
1744 service = %spec.service_name,
1745 lock_file = %lock_file_path.display(),
1746 "remote module service lock did not become ready before timeout; treating it as stale"
1747 );
1748 terminate_stale_remote_module_service(pid_file_path);
1749 release_remote_module_service_state(lock_file_path, pid_file_path);
1750 match create_remote_module_service_lock(lock_file_path) {
1751 Ok(()) => Ok(true),
1752 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1753 Err(source) => Err(AppError::new(
1754 ErrorCode::ExternalDependency,
1755 format!(
1756 "stale remote module service {} lock could not be replaced: {source}",
1757 spec.service_name
1758 ),
1759 )),
1760 }
1761}
1762
1763fn create_remote_module_service_lock(lock_file_path: &Path) -> std::io::Result<()> {
1764 if let Some(parent) = lock_file_path.parent() {
1765 fs::create_dir_all(parent)?;
1766 }
1767 let mut file = OpenOptions::new()
1768 .write(true)
1769 .create_new(true)
1770 .open(lock_file_path)?;
1771 writeln!(file, "owner_pid={}", std::process::id())?;
1772 Ok(())
1773}
1774
1775fn write_remote_module_service_pid(
1776 pid_file_path: &Path,
1777 child_pid: u32,
1778) -> platform_core::AppResult<()> {
1779 if let Some(parent) = pid_file_path.parent() {
1780 fs::create_dir_all(parent).map_err(|source| {
1781 AppError::new(
1782 ErrorCode::ExternalDependency,
1783 format!("remote module service pid directory could not be created: {source}"),
1784 )
1785 })?;
1786 }
1787 fs::write(pid_file_path, format!("{child_pid}\n")).map_err(|source| {
1788 AppError::new(
1789 ErrorCode::ExternalDependency,
1790 format!("remote module service pid file could not be written: {source}"),
1791 )
1792 })
1793}
1794
1795fn release_remote_module_service_state(lock_file_path: &Path, pid_file_path: &Path) {
1796 let _ = fs::remove_file(pid_file_path);
1797 let _ = fs::remove_file(lock_file_path);
1798}
1799
1800#[cfg(unix)]
1801fn terminate_stale_remote_module_service(pid_file_path: &Path) {
1802 let Ok(source) = fs::read_to_string(pid_file_path) else {
1803 return;
1804 };
1805 let Ok(pid) = source.trim().parse::<u32>() else {
1806 return;
1807 };
1808
1809 let _ = Command::new("kill")
1810 .arg("-TERM")
1811 .arg(format!("-{pid}"))
1812 .status();
1813 thread::sleep(Duration::from_millis(100));
1814}
1815
1816#[cfg(not(unix))]
1817fn terminate_stale_remote_module_service(_pid_file_path: &Path) {}
1818
1819async fn wait_for_remote_module_service_ready(
1820 client: &reqwest::Client,
1821 ready_url: &str,
1822 timeout: Duration,
1823) -> bool {
1824 let started = Instant::now();
1825 loop {
1826 if remote_service_ready(client, ready_url).await {
1827 return true;
1828 }
1829 if started.elapsed() >= timeout {
1830 return false;
1831 }
1832 tokio::time::sleep(Duration::from_millis(200)).await;
1833 }
1834}
1835
1836fn remote_module_service_state_path(
1837 services_state_dir: &Path,
1838 spec: &RemoteModuleServiceSpec,
1839 extension: &str,
1840) -> PathBuf {
1841 services_state_dir.join(format!(
1842 "remote-{}-{}.{}",
1843 remote_module_service_state_segment(&spec.module_name),
1844 remote_module_service_state_segment(&spec.service_name),
1845 extension
1846 ))
1847}
1848
1849fn remote_module_service_state_segment(value: &str) -> String {
1850 let mut segment = String::new();
1851 let mut previous_dash = false;
1852 for character in value.chars() {
1853 if character.is_ascii_alphanumeric() {
1854 segment.push(character.to_ascii_lowercase());
1855 previous_dash = false;
1856 } else if !segment.is_empty() && !previous_dash {
1857 segment.push('-');
1858 previous_dash = true;
1859 }
1860 }
1861 while segment.ends_with('-') {
1862 segment.pop();
1863 }
1864 if segment.is_empty() {
1865 "service".to_owned()
1866 } else {
1867 segment
1868 }
1869}
1870
1871fn terminate_remote_module_service(child: &mut Child) {
1872 if matches!(child.try_wait(), Ok(Some(_))) {
1873 return;
1874 }
1875
1876 #[cfg(unix)]
1877 {
1878 let process_group_id = child.id();
1879 let _ = Command::new("kill")
1880 .arg("-TERM")
1881 .arg(format!("-{process_group_id}"))
1882 .status();
1883 if wait_for_remote_module_service_exit(
1884 child,
1885 Duration::from_millis(REMOTE_SERVICE_TERMINATE_GRACE_MS),
1886 ) {
1887 return;
1888 }
1889 }
1890
1891 let _ = child.kill();
1892 let _ = child.wait();
1893}
1894
1895fn wait_for_remote_module_service_exit(child: &mut Child, timeout: Duration) -> bool {
1896 let started = Instant::now();
1897 loop {
1898 match child.try_wait() {
1899 Ok(Some(_)) => return true,
1900 Ok(None) => {}
1901 Err(_) => return true,
1902 }
1903 if started.elapsed() >= timeout {
1904 return false;
1905 }
1906 thread::sleep(Duration::from_millis(50));
1907 }
1908}
1909
1910#[cfg(unix)]
1911fn configure_remote_module_service_process(command: &mut Command) {
1912 use std::os::unix::process::CommandExt;
1913 command.process_group(0);
1914}
1915
1916#[cfg(not(unix))]
1917fn configure_remote_module_service_process(_command: &mut Command) {}
1918
1919fn shell_command(command: &str) -> Command {
1920 if cfg!(windows) {
1921 let mut process = Command::new("cmd");
1922 process.arg("/C").arg(command);
1923 process
1924 } else {
1925 let mut process = Command::new("sh");
1926 process.arg("-c").arg(command);
1927 process
1928 }
1929}
1930
1931fn current_timestamp() -> String {
1932 use platform_core::Clock;
1933 platform_core::SystemClock.now().to_rfc3339()
1934}
1935
1936fn duration_ms(started: Instant) -> u64 {
1937 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1938}
1939
1940#[must_use]
1942pub fn function_registry(modules: &[Module]) -> FunctionRegistry {
1943 let mut registry = FunctionRegistry::default();
1944 for module in modules {
1945 module.binding.register_functions(&mut registry);
1946 }
1947 registry
1948}
1949
1950pub async fn enqueue_lifecycle_activation_jobs(
1956 ctx: &AppContext,
1957 modules: &[Module],
1958 registry: &FunctionRegistry,
1959) -> platform_core::AppResult<Vec<String>> {
1960 validate_lifecycle_activation_jobs(modules, registry)?;
1961
1962 let client = RuntimeClient::new(ctx.db.clone());
1963 let mut run_ids = Vec::new();
1964
1965 for module in modules {
1966 let Some(lifecycle) = &module.manifest.lifecycle else {
1967 continue;
1968 };
1969
1970 for job in &lifecycle.activation_jobs {
1971 if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1972 continue;
1973 }
1974 if !module_declares_runtime_function(module, &job.function_name) {
1975 continue;
1976 }
1977
1978 let Some(definition) = registry.get(&job.function_name) else {
1979 continue;
1980 };
1981
1982 let enqueue_result = client
1983 .enqueue_function(EnqueueFunctionRequest {
1984 function_name: job.function_name.clone(),
1985 input_json: job.input.clone(),
1986 correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1987 actor: ActorContext::Service {
1988 service_id: "worker".to_owned(),
1989 scopes: vec!["runtime.functions.enqueue".to_owned()],
1990 },
1991 trace: TraceContext::default(),
1992 causation_id: Some(format!(
1993 "module_lifecycle:{}:{}",
1994 module.manifest.name, job.name
1995 )),
1996 max_attempts: Some(runtime_max_attempts_for_enqueue(
1997 definition.retry_policy.max_attempts,
1998 )),
1999 })
2000 .await;
2001
2002 match enqueue_result {
2003 Ok(run_id) => run_ids.push(run_id),
2004 Err(error) if job.required => return Err(error),
2005 Err(error) => warn_optional_lifecycle_enqueue_failure(
2006 &module.manifest.name,
2007 &job.name,
2008 &job.function_name,
2009 &error,
2010 ),
2011 }
2012 }
2013 }
2014
2015 Ok(run_ids)
2016}
2017
2018fn validate_lifecycle_activation_jobs(
2019 modules: &[Module],
2020 registry: &FunctionRegistry,
2021) -> platform_core::AppResult<()> {
2022 for module in modules {
2023 let Some(lifecycle) = &module.manifest.lifecycle else {
2024 continue;
2025 };
2026
2027 for check in &lifecycle.startup_checks {
2028 match &check.check {
2029 LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
2030 if !module_declares_runtime_function(module, function_name) {
2031 let reason = format!(
2032 "startup check `{}` references function `{}` not declared by module `{}`",
2033 check.name, function_name, module.manifest.name
2034 );
2035 if !check.required {
2036 warn_optional_lifecycle_skip(
2037 &module.manifest.name,
2038 "startup_checks",
2039 &check.name,
2040 &reason,
2041 );
2042 continue;
2043 }
2044 return Err(lifecycle_validation_error(
2045 &module.manifest.name,
2046 "startup_checks",
2047 &check.name,
2048 format!("required {reason}"),
2049 ));
2050 }
2051 if registry.get(function_name).is_none() {
2052 let reason = format!(
2053 "startup check `{}` references missing function `{}`",
2054 check.name, function_name
2055 );
2056 if !check.required {
2057 warn_optional_lifecycle_skip(
2058 &module.manifest.name,
2059 "startup_checks",
2060 &check.name,
2061 &reason,
2062 );
2063 continue;
2064 }
2065 return Err(lifecycle_validation_error(
2066 &module.manifest.name,
2067 "startup_checks",
2068 &check.name,
2069 format!("required {reason}"),
2070 ));
2071 }
2072 }
2073 LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
2074 if !module.manifest.capabilities.contains(capability) {
2075 let reason = format!(
2076 "startup check `{}` references missing capability `{}`",
2077 check.name, capability
2078 );
2079 if !check.required {
2080 warn_optional_lifecycle_skip(
2081 &module.manifest.name,
2082 "startup_checks",
2083 &check.name,
2084 &reason,
2085 );
2086 continue;
2087 }
2088 return Err(lifecycle_validation_error(
2089 &module.manifest.name,
2090 "startup_checks",
2091 &check.name,
2092 format!("required {reason}"),
2093 ));
2094 }
2095 }
2096 _ => {
2097 let reason = format!(
2098 "startup check `{}` uses an unsupported lifecycle check kind",
2099 check.name
2100 );
2101 if !check.required {
2102 warn_optional_lifecycle_skip(
2103 &module.manifest.name,
2104 "startup_checks",
2105 &check.name,
2106 &reason,
2107 );
2108 continue;
2109 }
2110 return Err(lifecycle_validation_error(
2111 &module.manifest.name,
2112 "startup_checks",
2113 &check.name,
2114 format!("required {reason}"),
2115 ));
2116 }
2117 }
2118 }
2119
2120 for job in &lifecycle.activation_jobs {
2121 if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
2122 continue;
2123 }
2124
2125 if !module_declares_runtime_function(module, &job.function_name) {
2126 let reason = format!(
2127 "activation job `{}` references function `{}` not declared by module `{}`",
2128 job.name, job.function_name, module.manifest.name
2129 );
2130 if !job.required {
2131 warn_optional_lifecycle_skip(
2132 &module.manifest.name,
2133 "activation_jobs",
2134 &job.name,
2135 &reason,
2136 );
2137 continue;
2138 }
2139 return Err(lifecycle_validation_error(
2140 &module.manifest.name,
2141 "activation_jobs",
2142 &job.name,
2143 format!("required {reason}"),
2144 ));
2145 }
2146 if registry.get(&job.function_name).is_none() {
2147 let reason = format!(
2148 "activation job `{}` references missing function `{}`",
2149 job.name, job.function_name
2150 );
2151 if !job.required {
2152 warn_optional_lifecycle_skip(
2153 &module.manifest.name,
2154 "activation_jobs",
2155 &job.name,
2156 &reason,
2157 );
2158 continue;
2159 }
2160 return Err(lifecycle_validation_error(
2161 &module.manifest.name,
2162 "activation_jobs",
2163 &job.name,
2164 format!("required {reason}"),
2165 ));
2166 }
2167 }
2168 }
2169
2170 Ok(())
2171}
2172
2173fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
2174 module.manifest.runtime.as_ref().is_some_and(|runtime| {
2175 runtime
2176 .functions
2177 .iter()
2178 .any(|function| function.name == function_name)
2179 })
2180}
2181
2182fn lifecycle_validation_error(
2183 module_name: &str,
2184 collection: &str,
2185 item_name: &str,
2186 reason: String,
2187) -> AppError {
2188 AppError::validation(
2189 "Module lifecycle declaration failed validation",
2190 vec![ErrorDetail {
2191 field: Some(format!(
2192 "module.{module_name}.lifecycle.{collection}.{item_name}"
2193 )),
2194 reason,
2195 }],
2196 )
2197}
2198
2199fn warn_optional_lifecycle_skip(
2200 module_name: &str,
2201 collection: &str,
2202 item_name: &str,
2203 reason: &str,
2204) {
2205 tracing::warn!(
2206 module_name = %module_name,
2207 lifecycle_collection = %collection,
2208 lifecycle_item = %item_name,
2209 reason = %reason,
2210 "optional module lifecycle declaration skipped"
2211 );
2212}
2213
2214fn warn_optional_lifecycle_enqueue_failure(
2215 module_name: &str,
2216 job_name: &str,
2217 function_name: &str,
2218 error: &AppError,
2219) {
2220 tracing::warn!(
2221 module_name = %module_name,
2222 lifecycle_collection = "activation_jobs",
2223 lifecycle_item = %job_name,
2224 function_name = %function_name,
2225 error_code = %error.code.as_str(),
2226 error_message = %error.public_message,
2227 "optional module lifecycle activation enqueue failed"
2228 );
2229}
2230
2231fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
2232 i32::try_from(max_attempts).unwrap_or(i32::MAX)
2233}
2234
2235pub fn scheduled_functions(
2237 modules: &[Module],
2238 registry: &FunctionRegistry,
2239) -> platform_core::AppResult<Vec<ScheduledFunctionDefinition>> {
2240 let mut schedules = Vec::new();
2241
2242 for module in modules {
2243 if !matches!(module.load_status, ModuleLoadStatus::Loaded) {
2244 continue;
2245 }
2246 let Some(runtime) = &module.manifest.runtime else {
2247 continue;
2248 };
2249
2250 for schedule in &runtime.schedules {
2251 if schedule.name.trim().is_empty() {
2252 return Err(AppError::new(
2253 ErrorCode::Validation,
2254 format!(
2255 "scheduled runtime function for module {} is missing a name",
2256 module.manifest.name
2257 ),
2258 ));
2259 }
2260 if !module_declares_runtime_function(module, &schedule.function_name) {
2261 return Err(AppError::new(
2262 ErrorCode::Validation,
2263 format!(
2264 "scheduled runtime function {}:{} references function {} not declared by module {}",
2265 module.manifest.name,
2266 schedule.name,
2267 schedule.function_name,
2268 module.manifest.name
2269 ),
2270 ));
2271 }
2272 let Some(function) = registry.get(&schedule.function_name) else {
2273 return Err(AppError::new(
2274 ErrorCode::Validation,
2275 format!(
2276 "scheduled runtime function {}:{} references missing function {}",
2277 module.manifest.name, schedule.name, schedule.function_name
2278 ),
2279 ));
2280 };
2281 let parsed_schedule = CronSchedule::parse(&schedule.cron).map_err(|error| {
2282 AppError::new(
2283 ErrorCode::Validation,
2284 format!(
2285 "scheduled runtime function {}:{} has invalid cron expression: {error}",
2286 module.manifest.name, schedule.name
2287 ),
2288 )
2289 })?;
2290 schedules.push(ScheduledFunctionDefinition {
2291 schedule_key: format!("{}:{}", module.manifest.name, schedule.name),
2292 module_name: module.manifest.name.clone(),
2293 schedule_name: schedule.name.clone(),
2294 function_name: schedule.function_name.clone(),
2295 cron: schedule.cron.clone(),
2296 schedule: parsed_schedule,
2297 input_json: schedule.input.clone(),
2298 max_attempts: runtime_max_attempts_for_enqueue(function.retry_policy.max_attempts),
2299 });
2300 }
2301 }
2302
2303 Ok(schedules)
2304}
2305
2306#[must_use]
2308pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
2309 event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
2310}
2311
2312#[must_use]
2315pub fn event_handlers_with_runtime_actions(
2316 ctx: &AppContext,
2317 modules: &[Module],
2318 function_registry: Arc<FunctionRegistry>,
2319) -> EventHandlerRegistry {
2320 let context = EventHandlerRegistrationContext::with_runtime(
2321 RuntimeClient::new(ctx.db.clone()),
2322 function_registry,
2323 );
2324 event_handlers_with_context(modules, &context)
2325}
2326
2327fn event_handlers_with_context(
2328 modules: &[Module],
2329 context: &EventHandlerRegistrationContext,
2330) -> EventHandlerRegistry {
2331 let mut registry = EventHandlerRegistry::new();
2332 for module in modules {
2333 module
2334 .binding
2335 .register_event_handlers(&mut registry, context);
2336 }
2337 registry
2338}
2339
2340pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
2348 merge_linked_http_for_profile(base, CompositionProfile::default())
2349}
2350
2351pub fn merge_linked_http_for_profile(
2352 base: ApiOpenApiRouter,
2353 profile: CompositionProfile,
2354) -> ApiOpenApiRouter {
2355 linked_http_modules_for_profile(profile)
2356 .into_iter()
2357 .filter_map(|module| module.linked_http)
2358 .fold(base, |router, contribution| (contribution.merge)(router))
2359}
2360
2361pub fn merge_linked_http_for_config(
2362 base: ApiOpenApiRouter,
2363 config: &platform_core::AppConfig,
2364) -> platform_core::AppResult<ApiOpenApiRouter> {
2365 Ok(linked_http_modules_for_config(config)?
2366 .into_iter()
2367 .filter_map(|module| module.linked_http)
2368 .fold(base, |router, contribution| (contribution.merge)(router)))
2369}
2370
2371pub fn merge_linked_http_for_context(
2372 base: ApiOpenApiRouter,
2373 ctx: &AppContext,
2374) -> platform_core::AppResult<ApiOpenApiRouter> {
2375 Ok(linked_http_modules_for_context(ctx)?
2376 .into_iter()
2377 .filter_map(|module| module.linked_http)
2378 .fold(base, |router, contribution| (contribution.merge)(router)))
2379}
2380
2381pub fn merge_linked_http_for_context_with_composition(
2382 base: ApiOpenApiRouter,
2383 ctx: &AppContext,
2384 composition: &HostComposition,
2385) -> platform_core::AppResult<ApiOpenApiRouter> {
2386 Ok(
2387 linked_http_modules_for_context_with_composition(ctx, composition)?
2388 .into_iter()
2389 .filter_map(|module| module.linked_http)
2390 .fold(base, |router, contribution| (contribution.merge)(router)),
2391 )
2392}
2393
2394#[must_use]
2397pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
2398 story_display_descriptors_for_profile(CompositionProfile::default())
2399}
2400
2401#[must_use]
2402pub fn story_display_descriptors_for_profile(
2403 profile: CompositionProfile,
2404) -> Vec<StoryDisplayDescriptor> {
2405 module_manifests_for_profile(profile)
2406 .into_iter()
2407 .flat_map(story_display_descriptors_from_manifest)
2408 .collect()
2409}
2410
2411pub fn story_display_descriptors_for_config(
2412 config: &platform_core::AppConfig,
2413) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2414 Ok(linked_module_entries_for_config(config)?
2415 .into_iter()
2416 .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2417 .collect())
2418}
2419
2420pub fn story_display_descriptors_for_context(
2421 ctx: &AppContext,
2422) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2423 Ok(linked_module_entries_for_context(ctx)?
2424 .into_iter()
2425 .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2426 .collect())
2427}
2428
2429pub fn install_default_story_display_catalog(ctx: &AppContext) -> platform_core::AppResult<()> {
2430 install_default_story_display_catalog_with_composition(ctx, &HostComposition::default())
2431}
2432
2433pub fn install_default_story_display_catalog_with_composition(
2434 ctx: &AppContext,
2435 composition: &HostComposition,
2436) -> platform_core::AppResult<()> {
2437 let profile = CompositionProfile::from_config(&ctx.config)?;
2438 if !linked_module_enabled(ctx, story::module::MODULE_NAME) {
2439 story::backend::install_default_story_display(Vec::new());
2440 return Ok(());
2441 }
2442 let mut descriptors = story_display_descriptors_for_context(ctx)?;
2443 descriptors.extend(
2444 host_linked_modules_for_context(ctx, composition, profile)
2445 .into_iter()
2446 .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)())),
2447 );
2448 story::backend::install_default_story_display(descriptors);
2449 Ok(())
2450}
2451
2452pub fn install_story_display_catalog(metadata: &[AdminModuleMetadata]) {
2453 story::backend::install_story_display(
2454 metadata
2455 .iter()
2456 .flat_map(story_display_descriptors_from_metadata)
2457 .collect(),
2458 );
2459}
2460
2461fn story_display_descriptors_from_metadata(
2462 module: &AdminModuleMetadata,
2463) -> Vec<StoryDisplayDescriptor> {
2464 story_display_descriptors_from_manifest(
2465 ModuleManifest::builder(module.module_name.clone())
2466 .story_display(module.story_display.clone())
2467 .http_routes(module.http_routes.clone())
2468 .build(),
2469 )
2470}
2471
2472fn story_display_descriptors_from_manifest(
2473 manifest: ModuleManifest,
2474) -> Vec<StoryDisplayDescriptor> {
2475 let mut descriptors = manifest.story_display;
2476 let existing_http = descriptors
2477 .iter()
2478 .filter_map(|descriptor| match &descriptor.source {
2479 StoryDisplaySource::HttpRequest { method, path } => {
2480 Some((method.clone(), path.clone()))
2481 }
2482 StoryDisplaySource::ExecutionName { .. } => None,
2483 })
2484 .collect::<Vec<_>>();
2485
2486 descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
2487 let display_name = route.display_name?;
2488 let method = http_method_label(route.method)?;
2489 if existing_http
2490 .iter()
2491 .any(|(existing_method, existing_path)| {
2492 existing_method == method && existing_path == &route.path
2493 })
2494 {
2495 return None;
2496 }
2497 Some(StoryDisplayDescriptor {
2498 source: StoryDisplaySource::HttpRequest {
2499 method: method.to_owned(),
2500 path: route.path,
2501 },
2502 display_name,
2503 story_title: route.story_title,
2504 })
2505 }));
2506 descriptors
2507}
2508
2509fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
2510 Some(match method {
2511 ModuleHttpMethod::Get => "GET",
2512 ModuleHttpMethod::Post => "POST",
2513 ModuleHttpMethod::Put => "PUT",
2514 ModuleHttpMethod::Patch => "PATCH",
2515 ModuleHttpMethod::Delete => "DELETE",
2516 _ => return None,
2517 })
2518}
2519
2520pub fn runtime_config_descriptors(
2525 ctx: &AppContext,
2526) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2527 runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
2528}
2529
2530pub fn runtime_config_descriptors_with_composition(
2531 ctx: &AppContext,
2532 composition: &HostComposition,
2533) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2534 let profile = CompositionProfile::from_config(&ctx.config)?;
2535 let module_enabled_descriptors =
2536 linked_module_entries(profile)
2537 .iter()
2538 .map(|entry| RuntimeConfigDescriptor {
2539 key: module_enabled_config_key(entry.module_name),
2540 scope: RuntimeConfigScope::Shared,
2541 group: Some("modules"),
2542 section: None,
2543 order: 10,
2544 visible_when: None,
2545 generated: None,
2546 value_type: RuntimeConfigType::Bool,
2547 default: serde_json::json!(linked_module_enabled_from_config(
2548 &ctx.config,
2549 entry.module_name
2550 )),
2551 editable: true,
2552 restart_only: true,
2553 description: "Whether this linked module is loaded on service startup.",
2554 });
2555 let host_module_enabled_descriptors = host_linked_modules_not_in_profile(composition, profile)
2556 .map(|entry| RuntimeConfigDescriptor {
2557 key: module_enabled_config_key(entry.module_name),
2558 scope: RuntimeConfigScope::Shared,
2559 group: Some("modules"),
2560 section: None,
2561 order: 10,
2562 visible_when: None,
2563 generated: None,
2564 value_type: RuntimeConfigType::Bool,
2565 default: serde_json::json!(linked_module_enabled_from_config(
2566 &ctx.config,
2567 entry.module_name
2568 )),
2569 editable: true,
2570 restart_only: true,
2571 description: "Whether this host linked module is loaded on service startup.",
2572 });
2573 let remote_module_enabled_descriptors =
2574 ctx.config
2575 .module_sources
2576 .remote
2577 .iter()
2578 .map(|source| RuntimeConfigDescriptor {
2579 key: module_enabled_config_key(&source.name),
2580 scope: RuntimeConfigScope::Shared,
2581 group: Some("modules"),
2582 section: None,
2583 order: 10,
2584 visible_when: None,
2585 generated: None,
2586 value_type: RuntimeConfigType::Bool,
2587 default: serde_json::json!(remote_module_enabled_from_config(
2588 &ctx.config,
2589 &source.name
2590 )),
2591 editable: true,
2592 restart_only: true,
2593 description: "Whether this remote module is loaded on service startup.",
2594 });
2595 let module_descriptors = linked_module_entries(profile)
2596 .iter()
2597 .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2598 .map(|entry| (entry.load)(ctx))
2599 .chain(
2600 host_linked_modules_for_config(&ctx.config, composition, profile)
2601 .into_iter()
2602 .map(|entry| load_host_linked_module(ctx, entry)),
2603 )
2604 .flat_map(|module| module.runtime_config.iter().cloned())
2605 .collect::<Vec<_>>();
2606 Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
2609 .iter()
2610 .cloned()
2611 .chain(module_enabled_descriptors)
2612 .chain(host_module_enabled_descriptors)
2613 .chain(remote_module_enabled_descriptors)
2614 .chain(module_descriptors)
2615 .collect())
2616}
2617
2618pub fn runtime_config_group_descriptors(
2620 ctx: &AppContext,
2621) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2622 runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
2623}
2624
2625pub fn runtime_config_group_descriptors_with_composition(
2626 ctx: &AppContext,
2627 composition: &HostComposition,
2628) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2629 let profile = CompositionProfile::from_config(&ctx.config)?;
2630 let module_groups = linked_module_entries(profile)
2631 .iter()
2632 .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2633 .map(|entry| (entry.load)(ctx))
2634 .chain(
2635 host_linked_modules_for_config(&ctx.config, composition, profile)
2636 .into_iter()
2637 .map(|entry| load_host_linked_module(ctx, entry)),
2638 )
2639 .flat_map(|module| module.runtime_config_groups.iter().cloned())
2640 .collect::<Vec<_>>();
2641
2642 Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
2643 .chain(
2644 platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
2645 .iter()
2646 .cloned(),
2647 )
2648 .chain(module_groups)
2649 .collect())
2650}
2651
2652#[cfg(test)]
2653mod tests {
2654 use super::*;
2655 use async_trait::async_trait;
2656 use auth::models::AuthUserId;
2657 use auth::session_policy::{
2658 AuthHostExtension, AuthSessionPolicy, SessionCreateDecision, SessionCreateInput,
2659 };
2660 use platform_core::{
2661 AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
2662 LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS, RedisConfig,
2663 RemoteModuleSourceConfig, RuntimeConfigProvider, RuntimeConfigRegistry,
2664 RuntimeConfigSnapshot, ServiceConfig, TelemetryConfig, apply_migrations,
2665 };
2666 use platform_module::{
2667 ConsoleArea, LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration,
2668 LifecycleSurface, ModuleManifestLintSeverity, RuntimeFunctionDeclaration, RuntimeSurface,
2669 lint_module_manifest,
2670 };
2671 use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
2672 use platform_testing::{SequentialIdGenerator, TestDatabase};
2673 use serde_json::{Value, json};
2674 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
2675 use std::collections::BTreeMap;
2676 use std::sync::Arc;
2677 use std::time::Duration;
2678
2679 #[derive(Debug)]
2680 struct TestRuntimeConfigProvider {
2681 snapshot: Arc<RuntimeConfigSnapshot>,
2682 }
2683
2684 impl RuntimeConfigProvider for TestRuntimeConfigProvider {
2685 fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
2686 Arc::clone(&self.snapshot)
2687 }
2688 }
2689
2690 #[test]
2691 fn linked_module_entry_names_match_manifests() {
2692 for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
2693 for entry in linked_module_entries(profile) {
2694 assert_eq!(
2695 entry.module_name,
2696 (entry.manifest)().name,
2697 "linked module entry name must match ModuleManifest::name"
2698 );
2699 }
2700 }
2701 }
2702
2703 #[test]
2704 fn core_profile_excludes_demo_linked_modules() {
2705 let names = module_manifests_for_profile(CompositionProfile::Core)
2706 .into_iter()
2707 .map(|manifest| manifest.name)
2708 .collect::<Vec<_>>();
2709
2710 assert_eq!(names, vec!["platform-story"]);
2711 }
2712
2713 #[test]
2714 fn demo_profile_includes_fixture_linked_modules() {
2715 let names = module_manifests_for_profile(CompositionProfile::Demo)
2716 .into_iter()
2717 .map(|manifest| manifest.name)
2718 .collect::<Vec<_>>();
2719
2720 assert_eq!(
2721 names,
2722 vec![
2723 auth::module::MODULE_NAME,
2724 auth_anonymous::module::MODULE_NAME,
2725 auth_oauth::module::MODULE_NAME,
2726 auth_password::module::MODULE_NAME,
2727 auth_phone::module::MODULE_NAME,
2728 auth_github::module::MODULE_NAME,
2729 auth_google::module::MODULE_NAME,
2730 auth_oidc::module::MODULE_NAME,
2731 story::module::MODULE_NAME,
2732 ]
2733 );
2734 }
2735
2736 #[test]
2737 fn http_route_metadata_contributes_story_display_descriptors() {
2738 let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
2739
2740 assert!(descriptors.iter().any(|descriptor| {
2741 matches!(
2742 &descriptor.source,
2743 StoryDisplaySource::HttpRequest { method, path }
2744 if method == "POST" && path == "/v1/auth/dev/sessions"
2745 ) && descriptor.display_name == "Create Development Session"
2746 }));
2747 }
2748
2749 #[test]
2750 fn core_profile_migrations_exclude_demo_module_migrations() {
2751 let names = migrations_for_profile(CompositionProfile::Core)
2752 .into_iter()
2753 .map(|migration| migration.name)
2754 .collect::<Vec<_>>();
2755
2756 assert!(names.iter().any(|name| name.starts_with("platform/")));
2757 assert!(names.iter().any(|name| name.starts_with("runtime/")));
2758 assert!(!names.iter().any(|name| name.starts_with("auth/")));
2759 assert!(!names.iter().any(|name| name.starts_with("auth-oauth/")));
2760 assert!(!names.iter().any(|name| name.starts_with("auth-github/")));
2761 assert!(!names.iter().any(|name| name.starts_with("auth-google/")));
2762 assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2763 assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
2764 }
2765
2766 #[test]
2767 fn demo_profile_migrations_include_fixture_module_migrations() {
2768 let names = migrations_for_profile(CompositionProfile::Demo)
2769 .into_iter()
2770 .map(|migration| migration.name)
2771 .collect::<Vec<_>>();
2772
2773 assert!(
2774 names
2775 .iter()
2776 .any(|name| name == &"auth/0001_create_auth_schema")
2777 );
2778 assert!(
2779 names
2780 .iter()
2781 .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
2782 );
2783 assert!(
2784 names
2785 .iter()
2786 .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2787 );
2788 assert!(
2789 names
2790 .iter()
2791 .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
2792 );
2793 assert!(
2794 names
2795 .iter()
2796 .any(|name| name == &"auth-github/0001_create_auth_github_schema")
2797 );
2798 assert!(
2799 names
2800 .iter()
2801 .any(|name| name == &"auth-google/0001_create_auth_google_schema")
2802 );
2803 assert!(
2804 names
2805 .iter()
2806 .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
2807 );
2808 }
2809
2810 #[test]
2811 fn host_composition_migrations_include_enabled_host_linked_modules() {
2812 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2813 let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2814
2815 let names = migrations_for_config_with_composition(&config, &composition)
2816 .expect("host composition migrations should load")
2817 .into_iter()
2818 .map(|migration| migration.name)
2819 .collect::<Vec<_>>();
2820
2821 assert!(names.iter().any(|name| name == &"billing/0001_init"));
2822 }
2823
2824 #[test]
2825 fn host_composition_can_install_auth_modules() {
2826 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2827 config.module_sources.linked_profile = "core".to_owned();
2828 let composition = HostComposition::new()
2829 .with_linked_module(auth_linked_module())
2830 .with_linked_module(auth_oauth_linked_module())
2831 .with_linked_module(auth_password_linked_module())
2832 .with_linked_module(auth_phone_linked_module())
2833 .with_linked_module(auth_github_linked_module())
2834 .with_linked_module(auth_google_linked_module())
2835 .with_linked_module(auth_oidc_linked_module());
2836
2837 let names = migrations_for_config_with_composition(&config, &composition)
2838 .expect("host composition migrations should load")
2839 .into_iter()
2840 .map(|migration| migration.name)
2841 .collect::<Vec<_>>();
2842
2843 assert!(
2844 names
2845 .iter()
2846 .any(|name| name == &"auth/0001_create_auth_schema")
2847 );
2848 assert!(
2849 names
2850 .iter()
2851 .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
2852 );
2853 assert!(
2854 names
2855 .iter()
2856 .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2857 );
2858 assert!(
2859 names
2860 .iter()
2861 .any(|name| name == &"auth-phone/0001_create_auth_phone_schema")
2862 );
2863 assert!(
2864 names
2865 .iter()
2866 .any(|name| name == &"auth-github/0001_create_auth_github_schema")
2867 );
2868 assert!(
2869 names
2870 .iter()
2871 .any(|name| name == &"auth-google/0001_create_auth_google_schema")
2872 );
2873 assert!(
2874 names
2875 .iter()
2876 .any(|name| name == &"auth-oidc/0001_create_auth_oidc_schema")
2877 );
2878 }
2879
2880 #[tokio::test]
2881 async fn auth_phone_linked_module_declares_routes_runtime_config_and_migrations() {
2882 let linked = auth_phone_linked_module();
2883 let manifest = (linked.manifest)();
2884 let binding = linked
2885 .http_binding
2886 .expect("auth-phone should expose HTTP binding")();
2887 let module =
2888 (linked
2889 .load
2890 .expect("auth-phone should load as linked module"))(&AppContext::new(
2891 test_config_with_database_url("postgres://localhost/lenso_test"),
2892 platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2893 .expect("lazy pool should build"),
2894 Arc::new(LoggingEventPublisher),
2895 ));
2896
2897 assert_eq!(linked.module_name, auth_phone::module::MODULE_NAME);
2898 assert_eq!(manifest.name, auth_phone::module::MODULE_NAME);
2899 assert_eq!(
2900 manifest.dependencies,
2901 vec![
2902 auth::module::MODULE_NAME.to_owned(),
2903 auth_password::module::MODULE_NAME.to_owned(),
2904 ]
2905 );
2906 assert!(
2907 manifest
2908 .http_routes
2909 .iter()
2910 .any(|route| route.path == "/v1/auth/phone/otp/start")
2911 );
2912 assert!(
2913 manifest
2914 .http_routes
2915 .iter()
2916 .any(|route| route.path == "/v1/auth/phone/password/login")
2917 );
2918 assert_eq!(
2919 binding
2920 .http
2921 .expect("auth-phone HTTP contribution")
2922 .public_prefixes,
2923 &["/v1/auth/phone/"]
2924 );
2925 assert!(
2926 linked
2927 .migrations
2928 .iter()
2929 .any(|migration| migration.name == "auth-phone/0001_create_auth_phone_schema")
2930 );
2931 assert!(
2932 module
2933 .runtime_config
2934 .iter()
2935 .any(|descriptor| descriptor.key == "auth-phone.otp_code_length")
2936 );
2937 assert!(
2938 module
2939 .runtime_config_groups
2940 .iter()
2941 .any(|group| group.id == "auth-phone.otp")
2942 );
2943 }
2944
2945 #[tokio::test]
2946 async fn host_composition_runtime_config_includes_host_module_toggle() {
2947 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2948 .expect("lazy pool should build");
2949 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2950 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2951 let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2952
2953 let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
2954 .expect("host composition descriptors should load")
2955 .into_iter()
2956 .map(|descriptor| descriptor.key)
2957 .collect::<Vec<_>>();
2958
2959 assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
2960 }
2961
2962 #[tokio::test]
2963 async fn host_composition_skips_modules_already_in_linked_profile() {
2964 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2965 .expect("lazy pool should build");
2966 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2967 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2968 let composition = HostComposition::new().with_linked_module(auth_linked_module());
2969
2970 let descriptors = runtime_config_descriptors_with_composition(&ctx, &composition)
2971 .expect("host composition descriptors should load");
2972 let auth_toggle_count = descriptors
2973 .iter()
2974 .filter(|descriptor| descriptor.key == "modules.auth.enabled")
2975 .count();
2976
2977 assert_eq!(auth_toggle_count, 1);
2978 RuntimeConfigRegistry::try_new(descriptors).expect("descriptors should be unique");
2979 }
2980
2981 #[tokio::test]
2982 async fn host_composition_modules_include_manifest_only_modules() {
2983 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2984 .expect("lazy pool should build");
2985 let config = test_config_with_database_url("postgres://localhost/lenso_test");
2986 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2987 let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2988
2989 let names = modules_for_config_with_composition(&ctx, &composition)
2990 .expect("host composition modules should load")
2991 .into_iter()
2992 .map(|module| module.manifest.name)
2993 .collect::<Vec<_>>();
2994
2995 assert!(names.iter().any(|name| name == "billing"));
2996 }
2997
2998 #[tokio::test]
2999 async fn host_wiring_collects_auth_session_policy_contributions() {
3000 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3001 .expect("lazy pool should build");
3002 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3003 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3004 let composition = HostComposition::new().with_linked_module(
3005 test_host_linked_module()
3006 .with_contribution(AuthHostExtension::session_policy(test_session_policy)),
3007 );
3008
3009 let wiring = host_wiring_for_context_with_composition(&ctx, &composition)
3010 .expect("host wiring should compose");
3011 let now = ctx.clock.now();
3012 let decision = wiring
3013 .auth_session_policy()
3014 .policy()
3015 .before_session_create(&SessionCreateInput {
3016 user_id: AuthUserId("usr_wiring".to_owned()),
3017 session_id: "sess_wiring".to_owned(),
3018 proposed_device_id: Some("device_wiring".to_owned()),
3019 created_at: now,
3020 expires_at: now,
3021 client: Default::default(),
3022 })
3023 .await
3024 .expect("wired policy should allow session");
3025
3026 assert_eq!(decision.device_id.as_deref(), Some("device_from_wiring"));
3027 }
3028
3029 fn test_session_policy(_ctx: &AppContext) -> Arc<dyn AuthSessionPolicy> {
3030 Arc::new(TestSessionPolicy)
3031 }
3032
3033 #[derive(Debug)]
3034 struct TestSessionPolicy;
3035
3036 #[async_trait]
3037 impl AuthSessionPolicy for TestSessionPolicy {
3038 async fn before_session_create(
3039 &self,
3040 input: &SessionCreateInput,
3041 ) -> platform_core::AppResult<SessionCreateDecision> {
3042 assert_eq!(input.proposed_device_id.as_deref(), Some("device_wiring"));
3043 Ok(SessionCreateDecision {
3044 device_id: Some("device_from_wiring".to_owned()),
3045 })
3046 }
3047 }
3048
3049 #[test]
3050 fn demo_profile_includes_every_core_entry() {
3051 let demo_names = linked_module_entries(CompositionProfile::Demo)
3052 .iter()
3053 .map(|entry| entry.module_name)
3054 .collect::<Vec<_>>();
3055
3056 for core_entry in linked_module_entries(CompositionProfile::Core) {
3057 assert!(
3058 demo_names.contains(&core_entry.module_name),
3059 "demo profile should include core linked module `{}`",
3060 core_entry.module_name
3061 );
3062 }
3063 }
3064
3065 #[test]
3066 fn default_module_manifests_use_demo_profile() {
3067 let names = module_manifests()
3068 .into_iter()
3069 .map(|manifest| manifest.name)
3070 .collect::<Vec<_>>();
3071
3072 assert_eq!(
3073 names,
3074 vec![
3075 auth::module::MODULE_NAME,
3076 auth_anonymous::module::MODULE_NAME,
3077 auth_oauth::module::MODULE_NAME,
3078 auth_password::module::MODULE_NAME,
3079 auth_phone::module::MODULE_NAME,
3080 auth_github::module::MODULE_NAME,
3081 auth_google::module::MODULE_NAME,
3082 auth_oidc::module::MODULE_NAME,
3083 story::module::MODULE_NAME,
3084 ]
3085 );
3086 }
3087
3088 #[test]
3089 fn linked_http_route_owners_are_profile_aware() {
3090 assert_eq!(
3091 linked_http_route_owners_for_profile(CompositionProfile::Core),
3092 vec![LinkedHttpRouteOwner {
3093 module_name: "platform-story".to_owned(),
3094 public_prefixes: &["/admin/runtime/stories"],
3095 }]
3096 );
3097 assert_eq!(
3098 linked_http_route_owners_for_profile(CompositionProfile::Demo),
3099 vec![
3100 LinkedHttpRouteOwner {
3101 module_name: "auth".to_owned(),
3102 public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
3103 },
3104 LinkedHttpRouteOwner {
3105 module_name: "auth-anonymous".to_owned(),
3106 public_prefixes: &["/v1/auth/anonymous/"],
3107 },
3108 LinkedHttpRouteOwner {
3109 module_name: "auth-password".to_owned(),
3110 public_prefixes: &["/v1/auth/password/"],
3111 },
3112 LinkedHttpRouteOwner {
3113 module_name: "auth-phone".to_owned(),
3114 public_prefixes: &["/v1/auth/phone/"],
3115 },
3116 LinkedHttpRouteOwner {
3117 module_name: "auth-github".to_owned(),
3118 public_prefixes: &["/v1/auth/github/"],
3119 },
3120 LinkedHttpRouteOwner {
3121 module_name: "auth-google".to_owned(),
3122 public_prefixes: &["/v1/auth/google/"],
3123 },
3124 LinkedHttpRouteOwner {
3125 module_name: "auth-oidc".to_owned(),
3126 public_prefixes: &["/.well-known/", "/oauth/"],
3127 },
3128 LinkedHttpRouteOwner {
3129 module_name: "platform-story".to_owned(),
3130 public_prefixes: &["/admin/runtime/stories"],
3131 },
3132 ]
3133 );
3134 }
3135
3136 #[tokio::test]
3137 async fn modules_for_config_uses_core_linked_profile() {
3138 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3139 .expect("lazy pool should build");
3140 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3141 config.module_sources.linked_profile = "core".to_owned();
3142 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3143
3144 let names = modules_for_config(&ctx)
3145 .expect("core linked profile should parse")
3146 .into_iter()
3147 .map(|module| module.manifest.name)
3148 .collect::<Vec<_>>();
3149
3150 assert_eq!(names, vec!["platform-story"]);
3151 }
3152
3153 #[tokio::test]
3154 async fn auth_actor_resolver_is_profile_and_composition_aware() {
3155 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3156 .expect("lazy pool should build");
3157 let demo_ctx = AppContext::new(
3158 test_config_with_database_url("postgres://localhost/lenso_test"),
3159 db.clone(),
3160 Arc::new(LoggingEventPublisher),
3161 );
3162 assert!(
3163 auth_actor_resolver_for_context(&demo_ctx)
3164 .expect("demo profile")
3165 .is_some()
3166 );
3167
3168 let mut composition_config =
3169 test_config_with_database_url("postgres://localhost/lenso_test");
3170 composition_config.module_sources.linked_profile = "core".to_owned();
3171 let composition_ctx = AppContext::new(
3172 composition_config,
3173 db.clone(),
3174 Arc::new(LoggingEventPublisher),
3175 );
3176 let composition = HostComposition::new().with_linked_module(auth_linked_module());
3177 assert!(
3178 auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
3179 .expect("auth composition")
3180 .is_some()
3181 );
3182
3183 let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
3184 core_config.module_sources.linked_profile = "core".to_owned();
3185 let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
3186 assert!(
3187 auth_actor_resolver_for_context(&core_ctx)
3188 .expect("core profile")
3189 .is_none()
3190 );
3191 }
3192
3193 #[tokio::test]
3194 async fn auth_actor_resolver_respects_disabled_auth_module() {
3195 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3196 .expect("lazy pool should build");
3197 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3198 config.modules.insert(
3199 auth::module::MODULE_NAME.to_owned(),
3200 ModuleConfig {
3201 enabled: Some(false),
3202 values: BTreeMap::new(),
3203 },
3204 );
3205 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3206
3207 assert!(
3208 auth_actor_resolver_for_context(&ctx)
3209 .expect("demo profile")
3210 .is_none()
3211 );
3212 }
3213
3214 #[tokio::test]
3215 async fn auth_linked_providers_require_auth_module() {
3216 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3217 .expect("lazy pool should build");
3218 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3219 config.modules.insert(
3220 auth::module::MODULE_NAME.to_owned(),
3221 ModuleConfig {
3222 enabled: Some(false),
3223 values: BTreeMap::new(),
3224 },
3225 );
3226 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3227
3228 let names = modules_for_config(&ctx)
3229 .expect("demo profile")
3230 .into_iter()
3231 .map(|module| module.manifest.name)
3232 .collect::<Vec<_>>();
3233
3234 assert!(!names.iter().any(|name| name == "auth-oauth"));
3235 assert!(!names.iter().any(|name| name == "auth-anonymous"));
3236 assert!(!names.iter().any(|name| name == "auth-password"));
3237 assert!(!names.iter().any(|name| name == "auth-phone"));
3238 assert!(!names.iter().any(|name| name == "auth-github"));
3239 assert!(!names.iter().any(|name| name == "auth-google"));
3240 assert!(!names.iter().any(|name| name == "auth-oidc"));
3241 }
3242
3243 #[tokio::test]
3244 async fn auth_github_requires_oauth_substrate() {
3245 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3246 .expect("lazy pool should build");
3247 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3248 config.modules.insert(
3249 auth_oauth::module::MODULE_NAME.to_owned(),
3250 ModuleConfig {
3251 enabled: Some(false),
3252 values: BTreeMap::new(),
3253 },
3254 );
3255 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3256
3257 let names = modules_for_config(&ctx)
3258 .expect("demo profile")
3259 .into_iter()
3260 .map(|module| module.manifest.name)
3261 .collect::<Vec<_>>();
3262
3263 assert!(!names.iter().any(|name| name == "auth-oauth"));
3264 assert!(!names.iter().any(|name| name == "auth-github"));
3265 assert!(!names.iter().any(|name| name == "auth-google"));
3266 assert!(names.iter().any(|name| name == "auth-password"));
3267 assert!(names.iter().any(|name| name == "auth-phone"));
3268 assert!(names.iter().any(|name| name == "auth-oidc"));
3269
3270 let metadata = load_admin_module_metadata(&ctx)
3271 .await
3272 .expect("module metadata should load");
3273 let auth_github = metadata
3274 .iter()
3275 .find(|module| module.module_name == "auth-github")
3276 .expect("dependency-disabled GitHub provider should remain visible in metadata");
3277
3278 assert!(matches!(
3279 &auth_github.load_status,
3280 ModuleLoadStatus::Error { message }
3281 if message == "module dependency disabled: auth-oauth"
3282 ));
3283 }
3284
3285 #[tokio::test]
3286 async fn auth_google_requires_oauth_substrate() {
3287 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3288 .expect("lazy pool should build");
3289 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3290 config.modules.insert(
3291 auth_oauth::module::MODULE_NAME.to_owned(),
3292 ModuleConfig {
3293 enabled: Some(false),
3294 values: BTreeMap::new(),
3295 },
3296 );
3297 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3298
3299 let names = modules_for_config(&ctx)
3300 .expect("demo profile")
3301 .into_iter()
3302 .map(|module| module.manifest.name)
3303 .collect::<Vec<_>>();
3304
3305 assert!(!names.iter().any(|name| name == "auth-oauth"));
3306 assert!(!names.iter().any(|name| name == "auth-github"));
3307 assert!(!names.iter().any(|name| name == "auth-google"));
3308 assert!(names.iter().any(|name| name == "auth-password"));
3309 assert!(names.iter().any(|name| name == "auth-phone"));
3310 assert!(names.iter().any(|name| name == "auth-oidc"));
3311
3312 let metadata = load_admin_module_metadata(&ctx)
3313 .await
3314 .expect("module metadata should load");
3315 let auth_google = metadata
3316 .iter()
3317 .find(|module| module.module_name == "auth-google")
3318 .expect("dependency-disabled Google provider should remain visible in metadata");
3319
3320 assert!(matches!(
3321 &auth_google.load_status,
3322 ModuleLoadStatus::Error { message }
3323 if message == "module dependency disabled: auth-oauth"
3324 ));
3325 }
3326
3327 #[tokio::test]
3328 async fn auth_provider_dependency_status_is_visible_in_metadata() {
3329 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3330 .expect("lazy pool should build");
3331 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3332 config.modules.insert(
3333 auth::module::MODULE_NAME.to_owned(),
3334 ModuleConfig {
3335 enabled: Some(false),
3336 values: BTreeMap::new(),
3337 },
3338 );
3339 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3340
3341 let metadata = load_admin_module_metadata(&ctx)
3342 .await
3343 .expect("module metadata should load");
3344 let auth_oauth = metadata
3345 .iter()
3346 .find(|module| module.module_name == "auth-oauth")
3347 .expect("dependency-disabled substrate should remain visible in metadata");
3348 let auth_anonymous = metadata
3349 .iter()
3350 .find(|module| module.module_name == "auth-anonymous")
3351 .expect("dependency-disabled provider should remain visible in metadata");
3352 let auth_password = metadata
3353 .iter()
3354 .find(|module| module.module_name == "auth-password")
3355 .expect("dependency-disabled provider should remain visible in metadata");
3356 let auth_phone = metadata
3357 .iter()
3358 .find(|module| module.module_name == "auth-phone")
3359 .expect("dependency-disabled provider should remain visible in metadata");
3360 let auth_github = metadata
3361 .iter()
3362 .find(|module| module.module_name == "auth-github")
3363 .expect("dependency-disabled provider should remain visible in metadata");
3364 let auth_google = metadata
3365 .iter()
3366 .find(|module| module.module_name == "auth-google")
3367 .expect("dependency-disabled provider should remain visible in metadata");
3368 let auth_oidc = metadata
3369 .iter()
3370 .find(|module| module.module_name == "auth-oidc")
3371 .expect("dependency-disabled provider should remain visible in metadata");
3372
3373 assert_eq!(
3374 auth_oauth.dependencies,
3375 vec![auth::module::MODULE_NAME.to_owned()]
3376 );
3377 assert_eq!(
3378 auth_anonymous.dependencies,
3379 vec![auth::module::MODULE_NAME.to_owned()]
3380 );
3381 assert_eq!(
3382 auth_password.dependencies,
3383 vec![auth::module::MODULE_NAME.to_owned()]
3384 );
3385 assert_eq!(
3386 auth_phone.dependencies,
3387 vec![
3388 auth::module::MODULE_NAME.to_owned(),
3389 auth_password::module::MODULE_NAME.to_owned(),
3390 ]
3391 );
3392 assert_eq!(
3393 auth_github.dependencies,
3394 vec![
3395 auth::module::MODULE_NAME.to_owned(),
3396 auth_oauth::module::MODULE_NAME.to_owned()
3397 ]
3398 );
3399 assert_eq!(
3400 auth_google.dependencies,
3401 vec![
3402 auth::module::MODULE_NAME.to_owned(),
3403 auth_oauth::module::MODULE_NAME.to_owned()
3404 ]
3405 );
3406 assert_eq!(
3407 auth_oidc.dependencies,
3408 vec![auth::module::MODULE_NAME.to_owned()]
3409 );
3410 assert!(matches!(
3411 &auth_oauth.load_status,
3412 ModuleLoadStatus::Error { message }
3413 if message == "module dependency disabled: auth"
3414 ));
3415 assert!(matches!(
3416 &auth_anonymous.load_status,
3417 ModuleLoadStatus::Error { message }
3418 if message == "module dependency disabled: auth"
3419 ));
3420 assert!(matches!(
3421 &auth_password.load_status,
3422 ModuleLoadStatus::Error { message }
3423 if message == "module dependency disabled: auth"
3424 ));
3425 assert!(matches!(
3426 &auth_phone.load_status,
3427 ModuleLoadStatus::Error { message }
3428 if message == "module dependency disabled: auth"
3429 ));
3430 assert!(matches!(
3431 &auth_github.load_status,
3432 ModuleLoadStatus::Error { message }
3433 if message == "module dependency disabled: auth"
3434 ));
3435 assert!(matches!(
3436 &auth_google.load_status,
3437 ModuleLoadStatus::Error { message }
3438 if message == "module dependency disabled: auth"
3439 ));
3440 assert!(matches!(
3441 &auth_oidc.load_status,
3442 ModuleLoadStatus::Error { message }
3443 if message == "module dependency disabled: auth"
3444 ));
3445 }
3446
3447 #[tokio::test]
3448 async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
3449 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3450 .expect("lazy pool should build");
3451 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3452 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3453 let registry =
3454 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3455 .expect("registry");
3456 let mut stored = BTreeMap::new();
3457 stored.insert(
3458 ("*".to_owned(), "auth-password.token_strategy".to_owned()),
3459 json!("jwt"),
3460 );
3461 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
3462 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3463 snapshot: Arc::new(snapshot),
3464 }));
3465
3466 assert!(
3467 auth_actor_resolver_for_context(&ctx)
3468 .expect("JWT resolver should be skipped until jwt_secret is configured")
3469 .is_some()
3470 );
3471 }
3472
3473 #[tokio::test]
3474 async fn auth_actor_resolver_requires_redis_when_session_cache_is_redis() {
3475 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3476 .expect("lazy pool should build");
3477 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3478 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3479 let registry =
3480 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3481 .expect("registry");
3482 let mut stored = BTreeMap::new();
3483 stored.insert(
3484 ("*".to_owned(), "auth.session_cache".to_owned()),
3485 json!("redis"),
3486 );
3487 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
3488 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3489 snapshot: Arc::new(snapshot),
3490 }));
3491
3492 let error =
3493 auth_actor_resolver_for_context(&ctx).expect_err("redis cache should require Redis");
3494
3495 assert_eq!(error.code, ErrorCode::Validation);
3496 }
3497
3498 #[tokio::test]
3499 async fn modules_for_config_skips_disabled_linked_modules() {
3500 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3501 .expect("lazy pool should build");
3502 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3503 config.modules.insert(
3504 "auth-password".to_owned(),
3505 ModuleConfig {
3506 enabled: Some(false),
3507 values: BTreeMap::new(),
3508 },
3509 );
3510 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3511
3512 let names = modules_for_config(&ctx)
3513 .expect("demo linked profile should parse")
3514 .into_iter()
3515 .map(|module| module.manifest.name)
3516 .collect::<Vec<_>>();
3517
3518 assert_eq!(
3519 names,
3520 vec![
3521 "auth",
3522 "auth-anonymous",
3523 "auth-oauth",
3524 "auth-github",
3525 "auth-google",
3526 "auth-oidc",
3527 "platform-story"
3528 ]
3529 );
3530 }
3531
3532 #[tokio::test]
3533 async fn modules_for_config_uses_runtime_config_enabled_flag() {
3534 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3535 .expect("lazy pool should build");
3536 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3537 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3538 let registry =
3539 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3540 .expect("registry");
3541 let mut stored = BTreeMap::new();
3542 stored.insert(
3543 ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
3544 json!(false),
3545 );
3546 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
3547 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3548 snapshot: Arc::new(snapshot),
3549 }));
3550
3551 let names = modules_for_config(&ctx)
3552 .expect("demo linked profile should parse")
3553 .into_iter()
3554 .map(|module| module.manifest.name)
3555 .collect::<Vec<_>>();
3556
3557 assert_eq!(
3558 names,
3559 vec![
3560 "auth",
3561 "auth-anonymous",
3562 "auth-oauth",
3563 "auth-github",
3564 "auth-google",
3565 "auth-oidc",
3566 "platform-story"
3567 ]
3568 );
3569 let linked_http_names = linked_http_modules_for_context(&ctx)
3570 .expect("linked HTTP modules should load")
3571 .into_iter()
3572 .map(|module| module.manifest.name)
3573 .collect::<Vec<_>>();
3574
3575 assert_eq!(
3576 linked_http_names,
3577 vec![
3578 "auth",
3579 "auth-anonymous",
3580 "auth-github",
3581 "auth-google",
3582 "auth-oidc",
3583 "platform-story"
3584 ]
3585 );
3586 }
3587
3588 #[tokio::test]
3589 async fn story_module_runtime_config_disables_backend_metadata() {
3590 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3591 .expect("lazy pool should build");
3592 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3593 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3594 let registry =
3595 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3596 .expect("registry");
3597 let mut stored = BTreeMap::new();
3598 stored.insert(
3599 ("*".to_owned(), "modules.platform-story.enabled".to_owned()),
3600 json!(false),
3601 );
3602 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
3603 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3604 snapshot: Arc::new(snapshot),
3605 }));
3606
3607 let linked_http_names = linked_http_modules_for_context(&ctx)
3608 .expect("linked HTTP modules should load")
3609 .into_iter()
3610 .map(|module| module.manifest.name)
3611 .collect::<Vec<_>>();
3612 assert_eq!(
3613 linked_http_names,
3614 vec![
3615 auth::module::MODULE_NAME,
3616 auth_anonymous::module::MODULE_NAME,
3617 auth_password::module::MODULE_NAME,
3618 auth_phone::module::MODULE_NAME,
3619 auth_github::module::MODULE_NAME,
3620 auth_google::module::MODULE_NAME,
3621 auth_oidc::module::MODULE_NAME,
3622 ]
3623 );
3624
3625 let metadata = load_admin_module_metadata(&ctx)
3626 .await
3627 .expect("module metadata should load");
3628 let story = metadata
3629 .iter()
3630 .find(|module| module.module_name == "platform-story")
3631 .expect("disabled story module should remain visible in metadata");
3632
3633 assert!(matches!(
3634 &story.load_status,
3635 ModuleLoadStatus::Error { message }
3636 if message == "module disabled by configuration"
3637 ));
3638 assert_eq!(story.console.len(), 1);
3639 assert_eq!(story.http_routes.len(), story::module::http_routes().len());
3640 }
3641
3642 #[tokio::test]
3643 async fn runtime_config_descriptors_include_module_enabled_flags() {
3644 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3645 .expect("lazy pool should build");
3646 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3647 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3648
3649 let keys = runtime_config_descriptors(&ctx)
3650 .expect("descriptors should load")
3651 .into_iter()
3652 .map(|descriptor| {
3653 (
3654 descriptor.key,
3655 descriptor.group,
3656 descriptor.restart_only,
3657 descriptor.default,
3658 )
3659 })
3660 .collect::<Vec<_>>();
3661
3662 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3663 key == "modules.auth.enabled"
3664 && *group == Some("modules")
3665 && *restart_only
3666 && default == &json!(true)
3667 }));
3668 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3669 key == "modules.auth-anonymous.enabled"
3670 && *group == Some("modules")
3671 && *restart_only
3672 && default == &json!(true)
3673 }));
3674 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3675 key == "modules.auth-password.enabled"
3676 && *group == Some("modules")
3677 && *restart_only
3678 && default == &json!(true)
3679 }));
3680 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3681 key == "modules.auth-phone.enabled"
3682 && *group == Some("modules")
3683 && *restart_only
3684 && default == &json!(true)
3685 }));
3686 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3687 key == "modules.auth-oauth.enabled"
3688 && *group == Some("modules")
3689 && *restart_only
3690 && default == &json!(true)
3691 }));
3692 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3693 key == "modules.auth-github.enabled"
3694 && *group == Some("modules")
3695 && *restart_only
3696 && default == &json!(true)
3697 }));
3698 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3699 key == "modules.auth-google.enabled"
3700 && *group == Some("modules")
3701 && *restart_only
3702 && default == &json!(true)
3703 }));
3704 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3705 key == "modules.auth-oidc.enabled"
3706 && *group == Some("modules")
3707 && *restart_only
3708 && default == &json!(true)
3709 }));
3710 assert!(keys.iter().any(|(key, group, restart_only, default)| {
3711 key == "modules.platform-story.enabled"
3712 && *group == Some("modules")
3713 && *restart_only
3714 && default == &json!(true)
3715 }));
3716 }
3717
3718 #[tokio::test]
3719 async fn runtime_config_groups_include_module_owned_groups() {
3720 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3721 .expect("lazy pool should build");
3722 let config = test_config_with_database_url("postgres://localhost/lenso_test");
3723 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3724
3725 let groups = runtime_config_group_descriptors(&ctx)
3726 .expect("groups should load")
3727 .into_iter()
3728 .map(|group| (group.id, group.label))
3729 .collect::<Vec<_>>();
3730
3731 assert!(groups.contains(&("modules", "Modules")));
3732 assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
3733 assert!(groups.contains(&("auth-password.tokens", "Tokens")));
3734 assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
3735 assert!(groups.contains(&("auth-phone.otp", "Phone OTP")));
3736 assert!(!groups.iter().any(|(id, _)| *id == "auth-phone.password"));
3737 }
3738
3739 #[tokio::test]
3740 async fn runtime_config_descriptors_include_remote_module_enabled_flags() {
3741 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3742 .expect("lazy pool should build");
3743 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3744 config.module_sources.remote.push(RemoteModuleSourceConfig {
3745 name: "remote-crm".to_owned(),
3746 base_url: "http://127.0.0.1:65535".to_owned(),
3747 auth_token_env: None,
3748 timeout_ms: 1,
3749 });
3750 config.modules.insert(
3751 "remote-crm".to_owned(),
3752 ModuleConfig {
3753 enabled: Some(false),
3754 values: BTreeMap::new(),
3755 },
3756 );
3757 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3758
3759 let keys = runtime_config_descriptors(&ctx)
3760 .expect("descriptors should load")
3761 .into_iter()
3762 .map(|descriptor| (descriptor.key, descriptor.restart_only, descriptor.default))
3763 .collect::<Vec<_>>();
3764
3765 assert!(keys.iter().any(|(key, restart_only, default)| {
3766 key == "modules.remote-crm.enabled" && *restart_only && default == &json!(false)
3767 }));
3768 }
3769
3770 #[tokio::test]
3771 async fn load_modules_skips_runtime_disabled_remote_modules() {
3772 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3773 .expect("lazy pool should build");
3774 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3775 config.module_sources.remote.push(RemoteModuleSourceConfig {
3776 name: "remote-crm".to_owned(),
3777 base_url: "http://127.0.0.1:65535".to_owned(),
3778 auth_token_env: None,
3779 timeout_ms: 1,
3780 });
3781 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3782 let registry =
3783 RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
3784 .expect("registry");
3785 let mut stored = BTreeMap::new();
3786 stored.insert(
3787 ("*".to_owned(), "modules.remote-crm.enabled".to_owned()),
3788 json!(false),
3789 );
3790 let snapshot = RuntimeConfigSnapshot::resolve(®istry, "api", &stored);
3791 let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
3792 snapshot: Arc::new(snapshot),
3793 }));
3794
3795 let names = load_modules(&ctx)
3796 .await
3797 .expect("disabled remote should not be loaded")
3798 .into_iter()
3799 .map(|module| module.manifest.name)
3800 .collect::<Vec<_>>();
3801
3802 assert!(!names.iter().any(|name| name == "remote-crm"));
3803 }
3804
3805 #[tokio::test]
3806 async fn module_metadata_reports_disabled_remote_modules() {
3807 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3808 .expect("lazy pool should build");
3809 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3810 config.module_sources.remote.push(RemoteModuleSourceConfig {
3811 name: "remote-grpc-crm".to_owned(),
3812 base_url: "grpc://127.0.0.1:65535".to_owned(),
3813 auth_token_env: None,
3814 timeout_ms: 1,
3815 });
3816 config.modules.insert(
3817 "remote-grpc-crm".to_owned(),
3818 ModuleConfig {
3819 enabled: Some(false),
3820 values: BTreeMap::new(),
3821 },
3822 );
3823 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3824
3825 let metadata = load_admin_module_metadata(&ctx)
3826 .await
3827 .expect("module metadata should load");
3828 let remote = metadata
3829 .iter()
3830 .find(|module| module.module_name == "remote-grpc-crm")
3831 .expect("disabled remote module should remain visible in metadata");
3832
3833 assert_eq!(remote.source, ModuleSource::Remote);
3834 assert!(matches!(
3835 &remote.load_status,
3836 ModuleLoadStatus::Error { message }
3837 if message == "module disabled by configuration"
3838 ));
3839 assert!(matches!(
3840 &remote.source_diagnostics,
3841 Some(AdminModuleSourceDiagnostics::Remote(diagnostics))
3842 if diagnostics.transport == "grpc"
3843 && diagnostics.base_url == "http://127.0.0.1:65535"
3844 ));
3845 }
3846
3847 #[test]
3848 fn migrations_for_config_skip_disabled_linked_module_migrations() {
3849 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3850 config.modules.insert(
3851 "auth-password".to_owned(),
3852 ModuleConfig {
3853 enabled: Some(false),
3854 values: BTreeMap::new(),
3855 },
3856 );
3857
3858 let names = migrations_for_config(&config)
3859 .expect("demo linked profile should parse")
3860 .into_iter()
3861 .map(|migration| migration.name)
3862 .collect::<Vec<_>>();
3863
3864 assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
3865 assert!(!names.iter().any(|name| name.starts_with("auth-phone/")));
3866 assert!(
3867 names
3868 .iter()
3869 .any(|name| name == &"auth/0001_create_auth_schema")
3870 );
3871 assert!(
3872 names
3873 .iter()
3874 .any(|name| name == &"auth-oauth/0001_create_auth_oauth_schema")
3875 );
3876 assert!(
3877 names
3878 .iter()
3879 .any(|name| name == &"auth-github/0001_create_auth_github_schema")
3880 );
3881 assert!(
3882 names
3883 .iter()
3884 .any(|name| name == &"auth-google/0001_create_auth_google_schema")
3885 );
3886 }
3887
3888 #[test]
3889 fn linked_http_modules_for_config_skip_disabled_linked_routes() {
3890 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3891 config.modules.insert(
3892 "auth-password".to_owned(),
3893 ModuleConfig {
3894 enabled: Some(false),
3895 values: BTreeMap::new(),
3896 },
3897 );
3898
3899 let names = linked_http_modules_for_config(&config)
3900 .expect("demo linked profile should parse")
3901 .into_iter()
3902 .map(|module| module.manifest.name)
3903 .collect::<Vec<_>>();
3904
3905 assert_eq!(
3906 names,
3907 vec![
3908 "auth",
3909 "auth-anonymous",
3910 "auth-github",
3911 "auth-google",
3912 "auth-oidc",
3913 "platform-story"
3914 ]
3915 );
3916 }
3917
3918 #[test]
3919 fn linked_http_modules_for_config_skip_disabled_story_routes() {
3920 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3921 config.modules.insert(
3922 "platform-story".to_owned(),
3923 ModuleConfig {
3924 enabled: Some(false),
3925 values: BTreeMap::new(),
3926 },
3927 );
3928
3929 let names = linked_http_modules_for_config(&config)
3930 .expect("demo linked profile should parse")
3931 .into_iter()
3932 .map(|module| module.manifest.name)
3933 .collect::<Vec<_>>();
3934
3935 assert_eq!(
3936 names,
3937 vec![
3938 auth::module::MODULE_NAME,
3939 auth_anonymous::module::MODULE_NAME,
3940 auth_password::module::MODULE_NAME,
3941 auth_phone::module::MODULE_NAME,
3942 auth_github::module::MODULE_NAME,
3943 auth_google::module::MODULE_NAME,
3944 auth_oidc::module::MODULE_NAME,
3945 ]
3946 );
3947 }
3948
3949 #[tokio::test]
3950 async fn disabled_story_module_omits_default_story_display_catalog() {
3951 story::backend::reset_catalogs_for_test();
3952 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3953 config.modules.insert(
3954 "platform-story".to_owned(),
3955 ModuleConfig {
3956 enabled: Some(false),
3957 values: BTreeMap::new(),
3958 },
3959 );
3960 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3961 .expect("lazy pool should build");
3962 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3963
3964 install_default_story_display_catalog(&ctx)
3965 .expect("story display catalog installation should succeed");
3966
3967 assert!(story::backend::story_display_catalog_snapshot().is_empty());
3968 }
3969
3970 #[tokio::test]
3971 async fn module_metadata_reports_disabled_linked_modules() {
3972 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3973 .expect("lazy pool should build");
3974 let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3975 config.modules.insert(
3976 "auth-password".to_owned(),
3977 ModuleConfig {
3978 enabled: Some(false),
3979 values: BTreeMap::new(),
3980 },
3981 );
3982 let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3983
3984 let metadata = load_admin_module_metadata(&ctx)
3985 .await
3986 .expect("module metadata should load");
3987 let auth_password = metadata
3988 .iter()
3989 .find(|module| module.module_name == "auth-password")
3990 .expect("disabled module should remain visible in metadata");
3991
3992 assert!(matches!(
3993 &auth_password.load_status,
3994 ModuleLoadStatus::Error { message }
3995 if message == "module disabled by configuration"
3996 ));
3997 }
3998
3999 #[test]
4000 fn composition_profile_rejects_unknown_values() {
4001 let error = CompositionProfile::parse("fixture")
4002 .expect_err("fixture is not a supported linked module profile");
4003
4004 assert_eq!(error.code, ErrorCode::Validation);
4005 assert!(
4006 error
4007 .details
4008 .iter()
4009 .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
4010 );
4011 }
4012
4013 #[test]
4014 fn linked_http_route_owners_are_projected_from_modules() {
4015 assert_eq!(
4016 linked_http_route_owners(),
4017 vec![
4018 LinkedHttpRouteOwner {
4019 module_name: "auth".to_owned(),
4020 public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
4021 },
4022 LinkedHttpRouteOwner {
4023 module_name: "auth-anonymous".to_owned(),
4024 public_prefixes: &["/v1/auth/anonymous/"],
4025 },
4026 LinkedHttpRouteOwner {
4027 module_name: "auth-password".to_owned(),
4028 public_prefixes: &["/v1/auth/password/"],
4029 },
4030 LinkedHttpRouteOwner {
4031 module_name: "auth-phone".to_owned(),
4032 public_prefixes: &["/v1/auth/phone/"],
4033 },
4034 LinkedHttpRouteOwner {
4035 module_name: "auth-github".to_owned(),
4036 public_prefixes: &["/v1/auth/github/"],
4037 },
4038 LinkedHttpRouteOwner {
4039 module_name: "auth-google".to_owned(),
4040 public_prefixes: &["/v1/auth/google/"],
4041 },
4042 LinkedHttpRouteOwner {
4043 module_name: "auth-oidc".to_owned(),
4044 public_prefixes: &["/.well-known/", "/oauth/"],
4045 },
4046 LinkedHttpRouteOwner {
4047 module_name: "platform-story".to_owned(),
4048 public_prefixes: &["/admin/runtime/stories"],
4049 },
4050 ]
4051 );
4052 }
4053
4054 #[test]
4055 fn linked_http_bindings_are_declared_in_manifests() {
4056 for module in linked_http_modules() {
4057 let http = module
4058 .linked_http
4059 .expect("linked HTTP module should carry HTTP contribution");
4060 assert!(
4061 !module.manifest.http_routes.is_empty(),
4062 "linked HTTP module `{}` must declare ModuleManifest::http_routes",
4063 module.manifest.name
4064 );
4065 for route in &module.manifest.http_routes {
4066 assert!(
4067 http.public_prefixes
4068 .iter()
4069 .any(|prefix| route.path.starts_with(prefix)),
4070 "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
4071 module.manifest.name,
4072 route.path
4073 );
4074 }
4075 }
4076 }
4077
4078 #[test]
4079 fn linked_http_modules_are_registered_modules() {
4080 let manifests = module_manifests();
4081
4082 for module in linked_http_modules() {
4083 let registered_manifest = manifests
4084 .iter()
4085 .find(|manifest| manifest.name == module.manifest.name)
4086 .unwrap_or_else(|| {
4087 panic!(
4088 "linked HTTP module `{}` is missing from module_manifests",
4089 module.manifest.name
4090 )
4091 });
4092 assert_eq!(
4093 registered_manifest, &module.manifest,
4094 "linked HTTP module `{}` must use the registered ModuleManifest",
4095 module.manifest.name
4096 );
4097 }
4098 }
4099
4100 #[test]
4101 fn linked_http_routes_include_story_module_routes() {
4102 let document = merge_linked_http(platform_http::OpenApiRouter::new()).to_openapi();
4103 let value = serde_json::to_value(document).expect("OpenAPI document should serialize");
4104 let paths = value["paths"].as_object().expect("OpenAPI paths object");
4105
4106 assert!(paths.contains_key("/admin/runtime/stories"));
4107 assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}"));
4108 assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/heatmap"));
4109 assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/technical-operations"));
4110 }
4111
4112 #[test]
4113 fn platform_story_manifest_declares_story_console_surface() {
4114 let manifest = module_manifests()
4115 .into_iter()
4116 .find(|manifest| manifest.name == "platform-story")
4117 .expect("platform-story manifest should be registered");
4118 let console_surface_contract: Value = serde_json::from_str(include_str!(
4119 "../../../modules/story/console/console-surface.json"
4120 ))
4121 .expect("story console surface contract should be valid json");
4122
4123 assert_eq!(manifest.admin, None);
4124 assert_eq!(manifest.console.len(), 1);
4125 let surface = &manifest.console[0];
4126 let surface_json =
4127 serde_json::to_value(surface).expect("platform-story console surface should serialize");
4128
4129 assert_eq!(
4130 manifest.capabilities,
4131 required_capabilities_from_contract(&console_surface_contract)
4132 );
4133 assert_eq!(manifest.name, console_surface_contract["id"]);
4134 assert_eq!(surface.name, console_surface_contract["surfaceName"]);
4135 assert_eq!(surface.label, console_surface_contract["label"]);
4136 assert_eq!(surface.area, ConsoleArea::Runtime);
4137 assert_eq!(surface_json["area"], console_surface_contract["area"]);
4138 assert_eq!(surface.route, console_surface_contract["route"]);
4139 assert_eq!(
4140 surface.package.name,
4141 console_surface_contract["packageName"]
4142 );
4143 assert_eq!(
4144 surface.package.export,
4145 console_surface_contract["exportName"]
4146 );
4147 assert_eq!(surface_json["icon"], console_surface_contract["icon"]);
4148 assert_eq!(surface.navigation, None);
4149 assert!(console_surface_contract.get("navigation").is_none());
4150 assert_eq!(
4151 surface.required_capabilities,
4152 required_capabilities_from_contract(&console_surface_contract)
4153 );
4154
4155 let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
4156 assert!(
4157 lints
4158 .iter()
4159 .all(|lint| lint.severity == ModuleManifestLintSeverity::Ok),
4160 "platform-story manifest should not have warning/error lints: {lints:?}"
4161 );
4162 }
4163
4164 fn required_capabilities_from_contract(contract: &Value) -> Vec<String> {
4165 contract["requiredCapabilities"]
4166 .as_array()
4167 .expect("requiredCapabilities should be an array")
4168 .iter()
4169 .map(|capability| {
4170 capability
4171 .as_str()
4172 .expect("requiredCapabilities should contain strings")
4173 .to_owned()
4174 })
4175 .collect()
4176 }
4177
4178 #[tokio::test]
4179 async fn lifecycle_activation_enqueue_creates_function_run() {
4180 let Some(db) = TestDatabase::create().await else {
4181 return;
4182 };
4183 apply_runtime_stack_migrations(&db).await;
4184
4185 let mut ctx = AppContext::new(
4186 test_config(&db),
4187 db.pool.clone(),
4188 Arc::new(LoggingEventPublisher),
4189 );
4190 ctx.ids = Arc::new(SequentialIdGenerator::default());
4191 let modules = vec![
4192 test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
4193 .into(),
4194 ];
4195 let registry = registry_with_lifecycle_function(7);
4196
4197 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
4198 .await
4199 .expect("lifecycle activation job should enqueue");
4200
4201 assert_eq!(run_ids.len(), 1);
4202 let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
4203 r#"
4204 select function_name, input_json, max_attempts, correlation_id, actor
4205 from runtime.function_runs
4206 where id = $1
4207 "#,
4208 )
4209 .bind(&run_ids[0])
4210 .fetch_one(&db.pool)
4211 .await
4212 .expect("function run should exist");
4213
4214 assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
4215 assert_eq!(row.1["warm"], "cache");
4216 assert_eq!(
4217 row.1["_lenso_runtime"]["correlation_id"],
4218 "corr_lifecycle_1"
4219 );
4220 assert_eq!(
4221 row.1["_lenso_runtime"]["causation_id"],
4222 "module_lifecycle:test-module:warm cache"
4223 );
4224 assert_eq!(row.2, 7);
4225 assert_eq!(row.3, "corr_lifecycle_1");
4226 assert_eq!(row.4["kind"], "service");
4227 assert_eq!(row.4["service_id"], "worker");
4228 assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
4229
4230 db.cleanup().await;
4231 }
4232
4233 #[test]
4234 fn lifecycle_activation_validation_rejects_required_missing_function() {
4235 let modules =
4236 vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
4237 let registry = FunctionRegistry::default();
4238
4239 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
4240 .expect_err("required missing activation function should fail validation");
4241
4242 assert_eq!(error.code, ErrorCode::Validation);
4243 assert_eq!(
4244 error.details[0].field.as_deref(),
4245 Some("module.test-module.lifecycle.activation_jobs.warm cache")
4246 );
4247 assert!(
4248 error.details[0].reason.contains("missing function"),
4249 "validation detail should name the missing registry function"
4250 );
4251 }
4252
4253 #[test]
4254 fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
4255 let modules = vec![test_lifecycle_module_with_lifecycle(
4256 LifecycleSurface {
4257 startup_checks: vec![LifecycleStartupCheckDeclaration {
4258 name: "function registered".to_owned(),
4259 required: true,
4260 check: LifecycleStartupCheckKind::FunctionRegistered {
4261 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
4262 },
4263 }],
4264 activation_jobs: Vec::new(),
4265 },
4266 true,
4267 Vec::new(),
4268 )];
4269 let registry = FunctionRegistry::default();
4270
4271 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
4272 .expect_err("required startup check should fail when function is missing");
4273
4274 assert_eq!(error.code, ErrorCode::Validation);
4275 assert_eq!(
4276 error.details[0].field.as_deref(),
4277 Some("module.test-module.lifecycle.startup_checks.function registered")
4278 );
4279 assert!(
4280 error.details[0].reason.contains("missing function"),
4281 "validation detail should name the missing registry function"
4282 );
4283 }
4284
4285 #[test]
4286 fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
4287 let modules = vec![test_lifecycle_module_with_lifecycle(
4288 LifecycleSurface {
4289 startup_checks: vec![LifecycleStartupCheckDeclaration {
4290 name: "function registered".to_owned(),
4291 required: true,
4292 check: LifecycleStartupCheckKind::FunctionRegistered {
4293 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
4294 },
4295 }],
4296 activation_jobs: Vec::new(),
4297 },
4298 false,
4299 Vec::new(),
4300 )];
4301 let registry = registry_with_lifecycle_function(3);
4302
4303 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
4304 .expect_err("required startup check should fail when manifest does not declare it");
4305
4306 assert_eq!(error.code, ErrorCode::Validation);
4307 assert_eq!(
4308 error.details[0].field.as_deref(),
4309 Some("module.test-module.lifecycle.startup_checks.function registered")
4310 );
4311 assert!(
4312 error.details[0].reason.contains("not declared"),
4313 "validation detail should name the missing module runtime declaration"
4314 );
4315 }
4316
4317 #[test]
4318 fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
4319 let modules = vec![test_lifecycle_module_with_lifecycle(
4320 LifecycleSurface {
4321 startup_checks: vec![LifecycleStartupCheckDeclaration {
4322 name: "capability declared".to_owned(),
4323 required: true,
4324 check: LifecycleStartupCheckKind::CapabilityDeclared {
4325 capability: "test.cache.warm".to_owned(),
4326 },
4327 }],
4328 activation_jobs: Vec::new(),
4329 },
4330 false,
4331 Vec::new(),
4332 )];
4333 let registry = FunctionRegistry::default();
4334
4335 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
4336 .expect_err("required startup check should fail when capability is missing");
4337
4338 assert_eq!(error.code, ErrorCode::Validation);
4339 assert_eq!(
4340 error.details[0].field.as_deref(),
4341 Some("module.test-module.lifecycle.startup_checks.capability declared")
4342 );
4343 assert!(
4344 error.details[0].reason.contains("missing capability"),
4345 "validation detail should name the missing capability"
4346 );
4347 }
4348
4349 #[test]
4350 fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
4351 let modules = vec![test_lifecycle_module_with_lifecycle(
4352 LifecycleSurface {
4353 startup_checks: vec![
4354 LifecycleStartupCheckDeclaration {
4355 name: "optional function".to_owned(),
4356 required: false,
4357 check: LifecycleStartupCheckKind::FunctionRegistered {
4358 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
4359 },
4360 },
4361 LifecycleStartupCheckDeclaration {
4362 name: "optional capability".to_owned(),
4363 required: false,
4364 check: LifecycleStartupCheckKind::CapabilityDeclared {
4365 capability: "test.cache.warm".to_owned(),
4366 },
4367 },
4368 ],
4369 activation_jobs: Vec::new(),
4370 },
4371 false,
4372 Vec::new(),
4373 )];
4374 let registry = FunctionRegistry::default();
4375
4376 validate_lifecycle_activation_jobs(&modules, ®istry)
4377 .expect("optional startup checks should not fail validation");
4378 }
4379
4380 #[test]
4381 fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
4382 let modules = vec![
4383 test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
4384 .without_runtime_declaration()
4385 .into(),
4386 ];
4387 let registry = registry_with_lifecycle_function(3);
4388
4389 let error = validate_lifecycle_activation_jobs(&modules, ®istry)
4390 .expect_err("required activation job should fail when manifest does not declare it");
4391
4392 assert_eq!(error.code, ErrorCode::Validation);
4393 assert_eq!(
4394 error.details[0].field.as_deref(),
4395 Some("module.test-module.lifecycle.activation_jobs.warm cache")
4396 );
4397 assert!(
4398 error.details[0].reason.contains("not declared"),
4399 "validation detail should name the missing module runtime declaration"
4400 );
4401 }
4402
4403 #[tokio::test]
4404 async fn optional_missing_lifecycle_activation_is_skipped() {
4405 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
4406 .expect("lazy pool should build");
4407 let ctx = AppContext::new(
4408 test_config_with_database_url("postgres://localhost/lenso_test"),
4409 db,
4410 Arc::new(LoggingEventPublisher),
4411 );
4412 let modules =
4413 vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
4414 let registry = FunctionRegistry::default();
4415
4416 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
4417 .await
4418 .expect("optional missing activation function should be skipped");
4419
4420 assert!(run_ids.is_empty());
4421 }
4422
4423 #[tokio::test]
4424 async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
4425 let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
4426 .expect("lazy pool should build");
4427 let ctx = AppContext::new(
4428 test_config_with_database_url("postgres://localhost/lenso_test"),
4429 db,
4430 Arc::new(LoggingEventPublisher),
4431 );
4432 let modules = vec![
4433 test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
4434 .without_runtime_declaration()
4435 .into(),
4436 ];
4437 let registry = registry_with_lifecycle_function(3);
4438
4439 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
4440 .await
4441 .expect("optional undeclared activation function should be skipped");
4442
4443 assert!(run_ids.is_empty());
4444 }
4445
4446 #[tokio::test]
4447 async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
4448 let db = PgPoolOptions::new()
4449 .max_connections(1)
4450 .acquire_timeout(Duration::from_millis(50))
4451 .connect_lazy_with(
4452 PgConnectOptions::new()
4453 .host("127.0.0.1")
4454 .port(1)
4455 .username("postgres")
4456 .database("lenso_test"),
4457 );
4458 let ctx = AppContext::new(
4459 test_config_with_database_url("postgres://localhost:1/lenso_test"),
4460 db,
4461 Arc::new(LoggingEventPublisher),
4462 );
4463 let modules =
4464 vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
4465 let registry = registry_with_lifecycle_function(3);
4466
4467 let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, ®istry)
4468 .await
4469 .expect("optional enqueue failure should be skipped");
4470
4471 assert!(run_ids.is_empty());
4472 }
4473
4474 #[test]
4475 fn lifecycle_activation_max_attempts_conversion_saturates() {
4476 assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
4477 assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
4478 }
4479
4480 const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
4481
4482 #[derive(Debug)]
4483 struct NoopFunctionHandler;
4484
4485 #[async_trait]
4486 impl FunctionHandler for NoopFunctionHandler {
4487 async fn call(
4488 &self,
4489 _ctx: ExecutionContext,
4490 _input: Value,
4491 ) -> platform_core::AppResult<Value> {
4492 Ok(Value::Null)
4493 }
4494 }
4495
4496 fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
4497 LifecycleActivationJobDeclaration {
4498 name: "warm cache".to_owned(),
4499 function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
4500 run_policy: LifecycleActivationRunPolicy::EveryStartup,
4501 input,
4502 required,
4503 }
4504 }
4505
4506 struct TestLifecycleModuleBuilder {
4507 lifecycle: LifecycleSurface,
4508 declare_runtime_function: bool,
4509 capabilities: Vec<String>,
4510 }
4511
4512 impl TestLifecycleModuleBuilder {
4513 fn without_runtime_declaration(mut self) -> Self {
4514 self.declare_runtime_function = false;
4515 self
4516 }
4517 }
4518
4519 impl From<TestLifecycleModuleBuilder> for Module {
4520 fn from(builder: TestLifecycleModuleBuilder) -> Self {
4521 let mut manifest = ModuleManifest::builder("test-module").lifecycle(builder.lifecycle);
4522 if builder.declare_runtime_function {
4523 manifest = manifest.runtime(RuntimeSurface {
4524 functions: vec![RuntimeFunctionDeclaration {
4525 name: LIFECYCLE_FUNCTION_NAME.to_owned(),
4526 version: 1,
4527 queue: "test".to_owned(),
4528 input_schema: None,
4529 retry_policy: None,
4530 operation: None,
4531 }],
4532 schedules: vec![],
4533 });
4534 }
4535 if !builder.capabilities.is_empty() {
4536 manifest = manifest.capabilities(builder.capabilities);
4537 }
4538 Module::linked(manifest.build(), LinkedBinding::builder().build())
4539 }
4540 }
4541
4542 fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
4543 TestLifecycleModuleBuilder {
4544 lifecycle: LifecycleSurface {
4545 startup_checks: Vec::new(),
4546 activation_jobs: vec![job],
4547 },
4548 declare_runtime_function: true,
4549 capabilities: Vec::new(),
4550 }
4551 }
4552
4553 fn test_lifecycle_module_with_lifecycle(
4554 lifecycle: LifecycleSurface,
4555 declare_runtime_function: bool,
4556 capabilities: Vec<String>,
4557 ) -> Module {
4558 TestLifecycleModuleBuilder {
4559 lifecycle,
4560 declare_runtime_function,
4561 capabilities,
4562 }
4563 .into()
4564 }
4565
4566 fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
4567 let mut registry = FunctionRegistry::default();
4568 registry.register(FunctionDefinition {
4569 name: LIFECYCLE_FUNCTION_NAME.to_owned(),
4570 version: 1,
4571 queue: "test".to_owned(),
4572 retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
4573 handler: Arc::new(NoopFunctionHandler),
4574 });
4575 registry
4576 }
4577
4578 #[test]
4579 fn remote_module_service_specs_parse() {
4580 let specs = parse_remote_module_service_specs(&serde_json::json!({
4581 "modules": [
4582 {
4583 "moduleName": "crm",
4584 "services": [
4585 {
4586 "name": "crm-api",
4587 "command": "pnpm dev",
4588 "cwd": "../crm",
4589 "readyUrl": "http://127.0.0.1:4100/lenso/module/v1/manifest",
4590 "readyTimeoutMs": 12000,
4591 "autoStart": true
4592 }
4593 ]
4594 }
4595 ],
4596 "version": 1
4597 }))
4598 .expect("service specs parse");
4599
4600 assert_eq!(specs.len(), 1);
4601 assert_eq!(specs[0].module_name, "crm");
4602 assert_eq!(specs[0].service_name, "crm-api");
4603 assert_eq!(specs[0].ready_timeout_ms, 12000);
4604 }
4605
4606 #[test]
4607 fn remote_module_service_state_path_sanitizes_names() {
4608 let spec = RemoteModuleServiceSpec {
4609 module_name: "CRM Module".to_owned(),
4610 service_name: "API Worker!".to_owned(),
4611 command: "pnpm dev".to_owned(),
4612 cwd: None,
4613 ready_url: "http://127.0.0.1:4100/lenso/module/v1/manifest".to_owned(),
4614 ready_timeout_ms: 12000,
4615 auto_start: true,
4616 };
4617
4618 let path = remote_module_service_state_path(Path::new(".lenso"), &spec, "lock");
4619
4620 assert_eq!(
4621 path,
4622 PathBuf::from(".lenso/remote-crm-module-api-worker.lock")
4623 );
4624 }
4625
4626 #[test]
4627 fn remote_module_service_lock_is_exclusive_and_released() {
4628 let unique = std::time::SystemTime::now()
4629 .duration_since(std::time::UNIX_EPOCH)
4630 .expect("system time should be after Unix epoch")
4631 .as_nanos();
4632 let dir = std::env::temp_dir().join(format!(
4633 "lenso-bootstrap-service-lock-{}-{unique}",
4634 std::process::id()
4635 ));
4636 let lock_file_path = dir.join("service.lock");
4637 let pid_file_path = dir.join("service.pid");
4638
4639 let _ = std::fs::remove_dir_all(&dir);
4640 create_remote_module_service_lock(&lock_file_path)
4641 .expect("first lock claim should create the lock");
4642 let second_claim = create_remote_module_service_lock(&lock_file_path)
4643 .expect_err("second lock claim should fail while the file exists");
4644 assert_eq!(second_claim.kind(), std::io::ErrorKind::AlreadyExists);
4645 std::fs::write(&pid_file_path, "123\n").expect("pid file should write");
4646
4647 release_remote_module_service_state(&lock_file_path, &pid_file_path);
4648
4649 assert!(!lock_file_path.exists());
4650 assert!(!pid_file_path.exists());
4651 let _ = std::fs::remove_dir_all(&dir);
4652 }
4653
4654 const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
4655 name: "billing/0001_init",
4656 sql: "select 1;",
4657 }];
4658
4659 fn test_host_manifest() -> ModuleManifest {
4660 ModuleManifest::builder("billing").build()
4661 }
4662
4663 fn test_host_linked_module() -> HostLinkedModule {
4664 HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
4665 }
4666
4667 fn test_config(db: &TestDatabase) -> AppConfig {
4668 test_config_with_database_url(db.url.clone())
4669 }
4670
4671 fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
4672 AppConfig {
4673 service: ServiceConfig::default(),
4674 database: DatabaseConfig {
4675 url: database_url.into(),
4676 max_connections: 5,
4677 },
4678 redis: RedisConfig::default(),
4679 http: HttpConfig::default(),
4680 telemetry: TelemetryConfig::default(),
4681 auth: AuthConfig::default(),
4682 console: Default::default(),
4683 module_sources: ModuleSourcesConfig::default(),
4684 modules: BTreeMap::new(),
4685 }
4686 }
4687
4688 async fn apply_runtime_stack_migrations(db: &TestDatabase) {
4689 let migrations = PLATFORM_MIGRATIONS
4690 .iter()
4691 .chain(RUNTIME_MIGRATIONS)
4692 .copied()
4693 .collect::<Vec<_>>();
4694 apply_migrations(&db.pool, &migrations)
4695 .await
4696 .expect("platform and runtime migrations should apply");
4697 }
4698}