1use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AppType {
36 Native,
38 Bun,
40 Standalone,
46 #[serde(rename = "platform-runtime")]
50 PlatformRuntime,
51 #[serde(rename = "managed-v1")]
54 ManagedV1,
55 Burger,
58 #[serde(rename = "ui-only")]
66 UiOnly,
67}
68
69impl AppType {
70 pub fn as_str(self) -> &'static str {
71 match self {
72 AppType::Native => "native",
73 AppType::Bun => "bun",
74 AppType::Standalone => "standalone",
75 AppType::PlatformRuntime => "platform-runtime",
76 AppType::ManagedV1 => "managed-v1",
77 AppType::Burger => "burger",
78 AppType::UiOnly => "ui-only",
79 }
80 }
81}
82
83impl std::fmt::Display for AppType {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 write!(f, "{}", self.as_str())
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum AppTier {
96 FirstParty,
100 Optional,
104 Development,
115}
116
117impl AppTier {
118 pub fn as_str(self) -> &'static str {
119 match self {
120 AppTier::FirstParty => "first_party",
121 AppTier::Optional => "optional",
122 AppTier::Development => "development",
123 }
124 }
125}
126
127impl std::fmt::Display for AppTier {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 write!(f, "{}", self.as_str())
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "lowercase")]
139pub enum AbiVersion {
140 V1,
141}
142
143impl AbiVersion {
144 pub fn as_str(&self) -> &'static str {
145 match self {
146 AbiVersion::V1 => "v1",
147 }
148 }
149
150 pub fn is_supported(&self) -> bool {
152 matches!(self, AbiVersion::V1)
153 }
154}
155
156impl std::fmt::Display for AbiVersion {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 write!(f, "{}", self.as_str())
159 }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum HotReloadKind {
169 Supported,
171 Experimental,
181 Unsupported,
183}
184
185impl HotReloadKind {
186 pub fn default_for(app_type: AppType) -> Self {
187 match app_type {
188 AppType::Native => HotReloadKind::Experimental,
189 AppType::Bun => HotReloadKind::Supported,
190 AppType::Standalone => HotReloadKind::Unsupported,
193 AppType::PlatformRuntime => HotReloadKind::Unsupported,
194 AppType::ManagedV1 => HotReloadKind::Supported,
195 AppType::Burger => HotReloadKind::Supported,
196 AppType::UiOnly => HotReloadKind::Unsupported,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
209pub struct ManifestCapabilities {
210 #[serde(default)]
213 pub requires: Vec<String>,
214
215 #[serde(default)]
218 pub provides: Vec<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223pub struct ProvidedScope {
224 pub scope: String,
225 pub description: String,
226 pub resource_pattern: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct EndpointPolicy {
232 pub method: String,
233 pub path: String,
234 pub required_permissions: Vec<String>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ProvidedCapability {
240 #[serde(default)]
241 pub description: String,
242 #[serde(default)]
243 pub schema: Option<serde_json::Value>,
244 #[serde(
249 default = "default_discoverable",
250 skip_serializing_if = "is_discoverable"
251 )]
252 pub discoverable: bool,
253}
254
255fn default_discoverable() -> bool {
256 true
257}
258
259fn is_discoverable(value: &bool) -> bool {
260 *value
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct StandaloneConfig {
275 pub socket_path: std::path::PathBuf,
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(rename_all = "snake_case")]
281pub enum AppUiKind {
282 Stage,
283 Widget,
284}
285
286fn default_app_ui_kind() -> AppUiKind {
287 AppUiKind::Stage
288}
289
290fn default_nav_section() -> String {
291 "default".to_string()
292}
293
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct AppUiNav {
297 #[serde(default = "default_nav_section")]
298 pub section: String,
299 #[serde(default)]
300 pub order: i32,
301}
302
303pub const ASSISTANT_SLOT: &str = "assistant";
306
307pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail", ASSISTANT_SLOT];
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328pub struct AppUiSurface {
329 pub id: String,
330 pub slot: String,
331 pub entry: String,
332 pub title: String,
333 #[serde(default)]
334 pub order: i32,
335 #[serde(default)]
336 pub requires: AppUiRequirements,
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "kebab-case")]
342pub enum AppDataOfflinePolicy {
343 LastKnown,
345 OnlineOnly,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
351#[serde(rename_all = "kebab-case")]
352pub enum AppDataQueryKind {
353 Collection,
354 Detail,
355 Snapshot,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
360#[serde(rename_all = "kebab-case")]
361pub enum AppDataStreamKind {
362 Changes,
363 Events,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "kebab-case")]
369pub enum AppDataSyncKind {
370 Cursor,
371 Snapshot,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct AppDataSyncPolicy {
377 pub kind: AppDataSyncKind,
378 pub cursor_ttl_secs: Option<u32>,
379 pub full_refresh_interval_secs: Option<u32>,
380 pub retention_secs: Option<u32>,
381}
382
383impl Serialize for AppDataSyncPolicy {
384 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
385 where
386 S: serde::Serializer,
387 {
388 use serde::ser::SerializeStruct;
389
390 if self.cursor_ttl_secs.is_none()
391 && self.full_refresh_interval_secs.is_none()
392 && self.retention_secs.is_none()
393 {
394 return self.kind.serialize(serializer);
395 }
396
397 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
398 state.serialize_field("kind", &self.kind)?;
399 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
400 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
401 }
402 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
403 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
404 }
405 if let Some(retention_secs) = self.retention_secs {
406 state.serialize_field("retention_secs", &retention_secs)?;
407 }
408 state.end()
409 }
410}
411
412impl<'de> Deserialize<'de> for AppDataSyncPolicy {
413 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
414 where
415 D: serde::Deserializer<'de>,
416 {
417 #[derive(Deserialize)]
418 #[serde(deny_unknown_fields)]
419 struct ObjectPolicy {
420 kind: AppDataSyncKind,
421 #[serde(default)]
422 cursor_ttl_secs: Option<u32>,
423 #[serde(default)]
424 full_refresh_interval_secs: Option<u32>,
425 #[serde(default)]
426 retention_secs: Option<u32>,
427 }
428
429 #[derive(Deserialize)]
430 #[serde(untagged)]
431 enum WirePolicy {
432 Kind(AppDataSyncKind),
433 Object(ObjectPolicy),
434 }
435
436 match WirePolicy::deserialize(deserializer)? {
437 WirePolicy::Kind(kind) => Ok(Self {
438 kind,
439 cursor_ttl_secs: None,
440 full_refresh_interval_secs: None,
441 retention_secs: None,
442 }),
443 WirePolicy::Object(policy) => Ok(Self {
444 kind: policy.kind,
445 cursor_ttl_secs: policy.cursor_ttl_secs,
446 full_refresh_interval_secs: policy.full_refresh_interval_secs,
447 retention_secs: policy.retention_secs,
448 }),
449 }
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(deny_unknown_fields)]
456pub struct AppDataQueryDeclaration {
457 pub name: String,
458 pub capability: String,
459 pub kind: AppDataQueryKind,
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct AppDataStreamDeclaration {
466 pub name: String,
467 pub kind: AppDataStreamKind,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(deny_unknown_fields)]
473pub struct AppDataManifest {
474 pub namespace: String,
475 pub offline: AppDataOfflinePolicy,
476 pub sync: AppDataSyncPolicy,
477 #[serde(default)]
478 pub queries: Vec<AppDataQueryDeclaration>,
479 #[serde(default)]
480 pub streams: Vec<AppDataStreamDeclaration>,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
488#[serde(deny_unknown_fields)]
489pub struct AppUiRequirements {
490 #[serde(default)]
491 pub capabilities: Vec<String>,
492 #[serde(default)]
493 pub queries: Vec<String>,
494 #[serde(default)]
495 pub streams: Vec<String>,
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
501 pub commands: Vec<String>,
502}
503
504impl AppUiRequirements {
505 pub fn validate_commands(&self) -> Result<(), String> {
507 let mut seen = HashSet::new();
508 for command in &self.commands {
509 let name = command.trim();
510 let versioned = name.rsplit_once(".v").is_some_and(|(head, version)| {
511 !head.is_empty()
512 && head.chars().all(|c| {
513 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-'
514 })
515 && version.chars().next().is_some_and(|c| ('1'..='9').contains(&c))
516 && version.chars().all(|c| c.is_ascii_digit())
517 });
518 if !versioned || name != command {
519 return Err(format!("ui.requires.commands entry '{command}' must be a versioned name like 'devtools.dom.snapshot.v1'"));
520 }
521 if !seen.insert(name) {
522 return Err(format!("ui.requires.commands contains duplicate '{name}'"));
523 }
524 }
525 Ok(())
526 }
527
528 pub fn resolved(&self) -> Result<Vec<String>, String> {
532 let mut resolved = Vec::new();
533 let mut seen = HashSet::new();
534 for (values, allow_wildcard) in [
535 (&self.capabilities, true),
536 (&self.queries, false),
537 (&self.streams, false),
538 ] {
539 for value in values {
540 let requirement = value.trim();
541 if requirement.is_empty() {
542 return Err("ui.requires entries must not be blank".to_string());
543 }
544 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
545 format!("ui.requires entry '{requirement}' invalid: {error}")
546 })?;
547 if seen.insert(requirement.to_string()) {
548 resolved.push(requirement.to_string());
549 }
550 }
551 }
552 Ok(resolved)
553 }
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558pub struct AppUiManifest {
559 #[serde(default = "default_app_ui_kind")]
560 pub kind: AppUiKind,
561 pub entry: String,
562 pub title: String,
563 #[serde(default)]
564 pub icon: Option<String>,
565 #[serde(default)]
566 pub nav: Option<AppUiNav>,
567 #[serde(default)]
568 pub composes: Vec<String>,
569 #[serde(default)]
571 pub surfaces: Vec<AppUiSurface>,
572 pub ui_api: u8,
573 #[serde(default)]
574 pub integrity: BTreeMap<String, String>,
575 #[serde(default)]
579 pub description: Option<String>,
580 #[serde(default)]
583 pub keywords: Vec<String>,
584 #[serde(default)]
587 pub requires: AppUiRequirements,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub data: Option<AppDataManifest>,
590}
591
592pub fn validate_manifest_path(path: &str) -> Result<(), String> {
604 if path.is_empty() {
605 return Err("path must not be empty".to_string());
606 }
607
608 if path.starts_with('/') {
610 return Err(format!(
611 "path '{}' must not be absolute (starts with /)",
612 path
613 ));
614 }
615
616 let first = path.chars().next().unwrap();
619 if !first.is_ascii_alphanumeric() && first != '_' {
620 return Err(format!(
621 "path '{}' must begin with an alphanumeric character or underscore",
622 path
623 ));
624 }
625 for ch in path.chars().skip(1) {
626 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
627 return Err(format!(
628 "path '{}' contains disallowed character '{}'",
629 path, ch
630 ));
631 }
632 }
633
634 for segment in path.split('/') {
636 if segment == ".." {
637 return Err(format!(
638 "path '{}' contains a '..' segment (path traversal rejected)",
639 path
640 ));
641 }
642 }
643
644 Ok(())
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize)]
655pub struct AppManifest {
656 #[serde(default = "default_manifest_version", rename = "manifest_version")]
658 pub manifest_version: u8,
659
660 pub name: String,
661 pub version: String,
662
663 #[serde(default = "default_app_type_native")]
664 pub app_type: AppType,
665
666 #[serde(default)]
667 pub description: String,
668
669 pub abi: Option<AbiVersion>,
672
673 pub entrypoint: Option<String>,
676
677 pub hot_reload: Option<HotReloadKind>,
680
681 #[serde(default)]
683 pub critical: bool,
684
685 #[serde(
686 default = "default_auto_start",
687 deserialize_with = "deserialize_auto_start"
688 )]
689 pub auto_start: bool,
690
691 #[serde(default)]
692 pub has_ui: bool,
693
694 #[serde(default = "default_ui_path")]
695 pub ui_path: String,
696
697 #[serde(default)]
698 pub permissions: Vec<String>,
699
700 #[serde(default)]
703 pub requires: Vec<String>,
704
705 #[serde(default)]
706 pub optional_permissions: Vec<String>,
707
708 #[serde(default)]
709 pub provides_scopes: Vec<ProvidedScope>,
710
711 #[serde(default)]
712 pub endpoint_policies: Vec<EndpointPolicy>,
713
714 #[serde(default)]
715 pub capability_scopes: HashMap<String, String>,
716
717 #[serde(default)]
718 pub provides: HashMap<String, ProvidedCapability>,
719
720 #[serde(default)]
722 pub capabilities: ManifestCapabilities,
723
724 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub ui: Option<AppUiManifest>,
728
729 #[serde(default)]
731 pub author: Option<String>,
732
733 #[serde(default)]
734 pub homepage: Option<String>,
735
736 #[serde(default)]
737 pub depends_on: Option<Vec<String>>,
738
739 #[serde(default)]
740 pub boot_priority: Option<u32>,
741
742 #[serde(default)]
746 pub governor: Option<GovernorManifest>,
747
748 #[serde(default)]
754 pub subscribes: Vec<String>,
755
756 #[serde(default)]
760 pub standalone: Option<StandaloneConfig>,
761
762 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub tcp: Option<TcpManifest>,
766
767 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub resources: Option<ResourcesManifest>,
771
772 #[serde(default, skip_serializing_if = "Vec::is_empty")]
779 pub schedules: Vec<AppScheduleManifest>,
780
781 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub host_contract: Option<crate::HostContractStamp>,
786}
787
788pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
790
791#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
797pub struct ResourcesManifest {
798 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub memory_mb: Option<u32>,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub callback_deadline_ms: Option<u32>,
802}
803
804#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
810#[serde(rename_all = "lowercase")]
811pub enum LatencyClass {
812 Interactive,
815 Background,
818}
819
820impl LatencyClass {
821 pub fn as_str(&self) -> &'static str {
822 match self {
823 LatencyClass::Interactive => "interactive",
824 LatencyClass::Background => "background",
825 }
826 }
827
828 #[allow(clippy::should_implement_trait)]
829 pub fn from_str(s: &str) -> Result<Self, String> {
830 match s {
831 "interactive" => Ok(LatencyClass::Interactive),
832 "background" => Ok(LatencyClass::Background),
833 _ => Err(format!("Invalid LatencyClass: {}", s)),
834 }
835 }
836}
837
838impl std::fmt::Display for LatencyClass {
839 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
840 write!(f, "{}", self.as_str())
841 }
842}
843
844#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
847pub struct GovernorManifest {
848 #[serde(default)]
852 pub terminable: Option<bool>,
853
854 #[serde(default)]
858 pub min_idle_secs: Option<u64>,
859
860 #[serde(default)]
893 pub memory_budget_kb: Option<u64>,
894
895 #[serde(default)]
900 pub latency_class: Option<LatencyClass>,
901}
902
903#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
907pub struct TcpManifest {
908 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub preferred_port: Option<u16>,
913
914 #[serde(default, skip_serializing_if = "Option::is_none")]
921 pub direct_bind: Option<bool>,
922}
923
924#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941pub struct AppScheduleManifest {
942 pub id: String,
949
950 pub cron: String,
959
960 pub capability: String,
968
969 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub payload: Option<serde_json::Value>,
974}
975
976impl AppScheduleManifest {
977 pub fn effective_payload(&self) -> serde_json::Value {
979 self.payload
980 .clone()
981 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
982 }
983}
984
985fn cron_field_char_allowed(ch: char) -> bool {
990 ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
991}
992
993fn validate_cron_shape(expression: &str) -> Result<(), String> {
996 let fields: Vec<&str> = expression.split_whitespace().collect();
997 if fields.len() != 6 {
998 return Err(format!(
999 "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
1000 expression,
1001 fields.len()
1002 ));
1003 }
1004 for field in fields {
1005 if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
1006 return Err(format!(
1007 "cron '{}' contains disallowed character '{}'",
1008 expression, ch
1009 ));
1010 }
1011 }
1012 Ok(())
1013}
1014
1015fn validate_schedule_id(id: &str) -> Result<(), String> {
1018 if id.is_empty() {
1019 return Err("schedule id must not be empty".to_string());
1020 }
1021 let first = id.chars().next().unwrap();
1022 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
1023 return Err(format!(
1024 "schedule id '{}' must begin with a lowercase letter or digit",
1025 id
1026 ));
1027 }
1028 for ch in id.chars() {
1029 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
1030 return Err(format!(
1031 "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
1032 id, ch
1033 ));
1034 }
1035 }
1036 Ok(())
1037}
1038
1039fn default_manifest_version() -> u8 {
1040 1
1041}
1042
1043fn default_auto_start() -> bool {
1044 true
1045}
1046
1047fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
1053where
1054 D: serde::Deserializer<'de>,
1055{
1056 #[derive(Deserialize)]
1057 #[serde(untagged)]
1058 enum BoolOrStr {
1059 Bool(bool),
1060 Str(String),
1061 }
1062 Ok(match BoolOrStr::deserialize(deserializer)? {
1063 BoolOrStr::Bool(b) => b,
1064 BoolOrStr::Str(s) => matches!(
1065 s.trim().to_ascii_lowercase().as_str(),
1066 "true" | "eager" | "active" | "auto" | "on" | "1"
1067 ),
1068 })
1069}
1070
1071fn default_ui_path() -> String {
1072 "dist".to_string()
1073}
1074
1075fn default_app_type_native() -> AppType {
1076 AppType::Native
1077}
1078
1079impl AppManifest {
1080 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1084 let top = normalized_requirements(&self.requires)?;
1085 let nested = normalized_requirements(&self.capabilities.requires)?;
1086 if !top.is_empty()
1087 && !nested.is_empty()
1088 && top.iter().cloned().collect::<BTreeSet<_>>()
1089 != nested.iter().cloned().collect::<BTreeSet<_>>()
1090 {
1091 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1092 }
1093 Ok(if !top.is_empty() { top } else { nested })
1094 }
1095
1096 pub fn effective_hot_reload(&self) -> HotReloadKind {
1099 self.hot_reload
1100 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1101 }
1102
1103 pub fn effective_entrypoint(&self) -> &str {
1109 if let Some(ref ep) = self.entrypoint {
1110 ep.as_str()
1111 } else {
1112 match self.app_type {
1113 AppType::Native => "app.so",
1114 AppType::Bun => "dist/index.js",
1115 AppType::Standalone => "",
1116 AppType::PlatformRuntime => "bun",
1117 AppType::ManagedV1 => "llmc-generated-app",
1118 AppType::Burger => "dist/index.js",
1119 AppType::UiOnly => "",
1120 }
1121 }
1122 }
1123
1124 pub fn has_capability_providers(&self) -> bool {
1127 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1128 }
1129
1130 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1144 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1145 for raw in &self.capabilities.provides {
1146 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1147 if name.is_empty() {
1148 continue;
1149 }
1150 out.entry(name).or_insert(ProvidedCapability {
1151 description: String::new(),
1152 schema: None,
1153 discoverable: true,
1154 });
1155 }
1156 out
1157 }
1158
1159 fn validate_schedules(&self) -> Result<(), String> {
1169 if self.schedules.is_empty() {
1170 return Ok(());
1171 }
1172 let provided = self.resolved_capability_provides();
1173 let mut seen: HashSet<&str> = HashSet::new();
1174 for schedule in &self.schedules {
1175 validate_schedule_id(&schedule.id)?;
1176 if !seen.insert(schedule.id.as_str()) {
1177 return Err(format!("duplicate schedule id '{}'", schedule.id));
1178 }
1179 validate_cron_shape(&schedule.cron)
1180 .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1181 if schedule.capability.trim().is_empty() {
1182 return Err(format!(
1183 "schedule '{}': capability must not be blank",
1184 schedule.id
1185 ));
1186 }
1187 if !provided.contains_key(schedule.capability.trim()) {
1188 return Err(format!(
1189 "schedule '{}' names capability '{}', which this app does not provide \
1190 (a schedule may only dispatch a capability declared in this manifest's \
1191 'provides')",
1192 schedule.id, schedule.capability
1193 ));
1194 }
1195 if let Some(payload) = &schedule.payload {
1196 if !payload.is_object() {
1197 return Err(format!(
1198 "schedule '{}': payload must be a JSON object",
1199 schedule.id
1200 ));
1201 }
1202 }
1203 }
1204 Ok(())
1205 }
1206
1207 pub fn validate(&self) -> Result<(), String> {
1212 self.validate_with_socket_path_policy(false)
1213 }
1214
1215 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1220 if self.manifest_version == 2 && self.abi.is_none() {
1222 return Err("manifest_version 2 requires an 'abi' field".to_string());
1223 }
1224
1225 validate_app_name(&self.name)?;
1228 self.resolved_requires()?;
1229
1230 if let Some(ref ep) = self.entrypoint {
1232 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1233 }
1234 if !self.ui_path.is_empty() && self.ui_path != "dist" {
1236 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1237 }
1238
1239 if let Some(ui) = &self.ui {
1240 validate_app_ui(&self.name, ui)?;
1241 }
1242
1243 if let Some(ref hp) = self.homepage {
1245 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1246 return Err(format!(
1247 "homepage '{}' must use https:// or http:// scheme",
1248 hp
1249 ));
1250 }
1251 }
1252
1253 match self.app_type {
1260 AppType::Standalone => {
1261 if self.has_capability_providers() {
1262 let cfg = self.standalone.as_ref().ok_or_else(|| {
1263 "standalone apps that declare 'provides' require a \
1264 'standalone.socket_path' field"
1265 .to_string()
1266 })?;
1267 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1268 }
1269 }
1270 AppType::Native
1271 | AppType::Bun
1272 | AppType::PlatformRuntime
1273 | AppType::ManagedV1
1274 | AppType::Burger
1275 | AppType::UiOnly => {
1276 if self.standalone.is_some() {
1277 return Err(format!(
1278 "'standalone' block is only valid when app_type == 'standalone' \
1279 (found app_type='{}')",
1280 self.app_type
1281 ));
1282 }
1283 }
1284 }
1285
1286 if self.app_type == AppType::UiOnly {
1287 validate_ui_only(self)?;
1288 }
1289
1290 if let Some(resources) = &self.resources {
1291 if resources.memory_mb == Some(0) {
1292 return Err("resources.memory_mb must be at least 1".to_string());
1293 }
1294 if let Some(deadline) = resources.callback_deadline_ms {
1295 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1296 return Err(format!(
1297 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1298 ));
1299 }
1300 }
1301 }
1302
1303 self.validate_schedules()?;
1304
1305 if self.app_type == AppType::PlatformRuntime
1306 && !self.resolved_capability_provides().is_empty()
1307 {
1308 return Err(
1309 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1310 );
1311 }
1312
1313 Ok(())
1314 }
1315
1316 pub fn from_json(json: &str) -> Result<Self, String> {
1318 Self::from_json_with_socket_path_policy(json, false)
1319 }
1320
1321 pub fn from_json_with_socket_path_policy(
1323 json: &str,
1324 allow_non_run: bool,
1325 ) -> Result<Self, String> {
1326 let mut manifest: Self =
1327 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1328 if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1329 manifest.app_type = AppType::UiOnly;
1330 }
1331 if manifest.ui.is_some() {
1332 manifest.has_ui = true;
1333 }
1334 manifest.validate_with_socket_path_policy(allow_non_run)?;
1335 Ok(manifest)
1336 }
1337}
1338
1339fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1340 let mut seen = HashSet::new();
1341 let mut resolved = Vec::new();
1342 for value in values {
1343 let requirement = value.trim();
1344 if requirement.is_empty() {
1345 return Err("capability requirements must not be blank".to_string());
1346 }
1347 if seen.insert(requirement.to_string()) {
1348 resolved.push(requirement.to_string());
1349 }
1350 }
1351 Ok(resolved)
1352}
1353
1354fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1358 match manifest.ui.as_ref().map(|ui| ui.kind) {
1360 Some(AppUiKind::Stage | AppUiKind::Widget) => {}
1361 None => {
1362 return Err(
1363 "ui-only apps must declare a ui block of kind \"stage\" or \"widget\"".to_string(),
1364 )
1365 }
1366 }
1367 match ui_only_process_declaration(manifest) {
1368 Some(what) => Err(format!(
1369 "ui-only apps have no backend process and must not declare {what}"
1370 )),
1371 None => Ok(()),
1372 }
1373}
1374
1375pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1380 if manifest.entrypoint.is_some() {
1381 Some("'entrypoint'")
1382 } else if manifest.has_capability_providers() {
1383 Some("capability providers ('provides' / 'capabilities.provides')")
1384 } else if manifest.tcp.is_some() {
1385 Some("a 'tcp' block")
1386 } else if manifest.resources.is_some() {
1387 Some("a 'resources' block")
1388 } else if manifest.standalone.is_some() {
1389 Some("a 'standalone' block")
1390 } else if !manifest.subscribes.is_empty() {
1391 Some("event subscriptions ('subscribes')")
1392 } else if manifest
1393 .depends_on
1394 .as_ref()
1395 .is_some_and(|deps| !deps.is_empty())
1396 {
1397 Some("dependencies ('depends_on')")
1398 } else {
1399 None
1400 }
1401}
1402
1403pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1416 manifest.as_object().is_some_and(|object| {
1417 let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1418 && !object.contains_key("app_type")
1419 && !object.contains_key("entrypoint");
1420 derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1421 })
1422}
1423
1424const UI_FORBIDDEN_CAPABILITY_PREFIXES: &[&str] = &["graph.runtime."];
1428
1429fn refuse_forbidden_ui_requirements(
1430 label: &str,
1431 requires: &AppUiRequirements,
1432) -> Result<(), String> {
1433 for (field, values) in [
1437 ("capabilities", &requires.capabilities),
1438 ("queries", &requires.queries),
1439 ("streams", &requires.streams),
1440 ] {
1441 refuse_forbidden_ui_scopes(&format!("{label}.{field}"), values)?;
1442 }
1443 Ok(())
1444}
1445
1446fn refuse_forbidden_ui_scopes(label: &str, scopes: &[String]) -> Result<(), String> {
1447 for scope in scopes {
1448 let name = scope.trim();
1449 let wildcard_root = name
1451 .strip_suffix('*')
1452 .filter(|_| name == "*" || name.ends_with(".*"));
1453 let covered = |prefix: &str| {
1454 name.starts_with(prefix) || wildcard_root.is_some_and(|root| prefix.starts_with(root))
1455 };
1456 if let Some(prefix) = UI_FORBIDDEN_CAPABILITY_PREFIXES
1457 .iter()
1458 .find(|prefix| covered(prefix))
1459 {
1460 return Err(format!(
1461 "{label} must not request '{name}': {prefix}* is reachable only through the agent app"
1462 ));
1463 }
1464 }
1465 Ok(())
1466}
1467
1468fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1469 if ui.ui_api != 1 && ui.ui_api != 2 {
1470 return Err(format!(
1471 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1472 ui.ui_api
1473 ));
1474 }
1475 if ui.title.trim().is_empty() {
1476 return Err("ui.title must not be blank".to_string());
1477 }
1478 refuse_forbidden_ui_requirements("ui.requires", &ui.requires)?;
1479 ui.requires.resolved()?;
1480 ui.requires.validate_commands()?;
1481 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1482 if let Some(icon) = &ui.icon {
1483 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1484 }
1485 if let Some(nav) = &ui.nav {
1486 if ui.kind == AppUiKind::Widget {
1487 return Err("widget ui must omit nav metadata".to_string());
1488 }
1489 if nav.section.trim().is_empty() {
1490 return Err("ui.nav.section must not be blank".to_string());
1491 }
1492 }
1493 if let Some(data) = &ui.data {
1497 validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1498 }
1499
1500 let mut composed = HashSet::new();
1501 for name in &ui.composes {
1502 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1503 if name == app_name {
1504 return Err("ui.composes must not contain the app itself".to_string());
1505 }
1506 if !composed.insert(name) {
1507 return Err(format!("ui.composes contains duplicate app '{name}'"));
1508 }
1509 }
1510
1511 let mut surface_ids = HashSet::new();
1512 for surface in &ui.surfaces {
1513 let id = surface.id.trim();
1514 if id.is_empty() {
1515 return Err("ui.surfaces entry id must not be blank".to_string());
1516 }
1517 if !surface_ids.insert(id.to_string()) {
1518 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1519 }
1520 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1521 return Err(format!(
1522 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1523 surface.slot,
1524 KNOWN_SURFACE_SLOTS.join(", ")
1525 ));
1526 }
1527 if surface.title.trim().is_empty() {
1528 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1529 }
1530 validate_manifest_path(&surface.entry)
1531 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1532 if !ui.integrity.contains_key(&surface.entry) {
1541 return Err(format!("ui.integrity must include surface '{id}' entry"));
1542 }
1543 refuse_forbidden_ui_requirements(
1544 &format!("ui.surfaces entry '{id}' requires"),
1545 &surface.requires,
1546 )?;
1547 surface
1548 .requires
1549 .resolved()
1550 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1551 surface
1552 .requires
1553 .validate_commands()
1554 .map_err(|error| format!("ui.surfaces entry '{id}': {error}"))?;
1555 }
1556
1557 if ui
1558 .surfaces
1559 .iter()
1560 .filter(|surface| surface.slot == ASSISTANT_SLOT)
1561 .count()
1562 > 1
1563 {
1564 return Err("ui.surfaces may declare at most one 'assistant' surface".to_string());
1565 }
1566
1567 for (path, digest) in &ui.integrity {
1568 validate_manifest_path(path)
1569 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1570 if !is_lowercase_sha256(digest) {
1571 return Err(format!(
1572 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1573 ));
1574 }
1575 }
1576 if !ui.integrity.contains_key(&ui.entry) {
1577 return Err("ui.integrity must include the declared entry".to_string());
1578 }
1579 if let Some(icon) = &ui.icon {
1580 if !ui.integrity.contains_key(icon) {
1581 return Err("ui.integrity must include the declared icon".to_string());
1582 }
1583 }
1584 Ok(())
1585}
1586
1587fn validate_app_data(
1588 app_name: &str,
1589 data: &AppDataManifest,
1590 resolved_requires: &[String],
1591) -> Result<(), String> {
1592 validate_app_data_namespace(&data.namespace)?;
1593 validate_app_data_namespace_owner(app_name, &data.namespace)?;
1594 validate_app_data_sync_policy(&data.sync)?;
1595 if data.queries.is_empty() && data.offline != AppDataOfflinePolicy::OnlineOnly {
1596 return Err("ui.data.queries must declare at least one query for last-known data".to_string());
1597 }
1598
1599 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1600 let mut names = BTreeSet::new();
1601 for query in &data.queries {
1602 validate_namespaced_data_name(&query.name, &data.namespace)
1603 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1604 validate_capability_name(&query.capability).map_err(|error| {
1605 format!(
1606 "ui.data query '{}' capability '{}' invalid: {error}",
1607 query.name, query.capability
1608 )
1609 })?;
1610 if !requires.contains(query.capability.as_str()) {
1611 return Err(format!(
1612 "ui.data query '{}' capability '{}' must be declared in requires",
1613 query.name, query.capability
1614 ));
1615 }
1616 if !names.insert(query.name.as_str()) {
1617 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1618 }
1619 }
1620
1621 for stream in &data.streams {
1622 validate_namespaced_data_name(&stream.name, &data.namespace)
1623 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1624 if !names.insert(stream.name.as_str()) {
1625 return Err(format!(
1626 "ui.data contains duplicate declaration '{}'",
1627 stream.name
1628 ));
1629 }
1630 }
1631
1632 Ok(())
1633}
1634
1635fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1636 if !is_safe_name_segment(namespace) {
1637 return Err(format!(
1638 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1639 namespace
1640 ));
1641 }
1642 if matches!(
1643 namespace,
1644 "core" | "internal" | "node" | "platform" | "system"
1645 ) {
1646 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1647 }
1648 Ok(())
1649}
1650
1651pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1662 if namespace != app_name {
1663 return Err(format!(
1664 "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1665 Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1666 to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1667 ));
1668 }
1669 Ok(())
1670}
1671
1672fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1673 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1674 validate_optional_range(
1675 "full_refresh_interval_secs",
1676 sync.full_refresh_interval_secs,
1677 60,
1678 604_800,
1679 )?;
1680 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1681 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1682 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1683 }
1684 Ok(())
1685}
1686
1687fn validate_optional_range(
1688 field: &str,
1689 value: Option<u32>,
1690 min: u32,
1691 max: u32,
1692) -> Result<(), String> {
1693 if let Some(value) = value {
1694 if value < min || value > max {
1695 return Err(format!(
1696 "ui.data.sync {field} must be between {min} and {max} seconds"
1697 ));
1698 }
1699 }
1700 Ok(())
1701}
1702
1703fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1704 validate_capability_name(name)?;
1705 let Some(rest) = name
1706 .strip_prefix(namespace)
1707 .and_then(|suffix| suffix.strip_prefix('.'))
1708 else {
1709 return Err(format!("name must use namespace '{namespace}'"));
1710 };
1711 if rest.is_empty() {
1712 return Err("name must include a value after its namespace".to_string());
1713 }
1714 if !has_version_suffix(name) {
1715 return Err("name must end with a .vN version suffix".to_string());
1716 }
1717 Ok(())
1718}
1719
1720fn validate_capability_name(name: &str) -> Result<(), String> {
1721 if name.is_empty() {
1722 return Err("name must not be empty".to_string());
1723 }
1724 if name.contains('/') || name.contains("..") {
1725 return Err("name must not contain path separators or traversal".to_string());
1726 }
1727 if !name.split('.').all(is_safe_declaration_segment) {
1728 return Err("name must contain only lowercase dot-separated segments".to_string());
1729 }
1730 Ok(())
1731}
1732
1733fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1734 if allow_wildcard && name.ends_with(".*") {
1735 return validate_capability_name(&name[..name.len() - 2]);
1736 }
1737 validate_capability_name(name)
1738}
1739
1740fn has_version_suffix(name: &str) -> bool {
1741 let Some(version) = name.rsplit('.').next() else {
1742 return false;
1743 };
1744 let Some(digits) = version.strip_prefix('v') else {
1745 return false;
1746 };
1747 !digits.is_empty()
1748 && !digits.starts_with('0')
1749 && digits.bytes().all(|byte| byte.is_ascii_digit())
1750}
1751
1752fn is_safe_name_segment(segment: &str) -> bool {
1753 if segment.is_empty() {
1754 return false;
1755 }
1756 let mut chars = segment.chars();
1757 let Some(first) = chars.next() else {
1758 return false;
1759 };
1760 first.is_ascii_lowercase()
1761 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1762}
1763
1764fn is_safe_declaration_segment(segment: &str) -> bool {
1777 let mut chars = segment.chars();
1778 let Some(first) = chars.next() else {
1779 return false;
1780 };
1781 first.is_ascii_lowercase()
1782 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1783}
1784
1785fn is_lowercase_sha256(value: &str) -> bool {
1786 value.len() == 64
1787 && value
1788 .bytes()
1789 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1790}
1791
1792pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1801 validate_standalone_socket_path_with_policy(path, false)
1802}
1803
1804pub fn validate_standalone_socket_path_with_policy(
1806 path: &std::path::Path,
1807 allow_non_run: bool,
1808) -> Result<(), String> {
1809 if !path.is_absolute() {
1810 return Err(format!(
1811 "standalone.socket_path '{}' must be absolute",
1812 path.display()
1813 ));
1814 }
1815 if !allow_non_run && !path.starts_with("/run/") {
1816 return Err(format!(
1817 "standalone.socket_path '{}' must live under /run/",
1818 path.display()
1819 ));
1820 }
1821 if path
1822 .components()
1823 .any(|c| matches!(c, std::path::Component::ParentDir))
1824 {
1825 return Err(format!(
1826 "standalone.socket_path '{}' must not contain '..' segments",
1827 path.display()
1828 ));
1829 }
1830 Ok(())
1831}
1832
1833fn validate_app_name(name: &str) -> Result<(), String> {
1837 let (publisher, app) = if let Some(slash) = name.find('/') {
1838 let (p, rest) = name.split_at(slash);
1839 (Some(p), &rest[1..])
1840 } else {
1841 (None, name)
1842 };
1843
1844 let valid_segment = |s: &str| -> bool {
1845 if s.is_empty() {
1846 return false;
1847 }
1848 let mut chars = s.chars();
1849 let first = chars.next().unwrap();
1850 if !first.is_ascii_lowercase() {
1851 return false;
1852 }
1853 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1854 };
1855
1856 if let Some(pub_name) = publisher {
1857 if !valid_segment(pub_name) {
1858 return Err(format!(
1859 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1860 pub_name
1861 ));
1862 }
1863 }
1864
1865 if !valid_segment(app) {
1866 return Err(format!(
1867 "app name segment '{}' must match [a-z][a-z0-9-]*",
1868 app
1869 ));
1870 }
1871
1872 Ok(())
1873}
1874
1875#[cfg(test)]
1878mod tests {
1879 use super::*;
1880
1881 fn parse_ok(json: &str) -> AppManifest {
1882 AppManifest::from_json(json).expect("should parse")
1883 }
1884
1885 fn parse_err(json: &str) -> String {
1886 AppManifest::from_json(json).expect_err("should fail")
1887 }
1888
1889 const SCHEDULED_APP: &str = r#"{
1892 "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1893 "provides":{"network.ledger.poll":{"description":"Record connections"}},
1894 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1895 "capability":"network.ledger.poll"}]
1896 }"#;
1897
1898 #[test]
1899 fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1900 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1903 assert!(m.schedules.is_empty());
1904 }
1905
1906 #[test]
1907 fn a_schedule_is_parsed_whole() {
1908 let m = parse_ok(SCHEDULED_APP);
1909 assert_eq!(m.schedules.len(), 1);
1910 let s = &m.schedules[0];
1911 assert_eq!(s.id, "ledger-poll");
1912 assert_eq!(s.cron, "0 */15 * * * *");
1913 assert_eq!(s.capability, "network.ledger.poll");
1914 assert_eq!(s.effective_payload(), serde_json::json!({}));
1917 }
1918
1919 #[test]
1920 fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1921 let err = parse_err(
1924 r#"{"name":"network","version":"1.0.0","app_type":"bun",
1925 "provides":{"network.ledger.poll":{"description":"x"}},
1926 "schedules":[{"id":"drain","cron":"0 0 * * * *",
1927 "capability":"core.lightning.send_payment"}]}"#,
1928 );
1929 assert!(err.contains("core.lightning.send_payment"), "{err}");
1930 assert!(err.contains("does not provide"), "{err}");
1931 }
1932
1933 #[test]
1934 fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1935 let m = parse_ok(
1938 r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1939 "app_type":"bun",
1940 "capabilities":{"provides":["network.ledger.poll"]},
1941 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1942 "capability":"network.ledger.poll"}]}"#,
1943 );
1944 assert_eq!(m.schedules.len(), 1);
1945 }
1946
1947 #[test]
1948 fn a_cron_expression_without_six_fields_is_refused() {
1949 let err = parse_err(
1951 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1952 "provides":{"n.poll":{"description":"x"}},
1953 "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1954 );
1955 assert!(err.contains("6 fields"), "{err}");
1956 }
1957
1958 #[test]
1959 fn a_cron_expression_with_junk_in_it_is_refused() {
1960 let err = parse_err(
1961 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1962 "provides":{"n.poll":{"description":"x"}},
1963 "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1964 );
1965 assert!(err.contains("disallowed character"), "{err}");
1966 }
1967
1968 #[test]
1969 fn two_schedules_cannot_share_an_id() {
1970 let err = parse_err(
1973 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1974 "provides":{"n.poll":{"description":"x"}},
1975 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1976 {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1977 );
1978 assert!(err.contains("duplicate schedule id"), "{err}");
1979 }
1980
1981 #[test]
1982 fn a_schedule_id_must_be_a_safe_token() {
1983 let err = parse_err(
1984 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1985 "provides":{"n.poll":{"description":"x"}},
1986 "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1987 );
1988 assert!(err.contains("schedule id"), "{err}");
1989 }
1990
1991 #[test]
1992 fn a_non_object_payload_is_refused() {
1993 let err = parse_err(
1994 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1995 "provides":{"n.poll":{"description":"x"}},
1996 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1997 "payload":"not-an-object"}]}"#,
1998 );
1999 assert!(err.contains("JSON object"), "{err}");
2000 }
2001
2002 #[test]
2003 fn a_declared_payload_survives_the_round_trip() {
2004 let m = parse_ok(
2005 r#"{"name":"n","version":"1.0.0","app_type":"bun",
2006 "provides":{"n.poll":{"description":"x"}},
2007 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
2008 "payload":{"depth":2}}]}"#,
2009 );
2010 assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
2011 let round_tripped: AppManifest =
2012 serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
2013 assert_eq!(round_tripped.schedules, m.schedules);
2014 }
2015
2016 #[test]
2017 fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
2018 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2020 let json = serde_json::to_string(&m).unwrap();
2021 assert!(!json.contains("schedules"), "{json}");
2022 }
2023
2024 #[test]
2027 fn v1_minimal_native() {
2028 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2029 assert_eq!(m.manifest_version, 1);
2030 assert_eq!(m.app_type, AppType::Native);
2031 assert!(m.abi.is_none());
2032 }
2033
2034 #[test]
2035 fn v1_minimal_bun() {
2036 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
2037 assert_eq!(m.app_type, AppType::Bun);
2038 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2039 }
2040
2041 #[test]
2042 fn v1_no_manifest_version_field_defaults_to_1() {
2043 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
2044 assert_eq!(m.manifest_version, 1);
2045 }
2046
2047 #[test]
2048 fn v1_all_optional_fields_missing() {
2049 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
2050 assert!(!m.critical);
2051 assert!(m.auto_start);
2052 assert!(!m.has_ui);
2053 assert_eq!(m.ui_path, "dist");
2054 assert!(m.permissions.is_empty());
2055 assert!(m.optional_permissions.is_empty());
2056 assert!(m.governor.is_none());
2059 assert!(m.subscribes.is_empty());
2060 }
2061
2062 #[test]
2063 fn v1_with_permissions_and_provides() {
2064 let json = r#"{
2065 "name": "example",
2066 "version": "1.0.0",
2067 "app_type": "bun",
2068 "permissions": ["core.storage.kv"],
2069 "optional_permissions": ["core.notifications.create"],
2070 "provides": {
2071 "core.example.run": { "description": "Run example job" }
2072 }
2073 }"#;
2074 let m = parse_ok(json);
2075 assert_eq!(m.permissions, vec!["core.storage.kv"]);
2076 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
2077 assert!(m.provides.contains_key("core.example.run"));
2078 }
2079
2080 #[test]
2083 fn provides_discoverable_defaults_true_and_parses_false() {
2084 let json = r#"{
2085 "name": "remote-support",
2086 "version": "1.0.0",
2087 "app_type": "bun",
2088 "capabilities": { "provides": ["remote_support.status", "remote_support.challenge"] },
2089 "provides": {
2090 "remote_support.status": { "description": "Status" },
2091 "remote_support.mode.set": { "description": "Set mode", "discoverable": false }
2092 }
2093 }"#;
2094 let m = parse_ok(json);
2095 assert!(m.provides["remote_support.status"].discoverable);
2096 assert!(!m.provides["remote_support.mode.set"].discoverable);
2097
2098 let resolved = m.resolved_capability_provides();
2099 assert!(!resolved["remote_support.mode.set"].discoverable);
2100 assert!(resolved["remote_support.status"].discoverable);
2101 assert!(resolved["remote_support.challenge"].discoverable);
2103
2104 let written = serde_json::to_value(&m).unwrap();
2105 assert_eq!(
2106 written["provides"]["remote_support.mode.set"]["discoverable"],
2107 serde_json::json!(false)
2108 );
2109 assert!(written["provides"]["remote_support.status"]
2110 .get("discoverable")
2111 .is_none());
2112 }
2113
2114 #[test]
2115 fn provides_discoverable_must_be_a_boolean() {
2116 let json = r#"{
2117 "name": "example",
2118 "version": "1.0.0",
2119 "app_type": "bun",
2120 "provides": { "example.run": { "discoverable": "no" } }
2121 }"#;
2122 assert!(AppManifest::from_json(json).is_err());
2123 }
2124
2125 #[test]
2128 fn v2_minimal_native() {
2129 let json = r#"{
2130 "manifest_version": 2,
2131 "name": "cron",
2132 "version": "1.0.0",
2133 "app_type": "native",
2134 "abi": "v1",
2135 "entrypoint": "app.so",
2136 "hot_reload": "experimental"
2137 }"#;
2138 let m = parse_ok(json);
2139 assert_eq!(m.manifest_version, 2);
2140 assert_eq!(m.abi, Some(AbiVersion::V1));
2141 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
2142 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
2143 }
2144
2145 #[test]
2146 fn v2_minimal_bun_with_capabilities() {
2147 let json = r#"{
2148 "manifest_version": 2,
2149 "name": "example-fullstack",
2150 "version": "1.0.0",
2151 "app_type": "bun",
2152 "abi": "v1",
2153 "entrypoint": "dist/index.js",
2154 "hot_reload": "supported",
2155 "has_ui": true,
2156 "ui_path": "ui/dist",
2157 "capabilities": {
2158 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
2159 "provides": []
2160 },
2161 "governor": { "terminable": false, "min_idle_secs": 300 },
2162 "subscribes": ["core.chat.message.received"]
2163 }"#;
2164 let m = parse_ok(json);
2165 assert_eq!(m.manifest_version, 2);
2166 assert_eq!(m.capabilities.requires.len(), 2);
2167 let governor = m.governor.expect("governor block should parse");
2169 assert_eq!(governor.terminable, Some(false));
2170 assert_eq!(governor.min_idle_secs, Some(300));
2171 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
2172 }
2173
2174 #[test]
2175 fn stage_contract_fixture_parses_with_normalized_requirements() {
2176 let json = include_str!(
2177 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2178 );
2179 let m = parse_ok(json);
2180 assert!(m.has_ui);
2181 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2182 let ui = m.ui.expect("fixture should declare ui");
2183 assert_eq!(ui.kind, AppUiKind::Stage);
2184 assert_eq!(ui.entry, "ui/main.js");
2185 assert_eq!(ui.nav.unwrap().order, 10);
2186 }
2187
2188 #[test]
2189 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2190 let m = parse_ok(
2191 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2192 );
2193 assert!(m.has_ui);
2194 assert_eq!(m.ui_path, "ui/dist");
2195 assert!(m.ui.is_none());
2196 }
2197
2198 #[test]
2199 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2200 let manifest = parse_ok(
2201 r#"{
2202 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2203 "requires":["core.cron.register"],
2204 "ui":{
2205 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2206 "requires":{
2207 "capabilities":["ui.snapshot.v1"],
2208 "queries":["ui.query.v1"],
2209 "streams":["ui.event.v1"]
2210 },
2211 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2212 }
2213 }"#,
2214 );
2215
2216 assert_eq!(
2217 manifest.resolved_requires().unwrap(),
2218 vec!["core.cron.register"]
2219 );
2220 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2221 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2222 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2223 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2224 assert_eq!(
2225 ui.requires.resolved().unwrap(),
2226 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2227 );
2228 let serialized = serde_json::to_value(ui).unwrap();
2229 assert_eq!(
2230 serialized["requires"]["queries"],
2231 serde_json::json!(["ui.query.v1"])
2232 );
2233 }
2234
2235 #[test]
2236 fn ui_requirements_reject_blank_entries() {
2237 let error = parse_err(
2238 r#"{
2239 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2240 "ui":{
2241 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2242 "requires":{"capabilities":[""],"queries":[],"streams":[]},
2243 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2244 }
2245 }"#,
2246 );
2247 assert!(error.contains("ui.requires entries must not be blank"));
2248 }
2249
2250 #[test]
2251 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2252 assert!(validate_app_data_namespace("obs-viewer").is_ok());
2259
2260 let error = validate_app_data_namespace("obs_viewer")
2261 .expect_err("underscore must not be admitted into a storage namespace");
2262 assert!(
2263 error.contains("must match [a-z][a-z0-9-]*"),
2264 "unexpected error: {error}"
2265 );
2266 }
2267
2268 #[test]
2269 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2270 let error = parse_err(
2271 r#"{
2272 "name":"ui-data","version":"1.0.0","app_type":"bun",
2273 "requires":["ui.snapshot.v1"],
2274 "ui":{
2275 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2276 "requires":{"capabilities":[],"queries":[],"streams":[]},
2277 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2278 "data":{
2279 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2280 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2281 "streams":[]
2282 }
2283 }
2284 }"#,
2285 );
2286 assert!(error.contains("must be declared in requires"));
2287 }
2288
2289 #[test]
2294 fn ui_data_namespace_must_equal_the_app_name() {
2295 let manifest = |namespace: &str| {
2296 format!(
2297 r#"{{
2298 "name":"burger-runtime","version":"0.2.0",
2299 "ui":{{
2300 "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2301 "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2302 "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2303 "data":{{
2304 "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2305 "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2306 "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2307 }}
2308 }}
2309 }}"#
2310 )
2311 };
2312
2313 let error = parse_err(&manifest("burger"));
2314 assert!(
2315 error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2316 "unexpected error: {error}"
2317 );
2318
2319 let accepted = parse_ok(&manifest("burger-runtime"));
2320 let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2321 assert_eq!(
2322 data.map(|data| data.namespace.as_str()),
2323 Some("burger-runtime")
2324 );
2325 }
2326
2327 #[test]
2328 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2329 let accepted = parse_ok(
2330 r#"{
2331 "name":"aliases","version":"1.0.0","app_type":"bun",
2332 "requires":["core.chat.read","core.chat.read","core.chat.send"],
2333 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2334 }"#,
2335 );
2336 assert_eq!(
2337 accepted.resolved_requires().unwrap(),
2338 vec!["core.chat.read", "core.chat.send"]
2339 );
2340
2341 let err = parse_err(
2342 r#"{
2343 "name":"aliases","version":"1.0.0","app_type":"bun",
2344 "requires":["core.chat.read"],
2345 "capabilities":{"requires":["core.wallet.pay"]}
2346 }"#,
2347 );
2348 assert!(err.contains("conflicts"), "unexpected error: {err}");
2349 }
2350
2351 #[test]
2352 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2353 let base = |ui: &str| {
2354 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2355 };
2356 let widget = base(
2357 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2358 );
2359 assert_eq!(
2360 parse_ok(&widget).ui.expect("widget ui").kind,
2361 AppUiKind::Widget
2362 );
2363 let widget_nav = base(
2364 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2365 );
2366 assert!(parse_err(&widget_nav).contains("must omit nav"));
2367 let api = base(
2368 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2369 );
2370 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2371 let unsupported_api = base(
2372 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2373 );
2374 assert!(parse_err(&unsupported_api).contains("ui_api"));
2375 let path = base(
2376 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2377 );
2378 assert!(parse_err(&path).contains("entry"));
2379 }
2380
2381 #[test]
2382 fn stage_requires_integrity_for_entry_and_icon() {
2383 let missing_entry = r#"{
2384 "name":"stage","version":"1.0.0","app_type":"bun",
2385 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2386 }"#;
2387 assert!(parse_err(missing_entry).contains("entry"));
2388 let missing_icon = r#"{
2389 "name":"stage","version":"1.0.0","app_type":"bun",
2390 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2391 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2392 }"#;
2393 assert!(parse_err(missing_icon).contains("icon"));
2394 let uppercase = r#"{
2395 "name":"stage","version":"1.0.0","app_type":"bun",
2396 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2397 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2398 }"#;
2399 assert!(parse_err(uppercase).contains("lowercase"));
2400 }
2401
2402 #[test]
2403 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2404 let manifest = |composes: &str| {
2405 format!(
2406 r#"{{
2407 "name":"stage","version":"1.0.0","app_type":"bun",
2408 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2409 "composes":{composes},
2410 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2411 }}"#
2412 )
2413 };
2414 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2415 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2416 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2417 }
2418
2419 fn manifest_with_ui() -> AppManifest {
2431 parse_ok(
2432 r#"{
2433 "name":"stage","version":"1.0.0","app_type":"bun",
2434 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2435 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2436 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2437 }"#,
2438 )
2439 }
2440
2441 fn surface(id: &str, slot: &str) -> AppUiSurface {
2442 AppUiSurface {
2443 id: id.to_string(),
2444 slot: slot.to_string(),
2445 entry: "ui/dist/surfaces/chip.js".to_string(),
2446 title: "Chip".to_string(),
2447 order: 10,
2448 requires: AppUiRequirements {
2449 capabilities: vec!["wallet.balance.get".to_string()],
2450 ..Default::default()
2451 },
2452 }
2453 }
2454
2455 #[test]
2456 fn manifest_without_surfaces_still_parses() {
2457 let manifest = manifest_with_ui();
2458 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2459 assert!(manifest.validate().is_ok());
2460 }
2461
2462 #[test]
2463 fn surface_in_a_known_slot_is_accepted() {
2464 let mut manifest = manifest_with_ui();
2465 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2466 assert!(manifest.validate().is_ok());
2467 }
2468
2469 #[test]
2470 fn surface_in_an_unknown_slot_is_rejected() {
2471 let mut manifest = manifest_with_ui();
2472 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2473 let error = manifest.validate().unwrap_err();
2474 assert!(error.contains("menu-bar"), "unexpected error: {error}");
2475 }
2476
2477 #[test]
2478 fn duplicate_surface_ids_are_rejected() {
2479 let mut manifest = manifest_with_ui();
2480 manifest.ui.as_mut().unwrap().surfaces = vec![
2481 surface("chip", "status-rail"),
2482 surface("chip", "status-rail"),
2483 ];
2484 let error = manifest.validate().unwrap_err();
2485 assert!(error.contains("duplicate"), "unexpected error: {error}");
2486 }
2487
2488 #[test]
2489 fn surface_with_a_blank_id_is_rejected() {
2490 let mut manifest = manifest_with_ui();
2491 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
2492 assert!(manifest.validate().is_err());
2493 }
2494
2495 #[test]
2496 fn surface_with_an_unsafe_entry_path_is_rejected() {
2497 let mut manifest = manifest_with_ui();
2498 let mut bad = surface("chip", "status-rail");
2499 bad.entry = "../../etc/passwd".to_string();
2500 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2501 assert!(manifest.validate().is_err());
2502 }
2503
2504 #[test]
2505 fn surface_entry_missing_from_integrity_is_rejected() {
2506 let mut manifest = manifest_with_ui();
2510 let mut unpinned = surface("chip", "status-rail");
2511 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2512 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2513 let error = manifest.validate().unwrap_err();
2514 assert!(
2515 error.contains("ui.integrity must include surface 'chip' entry"),
2516 "unexpected error: {error}"
2517 );
2518 }
2519
2520 #[test]
2521 fn surface_with_a_blank_required_capability_is_rejected() {
2522 let mut manifest = manifest_with_ui();
2523 let mut bad = surface("chip", "status-rail");
2524 bad.requires.capabilities = vec![" ".to_string()];
2525 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2526 assert!(manifest.validate().is_err());
2527 }
2528
2529 #[test]
2530 fn a_stage_requesting_graph_runtime_is_rejected() {
2531 for name in ["graph.runtime.pi_mono.react", "graph.runtime.*", "graph.*"] {
2532 let mut manifest = manifest_with_ui();
2533 manifest
2534 .ui
2535 .as_mut()
2536 .unwrap()
2537 .requires
2538 .capabilities
2539 .push(name.to_string());
2540 let error = manifest.validate().unwrap_err();
2541 assert!(
2542 error.contains(&format!("ui.requires.capabilities must not request '{name}'")),
2543 "{name}: {error}"
2544 );
2545 }
2546 }
2547
2548 #[test]
2549 fn a_surface_requesting_graph_runtime_is_rejected() {
2550 let mut manifest = manifest_with_ui();
2551 let mut chip = surface("chip", "status-rail");
2552 chip.requires
2553 .capabilities
2554 .push("graph.runtime.pi_mono.react".to_string());
2555 manifest.ui.as_mut().unwrap().surfaces = vec![chip];
2556 let error = manifest.validate().unwrap_err();
2557 assert!(
2558 error.contains("ui.surfaces entry 'chip' requires.capabilities must not request 'graph.runtime.pi_mono.react'"),
2559 "{error}"
2560 );
2561 }
2562
2563 #[test]
2564 fn graph_capabilities_outside_the_runtime_are_still_accepted() {
2565 let mut manifest = manifest_with_ui();
2566 manifest
2567 .ui
2568 .as_mut()
2569 .unwrap()
2570 .requires
2571 .capabilities
2572 .push("graph.engine.flows.list".to_string());
2573 assert!(manifest.validate().is_ok());
2574 }
2575
2576 fn manifest_with_ui_requirement(
2579 surface_requirement: bool,
2580 field: &str,
2581 name: &str,
2582 ) -> AppManifest {
2583 let mut manifest = manifest_with_ui();
2584 let ui = manifest.ui.as_mut().unwrap();
2585 let requires = if surface_requirement {
2586 ui.surfaces.push(surface("chip", "status-rail"));
2587 &mut ui.surfaces[0].requires
2588 } else {
2589 &mut ui.requires
2590 };
2591 match field {
2592 "capabilities" => &mut requires.capabilities,
2593 "queries" => &mut requires.queries,
2594 "streams" => &mut requires.streams,
2595 _ => panic!("unknown requirement field"),
2596 }
2597 .push(name.to_string());
2598 manifest
2599 }
2600
2601 #[test]
2602 fn ui_runtime_scope_fields_reject_exact_names_and_covering_wildcards() {
2603 let mut failures = Vec::new();
2604 for is_surface in [false, true] {
2605 for field in ["capabilities", "queries", "streams"] {
2606 for name in [
2607 "graph.runtime.pi_mono.react",
2608 "graph.runtime.pi_mono.tool_result",
2609 "graph.runtime.pi_mono.card_resolve",
2610 "graph.runtime.pi_mono.pending",
2611 "graph.runtime.*",
2612 "graph.runtime.pi_mono.*",
2613 "graph.*",
2614 "*",
2615 "graph.runtime.",
2616 " graph.runtime.pi_mono.react ",
2617 " * ",
2618 ] {
2619 let manifest = manifest_with_ui_requirement(is_surface, field, name);
2620 let label = if is_surface {
2621 "ui.surfaces entry 'chip' requires"
2622 } else {
2623 "ui.requires"
2624 };
2625 let expected = format!(
2626 "{label}.{field} must not request '{}': graph.runtime.* is reachable only through the agent app",
2627 name.trim()
2628 );
2629 if manifest.validate() != Err(expected.clone()) {
2630 failures.push(format!("{expected}; got {:?}", manifest.validate()));
2631 }
2632 }
2633 }
2634 }
2635 assert!(failures.is_empty(), "{}", failures.join("\n"));
2636 }
2637
2638 #[test]
2639 fn ui_runtime_boundary_preserves_unrelated_names_and_capability_wildcards() {
2640 for is_surface in [false, true] {
2641 for field in ["capabilities", "queries", "streams"] {
2642 for name in [
2643 "graph.engine.flows.list",
2644 "graph.runtime_tools.read",
2645 "graph.runtimes.read",
2646 "other.graph.runtime.read",
2647 "agent.prompt",
2648 "observability.snapshot",
2649 "core.diag.snapshot",
2650 "core.apps.restart",
2651 ] {
2652 let result = manifest_with_ui_requirement(is_surface, field, name).validate();
2653 assert!(result.is_ok(), "{is_surface}/{field}/{name}: {result:?}");
2654 }
2655 }
2656 for name in [
2657 "graph.engine.*",
2658 "graph.runtime_tools.*",
2659 "agent.*",
2660 "core.*",
2661 ] {
2662 let result =
2663 manifest_with_ui_requirement(is_surface, "capabilities", name).validate();
2664 assert!(result.is_ok(), "{is_surface}/{name}: {result:?}");
2665 }
2666 }
2667 }
2668
2669 #[test]
2670 fn ui_runtime_boundary_does_not_relax_requirement_syntax() {
2671 for is_surface in [false, true] {
2672 for field in ["capabilities", "queries", "streams"] {
2673 for name in [
2674 "",
2675 " ",
2676 "graph*",
2677 "gra*",
2678 "graph..read",
2679 "Graph.read",
2680 "agent/read",
2681 "agent.",
2682 ] {
2683 let error = manifest_with_ui_requirement(is_surface, field, name)
2684 .validate()
2685 .unwrap_err();
2686 assert!(
2687 !error.contains("reachable only through the agent app"),
2688 "{error}"
2689 );
2690 }
2691 }
2692 for field in ["queries", "streams"] {
2693 for name in ["graph.engine.*", "agent.*"] {
2694 let error = manifest_with_ui_requirement(is_surface, field, name)
2695 .validate()
2696 .unwrap_err();
2697 assert!(error.contains("invalid"), "{error}");
2698 }
2699 }
2700 }
2701 for field in ["capabilities", "queries", "streams"] {
2703 let manifest = manifest_with_ui_requirement(false, field, "*");
2704 assert!(manifest.ui.unwrap().requires.resolved().is_err());
2705 }
2706 }
2707
2708 #[test]
2709 fn ui_runtime_boundary_preserves_backend_requirements() {
2710 for nested in [false, true] {
2711 let mut manifest = manifest_with_ui();
2712 let requires = if nested {
2713 &mut manifest.capabilities.requires
2714 } else {
2715 &mut manifest.requires
2716 };
2717 *requires = vec![
2718 "graph.runtime.pi_mono.react".to_string(),
2719 "graph.runtime.*".to_string(),
2720 ];
2721 assert!(manifest.validate().is_ok());
2722 assert_eq!(
2723 manifest.resolved_requires().unwrap(),
2724 vec!["graph.runtime.pi_mono.react", "graph.runtime.*"]
2725 );
2726 }
2727 }
2728
2729 #[test]
2730 fn v2_missing_abi_is_error() {
2731 let json = r#"{
2732 "manifest_version": 2,
2733 "name": "example",
2734 "version": "1.0.0",
2735 "app_type": "bun"
2736 }"#;
2737 let err = parse_err(json);
2738 assert!(err.contains("abi"), "expected abi error, got: {}", err);
2739 }
2740
2741 #[test]
2744 fn publisher_prefixed_name_accepted() {
2745 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2746 assert_eq!(m.name, "alice/weather");
2747 }
2748
2749 #[test]
2750 fn double_slash_name_rejected() {
2751 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2752 assert!(!err.is_empty());
2753 }
2754
2755 #[test]
2758 fn name_starting_with_digit_rejected() {
2759 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2760 assert!(!err.is_empty());
2761 }
2762
2763 #[test]
2764 fn name_with_uppercase_rejected() {
2765 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2766 assert!(!err.is_empty());
2767 }
2768
2769 #[test]
2770 fn empty_name_rejected() {
2771 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2772 assert!(!err.is_empty());
2773 }
2774
2775 #[test]
2778 fn path_traversal_double_dot_rejected() {
2779 let json = r#"{
2780 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2781 "app_type": "bun", "abi": "v1",
2782 "entrypoint": "../etc/passwd"
2783 }"#;
2784 let err = parse_err(json);
2785 assert!(err.contains(".."), "expected traversal error, got: {}", err);
2786 }
2787
2788 #[test]
2789 fn path_traversal_encoded_dot_not_decoded() {
2790 let json = r#"{
2792 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2793 "app_type": "bun", "abi": "v1",
2794 "entrypoint": "foo/../bar"
2795 }"#;
2796 let err = parse_err(json);
2797 assert!(!err.is_empty(), "should have failed: {}", err);
2798 }
2799
2800 #[test]
2801 fn absolute_path_rejected() {
2802 let json = r#"{
2803 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2804 "app_type": "bun", "abi": "v1",
2805 "entrypoint": "/usr/bin/sh"
2806 }"#;
2807 let err = parse_err(json);
2808 assert!(
2809 err.contains("absolute"),
2810 "expected absolute error, got: {}",
2811 err
2812 );
2813 }
2814
2815 #[test]
2816 fn shell_metachar_in_path_rejected() {
2817 let json = r#"{
2818 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2819 "app_type": "bun", "abi": "v1",
2820 "entrypoint": "dist/index.js;rm -rf /"
2821 }"#;
2822 let err = parse_err(json);
2823 assert!(!err.is_empty());
2824 }
2825
2826 #[test]
2827 fn valid_nested_path_accepted() {
2828 let json = r#"{
2829 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2830 "app_type": "bun", "abi": "v1",
2831 "entrypoint": "dist/index.js",
2832 "ui_path": "ui/dist"
2833 }"#;
2834 parse_ok(json);
2835 }
2836
2837 #[test]
2840 fn homepage_https_accepted() {
2841 let json = r#"{
2842 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2843 "homepage": "https://example.com"
2844 }"#;
2845 parse_ok(json);
2846 }
2847
2848 #[test]
2849 fn homepage_javascript_scheme_rejected() {
2850 let json = r#"{
2851 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2852 "homepage": "javascript:alert(1)"
2853 }"#;
2854 let err = parse_err(json);
2855 assert!(
2856 err.contains("scheme"),
2857 "expected scheme error, got: {}",
2858 err
2859 );
2860 }
2861
2862 #[test]
2863 fn homepage_file_scheme_rejected() {
2864 let json = r#"{
2865 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2866 "homepage": "file:///etc/passwd"
2867 }"#;
2868 let err = parse_err(json);
2869 assert!(!err.is_empty());
2870 }
2871
2872 #[test]
2875 fn effective_entrypoint_native_default() {
2876 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2877 assert_eq!(m.effective_entrypoint(), "app.so");
2878 }
2879
2880 #[test]
2881 fn effective_entrypoint_bun_default() {
2882 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2883 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2884 }
2885
2886 #[test]
2887 fn effective_hot_reload_native_default_is_experimental() {
2888 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2889 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2890 }
2891
2892 #[test]
2893 fn effective_hot_reload_bun_default_is_supported() {
2894 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2895 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2896 }
2897
2898 #[test]
2899 fn hot_reload_unsupported_explicit() {
2900 let json = r#"{
2901 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2902 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2903 }"#;
2904 let m = parse_ok(json);
2905 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2906 }
2907
2908 #[test]
2911 fn validate_path_simple_valid() {
2912 assert!(validate_manifest_path("dist/index.js").is_ok());
2913 assert!(validate_manifest_path("app.so").is_ok());
2914 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2915 assert!(validate_manifest_path("build_output/main").is_ok());
2916 }
2917
2918 #[test]
2919 fn validate_path_empty_rejected() {
2920 assert!(validate_manifest_path("").is_err());
2921 }
2922
2923 #[test]
2924 fn validate_path_absolute_rejected() {
2925 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2926 }
2927
2928 #[test]
2929 fn validate_path_double_dot_segment_rejected() {
2930 assert!(validate_manifest_path("foo/../bar").is_err());
2931 assert!(validate_manifest_path("../etc/passwd").is_err());
2932 }
2933
2934 #[test]
2935 fn validate_path_leading_dot_rejected() {
2936 assert!(validate_manifest_path(".hidden").is_err());
2937 }
2938
2939 #[test]
2940 fn validate_path_null_byte_rejected() {
2941 let path = "foo\0bar";
2943 assert!(validate_manifest_path(path).is_err());
2944 }
2945
2946 #[test]
2947 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2948 let path = std::path::Path::new("/tmp/node-app/example.sock");
2949 assert!(validate_standalone_socket_path(path).is_err());
2950 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2951 assert!(validate_standalone_socket_path_with_policy(
2952 std::path::Path::new("/tmp/node-app/../escape.sock"),
2953 true,
2954 )
2955 .is_err());
2956 }
2957
2958 #[test]
2961 fn manifest_capabilities_defaults_to_empty() {
2962 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2963 assert!(m.capabilities.requires.is_empty());
2964 assert!(m.capabilities.provides.is_empty());
2965 }
2966
2967 #[test]
2970 fn resolved_capability_provides_v1_only() {
2971 let json = r#"{
2972 "name": "example", "version": "1.0.0", "app_type": "bun",
2973 "provides": { "core.example.run": { "description": "Run example job" } }
2974 }"#;
2975 let m = parse_ok(json);
2976 let out = m.resolved_capability_provides();
2977 assert_eq!(out.len(), 1);
2978 assert_eq!(
2979 out.get("core.example.run").unwrap().description,
2980 "Run example job"
2981 );
2982 }
2983
2984 #[test]
2985 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2986 let json = r#"{
2987 "manifest_version": 2, "name": "example", "version": "1.0.0",
2988 "app_type": "bun", "abi": "v1",
2989 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2990 }"#;
2991 let m = parse_ok(json);
2992 let out = m.resolved_capability_provides();
2993 assert_eq!(out.len(), 2);
2994 assert_eq!(out.get("core.example.run").unwrap().description, "");
2995 assert!(out.get("core.example.run").unwrap().schema.is_none());
2996 assert!(out.contains_key("core.example.other"));
2998 assert!(!out.contains_key("core.example.other:extra"));
2999 }
3000
3001 #[test]
3002 fn resolved_capability_provides_v1_wins_on_conflict() {
3003 let json = r#"{
3004 "manifest_version": 2, "name": "example", "version": "1.0.0",
3005 "app_type": "bun", "abi": "v1",
3006 "provides": { "core.example.run": { "description": "v1 wins" } },
3007 "capabilities": { "requires": [], "provides": ["core.example.run"] }
3008 }"#;
3009 let m = parse_ok(json);
3010 let out = m.resolved_capability_provides();
3011 assert_eq!(out.len(), 1);
3012 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
3013 }
3014
3015 #[test]
3016 fn resolved_capability_provides_blank_v2_name_skipped() {
3017 let json = r#"{
3018 "manifest_version": 2, "name": "example", "version": "1.0.0",
3019 "app_type": "bun", "abi": "v1",
3020 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
3021 }"#;
3022 let m = parse_ok(json);
3023 let out = m.resolved_capability_provides();
3024 assert_eq!(out.len(), 1);
3025 assert!(out.contains_key("core.example.run"));
3026 }
3027
3028 #[test]
3029 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
3030 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
3031 assert!(m.resolved_capability_provides().is_empty());
3032 }
3033
3034 #[test]
3037 fn abi_v1_is_supported() {
3038 assert!(AbiVersion::V1.is_supported());
3039 }
3040
3041 #[test]
3044 fn app_tier_display() {
3045 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
3046 assert_eq!(AppTier::Optional.to_string(), "optional");
3047 assert_eq!(AppTier::Development.to_string(), "development");
3048 }
3049
3050 #[test]
3051 fn app_tier_serde_roundtrip() {
3052 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
3054 let json = serde_json::to_string(&tier).unwrap();
3055 let back: AppTier = serde_json::from_str(&json).unwrap();
3056 assert_eq!(
3057 tier, back,
3058 "roundtrip failed for {:?}: serialized as {}",
3059 tier, json
3060 );
3061 }
3062 assert_eq!(
3063 serde_json::to_string(&AppTier::Development).unwrap(),
3064 "\"development\""
3065 );
3066 }
3067
3068 #[test]
3071 fn app_type_display() {
3072 assert_eq!(AppType::Native.to_string(), "native");
3073 assert_eq!(AppType::Bun.to_string(), "bun");
3074 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
3075 }
3076
3077 #[test]
3078 fn platform_runtime_is_a_supported_packaging_type() {
3079 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
3080 "manifest_version": 2,
3081 "abi": "v1",
3082 "name": "bun-runtime",
3083 "version": "1.0.0",
3084 "app_type": "platform-runtime",
3085 "entrypoint": "bun"
3086 }))
3087 .expect("platform runtime manifest should parse");
3088
3089 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
3090 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
3091 }
3092
3093 #[test]
3096 fn governor_manifest_memory_budget_defaults_to_none() {
3097 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
3098 assert_eq!(parsed.memory_budget_kb, None);
3099 }
3100
3101 #[test]
3102 fn governor_manifest_parses_declared_memory_budget() {
3103 let parsed: GovernorManifest =
3104 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
3105 assert_eq!(parsed.memory_budget_kb, Some(40_960));
3106 }
3107
3108 #[test]
3111 fn latency_class_parses_and_defaults_none() {
3112 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
3113 assert_eq!(parsed.latency_class, None);
3114 }
3115
3116 #[test]
3117 fn latency_class_parses_declared_interactive() {
3118 let json = r#"{
3119 "name": "example",
3120 "version": "1.0.0",
3121 "app_type": "bun",
3122 "governor": {"latency_class": "interactive"}
3123 }"#;
3124 let m = parse_ok(json);
3125 let governor = m.governor.expect("governor block should parse");
3126 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
3127 }
3128
3129 #[test]
3130 fn latency_class_parses_declared_background() {
3131 let parsed: GovernorManifest =
3132 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
3133 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
3134 }
3135
3136 #[test]
3137 fn latency_class_invalid_value_is_parse_error() {
3138 let result: Result<GovernorManifest, _> =
3139 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
3140 assert!(result.is_err(), "unknown latency_class value must fail to parse");
3141 }
3142
3143 #[test]
3144 fn latency_class_as_str_and_from_str_roundtrip() {
3145 for class in [LatencyClass::Interactive, LatencyClass::Background] {
3146 let s = class.as_str();
3147 assert_eq!(LatencyClass::from_str(s), Ok(class));
3148 }
3149 assert!(LatencyClass::from_str("urgent").is_err());
3150 }
3151
3152 fn ui_only_manifest() -> serde_json::Value {
3155 serde_json::json!({
3156 "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
3157 "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
3158 "ui": {
3159 "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
3160 "nav": { "section": "system", "order": 90 }, "ui_api": 1,
3161 "requires": {
3162 "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
3163 "queries": [], "streams": ["app.burger_metrics"]
3164 },
3165 "data": {
3166 "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
3167 "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
3168 "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
3169 },
3170 "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
3171 }
3172 })
3173 }
3174
3175 fn ui_only_with(key: &str, value: serde_json::Value) -> String {
3176 let mut manifest = ui_only_manifest();
3177 manifest[key] = value;
3178 manifest.to_string()
3179 }
3180
3181 #[test]
3182 fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
3183 let m = parse_ok(&ui_only_manifest().to_string());
3184 assert_eq!(m.app_type, AppType::UiOnly);
3185 assert_eq!(m.app_type.as_str(), "ui-only");
3186 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
3187 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
3188 assert!(m.has_ui);
3189 let ui = m.ui.as_ref().expect("a ui block");
3190 assert_eq!(ui.kind, AppUiKind::Stage);
3191 let data = ui.data.as_ref().expect("the F5 ui.data block validates");
3192 assert_eq!(data.namespace, "burger-runtime");
3193 assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
3194 assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
3195 assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
3196 }
3197
3198 #[test]
3199 fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
3200 let m = parse_ok(&ui_only_manifest().to_string());
3201 let wire = serde_json::to_string(&m).unwrap();
3202 assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
3203 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
3204 }
3205
3206 #[test]
3207 fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
3208 for (key, value) in [
3209 (
3210 "provides",
3211 serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
3212 ),
3213 (
3214 "capabilities",
3215 serde_json::json!({ "provides": ["burger.runtime.peek"] }),
3216 ),
3217 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
3218 ("resources", serde_json::json!({ "memory_mb": 16 })),
3219 (
3220 "standalone",
3221 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
3222 ),
3223 ("subscribes", serde_json::json!(["system.network.changed"])),
3224 ("depends_on", serde_json::json!(["cron"])),
3225 ] {
3226 let err = parse_err(&ui_only_with(key, value));
3227 assert!(err.contains("ui-only"), "{key}: {err}");
3228 }
3229 let mut explicit_with_entry = ui_only_manifest();
3230 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
3231 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
3232 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
3233 }
3234
3235 #[test]
3236 fn a_ui_only_manifest_must_declare_a_ui_block() {
3237 let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
3238 assert!(parse_err(no_ui).contains(r#"kind "stage" or "widget""#));
3239 }
3240
3241 fn ui_only_widget_manifest() -> serde_json::Value {
3245 serde_json::json!({
3246 "manifest_version": 2, "abi": "v1", "name": "agent-chat", "version": "1.0.0",
3247 "has_ui": true, "ui_path": "ui/dist",
3248 "ui": {
3249 "kind": "widget", "entry": "ui/dist/main.js", "title": "Agent chat", "ui_api": 2,
3250 "requires": {
3251 "capabilities": ["agent.prompt", "agent.conversation.entries"],
3252 "queries": [], "streams": ["app.agent_session"]
3253 },
3254 "integrity": { "ui/dist/main.js": "a".repeat(64) }
3255 }
3256 })
3257 }
3258
3259 fn ui_only_widget_with(key: &str, value: serde_json::Value) -> String {
3260 let mut manifest = ui_only_widget_manifest();
3261 manifest[key] = value;
3262 manifest.to_string()
3263 }
3264
3265 #[test]
3266 fn a_widget_manifest_without_app_type_or_entrypoint_is_ui_only() {
3267 let m = parse_ok(&ui_only_widget_manifest().to_string());
3268 assert_eq!(m.app_type, AppType::UiOnly);
3269 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
3270 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
3271 assert!(m.has_ui);
3272 assert_eq!(m.ui.as_ref().expect("a ui block").kind, AppUiKind::Widget);
3273
3274 let mut explicit = ui_only_widget_manifest();
3275 explicit["app_type"] = serde_json::json!("ui-only");
3276 let m = parse_ok(&explicit.to_string());
3277 assert_eq!(m.app_type, AppType::UiOnly);
3278 let wire = serde_json::to_string(&m).unwrap();
3279 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly, "{wire}");
3280 }
3281
3282 #[test]
3283 fn a_ui_only_widget_refuses_everything_that_implies_a_process() {
3284 for (key, value) in [
3285 (
3286 "provides",
3287 serde_json::json!({ "agent-chat.widget.peek": { "description": "x" } }),
3288 ),
3289 (
3290 "capabilities",
3291 serde_json::json!({ "provides": ["agent-chat.widget.peek"] }),
3292 ),
3293 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
3294 ("resources", serde_json::json!({ "memory_mb": 16 })),
3295 (
3296 "standalone",
3297 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
3298 ),
3299 ("subscribes", serde_json::json!(["system.network.changed"])),
3300 ("depends_on", serde_json::json!(["agent"])),
3301 ] {
3302 let err = parse_err(&ui_only_widget_with(key, value));
3303 assert!(err.contains("ui-only"), "{key}: {err}");
3304 }
3305 let mut explicit_with_entry = ui_only_widget_manifest();
3306 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
3307 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
3308 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
3309 }
3310
3311 #[test]
3312 fn a_ui_only_widget_keeps_the_widget_rules() {
3313 let mut with_nav = ui_only_widget_manifest();
3314 with_nav["ui"]["nav"] = serde_json::json!({ "section": "system", "order": 1 });
3315 assert!(parse_err(&with_nav.to_string()).contains("widget ui must omit nav metadata"));
3316
3317 let data = |namespace: &str| {
3318 serde_json::json!({
3319 "namespace": namespace, "offline": "online-only", "sync": "snapshot",
3320 "queries": [], "streams": []
3321 })
3322 };
3323 let mut foreign_data = ui_only_widget_manifest();
3324 foreign_data["ui"]["data"] = data("agent");
3325 assert!(parse_err(&foreign_data.to_string()).contains("must equal the app name"));
3326
3327 let mut own_data = ui_only_widget_manifest();
3328 own_data["ui"]["data"] = data("agent-chat");
3329 assert_eq!(parse_ok(&own_data.to_string()).app_type, AppType::UiOnly);
3330 }
3331
3332 #[test]
3335 fn a_widget_with_a_backend_is_not_ui_only() {
3336 let mut bun = ui_only_widget_manifest();
3337 bun["app_type"] = serde_json::json!("bun");
3338 bun["entrypoint"] = serde_json::json!("dist/index.js");
3339 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
3340 }
3341
3342 #[test]
3343 fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
3344 assert_eq!(
3345 parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
3346 AppType::Native
3347 );
3348 let mut with_entry = ui_only_manifest();
3349 with_entry["entrypoint"] = serde_json::json!("app.so");
3350 assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
3351 let mut bun = ui_only_manifest();
3352 bun["app_type"] = serde_json::json!("bun");
3353 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
3354 }
3355
3356 #[test]
3357 fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
3358 assert!(declares_ui_only(&ui_only_manifest()));
3359 assert!(declares_ui_only(
3360 &serde_json::json!({ "app_type": "ui-only" })
3361 ));
3362 assert!(!declares_ui_only(
3363 &serde_json::json!({ "app_type": "bun", "ui": {} })
3364 ));
3365 assert!(!declares_ui_only(
3366 &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
3367 ));
3368 assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
3369 assert!(!declares_ui_only(&serde_json::json!("ui-only")));
3370 }
3371}
3372
3373#[cfg(test)]
3374mod shared_app_data_corpus_tests {
3375 use super::*;
3376
3377 #[test]
3382 fn shared_app_data_corpus_matches_the_host_validator() {
3383 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
3384 let mut checked = 0;
3385 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
3386 let path = entry.expect("readable entry").path();
3387 if path.extension().and_then(|e| e.to_str()) != Some("json") {
3388 continue;
3389 }
3390 let fixture: serde_json::Value =
3391 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
3392 .expect("valid fixture json");
3393 let name = path
3394 .file_name()
3395 .and_then(|n| n.to_str())
3396 .unwrap_or("?")
3397 .to_string();
3398 let manifest = serde_json::json!({
3402 "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
3403 "app_type": "bun", "entrypoint": "dist/index.js",
3404 "ui": fixture["ui"],
3405 });
3406 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
3407 match fixture["expect"].as_str().expect("expect field") {
3408 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
3409 "reject" => {
3410 let error = result.expect_err(&format!("{name} should be rejected"));
3411 let reason = fixture["reason"]
3412 .as_str()
3413 .expect("reject fixtures need a reason");
3414 assert!(
3415 error.contains(reason),
3416 "{name}: {error:?} should mention {reason:?}"
3417 );
3418 }
3419 other => panic!("{name}: unknown expect {other:?}"),
3420 }
3421 checked += 1;
3422 }
3423 assert!(checked >= 10, "expected the full corpus, walked {checked}");
3425 }
3426}
3427
3428#[cfg(test)]
3429mod burger_manifest_tests {
3430 use super::*;
3431
3432 #[test]
3433 fn burger_app_type_parses_with_bundle_entrypoint_default() {
3434 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
3435 .expect("burger manifest parses");
3436 assert_eq!(m.app_type, AppType::Burger);
3437 assert_eq!(m.app_type.as_str(), "burger");
3438 assert_eq!(m.effective_entrypoint(), "dist/index.js");
3439 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
3440 assert!(m.resources.is_none());
3441 }
3442
3443 #[test]
3444 fn resources_memory_mb_is_carried() {
3445 let m = AppManifest::from_json(
3446 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
3447 )
3448 .expect("resources block parses");
3449 assert_eq!(
3450 m.resources,
3451 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
3452 );
3453 }
3454
3455 #[test]
3456 fn resources_callback_deadline_ms_is_carried() {
3457 let m = AppManifest::from_json(
3458 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
3459 )
3460 .expect("callback deadline parses");
3461 assert_eq!(
3462 m.resources,
3463 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
3464 );
3465 let max = AppManifest::from_json(
3466 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3467 )
3468 .expect("the maximum is inclusive");
3469 assert_eq!(
3470 max.resources.and_then(|r| r.callback_deadline_ms),
3471 Some(MAX_CALLBACK_DEADLINE_MS)
3472 );
3473 }
3474
3475 #[test]
3476 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3477 for bad in ["0", "600001"] {
3478 let json = format!(
3479 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3480 );
3481 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3482 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3483 }
3484 }
3485
3486 #[test]
3487 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3488 for bad in ["-1", "1.5", "\"5000\""] {
3489 let json = format!(
3490 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3491 );
3492 assert!(
3493 AppManifest::from_json(&json).is_err(),
3494 "callback_deadline_ms={bad} must be rejected"
3495 );
3496 }
3497 }
3498
3499 #[test]
3500 fn resources_memory_mb_zero_is_rejected() {
3501 let err = AppManifest::from_json(
3502 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3503 )
3504 .expect_err("a zero memory limit is invalid");
3505 assert!(err.contains("resources.memory_mb"), "{err}");
3506 }
3507
3508 #[test]
3509 fn burger_manifest_rejects_a_standalone_block() {
3510 let err = AppManifest::from_json(
3511 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3512 )
3513 .expect_err("standalone block is only valid for standalone apps");
3514 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3515 }
3516}