Skip to main content

lenso_contracts/
manifest.rs

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