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