1use crate::StoryDisplayDescriptor;
5use crate::admin::{
6 AdminDeclarativeComponent, AdminDeclarativeSurface, AdminEmbeddedEntry, AdminEmbeddedRuntime,
7 AdminEmbeddedSurface, AdminPermission, AdminSurface,
8};
9use crate::admin_schema::AdminSchema;
10use crate::console::ConsoleSurface;
11use crate::events::{EventHandlerDeclaration, EventSurface};
12use crate::http::{ModuleHttpMethod, ModuleHttpRoute, lint_module_http_routes};
13use crate::lifecycle::{
14 LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind,
15 LifecycleSurface,
16};
17use crate::module_source::ModuleSource;
18use crate::runtime::{RuntimeFunctionDeclaration, RuntimeSurface};
19use serde::{Deserialize, Serialize};
20use std::collections::HashSet;
21use utoipa::ToSchema;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[non_exhaustive]
29pub struct ModuleManifest {
30 pub name: String,
32
33 #[serde(default)]
35 pub story_display: Vec<StoryDisplayDescriptor>,
36
37 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub admin: Option<AdminSurface>,
42
43 #[serde(default)]
46 pub http_routes: Vec<ModuleHttpRoute>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub runtime: Option<RuntimeSurface>,
52
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub events: Option<EventSurface>,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub lifecycle: Option<LifecycleSurface>,
62
63 #[serde(default)]
65 pub console: Vec<ConsoleSurface>,
66
67 #[serde(default)]
69 pub capabilities: Vec<String>,
70
71 #[serde(default)]
73 pub dependencies: Vec<String>,
74}
75
76impl ModuleManifest {
77 #[must_use]
79 pub fn builder(name: impl Into<String>) -> ModuleManifestBuilder {
80 ModuleManifestBuilder {
81 manifest: ModuleManifest {
82 name: name.into(),
83 story_display: Vec::new(),
84 admin: None,
85 http_routes: Vec::new(),
86 runtime: None,
87 events: None,
88 lifecycle: None,
89 console: Vec::new(),
90 capabilities: Vec::new(),
91 dependencies: Vec::new(),
92 },
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
98#[serde(rename_all = "snake_case")]
99pub enum ModuleManifestLintSeverity {
100 Ok,
101 Warning,
102 Error,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
106pub struct ModuleManifestLint {
107 pub severity: ModuleManifestLintSeverity,
108 pub subject: String,
109 pub message: String,
110 pub suggestion: String,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ModuleCapabilityReference {
115 pub capability: String,
116 pub subject: String,
117}
118
119pub fn lint_module_manifest(
120 source: ModuleSource,
121 manifest: &ModuleManifest,
122) -> Vec<ModuleManifestLint> {
123 lint_module_manifest_parts(
124 source,
125 &manifest.name,
126 manifest.admin.as_ref(),
127 &manifest.http_routes,
128 manifest.runtime.as_ref(),
129 manifest.events.as_ref(),
130 manifest.lifecycle.as_ref(),
131 &manifest.console,
132 &manifest.capabilities,
133 &manifest.dependencies,
134 )
135}
136
137pub fn lint_module_manifest_parts(
138 source: ModuleSource,
139 name: &str,
140 admin: Option<&AdminSurface>,
141 http_routes: &[ModuleHttpRoute],
142 runtime: Option<&RuntimeSurface>,
143 events: Option<&EventSurface>,
144 lifecycle: Option<&LifecycleSurface>,
145 console: &[ConsoleSurface],
146 capabilities: &[String],
147 dependencies: &[String],
148) -> Vec<ModuleManifestLint> {
149 let mut lints = Vec::new();
150
151 if !present(name) {
152 lints.push(ModuleManifestLint {
153 severity: ModuleManifestLintSeverity::Error,
154 subject: "module.name".to_owned(),
155 message: "Missing module manifest name.".to_owned(),
156 suggestion: "Set ModuleManifest.name to the stable module identifier.".to_owned(),
157 });
158 }
159
160 for capability in capabilities {
161 if !valid_capability(capability) {
162 lints.push(ModuleManifestLint {
163 severity: ModuleManifestLintSeverity::Warning,
164 subject: format!("capability {capability}"),
165 message: "Capability name should use dot-separated lowercase identifiers."
166 .to_owned(),
167 suggestion: "Use a stable capability name such as module.entity.read.".to_owned(),
168 });
169 }
170 }
171 for dependency in dependencies {
172 if !present(dependency) {
173 lints.push(ModuleManifestLint {
174 severity: ModuleManifestLintSeverity::Error,
175 subject: "dependency".to_owned(),
176 message: "Module dependency name must not be empty.".to_owned(),
177 suggestion: "Remove the empty dependency or set it to a stable module name."
178 .to_owned(),
179 });
180 } else if dependency == name {
181 lints.push(ModuleManifestLint {
182 severity: ModuleManifestLintSeverity::Error,
183 subject: format!("dependency {dependency}"),
184 message: "Module must not depend on itself.".to_owned(),
185 suggestion: "Remove the self dependency from ModuleManifest.dependencies."
186 .to_owned(),
187 });
188 }
189 }
190
191 for route_lint in lint_module_http_routes(source, http_routes) {
192 lints.push(ModuleManifestLint {
193 severity: match route_lint.severity {
194 crate::http::ModuleRouteLintSeverity::Ok => ModuleManifestLintSeverity::Ok,
195 crate::http::ModuleRouteLintSeverity::Warning => {
196 ModuleManifestLintSeverity::Warning
197 }
198 crate::http::ModuleRouteLintSeverity::Error => ModuleManifestLintSeverity::Error,
199 },
200 subject: route_lint.subject,
201 message: route_lint.message,
202 suggestion: route_lint.suggestion,
203 });
204 }
205 lint_capability_references(
206 admin,
207 http_routes,
208 lifecycle,
209 console,
210 capabilities,
211 &mut lints,
212 );
213
214 if let Some(admin) = admin {
215 lint_admin_surface(admin, &mut lints);
216 }
217 let mut runtime_lints = Vec::new();
218 if let Some(runtime) = runtime {
219 lint_runtime_surface(runtime, &mut runtime_lints);
220 }
221 if let Some(events) = events {
222 lint_event_surface(events, &mut lints);
223 }
224 if let Some(lifecycle) = lifecycle {
225 lint_lifecycle_surface(lifecycle, runtime, capabilities, &mut lints);
226 }
227 lint_console_surfaces(console, &mut lints);
228 lints.extend(runtime_lints);
229
230 if lints.is_empty() {
231 lints.push(ModuleManifestLint {
232 severity: ModuleManifestLintSeverity::Ok,
233 subject: "manifest".to_owned(),
234 message: "Module manifest metadata is complete.".to_owned(),
235 suggestion: "No action needed.".to_owned(),
236 });
237 }
238
239 lints
240}
241
242pub fn module_capability_references(
243 admin: Option<&AdminSurface>,
244 http_routes: &[ModuleHttpRoute],
245 lifecycle: Option<&LifecycleSurface>,
246 console: &[ConsoleSurface],
247) -> Vec<ModuleCapabilityReference> {
248 let mut references = Vec::new();
249
250 for route in http_routes {
251 if let Some(capability) = route.capability.as_deref()
252 && present(capability)
253 {
254 references.push(ModuleCapabilityReference {
255 capability: capability.to_owned(),
256 subject: format!("http_route.{}", route_identity(route)),
257 });
258 }
259 }
260
261 if let Some(admin) = admin {
262 collect_admin_capability_references(admin, &mut references);
263 }
264
265 if let Some(lifecycle) = lifecycle {
266 for check in &lifecycle.startup_checks {
267 if let LifecycleStartupCheckKind::CapabilityDeclared { capability } = &check.check
268 && present(capability)
269 {
270 references.push(ModuleCapabilityReference {
271 capability: capability.to_owned(),
272 subject: format!("lifecycle.startup_check.capability.{capability}"),
273 });
274 }
275 }
276 }
277
278 for surface in console {
279 let subject = if present(&surface.name) {
280 format!("console.surface.{}", surface.name)
281 } else {
282 "console.surface".to_owned()
283 };
284 for capability in &surface.required_capabilities {
285 if present(capability) {
286 references.push(ModuleCapabilityReference {
287 capability: capability.clone(),
288 subject: subject.clone(),
289 });
290 }
291 }
292 }
293
294 references
295}
296
297fn lint_capability_references(
298 admin: Option<&AdminSurface>,
299 http_routes: &[ModuleHttpRoute],
300 lifecycle: Option<&LifecycleSurface>,
301 console: &[ConsoleSurface],
302 capabilities: &[String],
303 lints: &mut Vec<ModuleManifestLint>,
304) {
305 let declared = capabilities
306 .iter()
307 .map(String::as_str)
308 .collect::<HashSet<_>>();
309
310 for reference in module_capability_references(admin, http_routes, lifecycle, console) {
311 if reference.subject.starts_with("lifecycle.") {
314 continue;
315 }
316 if declared.contains(reference.capability.as_str()) {
317 continue;
318 }
319 lints.push(ModuleManifestLint {
320 severity: ModuleManifestLintSeverity::Warning,
321 subject: format!("capability.reference.{}", reference.subject),
322 message: "Capability reference is not declared by the module.".to_owned(),
323 suggestion: format!(
324 "Add `{}` to ModuleManifest.capabilities or update the reference.",
325 reference.capability
326 ),
327 });
328 }
329}
330
331fn collect_admin_capability_references(
332 admin: &AdminSurface,
333 references: &mut Vec<ModuleCapabilityReference>,
334) {
335 match admin {
336 AdminSurface::Schema(schema) => {
337 collect_schema_capability_references("admin.schema", schema, references);
338 }
339 AdminSurface::DeclarativeCustom(surface) => {
340 for action in &surface.actions {
341 if present(&action.capability) {
342 let action_subject = if present(&action.name) {
343 format!("admin.declarative.action.{}", action.name)
344 } else {
345 "admin.declarative.action".to_owned()
346 };
347 references.push(ModuleCapabilityReference {
348 capability: action.capability.clone(),
349 subject: action_subject,
350 });
351 }
352 }
353 if let Some(schema) = &surface.fallback_schema {
354 collect_schema_capability_references(
355 "admin.declarative.fallback_schema",
356 schema,
357 references,
358 );
359 }
360 }
361 AdminSurface::EmbeddedCustom(surface) => {
362 if let Some(schema) = &surface.fallback_schema {
363 collect_schema_capability_references(
364 "admin.embedded.fallback_schema",
365 schema,
366 references,
367 );
368 }
369 }
370 }
371}
372
373fn collect_schema_capability_references(
374 prefix: &str,
375 schema: &AdminSchema,
376 references: &mut Vec<ModuleCapabilityReference>,
377) {
378 for entity in &schema.entities {
379 if present(&entity.read_capability) {
380 references.push(ModuleCapabilityReference {
381 capability: entity.read_capability.clone(),
382 subject: format!("{prefix}.{}", entity.name),
383 });
384 }
385 }
386}
387
388fn lint_runtime_surface(runtime: &RuntimeSurface, lints: &mut Vec<ModuleManifestLint>) {
389 if runtime.functions.is_empty() {
390 lints.push(ModuleManifestLint {
391 severity: ModuleManifestLintSeverity::Warning,
392 subject: "runtime.functions".to_owned(),
393 message: "Runtime surface declares no functions.".to_owned(),
394 suggestion: "Add at least one function declaration or omit the runtime surface."
395 .to_owned(),
396 });
397 return;
398 }
399
400 let mut names = HashSet::new();
401 for function in &runtime.functions {
402 lint_runtime_function(function, &mut names, lints);
403 }
404}
405
406fn lint_runtime_function(
407 function: &RuntimeFunctionDeclaration,
408 names: &mut HashSet<String>,
409 lints: &mut Vec<ModuleManifestLint>,
410) {
411 let subject = if present(&function.name) {
412 format!("runtime.function.{}", function.name)
413 } else {
414 "runtime.function".to_owned()
415 };
416
417 if !present(&function.name) {
418 lints.push(ModuleManifestLint {
419 severity: ModuleManifestLintSeverity::Error,
420 subject: subject.clone(),
421 message: "Runtime function declaration is missing a name.".to_owned(),
422 suggestion: "Set a stable versioned function name such as module.action.v1.".to_owned(),
423 });
424 } else if !valid_runtime_function_name(&function.name) {
425 lints.push(ModuleManifestLint {
426 severity: ModuleManifestLintSeverity::Warning,
427 subject: subject.clone(),
428 message: "Runtime function name should be a stable path-safe identifier.".to_owned(),
429 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
430 });
431 } else if !names.insert(function.name.clone()) {
432 lints.push(ModuleManifestLint {
433 severity: ModuleManifestLintSeverity::Error,
434 subject: subject.clone(),
435 message: "Duplicate runtime function declaration.".to_owned(),
436 suggestion: "Keep one declaration per runtime function name.".to_owned(),
437 });
438 }
439
440 if !present(&function.queue) {
441 lints.push(ModuleManifestLint {
442 severity: ModuleManifestLintSeverity::Warning,
443 subject: subject.clone(),
444 message: "Runtime function declaration is missing a queue.".to_owned(),
445 suggestion: "Set the host queue used to claim this function.".to_owned(),
446 });
447 }
448
449 if let Some(input_schema) = &function.input_schema
450 && input_schema != &function.name
451 {
452 lints.push(ModuleManifestLint {
453 severity: ModuleManifestLintSeverity::Warning,
454 subject: format!("{subject}.input_schema"),
455 message: "Runtime function input schema does not match the function name.".to_owned(),
456 suggestion: "Use the versioned function name as the input_schema contract identifier."
457 .to_owned(),
458 });
459 }
460
461 if let Some(retry_policy) = &function.retry_policy
462 && retry_policy.max_attempts == 0
463 {
464 lints.push(ModuleManifestLint {
465 severity: ModuleManifestLintSeverity::Warning,
466 subject: format!("{subject}.retry_policy"),
467 message: "Runtime function retry policy declares zero attempts.".to_owned(),
468 suggestion: "Set max_attempts to at least 1 or omit the retry policy.".to_owned(),
469 });
470 }
471}
472
473fn lint_event_surface(events: &EventSurface, lints: &mut Vec<ModuleManifestLint>) {
474 if events.handlers.is_empty() {
475 lints.push(ModuleManifestLint {
476 severity: ModuleManifestLintSeverity::Warning,
477 subject: "events.handlers".to_owned(),
478 message: "Event surface declares no handlers.".to_owned(),
479 suggestion: "Add at least one event handler declaration or omit the events surface."
480 .to_owned(),
481 });
482 return;
483 }
484
485 let mut names = HashSet::new();
486 for handler in &events.handlers {
487 lint_event_handler(handler, &mut names, lints);
488 }
489}
490
491fn lint_event_handler(
492 handler: &EventHandlerDeclaration,
493 names: &mut HashSet<String>,
494 lints: &mut Vec<ModuleManifestLint>,
495) {
496 let subject = if present(&handler.name) {
497 format!("events.handler.{}", handler.name)
498 } else {
499 "events.handler".to_owned()
500 };
501
502 if !present(&handler.name) {
503 lints.push(ModuleManifestLint {
504 severity: ModuleManifestLintSeverity::Error,
505 subject: subject.clone(),
506 message: "Event handler declaration is missing a name.".to_owned(),
507 suggestion: "Set a stable handler name such as sync_contact_on_user_registered."
508 .to_owned(),
509 });
510 } else if !valid_runtime_function_name(&handler.name) {
511 lints.push(ModuleManifestLint {
512 severity: ModuleManifestLintSeverity::Warning,
513 subject: subject.clone(),
514 message: "Event handler name should be a stable path-safe identifier.".to_owned(),
515 suggestion: "Use ASCII letters, digits, dot, underscore, or hyphen.".to_owned(),
516 });
517 } else if !names.insert(handler.name.clone()) {
518 lints.push(ModuleManifestLint {
519 severity: ModuleManifestLintSeverity::Error,
520 subject: subject.clone(),
521 message: "Duplicate event handler declaration.".to_owned(),
522 suggestion: "Keep one declaration per event handler name.".to_owned(),
523 });
524 }
525
526 if !present(&handler.event_name) {
527 lints.push(ModuleManifestLint {
528 severity: ModuleManifestLintSeverity::Error,
529 subject: format!("{subject}.event_name"),
530 message: "Event handler declaration is missing an event_name.".to_owned(),
531 suggestion: "Set the stable outbox event name this handler consumes.".to_owned(),
532 });
533 } else if !valid_runtime_function_name(&handler.event_name) {
534 lints.push(ModuleManifestLint {
535 severity: ModuleManifestLintSeverity::Warning,
536 subject: format!("{subject}.event_name"),
537 message: "Event name should be a stable path-safe identifier.".to_owned(),
538 suggestion: "Use the versioned event name such as identity.user_registered.v1."
539 .to_owned(),
540 });
541 }
542}
543
544fn lint_lifecycle_surface(
545 lifecycle: &LifecycleSurface,
546 runtime: Option<&RuntimeSurface>,
547 capabilities: &[String],
548 lints: &mut Vec<ModuleManifestLint>,
549) {
550 if lifecycle.startup_checks.is_empty() && lifecycle.activation_jobs.is_empty() {
551 lints.push(ModuleManifestLint {
552 severity: ModuleManifestLintSeverity::Warning,
553 subject: "lifecycle".to_owned(),
554 message: "Lifecycle surface declares no startup checks or activation jobs.".to_owned(),
555 suggestion: "Add lifecycle entries or omit the lifecycle surface.".to_owned(),
556 });
557 return;
558 }
559
560 let runtime_functions = runtime_function_names(runtime);
561 let capability_names = capabilities.iter().cloned().collect::<HashSet<_>>();
562
563 for check in &lifecycle.startup_checks {
564 lint_lifecycle_startup_check(check, &runtime_functions, &capability_names, lints);
565 }
566
567 for job in &lifecycle.activation_jobs {
568 lint_lifecycle_activation_job(job, &runtime_functions, lints);
569 }
570}
571
572fn lint_lifecycle_startup_check(
573 check: &LifecycleStartupCheckDeclaration,
574 runtime_functions: &HashSet<String>,
575 capabilities: &HashSet<String>,
576 lints: &mut Vec<ModuleManifestLint>,
577) {
578 if !present(&check.name) {
579 lints.push(ModuleManifestLint {
580 severity: ModuleManifestLintSeverity::Warning,
581 subject: "lifecycle.startup_check".to_owned(),
582 message: "Lifecycle startup check is missing a name.".to_owned(),
583 suggestion: "Set a short operator-facing check name.".to_owned(),
584 });
585 }
586
587 match &check.check {
588 LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
589 if !runtime_functions.contains(function_name) {
590 lints.push(ModuleManifestLint {
591 severity: ModuleManifestLintSeverity::Error,
592 subject: format!(
593 "lifecycle.startup_check.function_registered.{function_name}"
594 ),
595 message: "Lifecycle startup check references an unknown runtime function."
596 .to_owned(),
597 suggestion:
598 "Declare the function in ModuleManifest.runtime.functions or remove the check."
599 .to_owned(),
600 });
601 }
602 }
603 LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
604 if !capabilities.contains(capability) {
605 lints.push(ModuleManifestLint {
606 severity: ModuleManifestLintSeverity::Warning,
607 subject: format!("lifecycle.startup_check.capability.{capability}"),
608 message: "Lifecycle startup check references an undeclared capability."
609 .to_owned(),
610 suggestion:
611 "Add the capability to ModuleManifest.capabilities or update the check."
612 .to_owned(),
613 });
614 }
615 }
616 }
617}
618
619fn lint_lifecycle_activation_job(
620 job: &LifecycleActivationJobDeclaration,
621 runtime_functions: &HashSet<String>,
622 lints: &mut Vec<ModuleManifestLint>,
623) {
624 let subject = if present(&job.name) {
625 format!("lifecycle.activation_job.{}", job.name)
626 } else {
627 "lifecycle.activation_job".to_owned()
628 };
629
630 if !present(&job.name) {
631 lints.push(ModuleManifestLint {
632 severity: ModuleManifestLintSeverity::Warning,
633 subject: subject.clone(),
634 message: "Lifecycle activation job is missing a name.".to_owned(),
635 suggestion: "Set a short operator-facing activation job name.".to_owned(),
636 });
637 }
638
639 if !present(&job.function_name) {
640 lints.push(ModuleManifestLint {
641 severity: ModuleManifestLintSeverity::Error,
642 subject,
643 message: "Lifecycle activation job is missing a function name.".to_owned(),
644 suggestion: "Set function_name to a declared runtime function.".to_owned(),
645 });
646 } else if !runtime_functions.contains(&job.function_name) {
647 lints.push(ModuleManifestLint {
648 severity: ModuleManifestLintSeverity::Error,
649 subject,
650 message: "Lifecycle activation job references an unknown runtime function.".to_owned(),
651 suggestion:
652 "Declare the function in ModuleManifest.runtime.functions or remove the activation job."
653 .to_owned(),
654 });
655 }
656}
657
658fn runtime_function_names(runtime: Option<&RuntimeSurface>) -> HashSet<String> {
659 runtime
660 .into_iter()
661 .flat_map(|surface| surface.functions.iter())
662 .map(|function| function.name.clone())
663 .collect()
664}
665
666fn lint_console_surfaces(console: &[ConsoleSurface], lints: &mut Vec<ModuleManifestLint>) {
667 let mut names = HashSet::new();
668 let mut routes = HashSet::new();
669
670 for surface in console {
671 let subject = if present(&surface.name) {
672 format!("console.surface.{}", surface.name)
673 } else {
674 "console.surface".to_owned()
675 };
676
677 if !present(&surface.name) {
678 lints.push(ModuleManifestLint {
679 severity: ModuleManifestLintSeverity::Error,
680 subject: subject.clone(),
681 message: "Console surface is missing a name.".to_owned(),
682 suggestion: "Set a stable surface name such as stories.".to_owned(),
683 });
684 } else if !valid_console_surface_name(&surface.name) {
685 lints.push(ModuleManifestLint {
686 severity: ModuleManifestLintSeverity::Warning,
687 subject: subject.clone(),
688 message: "Console surface name should be a path-safe identifier.".to_owned(),
689 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
690 });
691 } else if !names.insert(surface.name.clone()) {
692 lints.push(ModuleManifestLint {
693 severity: ModuleManifestLintSeverity::Error,
694 subject: subject.clone(),
695 message: "Duplicate console surface declaration.".to_owned(),
696 suggestion: "Keep one console surface per surface name.".to_owned(),
697 });
698 }
699
700 if !present(&surface.label) {
701 lints.push(ModuleManifestLint {
702 severity: ModuleManifestLintSeverity::Warning,
703 subject: format!("{subject}.label"),
704 message: "Console surface is missing an operator-facing label.".to_owned(),
705 suggestion: "Set a short navigation label such as Stories.".to_owned(),
706 });
707 }
708
709 if !surface.route.starts_with('/') || surface.route.contains('*') {
710 lints.push(ModuleManifestLint {
711 severity: ModuleManifestLintSeverity::Error,
712 subject: format!("{subject}.route"),
713 message: "Console surface route must be an absolute static route.".to_owned(),
714 suggestion: "Use a Console route such as /runtime/stories.".to_owned(),
715 });
716 } else if !routes.insert(surface.route.clone()) {
717 lints.push(ModuleManifestLint {
718 severity: ModuleManifestLintSeverity::Error,
719 subject: format!("{subject}.route"),
720 message: "Duplicate console surface route declaration.".to_owned(),
721 suggestion: "Keep one console surface per route.".to_owned(),
722 });
723 }
724
725 if !valid_console_package_name(&surface.package.name) {
726 lints.push(ModuleManifestLint {
727 severity: ModuleManifestLintSeverity::Warning,
728 subject: format!("{subject}.package"),
729 message: "Console surface package should be an npm package name.".to_owned(),
730 suggestion: "Use a build-time package name such as @lenso/story-console."
731 .to_owned(),
732 });
733 }
734
735 if !present(&surface.package.export) {
736 lints.push(ModuleManifestLint {
737 severity: ModuleManifestLintSeverity::Warning,
738 subject: format!("{subject}.package.export"),
739 message: "Console surface package export is missing.".to_owned(),
740 suggestion: "Set the named export registered by the Runtime Console build."
741 .to_owned(),
742 });
743 }
744
745 if let Some(navigation) = &surface.navigation {
746 lint_console_navigation(&subject, navigation, lints);
747 }
748 }
749}
750
751const HOST_SYSTEM_CONSOLE_WORKSPACE_ID: &str = "system";
752
753fn lint_console_navigation(
754 subject: &str,
755 navigation: &crate::ConsoleNavigation,
756 lints: &mut Vec<ModuleManifestLint>,
757) {
758 let workspace_subject = format!("{subject}.navigation.workspace");
759 if !valid_console_navigation_id(&navigation.workspace.id) {
760 lints.push(ModuleManifestLint {
761 severity: ModuleManifestLintSeverity::Warning,
762 subject: format!("{workspace_subject}.id"),
763 message: "Console workspace id should be a path-safe identifier.".to_owned(),
764 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
765 });
766 } else if navigation.workspace.id == HOST_SYSTEM_CONSOLE_WORKSPACE_ID {
767 lints.push(ModuleManifestLint {
768 severity: ModuleManifestLintSeverity::Warning,
769 subject: format!("{workspace_subject}.id"),
770 message: "Console workspace id system is reserved for host-owned surfaces.".to_owned(),
771 suggestion:
772 "Omit navigation to use the host System workspace, or use a module-owned workspace id."
773 .to_owned(),
774 });
775 }
776 if !present(&navigation.workspace.label) {
777 lints.push(ModuleManifestLint {
778 severity: ModuleManifestLintSeverity::Warning,
779 subject: format!("{workspace_subject}.label"),
780 message: "Console workspace is missing an operator-facing label.".to_owned(),
781 suggestion: "Set a short workspace label such as CRM.".to_owned(),
782 });
783 }
784 if let Some(group) = &navigation.group {
785 let group_subject = format!("{subject}.navigation.group");
786 if !valid_console_navigation_id(&group.id) {
787 lints.push(ModuleManifestLint {
788 severity: ModuleManifestLintSeverity::Warning,
789 subject: format!("{group_subject}.id"),
790 message: "Console navigation group id should be a path-safe identifier.".to_owned(),
791 suggestion: "Use ASCII letters, digits, underscore, or hyphen.".to_owned(),
792 });
793 }
794 if !present(&group.label) {
795 lints.push(ModuleManifestLint {
796 severity: ModuleManifestLintSeverity::Warning,
797 subject: format!("{group_subject}.label"),
798 message: "Console navigation group is missing an operator-facing label.".to_owned(),
799 suggestion: "Set a short group label such as Customers.".to_owned(),
800 });
801 }
802 }
803}
804
805fn lint_admin_surface(admin: &AdminSurface, lints: &mut Vec<ModuleManifestLint>) {
806 match admin {
807 AdminSurface::Schema(schema) => lint_schema_entities("admin.schema", schema, lints),
808 AdminSurface::DeclarativeCustom(surface) => {
809 if surface.pages.is_empty() {
810 lints.push(ModuleManifestLint {
811 severity: ModuleManifestLintSeverity::Warning,
812 subject: "admin.declarative.pages".to_owned(),
813 message: "Declarative admin surface declares no pages.".to_owned(),
814 suggestion: "Add at least one page or omit the declarative admin surface."
815 .to_owned(),
816 });
817 }
818 if let Some(schema) = &surface.fallback_schema {
819 lint_schema_entities("admin.declarative.fallback_schema", schema, lints);
820 }
821 let fallback_entities = surface
822 .fallback_schema
823 .as_ref()
824 .map(schema_entity_names)
825 .unwrap_or_default();
826 for page in &surface.pages {
827 for section in &page.sections {
828 match §ion.component {
829 AdminDeclarativeComponent::EntityTable { entity }
830 | AdminDeclarativeComponent::EntityDetail { entity } => {
831 if !fallback_entities.contains(entity) {
832 lints.push(ModuleManifestLint {
833 severity: ModuleManifestLintSeverity::Warning,
834 subject: format!("admin.declarative.section.{}", section.name),
835 message: format!(
836 "Declarative section references unknown fallback entity `{entity}`."
837 ),
838 suggestion:
839 "Declare the entity in fallback_schema or update the section binding."
840 .to_owned(),
841 });
842 }
843 }
844 AdminDeclarativeComponent::MetricStrip { .. } => {}
845 }
846 }
847 }
848 }
849 AdminSurface::EmbeddedCustom(surface) => {
850 if surface.runtime != AdminEmbeddedRuntime::Iframe {
851 lints.push(ModuleManifestLint {
852 severity: ModuleManifestLintSeverity::Warning,
853 subject: "admin.embedded.runtime".to_owned(),
854 message: "Embedded admin runtime is reserved for a future host policy."
855 .to_owned(),
856 suggestion: "Use iframe for the current embedded admin slice.".to_owned(),
857 });
858 }
859 match &surface.entry {
860 AdminEmbeddedEntry::Url {
861 url,
862 allowed_origins,
863 } => {
864 if !url.starts_with("https://") && !url.starts_with("http://localhost") {
865 lints.push(ModuleManifestLint {
866 severity: ModuleManifestLintSeverity::Warning,
867 subject: "admin.embedded.entry.url".to_owned(),
868 message:
869 "Embedded admin URL should use HTTPS outside local development."
870 .to_owned(),
871 suggestion: "Use an HTTPS URL and list its origin in allowed_origins."
872 .to_owned(),
873 });
874 }
875 if allowed_origins.is_empty() {
876 lints.push(ModuleManifestLint {
877 severity: ModuleManifestLintSeverity::Warning,
878 subject: "admin.embedded.entry.allowed_origins".to_owned(),
879 message: "Embedded admin surface declares no allowed origins."
880 .to_owned(),
881 suggestion:
882 "Declare the iframe origin allowlist before enabling the surface."
883 .to_owned(),
884 });
885 }
886 }
887 }
888 if let Some(schema) = &surface.fallback_schema {
889 lint_schema_entities("admin.embedded.fallback_schema", schema, lints);
890 let fallback_entities = schema_entity_names(schema);
891 for permission in &surface.permissions {
892 if let AdminPermission::ReadEntity { entity } = permission
893 && !fallback_entities.contains(entity)
894 {
895 lints.push(ModuleManifestLint {
896 severity: ModuleManifestLintSeverity::Warning,
897 subject: format!("admin.embedded.permission.{entity}"),
898 message: format!(
899 "Embedded admin permission references unknown fallback entity `{entity}`."
900 ),
901 suggestion:
902 "Declare the entity in fallback_schema or remove the permission."
903 .to_owned(),
904 });
905 }
906 }
907 }
908 }
909 }
910}
911
912fn lint_schema_entities(prefix: &str, schema: &AdminSchema, lints: &mut Vec<ModuleManifestLint>) {
913 if schema.entities.is_empty() {
914 lints.push(ModuleManifestLint {
915 severity: ModuleManifestLintSeverity::Warning,
916 subject: prefix.to_owned(),
917 message: "Admin schema declares no entities.".to_owned(),
918 suggestion: "Add at least one entity or omit the admin schema surface.".to_owned(),
919 });
920 }
921 for entity in &schema.entities {
922 if !present(&entity.read_capability) {
923 lints.push(ModuleManifestLint {
924 severity: ModuleManifestLintSeverity::Warning,
925 subject: format!("{prefix}.{}", entity.name),
926 message: "Admin entity is missing read capability.".to_owned(),
927 suggestion: "Declare the capability required to read this entity.".to_owned(),
928 });
929 }
930 }
931}
932
933fn schema_entity_names(schema: &AdminSchema) -> HashSet<String> {
934 schema
935 .entities
936 .iter()
937 .map(|entity| entity.name.clone())
938 .collect()
939}
940
941fn present(value: &str) -> bool {
942 !value.trim().is_empty()
943}
944
945fn valid_capability(value: &str) -> bool {
946 let mut parts = value.split('.');
947 let Some(first) = parts.next() else {
948 return false;
949 };
950 present(first)
951 && value.contains('.')
952 && std::iter::once(first).chain(parts).all(|part| {
953 present(part)
954 && part.chars().all(|character| {
955 character.is_ascii_lowercase() || character == '_' || character.is_ascii_digit()
956 })
957 })
958}
959
960fn valid_runtime_function_name(value: &str) -> bool {
961 present(value)
962 && value.chars().all(|character| {
963 character.is_ascii_alphanumeric()
964 || character == '.'
965 || character == '_'
966 || character == '-'
967 })
968}
969
970fn valid_console_surface_name(value: &str) -> bool {
971 present(value)
972 && value.chars().all(|character| {
973 character.is_ascii_alphanumeric() || character == '_' || character == '-'
974 })
975}
976
977fn valid_console_navigation_id(value: &str) -> bool {
978 valid_console_surface_name(value)
979}
980
981fn valid_console_package_name(value: &str) -> bool {
982 present(value)
983 && !value.contains(' ')
984 && (value.starts_with('@') || value.chars().any(|character| character == '-'))
985}
986
987fn route_identity(route: &ModuleHttpRoute) -> String {
988 format!("{} {}", method_label(route.method), route.path)
989}
990
991fn method_label(method: ModuleHttpMethod) -> &'static str {
992 match method {
993 ModuleHttpMethod::Get => "GET",
994 ModuleHttpMethod::Post => "POST",
995 ModuleHttpMethod::Put => "PUT",
996 ModuleHttpMethod::Patch => "PATCH",
997 ModuleHttpMethod::Delete => "DELETE",
998 }
999}
1000
1001#[derive(Debug)]
1003pub struct ModuleManifestBuilder {
1004 manifest: ModuleManifest,
1005}
1006
1007impl ModuleManifestBuilder {
1008 #[must_use]
1010 pub fn story_display(mut self, story_display: Vec<StoryDisplayDescriptor>) -> Self {
1011 self.manifest.story_display = story_display;
1012 self
1013 }
1014
1015 #[must_use]
1017 pub fn capabilities(mut self, capabilities: Vec<String>) -> Self {
1018 self.manifest.capabilities = capabilities;
1019 self
1020 }
1021
1022 #[must_use]
1024 pub fn dependencies(mut self, dependencies: Vec<String>) -> Self {
1025 self.manifest.dependencies = dependencies;
1026 self
1027 }
1028
1029 #[must_use]
1031 pub fn http_routes(mut self, routes: Vec<ModuleHttpRoute>) -> Self {
1032 self.manifest.http_routes = routes;
1033 self
1034 }
1035
1036 #[must_use]
1038 pub fn runtime(mut self, runtime: RuntimeSurface) -> Self {
1039 self.manifest.runtime = Some(runtime);
1040 self
1041 }
1042
1043 #[must_use]
1045 pub fn events(mut self, events: EventSurface) -> Self {
1046 self.manifest.events = Some(events);
1047 self
1048 }
1049
1050 #[must_use]
1052 pub fn admin(mut self, schema: AdminSchema) -> Self {
1053 self.manifest.admin = Some(AdminSurface::Schema(schema));
1054 self
1055 }
1056
1057 #[must_use]
1059 pub fn declarative_admin(mut self, surface: AdminDeclarativeSurface) -> Self {
1060 self.manifest.admin = Some(AdminSurface::DeclarativeCustom(surface));
1061 self
1062 }
1063
1064 #[must_use]
1066 pub fn embedded_admin(mut self, surface: AdminEmbeddedSurface) -> Self {
1067 self.manifest.admin = Some(AdminSurface::EmbeddedCustom(surface));
1068 self
1069 }
1070
1071 #[must_use]
1073 pub fn lifecycle(mut self, lifecycle: LifecycleSurface) -> Self {
1074 self.manifest.lifecycle = Some(lifecycle);
1075 self
1076 }
1077
1078 #[must_use]
1080 pub fn console(mut self, console: Vec<ConsoleSurface>) -> Self {
1081 self.manifest.console = console;
1082 self
1083 }
1084
1085 #[must_use]
1087 pub fn build(self) -> ModuleManifest {
1088 self.manifest
1089 }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use crate::admin::{
1096 AdminDeclarativeComponent, AdminDeclarativePage, AdminDeclarativeSection,
1097 AdminDeclarativeSurface,
1098 };
1099 use crate::{
1100 AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
1101 ConsoleArea, ConsolePackage, ConsoleSurface, EventHandlerDeclaration, EventSurface,
1102 };
1103 use crate::{
1104 LifecycleActivationJobDeclaration, LifecycleActivationRunPolicy,
1105 LifecycleStartupCheckDeclaration, LifecycleStartupCheckKind, LifecycleSurface,
1106 };
1107 use crate::{ModuleHttpMethod, ModuleHttpRoute};
1108 use crate::{RuntimeFunctionDeclaration, RuntimeRetryPolicyDeclaration, RuntimeSurface};
1109 use crate::{StoryDisplayDescriptor, StoryDisplaySource};
1110
1111 #[test]
1112 fn manifest_round_trips_through_json() {
1113 let manifest = ModuleManifest::builder("identity")
1114 .story_display(vec![StoryDisplayDescriptor {
1115 source: StoryDisplaySource::ExecutionName {
1116 name: "identity.create_user".to_owned(),
1117 },
1118 display_name: "Create User".to_owned(),
1119 story_title: Some("User Registration".to_owned()),
1120 }])
1121 .build();
1122
1123 let json = serde_json::to_string(&manifest).expect("serialize");
1124 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1125
1126 assert_eq!(manifest, back);
1127 }
1128
1129 #[test]
1130 fn manifest_with_console_surface_round_trips_through_json() {
1131 let manifest = ModuleManifest::builder("platform-story")
1132 .console(vec![ConsoleSurface {
1133 name: "stories".to_owned(),
1134 label: "Stories".to_owned(),
1135 area: ConsoleArea::Runtime,
1136 route: "/runtime/stories".to_owned(),
1137 package: ConsolePackage {
1138 name: "@lenso/story-console".to_owned(),
1139 export: "storyConsoleModule".to_owned(),
1140 },
1141 icon: Some("workflow".to_owned()),
1142 required_capabilities: vec!["runtime.stories.read".to_owned()],
1143 navigation: None,
1144 }])
1145 .capabilities(vec!["runtime.stories.read".to_owned()])
1146 .build();
1147
1148 let json = serde_json::to_string(&manifest).expect("serialize");
1149 assert!(json.contains(r#""console""#), "got {json}");
1150 assert!(json.contains(r#""area":"runtime""#), "got {json}");
1151
1152 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1153
1154 assert_eq!(manifest, back);
1155 }
1156
1157 #[test]
1158 fn console_surface_navigation_round_trips() {
1159 let surface = ConsoleSurface {
1160 name: "contacts".to_owned(),
1161 label: "Contacts".to_owned(),
1162 area: ConsoleArea::Data,
1163 route: "/crm/contacts".to_owned(),
1164 package: crate::ConsolePackage {
1165 name: "@lenso/crm-console".to_owned(),
1166 export: "crmConsoleModule".to_owned(),
1167 },
1168 icon: Some("users".to_owned()),
1169 required_capabilities: vec!["crm.contacts.read".to_owned()],
1170 navigation: Some(crate::ConsoleNavigation {
1171 workspace: crate::ConsoleWorkspaceRef {
1172 id: "crm".to_owned(),
1173 label: "CRM".to_owned(),
1174 icon: Some("briefcase".to_owned()),
1175 },
1176 group: Some(crate::ConsoleNavigationGroup {
1177 id: "customers".to_owned(),
1178 label: "Customers".to_owned(),
1179 icon: None,
1180 order: Some(20),
1181 }),
1182 order: Some(10),
1183 }),
1184 };
1185
1186 let json = serde_json::to_string(&surface).expect("serialize");
1187 let back: ConsoleSurface = serde_json::from_str(&json).expect("deserialize");
1188
1189 assert_eq!(back, surface);
1190 }
1191
1192 #[test]
1193 fn console_navigation_lints_empty_workspace_label() {
1194 let manifest = ModuleManifest::builder("crm")
1195 .capabilities(vec!["crm.contacts.read".to_owned()])
1196 .console(vec![ConsoleSurface {
1197 name: "contacts".to_owned(),
1198 label: "Contacts".to_owned(),
1199 area: ConsoleArea::Data,
1200 route: "/crm/contacts".to_owned(),
1201 package: crate::ConsolePackage {
1202 name: "@lenso/crm-console".to_owned(),
1203 export: "crmConsoleModule".to_owned(),
1204 },
1205 icon: None,
1206 required_capabilities: vec!["crm.contacts.read".to_owned()],
1207 navigation: Some(crate::ConsoleNavigation {
1208 workspace: crate::ConsoleWorkspaceRef {
1209 id: "crm".to_owned(),
1210 label: "".to_owned(),
1211 icon: None,
1212 },
1213 group: None,
1214 order: None,
1215 }),
1216 }])
1217 .build();
1218
1219 let subjects: Vec<_> = lint_module_manifest(ModuleSource::Linked, &manifest)
1220 .into_iter()
1221 .map(|lint| lint.subject)
1222 .collect();
1223
1224 assert!(
1225 subjects.contains(&"console.surface.contacts.navigation.workspace.label".to_owned())
1226 );
1227 }
1228
1229 #[test]
1230 fn console_navigation_lints_reserved_system_workspace() {
1231 let manifest = ModuleManifest::builder("crm")
1232 .capabilities(vec!["crm.contacts.read".to_owned()])
1233 .console(vec![ConsoleSurface {
1234 name: "contacts".to_owned(),
1235 label: "Contacts".to_owned(),
1236 area: ConsoleArea::Data,
1237 route: "/crm/contacts".to_owned(),
1238 package: crate::ConsolePackage {
1239 name: "@lenso/crm-console".to_owned(),
1240 export: "crmConsoleModule".to_owned(),
1241 },
1242 icon: None,
1243 required_capabilities: vec!["crm.contacts.read".to_owned()],
1244 navigation: Some(crate::ConsoleNavigation {
1245 workspace: crate::ConsoleWorkspaceRef {
1246 id: "system".to_owned(),
1247 label: "System".to_owned(),
1248 icon: Some("settings".to_owned()),
1249 },
1250 group: None,
1251 order: Some(10),
1252 }),
1253 }])
1254 .build();
1255
1256 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1257
1258 assert!(lints.iter().any(|lint| {
1259 lint.subject == "console.surface.contacts.navigation.workspace.id"
1260 && lint.severity == ModuleManifestLintSeverity::Warning
1261 && lint.message
1262 == "Console workspace id system is reserved for host-owned surfaces."
1263 }));
1264 }
1265
1266 #[test]
1267 fn lints_invalid_console_surface_declarations() {
1268 let manifest = ModuleManifest::builder("platform-story")
1269 .console(vec![
1270 ConsoleSurface {
1271 name: "stories".to_owned(),
1272 label: "Stories".to_owned(),
1273 area: ConsoleArea::Runtime,
1274 route: "runtime/stories".to_owned(),
1275 package: ConsolePackage {
1276 name: "story console".to_owned(),
1277 export: String::new(),
1278 },
1279 icon: None,
1280 required_capabilities: vec!["runtime.stories.read".to_owned()],
1281 navigation: None,
1282 },
1283 ConsoleSurface {
1284 name: "stories".to_owned(),
1285 label: "Stories duplicate".to_owned(),
1286 area: ConsoleArea::Runtime,
1287 route: "/runtime/stories".to_owned(),
1288 package: ConsolePackage {
1289 name: "@lenso/story-console".to_owned(),
1290 export: "storyConsoleModule".to_owned(),
1291 },
1292 icon: None,
1293 required_capabilities: vec![],
1294 navigation: None,
1295 },
1296 ])
1297 .build();
1298
1299 let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
1300 let subjects = lints
1301 .iter()
1302 .map(|lint| lint.subject.as_str())
1303 .collect::<Vec<_>>();
1304
1305 assert!(subjects.contains(&"console.surface.stories.route"));
1306 assert!(subjects.contains(&"console.surface.stories.package"));
1307 assert!(subjects.contains(&"console.surface.stories.package.export"));
1308 assert!(subjects.contains(&"capability.reference.console.surface.stories"));
1309 assert!(lints.iter().any(|lint| {
1310 lint.subject == "console.surface.stories"
1311 && lint.message == "Duplicate console surface declaration."
1312 }));
1313 }
1314
1315 #[test]
1316 fn empty_admin_is_skipped_in_json() {
1317 let manifest = ModuleManifest::builder("notifications").build();
1318 let json = serde_json::to_string(&manifest).expect("serialize");
1319 assert!(
1320 !json.contains("admin"),
1321 "admin: None must be skipped, got {json}"
1322 );
1323 }
1324
1325 #[test]
1326 fn manifest_lints_self_dependency() {
1327 let manifest = ModuleManifest::builder("auth")
1328 .dependencies(vec!["auth".to_owned()])
1329 .build();
1330
1331 let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
1332
1333 assert!(lints.iter().any(|lint| {
1334 lint.severity == ModuleManifestLintSeverity::Error
1335 && lint.subject == "dependency auth"
1336 && lint.message == "Module must not depend on itself."
1337 }));
1338 }
1339
1340 #[test]
1341 fn manifest_with_admin_serializes_schema_kind() {
1342 use crate::admin_schema::{AdminSchema, EntitySchema, FieldSchema, FieldType};
1343 let schema = AdminSchema {
1344 entities: vec![EntitySchema {
1345 name: "users".to_owned(),
1346 label: "Users".to_owned(),
1347 read_capability: "identity.users.read".to_owned(),
1348 fields: vec![FieldSchema {
1349 name: "email".into(),
1350 label: "Email".into(),
1351 field_type: FieldType::String,
1352 nullable: false,
1353 }],
1354 }],
1355 };
1356 let manifest = ModuleManifest::builder("identity").admin(schema).build();
1357 let json = serde_json::to_string(&manifest).expect("serialize");
1358 assert!(json.contains(r#""kind":"schema""#), "got {json}");
1359 }
1360
1361 #[test]
1362 fn manifest_with_declarative_admin_serializes_kind() {
1363 use crate::admin::AdminDeclarativeSurface;
1364
1365 let manifest = ModuleManifest::builder("remote-crm")
1366 .declarative_admin(AdminDeclarativeSurface {
1367 pages: vec![],
1368 actions: vec![],
1369 fallback_schema: None,
1370 })
1371 .build();
1372 let json = serde_json::to_string(&manifest).expect("serialize");
1373 assert!(
1374 json.contains(r#""kind":"declarative_custom""#),
1375 "got {json}"
1376 );
1377 }
1378
1379 #[test]
1380 fn manifest_with_embedded_admin_serializes_kind() {
1381 use crate::admin::{
1382 AdminEmbeddedEntry, AdminEmbeddedRuntime, AdminEmbeddedSurface, AdminSandboxPolicy,
1383 };
1384
1385 let manifest = ModuleManifest::builder("remote-crm")
1386 .embedded_admin(AdminEmbeddedSurface {
1387 runtime: AdminEmbeddedRuntime::Iframe,
1388 entry: AdminEmbeddedEntry::Url {
1389 url: "https://crm.example.test/admin".to_owned(),
1390 allowed_origins: vec!["https://crm.example.test".to_owned()],
1391 },
1392 sandbox: AdminSandboxPolicy {
1393 allow_scripts: true,
1394 allow_forms: false,
1395 allow_popups: false,
1396 allow_same_origin: false,
1397 },
1398 permissions: vec![],
1399 fallback_schema: None,
1400 })
1401 .build();
1402 let json = serde_json::to_string(&manifest).expect("serialize");
1403 assert!(json.contains(r#""kind":"embedded_custom""#), "got {json}");
1404 }
1405
1406 #[test]
1407 fn manifest_with_http_routes_round_trips_through_json() {
1408 let manifest = ModuleManifest::builder("remote-crm")
1409 .http_routes(vec![
1410 ModuleHttpRoute {
1411 method: ModuleHttpMethod::Get,
1412 path: "/contacts".to_owned(),
1413 capability: Some("remote_crm.contacts.read".to_owned()),
1414 display_name: Some("List Contacts".to_owned()),
1415 story_title: Some("List Contacts".to_owned()),
1416 },
1417 ModuleHttpRoute {
1418 method: ModuleHttpMethod::Post,
1419 path: "/contacts".to_owned(),
1420 capability: Some("remote_crm.contacts.write".to_owned()),
1421 display_name: None,
1422 story_title: None,
1423 },
1424 ])
1425 .build();
1426
1427 let json = serde_json::to_string(&manifest).expect("serialize");
1428 assert!(json.contains(r#""http_routes""#), "got {json}");
1429 assert!(json.contains(r#""method":"GET""#), "got {json}");
1430 assert!(
1431 json.contains(r#""display_name":"List Contacts""#),
1432 "got {json}"
1433 );
1434 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1435 assert_eq!(manifest, back);
1436 }
1437
1438 #[test]
1439 fn manifest_with_runtime_functions_round_trips_through_json() {
1440 let manifest = ModuleManifest::builder("remote-crm")
1441 .runtime(RuntimeSurface {
1442 functions: vec![RuntimeFunctionDeclaration {
1443 name: "remote_crm.sync_contact.v1".to_owned(),
1444 version: 1,
1445 queue: "remote-crm".to_owned(),
1446 input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
1447 retry_policy: Some(RuntimeRetryPolicyDeclaration {
1448 max_attempts: 3,
1449 initial_delay_ms: 1000,
1450 }),
1451 }],
1452 })
1453 .build();
1454
1455 let json = serde_json::to_string(&manifest).expect("serialize");
1456
1457 assert!(json.contains(r#""runtime""#), "got {json}");
1458 assert!(
1459 json.contains(r#""name":"remote_crm.sync_contact.v1""#),
1460 "got {json}"
1461 );
1462 assert!(json.contains(r#""queue":"remote-crm""#), "got {json}");
1463 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1464 assert_eq!(manifest, back);
1465 }
1466
1467 #[test]
1468 fn manifest_with_event_handlers_round_trips_through_json() {
1469 let manifest = ModuleManifest::builder("remote-crm")
1470 .events(EventSurface {
1471 handlers: vec![EventHandlerDeclaration {
1472 name: "sync_contact_on_user_registered".to_owned(),
1473 event_name: "identity.user_registered.v1".to_owned(),
1474 }],
1475 })
1476 .build();
1477
1478 let json = serde_json::to_string(&manifest).expect("serialize");
1479
1480 assert!(json.contains(r#""events""#), "got {json}");
1481 assert!(
1482 json.contains(r#""name":"sync_contact_on_user_registered""#),
1483 "got {json}"
1484 );
1485 assert!(
1486 json.contains(r#""event_name":"identity.user_registered.v1""#),
1487 "got {json}"
1488 );
1489 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1490 assert_eq!(manifest, back);
1491 }
1492
1493 #[test]
1494 fn manifest_lint_warns_for_invalid_capability_names() {
1495 let manifest = ModuleManifest::builder("remote-crm")
1496 .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
1497 .build();
1498
1499 assert!(
1500 lint_module_manifest(ModuleSource::Remote, &manifest)
1501 .iter()
1502 .any(|lint| lint.subject == "capability RemoteCRM Contacts Read"
1503 && lint.severity == ModuleManifestLintSeverity::Warning)
1504 );
1505 }
1506
1507 #[test]
1508 fn manifest_lint_warns_for_unknown_declarative_fallback_entities() {
1509 let manifest = ModuleManifest::builder("remote-crm")
1510 .declarative_admin(AdminDeclarativeSurface {
1511 pages: vec![AdminDeclarativePage {
1512 name: "dashboard".to_owned(),
1513 label: "Dashboard".to_owned(),
1514 sections: vec![AdminDeclarativeSection {
1515 name: "missing".to_owned(),
1516 label: "Missing".to_owned(),
1517 component: AdminDeclarativeComponent::EntityTable {
1518 entity: "contacts".to_owned(),
1519 },
1520 }],
1521 }],
1522 actions: vec![],
1523 fallback_schema: None,
1524 })
1525 .build();
1526
1527 assert!(
1528 lint_module_manifest(ModuleSource::Remote, &manifest)
1529 .iter()
1530 .any(|lint| lint.subject == "admin.declarative.section.missing"
1531 && lint.severity == ModuleManifestLintSeverity::Warning)
1532 );
1533 }
1534
1535 #[test]
1536 fn manifest_lint_warns_for_embedded_origin_policy() {
1537 let manifest = ModuleManifest::builder("remote-crm")
1538 .embedded_admin(AdminEmbeddedSurface {
1539 runtime: AdminEmbeddedRuntime::Iframe,
1540 entry: AdminEmbeddedEntry::Url {
1541 url: "http://crm.example.test/admin".to_owned(),
1542 allowed_origins: vec![],
1543 },
1544 sandbox: AdminSandboxPolicy {
1545 allow_scripts: true,
1546 allow_forms: false,
1547 allow_popups: false,
1548 allow_same_origin: false,
1549 },
1550 permissions: vec![],
1551 fallback_schema: None,
1552 })
1553 .build();
1554
1555 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1556
1557 assert!(
1558 lints
1559 .iter()
1560 .any(|lint| lint.subject == "admin.embedded.entry.url")
1561 );
1562 assert!(
1563 lints
1564 .iter()
1565 .any(|lint| lint.subject == "admin.embedded.entry.allowed_origins")
1566 );
1567 }
1568
1569 #[test]
1570 fn manifest_lint_warns_for_runtime_function_declarations() {
1571 let manifest = ModuleManifest::builder("remote-crm")
1572 .runtime(RuntimeSurface {
1573 functions: vec![
1574 RuntimeFunctionDeclaration {
1575 name: "remote_crm/sync_contact.v1".to_owned(),
1576 version: 1,
1577 queue: "".to_owned(),
1578 input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
1579 retry_policy: Some(RuntimeRetryPolicyDeclaration {
1580 max_attempts: 0,
1581 initial_delay_ms: 1000,
1582 }),
1583 },
1584 RuntimeFunctionDeclaration {
1585 name: "remote_crm.sync_contact.v1".to_owned(),
1586 version: 1,
1587 queue: "remote-crm".to_owned(),
1588 input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
1589 retry_policy: None,
1590 },
1591 RuntimeFunctionDeclaration {
1592 name: "remote_crm.sync_contact.v1".to_owned(),
1593 version: 1,
1594 queue: "remote-crm".to_owned(),
1595 input_schema: Some("remote_crm.sync_contact.v1".to_owned()),
1596 retry_policy: None,
1597 },
1598 ],
1599 })
1600 .build();
1601
1602 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1603
1604 assert!(lints.iter().any(|lint| {
1605 lint.subject == "runtime.function.remote_crm/sync_contact.v1"
1606 && lint.severity == ModuleManifestLintSeverity::Warning
1607 }));
1608 assert!(lints.iter().any(|lint| {
1609 lint.subject == "runtime.function.remote_crm/sync_contact.v1.retry_policy"
1610 && lint.severity == ModuleManifestLintSeverity::Warning
1611 }));
1612 assert!(lints.iter().any(|lint| {
1613 lint.subject == "runtime.function.remote_crm.sync_contact.v1.input_schema"
1614 && lint.severity == ModuleManifestLintSeverity::Warning
1615 }));
1616 assert!(lints.iter().any(|lint| {
1617 lint.subject == "runtime.function.remote_crm.sync_contact.v1"
1618 && lint.severity == ModuleManifestLintSeverity::Error
1619 }));
1620 }
1621
1622 #[test]
1623 fn manifest_with_lifecycle_round_trips_through_json() {
1624 let manifest = ModuleManifest::builder("remote-crm")
1625 .runtime(RuntimeSurface {
1626 functions: vec![RuntimeFunctionDeclaration {
1627 name: "remote_crm.warm_contact_cache.v1".to_owned(),
1628 version: 1,
1629 queue: "remote-crm".to_owned(),
1630 input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
1631 retry_policy: Some(RuntimeRetryPolicyDeclaration {
1632 max_attempts: 2,
1633 initial_delay_ms: 500,
1634 }),
1635 }],
1636 })
1637 .lifecycle(LifecycleSurface {
1638 startup_checks: vec![LifecycleStartupCheckDeclaration {
1639 name: "warm cache function is registered".to_owned(),
1640 required: true,
1641 check: LifecycleStartupCheckKind::FunctionRegistered {
1642 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
1643 },
1644 }],
1645 activation_jobs: vec![LifecycleActivationJobDeclaration {
1646 name: "warm contact cache".to_owned(),
1647 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
1648 run_policy: LifecycleActivationRunPolicy::EveryStartup,
1649 input: serde_json::json!({ "reason": "worker_startup" }),
1650 required: true,
1651 }],
1652 })
1653 .build();
1654
1655 let json = serde_json::to_string(&manifest).expect("serialize");
1656
1657 assert!(json.contains(r#""lifecycle""#), "got {json}");
1658 assert!(
1659 json.contains(r#""kind":"function_registered""#),
1660 "got {json}"
1661 );
1662 assert!(
1663 json.contains(r#""run_policy":"every_startup""#),
1664 "got {json}"
1665 );
1666 let back: ModuleManifest = serde_json::from_str(&json).expect("deserialize");
1667 assert_eq!(manifest, back);
1668 }
1669
1670 #[test]
1671 fn manifest_lint_flags_lifecycle_declarations_that_cannot_run() {
1672 let manifest = ModuleManifest::builder("remote-crm")
1673 .runtime(RuntimeSurface { functions: vec![] })
1674 .lifecycle(LifecycleSurface {
1675 startup_checks: vec![
1676 LifecycleStartupCheckDeclaration {
1677 name: "".to_owned(),
1678 required: true,
1679 check: LifecycleStartupCheckKind::FunctionRegistered {
1680 function_name: "remote_crm.missing.v1".to_owned(),
1681 },
1682 },
1683 LifecycleStartupCheckDeclaration {
1684 name: "missing capability".to_owned(),
1685 required: true,
1686 check: LifecycleStartupCheckKind::CapabilityDeclared {
1687 capability: "remote_crm.contacts.read".to_owned(),
1688 },
1689 },
1690 ],
1691 activation_jobs: vec![LifecycleActivationJobDeclaration {
1692 name: "warm contact cache".to_owned(),
1693 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
1694 run_policy: LifecycleActivationRunPolicy::EveryStartup,
1695 input: serde_json::json!({}),
1696 required: true,
1697 }],
1698 })
1699 .build();
1700
1701 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1702
1703 assert!(lints.iter().any(|lint| {
1704 lint.subject == "lifecycle.startup_check"
1705 && lint.severity == ModuleManifestLintSeverity::Warning
1706 && lint.message == "Lifecycle startup check is missing a name."
1707 }));
1708 assert!(lints.iter().any(|lint| {
1709 lint.subject == "lifecycle.startup_check.function_registered.remote_crm.missing.v1"
1710 && lint.severity == ModuleManifestLintSeverity::Error
1711 }));
1712 assert!(lints.iter().any(|lint| {
1713 lint.subject == "lifecycle.startup_check.capability.remote_crm.contacts.read"
1714 && lint.severity == ModuleManifestLintSeverity::Warning
1715 }));
1716 assert!(lints.iter().any(|lint| {
1717 lint.subject == "lifecycle.activation_job.warm contact cache"
1718 && lint.severity == ModuleManifestLintSeverity::Error
1719 }));
1720 }
1721
1722 #[test]
1723 fn manifest_lint_warns_for_empty_lifecycle_surface() {
1724 let manifest = ModuleManifest::builder("remote-crm")
1725 .lifecycle(LifecycleSurface {
1726 startup_checks: vec![],
1727 activation_jobs: vec![],
1728 })
1729 .build();
1730
1731 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1732
1733 assert!(lints.iter().any(|lint| {
1734 lint.subject == "lifecycle"
1735 && lint.severity == ModuleManifestLintSeverity::Warning
1736 && lint.message
1737 == "Lifecycle surface declares no startup checks or activation jobs."
1738 }));
1739 }
1740
1741 #[test]
1742 fn manifest_lint_warns_for_activation_job_missing_name() {
1743 let manifest = ModuleManifest::builder("remote-crm")
1744 .runtime(RuntimeSurface {
1745 functions: vec![RuntimeFunctionDeclaration {
1746 name: "remote_crm.warm_contact_cache.v1".to_owned(),
1747 version: 1,
1748 queue: "remote-crm".to_owned(),
1749 input_schema: Some("remote_crm.warm_contact_cache.v1".to_owned()),
1750 retry_policy: None,
1751 }],
1752 })
1753 .lifecycle(LifecycleSurface {
1754 startup_checks: vec![],
1755 activation_jobs: vec![LifecycleActivationJobDeclaration {
1756 name: "".to_owned(),
1757 function_name: "remote_crm.warm_contact_cache.v1".to_owned(),
1758 run_policy: LifecycleActivationRunPolicy::EveryStartup,
1759 input: serde_json::json!({}),
1760 required: true,
1761 }],
1762 })
1763 .build();
1764
1765 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1766
1767 assert!(lints.iter().any(|lint| {
1768 lint.subject == "lifecycle.activation_job"
1769 && lint.severity == ModuleManifestLintSeverity::Warning
1770 && lint.message == "Lifecycle activation job is missing a name."
1771 }));
1772 }
1773
1774 #[test]
1775 fn manifest_lint_errors_for_activation_job_missing_function_name() {
1776 let manifest = ModuleManifest::builder("remote-crm")
1777 .lifecycle(LifecycleSurface {
1778 startup_checks: vec![],
1779 activation_jobs: vec![LifecycleActivationJobDeclaration {
1780 name: "".to_owned(),
1781 function_name: "".to_owned(),
1782 run_policy: LifecycleActivationRunPolicy::EveryStartup,
1783 input: serde_json::json!({}),
1784 required: true,
1785 }],
1786 })
1787 .build();
1788
1789 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1790
1791 assert!(lints.iter().any(|lint| {
1792 lint.subject == "lifecycle.activation_job"
1793 && lint.severity == ModuleManifestLintSeverity::Error
1794 && lint.message == "Lifecycle activation job is missing a function name."
1795 }));
1796 }
1797
1798 #[test]
1799 fn manifest_lint_warns_for_undeclared_capability_references() {
1800 use crate::admin::{AdminAction, AdminActionDangerLevel};
1801
1802 let manifest = ModuleManifest::builder("remote-crm")
1803 .capabilities(vec!["remote_crm.contacts.write".to_owned()])
1804 .http_routes(vec![ModuleHttpRoute {
1805 method: ModuleHttpMethod::Get,
1806 path: "/contacts/{id}".to_owned(),
1807 capability: Some("remote_crm.contacts.read".to_owned()),
1808 display_name: Some("Fetch Contact".to_owned()),
1809 story_title: Some("Fetch Contact".to_owned()),
1810 }])
1811 .declarative_admin(AdminDeclarativeSurface {
1812 pages: vec![AdminDeclarativePage {
1813 name: "contacts".to_owned(),
1814 label: "Contacts".to_owned(),
1815 sections: vec![AdminDeclarativeSection {
1816 name: "contacts".to_owned(),
1817 label: "Contacts".to_owned(),
1818 component: AdminDeclarativeComponent::EntityTable {
1819 entity: "contacts".to_owned(),
1820 },
1821 }],
1822 }],
1823 actions: vec![AdminAction {
1824 name: "sync_contacts".to_owned(),
1825 label: "Sync Contacts".to_owned(),
1826 capability: "remote_crm.contacts.sync".to_owned(),
1827 input_schema: None,
1828 confirmation: None,
1829 danger_level: AdminActionDangerLevel::Low,
1830 }],
1831 fallback_schema: Some(AdminSchema {
1832 entities: vec![crate::EntitySchema {
1833 name: "contacts".to_owned(),
1834 label: "Contacts".to_owned(),
1835 fields: vec![],
1836 read_capability: "remote_crm.contacts.read".to_owned(),
1837 }],
1838 }),
1839 })
1840 .build();
1841
1842 let lints = lint_module_manifest(ModuleSource::Remote, &manifest);
1843
1844 assert!(lints.iter().any(|lint| {
1845 lint.severity == ModuleManifestLintSeverity::Warning
1846 && lint.subject == "capability.reference.http_route.GET /contacts/{id}"
1847 && lint.message == "Capability reference is not declared by the module."
1848 }));
1849 assert!(lints.iter().any(|lint| {
1850 lint.severity == ModuleManifestLintSeverity::Warning
1851 && lint.subject == "capability.reference.admin.declarative.action.sync_contacts"
1852 && lint.message == "Capability reference is not declared by the module."
1853 }));
1854 assert!(lints.iter().any(|lint| {
1855 lint.severity == ModuleManifestLintSeverity::Warning
1856 && lint.subject == "capability.reference.admin.declarative.fallback_schema.contacts"
1857 && lint.message == "Capability reference is not declared by the module."
1858 }));
1859 }
1860
1861 #[test]
1862 fn manifest_lint_catalog_covers_current_subjects() {
1863 let schema = AdminSchema {
1864 entities: vec![crate::EntitySchema {
1865 name: "contacts".to_owned(),
1866 label: "Contacts".to_owned(),
1867 fields: vec![],
1868 read_capability: "".to_owned(),
1869 }],
1870 };
1871 let manifest = ModuleManifest::builder("")
1872 .capabilities(vec!["RemoteCRM Contacts Read".to_owned()])
1873 .http_routes(vec![
1874 ModuleHttpRoute {
1875 method: ModuleHttpMethod::Get,
1876 path: "/contacts/{id}".to_owned(),
1877 capability: None,
1878 display_name: None,
1879 story_title: None,
1880 },
1881 ModuleHttpRoute {
1882 method: ModuleHttpMethod::Get,
1883 path: "/contacts/{id}".to_owned(),
1884 capability: None,
1885 display_name: None,
1886 story_title: None,
1887 },
1888 ])
1889 .embedded_admin(AdminEmbeddedSurface {
1890 runtime: AdminEmbeddedRuntime::Wasm,
1891 entry: AdminEmbeddedEntry::Url {
1892 url: "http://crm.example.test/admin".to_owned(),
1893 allowed_origins: vec![],
1894 },
1895 sandbox: AdminSandboxPolicy {
1896 allow_scripts: true,
1897 allow_forms: false,
1898 allow_popups: false,
1899 allow_same_origin: false,
1900 },
1901 permissions: vec![AdminPermission::ReadEntity {
1902 entity: "missing".to_owned(),
1903 }],
1904 fallback_schema: Some(schema),
1905 })
1906 .runtime(RuntimeSurface {
1907 functions: vec![RuntimeFunctionDeclaration {
1908 name: "remote_crm.sync_contact.v1".to_owned(),
1909 version: 1,
1910 queue: "".to_owned(),
1911 input_schema: Some("remote_crm.sync_contact.input.v1".to_owned()),
1912 retry_policy: Some(RuntimeRetryPolicyDeclaration {
1913 max_attempts: 0,
1914 initial_delay_ms: 1000,
1915 }),
1916 }],
1917 })
1918 .lifecycle(LifecycleSurface {
1919 startup_checks: vec![LifecycleStartupCheckDeclaration {
1920 name: "missing function".to_owned(),
1921 required: true,
1922 check: LifecycleStartupCheckKind::FunctionRegistered {
1923 function_name: "remote_crm.missing.v1".to_owned(),
1924 },
1925 }],
1926 activation_jobs: vec![LifecycleActivationJobDeclaration {
1927 name: "missing activation".to_owned(),
1928 function_name: "remote_crm.missing.v1".to_owned(),
1929 run_policy: LifecycleActivationRunPolicy::EveryStartup,
1930 input: serde_json::json!({}),
1931 required: true,
1932 }],
1933 })
1934 .console(vec![ConsoleSurface {
1935 name: "contacts".to_owned(),
1936 label: "Contacts".to_owned(),
1937 area: ConsoleArea::Data,
1938 route: "/remote-crm/contacts".to_owned(),
1939 package: ConsolePackage {
1940 name: "@lenso/remote-crm-console".to_owned(),
1941 export: "remoteCrmConsoleModule".to_owned(),
1942 },
1943 icon: None,
1944 required_capabilities: Vec::new(),
1945 navigation: Some(crate::ConsoleNavigation {
1946 workspace: crate::ConsoleWorkspaceRef {
1947 id: "system".to_owned(),
1948 label: "System".to_owned(),
1949 icon: None,
1950 },
1951 group: None,
1952 order: None,
1953 }),
1954 }])
1955 .build();
1956
1957 let catalog: Vec<_> = lint_module_manifest(ModuleSource::Remote, &manifest)
1958 .into_iter()
1959 .map(|lint| (lint.severity, lint.subject))
1960 .collect();
1961
1962 assert_eq!(
1963 catalog,
1964 vec![
1965 (ModuleManifestLintSeverity::Error, "module.name".to_owned()),
1966 (
1967 ModuleManifestLintSeverity::Warning,
1968 "capability RemoteCRM Contacts Read".to_owned(),
1969 ),
1970 (
1971 ModuleManifestLintSeverity::Error,
1972 "GET /contacts/{id}".to_owned(),
1973 ),
1974 (
1975 ModuleManifestLintSeverity::Warning,
1976 "GET /contacts/{id}".to_owned(),
1977 ),
1978 (
1979 ModuleManifestLintSeverity::Warning,
1980 "GET /contacts/{id}".to_owned(),
1981 ),
1982 (
1983 ModuleManifestLintSeverity::Warning,
1984 "GET /contacts/{id}".to_owned(),
1985 ),
1986 (
1987 ModuleManifestLintSeverity::Warning,
1988 "GET /contacts/{id}".to_owned(),
1989 ),
1990 (
1991 ModuleManifestLintSeverity::Warning,
1992 "GET /contacts/{id}".to_owned(),
1993 ),
1994 (
1995 ModuleManifestLintSeverity::Warning,
1996 "GET /contacts/{id}".to_owned(),
1997 ),
1998 (
1999 ModuleManifestLintSeverity::Warning,
2000 "admin.embedded.runtime".to_owned(),
2001 ),
2002 (
2003 ModuleManifestLintSeverity::Warning,
2004 "admin.embedded.entry.url".to_owned(),
2005 ),
2006 (
2007 ModuleManifestLintSeverity::Warning,
2008 "admin.embedded.entry.allowed_origins".to_owned(),
2009 ),
2010 (
2011 ModuleManifestLintSeverity::Warning,
2012 "admin.embedded.fallback_schema.contacts".to_owned(),
2013 ),
2014 (
2015 ModuleManifestLintSeverity::Warning,
2016 "admin.embedded.permission.missing".to_owned(),
2017 ),
2018 (
2019 ModuleManifestLintSeverity::Error,
2020 "lifecycle.startup_check.function_registered.remote_crm.missing.v1".to_owned(),
2021 ),
2022 (
2023 ModuleManifestLintSeverity::Error,
2024 "lifecycle.activation_job.missing activation".to_owned(),
2025 ),
2026 (
2027 ModuleManifestLintSeverity::Warning,
2028 "console.surface.contacts.navigation.workspace.id".to_owned(),
2029 ),
2030 (
2031 ModuleManifestLintSeverity::Warning,
2032 "runtime.function.remote_crm.sync_contact.v1".to_owned(),
2033 ),
2034 (
2035 ModuleManifestLintSeverity::Warning,
2036 "runtime.function.remote_crm.sync_contact.v1.input_schema".to_owned(),
2037 ),
2038 (
2039 ModuleManifestLintSeverity::Warning,
2040 "runtime.function.remote_crm.sync_contact.v1.retry_policy".to_owned(),
2041 ),
2042 ],
2043 );
2044 }
2045}