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