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