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