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