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