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    /// A UI-only app: a `ui` block (a stage, or a widget a stage composes) and
59    /// no backend at all (burger-07, Plans 07a/07b Contracts F5; widgets since
60    /// the `ui.widget-ui-only` host feature). Never loaded, spawned or
61    /// woken; node-server only lists and serves its UI bundle. Derived by
62    /// [`AppManifest::from_json`] for a manifest with a `ui` object and neither
63    /// `app_type` nor `entrypoint`. The string `"ui-only"` is also accepted,
64    /// so a serialised manifest round-trips.
65    #[serde(rename = "ui-only")]
66    UiOnly,
67}
68
69impl AppType {
70    pub fn as_str(self) -> &'static str {
71        match self {
72            AppType::Native => "native",
73            AppType::Bun => "bun",
74            AppType::Standalone => "standalone",
75            AppType::PlatformRuntime => "platform-runtime",
76            AppType::ManagedV1 => "managed-v1",
77            AppType::Burger => "burger",
78            AppType::UiOnly => "ui-only",
79        }
80    }
81}
82
83impl std::fmt::Display for AppType {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        write!(f, "{}", self.as_str())
86    }
87}
88
89/// Trust/distribution tier — derived at load time from the install path
90/// AND (per FR-028 cycle 4) the manifest sidecar's GPG signature.
91///
92/// This is **not** stored in the manifest; it is computed by `tier_validator`.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum AppTier {
96    /// App installed at the bundled path (`/usr/share/node/builtin-apps/`)
97    /// OR at the apt path with a manifest sidecar signed by a node project key.
98    /// May be `Native` or `Bun`. Highest trust.
99    FirstParty,
100    /// App installed at the optional apt path (`/usr/lib/node/apps/`) with
101    /// no/invalid project signature. MUST be `Bun`; `Native` at this tier
102    /// triggers `TierError` (FR-028).
103    Optional,
104    /// App loaded from a developer's local dev directory (`NODE_DEV_APPS_DIR`),
105    /// via `node-app-build dev` or manual sideload. Bypasses signature checks
106    /// because the dev directory is owned by the developer (security gate is
107    /// the file-system path: only the dev user can write to it). Permitted
108    /// for `Native` apps so cdylib developers can iterate without per-build
109    /// GPG signing.
110    ///
111    /// Daemon logs every Development-tier load at `info!` so operators of a
112    /// real node can see when a non-prod app is active. UI badges this tier
113    /// distinctly (amber/red, never green).
114    Development,
115}
116
117impl AppTier {
118    pub fn as_str(self) -> &'static str {
119        match self {
120            AppTier::FirstParty => "first_party",
121            AppTier::Optional => "optional",
122            AppTier::Development => "development",
123        }
124    }
125}
126
127impl std::fmt::Display for AppTier {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{}", self.as_str())
130    }
131}
132
133/// Host ABI compatibility version declared by the app.
134///
135/// The runtime's currently supported set is `[V1]`. Apps declaring an
136/// unsupported version are rejected with `AbiIncompatible` (FR-018).
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "lowercase")]
139pub enum AbiVersion {
140    V1,
141}
142
143impl AbiVersion {
144    pub fn as_str(&self) -> &'static str {
145        match self {
146            AbiVersion::V1 => "v1",
147        }
148    }
149
150    /// Returns true if this ABI version is supported by the current runtime.
151    pub fn is_supported(&self) -> bool {
152        matches!(self, AbiVersion::V1)
153    }
154}
155
156impl std::fmt::Display for AbiVersion {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        write!(f, "{}", self.as_str())
159    }
160}
161
162/// How in-process (native) app reload is expected to behave.
163///
164/// Per research.md §R10, native hot-reload is inherently unreliable due to
165/// `dlclose` semantics. The manifest field sets correct user expectations.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum HotReloadKind {
169    /// Reload is reliable (Bun subprocess restart). Default for `Bun` apps.
170    Supported,
171    /// Reload loads the library now on disk, but depends on the old instance
172    /// stopping. The daemon never unloads a native library (the old
173    /// instance's threads may still run in it), so a load after an upgrade
174    /// maps a private copy of the new file and runs that. When the old
175    /// instance's shutdown failed or timed out, the app cannot load again in
176    /// that process, and `app.reload` reports that the node must restart
177    /// (ADR-084). Default for `Native` apps (FR-024).
178    Experimental,
179    /// App must be restarted to pick up changes.
180    Unsupported,
181}
182
183impl HotReloadKind {
184    pub fn default_for(app_type: AppType) -> Self {
185        match app_type {
186            AppType::Native => HotReloadKind::Experimental,
187            AppType::Bun => HotReloadKind::Supported,
188            // Standalone apps are restarted by systemd, not the daemon —
189            // from the daemon's perspective they are never hot-reloaded.
190            AppType::Standalone => HotReloadKind::Unsupported,
191            AppType::PlatformRuntime => HotReloadKind::Unsupported,
192            AppType::ManagedV1 => HotReloadKind::Supported,
193            AppType::Burger => HotReloadKind::Supported,
194            // A stage bundle reloads with the page; there is no process to reload.
195            AppType::UiOnly => HotReloadKind::Unsupported,
196        }
197    }
198}
199
200// ── Sub-types ─────────────────────────────────────────────────────────────────
201
202/// Capability declarations from the v2 manifest `capabilities` block.
203///
204/// Semantic equivalent of the existing `permissions` + `provides` fields;
205/// v2 manifests may use either or both (backward compat preserved).
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct ManifestCapabilities {
208    /// Capabilities this app requests from the host or other apps.
209    /// Format: `"core.lightning.payment.send:max=1000sat/day"` (see §1.2).
210    #[serde(default)]
211    pub requires: Vec<String>,
212
213    /// Capabilities this app provides to other apps.
214    /// Format: `"core.cron.register"`.
215    #[serde(default)]
216    pub provides: Vec<String>,
217}
218
219/// A single scope provided by an app (existing v1 model, preserved verbatim).
220#[derive(Debug, Clone, Serialize, Deserialize, Default)]
221pub struct ProvidedScope {
222    pub scope: String,
223    pub description: String,
224    pub resource_pattern: String,
225}
226
227/// Declarative per-endpoint access policy (existing v1 model, preserved verbatim).
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct EndpointPolicy {
230    pub method: String,
231    pub path: String,
232    pub required_permissions: Vec<String>,
233}
234
235/// Capability provider declaration (existing v1 model, plus `discoverable`).
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ProvidedCapability {
238    #[serde(default)]
239    pub description: String,
240    #[serde(default)]
241    pub schema: Option<serde_json::Value>,
242    /// Whether `core.capabilities.list` / `search` report this capability.
243    /// Default `true`. `false` keeps it out of agent discovery (and so out of
244    /// agent tool generation); a caller that names it can still invoke it.
245    /// This is discovery hygiene, **not** access control.
246    #[serde(
247        default = "default_discoverable",
248        skip_serializing_if = "is_discoverable"
249    )]
250    pub discoverable: bool,
251}
252
253fn default_discoverable() -> bool {
254    true
255}
256
257fn is_discoverable(value: &bool) -> bool {
258    *value
259}
260
261/// Configuration for `AppType::Standalone` apps.
262///
263/// Carried only by manifests whose `app_type == "standalone"`. The daemon uses
264/// `socket_path` to route capability invocations as line-delimited JSON-RPC 2.0
265/// over the standalone daemon's own Unix domain socket.
266///
267/// Path-safety rules (validated by `AppManifest::validate`):
268/// - Absolute path.
269/// - Lives under `/run/`.
270/// - No `..` segments.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct StandaloneConfig {
273    pub socket_path: std::path::PathBuf,
274}
275
276/// Browser UI unit shipped by an app package.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum AppUiKind {
280    Stage,
281    Widget,
282}
283
284fn default_app_ui_kind() -> AppUiKind {
285    AppUiKind::Stage
286}
287
288fn default_nav_section() -> String {
289    "default".to_string()
290}
291
292/// Shell-owned navigation metadata for a top-level stage.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294pub struct AppUiNav {
295    #[serde(default = "default_nav_section")]
296    pub section: String,
297    #[serde(default)]
298    pub order: i32,
299}
300
301/// The chat-assistant surface slot: at most one app surface may claim it, and declaring it
302/// requires the host to support the assistant slot (`HostFeature::UiAssistantSlot`).
303pub const ASSISTANT_SLOT: &str = "assistant";
304
305/// Shell-chrome regions an app may contribute a surface to.
306///
307/// The shell owns this vocabulary; an app requests a region by name. Keep this
308/// list to slots that have a real occupant — a speculative slot is a contract
309/// nobody has had to honour yet.
310///
311/// Checked in TWO places on purpose. `node-app package` rejects an unknown slot
312/// so an author sees a typo while they can still fix it; the shell ALSO ignores
313/// surfaces whose slot it does not recognise, because an app packaged against a
314/// newer SDK can be installed on an older shell, and that shell must degrade by
315/// dropping the surface rather than failing the app.
316pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail", ASSISTANT_SLOT];
317
318/// A UI unit an app contributes to a named region of the shell's own chrome.
319///
320/// Not a route: it has no nav entry, and it is mounted by the shell rather than
321/// by any stage. `requires` is the surface's OWN authorization scope — the
322/// primary containment control, since a surface otherwise receives the same
323/// `StageContext` a stage receives. A wallet chip declares `wallet.balance.get`
324/// and is refused `wallet.payment.send` even though the app provides it.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct AppUiSurface {
327    pub id: String,
328    pub slot: String,
329    pub entry: String,
330    pub title: String,
331    #[serde(default)]
332    pub order: i32,
333    #[serde(default)]
334    pub requires: AppUiRequirements,
335}
336
337/// How the client shell may behave when the home node is unavailable.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "kebab-case")]
340pub enum AppDataOfflinePolicy {
341    /// A stage may render the last verified cached projection with stale/offline labeling.
342    LastKnown,
343    /// A stage must fail clearly when the home node is unavailable.
344    OnlineOnly,
345}
346
347/// Generic query declaration shape for app-owned cached projections.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(rename_all = "kebab-case")]
350pub enum AppDataQueryKind {
351    Collection,
352    Detail,
353    Snapshot,
354}
355
356/// Generic stream declaration shape for app-owned invalidation/cursor feeds.
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "kebab-case")]
359pub enum AppDataStreamKind {
360    Changes,
361    Events,
362}
363
364/// How the client shell refreshes app-owned data.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "kebab-case")]
367pub enum AppDataSyncKind {
368    Cursor,
369    Snapshot,
370}
371
372/// Bounded synchronization policy for generic app data.
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct AppDataSyncPolicy {
375    pub kind: AppDataSyncKind,
376    pub cursor_ttl_secs: Option<u32>,
377    pub full_refresh_interval_secs: Option<u32>,
378    pub retention_secs: Option<u32>,
379}
380
381impl Serialize for AppDataSyncPolicy {
382    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
383    where
384        S: serde::Serializer,
385    {
386        use serde::ser::SerializeStruct;
387
388        if self.cursor_ttl_secs.is_none()
389            && self.full_refresh_interval_secs.is_none()
390            && self.retention_secs.is_none()
391        {
392            return self.kind.serialize(serializer);
393        }
394
395        let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
396        state.serialize_field("kind", &self.kind)?;
397        if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
398            state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
399        }
400        if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
401            state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
402        }
403        if let Some(retention_secs) = self.retention_secs {
404            state.serialize_field("retention_secs", &retention_secs)?;
405        }
406        state.end()
407    }
408}
409
410impl<'de> Deserialize<'de> for AppDataSyncPolicy {
411    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
412    where
413        D: serde::Deserializer<'de>,
414    {
415        #[derive(Deserialize)]
416        #[serde(deny_unknown_fields)]
417        struct ObjectPolicy {
418            kind: AppDataSyncKind,
419            #[serde(default)]
420            cursor_ttl_secs: Option<u32>,
421            #[serde(default)]
422            full_refresh_interval_secs: Option<u32>,
423            #[serde(default)]
424            retention_secs: Option<u32>,
425        }
426
427        #[derive(Deserialize)]
428        #[serde(untagged)]
429        enum WirePolicy {
430            Kind(AppDataSyncKind),
431            Object(ObjectPolicy),
432        }
433
434        match WirePolicy::deserialize(deserializer)? {
435            WirePolicy::Kind(kind) => Ok(Self {
436                kind,
437                cursor_ttl_secs: None,
438                full_refresh_interval_secs: None,
439                retention_secs: None,
440            }),
441            WirePolicy::Object(policy) => Ok(Self {
442                kind: policy.kind,
443                cursor_ttl_secs: policy.cursor_ttl_secs,
444                full_refresh_interval_secs: policy.full_refresh_interval_secs,
445                retention_secs: policy.retention_secs,
446            }),
447        }
448    }
449}
450
451/// A namespaced app-owned query exposed through the generic stage data plane.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct AppDataQueryDeclaration {
455    pub name: String,
456    pub capability: String,
457    pub kind: AppDataQueryKind,
458}
459
460/// A namespaced app-owned stream exposed through the generic stage data plane.
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(deny_unknown_fields)]
463pub struct AppDataStreamDeclaration {
464    pub name: String,
465    pub kind: AppDataStreamKind,
466}
467
468/// Generic, app-owned data contract declared by a stage manifest.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470#[serde(deny_unknown_fields)]
471pub struct AppDataManifest {
472    pub namespace: String,
473    pub offline: AppDataOfflinePolicy,
474    pub sync: AppDataSyncPolicy,
475    #[serde(default)]
476    pub queries: Vec<AppDataQueryDeclaration>,
477    #[serde(default)]
478    pub streams: Vec<AppDataStreamDeclaration>,
479}
480
481/// Capability, query, and stream contracts exposed to an app-delivered UI
482/// stage. This is intentionally separate from the app's backend dependency
483/// declaration (`requires` / `capabilities.requires`): backend providers may
484/// need capabilities that must never be delegated to browser UI code.
485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
486#[serde(deny_unknown_fields)]
487pub struct AppUiRequirements {
488    #[serde(default)]
489    pub capabilities: Vec<String>,
490    #[serde(default)]
491    pub queries: Vec<String>,
492    #[serde(default)]
493    pub streams: Vec<String>,
494    /// Kernel commands this UI may send through `ctx.command`. Authorized by the
495    /// client kernel only, so deliberately NOT part of `resolved()` (the host-side
496    /// device-scope list). Omitted from the wire when empty so kernels that
497    /// predate the field keep accepting every manifest that does not use it.
498    #[serde(default, skip_serializing_if = "Vec::is_empty")]
499    pub commands: Vec<String>,
500}
501
502impl AppUiRequirements {
503    /// Validates `commands` separately from `resolved()`; see the field doc.
504    pub fn validate_commands(&self) -> Result<(), String> {
505        let mut seen = HashSet::new();
506        for command in &self.commands {
507            let name = command.trim();
508            let versioned = name.rsplit_once(".v").is_some_and(|(head, version)| {
509                !head.is_empty()
510                    && head.chars().all(|c| {
511                        c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-'
512                    })
513                    && version.chars().next().is_some_and(|c| ('1'..='9').contains(&c))
514                    && version.chars().all(|c| c.is_ascii_digit())
515            });
516            if !versioned || name != command {
517                return Err(format!("ui.requires.commands entry '{command}' must be a versioned name like 'devtools.dom.snapshot.v1'"));
518            }
519            if !seen.insert(name) {
520                return Err(format!("ui.requires.commands contains duplicate '{name}'"));
521            }
522        }
523        Ok(())
524    }
525
526    /// Return the UI's host-side device-scope declarations in stable, de-duplicated
527    /// order. Query and stream names are included because they are separately
528    /// authorized stage declarations at the client RPC boundary.
529    pub fn resolved(&self) -> Result<Vec<String>, String> {
530        let mut resolved = Vec::new();
531        let mut seen = HashSet::new();
532        for (values, allow_wildcard) in [
533            (&self.capabilities, true),
534            (&self.queries, false),
535            (&self.streams, false),
536        ] {
537            for value in values {
538                let requirement = value.trim();
539                if requirement.is_empty() {
540                    return Err("ui.requires entries must not be blank".to_string());
541                }
542                validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
543                    format!("ui.requires entry '{requirement}' invalid: {error}")
544                })?;
545                if seen.insert(requirement.to_string()) {
546                    resolved.push(requirement.to_string());
547                }
548            }
549        }
550        Ok(resolved)
551    }
552}
553
554/// The shell viewport a stage requests. Omission preserves the column layout.
555#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(rename_all = "lowercase")]
557pub enum UiLayout {
558    Full,
559    #[default]
560    Column,
561}
562
563/// Optional stage metadata carried by the canonical app manifest.
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
565pub struct AppUiManifest {
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub layout: Option<UiLayout>,
568    #[serde(default = "default_app_ui_kind")]
569    pub kind: AppUiKind,
570    pub entry: String,
571    pub title: String,
572    #[serde(default)]
573    pub icon: Option<String>,
574    #[serde(default)]
575    pub nav: Option<AppUiNav>,
576    #[serde(default)]
577    pub composes: Vec<String>,
578    /// Shell-chrome contributions. Empty for the overwhelming majority of apps.
579    #[serde(default)]
580    pub surfaces: Vec<AppUiSurface>,
581    pub ui_api: u8,
582    #[serde(default)]
583    pub integrity: BTreeMap<String, String>,
584    /// Stage-specific description that overrides the app-level
585    /// `AppManifest::description` when the stage's UI purpose differs from the
586    /// app's. Optional; when absent the app-level description is used.
587    #[serde(default)]
588    pub description: Option<String>,
589    /// Author-supplied synonyms for this stage (search/intent phrasings).
590    /// Optional; defaults to empty.
591    #[serde(default)]
592    pub keywords: Vec<String>,
593    /// The browser stage contract. Do not populate this from the app's
594    /// backend `requires` declaration.
595    #[serde(default)]
596    pub requires: AppUiRequirements,
597    #[serde(default, skip_serializing_if = "Option::is_none")]
598    pub data: Option<AppDataManifest>,
599}
600
601// ── Path-safety helpers ───────────────────────────────────────────────────────
602
603/// Validate a relative file path declared in a manifest (`entrypoint`, `ui_path`).
604///
605/// Rules (SEC-H3):
606/// 1. Matches `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$` — rejects shell metacharacters,
607///    leading `.`, leading `/`, etc.
608/// 2. No `..` segment anywhere.
609/// 3. Does not begin with `/` (absolute paths).
610///
611/// Returns `Ok(())` if valid, `Err(reason)` describing the violation.
612pub fn validate_manifest_path(path: &str) -> Result<(), String> {
613    if path.is_empty() {
614        return Err("path must not be empty".to_string());
615    }
616
617    // Rule 3: no absolute paths
618    if path.starts_with('/') {
619        return Err(format!(
620            "path '{}' must not be absolute (starts with /)",
621            path
622        ));
623    }
624
625    // Rule 1: allowed character set
626    // ^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$
627    let first = path.chars().next().unwrap();
628    if !first.is_ascii_alphanumeric() && first != '_' {
629        return Err(format!(
630            "path '{}' must begin with an alphanumeric character or underscore",
631            path
632        ));
633    }
634    for ch in path.chars().skip(1) {
635        if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
636            return Err(format!(
637                "path '{}' contains disallowed character '{}'",
638                path, ch
639            ));
640        }
641    }
642
643    // Rule 2: no `..` segment
644    for segment in path.split('/') {
645        if segment == ".." {
646            return Err(format!(
647                "path '{}' contains a '..' segment (path traversal rejected)",
648                path
649            ));
650        }
651    }
652
653    Ok(())
654}
655
656// ── AppManifest ───────────────────────────────────────────────────────────────
657
658/// Canonical manifest entity — unified v1/v2 format.
659///
660/// Deserializes both old (v1, no `manifest_version`) and new (v2) manifests.
661/// All v2-only fields use `#[serde(default)]` so that v1 manifests parse
662/// correctly without any field changes.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct AppManifest {
665    /// Schema version. Absent or 1 = legacy v1; 2 = extended v2.
666    #[serde(default = "default_manifest_version", rename = "manifest_version")]
667    pub manifest_version: u8,
668
669    pub name: String,
670    pub version: String,
671
672    #[serde(default = "default_app_type_native")]
673    pub app_type: AppType,
674
675    #[serde(default)]
676    pub description: String,
677
678    // ── v2-only additions (all optional, v1-compatible defaults) ─────────────
679    /// Host ABI compatibility version. Required when `manifest_version == 2`.
680    pub abi: Option<AbiVersion>,
681
682    /// Payload entry point relative to the app directory.
683    /// Default: `app.so` for Native, `dist/index.js` for Bun.
684    pub entrypoint: Option<String>,
685
686    /// Hot-reload behaviour classification.
687    /// Default: `experimental` for Native, `supported` for Bun.
688    pub hot_reload: Option<HotReloadKind>,
689
690    // ── Existing v1 fields (preserved verbatim — DO NOT RENAME) ──────────────
691    #[serde(default)]
692    pub critical: bool,
693
694    #[serde(
695        default = "default_auto_start",
696        deserialize_with = "deserialize_auto_start"
697    )]
698    pub auto_start: bool,
699
700    #[serde(default)]
701    pub has_ui: bool,
702
703    #[serde(default = "default_ui_path")]
704    pub ui_path: String,
705
706    #[serde(default)]
707    pub permissions: Vec<String>,
708
709    /// Capability requirements in the v2 top-level vocabulary. This is an
710    /// alias for `capabilities.requires`, not a second permission system.
711    #[serde(default)]
712    pub requires: Vec<String>,
713
714    #[serde(default)]
715    pub optional_permissions: Vec<String>,
716
717    #[serde(default)]
718    pub provides_scopes: Vec<ProvidedScope>,
719
720    #[serde(default)]
721    pub endpoint_policies: Vec<EndpointPolicy>,
722
723    #[serde(default)]
724    pub capability_scopes: HashMap<String, String>,
725
726    #[serde(default)]
727    pub provides: HashMap<String, ProvidedCapability>,
728
729    // ── v2 capabilities block (semantic alias for permissions + provides) ─────
730    #[serde(default)]
731    pub capabilities: ManifestCapabilities,
732
733    /// App-delivered browser UI metadata. Legacy `has_ui`/`ui_path` remains
734    /// readable but does not synthesize this block.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub ui: Option<AppUiManifest>,
737
738    // ── Optional metadata fields ──────────────────────────────────────────────
739    #[serde(default)]
740    pub author: Option<String>,
741
742    #[serde(default)]
743    pub homepage: Option<String>,
744
745    #[serde(default)]
746    pub depends_on: Option<Vec<String>>,
747
748    #[serde(default)]
749    pub boot_priority: Option<u32>,
750
751    /// App-governor idle-termination policy (issue #811 SP1). Absent means
752    /// the app is subject to the default eligibility rules with no explicit
753    /// opt-out and no minimum-idle override.
754    #[serde(default)]
755    pub governor: Option<GovernorManifest>,
756
757    /// Event-bus topics this app listens for while lazily started. Only
758    /// meaningful for apps holding the `EVENT_LISTENER` capability — a
759    /// listener with no declared `subscribes` topics is exempt from idle
760    /// termination because the governor cannot know what would need to wake
761    /// it back up (see `node-app-host::governor_eligibility`).
762    #[serde(default)]
763    pub subscribes: Vec<String>,
764
765    /// Required when `app_type == "standalone"` and the manifest declares any
766    /// `provides` / `capabilities.provides` entries. Carries the Unix domain
767    /// socket path the daemon dispatches capability calls to.
768    #[serde(default)]
769    pub standalone: Option<StandaloneConfig>,
770
771    /// Optional TCP-binding block — feature 470 (port registry).
772    /// Absence means the app does not bind a TCP port the registry manages.
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub tcp: Option<TcpManifest>,
775
776    /// Runtime resource requests (Burger Plan 02, Contracts C2/C7). Absence means
777    /// runtime defaults (Burger: 32 MB QuickJS memory limit, 5000 ms callback deadline).
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub resources: Option<ResourcesManifest>,
780
781    /// Recurring jobs this app needs on the node, declared so the host can put
782    /// the cron rows there without the app ever having run (econ-v1/node#3185).
783    ///
784    /// Empty — the default — is exactly the behaviour that shipped before: the
785    /// app owns its own registration and nothing happens until it starts. See
786    /// [`AppScheduleManifest`] for why declaring one does not pin the isolate.
787    #[serde(default, skip_serializing_if = "Vec::is_empty")]
788    pub schedules: Vec<AppScheduleManifest>,
789
790    /// Host contract features this manifest needs, stamped by
791    /// `node-app contract stamp`. Absent in manifests built before stamping
792    /// existed; see `check_host_contract`.
793    #[serde(default, skip_serializing_if = "Option::is_none")]
794    pub host_contract: Option<crate::HostContractStamp>,
795}
796
797/// Upper bound for `resources.callback_deadline_ms` (10 minutes).
798pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
799
800/// Runtime resource requests (Contract C2). `memory_mb` becomes the Burger
801/// host's per-app QuickJS memory limit (`load_app.memory_limit_mb`);
802/// `callback_deadline_ms` becomes its per-callback CPU watchdog
803/// (`load_app.callback_deadline_ms`). Both are omitted from `load_app` when
804/// absent so the Burger host applies its own defaults (32 MB, 5000 ms).
805#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
806pub struct ResourcesManifest {
807    #[serde(default, skip_serializing_if = "Option::is_none")]
808    pub memory_mb: Option<u32>,
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub callback_deadline_ms: Option<u32>,
811}
812
813/// Declared responsiveness expectation for an app's lease engine decisions
814/// (app lease engine design §8, Task 1). `None` on [`GovernorManifest`] means
815/// the app has not declared a preference — the lease engine (Task 5) then
816/// falls back to its own default rather than treating an unset field as
817/// either variant.
818#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(rename_all = "lowercase")]
820pub enum LatencyClass {
821    /// The app serves latency-sensitive, user-facing requests — the lease
822    /// engine should prefer to keep it warm.
823    Interactive,
824    /// The app only does deferred/background work — the lease engine may
825    /// treat it as a lower priority to keep resident.
826    Background,
827}
828
829impl LatencyClass {
830    pub fn as_str(&self) -> &'static str {
831        match self {
832            LatencyClass::Interactive => "interactive",
833            LatencyClass::Background => "background",
834        }
835    }
836
837    #[allow(clippy::should_implement_trait)]
838    pub fn from_str(s: &str) -> Result<Self, String> {
839        match s {
840            "interactive" => Ok(LatencyClass::Interactive),
841            "background" => Ok(LatencyClass::Background),
842            _ => Err(format!("Invalid LatencyClass: {}", s)),
843        }
844    }
845}
846
847impl std::fmt::Display for LatencyClass {
848    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
849        write!(f, "{}", self.as_str())
850    }
851}
852
853/// Idle-termination policy for a lazily-started app (issue #811 SP1 — the
854/// app governor). Nested under `AppManifest::governor`.
855#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
856pub struct GovernorManifest {
857    /// Explicit opt-out. `Some(false)` exempts the app from idle termination
858    /// regardless of any other eligibility rule. `None`/`Some(true)` defers
859    /// to the other eligibility rules.
860    #[serde(default)]
861    pub terminable: Option<bool>,
862
863    /// Minimum idle duration, in seconds, before the governor may terminate
864    /// this app — overrides the governor's default sweep threshold. `None`
865    /// defers to the default.
866    #[serde(default)]
867    pub min_idle_secs: Option<u64>,
868
869    /// Memory budget in KB. When the app's measured footprint — on the basis
870    /// selected by its measurement attribution, see
871    /// `node_app_host::app_memory::budget` — exceeds this, the owner is warned
872    /// in the shell.
873    ///
874    /// # What `None` defers to
875    ///
876    /// NOT one number. The default is chosen PER BASIS
877    /// (`node_app_host::app_memory::budget::default_budget_kb`), because the
878    /// bases are not comparable quantities:
879    ///
880    /// | basis                | default   | why                                     |
881    /// |----------------------|-----------|-----------------------------------------|
882    /// | `heap_used`, `pss`   | 10,240 KB | the app and nothing else                |
883    /// | `rss`                | 61,440 KB | the whole OS process, runtime included  |
884    /// | `not_attributable`   | 10,240 KB | never `over`; carried only for the wire |
885    ///
886    /// A shared-runtime Bun worker is compared on `heap_used`; a dedicated
887    /// process or cgroup-scoped standalone on `rss`, which charges it for a
888    /// JavaScript engine it did not choose and cannot shed.
889    ///
890    /// On top of that, a host-side runtime-critical entry
891    /// (`RUNTIME_CRITICAL_BUDGETS`) acts as a FLOOR, never a ceiling: it can
892    /// only raise an app above the per-basis default, never pull it below one.
893    ///
894    /// A value declared HERE is the one thing that overrides both, in either
895    /// direction — it is a deliberate choice by the app author, not a fallback,
896    /// so it is honoured unchanged even when it is lower than the default.
897    ///
898    /// Apps that legitimately need more than their basis default MUST declare a
899    /// realistic budget here; otherwise the warning is permanently lit and
900    /// stops meaning anything.
901    #[serde(default)]
902    pub memory_budget_kb: Option<u64>,
903
904    /// Declared responsiveness expectation (app lease engine design §8,
905    /// Task 1). `None` when the app declares no preference — see
906    /// [`LatencyClass`] for what each variant means and what `None` defers
907    /// to.
908    #[serde(default)]
909    pub latency_class: Option<LatencyClass>,
910}
911
912/// TCP port preferences for standalone apps that bind their own port.
913/// Consumed by the port registry (`system/server/src/services/port_registry/`)
914/// at install time.
915#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
916pub struct TcpManifest {
917    /// The TCP port the app would like to bind. Honored when free;
918    /// otherwise the registry assigns the next free port from the pool
919    /// (default 7000–7099). Absent → registry picks any free pool slot.
920    #[serde(default, skip_serializing_if = "Option::is_none")]
921    pub preferred_port: Option<u16>,
922
923    /// When `true`, the platform UI shell builds iframe URLs as direct LAN
924    /// connections to the assigned port rather than routing via the
925    /// `/api/v2/node-apps/{name}/ui/` reverse-proxy. Intended only for apps
926    /// that must outlive a platform restart (e.g. OTA self-upgrade). Remote
927    /// users may see a degraded experience — owned by the consuming app's UI,
928    /// not this spec (see `specs/470-port-registry/spec.md` Clarifications Q5b).
929    #[serde(default, skip_serializing_if = "Option::is_none")]
930    pub direct_bind: Option<bool>,
931}
932
933/// One recurring job an app declares in its own manifest, so the host can put
934/// the cron row on the node without the app ever having run (econ-v1/node#3185).
935///
936/// Before this existed, an app that records on a schedule had to register its
937/// own row when it started — which a `lazy` app only does once something first
938/// invokes it. On a node whose owner never opens that app, the row was never
939/// created, nothing was ever recorded, and nothing said so.
940///
941/// Declaring the schedule here does NOT make the app resident. The host
942/// registers a CAPABILITY-triggered row pointing at [`Self::capability`]; when
943/// it fires, the capability router resolves the provider from the registry
944/// (seeded at boot for unloaded apps) and cold-starts the app for the duration
945/// of the call. Between firings the isolate can be reclaimed exactly as before.
946/// `auto_start: "auto"` would also produce the row, and is NOT the answer: it
947/// pins the isolate permanently, which is the cost a sparse cadence exists to
948/// avoid.
949#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950pub struct AppScheduleManifest {
951    /// Stable identifier, unique within this app. Becomes the cron row's
952    /// `external_id` (paired with the app name as `external_type`), which is
953    /// what lets the host find its own row again without storing anything.
954    ///
955    /// Changing it retires the old row and creates a new one — it is an
956    /// identity, not a label.
957    pub id: String,
958
959    /// 6-field cron expression: `sec min hour day month weekday`.
960    ///
961    /// Checked here only for shape (six non-empty fields over the permitted
962    /// character set). The authoritative parse lives in the host, which refuses
963    /// the whole manifest on a bad expression — this crate is the schema
964    /// contract that app authors compile against, and is deliberately kept to
965    /// `serde` alone rather than pulling a cron parser and its date-time
966    /// dependencies into every app build.
967    pub cron: String,
968
969    /// The capability the row dispatches.
970    ///
971    /// MUST be one this same app declares in `provides` / `capabilities.provides`,
972    /// and the manifest is refused otherwise. Without that restriction any
973    /// manifest could schedule repeated dispatches at any capability on the node
974    /// — `core.lightning.send_payment`, say — and a manifest is not a surface
975    /// the owner reviews.
976    pub capability: String,
977
978    /// JSON object handed to the capability on each firing. Absent means `{}`.
979    /// A non-object payload is refused: every capability on the dispatch path
980    /// takes an object, and the router stamps `caller` into it.
981    #[serde(default, skip_serializing_if = "Option::is_none")]
982    pub payload: Option<serde_json::Value>,
983}
984
985impl AppScheduleManifest {
986    /// The payload to dispatch with, defaulting to an empty object.
987    pub fn effective_payload(&self) -> serde_json::Value {
988        self.payload
989            .clone()
990            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
991    }
992}
993
994/// Characters permitted in a cron field. Covers the standard vocabulary
995/// (`* , - /`), named months/weekdays, and the `?` / `L` / `W` / `#` forms
996/// extended syntaxes use — the host's real parser decides what it accepts, so
997/// this only rejects input that could not be a cron field at all.
998fn cron_field_char_allowed(ch: char) -> bool {
999    ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
1000}
1001
1002/// Shape check for a 6-field cron expression. See [`AppScheduleManifest::cron`]
1003/// for why the authoritative parse is the host's and not this crate's.
1004fn validate_cron_shape(expression: &str) -> Result<(), String> {
1005    let fields: Vec<&str> = expression.split_whitespace().collect();
1006    if fields.len() != 6 {
1007        return Err(format!(
1008            "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
1009            expression,
1010            fields.len()
1011        ));
1012    }
1013    for field in fields {
1014        if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
1015            return Err(format!(
1016                "cron '{}' contains disallowed character '{}'",
1017                expression, ch
1018            ));
1019        }
1020    }
1021    Ok(())
1022}
1023
1024/// A schedule id must be a stable, filesystem- and URL-safe token: it travels
1025/// as the cron row's `external_id` and is matched verbatim on every reconcile.
1026fn validate_schedule_id(id: &str) -> Result<(), String> {
1027    if id.is_empty() {
1028        return Err("schedule id must not be empty".to_string());
1029    }
1030    let first = id.chars().next().unwrap();
1031    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
1032        return Err(format!(
1033            "schedule id '{}' must begin with a lowercase letter or digit",
1034            id
1035        ));
1036    }
1037    for ch in id.chars() {
1038        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
1039            return Err(format!(
1040                "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
1041                id, ch
1042            ));
1043        }
1044    }
1045    Ok(())
1046}
1047
1048fn default_manifest_version() -> u8 {
1049    1
1050}
1051
1052fn default_auto_start() -> bool {
1053    true
1054}
1055
1056/// Deserialize `auto_start` from either a bool (manifest v1) or a load-mode
1057/// string (v2, e.g. `"lazy"`/`"eager"`/`"active"`). Eager-start modes map to
1058/// `true`; `"lazy"` and other on-demand/inactive states map to `false` (the app
1059/// is started on first capability use, not at boot). This keeps both manifest
1060/// schema generations parseable by `AppManifest::from_json`.
1061fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
1062where
1063    D: serde::Deserializer<'de>,
1064{
1065    #[derive(Deserialize)]
1066    #[serde(untagged)]
1067    enum BoolOrStr {
1068        Bool(bool),
1069        Str(String),
1070    }
1071    Ok(match BoolOrStr::deserialize(deserializer)? {
1072        BoolOrStr::Bool(b) => b,
1073        BoolOrStr::Str(s) => matches!(
1074            s.trim().to_ascii_lowercase().as_str(),
1075            "true" | "eager" | "active" | "auto" | "on" | "1"
1076        ),
1077    })
1078}
1079
1080fn default_ui_path() -> String {
1081    "dist".to_string()
1082}
1083
1084fn default_app_type_native() -> AppType {
1085    AppType::Native
1086}
1087
1088impl AppManifest {
1089    /// Resolve top-level `requires` and `capabilities.requires` into one
1090    /// canonical declaration list. Equal aliases are accepted regardless of
1091    /// order or duplicates; differing aliases are rejected.
1092    pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1093        let top = normalized_requirements(&self.requires)?;
1094        let nested = normalized_requirements(&self.capabilities.requires)?;
1095        if !top.is_empty()
1096            && !nested.is_empty()
1097            && top.iter().cloned().collect::<BTreeSet<_>>()
1098                != nested.iter().cloned().collect::<BTreeSet<_>>()
1099        {
1100            return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1101        }
1102        Ok(if !top.is_empty() { top } else { nested })
1103    }
1104
1105    /// Returns the effective `HotReloadKind` — explicit field or the default
1106    /// for the app type.
1107    pub fn effective_hot_reload(&self) -> HotReloadKind {
1108        self.hot_reload
1109            .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1110    }
1111
1112    /// Returns the effective entrypoint — explicit field or the type-specific default.
1113    ///
1114    /// Standalone and UI-only apps have no daemon-managed entrypoint (systemd
1115    /// owns a standalone's lifecycle; a UI-only stage has no process); the
1116    /// empty string signals "not applicable".
1117    pub fn effective_entrypoint(&self) -> &str {
1118        if let Some(ref ep) = self.entrypoint {
1119            ep.as_str()
1120        } else {
1121            match self.app_type {
1122                AppType::Native => "app.so",
1123                AppType::Bun => "dist/index.js",
1124                AppType::Standalone => "",
1125                AppType::PlatformRuntime => "bun",
1126                AppType::ManagedV1 => "llmc-generated-app",
1127                AppType::Burger => "dist/index.js",
1128                AppType::UiOnly => "",
1129            }
1130        }
1131    }
1132
1133    /// True iff this manifest declares at least one capability provider
1134    /// (via either the v1 `provides` map or the v2 `capabilities.provides` list).
1135    pub fn has_capability_providers(&self) -> bool {
1136        !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1137    }
1138
1139    /// Merges the v1 `provides` map and the v2 `capabilities.provides` name
1140    /// list into a single capability→declaration map (composition-root
1141    /// cleanup Round 4 T28 — extracted from
1142    /// `control_ipc::handlers::handle_app_register_standalone`, which uses
1143    /// this to shape a standalone app's declared providers for capability
1144    /// registration).
1145    ///
1146    /// - v1 entries (the `provides` map) carry their real
1147    ///   description/schema and always win on a name conflict.
1148    /// - v2-only names (declared only via `capabilities.provides`, format
1149    ///   `"name"` or `"name:extra"` — only the part before the first `:` is
1150    ///   used) get a blank declaration, inserted only if the name is not
1151    ///   already present from v1. Blank/whitespace-only names are skipped.
1152    pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1153        let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1154        for raw in &self.capabilities.provides {
1155            let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1156            if name.is_empty() {
1157                continue;
1158            }
1159            out.entry(name).or_insert(ProvidedCapability {
1160                description: String::new(),
1161                schema: None,
1162                discoverable: true,
1163            });
1164        }
1165        out
1166    }
1167
1168    /// Refuse a `schedules` block that the host could not honour, or should not.
1169    ///
1170    /// Three distinct refusals, and the third is the one that matters for
1171    /// security: a schedule may only name a capability THIS app provides.
1172    /// The host registers the row as the app and the cron app records the app
1173    /// as its `created_by`, so an unrestricted `capability` field would let any
1174    /// package schedule repeated dispatches at anything on the node under its
1175    /// own name. A manifest is not a surface the owner reviews, so the gate is
1176    /// here, at install, and loud — not at 03:00 and silent.
1177    fn validate_schedules(&self) -> Result<(), String> {
1178        if self.schedules.is_empty() {
1179            return Ok(());
1180        }
1181        let provided = self.resolved_capability_provides();
1182        let mut seen: HashSet<&str> = HashSet::new();
1183        for schedule in &self.schedules {
1184            validate_schedule_id(&schedule.id)?;
1185            if !seen.insert(schedule.id.as_str()) {
1186                return Err(format!("duplicate schedule id '{}'", schedule.id));
1187            }
1188            validate_cron_shape(&schedule.cron)
1189                .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1190            if schedule.capability.trim().is_empty() {
1191                return Err(format!(
1192                    "schedule '{}': capability must not be blank",
1193                    schedule.id
1194                ));
1195            }
1196            if !provided.contains_key(schedule.capability.trim()) {
1197                return Err(format!(
1198                    "schedule '{}' names capability '{}', which this app does not provide \
1199                     (a schedule may only dispatch a capability declared in this manifest's \
1200                     'provides')",
1201                    schedule.id, schedule.capability
1202                ));
1203            }
1204            if let Some(payload) = &schedule.payload {
1205                if !payload.is_object() {
1206                    return Err(format!(
1207                        "schedule '{}': payload must be a JSON object",
1208                        schedule.id
1209                    ));
1210                }
1211            }
1212        }
1213        Ok(())
1214    }
1215
1216    /// Validate the manifest for structural correctness.
1217    ///
1218    /// Returns `Ok(())` on success, or a human-readable error string.
1219    /// Called by the manifest parser after deserialization.
1220    pub fn validate(&self) -> Result<(), String> {
1221        self.validate_with_socket_path_policy(false)
1222    }
1223
1224    /// Validate this manifest with an explicit standalone socket-path policy.
1225    ///
1226    /// Runtime adapters may opt into non-`/run` paths for development without
1227    /// making the domain model read process configuration.
1228    pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1229        // v2 requires abi field
1230        if self.manifest_version == 2 && self.abi.is_none() {
1231            return Err("manifest_version 2 requires an 'abi' field".to_string());
1232        }
1233
1234        // Name validation: ^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$
1235        // (publisher/name form accepted but not yet semantically used — FR-019)
1236        validate_app_name(&self.name)?;
1237        self.resolved_requires()?;
1238
1239        // Path-safety on entrypoint and ui_path
1240        if let Some(ref ep) = self.entrypoint {
1241            validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1242        }
1243        // ui_path is only meaningful when has_ui is true, but validate always
1244        if !self.ui_path.is_empty() && self.ui_path != "dist" {
1245            validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1246        }
1247
1248        if let Some(ui) = &self.ui {
1249            validate_app_ui(&self.name, ui)?;
1250        }
1251
1252        // Homepage scheme validation (if present)
1253        if let Some(ref hp) = self.homepage {
1254            if !hp.starts_with("https://") && !hp.starts_with("http://") {
1255                return Err(format!(
1256                    "homepage '{}' must use https:// or http:// scheme",
1257                    hp
1258                ));
1259            }
1260        }
1261
1262        // Standalone-app rules:
1263        // - When `app_type == "standalone"` AND the manifest declares any
1264        //   capability providers, `standalone.socket_path` is required and
1265        //   must be an absolute path under `/run/` with no `..` segments.
1266        // - Non-standalone manifests MUST NOT carry a `standalone` block
1267        //   (rejected to surface accidental schema misuse).
1268        match self.app_type {
1269            AppType::Standalone => {
1270                if self.has_capability_providers() {
1271                    let cfg = self.standalone.as_ref().ok_or_else(|| {
1272                        "standalone apps that declare 'provides' require a \
1273                         'standalone.socket_path' field"
1274                            .to_string()
1275                    })?;
1276                    validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1277                }
1278            }
1279            AppType::Native
1280            | AppType::Bun
1281            | AppType::PlatformRuntime
1282            | AppType::ManagedV1
1283            | AppType::Burger
1284            | AppType::UiOnly => {
1285                if self.standalone.is_some() {
1286                    return Err(format!(
1287                        "'standalone' block is only valid when app_type == 'standalone' \
1288                         (found app_type='{}')",
1289                        self.app_type
1290                    ));
1291                }
1292            }
1293        }
1294
1295        if self.app_type == AppType::UiOnly {
1296            validate_ui_only(self)?;
1297        }
1298
1299        if let Some(resources) = &self.resources {
1300            if resources.memory_mb == Some(0) {
1301                return Err("resources.memory_mb must be at least 1".to_string());
1302            }
1303            if let Some(deadline) = resources.callback_deadline_ms {
1304                if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1305                    return Err(format!(
1306                        "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1307                    ));
1308                }
1309            }
1310        }
1311
1312        self.validate_schedules()?;
1313
1314        if self.app_type == AppType::PlatformRuntime
1315            && !self.resolved_capability_provides().is_empty()
1316        {
1317            return Err(
1318                "platform-runtime packages cannot provide runtime capabilities".to_string(),
1319            );
1320        }
1321
1322        Ok(())
1323    }
1324
1325    /// Parse from a JSON string, validate, and return the manifest.
1326    pub fn from_json(json: &str) -> Result<Self, String> {
1327        Self::from_json_with_socket_path_policy(json, false)
1328    }
1329
1330    /// Parse and validate with an explicit standalone socket-path policy.
1331    pub fn from_json_with_socket_path_policy(
1332        json: &str,
1333        allow_non_run: bool,
1334    ) -> Result<Self, String> {
1335        let mut manifest: Self =
1336            serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1337        if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1338            manifest.app_type = AppType::UiOnly;
1339        }
1340        if manifest.ui.is_some() {
1341            manifest.has_ui = true;
1342        }
1343        manifest.validate_with_socket_path_policy(allow_non_run)?;
1344        Ok(manifest)
1345    }
1346}
1347
1348fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1349    let mut seen = HashSet::new();
1350    let mut resolved = Vec::new();
1351    for value in values {
1352        let requirement = value.trim();
1353        if requirement.is_empty() {
1354            return Err("capability requirements must not be blank".to_string());
1355        }
1356        if seen.insert(requirement.to_string()) {
1357            resolved.push(requirement.to_string());
1358        }
1359    }
1360    Ok(resolved)
1361}
1362
1363/// Contracts F5: a UI-only app declares a stage or a widget and nothing that
1364/// implies a process. A widget runs inside the stage that composes it, so it
1365/// needs a backend no more than a stage does.
1366fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1367    // Exhaustive on purpose: a future ui kind must decide here whether it can be UI-only.
1368    match manifest.ui.as_ref().map(|ui| ui.kind) {
1369        Some(AppUiKind::Stage | AppUiKind::Widget) => {}
1370        None => {
1371            return Err(
1372                "ui-only apps must declare a ui block of kind \"stage\" or \"widget\"".to_string(),
1373            )
1374        }
1375    }
1376    match ui_only_process_declaration(manifest) {
1377        Some(what) => Err(format!(
1378            "ui-only apps have no backend process and must not declare {what}"
1379        )),
1380        None => Ok(()),
1381    }
1382}
1383
1384/// Contracts F5: the first thing `manifest` declares that implies a backend
1385/// process, which a UI-only app cannot have. A UI-only app is never started,
1386/// so it can neither receive events nor wait on dependencies. Shared by
1387/// [`AppManifest::validate`] and `node-app audit`.
1388pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1389    if manifest.entrypoint.is_some() {
1390        Some("'entrypoint'")
1391    } else if manifest.has_capability_providers() {
1392        Some("capability providers ('provides' / 'capabilities.provides')")
1393    } else if manifest.tcp.is_some() {
1394        Some("a 'tcp' block")
1395    } else if manifest.resources.is_some() {
1396        Some("a 'resources' block")
1397    } else if manifest.standalone.is_some() {
1398        Some("a 'standalone' block")
1399    } else if !manifest.subscribes.is_empty() {
1400        Some("event subscriptions ('subscribes')")
1401    } else if manifest
1402        .depends_on
1403        .as_ref()
1404        .is_some_and(|deps| !deps.is_empty())
1405    {
1406        Some("dependencies ('depends_on')")
1407    } else {
1408        None
1409    }
1410}
1411
1412/// Contracts F5: whether a raw manifest is UI-only — a `ui` object with
1413/// neither `app_type` nor `entrypoint`, or an explicit `"app_type":
1414/// "ui-only"`. Read off the raw JSON, because serde's `app_type` default
1415/// (`native`) hides whether the key was there. The one definition shared by
1416/// [`AppManifest::from_json`], `node-app audit` and `node-app package`.
1417///
1418/// Precedence: an explicit `app_type` other than `"ui-only"` always wins
1419/// over the derived form. A manifest with `"app_type": "bun"`, a `ui` block
1420/// and no `entrypoint` is a Bun app, not UI-only — the derived branch's
1421/// `!object.contains_key("app_type")` check is false, so it never fires, and
1422/// [`validate_ui_only`] is skipped for it. The derived form only applies
1423/// when `app_type` is absent entirely.
1424pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1425    manifest.as_object().is_some_and(|object| {
1426        let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1427            && !object.contains_key("app_type")
1428            && !object.contains_key("entrypoint");
1429        derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1430    })
1431}
1432
1433/// Scope prefixes no UI may request (C §9.2): the agent engine's runtime is reached only
1434/// through the agent app, which gates it by mode, approval card and caller. A stage or surface that
1435/// requested it directly would bypass all three.
1436const UI_FORBIDDEN_CAPABILITY_PREFIXES: &[&str] = &["graph.runtime."];
1437
1438fn refuse_forbidden_ui_requirements(
1439    label: &str,
1440    requires: &AppUiRequirements,
1441) -> Result<(), String> {
1442    // All three fields become browser scopes in resolved() and the client kernel.
1443    // Run before syntax validation so even bare '*' gets the runtime diagnostic;
1444    // resolved() still enforces the general per-field syntax afterwards.
1445    for (field, values) in [
1446        ("capabilities", &requires.capabilities),
1447        ("queries", &requires.queries),
1448        ("streams", &requires.streams),
1449    ] {
1450        refuse_forbidden_ui_scopes(&format!("{label}.{field}"), values)?;
1451    }
1452    Ok(())
1453}
1454
1455fn refuse_forbidden_ui_scopes(label: &str, scopes: &[String]) -> Result<(), String> {
1456    for scope in scopes {
1457        let name = scope.trim();
1458        // `graph.*` and `*` cover the runtime too, not only an entry that names it.
1459        let wildcard_root = name
1460            .strip_suffix('*')
1461            .filter(|_| name == "*" || name.ends_with(".*"));
1462        let covered = |prefix: &str| {
1463            name.starts_with(prefix) || wildcard_root.is_some_and(|root| prefix.starts_with(root))
1464        };
1465        if let Some(prefix) = UI_FORBIDDEN_CAPABILITY_PREFIXES
1466            .iter()
1467            .find(|prefix| covered(prefix))
1468        {
1469            return Err(format!(
1470                "{label} must not request '{name}': {prefix}* is reachable only through the agent app"
1471            ));
1472        }
1473    }
1474    Ok(())
1475}
1476
1477fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1478    // `layout` is validated during deserialization by UiLayout: only full/column
1479    // are representable. None resolves to UiLayout::default() (column).
1480    if ui.ui_api != 1 && ui.ui_api != 2 {
1481        return Err(format!(
1482            "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1483            ui.ui_api
1484        ));
1485    }
1486    if ui.title.trim().is_empty() {
1487        return Err("ui.title must not be blank".to_string());
1488    }
1489    refuse_forbidden_ui_requirements("ui.requires", &ui.requires)?;
1490    ui.requires.resolved()?;
1491    ui.requires.validate_commands()?;
1492    validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1493    if let Some(icon) = &ui.icon {
1494        validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1495    }
1496    if let Some(nav) = &ui.nav {
1497        if ui.kind == AppUiKind::Widget {
1498            return Err("widget ui must omit nav metadata".to_string());
1499        }
1500        if nav.section.trim().is_empty() {
1501            return Err("ui.nav.section must not be blank".to_string());
1502        }
1503    }
1504    // Widgets own app data under exactly the stage rules. Which hosts accept
1505    // that is decided by the `ui.widget-data` host feature, not here
1506    // (see `host_contract::check_host_contract`).
1507    if let Some(data) = &ui.data {
1508        validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1509    }
1510
1511    let mut composed = HashSet::new();
1512    for name in &ui.composes {
1513        validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1514        if name == app_name {
1515            return Err("ui.composes must not contain the app itself".to_string());
1516        }
1517        if !composed.insert(name) {
1518            return Err(format!("ui.composes contains duplicate app '{name}'"));
1519        }
1520    }
1521
1522    let mut surface_ids = HashSet::new();
1523    for surface in &ui.surfaces {
1524        let id = surface.id.trim();
1525        if id.is_empty() {
1526            return Err("ui.surfaces entry id must not be blank".to_string());
1527        }
1528        if !surface_ids.insert(id.to_string()) {
1529            return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1530        }
1531        if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1532            return Err(format!(
1533                "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1534                surface.slot,
1535                KNOWN_SURFACE_SLOTS.join(", ")
1536            ));
1537        }
1538        if surface.title.trim().is_empty() {
1539            return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1540        }
1541        validate_manifest_path(&surface.entry)
1542            .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1543        // Same rule `ui.entry` and `ui.icon` get below, and for a sharper reason: the client
1544        // kernel's `ensureIntegrityForUi` (`client/kernel/src/stages/stage-registry-service.js`)
1545        // REQUIRES a digest for every surface entry, and the throw there propagates out of
1546        // `parseCatalogEntry` through `parseCatalogResponse`'s `value.map(...)` — failing the
1547        // whole catalog snapshot, every stage on the node, and looping on retry. Without this
1548        // check a typo, or an entry emitted outside `ui_path` (which is the only tree
1549        // `generate_staged_integrity` stamps), packages cleanly, installs cleanly, and then
1550        // bricks every client's stage list. Refuse it here, where the author can still fix it.
1551        if !ui.integrity.contains_key(&surface.entry) {
1552            return Err(format!("ui.integrity must include surface '{id}' entry"));
1553        }
1554        refuse_forbidden_ui_requirements(
1555            &format!("ui.surfaces entry '{id}' requires"),
1556            &surface.requires,
1557        )?;
1558        surface
1559            .requires
1560            .resolved()
1561            .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1562        surface
1563            .requires
1564            .validate_commands()
1565            .map_err(|error| format!("ui.surfaces entry '{id}': {error}"))?;
1566    }
1567
1568    if ui
1569        .surfaces
1570        .iter()
1571        .filter(|surface| surface.slot == ASSISTANT_SLOT)
1572        .count()
1573        > 1
1574    {
1575        return Err("ui.surfaces may declare at most one 'assistant' surface".to_string());
1576    }
1577
1578    for (path, digest) in &ui.integrity {
1579        validate_manifest_path(path)
1580            .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1581        if !is_lowercase_sha256(digest) {
1582            return Err(format!(
1583                "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1584            ));
1585        }
1586    }
1587    if !ui.integrity.contains_key(&ui.entry) {
1588        return Err("ui.integrity must include the declared entry".to_string());
1589    }
1590    if let Some(icon) = &ui.icon {
1591        if !ui.integrity.contains_key(icon) {
1592            return Err("ui.integrity must include the declared icon".to_string());
1593        }
1594    }
1595    Ok(())
1596}
1597
1598fn validate_app_data(
1599    app_name: &str,
1600    data: &AppDataManifest,
1601    resolved_requires: &[String],
1602) -> Result<(), String> {
1603    validate_app_data_namespace(&data.namespace)?;
1604    validate_app_data_namespace_owner(app_name, &data.namespace)?;
1605    validate_app_data_sync_policy(&data.sync)?;
1606    if data.queries.is_empty() && data.offline != AppDataOfflinePolicy::OnlineOnly {
1607        return Err("ui.data.queries must declare at least one query for last-known data".to_string());
1608    }
1609
1610    let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1611    let mut names = BTreeSet::new();
1612    for query in &data.queries {
1613        validate_namespaced_data_name(&query.name, &data.namespace)
1614            .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1615        validate_capability_name(&query.capability).map_err(|error| {
1616            format!(
1617                "ui.data query '{}' capability '{}' invalid: {error}",
1618                query.name, query.capability
1619            )
1620        })?;
1621        if !requires.contains(query.capability.as_str()) {
1622            return Err(format!(
1623                "ui.data query '{}' capability '{}' must be declared in requires",
1624                query.name, query.capability
1625            ));
1626        }
1627        if !names.insert(query.name.as_str()) {
1628            return Err(format!("ui.data contains duplicate query '{}'", query.name));
1629        }
1630    }
1631
1632    for stream in &data.streams {
1633        validate_namespaced_data_name(&stream.name, &data.namespace)
1634            .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1635        if !names.insert(stream.name.as_str()) {
1636            return Err(format!(
1637                "ui.data contains duplicate declaration '{}'",
1638                stream.name
1639            ));
1640        }
1641    }
1642
1643    Ok(())
1644}
1645
1646fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1647    if !is_safe_name_segment(namespace) {
1648        return Err(format!(
1649            "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1650            namespace
1651        ));
1652    }
1653    if matches!(
1654        namespace,
1655        "core" | "internal" | "node" | "platform" | "system"
1656    ) {
1657        return Err(format!("ui.data namespace '{namespace}' is reserved"));
1658    }
1659    Ok(())
1660}
1661
1662/// A stage's projections are stored under `ui.data.namespace`, and the
1663/// Client Node PWA only accepts a namespace the app owns
1664/// (`validateEntryCompatibility`,
1665/// `client/kernel/src/stages/offline-readiness-coordinator.js`): any other
1666/// stage fails there as `schema-incompatible` and never reaches the nav. The
1667/// client also admits `<app>.`-prefixed namespaces, but a namespace cannot
1668/// contain a dot ([`validate_app_data_namespace`]), so here the rule is plain
1669/// equality. node-app-burger 0.2.0 shipped `burger` for `burger-runtime`.
1670///
1671/// Public so `node-app audit` reports the same rule, with the same message.
1672pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1673    if namespace != app_name {
1674        return Err(format!(
1675            "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1676             Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1677             to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1678        ));
1679    }
1680    Ok(())
1681}
1682
1683fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1684    validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1685    validate_optional_range(
1686        "full_refresh_interval_secs",
1687        sync.full_refresh_interval_secs,
1688        60,
1689        604_800,
1690    )?;
1691    validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1692    if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1693        return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1694    }
1695    Ok(())
1696}
1697
1698fn validate_optional_range(
1699    field: &str,
1700    value: Option<u32>,
1701    min: u32,
1702    max: u32,
1703) -> Result<(), String> {
1704    if let Some(value) = value {
1705        if value < min || value > max {
1706            return Err(format!(
1707                "ui.data.sync {field} must be between {min} and {max} seconds"
1708            ));
1709        }
1710    }
1711    Ok(())
1712}
1713
1714fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1715    validate_capability_name(name)?;
1716    let Some(rest) = name
1717        .strip_prefix(namespace)
1718        .and_then(|suffix| suffix.strip_prefix('.'))
1719    else {
1720        return Err(format!("name must use namespace '{namespace}'"));
1721    };
1722    if rest.is_empty() {
1723        return Err("name must include a value after its namespace".to_string());
1724    }
1725    if !has_version_suffix(name) {
1726        return Err("name must end with a .vN version suffix".to_string());
1727    }
1728    Ok(())
1729}
1730
1731fn validate_capability_name(name: &str) -> Result<(), String> {
1732    if name.is_empty() {
1733        return Err("name must not be empty".to_string());
1734    }
1735    if name.contains('/') || name.contains("..") {
1736        return Err("name must not contain path separators or traversal".to_string());
1737    }
1738    if !name.split('.').all(is_safe_declaration_segment) {
1739        return Err("name must contain only lowercase dot-separated segments".to_string());
1740    }
1741    Ok(())
1742}
1743
1744fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1745    if allow_wildcard && name.ends_with(".*") {
1746        return validate_capability_name(&name[..name.len() - 2]);
1747    }
1748    validate_capability_name(name)
1749}
1750
1751fn has_version_suffix(name: &str) -> bool {
1752    let Some(version) = name.rsplit('.').next() else {
1753        return false;
1754    };
1755    let Some(digits) = version.strip_prefix('v') else {
1756        return false;
1757    };
1758    !digits.is_empty()
1759        && !digits.starts_with('0')
1760        && digits.bytes().all(|byte| byte.is_ascii_digit())
1761}
1762
1763fn is_safe_name_segment(segment: &str) -> bool {
1764    if segment.is_empty() {
1765        return false;
1766    }
1767    let mut chars = segment.chars();
1768    let Some(first) = chars.next() else {
1769        return false;
1770    };
1771    first.is_ascii_lowercase()
1772        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1773}
1774
1775/// A segment of a capability, query, or stream name.
1776///
1777/// Deliberately looser than [`is_safe_name_segment`] by exactly one character:
1778/// `_`. Capability actions in this codebase are snake_case almost without
1779/// exception (`core.lightning.create_invoice`, `core.did.current_did`,
1780/// `contest.world.studio_state`), and app-event resources are too
1781/// (`app.agent_session` — `APP_EVENT_RESOURCE_PATTERN` in `@econ-v1/domain`
1782/// admits `_` for precisely these). Rejecting `_` here did not make a stage
1783/// safer, it made `ui.requires` unusable: a stage that declared any real
1784/// capability failed `resolved()`, and `build_ui_stage_catalog` then dropped
1785/// that stage from the shell entirely. The characters that actually matter —
1786/// path separators, traversal, uppercase, leading digits — are still refused.
1787fn is_safe_declaration_segment(segment: &str) -> bool {
1788    let mut chars = segment.chars();
1789    let Some(first) = chars.next() else {
1790        return false;
1791    };
1792    first.is_ascii_lowercase()
1793        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1794}
1795
1796fn is_lowercase_sha256(value: &str) -> bool {
1797    value.len() == 64
1798        && value
1799            .bytes()
1800            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1801}
1802
1803/// Validate a `StandaloneConfig::socket_path`.
1804///
1805/// Rules:
1806/// 1. Absolute path (starts with `/`).
1807/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc. — pins the
1808///    socket to a tmpfs path predictably writable by the standalone daemon).
1809///    Runtime adapters can explicitly bypass this restriction for development.
1810/// 3. No `..` segments anywhere in the path.
1811pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1812    validate_standalone_socket_path_with_policy(path, false)
1813}
1814
1815/// Validate a standalone socket path with an explicit runtime policy.
1816pub fn validate_standalone_socket_path_with_policy(
1817    path: &std::path::Path,
1818    allow_non_run: bool,
1819) -> Result<(), String> {
1820    if !path.is_absolute() {
1821        return Err(format!(
1822            "standalone.socket_path '{}' must be absolute",
1823            path.display()
1824        ));
1825    }
1826    if !allow_non_run && !path.starts_with("/run/") {
1827        return Err(format!(
1828            "standalone.socket_path '{}' must live under /run/",
1829            path.display()
1830        ));
1831    }
1832    if path
1833        .components()
1834        .any(|c| matches!(c, std::path::Component::ParentDir))
1835    {
1836        return Err(format!(
1837            "standalone.socket_path '{}' must not contain '..' segments",
1838            path.display()
1839        ));
1840    }
1841    Ok(())
1842}
1843
1844/// Validate an app name string.
1845///
1846/// Accepts `app-name` (simple) and `publisher/app-name` (publisher-prefixed, FR-019).
1847fn validate_app_name(name: &str) -> Result<(), String> {
1848    let (publisher, app) = if let Some(slash) = name.find('/') {
1849        let (p, rest) = name.split_at(slash);
1850        (Some(p), &rest[1..])
1851    } else {
1852        (None, name)
1853    };
1854
1855    let valid_segment = |s: &str| -> bool {
1856        if s.is_empty() {
1857            return false;
1858        }
1859        let mut chars = s.chars();
1860        let first = chars.next().unwrap();
1861        if !first.is_ascii_lowercase() {
1862            return false;
1863        }
1864        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1865    };
1866
1867    if let Some(pub_name) = publisher {
1868        if !valid_segment(pub_name) {
1869            return Err(format!(
1870                "publisher segment '{}' must match [a-z][a-z0-9-]*",
1871                pub_name
1872            ));
1873        }
1874    }
1875
1876    if !valid_segment(app) {
1877        return Err(format!(
1878            "app name segment '{}' must match [a-z][a-z0-9-]*",
1879            app
1880        ));
1881    }
1882
1883    Ok(())
1884}
1885
1886// ── Tests ─────────────────────────────────────────────────────────────────────
1887
1888#[cfg(test)]
1889mod tests {
1890    use super::*;
1891
1892    fn parse_ok(json: &str) -> AppManifest {
1893        AppManifest::from_json(json).expect("should parse")
1894    }
1895
1896    fn parse_err(json: &str) -> String {
1897        AppManifest::from_json(json).expect_err("should fail")
1898    }
1899
1900    // ── schedules (econ-v1/node#3185) ────────────────────────────────────────
1901
1902    const SCHEDULED_APP: &str = r#"{
1903        "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1904        "provides":{"network.ledger.poll":{"description":"Record connections"}},
1905        "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1906                      "capability":"network.ledger.poll"}]
1907    }"#;
1908
1909    #[test]
1910    fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1911        // The default has to stay byte-for-byte the old behaviour: every
1912        // manifest on every installed node predates this field.
1913        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1914        assert!(m.schedules.is_empty());
1915    }
1916
1917    #[test]
1918    fn a_schedule_is_parsed_whole() {
1919        let m = parse_ok(SCHEDULED_APP);
1920        assert_eq!(m.schedules.len(), 1);
1921        let s = &m.schedules[0];
1922        assert_eq!(s.id, "ledger-poll");
1923        assert_eq!(s.cron, "0 */15 * * * *");
1924        assert_eq!(s.capability, "network.ledger.poll");
1925        // An absent payload is an empty object, not null — the router stamps
1926        // `caller` into it, which requires an object.
1927        assert_eq!(s.effective_payload(), serde_json::json!({}));
1928    }
1929
1930    #[test]
1931    fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1932        // The security gate. Without it any package could schedule repeated
1933        // dispatches at anything on the node under its own name.
1934        let err = parse_err(
1935            r#"{"name":"network","version":"1.0.0","app_type":"bun",
1936                "provides":{"network.ledger.poll":{"description":"x"}},
1937                "schedules":[{"id":"drain","cron":"0 0 * * * *",
1938                              "capability":"core.lightning.send_payment"}]}"#,
1939        );
1940        assert!(err.contains("core.lightning.send_payment"), "{err}");
1941        assert!(err.contains("does not provide"), "{err}");
1942    }
1943
1944    #[test]
1945    fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1946        // v2 manifests list provided capability NAMES under `capabilities.provides`
1947        // rather than the v1 `provides` map; both are the same declaration.
1948        let m = parse_ok(
1949            r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1950                "app_type":"bun",
1951                "capabilities":{"provides":["network.ledger.poll"]},
1952                "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1953                              "capability":"network.ledger.poll"}]}"#,
1954        );
1955        assert_eq!(m.schedules.len(), 1);
1956    }
1957
1958    #[test]
1959    fn a_cron_expression_without_six_fields_is_refused() {
1960        // 5 fields is the Unix crontab shape; this platform's cron takes 6.
1961        let err = parse_err(
1962            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1963                "provides":{"n.poll":{"description":"x"}},
1964                "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1965        );
1966        assert!(err.contains("6 fields"), "{err}");
1967    }
1968
1969    #[test]
1970    fn a_cron_expression_with_junk_in_it_is_refused() {
1971        let err = parse_err(
1972            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1973                "provides":{"n.poll":{"description":"x"}},
1974                "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1975        );
1976        assert!(err.contains("disallowed character"), "{err}");
1977    }
1978
1979    #[test]
1980    fn two_schedules_cannot_share_an_id() {
1981        // The id is the cron row's `external_id`; a duplicate would make the
1982        // reconcile's read-before-write lookup ambiguous.
1983        let err = parse_err(
1984            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1985                "provides":{"n.poll":{"description":"x"}},
1986                "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1987                             {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1988        );
1989        assert!(err.contains("duplicate schedule id"), "{err}");
1990    }
1991
1992    #[test]
1993    fn a_schedule_id_must_be_a_safe_token() {
1994        let err = parse_err(
1995            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1996                "provides":{"n.poll":{"description":"x"}},
1997                "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1998        );
1999        assert!(err.contains("schedule id"), "{err}");
2000    }
2001
2002    #[test]
2003    fn a_non_object_payload_is_refused() {
2004        let err = parse_err(
2005            r#"{"name":"n","version":"1.0.0","app_type":"bun",
2006                "provides":{"n.poll":{"description":"x"}},
2007                "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
2008                              "payload":"not-an-object"}]}"#,
2009        );
2010        assert!(err.contains("JSON object"), "{err}");
2011    }
2012
2013    #[test]
2014    fn a_declared_payload_survives_the_round_trip() {
2015        let m = parse_ok(
2016            r#"{"name":"n","version":"1.0.0","app_type":"bun",
2017                "provides":{"n.poll":{"description":"x"}},
2018                "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
2019                              "payload":{"depth":2}}]}"#,
2020        );
2021        assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
2022        let round_tripped: AppManifest =
2023            serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
2024        assert_eq!(round_tripped.schedules, m.schedules);
2025    }
2026
2027    #[test]
2028    fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
2029        // So re-serializing an old manifest does not grow a field it never had.
2030        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2031        let json = serde_json::to_string(&m).unwrap();
2032        assert!(!json.contains("schedules"), "{json}");
2033    }
2034
2035    // ── v1 manifests ──────────────────────────────────────────────────────────
2036
2037    #[test]
2038    fn v1_minimal_native() {
2039        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2040        assert_eq!(m.manifest_version, 1);
2041        assert_eq!(m.app_type, AppType::Native);
2042        assert!(m.abi.is_none());
2043    }
2044
2045    #[test]
2046    fn v1_minimal_bun() {
2047        let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
2048        assert_eq!(m.app_type, AppType::Bun);
2049        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2050    }
2051
2052    #[test]
2053    fn v1_no_manifest_version_field_defaults_to_1() {
2054        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
2055        assert_eq!(m.manifest_version, 1);
2056    }
2057
2058    #[test]
2059    fn v1_all_optional_fields_missing() {
2060        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
2061        assert!(!m.critical);
2062        assert!(m.auto_start);
2063        assert!(!m.has_ui);
2064        assert_eq!(m.ui_path, "dist");
2065        assert!(m.permissions.is_empty());
2066        assert!(m.optional_permissions.is_empty());
2067        // #1556: governor/subscribes absent → today's implicit behavior
2068        // (no opt-out, no min-idle override, no declared subscriptions).
2069        assert!(m.governor.is_none());
2070        assert!(m.subscribes.is_empty());
2071    }
2072
2073    #[test]
2074    fn v1_with_permissions_and_provides() {
2075        let json = r#"{
2076            "name": "example",
2077            "version": "1.0.0",
2078            "app_type": "bun",
2079            "permissions": ["core.storage.kv"],
2080            "optional_permissions": ["core.notifications.create"],
2081            "provides": {
2082                "core.example.run": { "description": "Run example job" }
2083            }
2084        }"#;
2085        let m = parse_ok(json);
2086        assert_eq!(m.permissions, vec!["core.storage.kv"]);
2087        assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
2088        assert!(m.provides.contains_key("core.example.run"));
2089    }
2090
2091    /// `provides.<name>.discoverable` defaults to `true`; `false` survives the
2092    /// v1/v2 merge and a round-trip, and is written back only when `false`.
2093    #[test]
2094    fn provides_discoverable_defaults_true_and_parses_false() {
2095        let json = r#"{
2096            "name": "remote-support",
2097            "version": "1.0.0",
2098            "app_type": "bun",
2099            "capabilities": { "provides": ["remote_support.status", "remote_support.challenge"] },
2100            "provides": {
2101                "remote_support.status": { "description": "Status" },
2102                "remote_support.mode.set": { "description": "Set mode", "discoverable": false }
2103            }
2104        }"#;
2105        let m = parse_ok(json);
2106        assert!(m.provides["remote_support.status"].discoverable);
2107        assert!(!m.provides["remote_support.mode.set"].discoverable);
2108
2109        let resolved = m.resolved_capability_provides();
2110        assert!(!resolved["remote_support.mode.set"].discoverable);
2111        assert!(resolved["remote_support.status"].discoverable);
2112        // A v2-only name has no rich entry to carry the flag: discoverable.
2113        assert!(resolved["remote_support.challenge"].discoverable);
2114
2115        let written = serde_json::to_value(&m).unwrap();
2116        assert_eq!(
2117            written["provides"]["remote_support.mode.set"]["discoverable"],
2118            serde_json::json!(false)
2119        );
2120        assert!(written["provides"]["remote_support.status"]
2121            .get("discoverable")
2122            .is_none());
2123    }
2124
2125    #[test]
2126    fn provides_discoverable_must_be_a_boolean() {
2127        let json = r#"{
2128            "name": "example",
2129            "version": "1.0.0",
2130            "app_type": "bun",
2131            "provides": { "example.run": { "discoverable": "no" } }
2132        }"#;
2133        assert!(AppManifest::from_json(json).is_err());
2134    }
2135
2136    // ── v2 manifests ──────────────────────────────────────────────────────────
2137
2138    #[test]
2139    fn v2_minimal_native() {
2140        let json = r#"{
2141            "manifest_version": 2,
2142            "name": "cron",
2143            "version": "1.0.0",
2144            "app_type": "native",
2145            "abi": "v1",
2146            "entrypoint": "app.so",
2147            "hot_reload": "experimental"
2148        }"#;
2149        let m = parse_ok(json);
2150        assert_eq!(m.manifest_version, 2);
2151        assert_eq!(m.abi, Some(AbiVersion::V1));
2152        assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
2153        assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
2154    }
2155
2156    #[test]
2157    fn v2_minimal_bun_with_capabilities() {
2158        let json = r#"{
2159            "manifest_version": 2,
2160            "name": "example-fullstack",
2161            "version": "1.0.0",
2162            "app_type": "bun",
2163            "abi": "v1",
2164            "entrypoint": "dist/index.js",
2165            "hot_reload": "supported",
2166            "has_ui": true,
2167            "ui_path": "ui/dist",
2168            "capabilities": {
2169                "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
2170                "provides": []
2171            },
2172            "governor": { "terminable": false, "min_idle_secs": 300 },
2173            "subscribes": ["core.chat.message.received"]
2174        }"#;
2175        let m = parse_ok(json);
2176        assert_eq!(m.manifest_version, 2);
2177        assert_eq!(m.capabilities.requires.len(), 2);
2178        // #1556: governor/subscribes present → parsed through verbatim.
2179        let governor = m.governor.expect("governor block should parse");
2180        assert_eq!(governor.terminable, Some(false));
2181        assert_eq!(governor.min_idle_secs, Some(300));
2182        assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
2183    }
2184
2185    #[test]
2186    fn stage_contract_fixture_parses_with_normalized_requirements() {
2187        let json = include_str!(
2188            "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2189        );
2190        let m = parse_ok(json);
2191        assert!(m.has_ui);
2192        assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2193        let ui = m.ui.expect("fixture should declare ui");
2194        assert_eq!(ui.kind, AppUiKind::Stage);
2195        assert_eq!(ui.entry, "ui/main.js");
2196        assert_eq!(ui.nav.unwrap().order, 10);
2197    }
2198
2199    #[test]
2200    fn stage_layout_parses_validates_and_defaults_to_column() {
2201        let source = include_str!(
2202            "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2203        );
2204        let mut value: serde_json::Value = serde_json::from_str(source).unwrap();
2205        assert_eq!(parse_ok(source).ui.unwrap().layout, None);
2206        assert_eq!(UiLayout::default(), UiLayout::Column);
2207        for (name, layout) in [("full", UiLayout::Full), ("column", UiLayout::Column)] {
2208            value["ui"]["layout"] = serde_json::json!(name);
2209            let manifest = parse_ok(&value.to_string());
2210            assert_eq!(manifest.ui.as_ref().unwrap().layout, Some(layout));
2211            validate_app_ui(&manifest.name, manifest.ui.as_ref().unwrap()).unwrap();
2212            assert_eq!(
2213                serde_json::to_value(manifest.ui.unwrap()).unwrap()["layout"],
2214                name
2215            );
2216        }
2217        value["ui"]["layout"] = serde_json::json!("wide");
2218        assert!(parse_err(&value.to_string()).contains("unknown variant"));
2219    }
2220
2221    #[test]
2222    fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2223        let m = parse_ok(
2224            r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2225        );
2226        assert!(m.has_ui);
2227        assert_eq!(m.ui_path, "ui/dist");
2228        assert!(m.ui.is_none());
2229    }
2230
2231    #[test]
2232    fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2233        let manifest = parse_ok(
2234            r#"{
2235                "name":"ui-contract","version":"1.0.0","app_type":"bun",
2236                "requires":["core.cron.register"],
2237                "ui":{
2238                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2239                    "requires":{
2240                        "capabilities":["ui.snapshot.v1"],
2241                        "queries":["ui.query.v1"],
2242                        "streams":["ui.event.v1"]
2243                    },
2244                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2245                }
2246            }"#,
2247        );
2248
2249        assert_eq!(
2250            manifest.resolved_requires().unwrap(),
2251            vec!["core.cron.register"]
2252        );
2253        let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2254        assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2255        assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2256        assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2257        assert_eq!(
2258            ui.requires.resolved().unwrap(),
2259            vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2260        );
2261        let serialized = serde_json::to_value(ui).unwrap();
2262        assert_eq!(
2263            serialized["requires"]["queries"],
2264            serde_json::json!(["ui.query.v1"])
2265        );
2266    }
2267
2268    #[test]
2269    fn ui_requirements_reject_blank_entries() {
2270        let error = parse_err(
2271            r#"{
2272                "name":"ui-contract","version":"1.0.0","app_type":"bun",
2273                "ui":{
2274                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2275                    "requires":{"capabilities":[""],"queries":[],"streams":[]},
2276                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2277                }
2278            }"#,
2279        );
2280        assert!(error.contains("ui.requires entries must not be blank"));
2281    }
2282
2283    #[test]
2284    fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2285        // `is_safe_declaration_segment` deliberately admits `_` so `ui.requires`
2286        // can name real capabilities. `ui.data.namespace` is a different thing —
2287        // a storage key, contract `[a-z][a-z0-9-]*` — and keeps the stricter
2288        // `is_safe_name_segment`. Nothing else pins that separation, so a future
2289        // refactor collapsing the two predicates back together would silently
2290        // widen the namespace rule. This is the tripwire for that.
2291        assert!(validate_app_data_namespace("obs-viewer").is_ok());
2292
2293        let error = validate_app_data_namespace("obs_viewer")
2294            .expect_err("underscore must not be admitted into a storage namespace");
2295        assert!(
2296            error.contains("must match [a-z][a-z0-9-]*"),
2297            "unexpected error: {error}"
2298        );
2299    }
2300
2301    #[test]
2302    fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2303        let error = parse_err(
2304            r#"{
2305                "name":"ui-data","version":"1.0.0","app_type":"bun",
2306                "requires":["ui.snapshot.v1"],
2307                "ui":{
2308                    "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2309                    "requires":{"capabilities":[],"queries":[],"streams":[]},
2310                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2311                    "data":{
2312                        "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2313                        "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2314                        "streams":[]
2315                    }
2316                }
2317            }"#,
2318        );
2319        assert!(error.contains("must be declared in requires"));
2320    }
2321
2322    /// node-app-burger 0.2.0 shipped `ui.data.namespace: "burger"` for the app
2323    /// `burger-runtime`. The host accepted it, the Client Node PWA refused it
2324    /// as `schema-incompatible`, and the stage silently never appeared. The
2325    /// rule now fails `node-app validate` instead.
2326    #[test]
2327    fn ui_data_namespace_must_equal_the_app_name() {
2328        let manifest = |namespace: &str| {
2329            format!(
2330                r#"{{
2331                    "name":"burger-runtime","version":"0.2.0",
2332                    "ui":{{
2333                        "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2334                        "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2335                        "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2336                        "data":{{
2337                            "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2338                            "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2339                            "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2340                        }}
2341                    }}
2342                }}"#
2343            )
2344        };
2345
2346        let error = parse_err(&manifest("burger"));
2347        assert!(
2348            error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2349            "unexpected error: {error}"
2350        );
2351
2352        let accepted = parse_ok(&manifest("burger-runtime"));
2353        let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2354        assert_eq!(
2355            data.map(|data| data.namespace.as_str()),
2356            Some("burger-runtime")
2357        );
2358    }
2359
2360    #[test]
2361    fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2362        let accepted = parse_ok(
2363            r#"{
2364                "name":"aliases","version":"1.0.0","app_type":"bun",
2365                "requires":["core.chat.read","core.chat.read","core.chat.send"],
2366                "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2367            }"#,
2368        );
2369        assert_eq!(
2370            accepted.resolved_requires().unwrap(),
2371            vec!["core.chat.read", "core.chat.send"]
2372        );
2373
2374        let err = parse_err(
2375            r#"{
2376                "name":"aliases","version":"1.0.0","app_type":"bun",
2377                "requires":["core.chat.read"],
2378                "capabilities":{"requires":["core.wallet.pay"]}
2379            }"#,
2380        );
2381        assert!(err.contains("conflicts"), "unexpected error: {err}");
2382    }
2383
2384    #[test]
2385    fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2386        let base = |ui: &str| {
2387            format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2388        };
2389        let widget = base(
2390            r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2391        );
2392        assert_eq!(
2393            parse_ok(&widget).ui.expect("widget ui").kind,
2394            AppUiKind::Widget
2395        );
2396        let widget_nav = base(
2397            r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2398        );
2399        assert!(parse_err(&widget_nav).contains("must omit nav"));
2400        let api = base(
2401            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2402        );
2403        assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2404        let unsupported_api = base(
2405            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2406        );
2407        assert!(parse_err(&unsupported_api).contains("ui_api"));
2408        let path = base(
2409            r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2410        );
2411        assert!(parse_err(&path).contains("entry"));
2412    }
2413
2414    #[test]
2415    fn stage_requires_integrity_for_entry_and_icon() {
2416        let missing_entry = r#"{
2417            "name":"stage","version":"1.0.0","app_type":"bun",
2418            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2419        }"#;
2420        assert!(parse_err(missing_entry).contains("entry"));
2421        let missing_icon = r#"{
2422            "name":"stage","version":"1.0.0","app_type":"bun",
2423            "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2424            "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2425        }"#;
2426        assert!(parse_err(missing_icon).contains("icon"));
2427        let uppercase = r#"{
2428            "name":"stage","version":"1.0.0","app_type":"bun",
2429            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2430            "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2431        }"#;
2432        assert!(parse_err(uppercase).contains("lowercase"));
2433    }
2434
2435    #[test]
2436    fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2437        let manifest = |composes: &str| {
2438            format!(
2439                r#"{{
2440                    "name":"stage","version":"1.0.0","app_type":"bun",
2441                    "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2442                    "composes":{composes},
2443                    "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2444                }}"#
2445            )
2446        };
2447        assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2448        assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2449        assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2450    }
2451
2452    // ── ui.surfaces[] ────────────────────────────────────────────────────────
2453
2454    /// A minimal valid manifest with a `ui` block, for tests that only care
2455    /// about `ui.surfaces`. Mirrors the fixture used by
2456    /// `stage_requires_integrity_for_entry_and_icon` above.
2457    ///
2458    /// The integrity map covers `surface()`'s entry as well as `ui.entry`, because a surface
2459    /// entry must be integrity-pinned exactly like the stage entry and the icon — see
2460    /// `surface_entry_missing_from_integrity_is_rejected`. Before that rule existed this fixture
2461    /// declared a surface no digest covered, which is precisely the manifest the client kernel
2462    /// refuses.
2463    fn manifest_with_ui() -> AppManifest {
2464        parse_ok(
2465            r#"{
2466                "name":"stage","version":"1.0.0","app_type":"bun",
2467                "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2468                "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2469                "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2470            }"#,
2471        )
2472    }
2473
2474    fn surface(id: &str, slot: &str) -> AppUiSurface {
2475        AppUiSurface {
2476            id: id.to_string(),
2477            slot: slot.to_string(),
2478            entry: "ui/dist/surfaces/chip.js".to_string(),
2479            title: "Chip".to_string(),
2480            order: 10,
2481            requires: AppUiRequirements {
2482                capabilities: vec!["wallet.balance.get".to_string()],
2483                ..Default::default()
2484            },
2485        }
2486    }
2487
2488    #[test]
2489    fn manifest_without_surfaces_still_parses() {
2490        let manifest = manifest_with_ui();
2491        assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2492        assert!(manifest.validate().is_ok());
2493    }
2494
2495    #[test]
2496    fn surface_in_a_known_slot_is_accepted() {
2497        let mut manifest = manifest_with_ui();
2498        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2499        assert!(manifest.validate().is_ok());
2500    }
2501
2502    #[test]
2503    fn surface_in_an_unknown_slot_is_rejected() {
2504        let mut manifest = manifest_with_ui();
2505        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2506        let error = manifest.validate().unwrap_err();
2507        assert!(error.contains("menu-bar"), "unexpected error: {error}");
2508    }
2509
2510    #[test]
2511    fn duplicate_surface_ids_are_rejected() {
2512        let mut manifest = manifest_with_ui();
2513        manifest.ui.as_mut().unwrap().surfaces = vec![
2514            surface("chip", "status-rail"),
2515            surface("chip", "status-rail"),
2516        ];
2517        let error = manifest.validate().unwrap_err();
2518        assert!(error.contains("duplicate"), "unexpected error: {error}");
2519    }
2520
2521    #[test]
2522    fn surface_with_a_blank_id_is_rejected() {
2523        let mut manifest = manifest_with_ui();
2524        manifest.ui.as_mut().unwrap().surfaces = vec![surface("  ", "status-rail")];
2525        assert!(manifest.validate().is_err());
2526    }
2527
2528    #[test]
2529    fn surface_with_an_unsafe_entry_path_is_rejected() {
2530        let mut manifest = manifest_with_ui();
2531        let mut bad = surface("chip", "status-rail");
2532        bad.entry = "../../etc/passwd".to_string();
2533        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2534        assert!(manifest.validate().is_err());
2535    }
2536
2537    #[test]
2538    fn surface_entry_missing_from_integrity_is_rejected() {
2539        // The client kernel requires a digest for every surface entry and fails the WHOLE
2540        // catalog snapshot when one is missing, so a manifest that packages without one bricks
2541        // every installing node's stage list. Catch it at package time instead.
2542        let mut manifest = manifest_with_ui();
2543        let mut unpinned = surface("chip", "status-rail");
2544        unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2545        manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2546        let error = manifest.validate().unwrap_err();
2547        assert!(
2548            error.contains("ui.integrity must include surface 'chip' entry"),
2549            "unexpected error: {error}"
2550        );
2551    }
2552
2553    #[test]
2554    fn surface_with_a_blank_required_capability_is_rejected() {
2555        let mut manifest = manifest_with_ui();
2556        let mut bad = surface("chip", "status-rail");
2557        bad.requires.capabilities = vec!["   ".to_string()];
2558        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2559        assert!(manifest.validate().is_err());
2560    }
2561
2562    #[test]
2563    fn a_stage_requesting_graph_runtime_is_rejected() {
2564        for name in ["graph.runtime.pi_mono.react", "graph.runtime.*", "graph.*"] {
2565            let mut manifest = manifest_with_ui();
2566            manifest
2567                .ui
2568                .as_mut()
2569                .unwrap()
2570                .requires
2571                .capabilities
2572                .push(name.to_string());
2573            let error = manifest.validate().unwrap_err();
2574            assert!(
2575                error.contains(&format!("ui.requires.capabilities must not request '{name}'")),
2576                "{name}: {error}"
2577            );
2578        }
2579    }
2580
2581    #[test]
2582    fn a_surface_requesting_graph_runtime_is_rejected() {
2583        let mut manifest = manifest_with_ui();
2584        let mut chip = surface("chip", "status-rail");
2585        chip.requires
2586            .capabilities
2587            .push("graph.runtime.pi_mono.react".to_string());
2588        manifest.ui.as_mut().unwrap().surfaces = vec![chip];
2589        let error = manifest.validate().unwrap_err();
2590        assert!(
2591            error.contains("ui.surfaces entry 'chip' requires.capabilities must not request 'graph.runtime.pi_mono.react'"),
2592            "{error}"
2593        );
2594    }
2595
2596    #[test]
2597    fn graph_capabilities_outside_the_runtime_are_still_accepted() {
2598        let mut manifest = manifest_with_ui();
2599        manifest
2600            .ui
2601            .as_mut()
2602            .unwrap()
2603            .requires
2604            .capabilities
2605            .push("graph.engine.flows.list".to_string());
2606        assert!(manifest.validate().is_ok());
2607    }
2608
2609    // Exercise the public validator at both UI boundaries; a capabilities-only
2610    // check must not leave the query/stream delegation paths open.
2611    fn manifest_with_ui_requirement(
2612        surface_requirement: bool,
2613        field: &str,
2614        name: &str,
2615    ) -> AppManifest {
2616        let mut manifest = manifest_with_ui();
2617        let ui = manifest.ui.as_mut().unwrap();
2618        let requires = if surface_requirement {
2619            ui.surfaces.push(surface("chip", "status-rail"));
2620            &mut ui.surfaces[0].requires
2621        } else {
2622            &mut ui.requires
2623        };
2624        match field {
2625            "capabilities" => &mut requires.capabilities,
2626            "queries" => &mut requires.queries,
2627            "streams" => &mut requires.streams,
2628            _ => panic!("unknown requirement field"),
2629        }
2630        .push(name.to_string());
2631        manifest
2632    }
2633
2634    #[test]
2635    fn ui_runtime_scope_fields_reject_exact_names_and_covering_wildcards() {
2636        let mut failures = Vec::new();
2637        for is_surface in [false, true] {
2638            for field in ["capabilities", "queries", "streams"] {
2639                for name in [
2640                    "graph.runtime.pi_mono.react",
2641                    "graph.runtime.pi_mono.tool_result",
2642                    "graph.runtime.pi_mono.card_resolve",
2643                    "graph.runtime.pi_mono.pending",
2644                    "graph.runtime.*",
2645                    "graph.runtime.pi_mono.*",
2646                    "graph.*",
2647                    "*",
2648                    "graph.runtime.",
2649                    "  graph.runtime.pi_mono.react  ",
2650                    "  *  ",
2651                ] {
2652                    let manifest = manifest_with_ui_requirement(is_surface, field, name);
2653                    let label = if is_surface {
2654                        "ui.surfaces entry 'chip' requires"
2655                    } else {
2656                        "ui.requires"
2657                    };
2658                    let expected = format!(
2659                        "{label}.{field} must not request '{}': graph.runtime.* is reachable only through the agent app",
2660                        name.trim()
2661                    );
2662                    if manifest.validate() != Err(expected.clone()) {
2663                        failures.push(format!("{expected}; got {:?}", manifest.validate()));
2664                    }
2665                }
2666            }
2667        }
2668        assert!(failures.is_empty(), "{}", failures.join("\n"));
2669    }
2670
2671    #[test]
2672    fn ui_runtime_boundary_preserves_unrelated_names_and_capability_wildcards() {
2673        for is_surface in [false, true] {
2674            for field in ["capabilities", "queries", "streams"] {
2675                for name in [
2676                    "graph.engine.flows.list",
2677                    "graph.runtime_tools.read",
2678                    "graph.runtimes.read",
2679                    "other.graph.runtime.read",
2680                    "agent.prompt",
2681                    "observability.snapshot",
2682                    "core.diag.snapshot",
2683                    "core.apps.restart",
2684                ] {
2685                    let result = manifest_with_ui_requirement(is_surface, field, name).validate();
2686                    assert!(result.is_ok(), "{is_surface}/{field}/{name}: {result:?}");
2687                }
2688            }
2689            for name in [
2690                "graph.engine.*",
2691                "graph.runtime_tools.*",
2692                "agent.*",
2693                "core.*",
2694            ] {
2695                let result =
2696                    manifest_with_ui_requirement(is_surface, "capabilities", name).validate();
2697                assert!(result.is_ok(), "{is_surface}/{name}: {result:?}");
2698            }
2699        }
2700    }
2701
2702    #[test]
2703    fn ui_runtime_boundary_does_not_relax_requirement_syntax() {
2704        for is_surface in [false, true] {
2705            for field in ["capabilities", "queries", "streams"] {
2706                for name in [
2707                    "",
2708                    "  ",
2709                    "graph*",
2710                    "gra*",
2711                    "graph..read",
2712                    "Graph.read",
2713                    "agent/read",
2714                    "agent.",
2715                ] {
2716                    let error = manifest_with_ui_requirement(is_surface, field, name)
2717                        .validate()
2718                        .unwrap_err();
2719                    assert!(
2720                        !error.contains("reachable only through the agent app"),
2721                        "{error}"
2722                    );
2723                }
2724            }
2725            for field in ["queries", "streams"] {
2726                for name in ["graph.engine.*", "agent.*"] {
2727                    let error = manifest_with_ui_requirement(is_surface, field, name)
2728                        .validate()
2729                        .unwrap_err();
2730                    assert!(error.contains("invalid"), "{error}");
2731                }
2732            }
2733        }
2734        // The runtime diagnostic must not make bare '*' generally valid syntax.
2735        for field in ["capabilities", "queries", "streams"] {
2736            let manifest = manifest_with_ui_requirement(false, field, "*");
2737            assert!(manifest.ui.unwrap().requires.resolved().is_err());
2738        }
2739    }
2740
2741    #[test]
2742    fn ui_runtime_boundary_preserves_backend_requirements() {
2743        for nested in [false, true] {
2744            let mut manifest = manifest_with_ui();
2745            let requires = if nested {
2746                &mut manifest.capabilities.requires
2747            } else {
2748                &mut manifest.requires
2749            };
2750            *requires = vec![
2751                "graph.runtime.pi_mono.react".to_string(),
2752                "graph.runtime.*".to_string(),
2753            ];
2754            assert!(manifest.validate().is_ok());
2755            assert_eq!(
2756                manifest.resolved_requires().unwrap(),
2757                vec!["graph.runtime.pi_mono.react", "graph.runtime.*"]
2758            );
2759        }
2760    }
2761
2762    #[test]
2763    fn v2_missing_abi_is_error() {
2764        let json = r#"{
2765            "manifest_version": 2,
2766            "name": "example",
2767            "version": "1.0.0",
2768            "app_type": "bun"
2769        }"#;
2770        let err = parse_err(json);
2771        assert!(err.contains("abi"), "expected abi error, got: {}", err);
2772    }
2773
2774    // ── Publisher-prefixed name (FR-019) ──────────────────────────────────────
2775
2776    #[test]
2777    fn publisher_prefixed_name_accepted() {
2778        let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2779        assert_eq!(m.name, "alice/weather");
2780    }
2781
2782    #[test]
2783    fn double_slash_name_rejected() {
2784        let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2785        assert!(!err.is_empty());
2786    }
2787
2788    // ── Malformed names ───────────────────────────────────────────────────────
2789
2790    #[test]
2791    fn name_starting_with_digit_rejected() {
2792        let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2793        assert!(!err.is_empty());
2794    }
2795
2796    #[test]
2797    fn name_with_uppercase_rejected() {
2798        let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2799        assert!(!err.is_empty());
2800    }
2801
2802    #[test]
2803    fn empty_name_rejected() {
2804        let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2805        assert!(!err.is_empty());
2806    }
2807
2808    // ── Path-safety (SEC-H3) ─────────────────────────────────────────────────
2809
2810    #[test]
2811    fn path_traversal_double_dot_rejected() {
2812        let json = r#"{
2813            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2814            "app_type": "bun", "abi": "v1",
2815            "entrypoint": "../etc/passwd"
2816        }"#;
2817        let err = parse_err(json);
2818        assert!(err.contains(".."), "expected traversal error, got: {}", err);
2819    }
2820
2821    #[test]
2822    fn path_traversal_encoded_dot_not_decoded() {
2823        // The regex rejects '%' so encoded traversal fails at char check
2824        let json = r#"{
2825            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2826            "app_type": "bun", "abi": "v1",
2827            "entrypoint": "foo/../bar"
2828        }"#;
2829        let err = parse_err(json);
2830        assert!(!err.is_empty(), "should have failed: {}", err);
2831    }
2832
2833    #[test]
2834    fn absolute_path_rejected() {
2835        let json = r#"{
2836            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2837            "app_type": "bun", "abi": "v1",
2838            "entrypoint": "/usr/bin/sh"
2839        }"#;
2840        let err = parse_err(json);
2841        assert!(
2842            err.contains("absolute"),
2843            "expected absolute error, got: {}",
2844            err
2845        );
2846    }
2847
2848    #[test]
2849    fn shell_metachar_in_path_rejected() {
2850        let json = r#"{
2851            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2852            "app_type": "bun", "abi": "v1",
2853            "entrypoint": "dist/index.js;rm -rf /"
2854        }"#;
2855        let err = parse_err(json);
2856        assert!(!err.is_empty());
2857    }
2858
2859    #[test]
2860    fn valid_nested_path_accepted() {
2861        let json = r#"{
2862            "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2863            "app_type": "bun", "abi": "v1",
2864            "entrypoint": "dist/index.js",
2865            "ui_path": "ui/dist"
2866        }"#;
2867        parse_ok(json);
2868    }
2869
2870    // ── Homepage scheme ───────────────────────────────────────────────────────
2871
2872    #[test]
2873    fn homepage_https_accepted() {
2874        let json = r#"{
2875            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2876            "homepage": "https://example.com"
2877        }"#;
2878        parse_ok(json);
2879    }
2880
2881    #[test]
2882    fn homepage_javascript_scheme_rejected() {
2883        let json = r#"{
2884            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2885            "homepage": "javascript:alert(1)"
2886        }"#;
2887        let err = parse_err(json);
2888        assert!(
2889            err.contains("scheme"),
2890            "expected scheme error, got: {}",
2891            err
2892        );
2893    }
2894
2895    #[test]
2896    fn homepage_file_scheme_rejected() {
2897        let json = r#"{
2898            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2899            "homepage": "file:///etc/passwd"
2900        }"#;
2901        let err = parse_err(json);
2902        assert!(!err.is_empty());
2903    }
2904
2905    // ── Effective defaults ────────────────────────────────────────────────────
2906
2907    #[test]
2908    fn effective_entrypoint_native_default() {
2909        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2910        assert_eq!(m.effective_entrypoint(), "app.so");
2911    }
2912
2913    #[test]
2914    fn effective_entrypoint_bun_default() {
2915        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2916        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2917    }
2918
2919    #[test]
2920    fn effective_hot_reload_native_default_is_experimental() {
2921        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2922        assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2923    }
2924
2925    #[test]
2926    fn effective_hot_reload_bun_default_is_supported() {
2927        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2928        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2929    }
2930
2931    #[test]
2932    fn hot_reload_unsupported_explicit() {
2933        let json = r#"{
2934            "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2935            "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2936        }"#;
2937        let m = parse_ok(json);
2938        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2939    }
2940
2941    // ── validate_manifest_path unit tests ─────────────────────────────────────
2942
2943    #[test]
2944    fn validate_path_simple_valid() {
2945        assert!(validate_manifest_path("dist/index.js").is_ok());
2946        assert!(validate_manifest_path("app.so").is_ok());
2947        assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2948        assert!(validate_manifest_path("build_output/main").is_ok());
2949    }
2950
2951    #[test]
2952    fn validate_path_empty_rejected() {
2953        assert!(validate_manifest_path("").is_err());
2954    }
2955
2956    #[test]
2957    fn validate_path_absolute_rejected() {
2958        assert!(validate_manifest_path("/usr/bin/sh").is_err());
2959    }
2960
2961    #[test]
2962    fn validate_path_double_dot_segment_rejected() {
2963        assert!(validate_manifest_path("foo/../bar").is_err());
2964        assert!(validate_manifest_path("../etc/passwd").is_err());
2965    }
2966
2967    #[test]
2968    fn validate_path_leading_dot_rejected() {
2969        assert!(validate_manifest_path(".hidden").is_err());
2970    }
2971
2972    #[test]
2973    fn validate_path_null_byte_rejected() {
2974        // null byte is non-ASCII, rejected by char check
2975        let path = "foo\0bar";
2976        assert!(validate_manifest_path(path).is_err());
2977    }
2978
2979    #[test]
2980    fn standalone_socket_path_development_override_is_explicit_and_pure() {
2981        let path = std::path::Path::new("/tmp/node-app/example.sock");
2982        assert!(validate_standalone_socket_path(path).is_err());
2983        assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2984        assert!(validate_standalone_socket_path_with_policy(
2985            std::path::Path::new("/tmp/node-app/../escape.sock"),
2986            true,
2987        )
2988        .is_err());
2989    }
2990
2991    // ── ManifestCapabilities defaults ─────────────────────────────────────────
2992
2993    #[test]
2994    fn manifest_capabilities_defaults_to_empty() {
2995        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2996        assert!(m.capabilities.requires.is_empty());
2997        assert!(m.capabilities.provides.is_empty());
2998    }
2999
3000    // ── resolved_capability_provides (T28 standalone-registration shaping) ────
3001
3002    #[test]
3003    fn resolved_capability_provides_v1_only() {
3004        let json = r#"{
3005            "name": "example", "version": "1.0.0", "app_type": "bun",
3006            "provides": { "core.example.run": { "description": "Run example job" } }
3007        }"#;
3008        let m = parse_ok(json);
3009        let out = m.resolved_capability_provides();
3010        assert_eq!(out.len(), 1);
3011        assert_eq!(
3012            out.get("core.example.run").unwrap().description,
3013            "Run example job"
3014        );
3015    }
3016
3017    #[test]
3018    fn resolved_capability_provides_v2_names_get_blank_declaration() {
3019        let json = r#"{
3020            "manifest_version": 2, "name": "example", "version": "1.0.0",
3021            "app_type": "bun", "abi": "v1",
3022            "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
3023        }"#;
3024        let m = parse_ok(json);
3025        let out = m.resolved_capability_provides();
3026        assert_eq!(out.len(), 2);
3027        assert_eq!(out.get("core.example.run").unwrap().description, "");
3028        assert!(out.get("core.example.run").unwrap().schema.is_none());
3029        // Only the part before the first ':' is used as the name.
3030        assert!(out.contains_key("core.example.other"));
3031        assert!(!out.contains_key("core.example.other:extra"));
3032    }
3033
3034    #[test]
3035    fn resolved_capability_provides_v1_wins_on_conflict() {
3036        let json = r#"{
3037            "manifest_version": 2, "name": "example", "version": "1.0.0",
3038            "app_type": "bun", "abi": "v1",
3039            "provides": { "core.example.run": { "description": "v1 wins" } },
3040            "capabilities": { "requires": [], "provides": ["core.example.run"] }
3041        }"#;
3042        let m = parse_ok(json);
3043        let out = m.resolved_capability_provides();
3044        assert_eq!(out.len(), 1);
3045        assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
3046    }
3047
3048    #[test]
3049    fn resolved_capability_provides_blank_v2_name_skipped() {
3050        let json = r#"{
3051            "manifest_version": 2, "name": "example", "version": "1.0.0",
3052            "app_type": "bun", "abi": "v1",
3053            "capabilities": { "requires": [], "provides": ["  ", "core.example.run"] }
3054        }"#;
3055        let m = parse_ok(json);
3056        let out = m.resolved_capability_provides();
3057        assert_eq!(out.len(), 1);
3058        assert!(out.contains_key("core.example.run"));
3059    }
3060
3061    #[test]
3062    fn resolved_capability_provides_empty_manifest_yields_empty_map() {
3063        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
3064        assert!(m.resolved_capability_provides().is_empty());
3065    }
3066
3067    // ── ABI version ───────────────────────────────────────────────────────────
3068
3069    #[test]
3070    fn abi_v1_is_supported() {
3071        assert!(AbiVersion::V1.is_supported());
3072    }
3073
3074    // ── AppTier display ───────────────────────────────────────────────────────
3075
3076    #[test]
3077    fn app_tier_display() {
3078        assert_eq!(AppTier::FirstParty.to_string(), "first_party");
3079        assert_eq!(AppTier::Optional.to_string(), "optional");
3080        assert_eq!(AppTier::Development.to_string(), "development");
3081    }
3082
3083    #[test]
3084    fn app_tier_serde_roundtrip() {
3085        // Wire format must stay snake_case for the existing API contract.
3086        for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
3087            let json = serde_json::to_string(&tier).unwrap();
3088            let back: AppTier = serde_json::from_str(&json).unwrap();
3089            assert_eq!(
3090                tier, back,
3091                "roundtrip failed for {:?}: serialized as {}",
3092                tier, json
3093            );
3094        }
3095        assert_eq!(
3096            serde_json::to_string(&AppTier::Development).unwrap(),
3097            "\"development\""
3098        );
3099    }
3100
3101    // ── AppType display ───────────────────────────────────────────────────────
3102
3103    #[test]
3104    fn app_type_display() {
3105        assert_eq!(AppType::Native.to_string(), "native");
3106        assert_eq!(AppType::Bun.to_string(), "bun");
3107        assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
3108    }
3109
3110    #[test]
3111    fn platform_runtime_is_a_supported_packaging_type() {
3112        let manifest: AppManifest = serde_json::from_value(serde_json::json!({
3113            "manifest_version": 2,
3114            "abi": "v1",
3115            "name": "bun-runtime",
3116            "version": "1.0.0",
3117            "app_type": "platform-runtime",
3118            "entrypoint": "bun"
3119        }))
3120        .expect("platform runtime manifest should parse");
3121
3122        assert_eq!(manifest.app_type, AppType::PlatformRuntime);
3123        assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
3124    }
3125
3126    // ── GovernorManifest memory budget ──────────────────────────────────────────
3127
3128    #[test]
3129    fn governor_manifest_memory_budget_defaults_to_none() {
3130        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
3131        assert_eq!(parsed.memory_budget_kb, None);
3132    }
3133
3134    #[test]
3135    fn governor_manifest_parses_declared_memory_budget() {
3136        let parsed: GovernorManifest =
3137            serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
3138        assert_eq!(parsed.memory_budget_kb, Some(40_960));
3139    }
3140
3141    // ── GovernorManifest latency_class (app lease engine §8, Task 1) ────────────
3142
3143    #[test]
3144    fn latency_class_parses_and_defaults_none() {
3145        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
3146        assert_eq!(parsed.latency_class, None);
3147    }
3148
3149    #[test]
3150    fn latency_class_parses_declared_interactive() {
3151        let json = r#"{
3152            "name": "example",
3153            "version": "1.0.0",
3154            "app_type": "bun",
3155            "governor": {"latency_class": "interactive"}
3156        }"#;
3157        let m = parse_ok(json);
3158        let governor = m.governor.expect("governor block should parse");
3159        assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
3160    }
3161
3162    #[test]
3163    fn latency_class_parses_declared_background() {
3164        let parsed: GovernorManifest =
3165            serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
3166        assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
3167    }
3168
3169    #[test]
3170    fn latency_class_invalid_value_is_parse_error() {
3171        let result: Result<GovernorManifest, _> =
3172            serde_json::from_str(r#"{"latency_class": "urgent"}"#);
3173        assert!(result.is_err(), "unknown latency_class value must fail to parse");
3174    }
3175
3176    #[test]
3177    fn latency_class_as_str_and_from_str_roundtrip() {
3178        for class in [LatencyClass::Interactive, LatencyClass::Background] {
3179            let s = class.as_str();
3180            assert_eq!(LatencyClass::from_str(s), Ok(class));
3181        }
3182        assert!(LatencyClass::from_str("urgent").is_err());
3183    }
3184
3185    // ── UI-only stage apps (burger-07, Plans 07a/07b Contracts F5) ───────────
3186
3187    fn ui_only_manifest() -> serde_json::Value {
3188        serde_json::json!({
3189            "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
3190            "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
3191            "ui": {
3192                "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
3193                "nav": { "section": "system", "order": 90 }, "ui_api": 1,
3194                "requires": {
3195                    "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
3196                    "queries": [], "streams": ["app.burger_metrics"]
3197                },
3198                "data": {
3199                    "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
3200                    "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
3201                    "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
3202                },
3203                "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
3204            }
3205        })
3206    }
3207
3208    fn ui_only_with(key: &str, value: serde_json::Value) -> String {
3209        let mut manifest = ui_only_manifest();
3210        manifest[key] = value;
3211        manifest.to_string()
3212    }
3213
3214    #[test]
3215    fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
3216        let m = parse_ok(&ui_only_manifest().to_string());
3217        assert_eq!(m.app_type, AppType::UiOnly);
3218        assert_eq!(m.app_type.as_str(), "ui-only");
3219        assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
3220        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
3221        assert!(m.has_ui);
3222        let ui = m.ui.as_ref().expect("a ui block");
3223        assert_eq!(ui.kind, AppUiKind::Stage);
3224        let data = ui.data.as_ref().expect("the F5 ui.data block validates");
3225        assert_eq!(data.namespace, "burger-runtime");
3226        assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
3227        assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
3228        assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
3229    }
3230
3231    #[test]
3232    fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
3233        let m = parse_ok(&ui_only_manifest().to_string());
3234        let wire = serde_json::to_string(&m).unwrap();
3235        assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
3236        assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
3237    }
3238
3239    #[test]
3240    fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
3241        for (key, value) in [
3242            (
3243                "provides",
3244                serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
3245            ),
3246            (
3247                "capabilities",
3248                serde_json::json!({ "provides": ["burger.runtime.peek"] }),
3249            ),
3250            ("tcp", serde_json::json!({ "preferred_port": 7010 })),
3251            ("resources", serde_json::json!({ "memory_mb": 16 })),
3252            (
3253                "standalone",
3254                serde_json::json!({ "socket_path": "/run/node/x.sock" }),
3255            ),
3256            ("subscribes", serde_json::json!(["system.network.changed"])),
3257            ("depends_on", serde_json::json!(["cron"])),
3258        ] {
3259            let err = parse_err(&ui_only_with(key, value));
3260            assert!(err.contains("ui-only"), "{key}: {err}");
3261        }
3262        let mut explicit_with_entry = ui_only_manifest();
3263        explicit_with_entry["app_type"] = serde_json::json!("ui-only");
3264        explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
3265        assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
3266    }
3267
3268    #[test]
3269    fn a_ui_only_manifest_must_declare_a_ui_block() {
3270        let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
3271        assert!(parse_err(no_ui).contains(r#"kind "stage" or "widget""#));
3272    }
3273
3274    // ── UI-only widgets (ui.widget-ui-only host feature) ─────────────────────
3275
3276    /// A composed widget with no backend: the agent chat widget's shape.
3277    fn ui_only_widget_manifest() -> serde_json::Value {
3278        serde_json::json!({
3279            "manifest_version": 2, "abi": "v1", "name": "agent-chat", "version": "1.0.0",
3280            "has_ui": true, "ui_path": "ui/dist",
3281            "ui": {
3282                "kind": "widget", "entry": "ui/dist/main.js", "title": "Agent chat", "ui_api": 2,
3283                "requires": {
3284                    "capabilities": ["agent.prompt", "agent.conversation.entries"],
3285                    "queries": [], "streams": ["app.agent_session"]
3286                },
3287                "integrity": { "ui/dist/main.js": "a".repeat(64) }
3288            }
3289        })
3290    }
3291
3292    fn ui_only_widget_with(key: &str, value: serde_json::Value) -> String {
3293        let mut manifest = ui_only_widget_manifest();
3294        manifest[key] = value;
3295        manifest.to_string()
3296    }
3297
3298    #[test]
3299    fn a_widget_manifest_without_app_type_or_entrypoint_is_ui_only() {
3300        let m = parse_ok(&ui_only_widget_manifest().to_string());
3301        assert_eq!(m.app_type, AppType::UiOnly);
3302        assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
3303        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
3304        assert!(m.has_ui);
3305        assert_eq!(m.ui.as_ref().expect("a ui block").kind, AppUiKind::Widget);
3306
3307        let mut explicit = ui_only_widget_manifest();
3308        explicit["app_type"] = serde_json::json!("ui-only");
3309        let m = parse_ok(&explicit.to_string());
3310        assert_eq!(m.app_type, AppType::UiOnly);
3311        let wire = serde_json::to_string(&m).unwrap();
3312        assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly, "{wire}");
3313    }
3314
3315    #[test]
3316    fn a_ui_only_widget_refuses_everything_that_implies_a_process() {
3317        for (key, value) in [
3318            (
3319                "provides",
3320                serde_json::json!({ "agent-chat.widget.peek": { "description": "x" } }),
3321            ),
3322            (
3323                "capabilities",
3324                serde_json::json!({ "provides": ["agent-chat.widget.peek"] }),
3325            ),
3326            ("tcp", serde_json::json!({ "preferred_port": 7010 })),
3327            ("resources", serde_json::json!({ "memory_mb": 16 })),
3328            (
3329                "standalone",
3330                serde_json::json!({ "socket_path": "/run/node/x.sock" }),
3331            ),
3332            ("subscribes", serde_json::json!(["system.network.changed"])),
3333            ("depends_on", serde_json::json!(["agent"])),
3334        ] {
3335            let err = parse_err(&ui_only_widget_with(key, value));
3336            assert!(err.contains("ui-only"), "{key}: {err}");
3337        }
3338        let mut explicit_with_entry = ui_only_widget_manifest();
3339        explicit_with_entry["app_type"] = serde_json::json!("ui-only");
3340        explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
3341        assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
3342    }
3343
3344    #[test]
3345    fn a_ui_only_widget_keeps_the_widget_rules() {
3346        let mut with_nav = ui_only_widget_manifest();
3347        with_nav["ui"]["nav"] = serde_json::json!({ "section": "system", "order": 1 });
3348        assert!(parse_err(&with_nav.to_string()).contains("widget ui must omit nav metadata"));
3349
3350        let data = |namespace: &str| {
3351            serde_json::json!({
3352                "namespace": namespace, "offline": "online-only", "sync": "snapshot",
3353                "queries": [], "streams": []
3354            })
3355        };
3356        let mut foreign_data = ui_only_widget_manifest();
3357        foreign_data["ui"]["data"] = data("agent");
3358        assert!(parse_err(&foreign_data.to_string()).contains("must equal the app name"));
3359
3360        let mut own_data = ui_only_widget_manifest();
3361        own_data["ui"]["data"] = data("agent-chat");
3362        assert_eq!(parse_ok(&own_data.to_string()).app_type, AppType::UiOnly);
3363    }
3364
3365    /// A widget that declares a backend keeps its declared type: dropping
3366    /// `app_type` and `entrypoint` is what makes it UI-only, nothing else.
3367    #[test]
3368    fn a_widget_with_a_backend_is_not_ui_only() {
3369        let mut bun = ui_only_widget_manifest();
3370        bun["app_type"] = serde_json::json!("bun");
3371        bun["entrypoint"] = serde_json::json!("dist/index.js");
3372        assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
3373    }
3374
3375    #[test]
3376    fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
3377        assert_eq!(
3378            parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
3379            AppType::Native
3380        );
3381        let mut with_entry = ui_only_manifest();
3382        with_entry["entrypoint"] = serde_json::json!("app.so");
3383        assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
3384        let mut bun = ui_only_manifest();
3385        bun["app_type"] = serde_json::json!("bun");
3386        assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
3387    }
3388
3389    #[test]
3390    fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
3391        assert!(declares_ui_only(&ui_only_manifest()));
3392        assert!(declares_ui_only(
3393            &serde_json::json!({ "app_type": "ui-only" })
3394        ));
3395        assert!(!declares_ui_only(
3396            &serde_json::json!({ "app_type": "bun", "ui": {} })
3397        ));
3398        assert!(!declares_ui_only(
3399            &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
3400        ));
3401        assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
3402        assert!(!declares_ui_only(&serde_json::json!("ui-only")));
3403    }
3404}
3405
3406#[cfg(test)]
3407mod shared_app_data_corpus_tests {
3408    use super::*;
3409
3410    /// The SAME corpus the kernel validator runs
3411    /// (`client/kernel/src/stages/stage-registry-contract.test.js`). Two independent
3412    /// implementations of one contract, with nothing but this comparing them —
3413    /// whichever side drifts fails here.
3414    #[test]
3415    fn shared_app_data_corpus_matches_the_host_validator() {
3416        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
3417        let mut checked = 0;
3418        for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
3419            let path = entry.expect("readable entry").path();
3420            if path.extension().and_then(|e| e.to_str()) != Some("json") {
3421                continue;
3422            }
3423            let fixture: serde_json::Value =
3424                serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
3425                    .expect("valid fixture json");
3426            let name = path
3427                .file_name()
3428                .and_then(|n| n.to_str())
3429                .unwrap_or("?")
3430                .to_string();
3431            // `notes`: the app name the kernel half of this corpus uses, and the
3432            // namespace every corpus fixture declares — a data namespace must
3433            // equal its app name (`validate_app_data_namespace_owner`).
3434            let manifest = serde_json::json!({
3435                "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
3436                "app_type": "bun", "entrypoint": "dist/index.js",
3437                "ui": fixture["ui"],
3438            });
3439            let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
3440            match fixture["expect"].as_str().expect("expect field") {
3441                "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
3442                "reject" => {
3443                    let error = result.expect_err(&format!("{name} should be rejected"));
3444                    let reason = fixture["reason"]
3445                        .as_str()
3446                        .expect("reject fixtures need a reason");
3447                    assert!(
3448                        error.contains(reason),
3449                        "{name}: {error:?} should mention {reason:?}"
3450                    );
3451                }
3452                other => panic!("{name}: unknown expect {other:?}"),
3453            }
3454            checked += 1;
3455        }
3456        // Guards against a silently empty or mis-globbed corpus reporting success.
3457        assert!(checked >= 10, "expected the full corpus, walked {checked}");
3458    }
3459}
3460
3461#[cfg(test)]
3462mod burger_manifest_tests {
3463    use super::*;
3464
3465    #[test]
3466    fn burger_app_type_parses_with_bundle_entrypoint_default() {
3467        let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
3468            .expect("burger manifest parses");
3469        assert_eq!(m.app_type, AppType::Burger);
3470        assert_eq!(m.app_type.as_str(), "burger");
3471        assert_eq!(m.effective_entrypoint(), "dist/index.js");
3472        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
3473        assert!(m.resources.is_none());
3474    }
3475
3476    #[test]
3477    fn resources_memory_mb_is_carried() {
3478        let m = AppManifest::from_json(
3479            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
3480        )
3481        .expect("resources block parses");
3482        assert_eq!(
3483            m.resources,
3484            Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
3485        );
3486    }
3487
3488    #[test]
3489    fn resources_callback_deadline_ms_is_carried() {
3490        let m = AppManifest::from_json(
3491            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
3492        )
3493        .expect("callback deadline parses");
3494        assert_eq!(
3495            m.resources,
3496            Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
3497        );
3498        let max = AppManifest::from_json(
3499            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3500        )
3501        .expect("the maximum is inclusive");
3502        assert_eq!(
3503            max.resources.and_then(|r| r.callback_deadline_ms),
3504            Some(MAX_CALLBACK_DEADLINE_MS)
3505        );
3506    }
3507
3508    #[test]
3509    fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3510        for bad in ["0", "600001"] {
3511            let json = format!(
3512                r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3513            );
3514            let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3515            assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3516        }
3517    }
3518
3519    #[test]
3520    fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3521        for bad in ["-1", "1.5", "\"5000\""] {
3522            let json = format!(
3523                r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3524            );
3525            assert!(
3526                AppManifest::from_json(&json).is_err(),
3527                "callback_deadline_ms={bad} must be rejected"
3528            );
3529        }
3530    }
3531
3532    #[test]
3533    fn resources_memory_mb_zero_is_rejected() {
3534        let err = AppManifest::from_json(
3535            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3536        )
3537        .expect_err("a zero memory limit is invalid");
3538        assert!(err.contains("resources.memory_mb"), "{err}");
3539    }
3540
3541    #[test]
3542    fn burger_manifest_rejects_a_standalone_block() {
3543        let err = AppManifest::from_json(
3544            r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3545        )
3546        .expect_err("standalone block is only valid for standalone apps");
3547        assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3548    }
3549}