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