Skip to main content

node_app_manifest/
manifest.rs

1//! App manifest domain entity — unified v1/v2 schema.
2//!
3//! This module defines the canonical `AppManifest` used by the Node daemon to
4//! discover, load, and validate mini apps. It is a backward-compatible superset
5//! of the existing `PerAppManifest` (now promoted from `apps/server`).
6//!
7//! # Schema version detection
8//!
9//! - **v1** (legacy): no `manifest_version` field → `manifest_version = 1`.
10//!   All v2-only fields default to their v1-equivalent values. Zero existing
11//!   app manifests are invalidated.
12//! - **v2** (extended): `manifest_version = 2`. Adds `abi`, `entrypoint`,
13//!   `hot_reload`, and the typed `capabilities` block. Requires `abi` to be
14//!   present when `manifest_version == 2`.
15//!
16//! # Path-safety (SEC-H3)
17//!
18//! `entrypoint` and `ui_path` are validated at parse time:
19//! 1. Matches regex `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$`
20//! 2. Contains no `..` segment
21//! 3. Does not begin with `/`
22//!
23//! The canonicalize-inside-install-dir check (step 4) is performed by
24//! `tier_validator.rs` at load time because the install directory is not known
25//! until the daemon resolves the path.
26
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
29
30// ── Enums ────────────────────────────────────────────────────────────────────
31
32/// App execution model — determines how the daemon loads and isolates the app.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum AppType {
36    /// In-process cdylib loaded via dlopen. First-party path only (SEC-H1).
37    Native,
38    /// Isolated subprocess managed by the Bun runtime.
39    Bun,
40    /// Independent systemd-managed service that owns its own Unix domain socket.
41    /// The daemon does not start or supervise the process; it only routes
42    /// capability invocations to the app's socket as JSON-RPC 2.0.
43    /// Requires a `standalone.socket_path` when the manifest declares any
44    /// `provides` / `capabilities.provides` entries.
45    Standalone,
46    /// Packaging-only runtime dependency (for example the shared Bun runtime).
47    /// It is installed and versioned like an app package but is never loaded,
48    /// registered as a capability provider, or hot-reloaded as an app.
49    #[serde(rename = "platform-runtime")]
50    PlatformRuntime,
51    /// Verified executable generated by LLMC and launched through the
52    /// versioned managed-v1 stdio protocol.
53    #[serde(rename = "managed-v1")]
54    ManagedV1,
55    /// ES module bundle hosted by the Burger (QuickJS) runtime host,
56    /// `node-app-burger host` (Burger Plan 02, Contract C7).
57    Burger,
58}
59
60impl AppType {
61    pub fn as_str(self) -> &'static str {
62        match self {
63            AppType::Native => "native",
64            AppType::Bun => "bun",
65            AppType::Standalone => "standalone",
66            AppType::PlatformRuntime => "platform-runtime",
67            AppType::ManagedV1 => "managed-v1",
68            AppType::Burger => "burger",
69        }
70    }
71}
72
73impl std::fmt::Display for AppType {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        write!(f, "{}", self.as_str())
76    }
77}
78
79/// Trust/distribution tier — derived at load time from the install path
80/// AND (per FR-028 cycle 4) the manifest sidecar's GPG signature.
81///
82/// This is **not** stored in the manifest; it is computed by `tier_validator`.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum AppTier {
86    /// App installed at the bundled path (`/usr/share/node/builtin-apps/`)
87    /// OR at the apt path with a manifest sidecar signed by a node project key.
88    /// May be `Native` or `Bun`. Highest trust.
89    FirstParty,
90    /// App installed at the optional apt path (`/usr/lib/node/apps/`) with
91    /// no/invalid project signature. MUST be `Bun`; `Native` at this tier
92    /// triggers `TierError` (FR-028).
93    Optional,
94    /// App loaded from a developer's local dev directory (`NODE_DEV_APPS_DIR`),
95    /// via `node-app-build dev` or manual sideload. Bypasses signature checks
96    /// because the dev directory is owned by the developer (security gate is
97    /// the file-system path: only the dev user can write to it). Permitted
98    /// for `Native` apps so cdylib developers can iterate without per-build
99    /// GPG signing.
100    ///
101    /// Daemon logs every Development-tier load at `info!` so operators of a
102    /// real node can see when a non-prod app is active. UI badges this tier
103    /// distinctly (amber/red, never green).
104    Development,
105}
106
107impl AppTier {
108    pub fn as_str(self) -> &'static str {
109        match self {
110            AppTier::FirstParty => "first_party",
111            AppTier::Optional => "optional",
112            AppTier::Development => "development",
113        }
114    }
115}
116
117impl std::fmt::Display for AppTier {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        write!(f, "{}", self.as_str())
120    }
121}
122
123/// Host ABI compatibility version declared by the app.
124///
125/// The runtime's currently supported set is `[V1]`. Apps declaring an
126/// unsupported version are rejected with `AbiIncompatible` (FR-018).
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum AbiVersion {
130    V1,
131}
132
133impl AbiVersion {
134    pub fn as_str(&self) -> &'static str {
135        match self {
136            AbiVersion::V1 => "v1",
137        }
138    }
139
140    /// Returns true if this ABI version is supported by the current runtime.
141    pub fn is_supported(&self) -> bool {
142        matches!(self, AbiVersion::V1)
143    }
144}
145
146impl std::fmt::Display for AbiVersion {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(f, "{}", self.as_str())
149    }
150}
151
152/// How in-process (native) app reload is expected to behave.
153///
154/// Per research.md §R10, native hot-reload is inherently unreliable due to
155/// `dlclose` semantics. The manifest field sets correct user expectations.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum HotReloadKind {
159    /// Reload is reliable (Bun subprocess restart). Default for `Bun` apps.
160    Supported,
161    /// Reload is attempted but its result depends on whether the on-disk
162    /// library image actually changed. When the daemon has this app's OLD
163    /// image mapped (Linux's `dlopen` returns the cached handle for an
164    /// already-`dlopen`'d path, so a package upgrade landing new bytes at
165    /// the same path goes undetected without an explicit staleness check),
166    /// `app.reload` succeeds and reports `status: "restart_required"`; the
167    /// process keeps serving the OLD mapped image (with its last known-good
168    /// `provides`) until the node actually restarts, at which point the new
169    /// version activates. Default for `Native` apps (FR-024).
170    Experimental,
171    /// App must be restarted to pick up changes.
172    Unsupported,
173}
174
175impl HotReloadKind {
176    pub fn default_for(app_type: AppType) -> Self {
177        match app_type {
178            AppType::Native => HotReloadKind::Experimental,
179            AppType::Bun => HotReloadKind::Supported,
180            // Standalone apps are restarted by systemd, not the daemon —
181            // from the daemon's perspective they are never hot-reloaded.
182            AppType::Standalone => HotReloadKind::Unsupported,
183            AppType::PlatformRuntime => HotReloadKind::Unsupported,
184            AppType::ManagedV1 => HotReloadKind::Supported,
185            AppType::Burger => HotReloadKind::Supported,
186        }
187    }
188}
189
190// ── Sub-types ─────────────────────────────────────────────────────────────────
191
192/// Capability declarations from the v2 manifest `capabilities` block.
193///
194/// Semantic equivalent of the existing `permissions` + `provides` fields;
195/// v2 manifests may use either or both (backward compat preserved).
196#[derive(Debug, Clone, Default, Serialize, Deserialize)]
197pub struct ManifestCapabilities {
198    /// Capabilities this app requests from the host or other apps.
199    /// Format: `"core.lightning.payment.send:max=1000sat/day"` (see §1.2).
200    #[serde(default)]
201    pub requires: Vec<String>,
202
203    /// Capabilities this app provides to other apps.
204    /// Format: `"core.cron.register"`.
205    #[serde(default)]
206    pub provides: Vec<String>,
207}
208
209/// A single scope provided by an app (existing v1 model, preserved verbatim).
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211pub struct ProvidedScope {
212    pub scope: String,
213    pub description: String,
214    pub resource_pattern: String,
215}
216
217/// Declarative per-endpoint access policy (existing v1 model, preserved verbatim).
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct EndpointPolicy {
220    pub method: String,
221    pub path: String,
222    pub required_permissions: Vec<String>,
223}
224
225/// Capability provider declaration (existing v1 model, preserved verbatim).
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ProvidedCapability {
228    #[serde(default)]
229    pub description: String,
230    #[serde(default)]
231    pub schema: Option<serde_json::Value>,
232}
233
234/// Configuration for `AppType::Standalone` apps.
235///
236/// Carried only by manifests whose `app_type == "standalone"`. The daemon uses
237/// `socket_path` to route capability invocations as line-delimited JSON-RPC 2.0
238/// over the standalone daemon's own Unix domain socket.
239///
240/// Path-safety rules (validated by `AppManifest::validate`):
241/// - Absolute path.
242/// - Lives under `/run/`.
243/// - No `..` segments.
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct StandaloneConfig {
246    pub socket_path: std::path::PathBuf,
247}
248
249/// Browser UI unit shipped by an app package.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum AppUiKind {
253    Stage,
254    Widget,
255}
256
257fn default_app_ui_kind() -> AppUiKind {
258    AppUiKind::Stage
259}
260
261fn default_nav_section() -> String {
262    "default".to_string()
263}
264
265/// Shell-owned navigation metadata for a top-level stage.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct AppUiNav {
268    #[serde(default = "default_nav_section")]
269    pub section: String,
270    #[serde(default)]
271    pub order: i32,
272}
273
274/// Shell-chrome regions an app may contribute a surface to.
275///
276/// The shell owns this vocabulary; an app requests a region by name. Keep this
277/// list to slots that have a real occupant — a speculative slot is a contract
278/// nobody has had to honour yet.
279///
280/// Checked in TWO places on purpose. `node-app package` rejects an unknown slot
281/// so an author sees a typo while they can still fix it; the shell ALSO ignores
282/// surfaces whose slot it does not recognise, because an app packaged against a
283/// newer SDK can be installed on an older shell, and that shell must degrade by
284/// dropping the surface rather than failing the app.
285pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
286
287/// A UI unit an app contributes to a named region of the shell's own chrome.
288///
289/// Not a route: it has no nav entry, and it is mounted by the shell rather than
290/// by any stage. `requires` is the surface's OWN authorization scope — the
291/// primary containment control, since a surface otherwise receives the same
292/// `StageContext` a stage receives. A wallet chip declares `wallet.balance.get`
293/// and is refused `wallet.payment.send` even though the app provides it.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct AppUiSurface {
296    pub id: String,
297    pub slot: String,
298    pub entry: String,
299    pub title: String,
300    #[serde(default)]
301    pub order: i32,
302    #[serde(default)]
303    pub requires: AppUiRequirements,
304}
305
306/// How the client shell may behave when the home node is unavailable.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "kebab-case")]
309pub enum AppDataOfflinePolicy {
310    /// A stage may render the last verified cached projection with stale/offline labeling.
311    LastKnown,
312    /// A stage must fail clearly when the home node is unavailable.
313    OnlineOnly,
314}
315
316/// Generic query declaration shape for app-owned cached projections.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum AppDataQueryKind {
320    Collection,
321    Detail,
322    Snapshot,
323}
324
325/// Generic stream declaration shape for app-owned invalidation/cursor feeds.
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "kebab-case")]
328pub enum AppDataStreamKind {
329    Changes,
330    Events,
331}
332
333/// How the client shell refreshes app-owned data.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "kebab-case")]
336pub enum AppDataSyncKind {
337    Cursor,
338    Snapshot,
339}
340
341/// Bounded synchronization policy for generic app data.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct AppDataSyncPolicy {
344    pub kind: AppDataSyncKind,
345    pub cursor_ttl_secs: Option<u32>,
346    pub full_refresh_interval_secs: Option<u32>,
347    pub retention_secs: Option<u32>,
348}
349
350impl Serialize for AppDataSyncPolicy {
351    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
352    where
353        S: serde::Serializer,
354    {
355        use serde::ser::SerializeStruct;
356
357        if self.cursor_ttl_secs.is_none()
358            && self.full_refresh_interval_secs.is_none()
359            && self.retention_secs.is_none()
360        {
361            return self.kind.serialize(serializer);
362        }
363
364        let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
365        state.serialize_field("kind", &self.kind)?;
366        if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
367            state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
368        }
369        if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
370            state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
371        }
372        if let Some(retention_secs) = self.retention_secs {
373            state.serialize_field("retention_secs", &retention_secs)?;
374        }
375        state.end()
376    }
377}
378
379impl<'de> Deserialize<'de> for AppDataSyncPolicy {
380    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
381    where
382        D: serde::Deserializer<'de>,
383    {
384        #[derive(Deserialize)]
385        #[serde(deny_unknown_fields)]
386        struct ObjectPolicy {
387            kind: AppDataSyncKind,
388            #[serde(default)]
389            cursor_ttl_secs: Option<u32>,
390            #[serde(default)]
391            full_refresh_interval_secs: Option<u32>,
392            #[serde(default)]
393            retention_secs: Option<u32>,
394        }
395
396        #[derive(Deserialize)]
397        #[serde(untagged)]
398        enum WirePolicy {
399            Kind(AppDataSyncKind),
400            Object(ObjectPolicy),
401        }
402
403        match WirePolicy::deserialize(deserializer)? {
404            WirePolicy::Kind(kind) => Ok(Self {
405                kind,
406                cursor_ttl_secs: None,
407                full_refresh_interval_secs: None,
408                retention_secs: None,
409            }),
410            WirePolicy::Object(policy) => Ok(Self {
411                kind: policy.kind,
412                cursor_ttl_secs: policy.cursor_ttl_secs,
413                full_refresh_interval_secs: policy.full_refresh_interval_secs,
414                retention_secs: policy.retention_secs,
415            }),
416        }
417    }
418}
419
420/// A namespaced app-owned query exposed through the generic stage data plane.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct AppDataQueryDeclaration {
424    pub name: String,
425    pub capability: String,
426    pub kind: AppDataQueryKind,
427}
428
429/// A namespaced app-owned stream exposed through the generic stage data plane.
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431#[serde(deny_unknown_fields)]
432pub struct AppDataStreamDeclaration {
433    pub name: String,
434    pub kind: AppDataStreamKind,
435}
436
437/// Generic, app-owned data contract declared by a stage manifest.
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439#[serde(deny_unknown_fields)]
440pub struct AppDataManifest {
441    pub namespace: String,
442    pub offline: AppDataOfflinePolicy,
443    pub sync: AppDataSyncPolicy,
444    #[serde(default)]
445    pub queries: Vec<AppDataQueryDeclaration>,
446    #[serde(default)]
447    pub streams: Vec<AppDataStreamDeclaration>,
448}
449
450/// Capability, query, and stream contracts exposed to an app-delivered UI
451/// stage. This is intentionally separate from the app's backend dependency
452/// declaration (`requires` / `capabilities.requires`): backend providers may
453/// need capabilities that must never be delegated to browser UI code.
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
455#[serde(deny_unknown_fields)]
456pub struct AppUiRequirements {
457    #[serde(default)]
458    pub capabilities: Vec<String>,
459    #[serde(default)]
460    pub queries: Vec<String>,
461    #[serde(default)]
462    pub streams: Vec<String>,
463}
464
465impl AppUiRequirements {
466    /// Return the UI's complete declared contract in stable, de-duplicated
467    /// order. Query and stream names are included because they are separately
468    /// authorized stage declarations at the client RPC boundary.
469    pub fn resolved(&self) -> Result<Vec<String>, String> {
470        let mut resolved = Vec::new();
471        let mut seen = HashSet::new();
472        for (values, allow_wildcard) in [
473            (&self.capabilities, true),
474            (&self.queries, false),
475            (&self.streams, false),
476        ] {
477            for value in values {
478                let requirement = value.trim();
479                if requirement.is_empty() {
480                    return Err("ui.requires entries must not be blank".to_string());
481                }
482                validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
483                    format!("ui.requires entry '{requirement}' invalid: {error}")
484                })?;
485                if seen.insert(requirement.to_string()) {
486                    resolved.push(requirement.to_string());
487                }
488            }
489        }
490        Ok(resolved)
491    }
492}
493
494/// Optional stage metadata carried by the canonical app manifest.
495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496pub struct AppUiManifest {
497    #[serde(default = "default_app_ui_kind")]
498    pub kind: AppUiKind,
499    pub entry: String,
500    pub title: String,
501    #[serde(default)]
502    pub icon: Option<String>,
503    #[serde(default)]
504    pub nav: Option<AppUiNav>,
505    #[serde(default)]
506    pub composes: Vec<String>,
507    /// Shell-chrome contributions. Empty for the overwhelming majority of apps.
508    #[serde(default)]
509    pub surfaces: Vec<AppUiSurface>,
510    pub ui_api: u8,
511    #[serde(default)]
512    pub integrity: BTreeMap<String, String>,
513    /// Stage-specific description that overrides the app-level
514    /// `AppManifest::description` when the stage's UI purpose differs from the
515    /// app's. Optional; when absent the app-level description is used.
516    #[serde(default)]
517    pub description: Option<String>,
518    /// Author-supplied synonyms for this stage (search/intent phrasings).
519    /// Optional; defaults to empty.
520    #[serde(default)]
521    pub keywords: Vec<String>,
522    /// The browser stage contract. Do not populate this from the app's
523    /// backend `requires` declaration.
524    #[serde(default)]
525    pub requires: AppUiRequirements,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    pub data: Option<AppDataManifest>,
528}
529
530// ── Path-safety helpers ───────────────────────────────────────────────────────
531
532/// Validate a relative file path declared in a manifest (`entrypoint`, `ui_path`).
533///
534/// Rules (SEC-H3):
535/// 1. Matches `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$` — rejects shell metacharacters,
536///    leading `.`, leading `/`, etc.
537/// 2. No `..` segment anywhere.
538/// 3. Does not begin with `/` (absolute paths).
539///
540/// Returns `Ok(())` if valid, `Err(reason)` describing the violation.
541pub fn validate_manifest_path(path: &str) -> Result<(), String> {
542    if path.is_empty() {
543        return Err("path must not be empty".to_string());
544    }
545
546    // Rule 3: no absolute paths
547    if path.starts_with('/') {
548        return Err(format!(
549            "path '{}' must not be absolute (starts with /)",
550            path
551        ));
552    }
553
554    // Rule 1: allowed character set
555    // ^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$
556    let first = path.chars().next().unwrap();
557    if !first.is_ascii_alphanumeric() && first != '_' {
558        return Err(format!(
559            "path '{}' must begin with an alphanumeric character or underscore",
560            path
561        ));
562    }
563    for ch in path.chars().skip(1) {
564        if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
565            return Err(format!(
566                "path '{}' contains disallowed character '{}'",
567                path, ch
568            ));
569        }
570    }
571
572    // Rule 2: no `..` segment
573    for segment in path.split('/') {
574        if segment == ".." {
575            return Err(format!(
576                "path '{}' contains a '..' segment (path traversal rejected)",
577                path
578            ));
579        }
580    }
581
582    Ok(())
583}
584
585// ── AppManifest ───────────────────────────────────────────────────────────────
586
587/// Canonical manifest entity — unified v1/v2 format.
588///
589/// Deserializes both old (v1, no `manifest_version`) and new (v2) manifests.
590/// All v2-only fields use `#[serde(default)]` so that v1 manifests parse
591/// correctly without any field changes.
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct AppManifest {
594    /// Schema version. Absent or 1 = legacy v1; 2 = extended v2.
595    #[serde(default = "default_manifest_version", rename = "manifest_version")]
596    pub manifest_version: u8,
597
598    pub name: String,
599    pub version: String,
600
601    #[serde(default = "default_app_type_native")]
602    pub app_type: AppType,
603
604    #[serde(default)]
605    pub description: String,
606
607    // ── v2-only additions (all optional, v1-compatible defaults) ─────────────
608    /// Host ABI compatibility version. Required when `manifest_version == 2`.
609    pub abi: Option<AbiVersion>,
610
611    /// Payload entry point relative to the app directory.
612    /// Default: `app.so` for Native, `dist/index.js` for Bun.
613    pub entrypoint: Option<String>,
614
615    /// Hot-reload behaviour classification.
616    /// Default: `experimental` for Native, `supported` for Bun.
617    pub hot_reload: Option<HotReloadKind>,
618
619    // ── Existing v1 fields (preserved verbatim — DO NOT RENAME) ──────────────
620    #[serde(default)]
621    pub critical: bool,
622
623    #[serde(
624        default = "default_auto_start",
625        deserialize_with = "deserialize_auto_start"
626    )]
627    pub auto_start: bool,
628
629    #[serde(default)]
630    pub has_ui: bool,
631
632    #[serde(default = "default_ui_path")]
633    pub ui_path: String,
634
635    #[serde(default)]
636    pub permissions: Vec<String>,
637
638    /// Capability requirements in the v2 top-level vocabulary. This is an
639    /// alias for `capabilities.requires`, not a second permission system.
640    #[serde(default)]
641    pub requires: Vec<String>,
642
643    #[serde(default)]
644    pub optional_permissions: Vec<String>,
645
646    #[serde(default)]
647    pub provides_scopes: Vec<ProvidedScope>,
648
649    #[serde(default)]
650    pub endpoint_policies: Vec<EndpointPolicy>,
651
652    #[serde(default)]
653    pub capability_scopes: HashMap<String, String>,
654
655    #[serde(default)]
656    pub provides: HashMap<String, ProvidedCapability>,
657
658    // ── v2 capabilities block (semantic alias for permissions + provides) ─────
659    #[serde(default)]
660    pub capabilities: ManifestCapabilities,
661
662    /// App-delivered browser UI metadata. Legacy `has_ui`/`ui_path` remains
663    /// readable but does not synthesize this block.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub ui: Option<AppUiManifest>,
666
667    // ── Optional metadata fields ──────────────────────────────────────────────
668    #[serde(default)]
669    pub author: Option<String>,
670
671    #[serde(default)]
672    pub homepage: Option<String>,
673
674    #[serde(default)]
675    pub depends_on: Option<Vec<String>>,
676
677    #[serde(default)]
678    pub boot_priority: Option<u32>,
679
680    /// App-governor idle-termination policy (issue #811 SP1). Absent means
681    /// the app is subject to the default eligibility rules with no explicit
682    /// opt-out and no minimum-idle override.
683    #[serde(default)]
684    pub governor: Option<GovernorManifest>,
685
686    /// Event-bus topics this app listens for while lazily started. Only
687    /// meaningful for apps holding the `EVENT_LISTENER` capability — a
688    /// listener with no declared `subscribes` topics is exempt from idle
689    /// termination because the governor cannot know what would need to wake
690    /// it back up (see `node-app-host::governor_eligibility`).
691    #[serde(default)]
692    pub subscribes: Vec<String>,
693
694    /// Required when `app_type == "standalone"` and the manifest declares any
695    /// `provides` / `capabilities.provides` entries. Carries the Unix domain
696    /// socket path the daemon dispatches capability calls to.
697    #[serde(default)]
698    pub standalone: Option<StandaloneConfig>,
699
700    /// Optional TCP-binding block — feature 470 (port registry).
701    /// Absence means the app does not bind a TCP port the registry manages.
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub tcp: Option<TcpManifest>,
704
705    /// Runtime resource requests (Burger Plan 02, Contracts C2/C7). Absence means
706    /// runtime defaults (Burger: 32 MB QuickJS memory limit, 5000 ms callback deadline).
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub resources: Option<ResourcesManifest>,
709}
710
711/// Upper bound for `resources.callback_deadline_ms` (10 minutes).
712pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
713
714/// Runtime resource requests (Contract C2). `memory_mb` becomes the Burger
715/// host's per-app QuickJS memory limit (`load_app.memory_limit_mb`);
716/// `callback_deadline_ms` becomes its per-callback CPU watchdog
717/// (`load_app.callback_deadline_ms`). Both are omitted from `load_app` when
718/// absent so the Burger host applies its own defaults (32 MB, 5000 ms).
719#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
720pub struct ResourcesManifest {
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub memory_mb: Option<u32>,
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub callback_deadline_ms: Option<u32>,
725}
726
727/// Declared responsiveness expectation for an app's lease engine decisions
728/// (app lease engine design §8, Task 1). `None` on [`GovernorManifest`] means
729/// the app has not declared a preference — the lease engine (Task 5) then
730/// falls back to its own default rather than treating an unset field as
731/// either variant.
732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
733#[serde(rename_all = "lowercase")]
734pub enum LatencyClass {
735    /// The app serves latency-sensitive, user-facing requests — the lease
736    /// engine should prefer to keep it warm.
737    Interactive,
738    /// The app only does deferred/background work — the lease engine may
739    /// treat it as a lower priority to keep resident.
740    Background,
741}
742
743impl LatencyClass {
744    pub fn as_str(&self) -> &'static str {
745        match self {
746            LatencyClass::Interactive => "interactive",
747            LatencyClass::Background => "background",
748        }
749    }
750
751    #[allow(clippy::should_implement_trait)]
752    pub fn from_str(s: &str) -> Result<Self, String> {
753        match s {
754            "interactive" => Ok(LatencyClass::Interactive),
755            "background" => Ok(LatencyClass::Background),
756            _ => Err(format!("Invalid LatencyClass: {}", s)),
757        }
758    }
759}
760
761impl std::fmt::Display for LatencyClass {
762    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
763        write!(f, "{}", self.as_str())
764    }
765}
766
767/// Idle-termination policy for a lazily-started app (issue #811 SP1 — the
768/// app governor). Nested under `AppManifest::governor`.
769#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
770pub struct GovernorManifest {
771    /// Explicit opt-out. `Some(false)` exempts the app from idle termination
772    /// regardless of any other eligibility rule. `None`/`Some(true)` defers
773    /// to the other eligibility rules.
774    #[serde(default)]
775    pub terminable: Option<bool>,
776
777    /// Minimum idle duration, in seconds, before the governor may terminate
778    /// this app — overrides the governor's default sweep threshold. `None`
779    /// defers to the default.
780    #[serde(default)]
781    pub min_idle_secs: Option<u64>,
782
783    /// Memory budget in KB. When the app's measured footprint — on the basis
784    /// selected by its measurement attribution, see
785    /// `node_app_host::app_memory::budget` — exceeds this, the owner is warned
786    /// in the shell.
787    ///
788    /// # What `None` defers to
789    ///
790    /// NOT one number. The default is chosen PER BASIS
791    /// (`node_app_host::app_memory::budget::default_budget_kb`), because the
792    /// bases are not comparable quantities:
793    ///
794    /// | basis                | default   | why                                     |
795    /// |----------------------|-----------|-----------------------------------------|
796    /// | `heap_used`, `pss`   | 10,240 KB | the app and nothing else                |
797    /// | `rss`                | 61,440 KB | the whole OS process, runtime included  |
798    /// | `not_attributable`   | 10,240 KB | never `over`; carried only for the wire |
799    ///
800    /// A shared-runtime Bun worker is compared on `heap_used`; a dedicated
801    /// process or cgroup-scoped standalone on `rss`, which charges it for a
802    /// JavaScript engine it did not choose and cannot shed.
803    ///
804    /// On top of that, a host-side runtime-critical entry
805    /// (`RUNTIME_CRITICAL_BUDGETS`) acts as a FLOOR, never a ceiling: it can
806    /// only raise an app above the per-basis default, never pull it below one.
807    ///
808    /// A value declared HERE is the one thing that overrides both, in either
809    /// direction — it is a deliberate choice by the app author, not a fallback,
810    /// so it is honoured unchanged even when it is lower than the default.
811    ///
812    /// Apps that legitimately need more than their basis default MUST declare a
813    /// realistic budget here; otherwise the warning is permanently lit and
814    /// stops meaning anything.
815    #[serde(default)]
816    pub memory_budget_kb: Option<u64>,
817
818    /// Declared responsiveness expectation (app lease engine design §8,
819    /// Task 1). `None` when the app declares no preference — see
820    /// [`LatencyClass`] for what each variant means and what `None` defers
821    /// to.
822    #[serde(default)]
823    pub latency_class: Option<LatencyClass>,
824}
825
826/// TCP port preferences for standalone apps that bind their own port.
827/// Consumed by the port registry (`system/server/src/services/port_registry/`)
828/// at install time.
829#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
830pub struct TcpManifest {
831    /// The TCP port the app would like to bind. Honored when free;
832    /// otherwise the registry assigns the next free port from the pool
833    /// (default 7000–7099). Absent → registry picks any free pool slot.
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub preferred_port: Option<u16>,
836
837    /// When `true`, the platform UI shell builds iframe URLs as direct LAN
838    /// connections to the assigned port rather than routing via the
839    /// `/api/v2/node-apps/{name}/ui/` reverse-proxy. Intended only for apps
840    /// that must outlive a platform restart (e.g. OTA self-upgrade). Remote
841    /// users may see a degraded experience — owned by the consuming app's UI,
842    /// not this spec (see `specs/470-port-registry/spec.md` Clarifications Q5b).
843    #[serde(default, skip_serializing_if = "Option::is_none")]
844    pub direct_bind: Option<bool>,
845}
846
847fn default_manifest_version() -> u8 {
848    1
849}
850
851fn default_auto_start() -> bool {
852    true
853}
854
855/// Deserialize `auto_start` from either a bool (manifest v1) or a load-mode
856/// string (v2, e.g. `"lazy"`/`"eager"`/`"active"`). Eager-start modes map to
857/// `true`; `"lazy"` and other on-demand/inactive states map to `false` (the app
858/// is started on first capability use, not at boot). This keeps both manifest
859/// schema generations parseable by `AppManifest::from_json`.
860fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
861where
862    D: serde::Deserializer<'de>,
863{
864    #[derive(Deserialize)]
865    #[serde(untagged)]
866    enum BoolOrStr {
867        Bool(bool),
868        Str(String),
869    }
870    Ok(match BoolOrStr::deserialize(deserializer)? {
871        BoolOrStr::Bool(b) => b,
872        BoolOrStr::Str(s) => matches!(
873            s.trim().to_ascii_lowercase().as_str(),
874            "true" | "eager" | "active" | "auto" | "on" | "1"
875        ),
876    })
877}
878
879fn default_ui_path() -> String {
880    "dist".to_string()
881}
882
883fn default_app_type_native() -> AppType {
884    AppType::Native
885}
886
887impl AppManifest {
888    /// Resolve top-level `requires` and `capabilities.requires` into one
889    /// canonical declaration list. Equal aliases are accepted regardless of
890    /// order or duplicates; differing aliases are rejected.
891    pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
892        let top = normalized_requirements(&self.requires)?;
893        let nested = normalized_requirements(&self.capabilities.requires)?;
894        if !top.is_empty()
895            && !nested.is_empty()
896            && top.iter().cloned().collect::<BTreeSet<_>>()
897                != nested.iter().cloned().collect::<BTreeSet<_>>()
898        {
899            return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
900        }
901        Ok(if !top.is_empty() { top } else { nested })
902    }
903
904    /// Returns the effective `HotReloadKind` — explicit field or the default
905    /// for the app type.
906    pub fn effective_hot_reload(&self) -> HotReloadKind {
907        self.hot_reload
908            .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
909    }
910
911    /// Returns the effective entrypoint — explicit field or the type-specific default.
912    ///
913    /// Standalone apps have no daemon-managed entrypoint (systemd owns the
914    /// lifecycle); the empty string signals "not applicable".
915    pub fn effective_entrypoint(&self) -> &str {
916        if let Some(ref ep) = self.entrypoint {
917            ep.as_str()
918        } else {
919            match self.app_type {
920                AppType::Native => "app.so",
921                AppType::Bun => "dist/index.js",
922                AppType::Standalone => "",
923                AppType::PlatformRuntime => "bun",
924                AppType::ManagedV1 => "llmc-generated-app",
925                AppType::Burger => "dist/index.js",
926            }
927        }
928    }
929
930    /// True iff this manifest declares at least one capability provider
931    /// (via either the v1 `provides` map or the v2 `capabilities.provides` list).
932    pub fn has_capability_providers(&self) -> bool {
933        !self.provides.is_empty() || !self.capabilities.provides.is_empty()
934    }
935
936    /// Merges the v1 `provides` map and the v2 `capabilities.provides` name
937    /// list into a single capability→declaration map (composition-root
938    /// cleanup Round 4 T28 — extracted from
939    /// `control_ipc::handlers::handle_app_register_standalone`, which uses
940    /// this to shape a standalone app's declared providers for capability
941    /// registration).
942    ///
943    /// - v1 entries (the `provides` map) carry their real
944    ///   description/schema and always win on a name conflict.
945    /// - v2-only names (declared only via `capabilities.provides`, format
946    ///   `"name"` or `"name:extra"` — only the part before the first `:` is
947    ///   used) get a blank declaration, inserted only if the name is not
948    ///   already present from v1. Blank/whitespace-only names are skipped.
949    pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
950        let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
951        for raw in &self.capabilities.provides {
952            let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
953            if name.is_empty() {
954                continue;
955            }
956            out.entry(name).or_insert(ProvidedCapability {
957                description: String::new(),
958                schema: None,
959            });
960        }
961        out
962    }
963
964    /// Validate the manifest for structural correctness.
965    ///
966    /// Returns `Ok(())` on success, or a human-readable error string.
967    /// Called by the manifest parser after deserialization.
968    pub fn validate(&self) -> Result<(), String> {
969        self.validate_with_socket_path_policy(false)
970    }
971
972    /// Validate this manifest with an explicit standalone socket-path policy.
973    ///
974    /// Runtime adapters may opt into non-`/run` paths for development without
975    /// making the domain model read process configuration.
976    pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
977        // v2 requires abi field
978        if self.manifest_version == 2 && self.abi.is_none() {
979            return Err("manifest_version 2 requires an 'abi' field".to_string());
980        }
981
982        // Name validation: ^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$
983        // (publisher/name form accepted but not yet semantically used — FR-019)
984        validate_app_name(&self.name)?;
985        self.resolved_requires()?;
986
987        // Path-safety on entrypoint and ui_path
988        if let Some(ref ep) = self.entrypoint {
989            validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
990        }
991        // ui_path is only meaningful when has_ui is true, but validate always
992        if !self.ui_path.is_empty() && self.ui_path != "dist" {
993            validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
994        }
995
996        if let Some(ui) = &self.ui {
997            validate_app_ui(&self.name, ui)?;
998        }
999
1000        // Homepage scheme validation (if present)
1001        if let Some(ref hp) = self.homepage {
1002            if !hp.starts_with("https://") && !hp.starts_with("http://") {
1003                return Err(format!(
1004                    "homepage '{}' must use https:// or http:// scheme",
1005                    hp
1006                ));
1007            }
1008        }
1009
1010        // Standalone-app rules:
1011        // - When `app_type == "standalone"` AND the manifest declares any
1012        //   capability providers, `standalone.socket_path` is required and
1013        //   must be an absolute path under `/run/` with no `..` segments.
1014        // - Non-standalone manifests MUST NOT carry a `standalone` block
1015        //   (rejected to surface accidental schema misuse).
1016        match self.app_type {
1017            AppType::Standalone => {
1018                if self.has_capability_providers() {
1019                    let cfg = self.standalone.as_ref().ok_or_else(|| {
1020                        "standalone apps that declare 'provides' require a \
1021                         'standalone.socket_path' field"
1022                            .to_string()
1023                    })?;
1024                    validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1025                }
1026            }
1027            AppType::Native
1028            | AppType::Bun
1029            | AppType::PlatformRuntime
1030            | AppType::ManagedV1
1031            | AppType::Burger => {
1032                if self.standalone.is_some() {
1033                    return Err(format!(
1034                        "'standalone' block is only valid when app_type == 'standalone' \
1035                         (found app_type='{}')",
1036                        self.app_type
1037                    ));
1038                }
1039            }
1040        }
1041
1042        if let Some(resources) = &self.resources {
1043            if resources.memory_mb == Some(0) {
1044                return Err("resources.memory_mb must be at least 1".to_string());
1045            }
1046            if let Some(deadline) = resources.callback_deadline_ms {
1047                if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1048                    return Err(format!(
1049                        "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1050                    ));
1051                }
1052            }
1053        }
1054
1055        if self.app_type == AppType::PlatformRuntime
1056            && !self.resolved_capability_provides().is_empty()
1057        {
1058            return Err(
1059                "platform-runtime packages cannot provide runtime capabilities".to_string(),
1060            );
1061        }
1062
1063        Ok(())
1064    }
1065
1066    /// Parse from a JSON string, validate, and return the manifest.
1067    pub fn from_json(json: &str) -> Result<Self, String> {
1068        Self::from_json_with_socket_path_policy(json, false)
1069    }
1070
1071    /// Parse and validate with an explicit standalone socket-path policy.
1072    pub fn from_json_with_socket_path_policy(
1073        json: &str,
1074        allow_non_run: bool,
1075    ) -> Result<Self, String> {
1076        let mut manifest: Self =
1077            serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1078        if manifest.ui.is_some() {
1079            manifest.has_ui = true;
1080        }
1081        manifest.validate_with_socket_path_policy(allow_non_run)?;
1082        Ok(manifest)
1083    }
1084}
1085
1086fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1087    let mut seen = HashSet::new();
1088    let mut resolved = Vec::new();
1089    for value in values {
1090        let requirement = value.trim();
1091        if requirement.is_empty() {
1092            return Err("capability requirements must not be blank".to_string());
1093        }
1094        if seen.insert(requirement.to_string()) {
1095            resolved.push(requirement.to_string());
1096        }
1097    }
1098    Ok(resolved)
1099}
1100
1101fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1102    if ui.ui_api != 1 && ui.ui_api != 2 {
1103        return Err(format!(
1104            "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1105            ui.ui_api
1106        ));
1107    }
1108    if ui.title.trim().is_empty() {
1109        return Err("ui.title must not be blank".to_string());
1110    }
1111    ui.requires.resolved()?;
1112    validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1113    if let Some(icon) = &ui.icon {
1114        validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1115    }
1116    if let Some(nav) = &ui.nav {
1117        if ui.kind == AppUiKind::Widget {
1118            return Err("widget ui must omit nav metadata".to_string());
1119        }
1120        if nav.section.trim().is_empty() {
1121            return Err("ui.nav.section must not be blank".to_string());
1122        }
1123    }
1124    if let Some(data) = &ui.data {
1125        if ui.kind != AppUiKind::Stage {
1126            return Err("widget ui must omit app data declarations".to_string());
1127        }
1128        validate_app_data(data, &ui.requires.resolved()?)?;
1129    }
1130
1131    let mut composed = HashSet::new();
1132    for name in &ui.composes {
1133        validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1134        if name == app_name {
1135            return Err("ui.composes must not contain the app itself".to_string());
1136        }
1137        if !composed.insert(name) {
1138            return Err(format!("ui.composes contains duplicate app '{name}'"));
1139        }
1140    }
1141
1142    let mut surface_ids = HashSet::new();
1143    for surface in &ui.surfaces {
1144        let id = surface.id.trim();
1145        if id.is_empty() {
1146            return Err("ui.surfaces entry id must not be blank".to_string());
1147        }
1148        if !surface_ids.insert(id.to_string()) {
1149            return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1150        }
1151        if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1152            return Err(format!(
1153                "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1154                surface.slot,
1155                KNOWN_SURFACE_SLOTS.join(", ")
1156            ));
1157        }
1158        if surface.title.trim().is_empty() {
1159            return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1160        }
1161        validate_manifest_path(&surface.entry)
1162            .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1163        // Same rule `ui.entry` and `ui.icon` get below, and for a sharper reason: the client
1164        // kernel's `ensureIntegrityForUi` (`client/kernel/src/stages/stage-registry-service.js`)
1165        // REQUIRES a digest for every surface entry, and the throw there propagates out of
1166        // `parseCatalogEntry` through `parseCatalogResponse`'s `value.map(...)` — failing the
1167        // whole catalog snapshot, every stage on the node, and looping on retry. Without this
1168        // check a typo, or an entry emitted outside `ui_path` (which is the only tree
1169        // `generate_staged_integrity` stamps), packages cleanly, installs cleanly, and then
1170        // bricks every client's stage list. Refuse it here, where the author can still fix it.
1171        if !ui.integrity.contains_key(&surface.entry) {
1172            return Err(format!("ui.integrity must include surface '{id}' entry"));
1173        }
1174        surface
1175            .requires
1176            .resolved()
1177            .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1178    }
1179
1180    for (path, digest) in &ui.integrity {
1181        validate_manifest_path(path)
1182            .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1183        if !is_lowercase_sha256(digest) {
1184            return Err(format!(
1185                "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1186            ));
1187        }
1188    }
1189    if !ui.integrity.contains_key(&ui.entry) {
1190        return Err("ui.integrity must include the declared entry".to_string());
1191    }
1192    if let Some(icon) = &ui.icon {
1193        if !ui.integrity.contains_key(icon) {
1194            return Err("ui.integrity must include the declared icon".to_string());
1195        }
1196    }
1197    Ok(())
1198}
1199
1200fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1201    validate_app_data_namespace(&data.namespace)?;
1202    validate_app_data_sync_policy(&data.sync)?;
1203    if data.queries.is_empty() {
1204        return Err("ui.data.queries must declare at least one query".to_string());
1205    }
1206
1207    let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1208    let mut names = BTreeSet::new();
1209    for query in &data.queries {
1210        validate_namespaced_data_name(&query.name, &data.namespace)
1211            .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1212        validate_capability_name(&query.capability).map_err(|error| {
1213            format!(
1214                "ui.data query '{}' capability '{}' invalid: {error}",
1215                query.name, query.capability
1216            )
1217        })?;
1218        if !requires.contains(query.capability.as_str()) {
1219            return Err(format!(
1220                "ui.data query '{}' capability '{}' must be declared in requires",
1221                query.name, query.capability
1222            ));
1223        }
1224        if !names.insert(query.name.as_str()) {
1225            return Err(format!("ui.data contains duplicate query '{}'", query.name));
1226        }
1227    }
1228
1229    for stream in &data.streams {
1230        validate_namespaced_data_name(&stream.name, &data.namespace)
1231            .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1232        if !names.insert(stream.name.as_str()) {
1233            return Err(format!(
1234                "ui.data contains duplicate declaration '{}'",
1235                stream.name
1236            ));
1237        }
1238    }
1239
1240    Ok(())
1241}
1242
1243fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1244    if !is_safe_name_segment(namespace) {
1245        return Err(format!(
1246            "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1247            namespace
1248        ));
1249    }
1250    if matches!(
1251        namespace,
1252        "core" | "internal" | "node" | "platform" | "system"
1253    ) {
1254        return Err(format!("ui.data namespace '{namespace}' is reserved"));
1255    }
1256    Ok(())
1257}
1258
1259fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1260    validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1261    validate_optional_range(
1262        "full_refresh_interval_secs",
1263        sync.full_refresh_interval_secs,
1264        60,
1265        604_800,
1266    )?;
1267    validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1268    if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1269        return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1270    }
1271    Ok(())
1272}
1273
1274fn validate_optional_range(
1275    field: &str,
1276    value: Option<u32>,
1277    min: u32,
1278    max: u32,
1279) -> Result<(), String> {
1280    if let Some(value) = value {
1281        if value < min || value > max {
1282            return Err(format!(
1283                "ui.data.sync {field} must be between {min} and {max} seconds"
1284            ));
1285        }
1286    }
1287    Ok(())
1288}
1289
1290fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1291    validate_capability_name(name)?;
1292    let Some(rest) = name
1293        .strip_prefix(namespace)
1294        .and_then(|suffix| suffix.strip_prefix('.'))
1295    else {
1296        return Err(format!("name must use namespace '{namespace}'"));
1297    };
1298    if rest.is_empty() {
1299        return Err("name must include a value after its namespace".to_string());
1300    }
1301    if !has_version_suffix(name) {
1302        return Err("name must end with a .vN version suffix".to_string());
1303    }
1304    Ok(())
1305}
1306
1307fn validate_capability_name(name: &str) -> Result<(), String> {
1308    if name.is_empty() {
1309        return Err("name must not be empty".to_string());
1310    }
1311    if name.contains('/') || name.contains("..") {
1312        return Err("name must not contain path separators or traversal".to_string());
1313    }
1314    if !name.split('.').all(is_safe_declaration_segment) {
1315        return Err("name must contain only lowercase dot-separated segments".to_string());
1316    }
1317    Ok(())
1318}
1319
1320fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1321    if allow_wildcard && name.ends_with(".*") {
1322        return validate_capability_name(&name[..name.len() - 2]);
1323    }
1324    validate_capability_name(name)
1325}
1326
1327fn has_version_suffix(name: &str) -> bool {
1328    let Some(version) = name.rsplit('.').next() else {
1329        return false;
1330    };
1331    let Some(digits) = version.strip_prefix('v') else {
1332        return false;
1333    };
1334    !digits.is_empty()
1335        && !digits.starts_with('0')
1336        && digits.bytes().all(|byte| byte.is_ascii_digit())
1337}
1338
1339fn is_safe_name_segment(segment: &str) -> bool {
1340    if segment.is_empty() {
1341        return false;
1342    }
1343    let mut chars = segment.chars();
1344    let Some(first) = chars.next() else {
1345        return false;
1346    };
1347    first.is_ascii_lowercase()
1348        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1349}
1350
1351/// A segment of a capability, query, or stream name.
1352///
1353/// Deliberately looser than [`is_safe_name_segment`] by exactly one character:
1354/// `_`. Capability actions in this codebase are snake_case almost without
1355/// exception (`core.lightning.create_invoice`, `core.did.current_did`,
1356/// `contest.world.studio_state`), and app-event resources are too
1357/// (`app.agent_session` — `APP_EVENT_RESOURCE_PATTERN` in `@econ-v1/domain`
1358/// admits `_` for precisely these). Rejecting `_` here did not make a stage
1359/// safer, it made `ui.requires` unusable: a stage that declared any real
1360/// capability failed `resolved()`, and `build_ui_stage_catalog` then dropped
1361/// that stage from the shell entirely. The characters that actually matter —
1362/// path separators, traversal, uppercase, leading digits — are still refused.
1363fn is_safe_declaration_segment(segment: &str) -> bool {
1364    let mut chars = segment.chars();
1365    let Some(first) = chars.next() else {
1366        return false;
1367    };
1368    first.is_ascii_lowercase()
1369        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1370}
1371
1372fn is_lowercase_sha256(value: &str) -> bool {
1373    value.len() == 64
1374        && value
1375            .bytes()
1376            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1377}
1378
1379/// Validate a `StandaloneConfig::socket_path`.
1380///
1381/// Rules:
1382/// 1. Absolute path (starts with `/`).
1383/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc. — pins the
1384///    socket to a tmpfs path predictably writable by the standalone daemon).
1385///    Runtime adapters can explicitly bypass this restriction for development.
1386/// 3. No `..` segments anywhere in the path.
1387pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1388    validate_standalone_socket_path_with_policy(path, false)
1389}
1390
1391/// Validate a standalone socket path with an explicit runtime policy.
1392pub fn validate_standalone_socket_path_with_policy(
1393    path: &std::path::Path,
1394    allow_non_run: bool,
1395) -> Result<(), String> {
1396    if !path.is_absolute() {
1397        return Err(format!(
1398            "standalone.socket_path '{}' must be absolute",
1399            path.display()
1400        ));
1401    }
1402    if !allow_non_run && !path.starts_with("/run/") {
1403        return Err(format!(
1404            "standalone.socket_path '{}' must live under /run/",
1405            path.display()
1406        ));
1407    }
1408    if path
1409        .components()
1410        .any(|c| matches!(c, std::path::Component::ParentDir))
1411    {
1412        return Err(format!(
1413            "standalone.socket_path '{}' must not contain '..' segments",
1414            path.display()
1415        ));
1416    }
1417    Ok(())
1418}
1419
1420/// Validate an app name string.
1421///
1422/// Accepts `app-name` (simple) and `publisher/app-name` (publisher-prefixed, FR-019).
1423fn validate_app_name(name: &str) -> Result<(), String> {
1424    let (publisher, app) = if let Some(slash) = name.find('/') {
1425        let (p, rest) = name.split_at(slash);
1426        (Some(p), &rest[1..])
1427    } else {
1428        (None, name)
1429    };
1430
1431    let valid_segment = |s: &str| -> bool {
1432        if s.is_empty() {
1433            return false;
1434        }
1435        let mut chars = s.chars();
1436        let first = chars.next().unwrap();
1437        if !first.is_ascii_lowercase() {
1438            return false;
1439        }
1440        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1441    };
1442
1443    if let Some(pub_name) = publisher {
1444        if !valid_segment(pub_name) {
1445            return Err(format!(
1446                "publisher segment '{}' must match [a-z][a-z0-9-]*",
1447                pub_name
1448            ));
1449        }
1450    }
1451
1452    if !valid_segment(app) {
1453        return Err(format!(
1454            "app name segment '{}' must match [a-z][a-z0-9-]*",
1455            app
1456        ));
1457    }
1458
1459    Ok(())
1460}
1461
1462// ── Tests ─────────────────────────────────────────────────────────────────────
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467
1468    fn parse_ok(json: &str) -> AppManifest {
1469        AppManifest::from_json(json).expect("should parse")
1470    }
1471
1472    fn parse_err(json: &str) -> String {
1473        AppManifest::from_json(json).expect_err("should fail")
1474    }
1475
1476    // ── v1 manifests ──────────────────────────────────────────────────────────
1477
1478    #[test]
1479    fn v1_minimal_native() {
1480        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1481        assert_eq!(m.manifest_version, 1);
1482        assert_eq!(m.app_type, AppType::Native);
1483        assert!(m.abi.is_none());
1484    }
1485
1486    #[test]
1487    fn v1_minimal_bun() {
1488        let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1489        assert_eq!(m.app_type, AppType::Bun);
1490        assert_eq!(m.effective_entrypoint(), "dist/index.js");
1491    }
1492
1493    #[test]
1494    fn v1_no_manifest_version_field_defaults_to_1() {
1495        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1496        assert_eq!(m.manifest_version, 1);
1497    }
1498
1499    #[test]
1500    fn v1_all_optional_fields_missing() {
1501        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1502        assert!(!m.critical);
1503        assert!(m.auto_start);
1504        assert!(!m.has_ui);
1505        assert_eq!(m.ui_path, "dist");
1506        assert!(m.permissions.is_empty());
1507        assert!(m.optional_permissions.is_empty());
1508        // #1556: governor/subscribes absent → today's implicit behavior
1509        // (no opt-out, no min-idle override, no declared subscriptions).
1510        assert!(m.governor.is_none());
1511        assert!(m.subscribes.is_empty());
1512    }
1513
1514    #[test]
1515    fn v1_with_permissions_and_provides() {
1516        let json = r#"{
1517            "name": "example",
1518            "version": "1.0.0",
1519            "app_type": "bun",
1520            "permissions": ["core.storage.kv"],
1521            "optional_permissions": ["core.notifications.create"],
1522            "provides": {
1523                "core.example.run": { "description": "Run example job" }
1524            }
1525        }"#;
1526        let m = parse_ok(json);
1527        assert_eq!(m.permissions, vec!["core.storage.kv"]);
1528        assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1529        assert!(m.provides.contains_key("core.example.run"));
1530    }
1531
1532    // ── v2 manifests ──────────────────────────────────────────────────────────
1533
1534    #[test]
1535    fn v2_minimal_native() {
1536        let json = r#"{
1537            "manifest_version": 2,
1538            "name": "cron",
1539            "version": "1.0.0",
1540            "app_type": "native",
1541            "abi": "v1",
1542            "entrypoint": "app.so",
1543            "hot_reload": "experimental"
1544        }"#;
1545        let m = parse_ok(json);
1546        assert_eq!(m.manifest_version, 2);
1547        assert_eq!(m.abi, Some(AbiVersion::V1));
1548        assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1549        assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1550    }
1551
1552    #[test]
1553    fn v2_minimal_bun_with_capabilities() {
1554        let json = r#"{
1555            "manifest_version": 2,
1556            "name": "example-fullstack",
1557            "version": "1.0.0",
1558            "app_type": "bun",
1559            "abi": "v1",
1560            "entrypoint": "dist/index.js",
1561            "hot_reload": "supported",
1562            "has_ui": true,
1563            "ui_path": "ui/dist",
1564            "capabilities": {
1565                "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1566                "provides": []
1567            },
1568            "governor": { "terminable": false, "min_idle_secs": 300 },
1569            "subscribes": ["core.chat.message.received"]
1570        }"#;
1571        let m = parse_ok(json);
1572        assert_eq!(m.manifest_version, 2);
1573        assert_eq!(m.capabilities.requires.len(), 2);
1574        // #1556: governor/subscribes present → parsed through verbatim.
1575        let governor = m.governor.expect("governor block should parse");
1576        assert_eq!(governor.terminable, Some(false));
1577        assert_eq!(governor.min_idle_secs, Some(300));
1578        assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1579    }
1580
1581    #[test]
1582    fn stage_contract_fixture_parses_with_normalized_requirements() {
1583        let json = include_str!(
1584            "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1585        );
1586        let m = parse_ok(json);
1587        assert!(m.has_ui);
1588        assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1589        let ui = m.ui.expect("fixture should declare ui");
1590        assert_eq!(ui.kind, AppUiKind::Stage);
1591        assert_eq!(ui.entry, "ui/main.js");
1592        assert_eq!(ui.nav.unwrap().order, 10);
1593    }
1594
1595    #[test]
1596    fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1597        let m = parse_ok(
1598            r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1599        );
1600        assert!(m.has_ui);
1601        assert_eq!(m.ui_path, "ui/dist");
1602        assert!(m.ui.is_none());
1603    }
1604
1605    #[test]
1606    fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1607        let manifest = parse_ok(
1608            r#"{
1609                "name":"ui-contract","version":"1.0.0","app_type":"bun",
1610                "requires":["core.cron.register"],
1611                "ui":{
1612                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1613                    "requires":{
1614                        "capabilities":["ui.snapshot.v1"],
1615                        "queries":["ui.query.v1"],
1616                        "streams":["ui.event.v1"]
1617                    },
1618                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1619                }
1620            }"#,
1621        );
1622
1623        assert_eq!(
1624            manifest.resolved_requires().unwrap(),
1625            vec!["core.cron.register"]
1626        );
1627        let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1628        assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1629        assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1630        assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1631        assert_eq!(
1632            ui.requires.resolved().unwrap(),
1633            vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1634        );
1635        let serialized = serde_json::to_value(ui).unwrap();
1636        assert_eq!(
1637            serialized["requires"]["queries"],
1638            serde_json::json!(["ui.query.v1"])
1639        );
1640    }
1641
1642    #[test]
1643    fn ui_requirements_reject_blank_entries() {
1644        let error = parse_err(
1645            r#"{
1646                "name":"ui-contract","version":"1.0.0","app_type":"bun",
1647                "ui":{
1648                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1649                    "requires":{"capabilities":[""],"queries":[],"streams":[]},
1650                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1651                }
1652            }"#,
1653        );
1654        assert!(error.contains("ui.requires entries must not be blank"));
1655    }
1656
1657    #[test]
1658    fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1659        // `is_safe_declaration_segment` deliberately admits `_` so `ui.requires`
1660        // can name real capabilities. `ui.data.namespace` is a different thing —
1661        // a storage key, contract `[a-z][a-z0-9-]*` — and keeps the stricter
1662        // `is_safe_name_segment`. Nothing else pins that separation, so a future
1663        // refactor collapsing the two predicates back together would silently
1664        // widen the namespace rule. This is the tripwire for that.
1665        assert!(validate_app_data_namespace("obs-viewer").is_ok());
1666
1667        let error = validate_app_data_namespace("obs_viewer")
1668            .expect_err("underscore must not be admitted into a storage namespace");
1669        assert!(
1670            error.contains("must match [a-z][a-z0-9-]*"),
1671            "unexpected error: {error}"
1672        );
1673    }
1674
1675    #[test]
1676    fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1677        let error = parse_err(
1678            r#"{
1679                "name":"ui-data","version":"1.0.0","app_type":"bun",
1680                "requires":["ui.snapshot.v1"],
1681                "ui":{
1682                    "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1683                    "requires":{"capabilities":[],"queries":[],"streams":[]},
1684                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1685                    "data":{
1686                        "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1687                        "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1688                        "streams":[]
1689                    }
1690                }
1691            }"#,
1692        );
1693        assert!(error.contains("must be declared in requires"));
1694    }
1695
1696    #[test]
1697    fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1698        let accepted = parse_ok(
1699            r#"{
1700                "name":"aliases","version":"1.0.0","app_type":"bun",
1701                "requires":["core.chat.read","core.chat.read","core.chat.send"],
1702                "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1703            }"#,
1704        );
1705        assert_eq!(
1706            accepted.resolved_requires().unwrap(),
1707            vec!["core.chat.read", "core.chat.send"]
1708        );
1709
1710        let err = parse_err(
1711            r#"{
1712                "name":"aliases","version":"1.0.0","app_type":"bun",
1713                "requires":["core.chat.read"],
1714                "capabilities":{"requires":["core.wallet.pay"]}
1715            }"#,
1716        );
1717        assert!(err.contains("conflicts"), "unexpected error: {err}");
1718    }
1719
1720    #[test]
1721    fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1722        let base = |ui: &str| {
1723            format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1724        };
1725        let widget = base(
1726            r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1727        );
1728        assert_eq!(
1729            parse_ok(&widget).ui.expect("widget ui").kind,
1730            AppUiKind::Widget
1731        );
1732        let widget_nav = base(
1733            r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1734        );
1735        assert!(parse_err(&widget_nav).contains("must omit nav"));
1736        let api = base(
1737            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1738        );
1739        assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1740        let unsupported_api = base(
1741            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1742        );
1743        assert!(parse_err(&unsupported_api).contains("ui_api"));
1744        let path = base(
1745            r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1746        );
1747        assert!(parse_err(&path).contains("entry"));
1748    }
1749
1750    #[test]
1751    fn stage_requires_integrity_for_entry_and_icon() {
1752        let missing_entry = r#"{
1753            "name":"stage","version":"1.0.0","app_type":"bun",
1754            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1755        }"#;
1756        assert!(parse_err(missing_entry).contains("entry"));
1757        let missing_icon = r#"{
1758            "name":"stage","version":"1.0.0","app_type":"bun",
1759            "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1760            "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1761        }"#;
1762        assert!(parse_err(missing_icon).contains("icon"));
1763        let uppercase = r#"{
1764            "name":"stage","version":"1.0.0","app_type":"bun",
1765            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1766            "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1767        }"#;
1768        assert!(parse_err(uppercase).contains("lowercase"));
1769    }
1770
1771    #[test]
1772    fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1773        let manifest = |composes: &str| {
1774            format!(
1775                r#"{{
1776                    "name":"stage","version":"1.0.0","app_type":"bun",
1777                    "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1778                    "composes":{composes},
1779                    "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1780                }}"#
1781            )
1782        };
1783        assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1784        assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1785        assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1786    }
1787
1788    // ── ui.surfaces[] ────────────────────────────────────────────────────────
1789
1790    /// A minimal valid manifest with a `ui` block, for tests that only care
1791    /// about `ui.surfaces`. Mirrors the fixture used by
1792    /// `stage_requires_integrity_for_entry_and_icon` above.
1793    ///
1794    /// The integrity map covers `surface()`'s entry as well as `ui.entry`, because a surface
1795    /// entry must be integrity-pinned exactly like the stage entry and the icon — see
1796    /// `surface_entry_missing_from_integrity_is_rejected`. Before that rule existed this fixture
1797    /// declared a surface no digest covered, which is precisely the manifest the client kernel
1798    /// refuses.
1799    fn manifest_with_ui() -> AppManifest {
1800        parse_ok(
1801            r#"{
1802                "name":"stage","version":"1.0.0","app_type":"bun",
1803                "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1804                "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1805                "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1806            }"#,
1807        )
1808    }
1809
1810    fn surface(id: &str, slot: &str) -> AppUiSurface {
1811        AppUiSurface {
1812            id: id.to_string(),
1813            slot: slot.to_string(),
1814            entry: "ui/dist/surfaces/chip.js".to_string(),
1815            title: "Chip".to_string(),
1816            order: 10,
1817            requires: AppUiRequirements {
1818                capabilities: vec!["wallet.balance.get".to_string()],
1819                ..Default::default()
1820            },
1821        }
1822    }
1823
1824    #[test]
1825    fn manifest_without_surfaces_still_parses() {
1826        let manifest = manifest_with_ui();
1827        assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1828        assert!(manifest.validate().is_ok());
1829    }
1830
1831    #[test]
1832    fn surface_in_a_known_slot_is_accepted() {
1833        let mut manifest = manifest_with_ui();
1834        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1835        assert!(manifest.validate().is_ok());
1836    }
1837
1838    #[test]
1839    fn surface_in_an_unknown_slot_is_rejected() {
1840        let mut manifest = manifest_with_ui();
1841        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1842        let error = manifest.validate().unwrap_err();
1843        assert!(error.contains("menu-bar"), "unexpected error: {error}");
1844    }
1845
1846    #[test]
1847    fn duplicate_surface_ids_are_rejected() {
1848        let mut manifest = manifest_with_ui();
1849        manifest.ui.as_mut().unwrap().surfaces = vec![
1850            surface("chip", "status-rail"),
1851            surface("chip", "status-rail"),
1852        ];
1853        let error = manifest.validate().unwrap_err();
1854        assert!(error.contains("duplicate"), "unexpected error: {error}");
1855    }
1856
1857    #[test]
1858    fn surface_with_a_blank_id_is_rejected() {
1859        let mut manifest = manifest_with_ui();
1860        manifest.ui.as_mut().unwrap().surfaces = vec![surface("  ", "status-rail")];
1861        assert!(manifest.validate().is_err());
1862    }
1863
1864    #[test]
1865    fn surface_with_an_unsafe_entry_path_is_rejected() {
1866        let mut manifest = manifest_with_ui();
1867        let mut bad = surface("chip", "status-rail");
1868        bad.entry = "../../etc/passwd".to_string();
1869        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1870        assert!(manifest.validate().is_err());
1871    }
1872
1873    #[test]
1874    fn surface_entry_missing_from_integrity_is_rejected() {
1875        // The client kernel requires a digest for every surface entry and fails the WHOLE
1876        // catalog snapshot when one is missing, so a manifest that packages without one bricks
1877        // every installing node's stage list. Catch it at package time instead.
1878        let mut manifest = manifest_with_ui();
1879        let mut unpinned = surface("chip", "status-rail");
1880        unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
1881        manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
1882        let error = manifest.validate().unwrap_err();
1883        assert!(
1884            error.contains("ui.integrity must include surface 'chip' entry"),
1885            "unexpected error: {error}"
1886        );
1887    }
1888
1889    #[test]
1890    fn surface_with_a_blank_required_capability_is_rejected() {
1891        let mut manifest = manifest_with_ui();
1892        let mut bad = surface("chip", "status-rail");
1893        bad.requires.capabilities = vec!["   ".to_string()];
1894        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1895        assert!(manifest.validate().is_err());
1896    }
1897
1898    #[test]
1899    fn v2_missing_abi_is_error() {
1900        let json = r#"{
1901            "manifest_version": 2,
1902            "name": "example",
1903            "version": "1.0.0",
1904            "app_type": "bun"
1905        }"#;
1906        let err = parse_err(json);
1907        assert!(err.contains("abi"), "expected abi error, got: {}", err);
1908    }
1909
1910    // ── Publisher-prefixed name (FR-019) ──────────────────────────────────────
1911
1912    #[test]
1913    fn publisher_prefixed_name_accepted() {
1914        let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1915        assert_eq!(m.name, "alice/weather");
1916    }
1917
1918    #[test]
1919    fn double_slash_name_rejected() {
1920        let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1921        assert!(!err.is_empty());
1922    }
1923
1924    // ── Malformed names ───────────────────────────────────────────────────────
1925
1926    #[test]
1927    fn name_starting_with_digit_rejected() {
1928        let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1929        assert!(!err.is_empty());
1930    }
1931
1932    #[test]
1933    fn name_with_uppercase_rejected() {
1934        let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1935        assert!(!err.is_empty());
1936    }
1937
1938    #[test]
1939    fn empty_name_rejected() {
1940        let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1941        assert!(!err.is_empty());
1942    }
1943
1944    // ── Path-safety (SEC-H3) ─────────────────────────────────────────────────
1945
1946    #[test]
1947    fn path_traversal_double_dot_rejected() {
1948        let json = r#"{
1949            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1950            "app_type": "bun", "abi": "v1",
1951            "entrypoint": "../etc/passwd"
1952        }"#;
1953        let err = parse_err(json);
1954        assert!(err.contains(".."), "expected traversal error, got: {}", err);
1955    }
1956
1957    #[test]
1958    fn path_traversal_encoded_dot_not_decoded() {
1959        // The regex rejects '%' so encoded traversal fails at char check
1960        let json = r#"{
1961            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1962            "app_type": "bun", "abi": "v1",
1963            "entrypoint": "foo/../bar"
1964        }"#;
1965        let err = parse_err(json);
1966        assert!(!err.is_empty(), "should have failed: {}", err);
1967    }
1968
1969    #[test]
1970    fn absolute_path_rejected() {
1971        let json = r#"{
1972            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1973            "app_type": "bun", "abi": "v1",
1974            "entrypoint": "/usr/bin/sh"
1975        }"#;
1976        let err = parse_err(json);
1977        assert!(
1978            err.contains("absolute"),
1979            "expected absolute error, got: {}",
1980            err
1981        );
1982    }
1983
1984    #[test]
1985    fn shell_metachar_in_path_rejected() {
1986        let json = r#"{
1987            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1988            "app_type": "bun", "abi": "v1",
1989            "entrypoint": "dist/index.js;rm -rf /"
1990        }"#;
1991        let err = parse_err(json);
1992        assert!(!err.is_empty());
1993    }
1994
1995    #[test]
1996    fn valid_nested_path_accepted() {
1997        let json = r#"{
1998            "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1999            "app_type": "bun", "abi": "v1",
2000            "entrypoint": "dist/index.js",
2001            "ui_path": "ui/dist"
2002        }"#;
2003        parse_ok(json);
2004    }
2005
2006    // ── Homepage scheme ───────────────────────────────────────────────────────
2007
2008    #[test]
2009    fn homepage_https_accepted() {
2010        let json = r#"{
2011            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2012            "homepage": "https://example.com"
2013        }"#;
2014        parse_ok(json);
2015    }
2016
2017    #[test]
2018    fn homepage_javascript_scheme_rejected() {
2019        let json = r#"{
2020            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2021            "homepage": "javascript:alert(1)"
2022        }"#;
2023        let err = parse_err(json);
2024        assert!(
2025            err.contains("scheme"),
2026            "expected scheme error, got: {}",
2027            err
2028        );
2029    }
2030
2031    #[test]
2032    fn homepage_file_scheme_rejected() {
2033        let json = r#"{
2034            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2035            "homepage": "file:///etc/passwd"
2036        }"#;
2037        let err = parse_err(json);
2038        assert!(!err.is_empty());
2039    }
2040
2041    // ── Effective defaults ────────────────────────────────────────────────────
2042
2043    #[test]
2044    fn effective_entrypoint_native_default() {
2045        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2046        assert_eq!(m.effective_entrypoint(), "app.so");
2047    }
2048
2049    #[test]
2050    fn effective_entrypoint_bun_default() {
2051        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2052        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2053    }
2054
2055    #[test]
2056    fn effective_hot_reload_native_default_is_experimental() {
2057        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2058        assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2059    }
2060
2061    #[test]
2062    fn effective_hot_reload_bun_default_is_supported() {
2063        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2064        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2065    }
2066
2067    #[test]
2068    fn hot_reload_unsupported_explicit() {
2069        let json = r#"{
2070            "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2071            "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2072        }"#;
2073        let m = parse_ok(json);
2074        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2075    }
2076
2077    // ── validate_manifest_path unit tests ─────────────────────────────────────
2078
2079    #[test]
2080    fn validate_path_simple_valid() {
2081        assert!(validate_manifest_path("dist/index.js").is_ok());
2082        assert!(validate_manifest_path("app.so").is_ok());
2083        assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2084        assert!(validate_manifest_path("build_output/main").is_ok());
2085    }
2086
2087    #[test]
2088    fn validate_path_empty_rejected() {
2089        assert!(validate_manifest_path("").is_err());
2090    }
2091
2092    #[test]
2093    fn validate_path_absolute_rejected() {
2094        assert!(validate_manifest_path("/usr/bin/sh").is_err());
2095    }
2096
2097    #[test]
2098    fn validate_path_double_dot_segment_rejected() {
2099        assert!(validate_manifest_path("foo/../bar").is_err());
2100        assert!(validate_manifest_path("../etc/passwd").is_err());
2101    }
2102
2103    #[test]
2104    fn validate_path_leading_dot_rejected() {
2105        assert!(validate_manifest_path(".hidden").is_err());
2106    }
2107
2108    #[test]
2109    fn validate_path_null_byte_rejected() {
2110        // null byte is non-ASCII, rejected by char check
2111        let path = "foo\0bar";
2112        assert!(validate_manifest_path(path).is_err());
2113    }
2114
2115    #[test]
2116    fn standalone_socket_path_development_override_is_explicit_and_pure() {
2117        let path = std::path::Path::new("/tmp/node-app/example.sock");
2118        assert!(validate_standalone_socket_path(path).is_err());
2119        assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2120        assert!(validate_standalone_socket_path_with_policy(
2121            std::path::Path::new("/tmp/node-app/../escape.sock"),
2122            true,
2123        )
2124        .is_err());
2125    }
2126
2127    // ── ManifestCapabilities defaults ─────────────────────────────────────────
2128
2129    #[test]
2130    fn manifest_capabilities_defaults_to_empty() {
2131        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2132        assert!(m.capabilities.requires.is_empty());
2133        assert!(m.capabilities.provides.is_empty());
2134    }
2135
2136    // ── resolved_capability_provides (T28 standalone-registration shaping) ────
2137
2138    #[test]
2139    fn resolved_capability_provides_v1_only() {
2140        let json = r#"{
2141            "name": "example", "version": "1.0.0", "app_type": "bun",
2142            "provides": { "core.example.run": { "description": "Run example job" } }
2143        }"#;
2144        let m = parse_ok(json);
2145        let out = m.resolved_capability_provides();
2146        assert_eq!(out.len(), 1);
2147        assert_eq!(
2148            out.get("core.example.run").unwrap().description,
2149            "Run example job"
2150        );
2151    }
2152
2153    #[test]
2154    fn resolved_capability_provides_v2_names_get_blank_declaration() {
2155        let json = r#"{
2156            "manifest_version": 2, "name": "example", "version": "1.0.0",
2157            "app_type": "bun", "abi": "v1",
2158            "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2159        }"#;
2160        let m = parse_ok(json);
2161        let out = m.resolved_capability_provides();
2162        assert_eq!(out.len(), 2);
2163        assert_eq!(out.get("core.example.run").unwrap().description, "");
2164        assert!(out.get("core.example.run").unwrap().schema.is_none());
2165        // Only the part before the first ':' is used as the name.
2166        assert!(out.contains_key("core.example.other"));
2167        assert!(!out.contains_key("core.example.other:extra"));
2168    }
2169
2170    #[test]
2171    fn resolved_capability_provides_v1_wins_on_conflict() {
2172        let json = r#"{
2173            "manifest_version": 2, "name": "example", "version": "1.0.0",
2174            "app_type": "bun", "abi": "v1",
2175            "provides": { "core.example.run": { "description": "v1 wins" } },
2176            "capabilities": { "requires": [], "provides": ["core.example.run"] }
2177        }"#;
2178        let m = parse_ok(json);
2179        let out = m.resolved_capability_provides();
2180        assert_eq!(out.len(), 1);
2181        assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2182    }
2183
2184    #[test]
2185    fn resolved_capability_provides_blank_v2_name_skipped() {
2186        let json = r#"{
2187            "manifest_version": 2, "name": "example", "version": "1.0.0",
2188            "app_type": "bun", "abi": "v1",
2189            "capabilities": { "requires": [], "provides": ["  ", "core.example.run"] }
2190        }"#;
2191        let m = parse_ok(json);
2192        let out = m.resolved_capability_provides();
2193        assert_eq!(out.len(), 1);
2194        assert!(out.contains_key("core.example.run"));
2195    }
2196
2197    #[test]
2198    fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2199        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2200        assert!(m.resolved_capability_provides().is_empty());
2201    }
2202
2203    // ── ABI version ───────────────────────────────────────────────────────────
2204
2205    #[test]
2206    fn abi_v1_is_supported() {
2207        assert!(AbiVersion::V1.is_supported());
2208    }
2209
2210    // ── AppTier display ───────────────────────────────────────────────────────
2211
2212    #[test]
2213    fn app_tier_display() {
2214        assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2215        assert_eq!(AppTier::Optional.to_string(), "optional");
2216        assert_eq!(AppTier::Development.to_string(), "development");
2217    }
2218
2219    #[test]
2220    fn app_tier_serde_roundtrip() {
2221        // Wire format must stay snake_case for the existing API contract.
2222        for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2223            let json = serde_json::to_string(&tier).unwrap();
2224            let back: AppTier = serde_json::from_str(&json).unwrap();
2225            assert_eq!(
2226                tier, back,
2227                "roundtrip failed for {:?}: serialized as {}",
2228                tier, json
2229            );
2230        }
2231        assert_eq!(
2232            serde_json::to_string(&AppTier::Development).unwrap(),
2233            "\"development\""
2234        );
2235    }
2236
2237    // ── AppType display ───────────────────────────────────────────────────────
2238
2239    #[test]
2240    fn app_type_display() {
2241        assert_eq!(AppType::Native.to_string(), "native");
2242        assert_eq!(AppType::Bun.to_string(), "bun");
2243        assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2244    }
2245
2246    #[test]
2247    fn platform_runtime_is_a_supported_packaging_type() {
2248        let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2249            "manifest_version": 2,
2250            "abi": "v1",
2251            "name": "bun-runtime",
2252            "version": "1.0.0",
2253            "app_type": "platform-runtime",
2254            "entrypoint": "bun"
2255        }))
2256        .expect("platform runtime manifest should parse");
2257
2258        assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2259        assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2260    }
2261
2262    // ── GovernorManifest memory budget ──────────────────────────────────────────
2263
2264    #[test]
2265    fn governor_manifest_memory_budget_defaults_to_none() {
2266        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2267        assert_eq!(parsed.memory_budget_kb, None);
2268    }
2269
2270    #[test]
2271    fn governor_manifest_parses_declared_memory_budget() {
2272        let parsed: GovernorManifest =
2273            serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2274        assert_eq!(parsed.memory_budget_kb, Some(40_960));
2275    }
2276
2277    // ── GovernorManifest latency_class (app lease engine §8, Task 1) ────────────
2278
2279    #[test]
2280    fn latency_class_parses_and_defaults_none() {
2281        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2282        assert_eq!(parsed.latency_class, None);
2283    }
2284
2285    #[test]
2286    fn latency_class_parses_declared_interactive() {
2287        let json = r#"{
2288            "name": "example",
2289            "version": "1.0.0",
2290            "app_type": "bun",
2291            "governor": {"latency_class": "interactive"}
2292        }"#;
2293        let m = parse_ok(json);
2294        let governor = m.governor.expect("governor block should parse");
2295        assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2296    }
2297
2298    #[test]
2299    fn latency_class_parses_declared_background() {
2300        let parsed: GovernorManifest =
2301            serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2302        assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2303    }
2304
2305    #[test]
2306    fn latency_class_invalid_value_is_parse_error() {
2307        let result: Result<GovernorManifest, _> =
2308            serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2309        assert!(result.is_err(), "unknown latency_class value must fail to parse");
2310    }
2311
2312    #[test]
2313    fn latency_class_as_str_and_from_str_roundtrip() {
2314        for class in [LatencyClass::Interactive, LatencyClass::Background] {
2315            let s = class.as_str();
2316            assert_eq!(LatencyClass::from_str(s), Ok(class));
2317        }
2318        assert!(LatencyClass::from_str("urgent").is_err());
2319    }
2320}
2321
2322#[cfg(test)]
2323mod shared_app_data_corpus_tests {
2324    use super::*;
2325
2326    /// The SAME corpus the kernel validator runs
2327    /// (`client/kernel/src/stages/stage-registry-contract.test.js`). Two independent
2328    /// implementations of one contract, with nothing but this comparing them —
2329    /// whichever side drifts fails here.
2330    #[test]
2331    fn shared_app_data_corpus_matches_the_host_validator() {
2332        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2333        let mut checked = 0;
2334        for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2335            let path = entry.expect("readable entry").path();
2336            if path.extension().and_then(|e| e.to_str()) != Some("json") {
2337                continue;
2338            }
2339            let fixture: serde_json::Value =
2340                serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2341                    .expect("valid fixture json");
2342            let name = path
2343                .file_name()
2344                .and_then(|n| n.to_str())
2345                .unwrap_or("?")
2346                .to_string();
2347            let manifest = serde_json::json!({
2348                "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2349                "app_type": "bun", "entrypoint": "dist/index.js",
2350                "ui": fixture["ui"],
2351            });
2352            let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2353            match fixture["expect"].as_str().expect("expect field") {
2354                "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2355                "reject" => {
2356                    let error = result.expect_err(&format!("{name} should be rejected"));
2357                    let reason = fixture["reason"]
2358                        .as_str()
2359                        .expect("reject fixtures need a reason");
2360                    assert!(
2361                        error.contains(reason),
2362                        "{name}: {error:?} should mention {reason:?}"
2363                    );
2364                }
2365                other => panic!("{name}: unknown expect {other:?}"),
2366            }
2367            checked += 1;
2368        }
2369        // Guards against a silently empty or mis-globbed corpus reporting success.
2370        assert!(checked >= 10, "expected the full corpus, walked {checked}");
2371    }
2372}
2373
2374#[cfg(test)]
2375mod burger_manifest_tests {
2376    use super::*;
2377
2378    #[test]
2379    fn burger_app_type_parses_with_bundle_entrypoint_default() {
2380        let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
2381            .expect("burger manifest parses");
2382        assert_eq!(m.app_type, AppType::Burger);
2383        assert_eq!(m.app_type.as_str(), "burger");
2384        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2385        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2386        assert!(m.resources.is_none());
2387    }
2388
2389    #[test]
2390    fn resources_memory_mb_is_carried() {
2391        let m = AppManifest::from_json(
2392            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
2393        )
2394        .expect("resources block parses");
2395        assert_eq!(
2396            m.resources,
2397            Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
2398        );
2399    }
2400
2401    #[test]
2402    fn resources_callback_deadline_ms_is_carried() {
2403        let m = AppManifest::from_json(
2404            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
2405        )
2406        .expect("callback deadline parses");
2407        assert_eq!(
2408            m.resources,
2409            Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
2410        );
2411        let max = AppManifest::from_json(
2412            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
2413        )
2414        .expect("the maximum is inclusive");
2415        assert_eq!(
2416            max.resources.and_then(|r| r.callback_deadline_ms),
2417            Some(MAX_CALLBACK_DEADLINE_MS)
2418        );
2419    }
2420
2421    #[test]
2422    fn resources_callback_deadline_ms_out_of_range_is_rejected() {
2423        for bad in ["0", "600001"] {
2424            let json = format!(
2425                r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
2426            );
2427            let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
2428            assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
2429        }
2430    }
2431
2432    #[test]
2433    fn resources_callback_deadline_ms_must_be_a_positive_integer() {
2434        for bad in ["-1", "1.5", "\"5000\""] {
2435            let json = format!(
2436                r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
2437            );
2438            assert!(
2439                AppManifest::from_json(&json).is_err(),
2440                "callback_deadline_ms={bad} must be rejected"
2441            );
2442        }
2443    }
2444
2445    #[test]
2446    fn resources_memory_mb_zero_is_rejected() {
2447        let err = AppManifest::from_json(
2448            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
2449        )
2450        .expect_err("a zero memory limit is invalid");
2451        assert!(err.contains("resources.memory_mb"), "{err}");
2452    }
2453
2454    #[test]
2455    fn burger_manifest_rejects_a_standalone_block() {
2456        let err = AppManifest::from_json(
2457            r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
2458        )
2459        .expect_err("standalone block is only valid for standalone apps");
2460        assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
2461    }
2462}