Skip to main content

lenso_contracts/
schema.rs

1use crate::{
2    ArtifactReference, CONSOLE_MODULE_PROTOCOL, CONSOLE_MODULE_PROTOCOL_MAJOR,
3    CONSOLE_UI_ESM_FORMAT, ConsoleSurface, ConsoleSurfacePresentation, ConsoleUiArtifact,
4    ConsoleUiArtifactEntry, ConsoleUiArtifactFormat, ConsoleUiArtifactStyleAsset,
5    LinkedModuleDelivery, MODULE_MANIFEST_PROTOCOL, MODULE_RELEASE_PROTOCOL,
6    ModuleConfigActivation, ModuleConfigContract, ModuleConfigField, ModuleConfigFieldType,
7    ModuleConfigMutability, ModuleConfigScope, ModuleDelivery, ModuleManifest, ModuleRelease,
8};
9use schemars::JsonSchema;
10use serde_json::{Value, json};
11
12const MODULE_ID_PATTERN: &str = "^[a-z][a-z0-9_-]*/[a-z][a-z0-9_-]*$";
13const SHA256_PATTERN: &str = "^sha256:[0-9a-f]{64}$";
14
15pub fn module_manifest_schema() -> Value {
16    generated_module_schema::<ModuleManifest>(MODULE_MANIFEST_PROTOCOL, "LensoModuleManifest")
17}
18
19pub fn module_release_schema() -> Value {
20    generated_module_schema::<ModuleRelease>(MODULE_RELEASE_PROTOCOL, "LensoModuleRelease")
21}
22
23pub fn console_module_manifest_schema() -> Value {
24    let mut schema = generated_console_schema::<crate::ConsoleModuleManifest>(
25        "lenso.console-module.v1.schema.json",
26        "LensoConsoleModuleManifest",
27    );
28    if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
29        properties.insert(
30            "protocol".to_owned(),
31            json!({ "type": "string", "const": CONSOLE_MODULE_PROTOCOL }),
32        );
33        properties.insert(
34            "surfaces".to_owned(),
35            json!({ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/ConsoleModuleSurface" } }),
36        );
37    }
38    schema
39}
40
41pub fn console_ui_artifact_schema() -> Value {
42    let mut schema = generated_console_schema::<ConsoleUiArtifact>(
43        "lenso.console-ui-esm.v1.schema.json",
44        "LensoConsoleUiEsmArtifact",
45    );
46    if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
47        properties.insert(
48            "format".to_owned(),
49            json!({ "type": "string", "const": CONSOLE_UI_ESM_FORMAT }),
50        );
51        properties.insert(
52            "protocolMajor".to_owned(),
53            json!({ "type": "integer", "const": CONSOLE_MODULE_PROTOCOL_MAJOR }),
54        );
55    }
56    schema
57}
58
59/// Deterministic positive and negative vectors shared by framework and
60/// Console loaders. Negative cases are deliberately represented as complete
61/// Module Release documents so each consumer can run its own validator.
62pub fn console_contract_vectors() -> Value {
63    let positive = sample_console_release();
64    let valid = serde_json::to_value(&positive).expect("Console vector release serializes");
65    let negative = vec![
66        negative_release("protocol", "protocol", &valid, |release| {
67            release["console_ui_artifact"]["manifest"]["protocol"] =
68                Value::String("lenso.console-module.v9".to_owned());
69        }),
70        negative_release("host-api-range", "compatibility", &valid, |release| {
71            release["compatibility"]["host_api_requirement"] = Value::String("^9.0.0".to_owned());
72        }),
73        negative_release("surface-path", "path", &valid, |release| {
74            release["console_ui_artifact"]["manifest"]["surfaces"][0]["path"] =
75                Value::String("/data/../escape".to_owned());
76        }),
77        negative_release("entry", "entry", &valid, |release| {
78            release["console_ui_artifact"]["entry"] = Value::String("missing.js".to_owned());
79        }),
80        negative_release("style-asset-path", "style_asset", &valid, |release| {
81            release["console_ui_artifact"]["styleAssets"][0]["path"] =
82                Value::String("../style.css".to_owned());
83        }),
84        negative_release("style-asset-entry", "style_asset", &valid, |release| {
85            release["console_ui_artifact"]["styleAssets"][0]["path"] =
86                Value::String("assets/missing.css".to_owned());
87        }),
88        negative_release("module-identity", "identity", &valid, |release| {
89            release["console_ui_artifact"]["manifest"]["moduleId"] =
90                Value::String("other/module".to_owned());
91        }),
92        negative_release("retired-bridge", "retired_bridge", &valid, |release| {
93            release["manifest"]["console"][0]["presentation"]["kind"] =
94                Value::String("isolated".to_owned());
95            release["manifest"]["console"][0]["presentation"]["bridge_protocol"] =
96                Value::String("lenso.console-bridge.v1".to_owned());
97        }),
98    ];
99    json!({
100        "protocol": "lenso.console-contract-vectors.v1",
101        "positive": {
102            "id": "esm-module-release",
103            "expected": "accepted",
104            "release": valid,
105        },
106        "negative": negative,
107        "negativeOperations": [
108            {
109                "id": "config-read-without-field-capability",
110                "operation": "config_read",
111                "failure": "capability",
112                "request": {
113                    "context": {
114                        "systemId": "system-1",
115                        "serviceId": "service-1",
116                        "environmentId": "production",
117                        "targetServicePrincipal": "spiffe://lenso/service-1",
118                        "callerModuleId": "acme/support-console",
119                        "delegatedActorSubject": "operator-1",
120                        "delegatedAuthorityDigest": digest("f"),
121                        "capabilities": []
122                    },
123                    "moduleId": "acme/support-console",
124                    "keys": ["endpoint"]
125                }
126            }
127        ]
128    })
129}
130
131fn generated_console_schema<T: JsonSchema>(file_name: &str, title: &str) -> Value {
132    let mut schema = serde_json::to_value(schemars::schema_for!(T))
133        .expect("generated Console schema must serialize");
134    let object = schema
135        .as_object_mut()
136        .expect("generated Console schema root must be an object");
137    object.insert(
138        "$id".to_owned(),
139        Value::String(format!("https://contracts.lenso.local/console/{file_name}")),
140    );
141    object.insert("title".to_owned(), Value::String(title.to_owned()));
142    schema
143}
144
145fn sample_console_release() -> ModuleRelease {
146    let manifest = ModuleManifest::builder("acme/support-console")
147        .capabilities(vec![
148            "support.console.read".to_owned(),
149            "support.endpoint.read".to_owned(),
150            "support.endpoint.write".to_owned(),
151        ])
152        .config(ModuleConfigContract {
153            fields: vec![ModuleConfigField {
154                key: "endpoint".to_owned(),
155                field_type: ModuleConfigFieldType::String,
156                required: true,
157                scope: ModuleConfigScope::Service,
158                sensitive: false,
159                secret_reference: false,
160                mutability: ModuleConfigMutability::Reloadable,
161                activation: ModuleConfigActivation::Restart,
162                read_capability: Some("support.endpoint.read".to_owned()),
163                write_capability: Some("support.endpoint.write".to_owned()),
164                default: None,
165                validation: None,
166            }],
167        })
168        .console(vec![ConsoleSurface {
169            name: "support".to_owned(),
170            label: "Support".to_owned(),
171            route: "/data/support/tickets".to_owned(),
172            presentation: ConsoleSurfacePresentation::Esm {
173                entry: "support".to_owned(),
174            },
175            icon: Some("inbox".to_owned()),
176            required_capabilities: vec!["support.console.read".to_owned()],
177            navigation: None,
178        }])
179        .build();
180    let base_manifest = ModuleManifest::builder("acme/support-console").build();
181    let mut release = ModuleRelease::new(
182        "acme/support-console",
183        "1.2.3",
184        base_manifest,
185        ModuleDelivery::Linked(LinkedModuleDelivery {
186            package: "acme-support-console".to_owned(),
187            crate_version: "1.2.3".to_owned(),
188            archive_checksum: digest("a"),
189            default_features: true,
190            features: Vec::new(),
191            binding: "support_console".to_owned(),
192            attestations: Vec::new(),
193            migrations: Vec::new(),
194        }),
195    )
196    .expect("sample Console Module Release base is valid");
197    release.manifest = manifest.clone();
198    release.manifest_digest =
199        crate::digest_json(&manifest).expect("sample Console Manifest is digestible");
200    release.compatibility.host_api_requirement = Some("^1.0.0".to_owned());
201    release.compatibility.console_ui_requirement = Some("^2.0.0".to_owned());
202    release.console_ui_artifact = Some(ConsoleUiArtifact {
203        artifact: ArtifactReference {
204            locator: "oci://registry.example/acme/support-console-ui@sha256:bbbb".to_owned(),
205            digest: digest("b"),
206        },
207        format: ConsoleUiArtifactFormat::Esm,
208        protocol_major: CONSOLE_MODULE_PROTOCOL_MAJOR,
209        entry: "assets/support.js".to_owned(),
210        entries: vec![
211            ConsoleUiArtifactEntry {
212                name: "support".to_owned(),
213                path: "assets/support.js".to_owned(),
214            },
215            ConsoleUiArtifactEntry {
216                name: "support-style".to_owned(),
217                path: "assets/support.css".to_owned(),
218            },
219        ],
220        style_assets: vec![ConsoleUiArtifactStyleAsset {
221            path: "assets/support.css".to_owned(),
222            order: Some(0),
223            media: None,
224        }],
225        manifest: manifest.console_module_manifest("^1.0.0", "^2.0.0"),
226        requested_permissions: Vec::new(),
227        provenance: Vec::new(),
228    });
229    assert!(
230        release.validate().is_empty(),
231        "sample release must validate"
232    );
233    release
234}
235
236fn negative_release(
237    id: &str,
238    failure: &str,
239    valid: &Value,
240    mutate: impl FnOnce(&mut Value),
241) -> Value {
242    let mut release = valid.clone();
243    mutate(&mut release);
244    json!({ "id": id, "expected": "rejected", "failure": failure, "release": release })
245}
246
247fn digest(value: &str) -> String {
248    format!("sha256:{}", value.repeat(64))
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn console_contract_vectors_have_one_valid_and_only_invalid_negative_releases() {
257        let vectors = console_contract_vectors();
258        let positive: ModuleRelease =
259            serde_json::from_value(vectors["positive"]["release"].clone())
260                .expect("positive Console vector should deserialize");
261        assert!(positive.validate().is_empty());
262
263        let negatives = vectors["negative"]
264            .as_array()
265            .expect("negative Console vectors should be an array");
266        assert!(negatives.len() >= 7);
267        for vector in negatives {
268            let release: ModuleRelease = serde_json::from_value(vector["release"].clone())
269                .expect("negative Console vector should deserialize");
270            assert!(
271                !release.validate().is_empty(),
272                "negative vector {} unexpectedly validated",
273                vector["id"]
274            );
275        }
276    }
277}
278
279fn generated_module_schema<T: JsonSchema>(protocol: &str, title: &str) -> Value {
280    let mut schema = serde_json::to_value(schemars::schema_for!(T))
281        .expect("generated Module schema must serialize");
282    let object = schema
283        .as_object_mut()
284        .expect("generated Module schema root must be an object");
285    object.insert(
286        "$id".to_owned(),
287        Value::String(format!(
288            "https://contracts.lenso.local/modules/{protocol}.schema.json"
289        )),
290    );
291    object.insert("title".to_owned(), Value::String(title.to_owned()));
292    tighten_module_schema(&mut schema, protocol);
293    schema
294}
295
296fn tighten_module_schema(schema: &mut Value, protocol: &str) {
297    if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
298        properties.insert(
299            "protocol".to_owned(),
300            json!({ "type": "string", "const": protocol }),
301        );
302        if let Some(module_id) = properties.get_mut("module_id") {
303            *module_id = json!({ "type": "string", "pattern": MODULE_ID_PATTERN });
304        }
305        if let Some(manifest_digest) = properties.get_mut("manifest_digest") {
306            *manifest_digest = json!({ "type": "string", "pattern": SHA256_PATTERN });
307        }
308    }
309    if let Some(manifest) = schema
310        .get_mut("$defs")
311        .and_then(|defs| defs.get_mut("ModuleManifest"))
312        .and_then(Value::as_object_mut)
313        .and_then(|manifest| manifest.get_mut("properties"))
314        .and_then(Value::as_object_mut)
315    {
316        manifest.insert(
317            "protocol".to_owned(),
318            json!({ "type": "string", "const": MODULE_MANIFEST_PROTOCOL }),
319        );
320        manifest.insert(
321            "module_id".to_owned(),
322            json!({ "type": "string", "pattern": MODULE_ID_PATTERN }),
323        );
324    }
325}