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