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