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, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(rename_all = "lowercase")]
557pub enum UiLayout {
558 Full,
559 #[default]
560 Column,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct AppUiManifest {
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub layout: Option<UiLayout>,
568 #[serde(default = "default_app_ui_kind")]
569 pub kind: AppUiKind,
570 pub entry: String,
571 pub title: String,
572 #[serde(default)]
573 pub icon: Option<String>,
574 #[serde(default)]
575 pub nav: Option<AppUiNav>,
576 #[serde(default)]
577 pub composes: Vec<String>,
578 #[serde(default)]
580 pub surfaces: Vec<AppUiSurface>,
581 pub ui_api: u8,
582 #[serde(default)]
583 pub integrity: BTreeMap<String, String>,
584 #[serde(default)]
588 pub description: Option<String>,
589 #[serde(default)]
592 pub keywords: Vec<String>,
593 #[serde(default)]
596 pub requires: AppUiRequirements,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub data: Option<AppDataManifest>,
599}
600
601pub fn validate_manifest_path(path: &str) -> Result<(), String> {
613 if path.is_empty() {
614 return Err("path must not be empty".to_string());
615 }
616
617 if path.starts_with('/') {
619 return Err(format!(
620 "path '{}' must not be absolute (starts with /)",
621 path
622 ));
623 }
624
625 let first = path.chars().next().unwrap();
628 if !first.is_ascii_alphanumeric() && first != '_' {
629 return Err(format!(
630 "path '{}' must begin with an alphanumeric character or underscore",
631 path
632 ));
633 }
634 for ch in path.chars().skip(1) {
635 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
636 return Err(format!(
637 "path '{}' contains disallowed character '{}'",
638 path, ch
639 ));
640 }
641 }
642
643 for segment in path.split('/') {
645 if segment == ".." {
646 return Err(format!(
647 "path '{}' contains a '..' segment (path traversal rejected)",
648 path
649 ));
650 }
651 }
652
653 Ok(())
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct AppManifest {
665 #[serde(default = "default_manifest_version", rename = "manifest_version")]
667 pub manifest_version: u8,
668
669 pub name: String,
670 pub version: String,
671
672 #[serde(default = "default_app_type_native")]
673 pub app_type: AppType,
674
675 #[serde(default)]
676 pub description: String,
677
678 pub abi: Option<AbiVersion>,
681
682 pub entrypoint: Option<String>,
685
686 pub hot_reload: Option<HotReloadKind>,
689
690 #[serde(default)]
692 pub critical: bool,
693
694 #[serde(
695 default = "default_auto_start",
696 deserialize_with = "deserialize_auto_start"
697 )]
698 pub auto_start: bool,
699
700 #[serde(default)]
701 pub has_ui: bool,
702
703 #[serde(default = "default_ui_path")]
704 pub ui_path: String,
705
706 #[serde(default)]
707 pub permissions: Vec<String>,
708
709 #[serde(default)]
712 pub requires: Vec<String>,
713
714 #[serde(default)]
715 pub optional_permissions: Vec<String>,
716
717 #[serde(default)]
718 pub provides_scopes: Vec<ProvidedScope>,
719
720 #[serde(default)]
721 pub endpoint_policies: Vec<EndpointPolicy>,
722
723 #[serde(default)]
724 pub capability_scopes: HashMap<String, String>,
725
726 #[serde(default)]
727 pub provides: HashMap<String, ProvidedCapability>,
728
729 #[serde(default)]
731 pub capabilities: ManifestCapabilities,
732
733 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub ui: Option<AppUiManifest>,
737
738 #[serde(default)]
740 pub author: Option<String>,
741
742 #[serde(default)]
743 pub homepage: Option<String>,
744
745 #[serde(default)]
746 pub depends_on: Option<Vec<String>>,
747
748 #[serde(default)]
749 pub boot_priority: Option<u32>,
750
751 #[serde(default)]
755 pub governor: Option<GovernorManifest>,
756
757 #[serde(default)]
763 pub subscribes: Vec<String>,
764
765 #[serde(default)]
769 pub standalone: Option<StandaloneConfig>,
770
771 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub tcp: Option<TcpManifest>,
775
776 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub resources: Option<ResourcesManifest>,
780
781 #[serde(default, skip_serializing_if = "Vec::is_empty")]
788 pub schedules: Vec<AppScheduleManifest>,
789
790 #[serde(default, skip_serializing_if = "Option::is_none")]
794 pub host_contract: Option<crate::HostContractStamp>,
795}
796
797pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
799
800#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
806pub struct ResourcesManifest {
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub memory_mb: Option<u32>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub callback_deadline_ms: Option<u32>,
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(rename_all = "lowercase")]
820pub enum LatencyClass {
821 Interactive,
824 Background,
827}
828
829impl LatencyClass {
830 pub fn as_str(&self) -> &'static str {
831 match self {
832 LatencyClass::Interactive => "interactive",
833 LatencyClass::Background => "background",
834 }
835 }
836
837 #[allow(clippy::should_implement_trait)]
838 pub fn from_str(s: &str) -> Result<Self, String> {
839 match s {
840 "interactive" => Ok(LatencyClass::Interactive),
841 "background" => Ok(LatencyClass::Background),
842 _ => Err(format!("Invalid LatencyClass: {}", s)),
843 }
844 }
845}
846
847impl std::fmt::Display for LatencyClass {
848 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849 write!(f, "{}", self.as_str())
850 }
851}
852
853#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
856pub struct GovernorManifest {
857 #[serde(default)]
861 pub terminable: Option<bool>,
862
863 #[serde(default)]
867 pub min_idle_secs: Option<u64>,
868
869 #[serde(default)]
902 pub memory_budget_kb: Option<u64>,
903
904 #[serde(default)]
909 pub latency_class: Option<LatencyClass>,
910}
911
912#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
916pub struct TcpManifest {
917 #[serde(default, skip_serializing_if = "Option::is_none")]
921 pub preferred_port: Option<u16>,
922
923 #[serde(default, skip_serializing_if = "Option::is_none")]
930 pub direct_bind: Option<bool>,
931}
932
933#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950pub struct AppScheduleManifest {
951 pub id: String,
958
959 pub cron: String,
968
969 pub capability: String,
977
978 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub payload: Option<serde_json::Value>,
983}
984
985impl AppScheduleManifest {
986 pub fn effective_payload(&self) -> serde_json::Value {
988 self.payload
989 .clone()
990 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
991 }
992}
993
994fn cron_field_char_allowed(ch: char) -> bool {
999 ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
1000}
1001
1002fn validate_cron_shape(expression: &str) -> Result<(), String> {
1005 let fields: Vec<&str> = expression.split_whitespace().collect();
1006 if fields.len() != 6 {
1007 return Err(format!(
1008 "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
1009 expression,
1010 fields.len()
1011 ));
1012 }
1013 for field in fields {
1014 if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
1015 return Err(format!(
1016 "cron '{}' contains disallowed character '{}'",
1017 expression, ch
1018 ));
1019 }
1020 }
1021 Ok(())
1022}
1023
1024fn validate_schedule_id(id: &str) -> Result<(), String> {
1027 if id.is_empty() {
1028 return Err("schedule id must not be empty".to_string());
1029 }
1030 let first = id.chars().next().unwrap();
1031 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
1032 return Err(format!(
1033 "schedule id '{}' must begin with a lowercase letter or digit",
1034 id
1035 ));
1036 }
1037 for ch in id.chars() {
1038 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
1039 return Err(format!(
1040 "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
1041 id, ch
1042 ));
1043 }
1044 }
1045 Ok(())
1046}
1047
1048fn default_manifest_version() -> u8 {
1049 1
1050}
1051
1052fn default_auto_start() -> bool {
1053 true
1054}
1055
1056fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
1062where
1063 D: serde::Deserializer<'de>,
1064{
1065 #[derive(Deserialize)]
1066 #[serde(untagged)]
1067 enum BoolOrStr {
1068 Bool(bool),
1069 Str(String),
1070 }
1071 Ok(match BoolOrStr::deserialize(deserializer)? {
1072 BoolOrStr::Bool(b) => b,
1073 BoolOrStr::Str(s) => matches!(
1074 s.trim().to_ascii_lowercase().as_str(),
1075 "true" | "eager" | "active" | "auto" | "on" | "1"
1076 ),
1077 })
1078}
1079
1080fn default_ui_path() -> String {
1081 "dist".to_string()
1082}
1083
1084fn default_app_type_native() -> AppType {
1085 AppType::Native
1086}
1087
1088impl AppManifest {
1089 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1093 let top = normalized_requirements(&self.requires)?;
1094 let nested = normalized_requirements(&self.capabilities.requires)?;
1095 if !top.is_empty()
1096 && !nested.is_empty()
1097 && top.iter().cloned().collect::<BTreeSet<_>>()
1098 != nested.iter().cloned().collect::<BTreeSet<_>>()
1099 {
1100 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1101 }
1102 Ok(if !top.is_empty() { top } else { nested })
1103 }
1104
1105 pub fn effective_hot_reload(&self) -> HotReloadKind {
1108 self.hot_reload
1109 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1110 }
1111
1112 pub fn effective_entrypoint(&self) -> &str {
1118 if let Some(ref ep) = self.entrypoint {
1119 ep.as_str()
1120 } else {
1121 match self.app_type {
1122 AppType::Native => "app.so",
1123 AppType::Bun => "dist/index.js",
1124 AppType::Standalone => "",
1125 AppType::PlatformRuntime => "bun",
1126 AppType::ManagedV1 => "llmc-generated-app",
1127 AppType::Burger => "dist/index.js",
1128 AppType::UiOnly => "",
1129 }
1130 }
1131 }
1132
1133 pub fn has_capability_providers(&self) -> bool {
1136 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1137 }
1138
1139 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1153 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1154 for raw in &self.capabilities.provides {
1155 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1156 if name.is_empty() {
1157 continue;
1158 }
1159 out.entry(name).or_insert(ProvidedCapability {
1160 description: String::new(),
1161 schema: None,
1162 discoverable: true,
1163 });
1164 }
1165 out
1166 }
1167
1168 fn validate_schedules(&self) -> Result<(), String> {
1178 if self.schedules.is_empty() {
1179 return Ok(());
1180 }
1181 let provided = self.resolved_capability_provides();
1182 let mut seen: HashSet<&str> = HashSet::new();
1183 for schedule in &self.schedules {
1184 validate_schedule_id(&schedule.id)?;
1185 if !seen.insert(schedule.id.as_str()) {
1186 return Err(format!("duplicate schedule id '{}'", schedule.id));
1187 }
1188 validate_cron_shape(&schedule.cron)
1189 .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1190 if schedule.capability.trim().is_empty() {
1191 return Err(format!(
1192 "schedule '{}': capability must not be blank",
1193 schedule.id
1194 ));
1195 }
1196 if !provided.contains_key(schedule.capability.trim()) {
1197 return Err(format!(
1198 "schedule '{}' names capability '{}', which this app does not provide \
1199 (a schedule may only dispatch a capability declared in this manifest's \
1200 'provides')",
1201 schedule.id, schedule.capability
1202 ));
1203 }
1204 if let Some(payload) = &schedule.payload {
1205 if !payload.is_object() {
1206 return Err(format!(
1207 "schedule '{}': payload must be a JSON object",
1208 schedule.id
1209 ));
1210 }
1211 }
1212 }
1213 Ok(())
1214 }
1215
1216 pub fn validate(&self) -> Result<(), String> {
1221 self.validate_with_socket_path_policy(false)
1222 }
1223
1224 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1229 if self.manifest_version == 2 && self.abi.is_none() {
1231 return Err("manifest_version 2 requires an 'abi' field".to_string());
1232 }
1233
1234 validate_app_name(&self.name)?;
1237 self.resolved_requires()?;
1238
1239 if let Some(ref ep) = self.entrypoint {
1241 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1242 }
1243 if !self.ui_path.is_empty() && self.ui_path != "dist" {
1245 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1246 }
1247
1248 if let Some(ui) = &self.ui {
1249 validate_app_ui(&self.name, ui)?;
1250 }
1251
1252 if let Some(ref hp) = self.homepage {
1254 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1255 return Err(format!(
1256 "homepage '{}' must use https:// or http:// scheme",
1257 hp
1258 ));
1259 }
1260 }
1261
1262 match self.app_type {
1269 AppType::Standalone => {
1270 if self.has_capability_providers() {
1271 let cfg = self.standalone.as_ref().ok_or_else(|| {
1272 "standalone apps that declare 'provides' require a \
1273 'standalone.socket_path' field"
1274 .to_string()
1275 })?;
1276 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1277 }
1278 }
1279 AppType::Native
1280 | AppType::Bun
1281 | AppType::PlatformRuntime
1282 | AppType::ManagedV1
1283 | AppType::Burger
1284 | AppType::UiOnly => {
1285 if self.standalone.is_some() {
1286 return Err(format!(
1287 "'standalone' block is only valid when app_type == 'standalone' \
1288 (found app_type='{}')",
1289 self.app_type
1290 ));
1291 }
1292 }
1293 }
1294
1295 if self.app_type == AppType::UiOnly {
1296 validate_ui_only(self)?;
1297 }
1298
1299 if let Some(resources) = &self.resources {
1300 if resources.memory_mb == Some(0) {
1301 return Err("resources.memory_mb must be at least 1".to_string());
1302 }
1303 if let Some(deadline) = resources.callback_deadline_ms {
1304 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1305 return Err(format!(
1306 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1307 ));
1308 }
1309 }
1310 }
1311
1312 self.validate_schedules()?;
1313
1314 if self.app_type == AppType::PlatformRuntime
1315 && !self.resolved_capability_provides().is_empty()
1316 {
1317 return Err(
1318 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1319 );
1320 }
1321
1322 Ok(())
1323 }
1324
1325 pub fn from_json(json: &str) -> Result<Self, String> {
1327 Self::from_json_with_socket_path_policy(json, false)
1328 }
1329
1330 pub fn from_json_with_socket_path_policy(
1332 json: &str,
1333 allow_non_run: bool,
1334 ) -> Result<Self, String> {
1335 let mut manifest: Self =
1336 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1337 if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1338 manifest.app_type = AppType::UiOnly;
1339 }
1340 if manifest.ui.is_some() {
1341 manifest.has_ui = true;
1342 }
1343 manifest.validate_with_socket_path_policy(allow_non_run)?;
1344 Ok(manifest)
1345 }
1346}
1347
1348fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1349 let mut seen = HashSet::new();
1350 let mut resolved = Vec::new();
1351 for value in values {
1352 let requirement = value.trim();
1353 if requirement.is_empty() {
1354 return Err("capability requirements must not be blank".to_string());
1355 }
1356 if seen.insert(requirement.to_string()) {
1357 resolved.push(requirement.to_string());
1358 }
1359 }
1360 Ok(resolved)
1361}
1362
1363fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1367 match manifest.ui.as_ref().map(|ui| ui.kind) {
1369 Some(AppUiKind::Stage | AppUiKind::Widget) => {}
1370 None => {
1371 return Err(
1372 "ui-only apps must declare a ui block of kind \"stage\" or \"widget\"".to_string(),
1373 )
1374 }
1375 }
1376 match ui_only_process_declaration(manifest) {
1377 Some(what) => Err(format!(
1378 "ui-only apps have no backend process and must not declare {what}"
1379 )),
1380 None => Ok(()),
1381 }
1382}
1383
1384pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1389 if manifest.entrypoint.is_some() {
1390 Some("'entrypoint'")
1391 } else if manifest.has_capability_providers() {
1392 Some("capability providers ('provides' / 'capabilities.provides')")
1393 } else if manifest.tcp.is_some() {
1394 Some("a 'tcp' block")
1395 } else if manifest.resources.is_some() {
1396 Some("a 'resources' block")
1397 } else if manifest.standalone.is_some() {
1398 Some("a 'standalone' block")
1399 } else if !manifest.subscribes.is_empty() {
1400 Some("event subscriptions ('subscribes')")
1401 } else if manifest
1402 .depends_on
1403 .as_ref()
1404 .is_some_and(|deps| !deps.is_empty())
1405 {
1406 Some("dependencies ('depends_on')")
1407 } else {
1408 None
1409 }
1410}
1411
1412pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1425 manifest.as_object().is_some_and(|object| {
1426 let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1427 && !object.contains_key("app_type")
1428 && !object.contains_key("entrypoint");
1429 derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1430 })
1431}
1432
1433const UI_FORBIDDEN_CAPABILITY_PREFIXES: &[&str] = &["graph.runtime."];
1437
1438fn refuse_forbidden_ui_requirements(
1439 label: &str,
1440 requires: &AppUiRequirements,
1441) -> Result<(), String> {
1442 for (field, values) in [
1446 ("capabilities", &requires.capabilities),
1447 ("queries", &requires.queries),
1448 ("streams", &requires.streams),
1449 ] {
1450 refuse_forbidden_ui_scopes(&format!("{label}.{field}"), values)?;
1451 }
1452 Ok(())
1453}
1454
1455fn refuse_forbidden_ui_scopes(label: &str, scopes: &[String]) -> Result<(), String> {
1456 for scope in scopes {
1457 let name = scope.trim();
1458 let wildcard_root = name
1460 .strip_suffix('*')
1461 .filter(|_| name == "*" || name.ends_with(".*"));
1462 let covered = |prefix: &str| {
1463 name.starts_with(prefix) || wildcard_root.is_some_and(|root| prefix.starts_with(root))
1464 };
1465 if let Some(prefix) = UI_FORBIDDEN_CAPABILITY_PREFIXES
1466 .iter()
1467 .find(|prefix| covered(prefix))
1468 {
1469 return Err(format!(
1470 "{label} must not request '{name}': {prefix}* is reachable only through the agent app"
1471 ));
1472 }
1473 }
1474 Ok(())
1475}
1476
1477fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1478 if ui.ui_api != 1 && ui.ui_api != 2 {
1481 return Err(format!(
1482 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1483 ui.ui_api
1484 ));
1485 }
1486 if ui.title.trim().is_empty() {
1487 return Err("ui.title must not be blank".to_string());
1488 }
1489 refuse_forbidden_ui_requirements("ui.requires", &ui.requires)?;
1490 ui.requires.resolved()?;
1491 ui.requires.validate_commands()?;
1492 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1493 if let Some(icon) = &ui.icon {
1494 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1495 }
1496 if let Some(nav) = &ui.nav {
1497 if ui.kind == AppUiKind::Widget {
1498 return Err("widget ui must omit nav metadata".to_string());
1499 }
1500 if nav.section.trim().is_empty() {
1501 return Err("ui.nav.section must not be blank".to_string());
1502 }
1503 }
1504 if let Some(data) = &ui.data {
1508 validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1509 }
1510
1511 let mut composed = HashSet::new();
1512 for name in &ui.composes {
1513 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1514 if name == app_name {
1515 return Err("ui.composes must not contain the app itself".to_string());
1516 }
1517 if !composed.insert(name) {
1518 return Err(format!("ui.composes contains duplicate app '{name}'"));
1519 }
1520 }
1521
1522 let mut surface_ids = HashSet::new();
1523 for surface in &ui.surfaces {
1524 let id = surface.id.trim();
1525 if id.is_empty() {
1526 return Err("ui.surfaces entry id must not be blank".to_string());
1527 }
1528 if !surface_ids.insert(id.to_string()) {
1529 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1530 }
1531 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1532 return Err(format!(
1533 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1534 surface.slot,
1535 KNOWN_SURFACE_SLOTS.join(", ")
1536 ));
1537 }
1538 if surface.title.trim().is_empty() {
1539 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1540 }
1541 validate_manifest_path(&surface.entry)
1542 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1543 if !ui.integrity.contains_key(&surface.entry) {
1552 return Err(format!("ui.integrity must include surface '{id}' entry"));
1553 }
1554 refuse_forbidden_ui_requirements(
1555 &format!("ui.surfaces entry '{id}' requires"),
1556 &surface.requires,
1557 )?;
1558 surface
1559 .requires
1560 .resolved()
1561 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1562 surface
1563 .requires
1564 .validate_commands()
1565 .map_err(|error| format!("ui.surfaces entry '{id}': {error}"))?;
1566 }
1567
1568 if ui
1569 .surfaces
1570 .iter()
1571 .filter(|surface| surface.slot == ASSISTANT_SLOT)
1572 .count()
1573 > 1
1574 {
1575 return Err("ui.surfaces may declare at most one 'assistant' surface".to_string());
1576 }
1577
1578 for (path, digest) in &ui.integrity {
1579 validate_manifest_path(path)
1580 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1581 if !is_lowercase_sha256(digest) {
1582 return Err(format!(
1583 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1584 ));
1585 }
1586 }
1587 if !ui.integrity.contains_key(&ui.entry) {
1588 return Err("ui.integrity must include the declared entry".to_string());
1589 }
1590 if let Some(icon) = &ui.icon {
1591 if !ui.integrity.contains_key(icon) {
1592 return Err("ui.integrity must include the declared icon".to_string());
1593 }
1594 }
1595 Ok(())
1596}
1597
1598fn validate_app_data(
1599 app_name: &str,
1600 data: &AppDataManifest,
1601 resolved_requires: &[String],
1602) -> Result<(), String> {
1603 validate_app_data_namespace(&data.namespace)?;
1604 validate_app_data_namespace_owner(app_name, &data.namespace)?;
1605 validate_app_data_sync_policy(&data.sync)?;
1606 if data.queries.is_empty() && data.offline != AppDataOfflinePolicy::OnlineOnly {
1607 return Err("ui.data.queries must declare at least one query for last-known data".to_string());
1608 }
1609
1610 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1611 let mut names = BTreeSet::new();
1612 for query in &data.queries {
1613 validate_namespaced_data_name(&query.name, &data.namespace)
1614 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1615 validate_capability_name(&query.capability).map_err(|error| {
1616 format!(
1617 "ui.data query '{}' capability '{}' invalid: {error}",
1618 query.name, query.capability
1619 )
1620 })?;
1621 if !requires.contains(query.capability.as_str()) {
1622 return Err(format!(
1623 "ui.data query '{}' capability '{}' must be declared in requires",
1624 query.name, query.capability
1625 ));
1626 }
1627 if !names.insert(query.name.as_str()) {
1628 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1629 }
1630 }
1631
1632 for stream in &data.streams {
1633 validate_namespaced_data_name(&stream.name, &data.namespace)
1634 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1635 if !names.insert(stream.name.as_str()) {
1636 return Err(format!(
1637 "ui.data contains duplicate declaration '{}'",
1638 stream.name
1639 ));
1640 }
1641 }
1642
1643 Ok(())
1644}
1645
1646fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1647 if !is_safe_name_segment(namespace) {
1648 return Err(format!(
1649 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1650 namespace
1651 ));
1652 }
1653 if matches!(
1654 namespace,
1655 "core" | "internal" | "node" | "platform" | "system"
1656 ) {
1657 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1658 }
1659 Ok(())
1660}
1661
1662pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1673 if namespace != app_name {
1674 return Err(format!(
1675 "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1676 Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1677 to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1678 ));
1679 }
1680 Ok(())
1681}
1682
1683fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1684 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1685 validate_optional_range(
1686 "full_refresh_interval_secs",
1687 sync.full_refresh_interval_secs,
1688 60,
1689 604_800,
1690 )?;
1691 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1692 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1693 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1694 }
1695 Ok(())
1696}
1697
1698fn validate_optional_range(
1699 field: &str,
1700 value: Option<u32>,
1701 min: u32,
1702 max: u32,
1703) -> Result<(), String> {
1704 if let Some(value) = value {
1705 if value < min || value > max {
1706 return Err(format!(
1707 "ui.data.sync {field} must be between {min} and {max} seconds"
1708 ));
1709 }
1710 }
1711 Ok(())
1712}
1713
1714fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1715 validate_capability_name(name)?;
1716 let Some(rest) = name
1717 .strip_prefix(namespace)
1718 .and_then(|suffix| suffix.strip_prefix('.'))
1719 else {
1720 return Err(format!("name must use namespace '{namespace}'"));
1721 };
1722 if rest.is_empty() {
1723 return Err("name must include a value after its namespace".to_string());
1724 }
1725 if !has_version_suffix(name) {
1726 return Err("name must end with a .vN version suffix".to_string());
1727 }
1728 Ok(())
1729}
1730
1731fn validate_capability_name(name: &str) -> Result<(), String> {
1732 if name.is_empty() {
1733 return Err("name must not be empty".to_string());
1734 }
1735 if name.contains('/') || name.contains("..") {
1736 return Err("name must not contain path separators or traversal".to_string());
1737 }
1738 if !name.split('.').all(is_safe_declaration_segment) {
1739 return Err("name must contain only lowercase dot-separated segments".to_string());
1740 }
1741 Ok(())
1742}
1743
1744fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1745 if allow_wildcard && name.ends_with(".*") {
1746 return validate_capability_name(&name[..name.len() - 2]);
1747 }
1748 validate_capability_name(name)
1749}
1750
1751fn has_version_suffix(name: &str) -> bool {
1752 let Some(version) = name.rsplit('.').next() else {
1753 return false;
1754 };
1755 let Some(digits) = version.strip_prefix('v') else {
1756 return false;
1757 };
1758 !digits.is_empty()
1759 && !digits.starts_with('0')
1760 && digits.bytes().all(|byte| byte.is_ascii_digit())
1761}
1762
1763fn is_safe_name_segment(segment: &str) -> bool {
1764 if segment.is_empty() {
1765 return false;
1766 }
1767 let mut chars = segment.chars();
1768 let Some(first) = chars.next() else {
1769 return false;
1770 };
1771 first.is_ascii_lowercase()
1772 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1773}
1774
1775fn is_safe_declaration_segment(segment: &str) -> bool {
1788 let mut chars = segment.chars();
1789 let Some(first) = chars.next() else {
1790 return false;
1791 };
1792 first.is_ascii_lowercase()
1793 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1794}
1795
1796fn is_lowercase_sha256(value: &str) -> bool {
1797 value.len() == 64
1798 && value
1799 .bytes()
1800 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1801}
1802
1803pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1812 validate_standalone_socket_path_with_policy(path, false)
1813}
1814
1815pub fn validate_standalone_socket_path_with_policy(
1817 path: &std::path::Path,
1818 allow_non_run: bool,
1819) -> Result<(), String> {
1820 if !path.is_absolute() {
1821 return Err(format!(
1822 "standalone.socket_path '{}' must be absolute",
1823 path.display()
1824 ));
1825 }
1826 if !allow_non_run && !path.starts_with("/run/") {
1827 return Err(format!(
1828 "standalone.socket_path '{}' must live under /run/",
1829 path.display()
1830 ));
1831 }
1832 if path
1833 .components()
1834 .any(|c| matches!(c, std::path::Component::ParentDir))
1835 {
1836 return Err(format!(
1837 "standalone.socket_path '{}' must not contain '..' segments",
1838 path.display()
1839 ));
1840 }
1841 Ok(())
1842}
1843
1844fn validate_app_name(name: &str) -> Result<(), String> {
1848 let (publisher, app) = if let Some(slash) = name.find('/') {
1849 let (p, rest) = name.split_at(slash);
1850 (Some(p), &rest[1..])
1851 } else {
1852 (None, name)
1853 };
1854
1855 let valid_segment = |s: &str| -> bool {
1856 if s.is_empty() {
1857 return false;
1858 }
1859 let mut chars = s.chars();
1860 let first = chars.next().unwrap();
1861 if !first.is_ascii_lowercase() {
1862 return false;
1863 }
1864 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1865 };
1866
1867 if let Some(pub_name) = publisher {
1868 if !valid_segment(pub_name) {
1869 return Err(format!(
1870 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1871 pub_name
1872 ));
1873 }
1874 }
1875
1876 if !valid_segment(app) {
1877 return Err(format!(
1878 "app name segment '{}' must match [a-z][a-z0-9-]*",
1879 app
1880 ));
1881 }
1882
1883 Ok(())
1884}
1885
1886#[cfg(test)]
1889mod tests {
1890 use super::*;
1891
1892 fn parse_ok(json: &str) -> AppManifest {
1893 AppManifest::from_json(json).expect("should parse")
1894 }
1895
1896 fn parse_err(json: &str) -> String {
1897 AppManifest::from_json(json).expect_err("should fail")
1898 }
1899
1900 const SCHEDULED_APP: &str = r#"{
1903 "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1904 "provides":{"network.ledger.poll":{"description":"Record connections"}},
1905 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1906 "capability":"network.ledger.poll"}]
1907 }"#;
1908
1909 #[test]
1910 fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1911 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1914 assert!(m.schedules.is_empty());
1915 }
1916
1917 #[test]
1918 fn a_schedule_is_parsed_whole() {
1919 let m = parse_ok(SCHEDULED_APP);
1920 assert_eq!(m.schedules.len(), 1);
1921 let s = &m.schedules[0];
1922 assert_eq!(s.id, "ledger-poll");
1923 assert_eq!(s.cron, "0 */15 * * * *");
1924 assert_eq!(s.capability, "network.ledger.poll");
1925 assert_eq!(s.effective_payload(), serde_json::json!({}));
1928 }
1929
1930 #[test]
1931 fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1932 let err = parse_err(
1935 r#"{"name":"network","version":"1.0.0","app_type":"bun",
1936 "provides":{"network.ledger.poll":{"description":"x"}},
1937 "schedules":[{"id":"drain","cron":"0 0 * * * *",
1938 "capability":"core.lightning.send_payment"}]}"#,
1939 );
1940 assert!(err.contains("core.lightning.send_payment"), "{err}");
1941 assert!(err.contains("does not provide"), "{err}");
1942 }
1943
1944 #[test]
1945 fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1946 let m = parse_ok(
1949 r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1950 "app_type":"bun",
1951 "capabilities":{"provides":["network.ledger.poll"]},
1952 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1953 "capability":"network.ledger.poll"}]}"#,
1954 );
1955 assert_eq!(m.schedules.len(), 1);
1956 }
1957
1958 #[test]
1959 fn a_cron_expression_without_six_fields_is_refused() {
1960 let err = parse_err(
1962 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1963 "provides":{"n.poll":{"description":"x"}},
1964 "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1965 );
1966 assert!(err.contains("6 fields"), "{err}");
1967 }
1968
1969 #[test]
1970 fn a_cron_expression_with_junk_in_it_is_refused() {
1971 let err = parse_err(
1972 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1973 "provides":{"n.poll":{"description":"x"}},
1974 "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1975 );
1976 assert!(err.contains("disallowed character"), "{err}");
1977 }
1978
1979 #[test]
1980 fn two_schedules_cannot_share_an_id() {
1981 let err = parse_err(
1984 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1985 "provides":{"n.poll":{"description":"x"}},
1986 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1987 {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1988 );
1989 assert!(err.contains("duplicate schedule id"), "{err}");
1990 }
1991
1992 #[test]
1993 fn a_schedule_id_must_be_a_safe_token() {
1994 let err = parse_err(
1995 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1996 "provides":{"n.poll":{"description":"x"}},
1997 "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1998 );
1999 assert!(err.contains("schedule id"), "{err}");
2000 }
2001
2002 #[test]
2003 fn a_non_object_payload_is_refused() {
2004 let err = parse_err(
2005 r#"{"name":"n","version":"1.0.0","app_type":"bun",
2006 "provides":{"n.poll":{"description":"x"}},
2007 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
2008 "payload":"not-an-object"}]}"#,
2009 );
2010 assert!(err.contains("JSON object"), "{err}");
2011 }
2012
2013 #[test]
2014 fn a_declared_payload_survives_the_round_trip() {
2015 let m = parse_ok(
2016 r#"{"name":"n","version":"1.0.0","app_type":"bun",
2017 "provides":{"n.poll":{"description":"x"}},
2018 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
2019 "payload":{"depth":2}}]}"#,
2020 );
2021 assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
2022 let round_tripped: AppManifest =
2023 serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
2024 assert_eq!(round_tripped.schedules, m.schedules);
2025 }
2026
2027 #[test]
2028 fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
2029 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2031 let json = serde_json::to_string(&m).unwrap();
2032 assert!(!json.contains("schedules"), "{json}");
2033 }
2034
2035 #[test]
2038 fn v1_minimal_native() {
2039 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2040 assert_eq!(m.manifest_version, 1);
2041 assert_eq!(m.app_type, AppType::Native);
2042 assert!(m.abi.is_none());
2043 }
2044
2045 #[test]
2046 fn v1_minimal_bun() {
2047 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
2048 assert_eq!(m.app_type, AppType::Bun);
2049 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2050 }
2051
2052 #[test]
2053 fn v1_no_manifest_version_field_defaults_to_1() {
2054 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
2055 assert_eq!(m.manifest_version, 1);
2056 }
2057
2058 #[test]
2059 fn v1_all_optional_fields_missing() {
2060 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
2061 assert!(!m.critical);
2062 assert!(m.auto_start);
2063 assert!(!m.has_ui);
2064 assert_eq!(m.ui_path, "dist");
2065 assert!(m.permissions.is_empty());
2066 assert!(m.optional_permissions.is_empty());
2067 assert!(m.governor.is_none());
2070 assert!(m.subscribes.is_empty());
2071 }
2072
2073 #[test]
2074 fn v1_with_permissions_and_provides() {
2075 let json = r#"{
2076 "name": "example",
2077 "version": "1.0.0",
2078 "app_type": "bun",
2079 "permissions": ["core.storage.kv"],
2080 "optional_permissions": ["core.notifications.create"],
2081 "provides": {
2082 "core.example.run": { "description": "Run example job" }
2083 }
2084 }"#;
2085 let m = parse_ok(json);
2086 assert_eq!(m.permissions, vec!["core.storage.kv"]);
2087 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
2088 assert!(m.provides.contains_key("core.example.run"));
2089 }
2090
2091 #[test]
2094 fn provides_discoverable_defaults_true_and_parses_false() {
2095 let json = r#"{
2096 "name": "remote-support",
2097 "version": "1.0.0",
2098 "app_type": "bun",
2099 "capabilities": { "provides": ["remote_support.status", "remote_support.challenge"] },
2100 "provides": {
2101 "remote_support.status": { "description": "Status" },
2102 "remote_support.mode.set": { "description": "Set mode", "discoverable": false }
2103 }
2104 }"#;
2105 let m = parse_ok(json);
2106 assert!(m.provides["remote_support.status"].discoverable);
2107 assert!(!m.provides["remote_support.mode.set"].discoverable);
2108
2109 let resolved = m.resolved_capability_provides();
2110 assert!(!resolved["remote_support.mode.set"].discoverable);
2111 assert!(resolved["remote_support.status"].discoverable);
2112 assert!(resolved["remote_support.challenge"].discoverable);
2114
2115 let written = serde_json::to_value(&m).unwrap();
2116 assert_eq!(
2117 written["provides"]["remote_support.mode.set"]["discoverable"],
2118 serde_json::json!(false)
2119 );
2120 assert!(written["provides"]["remote_support.status"]
2121 .get("discoverable")
2122 .is_none());
2123 }
2124
2125 #[test]
2126 fn provides_discoverable_must_be_a_boolean() {
2127 let json = r#"{
2128 "name": "example",
2129 "version": "1.0.0",
2130 "app_type": "bun",
2131 "provides": { "example.run": { "discoverable": "no" } }
2132 }"#;
2133 assert!(AppManifest::from_json(json).is_err());
2134 }
2135
2136 #[test]
2139 fn v2_minimal_native() {
2140 let json = r#"{
2141 "manifest_version": 2,
2142 "name": "cron",
2143 "version": "1.0.0",
2144 "app_type": "native",
2145 "abi": "v1",
2146 "entrypoint": "app.so",
2147 "hot_reload": "experimental"
2148 }"#;
2149 let m = parse_ok(json);
2150 assert_eq!(m.manifest_version, 2);
2151 assert_eq!(m.abi, Some(AbiVersion::V1));
2152 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
2153 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
2154 }
2155
2156 #[test]
2157 fn v2_minimal_bun_with_capabilities() {
2158 let json = r#"{
2159 "manifest_version": 2,
2160 "name": "example-fullstack",
2161 "version": "1.0.0",
2162 "app_type": "bun",
2163 "abi": "v1",
2164 "entrypoint": "dist/index.js",
2165 "hot_reload": "supported",
2166 "has_ui": true,
2167 "ui_path": "ui/dist",
2168 "capabilities": {
2169 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
2170 "provides": []
2171 },
2172 "governor": { "terminable": false, "min_idle_secs": 300 },
2173 "subscribes": ["core.chat.message.received"]
2174 }"#;
2175 let m = parse_ok(json);
2176 assert_eq!(m.manifest_version, 2);
2177 assert_eq!(m.capabilities.requires.len(), 2);
2178 let governor = m.governor.expect("governor block should parse");
2180 assert_eq!(governor.terminable, Some(false));
2181 assert_eq!(governor.min_idle_secs, Some(300));
2182 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
2183 }
2184
2185 #[test]
2186 fn stage_contract_fixture_parses_with_normalized_requirements() {
2187 let json = include_str!(
2188 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2189 );
2190 let m = parse_ok(json);
2191 assert!(m.has_ui);
2192 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2193 let ui = m.ui.expect("fixture should declare ui");
2194 assert_eq!(ui.kind, AppUiKind::Stage);
2195 assert_eq!(ui.entry, "ui/main.js");
2196 assert_eq!(ui.nav.unwrap().order, 10);
2197 }
2198
2199 #[test]
2200 fn stage_layout_parses_validates_and_defaults_to_column() {
2201 let source = include_str!(
2202 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2203 );
2204 let mut value: serde_json::Value = serde_json::from_str(source).unwrap();
2205 assert_eq!(parse_ok(source).ui.unwrap().layout, None);
2206 assert_eq!(UiLayout::default(), UiLayout::Column);
2207 for (name, layout) in [("full", UiLayout::Full), ("column", UiLayout::Column)] {
2208 value["ui"]["layout"] = serde_json::json!(name);
2209 let manifest = parse_ok(&value.to_string());
2210 assert_eq!(manifest.ui.as_ref().unwrap().layout, Some(layout));
2211 validate_app_ui(&manifest.name, manifest.ui.as_ref().unwrap()).unwrap();
2212 assert_eq!(
2213 serde_json::to_value(manifest.ui.unwrap()).unwrap()["layout"],
2214 name
2215 );
2216 }
2217 value["ui"]["layout"] = serde_json::json!("wide");
2218 assert!(parse_err(&value.to_string()).contains("unknown variant"));
2219 }
2220
2221 #[test]
2222 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2223 let m = parse_ok(
2224 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2225 );
2226 assert!(m.has_ui);
2227 assert_eq!(m.ui_path, "ui/dist");
2228 assert!(m.ui.is_none());
2229 }
2230
2231 #[test]
2232 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2233 let manifest = parse_ok(
2234 r#"{
2235 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2236 "requires":["core.cron.register"],
2237 "ui":{
2238 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2239 "requires":{
2240 "capabilities":["ui.snapshot.v1"],
2241 "queries":["ui.query.v1"],
2242 "streams":["ui.event.v1"]
2243 },
2244 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2245 }
2246 }"#,
2247 );
2248
2249 assert_eq!(
2250 manifest.resolved_requires().unwrap(),
2251 vec!["core.cron.register"]
2252 );
2253 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2254 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2255 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2256 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2257 assert_eq!(
2258 ui.requires.resolved().unwrap(),
2259 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2260 );
2261 let serialized = serde_json::to_value(ui).unwrap();
2262 assert_eq!(
2263 serialized["requires"]["queries"],
2264 serde_json::json!(["ui.query.v1"])
2265 );
2266 }
2267
2268 #[test]
2269 fn ui_requirements_reject_blank_entries() {
2270 let error = parse_err(
2271 r#"{
2272 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2273 "ui":{
2274 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2275 "requires":{"capabilities":[""],"queries":[],"streams":[]},
2276 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2277 }
2278 }"#,
2279 );
2280 assert!(error.contains("ui.requires entries must not be blank"));
2281 }
2282
2283 #[test]
2284 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2285 assert!(validate_app_data_namespace("obs-viewer").is_ok());
2292
2293 let error = validate_app_data_namespace("obs_viewer")
2294 .expect_err("underscore must not be admitted into a storage namespace");
2295 assert!(
2296 error.contains("must match [a-z][a-z0-9-]*"),
2297 "unexpected error: {error}"
2298 );
2299 }
2300
2301 #[test]
2302 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2303 let error = parse_err(
2304 r#"{
2305 "name":"ui-data","version":"1.0.0","app_type":"bun",
2306 "requires":["ui.snapshot.v1"],
2307 "ui":{
2308 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2309 "requires":{"capabilities":[],"queries":[],"streams":[]},
2310 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2311 "data":{
2312 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2313 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2314 "streams":[]
2315 }
2316 }
2317 }"#,
2318 );
2319 assert!(error.contains("must be declared in requires"));
2320 }
2321
2322 #[test]
2327 fn ui_data_namespace_must_equal_the_app_name() {
2328 let manifest = |namespace: &str| {
2329 format!(
2330 r#"{{
2331 "name":"burger-runtime","version":"0.2.0",
2332 "ui":{{
2333 "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2334 "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2335 "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2336 "data":{{
2337 "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2338 "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2339 "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2340 }}
2341 }}
2342 }}"#
2343 )
2344 };
2345
2346 let error = parse_err(&manifest("burger"));
2347 assert!(
2348 error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2349 "unexpected error: {error}"
2350 );
2351
2352 let accepted = parse_ok(&manifest("burger-runtime"));
2353 let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2354 assert_eq!(
2355 data.map(|data| data.namespace.as_str()),
2356 Some("burger-runtime")
2357 );
2358 }
2359
2360 #[test]
2361 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2362 let accepted = parse_ok(
2363 r#"{
2364 "name":"aliases","version":"1.0.0","app_type":"bun",
2365 "requires":["core.chat.read","core.chat.read","core.chat.send"],
2366 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2367 }"#,
2368 );
2369 assert_eq!(
2370 accepted.resolved_requires().unwrap(),
2371 vec!["core.chat.read", "core.chat.send"]
2372 );
2373
2374 let err = parse_err(
2375 r#"{
2376 "name":"aliases","version":"1.0.0","app_type":"bun",
2377 "requires":["core.chat.read"],
2378 "capabilities":{"requires":["core.wallet.pay"]}
2379 }"#,
2380 );
2381 assert!(err.contains("conflicts"), "unexpected error: {err}");
2382 }
2383
2384 #[test]
2385 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2386 let base = |ui: &str| {
2387 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2388 };
2389 let widget = base(
2390 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2391 );
2392 assert_eq!(
2393 parse_ok(&widget).ui.expect("widget ui").kind,
2394 AppUiKind::Widget
2395 );
2396 let widget_nav = base(
2397 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2398 );
2399 assert!(parse_err(&widget_nav).contains("must omit nav"));
2400 let api = base(
2401 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2402 );
2403 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2404 let unsupported_api = base(
2405 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2406 );
2407 assert!(parse_err(&unsupported_api).contains("ui_api"));
2408 let path = base(
2409 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2410 );
2411 assert!(parse_err(&path).contains("entry"));
2412 }
2413
2414 #[test]
2415 fn stage_requires_integrity_for_entry_and_icon() {
2416 let missing_entry = r#"{
2417 "name":"stage","version":"1.0.0","app_type":"bun",
2418 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2419 }"#;
2420 assert!(parse_err(missing_entry).contains("entry"));
2421 let missing_icon = r#"{
2422 "name":"stage","version":"1.0.0","app_type":"bun",
2423 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2424 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2425 }"#;
2426 assert!(parse_err(missing_icon).contains("icon"));
2427 let uppercase = r#"{
2428 "name":"stage","version":"1.0.0","app_type":"bun",
2429 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2430 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2431 }"#;
2432 assert!(parse_err(uppercase).contains("lowercase"));
2433 }
2434
2435 #[test]
2436 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2437 let manifest = |composes: &str| {
2438 format!(
2439 r#"{{
2440 "name":"stage","version":"1.0.0","app_type":"bun",
2441 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2442 "composes":{composes},
2443 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2444 }}"#
2445 )
2446 };
2447 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2448 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2449 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2450 }
2451
2452 fn manifest_with_ui() -> AppManifest {
2464 parse_ok(
2465 r#"{
2466 "name":"stage","version":"1.0.0","app_type":"bun",
2467 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2468 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2469 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2470 }"#,
2471 )
2472 }
2473
2474 fn surface(id: &str, slot: &str) -> AppUiSurface {
2475 AppUiSurface {
2476 id: id.to_string(),
2477 slot: slot.to_string(),
2478 entry: "ui/dist/surfaces/chip.js".to_string(),
2479 title: "Chip".to_string(),
2480 order: 10,
2481 requires: AppUiRequirements {
2482 capabilities: vec!["wallet.balance.get".to_string()],
2483 ..Default::default()
2484 },
2485 }
2486 }
2487
2488 #[test]
2489 fn manifest_without_surfaces_still_parses() {
2490 let manifest = manifest_with_ui();
2491 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2492 assert!(manifest.validate().is_ok());
2493 }
2494
2495 #[test]
2496 fn surface_in_a_known_slot_is_accepted() {
2497 let mut manifest = manifest_with_ui();
2498 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2499 assert!(manifest.validate().is_ok());
2500 }
2501
2502 #[test]
2503 fn surface_in_an_unknown_slot_is_rejected() {
2504 let mut manifest = manifest_with_ui();
2505 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2506 let error = manifest.validate().unwrap_err();
2507 assert!(error.contains("menu-bar"), "unexpected error: {error}");
2508 }
2509
2510 #[test]
2511 fn duplicate_surface_ids_are_rejected() {
2512 let mut manifest = manifest_with_ui();
2513 manifest.ui.as_mut().unwrap().surfaces = vec![
2514 surface("chip", "status-rail"),
2515 surface("chip", "status-rail"),
2516 ];
2517 let error = manifest.validate().unwrap_err();
2518 assert!(error.contains("duplicate"), "unexpected error: {error}");
2519 }
2520
2521 #[test]
2522 fn surface_with_a_blank_id_is_rejected() {
2523 let mut manifest = manifest_with_ui();
2524 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
2525 assert!(manifest.validate().is_err());
2526 }
2527
2528 #[test]
2529 fn surface_with_an_unsafe_entry_path_is_rejected() {
2530 let mut manifest = manifest_with_ui();
2531 let mut bad = surface("chip", "status-rail");
2532 bad.entry = "../../etc/passwd".to_string();
2533 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2534 assert!(manifest.validate().is_err());
2535 }
2536
2537 #[test]
2538 fn surface_entry_missing_from_integrity_is_rejected() {
2539 let mut manifest = manifest_with_ui();
2543 let mut unpinned = surface("chip", "status-rail");
2544 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2545 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2546 let error = manifest.validate().unwrap_err();
2547 assert!(
2548 error.contains("ui.integrity must include surface 'chip' entry"),
2549 "unexpected error: {error}"
2550 );
2551 }
2552
2553 #[test]
2554 fn surface_with_a_blank_required_capability_is_rejected() {
2555 let mut manifest = manifest_with_ui();
2556 let mut bad = surface("chip", "status-rail");
2557 bad.requires.capabilities = vec![" ".to_string()];
2558 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2559 assert!(manifest.validate().is_err());
2560 }
2561
2562 #[test]
2563 fn a_stage_requesting_graph_runtime_is_rejected() {
2564 for name in ["graph.runtime.pi_mono.react", "graph.runtime.*", "graph.*"] {
2565 let mut manifest = manifest_with_ui();
2566 manifest
2567 .ui
2568 .as_mut()
2569 .unwrap()
2570 .requires
2571 .capabilities
2572 .push(name.to_string());
2573 let error = manifest.validate().unwrap_err();
2574 assert!(
2575 error.contains(&format!("ui.requires.capabilities must not request '{name}'")),
2576 "{name}: {error}"
2577 );
2578 }
2579 }
2580
2581 #[test]
2582 fn a_surface_requesting_graph_runtime_is_rejected() {
2583 let mut manifest = manifest_with_ui();
2584 let mut chip = surface("chip", "status-rail");
2585 chip.requires
2586 .capabilities
2587 .push("graph.runtime.pi_mono.react".to_string());
2588 manifest.ui.as_mut().unwrap().surfaces = vec![chip];
2589 let error = manifest.validate().unwrap_err();
2590 assert!(
2591 error.contains("ui.surfaces entry 'chip' requires.capabilities must not request 'graph.runtime.pi_mono.react'"),
2592 "{error}"
2593 );
2594 }
2595
2596 #[test]
2597 fn graph_capabilities_outside_the_runtime_are_still_accepted() {
2598 let mut manifest = manifest_with_ui();
2599 manifest
2600 .ui
2601 .as_mut()
2602 .unwrap()
2603 .requires
2604 .capabilities
2605 .push("graph.engine.flows.list".to_string());
2606 assert!(manifest.validate().is_ok());
2607 }
2608
2609 fn manifest_with_ui_requirement(
2612 surface_requirement: bool,
2613 field: &str,
2614 name: &str,
2615 ) -> AppManifest {
2616 let mut manifest = manifest_with_ui();
2617 let ui = manifest.ui.as_mut().unwrap();
2618 let requires = if surface_requirement {
2619 ui.surfaces.push(surface("chip", "status-rail"));
2620 &mut ui.surfaces[0].requires
2621 } else {
2622 &mut ui.requires
2623 };
2624 match field {
2625 "capabilities" => &mut requires.capabilities,
2626 "queries" => &mut requires.queries,
2627 "streams" => &mut requires.streams,
2628 _ => panic!("unknown requirement field"),
2629 }
2630 .push(name.to_string());
2631 manifest
2632 }
2633
2634 #[test]
2635 fn ui_runtime_scope_fields_reject_exact_names_and_covering_wildcards() {
2636 let mut failures = Vec::new();
2637 for is_surface in [false, true] {
2638 for field in ["capabilities", "queries", "streams"] {
2639 for name in [
2640 "graph.runtime.pi_mono.react",
2641 "graph.runtime.pi_mono.tool_result",
2642 "graph.runtime.pi_mono.card_resolve",
2643 "graph.runtime.pi_mono.pending",
2644 "graph.runtime.*",
2645 "graph.runtime.pi_mono.*",
2646 "graph.*",
2647 "*",
2648 "graph.runtime.",
2649 " graph.runtime.pi_mono.react ",
2650 " * ",
2651 ] {
2652 let manifest = manifest_with_ui_requirement(is_surface, field, name);
2653 let label = if is_surface {
2654 "ui.surfaces entry 'chip' requires"
2655 } else {
2656 "ui.requires"
2657 };
2658 let expected = format!(
2659 "{label}.{field} must not request '{}': graph.runtime.* is reachable only through the agent app",
2660 name.trim()
2661 );
2662 if manifest.validate() != Err(expected.clone()) {
2663 failures.push(format!("{expected}; got {:?}", manifest.validate()));
2664 }
2665 }
2666 }
2667 }
2668 assert!(failures.is_empty(), "{}", failures.join("\n"));
2669 }
2670
2671 #[test]
2672 fn ui_runtime_boundary_preserves_unrelated_names_and_capability_wildcards() {
2673 for is_surface in [false, true] {
2674 for field in ["capabilities", "queries", "streams"] {
2675 for name in [
2676 "graph.engine.flows.list",
2677 "graph.runtime_tools.read",
2678 "graph.runtimes.read",
2679 "other.graph.runtime.read",
2680 "agent.prompt",
2681 "observability.snapshot",
2682 "core.diag.snapshot",
2683 "core.apps.restart",
2684 ] {
2685 let result = manifest_with_ui_requirement(is_surface, field, name).validate();
2686 assert!(result.is_ok(), "{is_surface}/{field}/{name}: {result:?}");
2687 }
2688 }
2689 for name in [
2690 "graph.engine.*",
2691 "graph.runtime_tools.*",
2692 "agent.*",
2693 "core.*",
2694 ] {
2695 let result =
2696 manifest_with_ui_requirement(is_surface, "capabilities", name).validate();
2697 assert!(result.is_ok(), "{is_surface}/{name}: {result:?}");
2698 }
2699 }
2700 }
2701
2702 #[test]
2703 fn ui_runtime_boundary_does_not_relax_requirement_syntax() {
2704 for is_surface in [false, true] {
2705 for field in ["capabilities", "queries", "streams"] {
2706 for name in [
2707 "",
2708 " ",
2709 "graph*",
2710 "gra*",
2711 "graph..read",
2712 "Graph.read",
2713 "agent/read",
2714 "agent.",
2715 ] {
2716 let error = manifest_with_ui_requirement(is_surface, field, name)
2717 .validate()
2718 .unwrap_err();
2719 assert!(
2720 !error.contains("reachable only through the agent app"),
2721 "{error}"
2722 );
2723 }
2724 }
2725 for field in ["queries", "streams"] {
2726 for name in ["graph.engine.*", "agent.*"] {
2727 let error = manifest_with_ui_requirement(is_surface, field, name)
2728 .validate()
2729 .unwrap_err();
2730 assert!(error.contains("invalid"), "{error}");
2731 }
2732 }
2733 }
2734 for field in ["capabilities", "queries", "streams"] {
2736 let manifest = manifest_with_ui_requirement(false, field, "*");
2737 assert!(manifest.ui.unwrap().requires.resolved().is_err());
2738 }
2739 }
2740
2741 #[test]
2742 fn ui_runtime_boundary_preserves_backend_requirements() {
2743 for nested in [false, true] {
2744 let mut manifest = manifest_with_ui();
2745 let requires = if nested {
2746 &mut manifest.capabilities.requires
2747 } else {
2748 &mut manifest.requires
2749 };
2750 *requires = vec![
2751 "graph.runtime.pi_mono.react".to_string(),
2752 "graph.runtime.*".to_string(),
2753 ];
2754 assert!(manifest.validate().is_ok());
2755 assert_eq!(
2756 manifest.resolved_requires().unwrap(),
2757 vec!["graph.runtime.pi_mono.react", "graph.runtime.*"]
2758 );
2759 }
2760 }
2761
2762 #[test]
2763 fn v2_missing_abi_is_error() {
2764 let json = r#"{
2765 "manifest_version": 2,
2766 "name": "example",
2767 "version": "1.0.0",
2768 "app_type": "bun"
2769 }"#;
2770 let err = parse_err(json);
2771 assert!(err.contains("abi"), "expected abi error, got: {}", err);
2772 }
2773
2774 #[test]
2777 fn publisher_prefixed_name_accepted() {
2778 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2779 assert_eq!(m.name, "alice/weather");
2780 }
2781
2782 #[test]
2783 fn double_slash_name_rejected() {
2784 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2785 assert!(!err.is_empty());
2786 }
2787
2788 #[test]
2791 fn name_starting_with_digit_rejected() {
2792 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2793 assert!(!err.is_empty());
2794 }
2795
2796 #[test]
2797 fn name_with_uppercase_rejected() {
2798 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2799 assert!(!err.is_empty());
2800 }
2801
2802 #[test]
2803 fn empty_name_rejected() {
2804 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2805 assert!(!err.is_empty());
2806 }
2807
2808 #[test]
2811 fn path_traversal_double_dot_rejected() {
2812 let json = r#"{
2813 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2814 "app_type": "bun", "abi": "v1",
2815 "entrypoint": "../etc/passwd"
2816 }"#;
2817 let err = parse_err(json);
2818 assert!(err.contains(".."), "expected traversal error, got: {}", err);
2819 }
2820
2821 #[test]
2822 fn path_traversal_encoded_dot_not_decoded() {
2823 let json = r#"{
2825 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2826 "app_type": "bun", "abi": "v1",
2827 "entrypoint": "foo/../bar"
2828 }"#;
2829 let err = parse_err(json);
2830 assert!(!err.is_empty(), "should have failed: {}", err);
2831 }
2832
2833 #[test]
2834 fn absolute_path_rejected() {
2835 let json = r#"{
2836 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2837 "app_type": "bun", "abi": "v1",
2838 "entrypoint": "/usr/bin/sh"
2839 }"#;
2840 let err = parse_err(json);
2841 assert!(
2842 err.contains("absolute"),
2843 "expected absolute error, got: {}",
2844 err
2845 );
2846 }
2847
2848 #[test]
2849 fn shell_metachar_in_path_rejected() {
2850 let json = r#"{
2851 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2852 "app_type": "bun", "abi": "v1",
2853 "entrypoint": "dist/index.js;rm -rf /"
2854 }"#;
2855 let err = parse_err(json);
2856 assert!(!err.is_empty());
2857 }
2858
2859 #[test]
2860 fn valid_nested_path_accepted() {
2861 let json = r#"{
2862 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2863 "app_type": "bun", "abi": "v1",
2864 "entrypoint": "dist/index.js",
2865 "ui_path": "ui/dist"
2866 }"#;
2867 parse_ok(json);
2868 }
2869
2870 #[test]
2873 fn homepage_https_accepted() {
2874 let json = r#"{
2875 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2876 "homepage": "https://example.com"
2877 }"#;
2878 parse_ok(json);
2879 }
2880
2881 #[test]
2882 fn homepage_javascript_scheme_rejected() {
2883 let json = r#"{
2884 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2885 "homepage": "javascript:alert(1)"
2886 }"#;
2887 let err = parse_err(json);
2888 assert!(
2889 err.contains("scheme"),
2890 "expected scheme error, got: {}",
2891 err
2892 );
2893 }
2894
2895 #[test]
2896 fn homepage_file_scheme_rejected() {
2897 let json = r#"{
2898 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2899 "homepage": "file:///etc/passwd"
2900 }"#;
2901 let err = parse_err(json);
2902 assert!(!err.is_empty());
2903 }
2904
2905 #[test]
2908 fn effective_entrypoint_native_default() {
2909 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2910 assert_eq!(m.effective_entrypoint(), "app.so");
2911 }
2912
2913 #[test]
2914 fn effective_entrypoint_bun_default() {
2915 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2916 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2917 }
2918
2919 #[test]
2920 fn effective_hot_reload_native_default_is_experimental() {
2921 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2922 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2923 }
2924
2925 #[test]
2926 fn effective_hot_reload_bun_default_is_supported() {
2927 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2928 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2929 }
2930
2931 #[test]
2932 fn hot_reload_unsupported_explicit() {
2933 let json = r#"{
2934 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2935 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2936 }"#;
2937 let m = parse_ok(json);
2938 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2939 }
2940
2941 #[test]
2944 fn validate_path_simple_valid() {
2945 assert!(validate_manifest_path("dist/index.js").is_ok());
2946 assert!(validate_manifest_path("app.so").is_ok());
2947 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2948 assert!(validate_manifest_path("build_output/main").is_ok());
2949 }
2950
2951 #[test]
2952 fn validate_path_empty_rejected() {
2953 assert!(validate_manifest_path("").is_err());
2954 }
2955
2956 #[test]
2957 fn validate_path_absolute_rejected() {
2958 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2959 }
2960
2961 #[test]
2962 fn validate_path_double_dot_segment_rejected() {
2963 assert!(validate_manifest_path("foo/../bar").is_err());
2964 assert!(validate_manifest_path("../etc/passwd").is_err());
2965 }
2966
2967 #[test]
2968 fn validate_path_leading_dot_rejected() {
2969 assert!(validate_manifest_path(".hidden").is_err());
2970 }
2971
2972 #[test]
2973 fn validate_path_null_byte_rejected() {
2974 let path = "foo\0bar";
2976 assert!(validate_manifest_path(path).is_err());
2977 }
2978
2979 #[test]
2980 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2981 let path = std::path::Path::new("/tmp/node-app/example.sock");
2982 assert!(validate_standalone_socket_path(path).is_err());
2983 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2984 assert!(validate_standalone_socket_path_with_policy(
2985 std::path::Path::new("/tmp/node-app/../escape.sock"),
2986 true,
2987 )
2988 .is_err());
2989 }
2990
2991 #[test]
2994 fn manifest_capabilities_defaults_to_empty() {
2995 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2996 assert!(m.capabilities.requires.is_empty());
2997 assert!(m.capabilities.provides.is_empty());
2998 }
2999
3000 #[test]
3003 fn resolved_capability_provides_v1_only() {
3004 let json = r#"{
3005 "name": "example", "version": "1.0.0", "app_type": "bun",
3006 "provides": { "core.example.run": { "description": "Run example job" } }
3007 }"#;
3008 let m = parse_ok(json);
3009 let out = m.resolved_capability_provides();
3010 assert_eq!(out.len(), 1);
3011 assert_eq!(
3012 out.get("core.example.run").unwrap().description,
3013 "Run example job"
3014 );
3015 }
3016
3017 #[test]
3018 fn resolved_capability_provides_v2_names_get_blank_declaration() {
3019 let json = r#"{
3020 "manifest_version": 2, "name": "example", "version": "1.0.0",
3021 "app_type": "bun", "abi": "v1",
3022 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
3023 }"#;
3024 let m = parse_ok(json);
3025 let out = m.resolved_capability_provides();
3026 assert_eq!(out.len(), 2);
3027 assert_eq!(out.get("core.example.run").unwrap().description, "");
3028 assert!(out.get("core.example.run").unwrap().schema.is_none());
3029 assert!(out.contains_key("core.example.other"));
3031 assert!(!out.contains_key("core.example.other:extra"));
3032 }
3033
3034 #[test]
3035 fn resolved_capability_provides_v1_wins_on_conflict() {
3036 let json = r#"{
3037 "manifest_version": 2, "name": "example", "version": "1.0.0",
3038 "app_type": "bun", "abi": "v1",
3039 "provides": { "core.example.run": { "description": "v1 wins" } },
3040 "capabilities": { "requires": [], "provides": ["core.example.run"] }
3041 }"#;
3042 let m = parse_ok(json);
3043 let out = m.resolved_capability_provides();
3044 assert_eq!(out.len(), 1);
3045 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
3046 }
3047
3048 #[test]
3049 fn resolved_capability_provides_blank_v2_name_skipped() {
3050 let json = r#"{
3051 "manifest_version": 2, "name": "example", "version": "1.0.0",
3052 "app_type": "bun", "abi": "v1",
3053 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
3054 }"#;
3055 let m = parse_ok(json);
3056 let out = m.resolved_capability_provides();
3057 assert_eq!(out.len(), 1);
3058 assert!(out.contains_key("core.example.run"));
3059 }
3060
3061 #[test]
3062 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
3063 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
3064 assert!(m.resolved_capability_provides().is_empty());
3065 }
3066
3067 #[test]
3070 fn abi_v1_is_supported() {
3071 assert!(AbiVersion::V1.is_supported());
3072 }
3073
3074 #[test]
3077 fn app_tier_display() {
3078 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
3079 assert_eq!(AppTier::Optional.to_string(), "optional");
3080 assert_eq!(AppTier::Development.to_string(), "development");
3081 }
3082
3083 #[test]
3084 fn app_tier_serde_roundtrip() {
3085 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
3087 let json = serde_json::to_string(&tier).unwrap();
3088 let back: AppTier = serde_json::from_str(&json).unwrap();
3089 assert_eq!(
3090 tier, back,
3091 "roundtrip failed for {:?}: serialized as {}",
3092 tier, json
3093 );
3094 }
3095 assert_eq!(
3096 serde_json::to_string(&AppTier::Development).unwrap(),
3097 "\"development\""
3098 );
3099 }
3100
3101 #[test]
3104 fn app_type_display() {
3105 assert_eq!(AppType::Native.to_string(), "native");
3106 assert_eq!(AppType::Bun.to_string(), "bun");
3107 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
3108 }
3109
3110 #[test]
3111 fn platform_runtime_is_a_supported_packaging_type() {
3112 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
3113 "manifest_version": 2,
3114 "abi": "v1",
3115 "name": "bun-runtime",
3116 "version": "1.0.0",
3117 "app_type": "platform-runtime",
3118 "entrypoint": "bun"
3119 }))
3120 .expect("platform runtime manifest should parse");
3121
3122 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
3123 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
3124 }
3125
3126 #[test]
3129 fn governor_manifest_memory_budget_defaults_to_none() {
3130 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
3131 assert_eq!(parsed.memory_budget_kb, None);
3132 }
3133
3134 #[test]
3135 fn governor_manifest_parses_declared_memory_budget() {
3136 let parsed: GovernorManifest =
3137 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
3138 assert_eq!(parsed.memory_budget_kb, Some(40_960));
3139 }
3140
3141 #[test]
3144 fn latency_class_parses_and_defaults_none() {
3145 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
3146 assert_eq!(parsed.latency_class, None);
3147 }
3148
3149 #[test]
3150 fn latency_class_parses_declared_interactive() {
3151 let json = r#"{
3152 "name": "example",
3153 "version": "1.0.0",
3154 "app_type": "bun",
3155 "governor": {"latency_class": "interactive"}
3156 }"#;
3157 let m = parse_ok(json);
3158 let governor = m.governor.expect("governor block should parse");
3159 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
3160 }
3161
3162 #[test]
3163 fn latency_class_parses_declared_background() {
3164 let parsed: GovernorManifest =
3165 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
3166 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
3167 }
3168
3169 #[test]
3170 fn latency_class_invalid_value_is_parse_error() {
3171 let result: Result<GovernorManifest, _> =
3172 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
3173 assert!(result.is_err(), "unknown latency_class value must fail to parse");
3174 }
3175
3176 #[test]
3177 fn latency_class_as_str_and_from_str_roundtrip() {
3178 for class in [LatencyClass::Interactive, LatencyClass::Background] {
3179 let s = class.as_str();
3180 assert_eq!(LatencyClass::from_str(s), Ok(class));
3181 }
3182 assert!(LatencyClass::from_str("urgent").is_err());
3183 }
3184
3185 fn ui_only_manifest() -> serde_json::Value {
3188 serde_json::json!({
3189 "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
3190 "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
3191 "ui": {
3192 "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
3193 "nav": { "section": "system", "order": 90 }, "ui_api": 1,
3194 "requires": {
3195 "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
3196 "queries": [], "streams": ["app.burger_metrics"]
3197 },
3198 "data": {
3199 "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
3200 "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
3201 "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
3202 },
3203 "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
3204 }
3205 })
3206 }
3207
3208 fn ui_only_with(key: &str, value: serde_json::Value) -> String {
3209 let mut manifest = ui_only_manifest();
3210 manifest[key] = value;
3211 manifest.to_string()
3212 }
3213
3214 #[test]
3215 fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
3216 let m = parse_ok(&ui_only_manifest().to_string());
3217 assert_eq!(m.app_type, AppType::UiOnly);
3218 assert_eq!(m.app_type.as_str(), "ui-only");
3219 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
3220 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
3221 assert!(m.has_ui);
3222 let ui = m.ui.as_ref().expect("a ui block");
3223 assert_eq!(ui.kind, AppUiKind::Stage);
3224 let data = ui.data.as_ref().expect("the F5 ui.data block validates");
3225 assert_eq!(data.namespace, "burger-runtime");
3226 assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
3227 assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
3228 assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
3229 }
3230
3231 #[test]
3232 fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
3233 let m = parse_ok(&ui_only_manifest().to_string());
3234 let wire = serde_json::to_string(&m).unwrap();
3235 assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
3236 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
3237 }
3238
3239 #[test]
3240 fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
3241 for (key, value) in [
3242 (
3243 "provides",
3244 serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
3245 ),
3246 (
3247 "capabilities",
3248 serde_json::json!({ "provides": ["burger.runtime.peek"] }),
3249 ),
3250 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
3251 ("resources", serde_json::json!({ "memory_mb": 16 })),
3252 (
3253 "standalone",
3254 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
3255 ),
3256 ("subscribes", serde_json::json!(["system.network.changed"])),
3257 ("depends_on", serde_json::json!(["cron"])),
3258 ] {
3259 let err = parse_err(&ui_only_with(key, value));
3260 assert!(err.contains("ui-only"), "{key}: {err}");
3261 }
3262 let mut explicit_with_entry = ui_only_manifest();
3263 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
3264 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
3265 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
3266 }
3267
3268 #[test]
3269 fn a_ui_only_manifest_must_declare_a_ui_block() {
3270 let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
3271 assert!(parse_err(no_ui).contains(r#"kind "stage" or "widget""#));
3272 }
3273
3274 fn ui_only_widget_manifest() -> serde_json::Value {
3278 serde_json::json!({
3279 "manifest_version": 2, "abi": "v1", "name": "agent-chat", "version": "1.0.0",
3280 "has_ui": true, "ui_path": "ui/dist",
3281 "ui": {
3282 "kind": "widget", "entry": "ui/dist/main.js", "title": "Agent chat", "ui_api": 2,
3283 "requires": {
3284 "capabilities": ["agent.prompt", "agent.conversation.entries"],
3285 "queries": [], "streams": ["app.agent_session"]
3286 },
3287 "integrity": { "ui/dist/main.js": "a".repeat(64) }
3288 }
3289 })
3290 }
3291
3292 fn ui_only_widget_with(key: &str, value: serde_json::Value) -> String {
3293 let mut manifest = ui_only_widget_manifest();
3294 manifest[key] = value;
3295 manifest.to_string()
3296 }
3297
3298 #[test]
3299 fn a_widget_manifest_without_app_type_or_entrypoint_is_ui_only() {
3300 let m = parse_ok(&ui_only_widget_manifest().to_string());
3301 assert_eq!(m.app_type, AppType::UiOnly);
3302 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
3303 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
3304 assert!(m.has_ui);
3305 assert_eq!(m.ui.as_ref().expect("a ui block").kind, AppUiKind::Widget);
3306
3307 let mut explicit = ui_only_widget_manifest();
3308 explicit["app_type"] = serde_json::json!("ui-only");
3309 let m = parse_ok(&explicit.to_string());
3310 assert_eq!(m.app_type, AppType::UiOnly);
3311 let wire = serde_json::to_string(&m).unwrap();
3312 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly, "{wire}");
3313 }
3314
3315 #[test]
3316 fn a_ui_only_widget_refuses_everything_that_implies_a_process() {
3317 for (key, value) in [
3318 (
3319 "provides",
3320 serde_json::json!({ "agent-chat.widget.peek": { "description": "x" } }),
3321 ),
3322 (
3323 "capabilities",
3324 serde_json::json!({ "provides": ["agent-chat.widget.peek"] }),
3325 ),
3326 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
3327 ("resources", serde_json::json!({ "memory_mb": 16 })),
3328 (
3329 "standalone",
3330 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
3331 ),
3332 ("subscribes", serde_json::json!(["system.network.changed"])),
3333 ("depends_on", serde_json::json!(["agent"])),
3334 ] {
3335 let err = parse_err(&ui_only_widget_with(key, value));
3336 assert!(err.contains("ui-only"), "{key}: {err}");
3337 }
3338 let mut explicit_with_entry = ui_only_widget_manifest();
3339 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
3340 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
3341 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
3342 }
3343
3344 #[test]
3345 fn a_ui_only_widget_keeps_the_widget_rules() {
3346 let mut with_nav = ui_only_widget_manifest();
3347 with_nav["ui"]["nav"] = serde_json::json!({ "section": "system", "order": 1 });
3348 assert!(parse_err(&with_nav.to_string()).contains("widget ui must omit nav metadata"));
3349
3350 let data = |namespace: &str| {
3351 serde_json::json!({
3352 "namespace": namespace, "offline": "online-only", "sync": "snapshot",
3353 "queries": [], "streams": []
3354 })
3355 };
3356 let mut foreign_data = ui_only_widget_manifest();
3357 foreign_data["ui"]["data"] = data("agent");
3358 assert!(parse_err(&foreign_data.to_string()).contains("must equal the app name"));
3359
3360 let mut own_data = ui_only_widget_manifest();
3361 own_data["ui"]["data"] = data("agent-chat");
3362 assert_eq!(parse_ok(&own_data.to_string()).app_type, AppType::UiOnly);
3363 }
3364
3365 #[test]
3368 fn a_widget_with_a_backend_is_not_ui_only() {
3369 let mut bun = ui_only_widget_manifest();
3370 bun["app_type"] = serde_json::json!("bun");
3371 bun["entrypoint"] = serde_json::json!("dist/index.js");
3372 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
3373 }
3374
3375 #[test]
3376 fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
3377 assert_eq!(
3378 parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
3379 AppType::Native
3380 );
3381 let mut with_entry = ui_only_manifest();
3382 with_entry["entrypoint"] = serde_json::json!("app.so");
3383 assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
3384 let mut bun = ui_only_manifest();
3385 bun["app_type"] = serde_json::json!("bun");
3386 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
3387 }
3388
3389 #[test]
3390 fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
3391 assert!(declares_ui_only(&ui_only_manifest()));
3392 assert!(declares_ui_only(
3393 &serde_json::json!({ "app_type": "ui-only" })
3394 ));
3395 assert!(!declares_ui_only(
3396 &serde_json::json!({ "app_type": "bun", "ui": {} })
3397 ));
3398 assert!(!declares_ui_only(
3399 &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
3400 ));
3401 assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
3402 assert!(!declares_ui_only(&serde_json::json!("ui-only")));
3403 }
3404}
3405
3406#[cfg(test)]
3407mod shared_app_data_corpus_tests {
3408 use super::*;
3409
3410 #[test]
3415 fn shared_app_data_corpus_matches_the_host_validator() {
3416 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
3417 let mut checked = 0;
3418 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
3419 let path = entry.expect("readable entry").path();
3420 if path.extension().and_then(|e| e.to_str()) != Some("json") {
3421 continue;
3422 }
3423 let fixture: serde_json::Value =
3424 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
3425 .expect("valid fixture json");
3426 let name = path
3427 .file_name()
3428 .and_then(|n| n.to_str())
3429 .unwrap_or("?")
3430 .to_string();
3431 let manifest = serde_json::json!({
3435 "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
3436 "app_type": "bun", "entrypoint": "dist/index.js",
3437 "ui": fixture["ui"],
3438 });
3439 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
3440 match fixture["expect"].as_str().expect("expect field") {
3441 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
3442 "reject" => {
3443 let error = result.expect_err(&format!("{name} should be rejected"));
3444 let reason = fixture["reason"]
3445 .as_str()
3446 .expect("reject fixtures need a reason");
3447 assert!(
3448 error.contains(reason),
3449 "{name}: {error:?} should mention {reason:?}"
3450 );
3451 }
3452 other => panic!("{name}: unknown expect {other:?}"),
3453 }
3454 checked += 1;
3455 }
3456 assert!(checked >= 10, "expected the full corpus, walked {checked}");
3458 }
3459}
3460
3461#[cfg(test)]
3462mod burger_manifest_tests {
3463 use super::*;
3464
3465 #[test]
3466 fn burger_app_type_parses_with_bundle_entrypoint_default() {
3467 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
3468 .expect("burger manifest parses");
3469 assert_eq!(m.app_type, AppType::Burger);
3470 assert_eq!(m.app_type.as_str(), "burger");
3471 assert_eq!(m.effective_entrypoint(), "dist/index.js");
3472 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
3473 assert!(m.resources.is_none());
3474 }
3475
3476 #[test]
3477 fn resources_memory_mb_is_carried() {
3478 let m = AppManifest::from_json(
3479 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
3480 )
3481 .expect("resources block parses");
3482 assert_eq!(
3483 m.resources,
3484 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
3485 );
3486 }
3487
3488 #[test]
3489 fn resources_callback_deadline_ms_is_carried() {
3490 let m = AppManifest::from_json(
3491 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
3492 )
3493 .expect("callback deadline parses");
3494 assert_eq!(
3495 m.resources,
3496 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
3497 );
3498 let max = AppManifest::from_json(
3499 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3500 )
3501 .expect("the maximum is inclusive");
3502 assert_eq!(
3503 max.resources.and_then(|r| r.callback_deadline_ms),
3504 Some(MAX_CALLBACK_DEADLINE_MS)
3505 );
3506 }
3507
3508 #[test]
3509 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3510 for bad in ["0", "600001"] {
3511 let json = format!(
3512 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3513 );
3514 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3515 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3516 }
3517 }
3518
3519 #[test]
3520 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3521 for bad in ["-1", "1.5", "\"5000\""] {
3522 let json = format!(
3523 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3524 );
3525 assert!(
3526 AppManifest::from_json(&json).is_err(),
3527 "callback_deadline_ms={bad} must be rejected"
3528 );
3529 }
3530 }
3531
3532 #[test]
3533 fn resources_memory_mb_zero_is_rejected() {
3534 let err = AppManifest::from_json(
3535 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3536 )
3537 .expect_err("a zero memory limit is invalid");
3538 assert!(err.contains("resources.memory_mb"), "{err}");
3539 }
3540
3541 #[test]
3542 fn burger_manifest_rejects_a_standalone_block() {
3543 let err = AppManifest::from_json(
3544 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3545 )
3546 .expect_err("standalone block is only valid for standalone apps");
3547 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3548 }
3549}