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