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")]
65 UiOnly,
66}
67
68impl AppType {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 AppType::Native => "native",
72 AppType::Bun => "bun",
73 AppType::Standalone => "standalone",
74 AppType::PlatformRuntime => "platform-runtime",
75 AppType::ManagedV1 => "managed-v1",
76 AppType::Burger => "burger",
77 AppType::UiOnly => "ui-only",
78 }
79 }
80}
81
82impl std::fmt::Display for AppType {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "{}", self.as_str())
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum AppTier {
95 FirstParty,
99 Optional,
103 Development,
114}
115
116impl AppTier {
117 pub fn as_str(self) -> &'static str {
118 match self {
119 AppTier::FirstParty => "first_party",
120 AppTier::Optional => "optional",
121 AppTier::Development => "development",
122 }
123 }
124}
125
126impl std::fmt::Display for AppTier {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 write!(f, "{}", self.as_str())
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum AbiVersion {
139 V1,
140}
141
142impl AbiVersion {
143 pub fn as_str(&self) -> &'static str {
144 match self {
145 AbiVersion::V1 => "v1",
146 }
147 }
148
149 pub fn is_supported(&self) -> bool {
151 matches!(self, AbiVersion::V1)
152 }
153}
154
155impl std::fmt::Display for AbiVersion {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 write!(f, "{}", self.as_str())
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum HotReloadKind {
168 Supported,
170 Experimental,
180 Unsupported,
182}
183
184impl HotReloadKind {
185 pub fn default_for(app_type: AppType) -> Self {
186 match app_type {
187 AppType::Native => HotReloadKind::Experimental,
188 AppType::Bun => HotReloadKind::Supported,
189 AppType::Standalone => HotReloadKind::Unsupported,
192 AppType::PlatformRuntime => HotReloadKind::Unsupported,
193 AppType::ManagedV1 => HotReloadKind::Supported,
194 AppType::Burger => HotReloadKind::Supported,
195 AppType::UiOnly => HotReloadKind::Unsupported,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct ManifestCapabilities {
209 #[serde(default)]
212 pub requires: Vec<String>,
213
214 #[serde(default)]
217 pub provides: Vec<String>,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize, Default)]
222pub struct ProvidedScope {
223 pub scope: String,
224 pub description: String,
225 pub resource_pattern: String,
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct EndpointPolicy {
231 pub method: String,
232 pub path: String,
233 pub required_permissions: Vec<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ProvidedCapability {
239 #[serde(default)]
240 pub description: String,
241 #[serde(default)]
242 pub schema: Option<serde_json::Value>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct StandaloneConfig {
257 pub socket_path: std::path::PathBuf,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum AppUiKind {
264 Stage,
265 Widget,
266}
267
268fn default_app_ui_kind() -> AppUiKind {
269 AppUiKind::Stage
270}
271
272fn default_nav_section() -> String {
273 "default".to_string()
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct AppUiNav {
279 #[serde(default = "default_nav_section")]
280 pub section: String,
281 #[serde(default)]
282 pub order: i32,
283}
284
285pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct AppUiSurface {
307 pub id: String,
308 pub slot: String,
309 pub entry: String,
310 pub title: String,
311 #[serde(default)]
312 pub order: i32,
313 #[serde(default)]
314 pub requires: AppUiRequirements,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "kebab-case")]
320pub enum AppDataOfflinePolicy {
321 LastKnown,
323 OnlineOnly,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "kebab-case")]
330pub enum AppDataQueryKind {
331 Collection,
332 Detail,
333 Snapshot,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "kebab-case")]
339pub enum AppDataStreamKind {
340 Changes,
341 Events,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum AppDataSyncKind {
348 Cursor,
349 Snapshot,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct AppDataSyncPolicy {
355 pub kind: AppDataSyncKind,
356 pub cursor_ttl_secs: Option<u32>,
357 pub full_refresh_interval_secs: Option<u32>,
358 pub retention_secs: Option<u32>,
359}
360
361impl Serialize for AppDataSyncPolicy {
362 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
363 where
364 S: serde::Serializer,
365 {
366 use serde::ser::SerializeStruct;
367
368 if self.cursor_ttl_secs.is_none()
369 && self.full_refresh_interval_secs.is_none()
370 && self.retention_secs.is_none()
371 {
372 return self.kind.serialize(serializer);
373 }
374
375 let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
376 state.serialize_field("kind", &self.kind)?;
377 if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
378 state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
379 }
380 if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
381 state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
382 }
383 if let Some(retention_secs) = self.retention_secs {
384 state.serialize_field("retention_secs", &retention_secs)?;
385 }
386 state.end()
387 }
388}
389
390impl<'de> Deserialize<'de> for AppDataSyncPolicy {
391 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
392 where
393 D: serde::Deserializer<'de>,
394 {
395 #[derive(Deserialize)]
396 #[serde(deny_unknown_fields)]
397 struct ObjectPolicy {
398 kind: AppDataSyncKind,
399 #[serde(default)]
400 cursor_ttl_secs: Option<u32>,
401 #[serde(default)]
402 full_refresh_interval_secs: Option<u32>,
403 #[serde(default)]
404 retention_secs: Option<u32>,
405 }
406
407 #[derive(Deserialize)]
408 #[serde(untagged)]
409 enum WirePolicy {
410 Kind(AppDataSyncKind),
411 Object(ObjectPolicy),
412 }
413
414 match WirePolicy::deserialize(deserializer)? {
415 WirePolicy::Kind(kind) => Ok(Self {
416 kind,
417 cursor_ttl_secs: None,
418 full_refresh_interval_secs: None,
419 retention_secs: None,
420 }),
421 WirePolicy::Object(policy) => Ok(Self {
422 kind: policy.kind,
423 cursor_ttl_secs: policy.cursor_ttl_secs,
424 full_refresh_interval_secs: policy.full_refresh_interval_secs,
425 retention_secs: policy.retention_secs,
426 }),
427 }
428 }
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct AppDataQueryDeclaration {
435 pub name: String,
436 pub capability: String,
437 pub kind: AppDataQueryKind,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct AppDataStreamDeclaration {
444 pub name: String,
445 pub kind: AppDataStreamKind,
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct AppDataManifest {
452 pub namespace: String,
453 pub offline: AppDataOfflinePolicy,
454 pub sync: AppDataSyncPolicy,
455 #[serde(default)]
456 pub queries: Vec<AppDataQueryDeclaration>,
457 #[serde(default)]
458 pub streams: Vec<AppDataStreamDeclaration>,
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
466#[serde(deny_unknown_fields)]
467pub struct AppUiRequirements {
468 #[serde(default)]
469 pub capabilities: Vec<String>,
470 #[serde(default)]
471 pub queries: Vec<String>,
472 #[serde(default)]
473 pub streams: Vec<String>,
474}
475
476impl AppUiRequirements {
477 pub fn resolved(&self) -> Result<Vec<String>, String> {
481 let mut resolved = Vec::new();
482 let mut seen = HashSet::new();
483 for (values, allow_wildcard) in [
484 (&self.capabilities, true),
485 (&self.queries, false),
486 (&self.streams, false),
487 ] {
488 for value in values {
489 let requirement = value.trim();
490 if requirement.is_empty() {
491 return Err("ui.requires entries must not be blank".to_string());
492 }
493 validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
494 format!("ui.requires entry '{requirement}' invalid: {error}")
495 })?;
496 if seen.insert(requirement.to_string()) {
497 resolved.push(requirement.to_string());
498 }
499 }
500 }
501 Ok(resolved)
502 }
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct AppUiManifest {
508 #[serde(default = "default_app_ui_kind")]
509 pub kind: AppUiKind,
510 pub entry: String,
511 pub title: String,
512 #[serde(default)]
513 pub icon: Option<String>,
514 #[serde(default)]
515 pub nav: Option<AppUiNav>,
516 #[serde(default)]
517 pub composes: Vec<String>,
518 #[serde(default)]
520 pub surfaces: Vec<AppUiSurface>,
521 pub ui_api: u8,
522 #[serde(default)]
523 pub integrity: BTreeMap<String, String>,
524 #[serde(default)]
528 pub description: Option<String>,
529 #[serde(default)]
532 pub keywords: Vec<String>,
533 #[serde(default)]
536 pub requires: AppUiRequirements,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub data: Option<AppDataManifest>,
539}
540
541pub fn validate_manifest_path(path: &str) -> Result<(), String> {
553 if path.is_empty() {
554 return Err("path must not be empty".to_string());
555 }
556
557 if path.starts_with('/') {
559 return Err(format!(
560 "path '{}' must not be absolute (starts with /)",
561 path
562 ));
563 }
564
565 let first = path.chars().next().unwrap();
568 if !first.is_ascii_alphanumeric() && first != '_' {
569 return Err(format!(
570 "path '{}' must begin with an alphanumeric character or underscore",
571 path
572 ));
573 }
574 for ch in path.chars().skip(1) {
575 if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
576 return Err(format!(
577 "path '{}' contains disallowed character '{}'",
578 path, ch
579 ));
580 }
581 }
582
583 for segment in path.split('/') {
585 if segment == ".." {
586 return Err(format!(
587 "path '{}' contains a '..' segment (path traversal rejected)",
588 path
589 ));
590 }
591 }
592
593 Ok(())
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct AppManifest {
605 #[serde(default = "default_manifest_version", rename = "manifest_version")]
607 pub manifest_version: u8,
608
609 pub name: String,
610 pub version: String,
611
612 #[serde(default = "default_app_type_native")]
613 pub app_type: AppType,
614
615 #[serde(default)]
616 pub description: String,
617
618 pub abi: Option<AbiVersion>,
621
622 pub entrypoint: Option<String>,
625
626 pub hot_reload: Option<HotReloadKind>,
629
630 #[serde(default)]
632 pub critical: bool,
633
634 #[serde(
635 default = "default_auto_start",
636 deserialize_with = "deserialize_auto_start"
637 )]
638 pub auto_start: bool,
639
640 #[serde(default)]
641 pub has_ui: bool,
642
643 #[serde(default = "default_ui_path")]
644 pub ui_path: String,
645
646 #[serde(default)]
647 pub permissions: Vec<String>,
648
649 #[serde(default)]
652 pub requires: Vec<String>,
653
654 #[serde(default)]
655 pub optional_permissions: Vec<String>,
656
657 #[serde(default)]
658 pub provides_scopes: Vec<ProvidedScope>,
659
660 #[serde(default)]
661 pub endpoint_policies: Vec<EndpointPolicy>,
662
663 #[serde(default)]
664 pub capability_scopes: HashMap<String, String>,
665
666 #[serde(default)]
667 pub provides: HashMap<String, ProvidedCapability>,
668
669 #[serde(default)]
671 pub capabilities: ManifestCapabilities,
672
673 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub ui: Option<AppUiManifest>,
677
678 #[serde(default)]
680 pub author: Option<String>,
681
682 #[serde(default)]
683 pub homepage: Option<String>,
684
685 #[serde(default)]
686 pub depends_on: Option<Vec<String>>,
687
688 #[serde(default)]
689 pub boot_priority: Option<u32>,
690
691 #[serde(default)]
695 pub governor: Option<GovernorManifest>,
696
697 #[serde(default)]
703 pub subscribes: Vec<String>,
704
705 #[serde(default)]
709 pub standalone: Option<StandaloneConfig>,
710
711 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub tcp: Option<TcpManifest>,
715
716 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub resources: Option<ResourcesManifest>,
720
721 #[serde(default, skip_serializing_if = "Vec::is_empty")]
728 pub schedules: Vec<AppScheduleManifest>,
729}
730
731pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
733
734#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
740pub struct ResourcesManifest {
741 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub memory_mb: Option<u32>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub callback_deadline_ms: Option<u32>,
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
753#[serde(rename_all = "lowercase")]
754pub enum LatencyClass {
755 Interactive,
758 Background,
761}
762
763impl LatencyClass {
764 pub fn as_str(&self) -> &'static str {
765 match self {
766 LatencyClass::Interactive => "interactive",
767 LatencyClass::Background => "background",
768 }
769 }
770
771 #[allow(clippy::should_implement_trait)]
772 pub fn from_str(s: &str) -> Result<Self, String> {
773 match s {
774 "interactive" => Ok(LatencyClass::Interactive),
775 "background" => Ok(LatencyClass::Background),
776 _ => Err(format!("Invalid LatencyClass: {}", s)),
777 }
778 }
779}
780
781impl std::fmt::Display for LatencyClass {
782 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783 write!(f, "{}", self.as_str())
784 }
785}
786
787#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
790pub struct GovernorManifest {
791 #[serde(default)]
795 pub terminable: Option<bool>,
796
797 #[serde(default)]
801 pub min_idle_secs: Option<u64>,
802
803 #[serde(default)]
836 pub memory_budget_kb: Option<u64>,
837
838 #[serde(default)]
843 pub latency_class: Option<LatencyClass>,
844}
845
846#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
850pub struct TcpManifest {
851 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub preferred_port: Option<u16>,
856
857 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub direct_bind: Option<bool>,
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
884pub struct AppScheduleManifest {
885 pub id: String,
892
893 pub cron: String,
902
903 pub capability: String,
911
912 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub payload: Option<serde_json::Value>,
917}
918
919impl AppScheduleManifest {
920 pub fn effective_payload(&self) -> serde_json::Value {
922 self.payload
923 .clone()
924 .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
925 }
926}
927
928fn cron_field_char_allowed(ch: char) -> bool {
933 ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
934}
935
936fn validate_cron_shape(expression: &str) -> Result<(), String> {
939 let fields: Vec<&str> = expression.split_whitespace().collect();
940 if fields.len() != 6 {
941 return Err(format!(
942 "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
943 expression,
944 fields.len()
945 ));
946 }
947 for field in fields {
948 if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
949 return Err(format!(
950 "cron '{}' contains disallowed character '{}'",
951 expression, ch
952 ));
953 }
954 }
955 Ok(())
956}
957
958fn validate_schedule_id(id: &str) -> Result<(), String> {
961 if id.is_empty() {
962 return Err("schedule id must not be empty".to_string());
963 }
964 let first = id.chars().next().unwrap();
965 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
966 return Err(format!(
967 "schedule id '{}' must begin with a lowercase letter or digit",
968 id
969 ));
970 }
971 for ch in id.chars() {
972 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
973 return Err(format!(
974 "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
975 id, ch
976 ));
977 }
978 }
979 Ok(())
980}
981
982fn default_manifest_version() -> u8 {
983 1
984}
985
986fn default_auto_start() -> bool {
987 true
988}
989
990fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
996where
997 D: serde::Deserializer<'de>,
998{
999 #[derive(Deserialize)]
1000 #[serde(untagged)]
1001 enum BoolOrStr {
1002 Bool(bool),
1003 Str(String),
1004 }
1005 Ok(match BoolOrStr::deserialize(deserializer)? {
1006 BoolOrStr::Bool(b) => b,
1007 BoolOrStr::Str(s) => matches!(
1008 s.trim().to_ascii_lowercase().as_str(),
1009 "true" | "eager" | "active" | "auto" | "on" | "1"
1010 ),
1011 })
1012}
1013
1014fn default_ui_path() -> String {
1015 "dist".to_string()
1016}
1017
1018fn default_app_type_native() -> AppType {
1019 AppType::Native
1020}
1021
1022impl AppManifest {
1023 pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1027 let top = normalized_requirements(&self.requires)?;
1028 let nested = normalized_requirements(&self.capabilities.requires)?;
1029 if !top.is_empty()
1030 && !nested.is_empty()
1031 && top.iter().cloned().collect::<BTreeSet<_>>()
1032 != nested.iter().cloned().collect::<BTreeSet<_>>()
1033 {
1034 return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1035 }
1036 Ok(if !top.is_empty() { top } else { nested })
1037 }
1038
1039 pub fn effective_hot_reload(&self) -> HotReloadKind {
1042 self.hot_reload
1043 .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1044 }
1045
1046 pub fn effective_entrypoint(&self) -> &str {
1052 if let Some(ref ep) = self.entrypoint {
1053 ep.as_str()
1054 } else {
1055 match self.app_type {
1056 AppType::Native => "app.so",
1057 AppType::Bun => "dist/index.js",
1058 AppType::Standalone => "",
1059 AppType::PlatformRuntime => "bun",
1060 AppType::ManagedV1 => "llmc-generated-app",
1061 AppType::Burger => "dist/index.js",
1062 AppType::UiOnly => "",
1063 }
1064 }
1065 }
1066
1067 pub fn has_capability_providers(&self) -> bool {
1070 !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1071 }
1072
1073 pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1087 let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1088 for raw in &self.capabilities.provides {
1089 let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1090 if name.is_empty() {
1091 continue;
1092 }
1093 out.entry(name).or_insert(ProvidedCapability {
1094 description: String::new(),
1095 schema: None,
1096 });
1097 }
1098 out
1099 }
1100
1101 fn validate_schedules(&self) -> Result<(), String> {
1111 if self.schedules.is_empty() {
1112 return Ok(());
1113 }
1114 let provided = self.resolved_capability_provides();
1115 let mut seen: HashSet<&str> = HashSet::new();
1116 for schedule in &self.schedules {
1117 validate_schedule_id(&schedule.id)?;
1118 if !seen.insert(schedule.id.as_str()) {
1119 return Err(format!("duplicate schedule id '{}'", schedule.id));
1120 }
1121 validate_cron_shape(&schedule.cron)
1122 .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1123 if schedule.capability.trim().is_empty() {
1124 return Err(format!(
1125 "schedule '{}': capability must not be blank",
1126 schedule.id
1127 ));
1128 }
1129 if !provided.contains_key(schedule.capability.trim()) {
1130 return Err(format!(
1131 "schedule '{}' names capability '{}', which this app does not provide \
1132 (a schedule may only dispatch a capability declared in this manifest's \
1133 'provides')",
1134 schedule.id, schedule.capability
1135 ));
1136 }
1137 if let Some(payload) = &schedule.payload {
1138 if !payload.is_object() {
1139 return Err(format!(
1140 "schedule '{}': payload must be a JSON object",
1141 schedule.id
1142 ));
1143 }
1144 }
1145 }
1146 Ok(())
1147 }
1148
1149 pub fn validate(&self) -> Result<(), String> {
1154 self.validate_with_socket_path_policy(false)
1155 }
1156
1157 pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1162 if self.manifest_version == 2 && self.abi.is_none() {
1164 return Err("manifest_version 2 requires an 'abi' field".to_string());
1165 }
1166
1167 validate_app_name(&self.name)?;
1170 self.resolved_requires()?;
1171
1172 if let Some(ref ep) = self.entrypoint {
1174 validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1175 }
1176 if !self.ui_path.is_empty() && self.ui_path != "dist" {
1178 validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1179 }
1180
1181 if let Some(ui) = &self.ui {
1182 validate_app_ui(&self.name, ui)?;
1183 }
1184
1185 if let Some(ref hp) = self.homepage {
1187 if !hp.starts_with("https://") && !hp.starts_with("http://") {
1188 return Err(format!(
1189 "homepage '{}' must use https:// or http:// scheme",
1190 hp
1191 ));
1192 }
1193 }
1194
1195 match self.app_type {
1202 AppType::Standalone => {
1203 if self.has_capability_providers() {
1204 let cfg = self.standalone.as_ref().ok_or_else(|| {
1205 "standalone apps that declare 'provides' require a \
1206 'standalone.socket_path' field"
1207 .to_string()
1208 })?;
1209 validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1210 }
1211 }
1212 AppType::Native
1213 | AppType::Bun
1214 | AppType::PlatformRuntime
1215 | AppType::ManagedV1
1216 | AppType::Burger
1217 | AppType::UiOnly => {
1218 if self.standalone.is_some() {
1219 return Err(format!(
1220 "'standalone' block is only valid when app_type == 'standalone' \
1221 (found app_type='{}')",
1222 self.app_type
1223 ));
1224 }
1225 }
1226 }
1227
1228 if self.app_type == AppType::UiOnly {
1229 validate_ui_only(self)?;
1230 }
1231
1232 if let Some(resources) = &self.resources {
1233 if resources.memory_mb == Some(0) {
1234 return Err("resources.memory_mb must be at least 1".to_string());
1235 }
1236 if let Some(deadline) = resources.callback_deadline_ms {
1237 if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1238 return Err(format!(
1239 "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1240 ));
1241 }
1242 }
1243 }
1244
1245 self.validate_schedules()?;
1246
1247 if self.app_type == AppType::PlatformRuntime
1248 && !self.resolved_capability_provides().is_empty()
1249 {
1250 return Err(
1251 "platform-runtime packages cannot provide runtime capabilities".to_string(),
1252 );
1253 }
1254
1255 Ok(())
1256 }
1257
1258 pub fn from_json(json: &str) -> Result<Self, String> {
1260 Self::from_json_with_socket_path_policy(json, false)
1261 }
1262
1263 pub fn from_json_with_socket_path_policy(
1265 json: &str,
1266 allow_non_run: bool,
1267 ) -> Result<Self, String> {
1268 let mut manifest: Self =
1269 serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1270 if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1271 manifest.app_type = AppType::UiOnly;
1272 }
1273 if manifest.ui.is_some() {
1274 manifest.has_ui = true;
1275 }
1276 manifest.validate_with_socket_path_policy(allow_non_run)?;
1277 Ok(manifest)
1278 }
1279}
1280
1281fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1282 let mut seen = HashSet::new();
1283 let mut resolved = Vec::new();
1284 for value in values {
1285 let requirement = value.trim();
1286 if requirement.is_empty() {
1287 return Err("capability requirements must not be blank".to_string());
1288 }
1289 if seen.insert(requirement.to_string()) {
1290 resolved.push(requirement.to_string());
1291 }
1292 }
1293 Ok(resolved)
1294}
1295
1296fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1298 match &manifest.ui {
1299 Some(ui) if ui.kind == AppUiKind::Stage => {}
1300 _ => return Err("ui-only apps must declare a ui block of kind \"stage\"".to_string()),
1301 }
1302 match ui_only_process_declaration(manifest) {
1303 Some(what) => Err(format!(
1304 "ui-only apps have no backend process and must not declare {what}"
1305 )),
1306 None => Ok(()),
1307 }
1308}
1309
1310pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1315 if manifest.entrypoint.is_some() {
1316 Some("'entrypoint'")
1317 } else if manifest.has_capability_providers() {
1318 Some("capability providers ('provides' / 'capabilities.provides')")
1319 } else if manifest.tcp.is_some() {
1320 Some("a 'tcp' block")
1321 } else if manifest.resources.is_some() {
1322 Some("a 'resources' block")
1323 } else if manifest.standalone.is_some() {
1324 Some("a 'standalone' block")
1325 } else if !manifest.subscribes.is_empty() {
1326 Some("event subscriptions ('subscribes')")
1327 } else if manifest
1328 .depends_on
1329 .as_ref()
1330 .is_some_and(|deps| !deps.is_empty())
1331 {
1332 Some("dependencies ('depends_on')")
1333 } else {
1334 None
1335 }
1336}
1337
1338pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1351 manifest.as_object().is_some_and(|object| {
1352 let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1353 && !object.contains_key("app_type")
1354 && !object.contains_key("entrypoint");
1355 derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1356 })
1357}
1358
1359fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1360 if ui.ui_api != 1 && ui.ui_api != 2 {
1361 return Err(format!(
1362 "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1363 ui.ui_api
1364 ));
1365 }
1366 if ui.title.trim().is_empty() {
1367 return Err("ui.title must not be blank".to_string());
1368 }
1369 ui.requires.resolved()?;
1370 validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1371 if let Some(icon) = &ui.icon {
1372 validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1373 }
1374 if let Some(nav) = &ui.nav {
1375 if ui.kind == AppUiKind::Widget {
1376 return Err("widget ui must omit nav metadata".to_string());
1377 }
1378 if nav.section.trim().is_empty() {
1379 return Err("ui.nav.section must not be blank".to_string());
1380 }
1381 }
1382 if let Some(data) = &ui.data {
1383 if ui.kind != AppUiKind::Stage {
1384 return Err("widget ui must omit app data declarations".to_string());
1385 }
1386 validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1387 }
1388
1389 let mut composed = HashSet::new();
1390 for name in &ui.composes {
1391 validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1392 if name == app_name {
1393 return Err("ui.composes must not contain the app itself".to_string());
1394 }
1395 if !composed.insert(name) {
1396 return Err(format!("ui.composes contains duplicate app '{name}'"));
1397 }
1398 }
1399
1400 let mut surface_ids = HashSet::new();
1401 for surface in &ui.surfaces {
1402 let id = surface.id.trim();
1403 if id.is_empty() {
1404 return Err("ui.surfaces entry id must not be blank".to_string());
1405 }
1406 if !surface_ids.insert(id.to_string()) {
1407 return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1408 }
1409 if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1410 return Err(format!(
1411 "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1412 surface.slot,
1413 KNOWN_SURFACE_SLOTS.join(", ")
1414 ));
1415 }
1416 if surface.title.trim().is_empty() {
1417 return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1418 }
1419 validate_manifest_path(&surface.entry)
1420 .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1421 if !ui.integrity.contains_key(&surface.entry) {
1430 return Err(format!("ui.integrity must include surface '{id}' entry"));
1431 }
1432 surface
1433 .requires
1434 .resolved()
1435 .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1436 }
1437
1438 for (path, digest) in &ui.integrity {
1439 validate_manifest_path(path)
1440 .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1441 if !is_lowercase_sha256(digest) {
1442 return Err(format!(
1443 "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1444 ));
1445 }
1446 }
1447 if !ui.integrity.contains_key(&ui.entry) {
1448 return Err("ui.integrity must include the declared entry".to_string());
1449 }
1450 if let Some(icon) = &ui.icon {
1451 if !ui.integrity.contains_key(icon) {
1452 return Err("ui.integrity must include the declared icon".to_string());
1453 }
1454 }
1455 Ok(())
1456}
1457
1458fn validate_app_data(
1459 app_name: &str,
1460 data: &AppDataManifest,
1461 resolved_requires: &[String],
1462) -> Result<(), String> {
1463 validate_app_data_namespace(&data.namespace)?;
1464 validate_app_data_namespace_owner(app_name, &data.namespace)?;
1465 validate_app_data_sync_policy(&data.sync)?;
1466 if data.queries.is_empty() {
1467 return Err("ui.data.queries must declare at least one query".to_string());
1468 }
1469
1470 let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1471 let mut names = BTreeSet::new();
1472 for query in &data.queries {
1473 validate_namespaced_data_name(&query.name, &data.namespace)
1474 .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1475 validate_capability_name(&query.capability).map_err(|error| {
1476 format!(
1477 "ui.data query '{}' capability '{}' invalid: {error}",
1478 query.name, query.capability
1479 )
1480 })?;
1481 if !requires.contains(query.capability.as_str()) {
1482 return Err(format!(
1483 "ui.data query '{}' capability '{}' must be declared in requires",
1484 query.name, query.capability
1485 ));
1486 }
1487 if !names.insert(query.name.as_str()) {
1488 return Err(format!("ui.data contains duplicate query '{}'", query.name));
1489 }
1490 }
1491
1492 for stream in &data.streams {
1493 validate_namespaced_data_name(&stream.name, &data.namespace)
1494 .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1495 if !names.insert(stream.name.as_str()) {
1496 return Err(format!(
1497 "ui.data contains duplicate declaration '{}'",
1498 stream.name
1499 ));
1500 }
1501 }
1502
1503 Ok(())
1504}
1505
1506fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1507 if !is_safe_name_segment(namespace) {
1508 return Err(format!(
1509 "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1510 namespace
1511 ));
1512 }
1513 if matches!(
1514 namespace,
1515 "core" | "internal" | "node" | "platform" | "system"
1516 ) {
1517 return Err(format!("ui.data namespace '{namespace}' is reserved"));
1518 }
1519 Ok(())
1520}
1521
1522pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1533 if namespace != app_name {
1534 return Err(format!(
1535 "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1536 Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1537 to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1538 ));
1539 }
1540 Ok(())
1541}
1542
1543fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1544 validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1545 validate_optional_range(
1546 "full_refresh_interval_secs",
1547 sync.full_refresh_interval_secs,
1548 60,
1549 604_800,
1550 )?;
1551 validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1552 if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1553 return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1554 }
1555 Ok(())
1556}
1557
1558fn validate_optional_range(
1559 field: &str,
1560 value: Option<u32>,
1561 min: u32,
1562 max: u32,
1563) -> Result<(), String> {
1564 if let Some(value) = value {
1565 if value < min || value > max {
1566 return Err(format!(
1567 "ui.data.sync {field} must be between {min} and {max} seconds"
1568 ));
1569 }
1570 }
1571 Ok(())
1572}
1573
1574fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1575 validate_capability_name(name)?;
1576 let Some(rest) = name
1577 .strip_prefix(namespace)
1578 .and_then(|suffix| suffix.strip_prefix('.'))
1579 else {
1580 return Err(format!("name must use namespace '{namespace}'"));
1581 };
1582 if rest.is_empty() {
1583 return Err("name must include a value after its namespace".to_string());
1584 }
1585 if !has_version_suffix(name) {
1586 return Err("name must end with a .vN version suffix".to_string());
1587 }
1588 Ok(())
1589}
1590
1591fn validate_capability_name(name: &str) -> Result<(), String> {
1592 if name.is_empty() {
1593 return Err("name must not be empty".to_string());
1594 }
1595 if name.contains('/') || name.contains("..") {
1596 return Err("name must not contain path separators or traversal".to_string());
1597 }
1598 if !name.split('.').all(is_safe_declaration_segment) {
1599 return Err("name must contain only lowercase dot-separated segments".to_string());
1600 }
1601 Ok(())
1602}
1603
1604fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1605 if allow_wildcard && name.ends_with(".*") {
1606 return validate_capability_name(&name[..name.len() - 2]);
1607 }
1608 validate_capability_name(name)
1609}
1610
1611fn has_version_suffix(name: &str) -> bool {
1612 let Some(version) = name.rsplit('.').next() else {
1613 return false;
1614 };
1615 let Some(digits) = version.strip_prefix('v') else {
1616 return false;
1617 };
1618 !digits.is_empty()
1619 && !digits.starts_with('0')
1620 && digits.bytes().all(|byte| byte.is_ascii_digit())
1621}
1622
1623fn is_safe_name_segment(segment: &str) -> bool {
1624 if segment.is_empty() {
1625 return false;
1626 }
1627 let mut chars = segment.chars();
1628 let Some(first) = chars.next() else {
1629 return false;
1630 };
1631 first.is_ascii_lowercase()
1632 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1633}
1634
1635fn is_safe_declaration_segment(segment: &str) -> bool {
1648 let mut chars = segment.chars();
1649 let Some(first) = chars.next() else {
1650 return false;
1651 };
1652 first.is_ascii_lowercase()
1653 && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1654}
1655
1656fn is_lowercase_sha256(value: &str) -> bool {
1657 value.len() == 64
1658 && value
1659 .bytes()
1660 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1661}
1662
1663pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1672 validate_standalone_socket_path_with_policy(path, false)
1673}
1674
1675pub fn validate_standalone_socket_path_with_policy(
1677 path: &std::path::Path,
1678 allow_non_run: bool,
1679) -> Result<(), String> {
1680 if !path.is_absolute() {
1681 return Err(format!(
1682 "standalone.socket_path '{}' must be absolute",
1683 path.display()
1684 ));
1685 }
1686 if !allow_non_run && !path.starts_with("/run/") {
1687 return Err(format!(
1688 "standalone.socket_path '{}' must live under /run/",
1689 path.display()
1690 ));
1691 }
1692 if path
1693 .components()
1694 .any(|c| matches!(c, std::path::Component::ParentDir))
1695 {
1696 return Err(format!(
1697 "standalone.socket_path '{}' must not contain '..' segments",
1698 path.display()
1699 ));
1700 }
1701 Ok(())
1702}
1703
1704fn validate_app_name(name: &str) -> Result<(), String> {
1708 let (publisher, app) = if let Some(slash) = name.find('/') {
1709 let (p, rest) = name.split_at(slash);
1710 (Some(p), &rest[1..])
1711 } else {
1712 (None, name)
1713 };
1714
1715 let valid_segment = |s: &str| -> bool {
1716 if s.is_empty() {
1717 return false;
1718 }
1719 let mut chars = s.chars();
1720 let first = chars.next().unwrap();
1721 if !first.is_ascii_lowercase() {
1722 return false;
1723 }
1724 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1725 };
1726
1727 if let Some(pub_name) = publisher {
1728 if !valid_segment(pub_name) {
1729 return Err(format!(
1730 "publisher segment '{}' must match [a-z][a-z0-9-]*",
1731 pub_name
1732 ));
1733 }
1734 }
1735
1736 if !valid_segment(app) {
1737 return Err(format!(
1738 "app name segment '{}' must match [a-z][a-z0-9-]*",
1739 app
1740 ));
1741 }
1742
1743 Ok(())
1744}
1745
1746#[cfg(test)]
1749mod tests {
1750 use super::*;
1751
1752 fn parse_ok(json: &str) -> AppManifest {
1753 AppManifest::from_json(json).expect("should parse")
1754 }
1755
1756 fn parse_err(json: &str) -> String {
1757 AppManifest::from_json(json).expect_err("should fail")
1758 }
1759
1760 const SCHEDULED_APP: &str = r#"{
1763 "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1764 "provides":{"network.ledger.poll":{"description":"Record connections"}},
1765 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1766 "capability":"network.ledger.poll"}]
1767 }"#;
1768
1769 #[test]
1770 fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1771 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1774 assert!(m.schedules.is_empty());
1775 }
1776
1777 #[test]
1778 fn a_schedule_is_parsed_whole() {
1779 let m = parse_ok(SCHEDULED_APP);
1780 assert_eq!(m.schedules.len(), 1);
1781 let s = &m.schedules[0];
1782 assert_eq!(s.id, "ledger-poll");
1783 assert_eq!(s.cron, "0 */15 * * * *");
1784 assert_eq!(s.capability, "network.ledger.poll");
1785 assert_eq!(s.effective_payload(), serde_json::json!({}));
1788 }
1789
1790 #[test]
1791 fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1792 let err = parse_err(
1795 r#"{"name":"network","version":"1.0.0","app_type":"bun",
1796 "provides":{"network.ledger.poll":{"description":"x"}},
1797 "schedules":[{"id":"drain","cron":"0 0 * * * *",
1798 "capability":"core.lightning.send_payment"}]}"#,
1799 );
1800 assert!(err.contains("core.lightning.send_payment"), "{err}");
1801 assert!(err.contains("does not provide"), "{err}");
1802 }
1803
1804 #[test]
1805 fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1806 let m = parse_ok(
1809 r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1810 "app_type":"bun",
1811 "capabilities":{"provides":["network.ledger.poll"]},
1812 "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1813 "capability":"network.ledger.poll"}]}"#,
1814 );
1815 assert_eq!(m.schedules.len(), 1);
1816 }
1817
1818 #[test]
1819 fn a_cron_expression_without_six_fields_is_refused() {
1820 let err = parse_err(
1822 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1823 "provides":{"n.poll":{"description":"x"}},
1824 "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1825 );
1826 assert!(err.contains("6 fields"), "{err}");
1827 }
1828
1829 #[test]
1830 fn a_cron_expression_with_junk_in_it_is_refused() {
1831 let err = parse_err(
1832 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1833 "provides":{"n.poll":{"description":"x"}},
1834 "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1835 );
1836 assert!(err.contains("disallowed character"), "{err}");
1837 }
1838
1839 #[test]
1840 fn two_schedules_cannot_share_an_id() {
1841 let err = parse_err(
1844 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1845 "provides":{"n.poll":{"description":"x"}},
1846 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1847 {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1848 );
1849 assert!(err.contains("duplicate schedule id"), "{err}");
1850 }
1851
1852 #[test]
1853 fn a_schedule_id_must_be_a_safe_token() {
1854 let err = parse_err(
1855 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1856 "provides":{"n.poll":{"description":"x"}},
1857 "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1858 );
1859 assert!(err.contains("schedule id"), "{err}");
1860 }
1861
1862 #[test]
1863 fn a_non_object_payload_is_refused() {
1864 let err = parse_err(
1865 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1866 "provides":{"n.poll":{"description":"x"}},
1867 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1868 "payload":"not-an-object"}]}"#,
1869 );
1870 assert!(err.contains("JSON object"), "{err}");
1871 }
1872
1873 #[test]
1874 fn a_declared_payload_survives_the_round_trip() {
1875 let m = parse_ok(
1876 r#"{"name":"n","version":"1.0.0","app_type":"bun",
1877 "provides":{"n.poll":{"description":"x"}},
1878 "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1879 "payload":{"depth":2}}]}"#,
1880 );
1881 assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
1882 let round_tripped: AppManifest =
1883 serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
1884 assert_eq!(round_tripped.schedules, m.schedules);
1885 }
1886
1887 #[test]
1888 fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
1889 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1891 let json = serde_json::to_string(&m).unwrap();
1892 assert!(!json.contains("schedules"), "{json}");
1893 }
1894
1895 #[test]
1898 fn v1_minimal_native() {
1899 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1900 assert_eq!(m.manifest_version, 1);
1901 assert_eq!(m.app_type, AppType::Native);
1902 assert!(m.abi.is_none());
1903 }
1904
1905 #[test]
1906 fn v1_minimal_bun() {
1907 let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1908 assert_eq!(m.app_type, AppType::Bun);
1909 assert_eq!(m.effective_entrypoint(), "dist/index.js");
1910 }
1911
1912 #[test]
1913 fn v1_no_manifest_version_field_defaults_to_1() {
1914 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1915 assert_eq!(m.manifest_version, 1);
1916 }
1917
1918 #[test]
1919 fn v1_all_optional_fields_missing() {
1920 let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1921 assert!(!m.critical);
1922 assert!(m.auto_start);
1923 assert!(!m.has_ui);
1924 assert_eq!(m.ui_path, "dist");
1925 assert!(m.permissions.is_empty());
1926 assert!(m.optional_permissions.is_empty());
1927 assert!(m.governor.is_none());
1930 assert!(m.subscribes.is_empty());
1931 }
1932
1933 #[test]
1934 fn v1_with_permissions_and_provides() {
1935 let json = r#"{
1936 "name": "example",
1937 "version": "1.0.0",
1938 "app_type": "bun",
1939 "permissions": ["core.storage.kv"],
1940 "optional_permissions": ["core.notifications.create"],
1941 "provides": {
1942 "core.example.run": { "description": "Run example job" }
1943 }
1944 }"#;
1945 let m = parse_ok(json);
1946 assert_eq!(m.permissions, vec!["core.storage.kv"]);
1947 assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1948 assert!(m.provides.contains_key("core.example.run"));
1949 }
1950
1951 #[test]
1954 fn v2_minimal_native() {
1955 let json = r#"{
1956 "manifest_version": 2,
1957 "name": "cron",
1958 "version": "1.0.0",
1959 "app_type": "native",
1960 "abi": "v1",
1961 "entrypoint": "app.so",
1962 "hot_reload": "experimental"
1963 }"#;
1964 let m = parse_ok(json);
1965 assert_eq!(m.manifest_version, 2);
1966 assert_eq!(m.abi, Some(AbiVersion::V1));
1967 assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1968 assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1969 }
1970
1971 #[test]
1972 fn v2_minimal_bun_with_capabilities() {
1973 let json = r#"{
1974 "manifest_version": 2,
1975 "name": "example-fullstack",
1976 "version": "1.0.0",
1977 "app_type": "bun",
1978 "abi": "v1",
1979 "entrypoint": "dist/index.js",
1980 "hot_reload": "supported",
1981 "has_ui": true,
1982 "ui_path": "ui/dist",
1983 "capabilities": {
1984 "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1985 "provides": []
1986 },
1987 "governor": { "terminable": false, "min_idle_secs": 300 },
1988 "subscribes": ["core.chat.message.received"]
1989 }"#;
1990 let m = parse_ok(json);
1991 assert_eq!(m.manifest_version, 2);
1992 assert_eq!(m.capabilities.requires.len(), 2);
1993 let governor = m.governor.expect("governor block should parse");
1995 assert_eq!(governor.terminable, Some(false));
1996 assert_eq!(governor.min_idle_secs, Some(300));
1997 assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1998 }
1999
2000 #[test]
2001 fn stage_contract_fixture_parses_with_normalized_requirements() {
2002 let json = include_str!(
2003 "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2004 );
2005 let m = parse_ok(json);
2006 assert!(m.has_ui);
2007 assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2008 let ui = m.ui.expect("fixture should declare ui");
2009 assert_eq!(ui.kind, AppUiKind::Stage);
2010 assert_eq!(ui.entry, "ui/main.js");
2011 assert_eq!(ui.nav.unwrap().order, 10);
2012 }
2013
2014 #[test]
2015 fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2016 let m = parse_ok(
2017 r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2018 );
2019 assert!(m.has_ui);
2020 assert_eq!(m.ui_path, "ui/dist");
2021 assert!(m.ui.is_none());
2022 }
2023
2024 #[test]
2025 fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2026 let manifest = parse_ok(
2027 r#"{
2028 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2029 "requires":["core.cron.register"],
2030 "ui":{
2031 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2032 "requires":{
2033 "capabilities":["ui.snapshot.v1"],
2034 "queries":["ui.query.v1"],
2035 "streams":["ui.event.v1"]
2036 },
2037 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2038 }
2039 }"#,
2040 );
2041
2042 assert_eq!(
2043 manifest.resolved_requires().unwrap(),
2044 vec!["core.cron.register"]
2045 );
2046 let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2047 assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2048 assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2049 assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2050 assert_eq!(
2051 ui.requires.resolved().unwrap(),
2052 vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2053 );
2054 let serialized = serde_json::to_value(ui).unwrap();
2055 assert_eq!(
2056 serialized["requires"]["queries"],
2057 serde_json::json!(["ui.query.v1"])
2058 );
2059 }
2060
2061 #[test]
2062 fn ui_requirements_reject_blank_entries() {
2063 let error = parse_err(
2064 r#"{
2065 "name":"ui-contract","version":"1.0.0","app_type":"bun",
2066 "ui":{
2067 "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2068 "requires":{"capabilities":[""],"queries":[],"streams":[]},
2069 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2070 }
2071 }"#,
2072 );
2073 assert!(error.contains("ui.requires entries must not be blank"));
2074 }
2075
2076 #[test]
2077 fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2078 assert!(validate_app_data_namespace("obs-viewer").is_ok());
2085
2086 let error = validate_app_data_namespace("obs_viewer")
2087 .expect_err("underscore must not be admitted into a storage namespace");
2088 assert!(
2089 error.contains("must match [a-z][a-z0-9-]*"),
2090 "unexpected error: {error}"
2091 );
2092 }
2093
2094 #[test]
2095 fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2096 let error = parse_err(
2097 r#"{
2098 "name":"ui-data","version":"1.0.0","app_type":"bun",
2099 "requires":["ui.snapshot.v1"],
2100 "ui":{
2101 "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2102 "requires":{"capabilities":[],"queries":[],"streams":[]},
2103 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2104 "data":{
2105 "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2106 "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2107 "streams":[]
2108 }
2109 }
2110 }"#,
2111 );
2112 assert!(error.contains("must be declared in requires"));
2113 }
2114
2115 #[test]
2120 fn ui_data_namespace_must_equal_the_app_name() {
2121 let manifest = |namespace: &str| {
2122 format!(
2123 r#"{{
2124 "name":"burger-runtime","version":"0.2.0",
2125 "ui":{{
2126 "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2127 "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2128 "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2129 "data":{{
2130 "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2131 "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2132 "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2133 }}
2134 }}
2135 }}"#
2136 )
2137 };
2138
2139 let error = parse_err(&manifest("burger"));
2140 assert!(
2141 error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2142 "unexpected error: {error}"
2143 );
2144
2145 let accepted = parse_ok(&manifest("burger-runtime"));
2146 let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2147 assert_eq!(
2148 data.map(|data| data.namespace.as_str()),
2149 Some("burger-runtime")
2150 );
2151 }
2152
2153 #[test]
2154 fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2155 let accepted = parse_ok(
2156 r#"{
2157 "name":"aliases","version":"1.0.0","app_type":"bun",
2158 "requires":["core.chat.read","core.chat.read","core.chat.send"],
2159 "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2160 }"#,
2161 );
2162 assert_eq!(
2163 accepted.resolved_requires().unwrap(),
2164 vec!["core.chat.read", "core.chat.send"]
2165 );
2166
2167 let err = parse_err(
2168 r#"{
2169 "name":"aliases","version":"1.0.0","app_type":"bun",
2170 "requires":["core.chat.read"],
2171 "capabilities":{"requires":["core.wallet.pay"]}
2172 }"#,
2173 );
2174 assert!(err.contains("conflicts"), "unexpected error: {err}");
2175 }
2176
2177 #[test]
2178 fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2179 let base = |ui: &str| {
2180 format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2181 };
2182 let widget = base(
2183 r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2184 );
2185 assert_eq!(
2186 parse_ok(&widget).ui.expect("widget ui").kind,
2187 AppUiKind::Widget
2188 );
2189 let widget_nav = base(
2190 r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2191 );
2192 assert!(parse_err(&widget_nav).contains("must omit nav"));
2193 let api = base(
2194 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2195 );
2196 assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2197 let unsupported_api = base(
2198 r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2199 );
2200 assert!(parse_err(&unsupported_api).contains("ui_api"));
2201 let path = base(
2202 r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2203 );
2204 assert!(parse_err(&path).contains("entry"));
2205 }
2206
2207 #[test]
2208 fn stage_requires_integrity_for_entry_and_icon() {
2209 let missing_entry = r#"{
2210 "name":"stage","version":"1.0.0","app_type":"bun",
2211 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2212 }"#;
2213 assert!(parse_err(missing_entry).contains("entry"));
2214 let missing_icon = r#"{
2215 "name":"stage","version":"1.0.0","app_type":"bun",
2216 "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2217 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2218 }"#;
2219 assert!(parse_err(missing_icon).contains("icon"));
2220 let uppercase = r#"{
2221 "name":"stage","version":"1.0.0","app_type":"bun",
2222 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2223 "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2224 }"#;
2225 assert!(parse_err(uppercase).contains("lowercase"));
2226 }
2227
2228 #[test]
2229 fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2230 let manifest = |composes: &str| {
2231 format!(
2232 r#"{{
2233 "name":"stage","version":"1.0.0","app_type":"bun",
2234 "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2235 "composes":{composes},
2236 "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2237 }}"#
2238 )
2239 };
2240 assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2241 assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2242 assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2243 }
2244
2245 fn manifest_with_ui() -> AppManifest {
2257 parse_ok(
2258 r#"{
2259 "name":"stage","version":"1.0.0","app_type":"bun",
2260 "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2261 "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2262 "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2263 }"#,
2264 )
2265 }
2266
2267 fn surface(id: &str, slot: &str) -> AppUiSurface {
2268 AppUiSurface {
2269 id: id.to_string(),
2270 slot: slot.to_string(),
2271 entry: "ui/dist/surfaces/chip.js".to_string(),
2272 title: "Chip".to_string(),
2273 order: 10,
2274 requires: AppUiRequirements {
2275 capabilities: vec!["wallet.balance.get".to_string()],
2276 ..Default::default()
2277 },
2278 }
2279 }
2280
2281 #[test]
2282 fn manifest_without_surfaces_still_parses() {
2283 let manifest = manifest_with_ui();
2284 assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2285 assert!(manifest.validate().is_ok());
2286 }
2287
2288 #[test]
2289 fn surface_in_a_known_slot_is_accepted() {
2290 let mut manifest = manifest_with_ui();
2291 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2292 assert!(manifest.validate().is_ok());
2293 }
2294
2295 #[test]
2296 fn surface_in_an_unknown_slot_is_rejected() {
2297 let mut manifest = manifest_with_ui();
2298 manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2299 let error = manifest.validate().unwrap_err();
2300 assert!(error.contains("menu-bar"), "unexpected error: {error}");
2301 }
2302
2303 #[test]
2304 fn duplicate_surface_ids_are_rejected() {
2305 let mut manifest = manifest_with_ui();
2306 manifest.ui.as_mut().unwrap().surfaces = vec![
2307 surface("chip", "status-rail"),
2308 surface("chip", "status-rail"),
2309 ];
2310 let error = manifest.validate().unwrap_err();
2311 assert!(error.contains("duplicate"), "unexpected error: {error}");
2312 }
2313
2314 #[test]
2315 fn surface_with_a_blank_id_is_rejected() {
2316 let mut manifest = manifest_with_ui();
2317 manifest.ui.as_mut().unwrap().surfaces = vec![surface(" ", "status-rail")];
2318 assert!(manifest.validate().is_err());
2319 }
2320
2321 #[test]
2322 fn surface_with_an_unsafe_entry_path_is_rejected() {
2323 let mut manifest = manifest_with_ui();
2324 let mut bad = surface("chip", "status-rail");
2325 bad.entry = "../../etc/passwd".to_string();
2326 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2327 assert!(manifest.validate().is_err());
2328 }
2329
2330 #[test]
2331 fn surface_entry_missing_from_integrity_is_rejected() {
2332 let mut manifest = manifest_with_ui();
2336 let mut unpinned = surface("chip", "status-rail");
2337 unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2338 manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2339 let error = manifest.validate().unwrap_err();
2340 assert!(
2341 error.contains("ui.integrity must include surface 'chip' entry"),
2342 "unexpected error: {error}"
2343 );
2344 }
2345
2346 #[test]
2347 fn surface_with_a_blank_required_capability_is_rejected() {
2348 let mut manifest = manifest_with_ui();
2349 let mut bad = surface("chip", "status-rail");
2350 bad.requires.capabilities = vec![" ".to_string()];
2351 manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2352 assert!(manifest.validate().is_err());
2353 }
2354
2355 #[test]
2356 fn v2_missing_abi_is_error() {
2357 let json = r#"{
2358 "manifest_version": 2,
2359 "name": "example",
2360 "version": "1.0.0",
2361 "app_type": "bun"
2362 }"#;
2363 let err = parse_err(json);
2364 assert!(err.contains("abi"), "expected abi error, got: {}", err);
2365 }
2366
2367 #[test]
2370 fn publisher_prefixed_name_accepted() {
2371 let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2372 assert_eq!(m.name, "alice/weather");
2373 }
2374
2375 #[test]
2376 fn double_slash_name_rejected() {
2377 let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2378 assert!(!err.is_empty());
2379 }
2380
2381 #[test]
2384 fn name_starting_with_digit_rejected() {
2385 let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2386 assert!(!err.is_empty());
2387 }
2388
2389 #[test]
2390 fn name_with_uppercase_rejected() {
2391 let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2392 assert!(!err.is_empty());
2393 }
2394
2395 #[test]
2396 fn empty_name_rejected() {
2397 let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2398 assert!(!err.is_empty());
2399 }
2400
2401 #[test]
2404 fn path_traversal_double_dot_rejected() {
2405 let json = r#"{
2406 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2407 "app_type": "bun", "abi": "v1",
2408 "entrypoint": "../etc/passwd"
2409 }"#;
2410 let err = parse_err(json);
2411 assert!(err.contains(".."), "expected traversal error, got: {}", err);
2412 }
2413
2414 #[test]
2415 fn path_traversal_encoded_dot_not_decoded() {
2416 let json = r#"{
2418 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2419 "app_type": "bun", "abi": "v1",
2420 "entrypoint": "foo/../bar"
2421 }"#;
2422 let err = parse_err(json);
2423 assert!(!err.is_empty(), "should have failed: {}", err);
2424 }
2425
2426 #[test]
2427 fn absolute_path_rejected() {
2428 let json = r#"{
2429 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2430 "app_type": "bun", "abi": "v1",
2431 "entrypoint": "/usr/bin/sh"
2432 }"#;
2433 let err = parse_err(json);
2434 assert!(
2435 err.contains("absolute"),
2436 "expected absolute error, got: {}",
2437 err
2438 );
2439 }
2440
2441 #[test]
2442 fn shell_metachar_in_path_rejected() {
2443 let json = r#"{
2444 "manifest_version": 2, "name": "evil", "version": "1.0.0",
2445 "app_type": "bun", "abi": "v1",
2446 "entrypoint": "dist/index.js;rm -rf /"
2447 }"#;
2448 let err = parse_err(json);
2449 assert!(!err.is_empty());
2450 }
2451
2452 #[test]
2453 fn valid_nested_path_accepted() {
2454 let json = r#"{
2455 "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2456 "app_type": "bun", "abi": "v1",
2457 "entrypoint": "dist/index.js",
2458 "ui_path": "ui/dist"
2459 }"#;
2460 parse_ok(json);
2461 }
2462
2463 #[test]
2466 fn homepage_https_accepted() {
2467 let json = r#"{
2468 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2469 "homepage": "https://example.com"
2470 }"#;
2471 parse_ok(json);
2472 }
2473
2474 #[test]
2475 fn homepage_javascript_scheme_rejected() {
2476 let json = r#"{
2477 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2478 "homepage": "javascript:alert(1)"
2479 }"#;
2480 let err = parse_err(json);
2481 assert!(
2482 err.contains("scheme"),
2483 "expected scheme error, got: {}",
2484 err
2485 );
2486 }
2487
2488 #[test]
2489 fn homepage_file_scheme_rejected() {
2490 let json = r#"{
2491 "name": "my-app", "version": "1.0.0", "app_type": "bun",
2492 "homepage": "file:///etc/passwd"
2493 }"#;
2494 let err = parse_err(json);
2495 assert!(!err.is_empty());
2496 }
2497
2498 #[test]
2501 fn effective_entrypoint_native_default() {
2502 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2503 assert_eq!(m.effective_entrypoint(), "app.so");
2504 }
2505
2506 #[test]
2507 fn effective_entrypoint_bun_default() {
2508 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2509 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2510 }
2511
2512 #[test]
2513 fn effective_hot_reload_native_default_is_experimental() {
2514 let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2515 assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2516 }
2517
2518 #[test]
2519 fn effective_hot_reload_bun_default_is_supported() {
2520 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2521 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2522 }
2523
2524 #[test]
2525 fn hot_reload_unsupported_explicit() {
2526 let json = r#"{
2527 "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2528 "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2529 }"#;
2530 let m = parse_ok(json);
2531 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2532 }
2533
2534 #[test]
2537 fn validate_path_simple_valid() {
2538 assert!(validate_manifest_path("dist/index.js").is_ok());
2539 assert!(validate_manifest_path("app.so").is_ok());
2540 assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2541 assert!(validate_manifest_path("build_output/main").is_ok());
2542 }
2543
2544 #[test]
2545 fn validate_path_empty_rejected() {
2546 assert!(validate_manifest_path("").is_err());
2547 }
2548
2549 #[test]
2550 fn validate_path_absolute_rejected() {
2551 assert!(validate_manifest_path("/usr/bin/sh").is_err());
2552 }
2553
2554 #[test]
2555 fn validate_path_double_dot_segment_rejected() {
2556 assert!(validate_manifest_path("foo/../bar").is_err());
2557 assert!(validate_manifest_path("../etc/passwd").is_err());
2558 }
2559
2560 #[test]
2561 fn validate_path_leading_dot_rejected() {
2562 assert!(validate_manifest_path(".hidden").is_err());
2563 }
2564
2565 #[test]
2566 fn validate_path_null_byte_rejected() {
2567 let path = "foo\0bar";
2569 assert!(validate_manifest_path(path).is_err());
2570 }
2571
2572 #[test]
2573 fn standalone_socket_path_development_override_is_explicit_and_pure() {
2574 let path = std::path::Path::new("/tmp/node-app/example.sock");
2575 assert!(validate_standalone_socket_path(path).is_err());
2576 assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2577 assert!(validate_standalone_socket_path_with_policy(
2578 std::path::Path::new("/tmp/node-app/../escape.sock"),
2579 true,
2580 )
2581 .is_err());
2582 }
2583
2584 #[test]
2587 fn manifest_capabilities_defaults_to_empty() {
2588 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2589 assert!(m.capabilities.requires.is_empty());
2590 assert!(m.capabilities.provides.is_empty());
2591 }
2592
2593 #[test]
2596 fn resolved_capability_provides_v1_only() {
2597 let json = r#"{
2598 "name": "example", "version": "1.0.0", "app_type": "bun",
2599 "provides": { "core.example.run": { "description": "Run example job" } }
2600 }"#;
2601 let m = parse_ok(json);
2602 let out = m.resolved_capability_provides();
2603 assert_eq!(out.len(), 1);
2604 assert_eq!(
2605 out.get("core.example.run").unwrap().description,
2606 "Run example job"
2607 );
2608 }
2609
2610 #[test]
2611 fn resolved_capability_provides_v2_names_get_blank_declaration() {
2612 let json = r#"{
2613 "manifest_version": 2, "name": "example", "version": "1.0.0",
2614 "app_type": "bun", "abi": "v1",
2615 "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2616 }"#;
2617 let m = parse_ok(json);
2618 let out = m.resolved_capability_provides();
2619 assert_eq!(out.len(), 2);
2620 assert_eq!(out.get("core.example.run").unwrap().description, "");
2621 assert!(out.get("core.example.run").unwrap().schema.is_none());
2622 assert!(out.contains_key("core.example.other"));
2624 assert!(!out.contains_key("core.example.other:extra"));
2625 }
2626
2627 #[test]
2628 fn resolved_capability_provides_v1_wins_on_conflict() {
2629 let json = r#"{
2630 "manifest_version": 2, "name": "example", "version": "1.0.0",
2631 "app_type": "bun", "abi": "v1",
2632 "provides": { "core.example.run": { "description": "v1 wins" } },
2633 "capabilities": { "requires": [], "provides": ["core.example.run"] }
2634 }"#;
2635 let m = parse_ok(json);
2636 let out = m.resolved_capability_provides();
2637 assert_eq!(out.len(), 1);
2638 assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2639 }
2640
2641 #[test]
2642 fn resolved_capability_provides_blank_v2_name_skipped() {
2643 let json = r#"{
2644 "manifest_version": 2, "name": "example", "version": "1.0.0",
2645 "app_type": "bun", "abi": "v1",
2646 "capabilities": { "requires": [], "provides": [" ", "core.example.run"] }
2647 }"#;
2648 let m = parse_ok(json);
2649 let out = m.resolved_capability_provides();
2650 assert_eq!(out.len(), 1);
2651 assert!(out.contains_key("core.example.run"));
2652 }
2653
2654 #[test]
2655 fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2656 let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2657 assert!(m.resolved_capability_provides().is_empty());
2658 }
2659
2660 #[test]
2663 fn abi_v1_is_supported() {
2664 assert!(AbiVersion::V1.is_supported());
2665 }
2666
2667 #[test]
2670 fn app_tier_display() {
2671 assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2672 assert_eq!(AppTier::Optional.to_string(), "optional");
2673 assert_eq!(AppTier::Development.to_string(), "development");
2674 }
2675
2676 #[test]
2677 fn app_tier_serde_roundtrip() {
2678 for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2680 let json = serde_json::to_string(&tier).unwrap();
2681 let back: AppTier = serde_json::from_str(&json).unwrap();
2682 assert_eq!(
2683 tier, back,
2684 "roundtrip failed for {:?}: serialized as {}",
2685 tier, json
2686 );
2687 }
2688 assert_eq!(
2689 serde_json::to_string(&AppTier::Development).unwrap(),
2690 "\"development\""
2691 );
2692 }
2693
2694 #[test]
2697 fn app_type_display() {
2698 assert_eq!(AppType::Native.to_string(), "native");
2699 assert_eq!(AppType::Bun.to_string(), "bun");
2700 assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2701 }
2702
2703 #[test]
2704 fn platform_runtime_is_a_supported_packaging_type() {
2705 let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2706 "manifest_version": 2,
2707 "abi": "v1",
2708 "name": "bun-runtime",
2709 "version": "1.0.0",
2710 "app_type": "platform-runtime",
2711 "entrypoint": "bun"
2712 }))
2713 .expect("platform runtime manifest should parse");
2714
2715 assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2716 assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2717 }
2718
2719 #[test]
2722 fn governor_manifest_memory_budget_defaults_to_none() {
2723 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2724 assert_eq!(parsed.memory_budget_kb, None);
2725 }
2726
2727 #[test]
2728 fn governor_manifest_parses_declared_memory_budget() {
2729 let parsed: GovernorManifest =
2730 serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2731 assert_eq!(parsed.memory_budget_kb, Some(40_960));
2732 }
2733
2734 #[test]
2737 fn latency_class_parses_and_defaults_none() {
2738 let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2739 assert_eq!(parsed.latency_class, None);
2740 }
2741
2742 #[test]
2743 fn latency_class_parses_declared_interactive() {
2744 let json = r#"{
2745 "name": "example",
2746 "version": "1.0.0",
2747 "app_type": "bun",
2748 "governor": {"latency_class": "interactive"}
2749 }"#;
2750 let m = parse_ok(json);
2751 let governor = m.governor.expect("governor block should parse");
2752 assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2753 }
2754
2755 #[test]
2756 fn latency_class_parses_declared_background() {
2757 let parsed: GovernorManifest =
2758 serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2759 assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2760 }
2761
2762 #[test]
2763 fn latency_class_invalid_value_is_parse_error() {
2764 let result: Result<GovernorManifest, _> =
2765 serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2766 assert!(result.is_err(), "unknown latency_class value must fail to parse");
2767 }
2768
2769 #[test]
2770 fn latency_class_as_str_and_from_str_roundtrip() {
2771 for class in [LatencyClass::Interactive, LatencyClass::Background] {
2772 let s = class.as_str();
2773 assert_eq!(LatencyClass::from_str(s), Ok(class));
2774 }
2775 assert!(LatencyClass::from_str("urgent").is_err());
2776 }
2777
2778 fn ui_only_manifest() -> serde_json::Value {
2781 serde_json::json!({
2782 "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
2783 "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
2784 "ui": {
2785 "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
2786 "nav": { "section": "system", "order": 90 }, "ui_api": 1,
2787 "requires": {
2788 "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
2789 "queries": [], "streams": ["app.burger_metrics"]
2790 },
2791 "data": {
2792 "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
2793 "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
2794 "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
2795 },
2796 "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
2797 }
2798 })
2799 }
2800
2801 fn ui_only_with(key: &str, value: serde_json::Value) -> String {
2802 let mut manifest = ui_only_manifest();
2803 manifest[key] = value;
2804 manifest.to_string()
2805 }
2806
2807 #[test]
2808 fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
2809 let m = parse_ok(&ui_only_manifest().to_string());
2810 assert_eq!(m.app_type, AppType::UiOnly);
2811 assert_eq!(m.app_type.as_str(), "ui-only");
2812 assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
2813 assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2814 assert!(m.has_ui);
2815 let ui = m.ui.as_ref().expect("a ui block");
2816 assert_eq!(ui.kind, AppUiKind::Stage);
2817 let data = ui.data.as_ref().expect("the F5 ui.data block validates");
2818 assert_eq!(data.namespace, "burger-runtime");
2819 assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
2820 assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
2821 assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
2822 }
2823
2824 #[test]
2825 fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
2826 let m = parse_ok(&ui_only_manifest().to_string());
2827 let wire = serde_json::to_string(&m).unwrap();
2828 assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
2829 assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
2830 }
2831
2832 #[test]
2833 fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
2834 for (key, value) in [
2835 (
2836 "provides",
2837 serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
2838 ),
2839 (
2840 "capabilities",
2841 serde_json::json!({ "provides": ["burger.runtime.peek"] }),
2842 ),
2843 ("tcp", serde_json::json!({ "preferred_port": 7010 })),
2844 ("resources", serde_json::json!({ "memory_mb": 16 })),
2845 (
2846 "standalone",
2847 serde_json::json!({ "socket_path": "/run/node/x.sock" }),
2848 ),
2849 ("subscribes", serde_json::json!(["system.network.changed"])),
2850 ("depends_on", serde_json::json!(["cron"])),
2851 ] {
2852 let err = parse_err(&ui_only_with(key, value));
2853 assert!(err.contains("ui-only"), "{key}: {err}");
2854 }
2855 let mut explicit_with_entry = ui_only_manifest();
2856 explicit_with_entry["app_type"] = serde_json::json!("ui-only");
2857 explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
2858 assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
2859 }
2860
2861 #[test]
2862 fn a_ui_only_manifest_must_declare_a_stage() {
2863 let mut widget = ui_only_manifest();
2864 widget["ui"]["kind"] = serde_json::json!("widget");
2865 let widget_ui = widget["ui"].as_object_mut().unwrap();
2866 widget_ui.remove("nav");
2867 widget_ui.remove("data");
2870 assert!(parse_err(&widget.to_string()).contains(r#"kind "stage""#));
2871 let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
2872 assert!(parse_err(no_ui).contains(r#"kind "stage""#));
2873 }
2874
2875 #[test]
2876 fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
2877 assert_eq!(
2878 parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
2879 AppType::Native
2880 );
2881 let mut with_entry = ui_only_manifest();
2882 with_entry["entrypoint"] = serde_json::json!("app.so");
2883 assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
2884 let mut bun = ui_only_manifest();
2885 bun["app_type"] = serde_json::json!("bun");
2886 assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
2887 }
2888
2889 #[test]
2890 fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
2891 assert!(declares_ui_only(&ui_only_manifest()));
2892 assert!(declares_ui_only(
2893 &serde_json::json!({ "app_type": "ui-only" })
2894 ));
2895 assert!(!declares_ui_only(
2896 &serde_json::json!({ "app_type": "bun", "ui": {} })
2897 ));
2898 assert!(!declares_ui_only(
2899 &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
2900 ));
2901 assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
2902 assert!(!declares_ui_only(&serde_json::json!("ui-only")));
2903 }
2904}
2905
2906#[cfg(test)]
2907mod shared_app_data_corpus_tests {
2908 use super::*;
2909
2910 #[test]
2915 fn shared_app_data_corpus_matches_the_host_validator() {
2916 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2917 let mut checked = 0;
2918 for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2919 let path = entry.expect("readable entry").path();
2920 if path.extension().and_then(|e| e.to_str()) != Some("json") {
2921 continue;
2922 }
2923 let fixture: serde_json::Value =
2924 serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2925 .expect("valid fixture json");
2926 let name = path
2927 .file_name()
2928 .and_then(|n| n.to_str())
2929 .unwrap_or("?")
2930 .to_string();
2931 let manifest = serde_json::json!({
2935 "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
2936 "app_type": "bun", "entrypoint": "dist/index.js",
2937 "ui": fixture["ui"],
2938 });
2939 let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2940 match fixture["expect"].as_str().expect("expect field") {
2941 "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2942 "reject" => {
2943 let error = result.expect_err(&format!("{name} should be rejected"));
2944 let reason = fixture["reason"]
2945 .as_str()
2946 .expect("reject fixtures need a reason");
2947 assert!(
2948 error.contains(reason),
2949 "{name}: {error:?} should mention {reason:?}"
2950 );
2951 }
2952 other => panic!("{name}: unknown expect {other:?}"),
2953 }
2954 checked += 1;
2955 }
2956 assert!(checked >= 10, "expected the full corpus, walked {checked}");
2958 }
2959}
2960
2961#[cfg(test)]
2962mod burger_manifest_tests {
2963 use super::*;
2964
2965 #[test]
2966 fn burger_app_type_parses_with_bundle_entrypoint_default() {
2967 let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
2968 .expect("burger manifest parses");
2969 assert_eq!(m.app_type, AppType::Burger);
2970 assert_eq!(m.app_type.as_str(), "burger");
2971 assert_eq!(m.effective_entrypoint(), "dist/index.js");
2972 assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2973 assert!(m.resources.is_none());
2974 }
2975
2976 #[test]
2977 fn resources_memory_mb_is_carried() {
2978 let m = AppManifest::from_json(
2979 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
2980 )
2981 .expect("resources block parses");
2982 assert_eq!(
2983 m.resources,
2984 Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
2985 );
2986 }
2987
2988 #[test]
2989 fn resources_callback_deadline_ms_is_carried() {
2990 let m = AppManifest::from_json(
2991 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
2992 )
2993 .expect("callback deadline parses");
2994 assert_eq!(
2995 m.resources,
2996 Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
2997 );
2998 let max = AppManifest::from_json(
2999 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3000 )
3001 .expect("the maximum is inclusive");
3002 assert_eq!(
3003 max.resources.and_then(|r| r.callback_deadline_ms),
3004 Some(MAX_CALLBACK_DEADLINE_MS)
3005 );
3006 }
3007
3008 #[test]
3009 fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3010 for bad in ["0", "600001"] {
3011 let json = format!(
3012 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3013 );
3014 let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3015 assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3016 }
3017 }
3018
3019 #[test]
3020 fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3021 for bad in ["-1", "1.5", "\"5000\""] {
3022 let json = format!(
3023 r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3024 );
3025 assert!(
3026 AppManifest::from_json(&json).is_err(),
3027 "callback_deadline_ms={bad} must be rejected"
3028 );
3029 }
3030 }
3031
3032 #[test]
3033 fn resources_memory_mb_zero_is_rejected() {
3034 let err = AppManifest::from_json(
3035 r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3036 )
3037 .expect_err("a zero memory limit is invalid");
3038 assert!(err.contains("resources.memory_mb"), "{err}");
3039 }
3040
3041 #[test]
3042 fn burger_manifest_rejects_a_standalone_block() {
3043 let err = AppManifest::from_json(
3044 r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3045 )
3046 .expect_err("standalone block is only valid for standalone apps");
3047 assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3048 }
3049}