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