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