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
59pub 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}