1use std::collections::BTreeSet;
9use std::fmt;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13use crate::{AppManifest, AppUiKind};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum HostFeature {
18 UiSurfaces,
19 UiWidget,
20 UiComposition,
21 UiWidgetData,
22}
23
24impl HostFeature {
25 pub const ALL: [HostFeature; 4] = [
26 HostFeature::UiSurfaces,
27 HostFeature::UiWidget,
28 HostFeature::UiComposition,
29 HostFeature::UiWidgetData,
30 ];
31
32 pub fn id(self) -> &'static str {
34 match self {
35 HostFeature::UiSurfaces => "ui.surfaces",
36 HostFeature::UiWidget => "ui.widget",
37 HostFeature::UiComposition => "ui.composition",
38 HostFeature::UiWidgetData => "ui.widget-data",
39 }
40 }
41
42 pub fn from_id(id: &str) -> Option<HostFeature> {
43 HostFeature::ALL.into_iter().find(|feature| feature.id() == id)
44 }
45
46 pub fn unlocks(self) -> &'static str {
47 match self {
48 HostFeature::UiSurfaces => "`ui.surfaces` shell-chrome contributions",
49 HostFeature::UiWidget => "`ui.kind: \"widget\"` UIs composed by a parent stage",
50 HostFeature::UiComposition => "`ui.composes` and the composition input/event port",
51 HostFeature::UiWidgetData => "`ui.data` app-data declarations on a widget",
52 }
53 }
54
55 pub fn derived_when(self) -> &'static str {
56 match self {
57 HostFeature::UiSurfaces => "`ui.surfaces` is non-empty",
58 HostFeature::UiWidget => "`ui.kind` is `widget`",
59 HostFeature::UiComposition => "`ui.composes` is non-empty",
60 HostFeature::UiWidgetData => "`ui.kind` is `widget` and `ui.data` is present",
61 }
62 }
63
64 pub fn since(self) -> HostVersion {
66 match self {
67 HostFeature::UiSurfaces => HostVersion::new(7, 0, 11),
68 HostFeature::UiWidget => HostVersion::new(7, 1, 11),
69 HostFeature::UiComposition => HostVersion::new(7, 1, 11),
70 HostFeature::UiWidgetData => HostVersion::new(7, 2, 0),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct HostVersion {
79 pub major: u32,
80 pub minor: u32,
81 pub patch: u32,
82}
83
84impl HostVersion {
85 pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
86 HostVersion { major, minor, patch }
87 }
88
89 pub fn parse(value: &str) -> Option<HostVersion> {
91 let value = value.strip_prefix('v').unwrap_or(value);
92 let core = value.split(['+', '-', '~']).next()?;
93 let mut parts = core.split('.');
94 let major = parts.next()?.parse().ok()?;
95 let minor = parts.next()?.parse().ok()?;
96 let patch = parts.next()?.parse().ok()?;
97 if parts.next().is_some() {
98 return None;
99 }
100 Some(HostVersion::new(major, minor, patch))
101 }
102}
103
104impl fmt::Display for HostVersion {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
107 }
108}
109
110impl Serialize for HostVersion {
111 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
112 serializer.collect_str(self)
113 }
114}
115
116impl<'de> Deserialize<'de> for HostVersion {
117 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
118 let raw = String::deserialize(deserializer)?;
119 HostVersion::parse(&raw)
120 .ok_or_else(|| serde::de::Error::custom(format!("invalid host version '{raw}'")))
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct HostFeatureSet(BTreeSet<HostFeature>);
127
128impl HostFeatureSet {
129 pub fn current() -> Self {
131 HostFeatureSet(HostFeature::ALL.into_iter().collect())
132 }
133
134 pub fn at(version: HostVersion) -> Self {
136 HostFeatureSet(
137 HostFeature::ALL
138 .into_iter()
139 .filter(|feature| feature.since() <= version)
140 .collect(),
141 )
142 }
143
144 pub fn contains(&self, feature: HostFeature) -> bool {
145 self.0.contains(&feature)
146 }
147
148 pub fn ids(&self) -> Vec<&'static str> {
149 self.0.iter().map(|feature| feature.id()).collect()
150 }
151}
152
153pub fn required_features(manifest: &AppManifest) -> BTreeSet<HostFeature> {
155 let mut features = BTreeSet::new();
156 let Some(ui) = &manifest.ui else {
157 return features;
158 };
159 if ui.kind == AppUiKind::Widget {
160 features.insert(HostFeature::UiWidget);
161 if ui.data.is_some() {
162 features.insert(HostFeature::UiWidgetData);
163 }
164 }
165 if !ui.composes.is_empty() {
166 features.insert(HostFeature::UiComposition);
167 }
168 if !ui.surfaces.is_empty() {
169 features.insert(HostFeature::UiSurfaces);
170 }
171 features
172}
173
174pub fn min_host_for(features: &BTreeSet<HostFeature>) -> Option<HostVersion> {
176 features.iter().map(|feature| feature.since()).max()
177}
178
179pub fn render_host_feature_table() -> String {
181 let mut table = String::from(
182 "| Feature | Unlocks | Derived when | First host |\n|---|---|---|---|\n",
183 );
184 for feature in HostFeature::ALL {
185 table.push_str(&format!(
186 "| `{}` | {} | {} | {} |\n",
187 feature.id(),
188 feature.unlocks(),
189 feature.derived_when(),
190 feature.since()
191 ));
192 }
193 table
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct HostContractStamp {
204 pub features: Vec<String>,
206 #[serde(default, deserialize_with = "lenient_min_host", skip_serializing_if = "Option::is_none")]
207 pub min_host: Option<HostVersion>,
208}
209
210impl HostContractStamp {
211 pub fn same_contract(&self, other: &HostContractStamp) -> bool {
213 let mine: BTreeSet<&str> = self.features.iter().map(String::as_str).collect();
214 let theirs: BTreeSet<&str> = other.features.iter().map(String::as_str).collect();
215 mine == theirs && self.min_host == other.min_host
216 }
217}
218
219fn lenient_min_host<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<HostVersion>, D::Error> {
220 let raw = serde_json::Value::deserialize(deserializer)?;
221 Ok(raw.as_str().and_then(HostVersion::parse))
222}
223
224pub fn stamp_for(manifest: &AppManifest) -> HostContractStamp {
226 let features = required_features(manifest);
227 let mut ids: Vec<String> = features.iter().map(|feature| feature.id().to_string()).collect();
228 ids.sort();
229 HostContractStamp {
230 features: ids,
231 min_host: min_host_for(&features),
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum ManifestRejection {
238 HostFeatureMissing {
240 features: Vec<String>,
241 min_host: Option<HostVersion>,
242 },
243 Invalid { message: String },
245}
246
247impl fmt::Display for ManifestRejection {
248 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249 match self {
250 ManifestRejection::HostFeatureMissing { features, min_host } => {
251 write!(
252 f,
253 "requires host features this Node does not support: {}",
254 features.join(", ")
255 )?;
256 if let Some(version) = min_host {
257 write!(f, " (first available in Node {version})")?;
258 }
259 Ok(())
260 }
261 ManifestRejection::Invalid { message } => f.write_str(message),
262 }
263 }
264}
265
266pub fn precheck_stamp(json: &str, supported: &HostFeatureSet) -> Result<(), ManifestRejection> {
274 let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
275 return Ok(());
276 };
277 let Some(features) = value
278 .get("host_contract")
279 .and_then(|stamp| stamp.get("features"))
280 .and_then(serde_json::Value::as_array)
281 else {
282 return Ok(());
283 };
284 let missing: Vec<String> = features
285 .iter()
286 .filter_map(serde_json::Value::as_str)
287 .filter(|id| !HostFeature::from_id(id).is_some_and(|feature| supported.contains(feature)))
288 .map(str::to_string)
289 .collect();
290 if missing.is_empty() {
291 return Ok(());
292 }
293 let min_host = value["host_contract"]
294 .get("min_host")
295 .and_then(serde_json::Value::as_str)
296 .and_then(HostVersion::parse);
297 Err(ManifestRejection::HostFeatureMissing { features: missing, min_host })
298}
299
300pub fn check_host_contract(
302 manifest: &AppManifest,
303 supported: &HostFeatureSet,
304) -> Result<(), ManifestRejection> {
305 let derived = required_features(manifest);
306 let missing: BTreeSet<HostFeature> = derived
307 .iter()
308 .copied()
309 .filter(|feature| !supported.contains(*feature))
310 .collect();
311 if !missing.is_empty() {
312 let mut features: Vec<String> = missing.iter().map(|feature| feature.id().to_string()).collect();
313 features.sort();
314 return Err(ManifestRejection::HostFeatureMissing {
315 features,
316 min_host: min_host_for(&missing),
317 });
318 }
319 if let Some(stamp) = &manifest.host_contract {
320 let unstamped: Vec<&str> = derived
321 .iter()
322 .map(|feature| feature.id())
323 .filter(|id| !stamp.features.iter().any(|stamped| stamped == id))
324 .collect();
325 if !unstamped.is_empty() {
326 return Err(ManifestRejection::Invalid {
327 message: format!(
328 "host_contract is stale: missing {}; run `node-app contract stamp`",
329 unstamped.join(", ")
330 ),
331 });
332 }
333 if let (Some(required), stamped) = (min_host_for(&derived), stamp.min_host) {
334 if stamped.is_none_or(|version| version < required) {
335 return Err(ManifestRejection::Invalid {
336 message: format!(
337 "host_contract is stale: min_host must be at least {required}; run `node-app contract stamp`"
338 ),
339 });
340 }
341 }
342 }
343 Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use crate::AppManifest;
350
351 const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
352
353 fn manifest(ui: &str) -> AppManifest {
354 AppManifest::from_json(&format!(
355 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{ui}}}"#
356 ))
357 .expect("valid manifest")
358 }
359
360 fn ui(kind: &str, extra: &str) -> String {
361 format!(
362 r#"{{"kind":"{kind}","entry":"ui/main.js","title":"Demo","ui_api":1,"integrity":{{"ui/main.js":"{HASH}"}}{extra}}}"#
363 )
364 }
365
366 #[test]
367 fn plain_stage_requires_nothing() {
368 assert!(required_features(&manifest(&ui("stage", ""))).is_empty());
369 }
370
371 #[test]
372 fn widget_requires_ui_widget() {
373 let features = required_features(&manifest(&ui("widget", "")));
374 assert_eq!(features.into_iter().collect::<Vec<_>>(), vec![HostFeature::UiWidget]);
375 }
376
377 #[test]
378 fn widget_with_data_requires_widget_data() {
379 let mut m = manifest(&ui("widget", ""));
380 m.ui.as_mut().unwrap().data = Some(
381 serde_json::from_str(
382 r#"{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#,
383 )
384 .unwrap(),
385 );
386 let features: Vec<_> = required_features(&m).into_iter().collect();
387 assert_eq!(features, vec![HostFeature::UiWidget, HostFeature::UiWidgetData]);
388 }
389
390 #[test]
391 fn composing_stage_requires_composition() {
392 let features: Vec<_> = required_features(&manifest(&ui("stage", r#","composes":["child"]"#)))
393 .into_iter()
394 .collect();
395 assert_eq!(features, vec![HostFeature::UiComposition]);
396 }
397
398 #[test]
399 fn ids_round_trip_and_are_stable() {
400 let ids: Vec<_> = HostFeature::ALL.iter().map(|f| f.id()).collect();
401 assert_eq!(ids, vec!["ui.surfaces", "ui.widget", "ui.composition", "ui.widget-data"]);
402 for feature in HostFeature::ALL {
403 assert_eq!(HostFeature::from_id(feature.id()), Some(feature));
404 }
405 assert_eq!(HostFeature::from_id("ui.future"), None);
406 }
407
408 #[test]
409 fn feature_sets_follow_release_history() {
410 let old = HostFeatureSet::at(HostVersion::new(7, 1, 17));
411 assert!(old.contains(HostFeature::UiWidget));
412 assert!(old.contains(HostFeature::UiComposition));
413 assert!(!old.contains(HostFeature::UiWidgetData));
414 assert!(HostFeatureSet::at(HostVersion::new(7, 2, 0)).contains(HostFeature::UiWidgetData));
415 assert!(!HostFeatureSet::at(HostVersion::new(7, 0, 10)).contains(HostFeature::UiSurfaces));
416 assert_eq!(HostFeatureSet::current().ids().len(), HostFeature::ALL.len());
417 }
418
419 #[test]
420 fn host_version_parses_tags_and_package_suffixes() {
421 assert_eq!(HostVersion::parse("7.2.0"), Some(HostVersion::new(7, 2, 0)));
422 assert_eq!(HostVersion::parse("v7.1.17"), Some(HostVersion::new(7, 1, 17)));
423 assert_eq!(HostVersion::parse("7.1.17+composed.abc"), Some(HostVersion::new(7, 1, 17)));
424 assert_eq!(HostVersion::parse("7.1"), None);
425 assert_eq!(HostVersion::parse("seven"), None);
426 assert_eq!(HostVersion::new(7, 2, 0).to_string(), "7.2.0");
427 }
428
429 #[test]
430 fn min_host_is_the_latest_first_release() {
431 let set: BTreeSet<_> = [HostFeature::UiWidget, HostFeature::UiWidgetData].into_iter().collect();
432 assert_eq!(min_host_for(&set), Some(HostVersion::new(7, 2, 0)));
433 assert_eq!(min_host_for(&BTreeSet::new()), None);
434 }
435
436 #[test]
437 fn feature_table_lists_every_feature() {
438 let table = render_host_feature_table();
439 for feature in HostFeature::ALL {
440 assert!(table.contains(&format!("`{}`", feature.id())), "{table}");
441 }
442 }
443
444 const DATA: &str = r#","data":{"namespace":"demo","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#;
445
446 #[test]
447 fn widget_may_declare_app_data() {
448 let m = manifest(&ui("widget", DATA));
449 assert!(m.ui.unwrap().data.is_some());
450 }
451
452 #[test]
453 fn widget_app_data_gets_stage_rules() {
454 let error = AppManifest::from_json(&format!(
455 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
456 ui("widget", r#","data":{"namespace":"other","offline":"online-only","sync":"snapshot","queries":[],"streams":[]}"#)
457 ))
458 .expect_err("namespace must equal the app name");
459 assert!(error.contains("must equal the app name"), "{error}");
460 }
461
462 #[test]
463 fn widget_nav_is_still_rejected() {
464 let error = AppManifest::from_json(&format!(
465 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","ui":{}}}"#,
466 ui("widget", r#","nav":{"section":"default","order":1}"#)
467 ))
468 .expect_err("widgets have no nav");
469 assert!(error.contains("nav"), "{error}");
470 }
471
472 #[test]
473 fn stamp_is_sorted_derived_features_with_min_host() {
474 let stamp = stamp_for(&manifest(&ui("widget", DATA)));
475 assert_eq!(stamp.features, vec!["ui.widget", "ui.widget-data"]);
476 assert_eq!(stamp.min_host, Some(HostVersion::new(7, 2, 0)));
477 assert_eq!(
478 serde_json::to_string(&stamp).unwrap(),
479 r#"{"features":["ui.widget","ui.widget-data"],"min_host":"7.2.0"}"#
480 );
481 let empty = stamp_for(&manifest(&ui("stage", "")));
482 assert_eq!(serde_json::to_string(&empty).unwrap(), r#"{"features":[]}"#);
483 }
484
485 #[test]
486 fn check_accepts_absent_and_fresh_stamps() {
487 let m = manifest(&ui("widget", DATA));
488 assert_eq!(check_host_contract(&m, &HostFeatureSet::current()), Ok(()));
489 let mut stamped = m.clone();
490 stamped.host_contract = Some(stamp_for(&m));
491 assert_eq!(check_host_contract(&stamped, &HostFeatureSet::current()), Ok(()));
492 }
493
494 #[test]
495 fn check_rejects_a_stale_stamp() {
496 let mut m = manifest(&ui("widget", DATA));
497 m.host_contract = Some(HostContractStamp {
498 features: vec!["ui.widget".into()],
499 min_host: Some(HostVersion::new(7, 1, 11)),
500 });
501 match check_host_contract(&m, &HostFeatureSet::current()) {
502 Err(ManifestRejection::Invalid { message }) => {
503 assert!(message.contains("stale"), "{message}");
504 assert!(message.contains("ui.widget-data"), "{message}");
505 }
506 other => panic!("expected stale stamp rejection, got {other:?}"),
507 }
508 }
509
510 #[test]
511 fn check_reports_missing_features_for_an_older_target() {
512 let m = manifest(&ui("widget", DATA));
513 assert_eq!(
514 check_host_contract(&m, &HostFeatureSet::at(HostVersion::new(7, 1, 17))),
515 Err(ManifestRejection::HostFeatureMissing {
516 features: vec!["ui.widget-data".into()],
517 min_host: Some(HostVersion::new(7, 2, 0)),
518 })
519 );
520 }
521
522 #[test]
523 fn precheck_reports_unknown_stamped_feature_with_stamped_min_host() {
524 let json = format!(
525 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"9.0.0"}},"ui":{}}}"#,
526 ui("widget", "")
527 );
528 assert_eq!(
529 precheck_stamp(&json, &HostFeatureSet::current()),
530 Err(ManifestRejection::HostFeatureMissing {
531 features: vec!["ui.future".into()],
532 min_host: Some(HostVersion::new(9, 0, 0)),
533 })
534 );
535 }
536
537 #[test]
538 fn precheck_tolerates_a_newer_stamp_shape_and_the_manifest_still_parses() {
539 let json = format!(
540 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future"],"min_host":"9.0.0","extra":1}},"ui":{}}}"#,
541 ui("widget", "")
542 );
543 assert_eq!(
544 precheck_stamp(&json, &HostFeatureSet::current()),
545 Err(ManifestRejection::HostFeatureMissing {
546 features: vec!["ui.future".into()],
547 min_host: Some(HostVersion::new(9, 0, 0)),
548 })
549 );
550 let manifest = AppManifest::from_json(&json).expect("unknown stamp members are tolerated");
551 assert_eq!(manifest.host_contract.unwrap().min_host, Some(HostVersion::new(9, 0, 0)));
552 }
553
554 #[test]
555 fn unparseable_stamped_min_host_reads_as_absent() {
556 let json = format!(
557 r#"{{"name":"demo","version":"1.0.0","app_type":"bun","host_contract":{{"features":["ui.future","ui.widget"],"min_host":"nine"}},"ui":{}}}"#,
558 ui("widget", "")
559 );
560 assert_eq!(
561 precheck_stamp(&json, &HostFeatureSet::current()),
562 Err(ManifestRejection::HostFeatureMissing { features: vec!["ui.future".into()], min_host: None })
563 );
564 let manifest = AppManifest::from_json(&json).expect("an unparseable min_host is tolerated");
565 let stamp = manifest.host_contract.clone().unwrap();
566 assert_eq!(stamp.min_host, None);
567 assert_eq!(serde_json::to_string(&stamp).unwrap(), r#"{"features":["ui.future","ui.widget"]}"#);
568 let mut widget = manifest.clone();
570 widget.host_contract = Some(HostContractStamp { features: vec!["ui.widget".into()], min_host: None });
571 assert!(matches!(
572 check_host_contract(&widget, &HostFeatureSet::current()),
573 Err(ManifestRejection::Invalid { .. })
574 ));
575 }
576
577 #[test]
578 fn stamp_features_are_sorted_by_id_and_compared_as_sets() {
579 let m = manifest(&ui("widget", &format!(r#"{DATA},"composes":["child"]"#)));
580 let stamp = stamp_for(&m);
581 assert_eq!(stamp.features, vec!["ui.composition", "ui.widget", "ui.widget-data"]);
582 let reordered = HostContractStamp {
583 features: vec!["ui.widget-data".into(), "ui.composition".into(), "ui.widget".into()],
584 min_host: stamp.min_host,
585 };
586 assert!(stamp.same_contract(&reordered));
587 let mut stamped = m.clone();
588 stamped.host_contract = Some(reordered);
589 assert_eq!(check_host_contract(&stamped, &HostFeatureSet::current()), Ok(()));
590 let fewer = HostContractStamp { features: vec!["ui.widget".into()], min_host: stamp.min_host };
591 assert!(!stamp.same_contract(&fewer));
592 }
593
594 #[test]
595 fn precheck_ignores_absent_or_malformed_stamps() {
596 assert_eq!(precheck_stamp(r#"{"name":"demo"}"#, &HostFeatureSet::current()), Ok(()));
597 assert_eq!(precheck_stamp(r#"{"host_contract":7}"#, &HostFeatureSet::current()), Ok(()));
598 assert_eq!(precheck_stamp("not json", &HostFeatureSet::current()), Ok(()));
599 }
600
601 #[test]
602 fn rejection_messages_are_actionable() {
603 let missing = ManifestRejection::HostFeatureMissing {
604 features: vec!["ui.widget-data".into()],
605 min_host: Some(HostVersion::new(7, 2, 0)),
606 };
607 assert_eq!(
608 missing.to_string(),
609 "requires host features this Node does not support: ui.widget-data (first available in Node 7.2.0)"
610 );
611 }
612}