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/// Declared responsiveness expectation for an app's lease engine decisions
695/// (app lease engine design §8, Task 1). `None` on [`GovernorManifest`] means
696/// the app has not declared a preference — the lease engine (Task 5) then
697/// falls back to its own default rather than treating an unset field as
698/// either variant.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "lowercase")]
701pub enum LatencyClass {
702    /// The app serves latency-sensitive, user-facing requests — the lease
703    /// engine should prefer to keep it warm.
704    Interactive,
705    /// The app only does deferred/background work — the lease engine may
706    /// treat it as a lower priority to keep resident.
707    Background,
708}
709
710impl LatencyClass {
711    pub fn as_str(&self) -> &'static str {
712        match self {
713            LatencyClass::Interactive => "interactive",
714            LatencyClass::Background => "background",
715        }
716    }
717
718    #[allow(clippy::should_implement_trait)]
719    pub fn from_str(s: &str) -> Result<Self, String> {
720        match s {
721            "interactive" => Ok(LatencyClass::Interactive),
722            "background" => Ok(LatencyClass::Background),
723            _ => Err(format!("Invalid LatencyClass: {}", s)),
724        }
725    }
726}
727
728impl std::fmt::Display for LatencyClass {
729    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
730        write!(f, "{}", self.as_str())
731    }
732}
733
734/// Idle-termination policy for a lazily-started app (issue #811 SP1 — the
735/// app governor). Nested under `AppManifest::governor`.
736#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
737pub struct GovernorManifest {
738    /// Explicit opt-out. `Some(false)` exempts the app from idle termination
739    /// regardless of any other eligibility rule. `None`/`Some(true)` defers
740    /// to the other eligibility rules.
741    #[serde(default)]
742    pub terminable: Option<bool>,
743
744    /// Minimum idle duration, in seconds, before the governor may terminate
745    /// this app — overrides the governor's default sweep threshold. `None`
746    /// defers to the default.
747    #[serde(default)]
748    pub min_idle_secs: Option<u64>,
749
750    /// Memory budget in KB. When the app's measured footprint — on the basis
751    /// selected by its measurement attribution, see
752    /// `node_app_host::app_memory::budget` — exceeds this, the owner is warned
753    /// in the shell.
754    ///
755    /// # What `None` defers to
756    ///
757    /// NOT one number. The default is chosen PER BASIS
758    /// (`node_app_host::app_memory::budget::default_budget_kb`), because the
759    /// bases are not comparable quantities:
760    ///
761    /// | basis                | default   | why                                     |
762    /// |----------------------|-----------|-----------------------------------------|
763    /// | `heap_used`, `pss`   | 10,240 KB | the app and nothing else                |
764    /// | `rss`                | 61,440 KB | the whole OS process, runtime included  |
765    /// | `not_attributable`   | 10,240 KB | never `over`; carried only for the wire |
766    ///
767    /// A shared-runtime Bun worker is compared on `heap_used`; a dedicated
768    /// process or cgroup-scoped standalone on `rss`, which charges it for a
769    /// JavaScript engine it did not choose and cannot shed.
770    ///
771    /// On top of that, a host-side runtime-critical entry
772    /// (`RUNTIME_CRITICAL_BUDGETS`) acts as a FLOOR, never a ceiling: it can
773    /// only raise an app above the per-basis default, never pull it below one.
774    ///
775    /// A value declared HERE is the one thing that overrides both, in either
776    /// direction — it is a deliberate choice by the app author, not a fallback,
777    /// so it is honoured unchanged even when it is lower than the default.
778    ///
779    /// Apps that legitimately need more than their basis default MUST declare a
780    /// realistic budget here; otherwise the warning is permanently lit and
781    /// stops meaning anything.
782    #[serde(default)]
783    pub memory_budget_kb: Option<u64>,
784
785    /// Declared responsiveness expectation (app lease engine design §8,
786    /// Task 1). `None` when the app declares no preference — see
787    /// [`LatencyClass`] for what each variant means and what `None` defers
788    /// to.
789    #[serde(default)]
790    pub latency_class: Option<LatencyClass>,
791}
792
793/// TCP port preferences for standalone apps that bind their own port.
794/// Consumed by the port registry (`system/server/src/services/port_registry/`)
795/// at install time.
796#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
797pub struct TcpManifest {
798    /// The TCP port the app would like to bind. Honored when free;
799    /// otherwise the registry assigns the next free port from the pool
800    /// (default 7000–7099). Absent → registry picks any free pool slot.
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub preferred_port: Option<u16>,
803
804    /// When `true`, the platform UI shell builds iframe URLs as direct LAN
805    /// connections to the assigned port rather than routing via the
806    /// `/api/v2/node-apps/{name}/ui/` reverse-proxy. Intended only for apps
807    /// that must outlive a platform restart (e.g. OTA self-upgrade). Remote
808    /// users may see a degraded experience — owned by the consuming app's UI,
809    /// not this spec (see `specs/470-port-registry/spec.md` Clarifications Q5b).
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub direct_bind: Option<bool>,
812}
813
814fn default_manifest_version() -> u8 {
815    1
816}
817
818fn default_auto_start() -> bool {
819    true
820}
821
822/// Deserialize `auto_start` from either a bool (manifest v1) or a load-mode
823/// string (v2, e.g. `"lazy"`/`"eager"`/`"active"`). Eager-start modes map to
824/// `true`; `"lazy"` and other on-demand/inactive states map to `false` (the app
825/// is started on first capability use, not at boot). This keeps both manifest
826/// schema generations parseable by `AppManifest::from_json`.
827fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
828where
829    D: serde::Deserializer<'de>,
830{
831    #[derive(Deserialize)]
832    #[serde(untagged)]
833    enum BoolOrStr {
834        Bool(bool),
835        Str(String),
836    }
837    Ok(match BoolOrStr::deserialize(deserializer)? {
838        BoolOrStr::Bool(b) => b,
839        BoolOrStr::Str(s) => matches!(
840            s.trim().to_ascii_lowercase().as_str(),
841            "true" | "eager" | "active" | "auto" | "on" | "1"
842        ),
843    })
844}
845
846fn default_ui_path() -> String {
847    "dist".to_string()
848}
849
850fn default_app_type_native() -> AppType {
851    AppType::Native
852}
853
854impl AppManifest {
855    /// Resolve top-level `requires` and `capabilities.requires` into one
856    /// canonical declaration list. Equal aliases are accepted regardless of
857    /// order or duplicates; differing aliases are rejected.
858    pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
859        let top = normalized_requirements(&self.requires)?;
860        let nested = normalized_requirements(&self.capabilities.requires)?;
861        if !top.is_empty()
862            && !nested.is_empty()
863            && top.iter().cloned().collect::<BTreeSet<_>>()
864                != nested.iter().cloned().collect::<BTreeSet<_>>()
865        {
866            return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
867        }
868        Ok(if !top.is_empty() { top } else { nested })
869    }
870
871    /// Returns the effective `HotReloadKind` — explicit field or the default
872    /// for the app type.
873    pub fn effective_hot_reload(&self) -> HotReloadKind {
874        self.hot_reload
875            .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
876    }
877
878    /// Returns the effective entrypoint — explicit field or the type-specific default.
879    ///
880    /// Standalone apps have no daemon-managed entrypoint (systemd owns the
881    /// lifecycle); the empty string signals "not applicable".
882    pub fn effective_entrypoint(&self) -> &str {
883        if let Some(ref ep) = self.entrypoint {
884            ep.as_str()
885        } else {
886            match self.app_type {
887                AppType::Native => "app.so",
888                AppType::Bun => "dist/index.js",
889                AppType::Standalone => "",
890                AppType::PlatformRuntime => "bun",
891                AppType::ManagedV1 => "llmc-generated-app",
892            }
893        }
894    }
895
896    /// True iff this manifest declares at least one capability provider
897    /// (via either the v1 `provides` map or the v2 `capabilities.provides` list).
898    pub fn has_capability_providers(&self) -> bool {
899        !self.provides.is_empty() || !self.capabilities.provides.is_empty()
900    }
901
902    /// Merges the v1 `provides` map and the v2 `capabilities.provides` name
903    /// list into a single capability→declaration map (composition-root
904    /// cleanup Round 4 T28 — extracted from
905    /// `control_ipc::handlers::handle_app_register_standalone`, which uses
906    /// this to shape a standalone app's declared providers for capability
907    /// registration).
908    ///
909    /// - v1 entries (the `provides` map) carry their real
910    ///   description/schema and always win on a name conflict.
911    /// - v2-only names (declared only via `capabilities.provides`, format
912    ///   `"name"` or `"name:extra"` — only the part before the first `:` is
913    ///   used) get a blank declaration, inserted only if the name is not
914    ///   already present from v1. Blank/whitespace-only names are skipped.
915    pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
916        let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
917        for raw in &self.capabilities.provides {
918            let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
919            if name.is_empty() {
920                continue;
921            }
922            out.entry(name).or_insert(ProvidedCapability {
923                description: String::new(),
924                schema: None,
925            });
926        }
927        out
928    }
929
930    /// Validate the manifest for structural correctness.
931    ///
932    /// Returns `Ok(())` on success, or a human-readable error string.
933    /// Called by the manifest parser after deserialization.
934    pub fn validate(&self) -> Result<(), String> {
935        self.validate_with_socket_path_policy(false)
936    }
937
938    /// Validate this manifest with an explicit standalone socket-path policy.
939    ///
940    /// Runtime adapters may opt into non-`/run` paths for development without
941    /// making the domain model read process configuration.
942    pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
943        // v2 requires abi field
944        if self.manifest_version == 2 && self.abi.is_none() {
945            return Err("manifest_version 2 requires an 'abi' field".to_string());
946        }
947
948        // Name validation: ^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$
949        // (publisher/name form accepted but not yet semantically used — FR-019)
950        validate_app_name(&self.name)?;
951        self.resolved_requires()?;
952
953        // Path-safety on entrypoint and ui_path
954        if let Some(ref ep) = self.entrypoint {
955            validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
956        }
957        // ui_path is only meaningful when has_ui is true, but validate always
958        if !self.ui_path.is_empty() && self.ui_path != "dist" {
959            validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
960        }
961
962        if let Some(ui) = &self.ui {
963            validate_app_ui(&self.name, ui)?;
964        }
965
966        // Homepage scheme validation (if present)
967        if let Some(ref hp) = self.homepage {
968            if !hp.starts_with("https://") && !hp.starts_with("http://") {
969                return Err(format!(
970                    "homepage '{}' must use https:// or http:// scheme",
971                    hp
972                ));
973            }
974        }
975
976        // Standalone-app rules:
977        // - When `app_type == "standalone"` AND the manifest declares any
978        //   capability providers, `standalone.socket_path` is required and
979        //   must be an absolute path under `/run/` with no `..` segments.
980        // - Non-standalone manifests MUST NOT carry a `standalone` block
981        //   (rejected to surface accidental schema misuse).
982        match self.app_type {
983            AppType::Standalone => {
984                if self.has_capability_providers() {
985                    let cfg = self.standalone.as_ref().ok_or_else(|| {
986                        "standalone apps that declare 'provides' require a \
987                         'standalone.socket_path' field"
988                            .to_string()
989                    })?;
990                    validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
991                }
992            }
993            AppType::Native | AppType::Bun | AppType::PlatformRuntime | AppType::ManagedV1 => {
994                if self.standalone.is_some() {
995                    return Err(format!(
996                        "'standalone' block is only valid when app_type == 'standalone' \
997                         (found app_type='{}')",
998                        self.app_type
999                    ));
1000                }
1001            }
1002        }
1003
1004        if self.app_type == AppType::PlatformRuntime
1005            && !self.resolved_capability_provides().is_empty()
1006        {
1007            return Err(
1008                "platform-runtime packages cannot provide runtime capabilities".to_string(),
1009            );
1010        }
1011
1012        Ok(())
1013    }
1014
1015    /// Parse from a JSON string, validate, and return the manifest.
1016    pub fn from_json(json: &str) -> Result<Self, String> {
1017        Self::from_json_with_socket_path_policy(json, false)
1018    }
1019
1020    /// Parse and validate with an explicit standalone socket-path policy.
1021    pub fn from_json_with_socket_path_policy(
1022        json: &str,
1023        allow_non_run: bool,
1024    ) -> Result<Self, String> {
1025        let mut manifest: Self =
1026            serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1027        if manifest.ui.is_some() {
1028            manifest.has_ui = true;
1029        }
1030        manifest.validate_with_socket_path_policy(allow_non_run)?;
1031        Ok(manifest)
1032    }
1033}
1034
1035fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1036    let mut seen = HashSet::new();
1037    let mut resolved = Vec::new();
1038    for value in values {
1039        let requirement = value.trim();
1040        if requirement.is_empty() {
1041            return Err("capability requirements must not be blank".to_string());
1042        }
1043        if seen.insert(requirement.to_string()) {
1044            resolved.push(requirement.to_string());
1045        }
1046    }
1047    Ok(resolved)
1048}
1049
1050fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1051    if ui.ui_api != 1 && ui.ui_api != 2 {
1052        return Err(format!(
1053            "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1054            ui.ui_api
1055        ));
1056    }
1057    if ui.title.trim().is_empty() {
1058        return Err("ui.title must not be blank".to_string());
1059    }
1060    ui.requires.resolved()?;
1061    validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1062    if let Some(icon) = &ui.icon {
1063        validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1064    }
1065    if let Some(nav) = &ui.nav {
1066        if ui.kind == AppUiKind::Widget {
1067            return Err("widget ui must omit nav metadata".to_string());
1068        }
1069        if nav.section.trim().is_empty() {
1070            return Err("ui.nav.section must not be blank".to_string());
1071        }
1072    }
1073    if let Some(data) = &ui.data {
1074        if ui.kind != AppUiKind::Stage {
1075            return Err("widget ui must omit app data declarations".to_string());
1076        }
1077        validate_app_data(data, &ui.requires.resolved()?)?;
1078    }
1079
1080    let mut composed = HashSet::new();
1081    for name in &ui.composes {
1082        validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1083        if name == app_name {
1084            return Err("ui.composes must not contain the app itself".to_string());
1085        }
1086        if !composed.insert(name) {
1087            return Err(format!("ui.composes contains duplicate app '{name}'"));
1088        }
1089    }
1090
1091    let mut surface_ids = HashSet::new();
1092    for surface in &ui.surfaces {
1093        let id = surface.id.trim();
1094        if id.is_empty() {
1095            return Err("ui.surfaces entry id must not be blank".to_string());
1096        }
1097        if !surface_ids.insert(id.to_string()) {
1098            return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1099        }
1100        if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1101            return Err(format!(
1102                "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1103                surface.slot,
1104                KNOWN_SURFACE_SLOTS.join(", ")
1105            ));
1106        }
1107        if surface.title.trim().is_empty() {
1108            return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1109        }
1110        validate_manifest_path(&surface.entry)
1111            .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1112        // Same rule `ui.entry` and `ui.icon` get below, and for a sharper reason: the client
1113        // kernel's `ensureIntegrityForUi` (`client/kernel/src/stages/stage-registry-service.js`)
1114        // REQUIRES a digest for every surface entry, and the throw there propagates out of
1115        // `parseCatalogEntry` through `parseCatalogResponse`'s `value.map(...)` — failing the
1116        // whole catalog snapshot, every stage on the node, and looping on retry. Without this
1117        // check a typo, or an entry emitted outside `ui_path` (which is the only tree
1118        // `generate_staged_integrity` stamps), packages cleanly, installs cleanly, and then
1119        // bricks every client's stage list. Refuse it here, where the author can still fix it.
1120        if !ui.integrity.contains_key(&surface.entry) {
1121            return Err(format!("ui.integrity must include surface '{id}' entry"));
1122        }
1123        surface
1124            .requires
1125            .resolved()
1126            .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1127    }
1128
1129    for (path, digest) in &ui.integrity {
1130        validate_manifest_path(path)
1131            .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1132        if !is_lowercase_sha256(digest) {
1133            return Err(format!(
1134                "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1135            ));
1136        }
1137    }
1138    if !ui.integrity.contains_key(&ui.entry) {
1139        return Err("ui.integrity must include the declared entry".to_string());
1140    }
1141    if let Some(icon) = &ui.icon {
1142        if !ui.integrity.contains_key(icon) {
1143            return Err("ui.integrity must include the declared icon".to_string());
1144        }
1145    }
1146    Ok(())
1147}
1148
1149fn validate_app_data(data: &AppDataManifest, resolved_requires: &[String]) -> Result<(), String> {
1150    validate_app_data_namespace(&data.namespace)?;
1151    validate_app_data_sync_policy(&data.sync)?;
1152    if data.queries.is_empty() {
1153        return Err("ui.data.queries must declare at least one query".to_string());
1154    }
1155
1156    let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1157    let mut names = BTreeSet::new();
1158    for query in &data.queries {
1159        validate_namespaced_data_name(&query.name, &data.namespace)
1160            .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1161        validate_capability_name(&query.capability).map_err(|error| {
1162            format!(
1163                "ui.data query '{}' capability '{}' invalid: {error}",
1164                query.name, query.capability
1165            )
1166        })?;
1167        if !requires.contains(query.capability.as_str()) {
1168            return Err(format!(
1169                "ui.data query '{}' capability '{}' must be declared in requires",
1170                query.name, query.capability
1171            ));
1172        }
1173        if !names.insert(query.name.as_str()) {
1174            return Err(format!("ui.data contains duplicate query '{}'", query.name));
1175        }
1176    }
1177
1178    for stream in &data.streams {
1179        validate_namespaced_data_name(&stream.name, &data.namespace)
1180            .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1181        if !names.insert(stream.name.as_str()) {
1182            return Err(format!(
1183                "ui.data contains duplicate declaration '{}'",
1184                stream.name
1185            ));
1186        }
1187    }
1188
1189    Ok(())
1190}
1191
1192fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1193    if !is_safe_name_segment(namespace) {
1194        return Err(format!(
1195            "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1196            namespace
1197        ));
1198    }
1199    if matches!(
1200        namespace,
1201        "core" | "internal" | "node" | "platform" | "system"
1202    ) {
1203        return Err(format!("ui.data namespace '{namespace}' is reserved"));
1204    }
1205    Ok(())
1206}
1207
1208fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1209    validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1210    validate_optional_range(
1211        "full_refresh_interval_secs",
1212        sync.full_refresh_interval_secs,
1213        60,
1214        604_800,
1215    )?;
1216    validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1217    if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1218        return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1219    }
1220    Ok(())
1221}
1222
1223fn validate_optional_range(
1224    field: &str,
1225    value: Option<u32>,
1226    min: u32,
1227    max: u32,
1228) -> Result<(), String> {
1229    if let Some(value) = value {
1230        if value < min || value > max {
1231            return Err(format!(
1232                "ui.data.sync {field} must be between {min} and {max} seconds"
1233            ));
1234        }
1235    }
1236    Ok(())
1237}
1238
1239fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1240    validate_capability_name(name)?;
1241    let Some(rest) = name
1242        .strip_prefix(namespace)
1243        .and_then(|suffix| suffix.strip_prefix('.'))
1244    else {
1245        return Err(format!("name must use namespace '{namespace}'"));
1246    };
1247    if rest.is_empty() {
1248        return Err("name must include a value after its namespace".to_string());
1249    }
1250    if !has_version_suffix(name) {
1251        return Err("name must end with a .vN version suffix".to_string());
1252    }
1253    Ok(())
1254}
1255
1256fn validate_capability_name(name: &str) -> Result<(), String> {
1257    if name.is_empty() {
1258        return Err("name must not be empty".to_string());
1259    }
1260    if name.contains('/') || name.contains("..") {
1261        return Err("name must not contain path separators or traversal".to_string());
1262    }
1263    if !name.split('.').all(is_safe_declaration_segment) {
1264        return Err("name must contain only lowercase dot-separated segments".to_string());
1265    }
1266    Ok(())
1267}
1268
1269fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1270    if allow_wildcard && name.ends_with(".*") {
1271        return validate_capability_name(&name[..name.len() - 2]);
1272    }
1273    validate_capability_name(name)
1274}
1275
1276fn has_version_suffix(name: &str) -> bool {
1277    let Some(version) = name.rsplit('.').next() else {
1278        return false;
1279    };
1280    let Some(digits) = version.strip_prefix('v') else {
1281        return false;
1282    };
1283    !digits.is_empty()
1284        && !digits.starts_with('0')
1285        && digits.bytes().all(|byte| byte.is_ascii_digit())
1286}
1287
1288fn is_safe_name_segment(segment: &str) -> bool {
1289    if segment.is_empty() {
1290        return false;
1291    }
1292    let mut chars = segment.chars();
1293    let Some(first) = chars.next() else {
1294        return false;
1295    };
1296    first.is_ascii_lowercase()
1297        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1298}
1299
1300/// A segment of a capability, query, or stream name.
1301///
1302/// Deliberately looser than [`is_safe_name_segment`] by exactly one character:
1303/// `_`. Capability actions in this codebase are snake_case almost without
1304/// exception (`core.lightning.create_invoice`, `core.did.current_did`,
1305/// `contest.world.studio_state`), and app-event resources are too
1306/// (`app.agent_session` — `APP_EVENT_RESOURCE_PATTERN` in `@econ-v1/domain`
1307/// admits `_` for precisely these). Rejecting `_` here did not make a stage
1308/// safer, it made `ui.requires` unusable: a stage that declared any real
1309/// capability failed `resolved()`, and `build_ui_stage_catalog` then dropped
1310/// that stage from the shell entirely. The characters that actually matter —
1311/// path separators, traversal, uppercase, leading digits — are still refused.
1312fn is_safe_declaration_segment(segment: &str) -> bool {
1313    let mut chars = segment.chars();
1314    let Some(first) = chars.next() else {
1315        return false;
1316    };
1317    first.is_ascii_lowercase()
1318        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1319}
1320
1321fn is_lowercase_sha256(value: &str) -> bool {
1322    value.len() == 64
1323        && value
1324            .bytes()
1325            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1326}
1327
1328/// Validate a `StandaloneConfig::socket_path`.
1329///
1330/// Rules:
1331/// 1. Absolute path (starts with `/`).
1332/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc. — pins the
1333///    socket to a tmpfs path predictably writable by the standalone daemon).
1334///    Runtime adapters can explicitly bypass this restriction for development.
1335/// 3. No `..` segments anywhere in the path.
1336pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1337    validate_standalone_socket_path_with_policy(path, false)
1338}
1339
1340/// Validate a standalone socket path with an explicit runtime policy.
1341pub fn validate_standalone_socket_path_with_policy(
1342    path: &std::path::Path,
1343    allow_non_run: bool,
1344) -> Result<(), String> {
1345    if !path.is_absolute() {
1346        return Err(format!(
1347            "standalone.socket_path '{}' must be absolute",
1348            path.display()
1349        ));
1350    }
1351    if !allow_non_run && !path.starts_with("/run/") {
1352        return Err(format!(
1353            "standalone.socket_path '{}' must live under /run/",
1354            path.display()
1355        ));
1356    }
1357    if path
1358        .components()
1359        .any(|c| matches!(c, std::path::Component::ParentDir))
1360    {
1361        return Err(format!(
1362            "standalone.socket_path '{}' must not contain '..' segments",
1363            path.display()
1364        ));
1365    }
1366    Ok(())
1367}
1368
1369/// Validate an app name string.
1370///
1371/// Accepts `app-name` (simple) and `publisher/app-name` (publisher-prefixed, FR-019).
1372fn validate_app_name(name: &str) -> Result<(), String> {
1373    let (publisher, app) = if let Some(slash) = name.find('/') {
1374        let (p, rest) = name.split_at(slash);
1375        (Some(p), &rest[1..])
1376    } else {
1377        (None, name)
1378    };
1379
1380    let valid_segment = |s: &str| -> bool {
1381        if s.is_empty() {
1382            return false;
1383        }
1384        let mut chars = s.chars();
1385        let first = chars.next().unwrap();
1386        if !first.is_ascii_lowercase() {
1387            return false;
1388        }
1389        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1390    };
1391
1392    if let Some(pub_name) = publisher {
1393        if !valid_segment(pub_name) {
1394            return Err(format!(
1395                "publisher segment '{}' must match [a-z][a-z0-9-]*",
1396                pub_name
1397            ));
1398        }
1399    }
1400
1401    if !valid_segment(app) {
1402        return Err(format!(
1403            "app name segment '{}' must match [a-z][a-z0-9-]*",
1404            app
1405        ));
1406    }
1407
1408    Ok(())
1409}
1410
1411// ── Tests ─────────────────────────────────────────────────────────────────────
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416
1417    fn parse_ok(json: &str) -> AppManifest {
1418        AppManifest::from_json(json).expect("should parse")
1419    }
1420
1421    fn parse_err(json: &str) -> String {
1422        AppManifest::from_json(json).expect_err("should fail")
1423    }
1424
1425    // ── v1 manifests ──────────────────────────────────────────────────────────
1426
1427    #[test]
1428    fn v1_minimal_native() {
1429        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1430        assert_eq!(m.manifest_version, 1);
1431        assert_eq!(m.app_type, AppType::Native);
1432        assert!(m.abi.is_none());
1433    }
1434
1435    #[test]
1436    fn v1_minimal_bun() {
1437        let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1438        assert_eq!(m.app_type, AppType::Bun);
1439        assert_eq!(m.effective_entrypoint(), "dist/index.js");
1440    }
1441
1442    #[test]
1443    fn v1_no_manifest_version_field_defaults_to_1() {
1444        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1445        assert_eq!(m.manifest_version, 1);
1446    }
1447
1448    #[test]
1449    fn v1_all_optional_fields_missing() {
1450        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1451        assert!(!m.critical);
1452        assert!(m.auto_start);
1453        assert!(!m.has_ui);
1454        assert_eq!(m.ui_path, "dist");
1455        assert!(m.permissions.is_empty());
1456        assert!(m.optional_permissions.is_empty());
1457        // #1556: governor/subscribes absent → today's implicit behavior
1458        // (no opt-out, no min-idle override, no declared subscriptions).
1459        assert!(m.governor.is_none());
1460        assert!(m.subscribes.is_empty());
1461    }
1462
1463    #[test]
1464    fn v1_with_permissions_and_provides() {
1465        let json = r#"{
1466            "name": "example",
1467            "version": "1.0.0",
1468            "app_type": "bun",
1469            "permissions": ["core.storage.kv"],
1470            "optional_permissions": ["core.notifications.create"],
1471            "provides": {
1472                "core.example.run": { "description": "Run example job" }
1473            }
1474        }"#;
1475        let m = parse_ok(json);
1476        assert_eq!(m.permissions, vec!["core.storage.kv"]);
1477        assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1478        assert!(m.provides.contains_key("core.example.run"));
1479    }
1480
1481    // ── v2 manifests ──────────────────────────────────────────────────────────
1482
1483    #[test]
1484    fn v2_minimal_native() {
1485        let json = r#"{
1486            "manifest_version": 2,
1487            "name": "cron",
1488            "version": "1.0.0",
1489            "app_type": "native",
1490            "abi": "v1",
1491            "entrypoint": "app.so",
1492            "hot_reload": "experimental"
1493        }"#;
1494        let m = parse_ok(json);
1495        assert_eq!(m.manifest_version, 2);
1496        assert_eq!(m.abi, Some(AbiVersion::V1));
1497        assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1498        assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1499    }
1500
1501    #[test]
1502    fn v2_minimal_bun_with_capabilities() {
1503        let json = r#"{
1504            "manifest_version": 2,
1505            "name": "example-fullstack",
1506            "version": "1.0.0",
1507            "app_type": "bun",
1508            "abi": "v1",
1509            "entrypoint": "dist/index.js",
1510            "hot_reload": "supported",
1511            "has_ui": true,
1512            "ui_path": "ui/dist",
1513            "capabilities": {
1514                "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1515                "provides": []
1516            },
1517            "governor": { "terminable": false, "min_idle_secs": 300 },
1518            "subscribes": ["core.chat.message.received"]
1519        }"#;
1520        let m = parse_ok(json);
1521        assert_eq!(m.manifest_version, 2);
1522        assert_eq!(m.capabilities.requires.len(), 2);
1523        // #1556: governor/subscribes present → parsed through verbatim.
1524        let governor = m.governor.expect("governor block should parse");
1525        assert_eq!(governor.terminable, Some(false));
1526        assert_eq!(governor.min_idle_secs, Some(300));
1527        assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
1528    }
1529
1530    #[test]
1531    fn stage_contract_fixture_parses_with_normalized_requirements() {
1532        let json = include_str!(
1533            "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
1534        );
1535        let m = parse_ok(json);
1536        assert!(m.has_ui);
1537        assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
1538        let ui = m.ui.expect("fixture should declare ui");
1539        assert_eq!(ui.kind, AppUiKind::Stage);
1540        assert_eq!(ui.entry, "ui/main.js");
1541        assert_eq!(ui.nav.unwrap().order, 10);
1542    }
1543
1544    #[test]
1545    fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
1546        let m = parse_ok(
1547            r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
1548        );
1549        assert!(m.has_ui);
1550        assert_eq!(m.ui_path, "ui/dist");
1551        assert!(m.ui.is_none());
1552    }
1553
1554    #[test]
1555    fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
1556        let manifest = parse_ok(
1557            r#"{
1558                "name":"ui-contract","version":"1.0.0","app_type":"bun",
1559                "requires":["core.cron.register"],
1560                "ui":{
1561                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1562                    "requires":{
1563                        "capabilities":["ui.snapshot.v1"],
1564                        "queries":["ui.query.v1"],
1565                        "streams":["ui.event.v1"]
1566                    },
1567                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1568                }
1569            }"#,
1570        );
1571
1572        assert_eq!(
1573            manifest.resolved_requires().unwrap(),
1574            vec!["core.cron.register"]
1575        );
1576        let ui = manifest.ui.as_ref().expect("ui requirements should parse");
1577        assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
1578        assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
1579        assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
1580        assert_eq!(
1581            ui.requires.resolved().unwrap(),
1582            vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
1583        );
1584        let serialized = serde_json::to_value(ui).unwrap();
1585        assert_eq!(
1586            serialized["requires"]["queries"],
1587            serde_json::json!(["ui.query.v1"])
1588        );
1589    }
1590
1591    #[test]
1592    fn ui_requirements_reject_blank_entries() {
1593        let error = parse_err(
1594            r#"{
1595                "name":"ui-contract","version":"1.0.0","app_type":"bun",
1596                "ui":{
1597                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
1598                    "requires":{"capabilities":[""],"queries":[],"streams":[]},
1599                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
1600                }
1601            }"#,
1602        );
1603        assert!(error.contains("ui.requires entries must not be blank"));
1604    }
1605
1606    #[test]
1607    fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
1608        // `is_safe_declaration_segment` deliberately admits `_` so `ui.requires`
1609        // can name real capabilities. `ui.data.namespace` is a different thing —
1610        // a storage key, contract `[a-z][a-z0-9-]*` — and keeps the stricter
1611        // `is_safe_name_segment`. Nothing else pins that separation, so a future
1612        // refactor collapsing the two predicates back together would silently
1613        // widen the namespace rule. This is the tripwire for that.
1614        assert!(validate_app_data_namespace("obs-viewer").is_ok());
1615
1616        let error = validate_app_data_namespace("obs_viewer")
1617            .expect_err("underscore must not be admitted into a storage namespace");
1618        assert!(
1619            error.contains("must match [a-z][a-z0-9-]*"),
1620            "unexpected error: {error}"
1621        );
1622    }
1623
1624    #[test]
1625    fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
1626        let error = parse_err(
1627            r#"{
1628                "name":"ui-data","version":"1.0.0","app_type":"bun",
1629                "requires":["ui.snapshot.v1"],
1630                "ui":{
1631                    "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
1632                    "requires":{"capabilities":[],"queries":[],"streams":[]},
1633                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
1634                    "data":{
1635                        "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
1636                        "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
1637                        "streams":[]
1638                    }
1639                }
1640            }"#,
1641        );
1642        assert!(error.contains("must be declared in requires"));
1643    }
1644
1645    #[test]
1646    fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
1647        let accepted = parse_ok(
1648            r#"{
1649                "name":"aliases","version":"1.0.0","app_type":"bun",
1650                "requires":["core.chat.read","core.chat.read","core.chat.send"],
1651                "capabilities":{"requires":["core.chat.send","core.chat.read"]}
1652            }"#,
1653        );
1654        assert_eq!(
1655            accepted.resolved_requires().unwrap(),
1656            vec!["core.chat.read", "core.chat.send"]
1657        );
1658
1659        let err = parse_err(
1660            r#"{
1661                "name":"aliases","version":"1.0.0","app_type":"bun",
1662                "requires":["core.chat.read"],
1663                "capabilities":{"requires":["core.wallet.pay"]}
1664            }"#,
1665        );
1666        assert!(err.contains("conflicts"), "unexpected error: {err}");
1667    }
1668
1669    #[test]
1670    fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
1671        let base = |ui: &str| {
1672            format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
1673        };
1674        let widget = base(
1675            r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1676        );
1677        assert_eq!(
1678            parse_ok(&widget).ui.expect("widget ui").kind,
1679            AppUiKind::Widget
1680        );
1681        let widget_nav = base(
1682            r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1683        );
1684        assert!(parse_err(&widget_nav).contains("must omit nav"));
1685        let api = base(
1686            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1687        );
1688        assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
1689        let unsupported_api = base(
1690            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1691        );
1692        assert!(parse_err(&unsupported_api).contains("ui_api"));
1693        let path = base(
1694            r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
1695        );
1696        assert!(parse_err(&path).contains("entry"));
1697    }
1698
1699    #[test]
1700    fn stage_requires_integrity_for_entry_and_icon() {
1701        let missing_entry = r#"{
1702            "name":"stage","version":"1.0.0","app_type":"bun",
1703            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
1704        }"#;
1705        assert!(parse_err(missing_entry).contains("entry"));
1706        let missing_icon = r#"{
1707            "name":"stage","version":"1.0.0","app_type":"bun",
1708            "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
1709            "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
1710        }"#;
1711        assert!(parse_err(missing_icon).contains("icon"));
1712        let uppercase = r#"{
1713            "name":"stage","version":"1.0.0","app_type":"bun",
1714            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1715            "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
1716        }"#;
1717        assert!(parse_err(uppercase).contains("lowercase"));
1718    }
1719
1720    #[test]
1721    fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
1722        let manifest = |composes: &str| {
1723            format!(
1724                r#"{{
1725                    "name":"stage","version":"1.0.0","app_type":"bun",
1726                    "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
1727                    "composes":{composes},
1728                    "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
1729                }}"#
1730            )
1731        };
1732        assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
1733        assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
1734        assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
1735    }
1736
1737    // ── ui.surfaces[] ────────────────────────────────────────────────────────
1738
1739    /// A minimal valid manifest with a `ui` block, for tests that only care
1740    /// about `ui.surfaces`. Mirrors the fixture used by
1741    /// `stage_requires_integrity_for_entry_and_icon` above.
1742    ///
1743    /// The integrity map covers `surface()`'s entry as well as `ui.entry`, because a surface
1744    /// entry must be integrity-pinned exactly like the stage entry and the icon — see
1745    /// `surface_entry_missing_from_integrity_is_rejected`. Before that rule existed this fixture
1746    /// declared a surface no digest covered, which is precisely the manifest the client kernel
1747    /// refuses.
1748    fn manifest_with_ui() -> AppManifest {
1749        parse_ok(
1750            r#"{
1751                "name":"stage","version":"1.0.0","app_type":"bun",
1752                "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
1753                "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1754                "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
1755            }"#,
1756        )
1757    }
1758
1759    fn surface(id: &str, slot: &str) -> AppUiSurface {
1760        AppUiSurface {
1761            id: id.to_string(),
1762            slot: slot.to_string(),
1763            entry: "ui/dist/surfaces/chip.js".to_string(),
1764            title: "Chip".to_string(),
1765            order: 10,
1766            requires: AppUiRequirements {
1767                capabilities: vec!["wallet.balance.get".to_string()],
1768                ..Default::default()
1769            },
1770        }
1771    }
1772
1773    #[test]
1774    fn manifest_without_surfaces_still_parses() {
1775        let manifest = manifest_with_ui();
1776        assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
1777        assert!(manifest.validate().is_ok());
1778    }
1779
1780    #[test]
1781    fn surface_in_a_known_slot_is_accepted() {
1782        let mut manifest = manifest_with_ui();
1783        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
1784        assert!(manifest.validate().is_ok());
1785    }
1786
1787    #[test]
1788    fn surface_in_an_unknown_slot_is_rejected() {
1789        let mut manifest = manifest_with_ui();
1790        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
1791        let error = manifest.validate().unwrap_err();
1792        assert!(error.contains("menu-bar"), "unexpected error: {error}");
1793    }
1794
1795    #[test]
1796    fn duplicate_surface_ids_are_rejected() {
1797        let mut manifest = manifest_with_ui();
1798        manifest.ui.as_mut().unwrap().surfaces = vec![
1799            surface("chip", "status-rail"),
1800            surface("chip", "status-rail"),
1801        ];
1802        let error = manifest.validate().unwrap_err();
1803        assert!(error.contains("duplicate"), "unexpected error: {error}");
1804    }
1805
1806    #[test]
1807    fn surface_with_a_blank_id_is_rejected() {
1808        let mut manifest = manifest_with_ui();
1809        manifest.ui.as_mut().unwrap().surfaces = vec![surface("  ", "status-rail")];
1810        assert!(manifest.validate().is_err());
1811    }
1812
1813    #[test]
1814    fn surface_with_an_unsafe_entry_path_is_rejected() {
1815        let mut manifest = manifest_with_ui();
1816        let mut bad = surface("chip", "status-rail");
1817        bad.entry = "../../etc/passwd".to_string();
1818        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1819        assert!(manifest.validate().is_err());
1820    }
1821
1822    #[test]
1823    fn surface_entry_missing_from_integrity_is_rejected() {
1824        // The client kernel requires a digest for every surface entry and fails the WHOLE
1825        // catalog snapshot when one is missing, so a manifest that packages without one bricks
1826        // every installing node's stage list. Catch it at package time instead.
1827        let mut manifest = manifest_with_ui();
1828        let mut unpinned = surface("chip", "status-rail");
1829        unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
1830        manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
1831        let error = manifest.validate().unwrap_err();
1832        assert!(
1833            error.contains("ui.integrity must include surface 'chip' entry"),
1834            "unexpected error: {error}"
1835        );
1836    }
1837
1838    #[test]
1839    fn surface_with_a_blank_required_capability_is_rejected() {
1840        let mut manifest = manifest_with_ui();
1841        let mut bad = surface("chip", "status-rail");
1842        bad.requires.capabilities = vec!["   ".to_string()];
1843        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
1844        assert!(manifest.validate().is_err());
1845    }
1846
1847    #[test]
1848    fn v2_missing_abi_is_error() {
1849        let json = r#"{
1850            "manifest_version": 2,
1851            "name": "example",
1852            "version": "1.0.0",
1853            "app_type": "bun"
1854        }"#;
1855        let err = parse_err(json);
1856        assert!(err.contains("abi"), "expected abi error, got: {}", err);
1857    }
1858
1859    // ── Publisher-prefixed name (FR-019) ──────────────────────────────────────
1860
1861    #[test]
1862    fn publisher_prefixed_name_accepted() {
1863        let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
1864        assert_eq!(m.name, "alice/weather");
1865    }
1866
1867    #[test]
1868    fn double_slash_name_rejected() {
1869        let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
1870        assert!(!err.is_empty());
1871    }
1872
1873    // ── Malformed names ───────────────────────────────────────────────────────
1874
1875    #[test]
1876    fn name_starting_with_digit_rejected() {
1877        let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
1878        assert!(!err.is_empty());
1879    }
1880
1881    #[test]
1882    fn name_with_uppercase_rejected() {
1883        let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
1884        assert!(!err.is_empty());
1885    }
1886
1887    #[test]
1888    fn empty_name_rejected() {
1889        let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
1890        assert!(!err.is_empty());
1891    }
1892
1893    // ── Path-safety (SEC-H3) ─────────────────────────────────────────────────
1894
1895    #[test]
1896    fn path_traversal_double_dot_rejected() {
1897        let json = r#"{
1898            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1899            "app_type": "bun", "abi": "v1",
1900            "entrypoint": "../etc/passwd"
1901        }"#;
1902        let err = parse_err(json);
1903        assert!(err.contains(".."), "expected traversal error, got: {}", err);
1904    }
1905
1906    #[test]
1907    fn path_traversal_encoded_dot_not_decoded() {
1908        // The regex rejects '%' so encoded traversal fails at char check
1909        let json = r#"{
1910            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1911            "app_type": "bun", "abi": "v1",
1912            "entrypoint": "foo/../bar"
1913        }"#;
1914        let err = parse_err(json);
1915        assert!(!err.is_empty(), "should have failed: {}", err);
1916    }
1917
1918    #[test]
1919    fn absolute_path_rejected() {
1920        let json = r#"{
1921            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1922            "app_type": "bun", "abi": "v1",
1923            "entrypoint": "/usr/bin/sh"
1924        }"#;
1925        let err = parse_err(json);
1926        assert!(
1927            err.contains("absolute"),
1928            "expected absolute error, got: {}",
1929            err
1930        );
1931    }
1932
1933    #[test]
1934    fn shell_metachar_in_path_rejected() {
1935        let json = r#"{
1936            "manifest_version": 2, "name": "evil", "version": "1.0.0",
1937            "app_type": "bun", "abi": "v1",
1938            "entrypoint": "dist/index.js;rm -rf /"
1939        }"#;
1940        let err = parse_err(json);
1941        assert!(!err.is_empty());
1942    }
1943
1944    #[test]
1945    fn valid_nested_path_accepted() {
1946        let json = r#"{
1947            "manifest_version": 2, "name": "my-app", "version": "1.0.0",
1948            "app_type": "bun", "abi": "v1",
1949            "entrypoint": "dist/index.js",
1950            "ui_path": "ui/dist"
1951        }"#;
1952        parse_ok(json);
1953    }
1954
1955    // ── Homepage scheme ───────────────────────────────────────────────────────
1956
1957    #[test]
1958    fn homepage_https_accepted() {
1959        let json = r#"{
1960            "name": "my-app", "version": "1.0.0", "app_type": "bun",
1961            "homepage": "https://example.com"
1962        }"#;
1963        parse_ok(json);
1964    }
1965
1966    #[test]
1967    fn homepage_javascript_scheme_rejected() {
1968        let json = r#"{
1969            "name": "my-app", "version": "1.0.0", "app_type": "bun",
1970            "homepage": "javascript:alert(1)"
1971        }"#;
1972        let err = parse_err(json);
1973        assert!(
1974            err.contains("scheme"),
1975            "expected scheme error, got: {}",
1976            err
1977        );
1978    }
1979
1980    #[test]
1981    fn homepage_file_scheme_rejected() {
1982        let json = r#"{
1983            "name": "my-app", "version": "1.0.0", "app_type": "bun",
1984            "homepage": "file:///etc/passwd"
1985        }"#;
1986        let err = parse_err(json);
1987        assert!(!err.is_empty());
1988    }
1989
1990    // ── Effective defaults ────────────────────────────────────────────────────
1991
1992    #[test]
1993    fn effective_entrypoint_native_default() {
1994        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1995        assert_eq!(m.effective_entrypoint(), "app.so");
1996    }
1997
1998    #[test]
1999    fn effective_entrypoint_bun_default() {
2000        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2001        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2002    }
2003
2004    #[test]
2005    fn effective_hot_reload_native_default_is_experimental() {
2006        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2007        assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2008    }
2009
2010    #[test]
2011    fn effective_hot_reload_bun_default_is_supported() {
2012        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2013        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2014    }
2015
2016    #[test]
2017    fn hot_reload_unsupported_explicit() {
2018        let json = r#"{
2019            "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2020            "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2021        }"#;
2022        let m = parse_ok(json);
2023        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2024    }
2025
2026    // ── validate_manifest_path unit tests ─────────────────────────────────────
2027
2028    #[test]
2029    fn validate_path_simple_valid() {
2030        assert!(validate_manifest_path("dist/index.js").is_ok());
2031        assert!(validate_manifest_path("app.so").is_ok());
2032        assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2033        assert!(validate_manifest_path("build_output/main").is_ok());
2034    }
2035
2036    #[test]
2037    fn validate_path_empty_rejected() {
2038        assert!(validate_manifest_path("").is_err());
2039    }
2040
2041    #[test]
2042    fn validate_path_absolute_rejected() {
2043        assert!(validate_manifest_path("/usr/bin/sh").is_err());
2044    }
2045
2046    #[test]
2047    fn validate_path_double_dot_segment_rejected() {
2048        assert!(validate_manifest_path("foo/../bar").is_err());
2049        assert!(validate_manifest_path("../etc/passwd").is_err());
2050    }
2051
2052    #[test]
2053    fn validate_path_leading_dot_rejected() {
2054        assert!(validate_manifest_path(".hidden").is_err());
2055    }
2056
2057    #[test]
2058    fn validate_path_null_byte_rejected() {
2059        // null byte is non-ASCII, rejected by char check
2060        let path = "foo\0bar";
2061        assert!(validate_manifest_path(path).is_err());
2062    }
2063
2064    #[test]
2065    fn standalone_socket_path_development_override_is_explicit_and_pure() {
2066        let path = std::path::Path::new("/tmp/node-app/example.sock");
2067        assert!(validate_standalone_socket_path(path).is_err());
2068        assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2069        assert!(validate_standalone_socket_path_with_policy(
2070            std::path::Path::new("/tmp/node-app/../escape.sock"),
2071            true,
2072        )
2073        .is_err());
2074    }
2075
2076    // ── ManifestCapabilities defaults ─────────────────────────────────────────
2077
2078    #[test]
2079    fn manifest_capabilities_defaults_to_empty() {
2080        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2081        assert!(m.capabilities.requires.is_empty());
2082        assert!(m.capabilities.provides.is_empty());
2083    }
2084
2085    // ── resolved_capability_provides (T28 standalone-registration shaping) ────
2086
2087    #[test]
2088    fn resolved_capability_provides_v1_only() {
2089        let json = r#"{
2090            "name": "example", "version": "1.0.0", "app_type": "bun",
2091            "provides": { "core.example.run": { "description": "Run example job" } }
2092        }"#;
2093        let m = parse_ok(json);
2094        let out = m.resolved_capability_provides();
2095        assert_eq!(out.len(), 1);
2096        assert_eq!(
2097            out.get("core.example.run").unwrap().description,
2098            "Run example job"
2099        );
2100    }
2101
2102    #[test]
2103    fn resolved_capability_provides_v2_names_get_blank_declaration() {
2104        let json = r#"{
2105            "manifest_version": 2, "name": "example", "version": "1.0.0",
2106            "app_type": "bun", "abi": "v1",
2107            "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2108        }"#;
2109        let m = parse_ok(json);
2110        let out = m.resolved_capability_provides();
2111        assert_eq!(out.len(), 2);
2112        assert_eq!(out.get("core.example.run").unwrap().description, "");
2113        assert!(out.get("core.example.run").unwrap().schema.is_none());
2114        // Only the part before the first ':' is used as the name.
2115        assert!(out.contains_key("core.example.other"));
2116        assert!(!out.contains_key("core.example.other:extra"));
2117    }
2118
2119    #[test]
2120    fn resolved_capability_provides_v1_wins_on_conflict() {
2121        let json = r#"{
2122            "manifest_version": 2, "name": "example", "version": "1.0.0",
2123            "app_type": "bun", "abi": "v1",
2124            "provides": { "core.example.run": { "description": "v1 wins" } },
2125            "capabilities": { "requires": [], "provides": ["core.example.run"] }
2126        }"#;
2127        let m = parse_ok(json);
2128        let out = m.resolved_capability_provides();
2129        assert_eq!(out.len(), 1);
2130        assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2131    }
2132
2133    #[test]
2134    fn resolved_capability_provides_blank_v2_name_skipped() {
2135        let json = r#"{
2136            "manifest_version": 2, "name": "example", "version": "1.0.0",
2137            "app_type": "bun", "abi": "v1",
2138            "capabilities": { "requires": [], "provides": ["  ", "core.example.run"] }
2139        }"#;
2140        let m = parse_ok(json);
2141        let out = m.resolved_capability_provides();
2142        assert_eq!(out.len(), 1);
2143        assert!(out.contains_key("core.example.run"));
2144    }
2145
2146    #[test]
2147    fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2148        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2149        assert!(m.resolved_capability_provides().is_empty());
2150    }
2151
2152    // ── ABI version ───────────────────────────────────────────────────────────
2153
2154    #[test]
2155    fn abi_v1_is_supported() {
2156        assert!(AbiVersion::V1.is_supported());
2157    }
2158
2159    // ── AppTier display ───────────────────────────────────────────────────────
2160
2161    #[test]
2162    fn app_tier_display() {
2163        assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2164        assert_eq!(AppTier::Optional.to_string(), "optional");
2165        assert_eq!(AppTier::Development.to_string(), "development");
2166    }
2167
2168    #[test]
2169    fn app_tier_serde_roundtrip() {
2170        // Wire format must stay snake_case for the existing API contract.
2171        for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2172            let json = serde_json::to_string(&tier).unwrap();
2173            let back: AppTier = serde_json::from_str(&json).unwrap();
2174            assert_eq!(
2175                tier, back,
2176                "roundtrip failed for {:?}: serialized as {}",
2177                tier, json
2178            );
2179        }
2180        assert_eq!(
2181            serde_json::to_string(&AppTier::Development).unwrap(),
2182            "\"development\""
2183        );
2184    }
2185
2186    // ── AppType display ───────────────────────────────────────────────────────
2187
2188    #[test]
2189    fn app_type_display() {
2190        assert_eq!(AppType::Native.to_string(), "native");
2191        assert_eq!(AppType::Bun.to_string(), "bun");
2192        assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2193    }
2194
2195    #[test]
2196    fn platform_runtime_is_a_supported_packaging_type() {
2197        let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2198            "manifest_version": 2,
2199            "abi": "v1",
2200            "name": "bun-runtime",
2201            "version": "1.0.0",
2202            "app_type": "platform-runtime",
2203            "entrypoint": "bun"
2204        }))
2205        .expect("platform runtime manifest should parse");
2206
2207        assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2208        assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2209    }
2210
2211    // ── GovernorManifest memory budget ──────────────────────────────────────────
2212
2213    #[test]
2214    fn governor_manifest_memory_budget_defaults_to_none() {
2215        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2216        assert_eq!(parsed.memory_budget_kb, None);
2217    }
2218
2219    #[test]
2220    fn governor_manifest_parses_declared_memory_budget() {
2221        let parsed: GovernorManifest =
2222            serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2223        assert_eq!(parsed.memory_budget_kb, Some(40_960));
2224    }
2225
2226    // ── GovernorManifest latency_class (app lease engine §8, Task 1) ────────────
2227
2228    #[test]
2229    fn latency_class_parses_and_defaults_none() {
2230        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2231        assert_eq!(parsed.latency_class, None);
2232    }
2233
2234    #[test]
2235    fn latency_class_parses_declared_interactive() {
2236        let json = r#"{
2237            "name": "example",
2238            "version": "1.0.0",
2239            "app_type": "bun",
2240            "governor": {"latency_class": "interactive"}
2241        }"#;
2242        let m = parse_ok(json);
2243        let governor = m.governor.expect("governor block should parse");
2244        assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2245    }
2246
2247    #[test]
2248    fn latency_class_parses_declared_background() {
2249        let parsed: GovernorManifest =
2250            serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2251        assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2252    }
2253
2254    #[test]
2255    fn latency_class_invalid_value_is_parse_error() {
2256        let result: Result<GovernorManifest, _> =
2257            serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2258        assert!(result.is_err(), "unknown latency_class value must fail to parse");
2259    }
2260
2261    #[test]
2262    fn latency_class_as_str_and_from_str_roundtrip() {
2263        for class in [LatencyClass::Interactive, LatencyClass::Background] {
2264            let s = class.as_str();
2265            assert_eq!(LatencyClass::from_str(s), Ok(class));
2266        }
2267        assert!(LatencyClass::from_str("urgent").is_err());
2268    }
2269}
2270
2271#[cfg(test)]
2272mod shared_app_data_corpus_tests {
2273    use super::*;
2274
2275    /// The SAME corpus the kernel validator runs
2276    /// (`client/kernel/src/stages/stage-registry-contract.test.js`). Two independent
2277    /// implementations of one contract, with nothing but this comparing them —
2278    /// whichever side drifts fails here.
2279    #[test]
2280    fn shared_app_data_corpus_matches_the_host_validator() {
2281        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2282        let mut checked = 0;
2283        for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2284            let path = entry.expect("readable entry").path();
2285            if path.extension().and_then(|e| e.to_str()) != Some("json") {
2286                continue;
2287            }
2288            let fixture: serde_json::Value =
2289                serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2290                    .expect("valid fixture json");
2291            let name = path
2292                .file_name()
2293                .and_then(|n| n.to_str())
2294                .unwrap_or("?")
2295                .to_string();
2296            let manifest = serde_json::json!({
2297                "manifest_version": 2, "abi": "v1", "name": "fixture", "version": "0.1.0",
2298                "app_type": "bun", "entrypoint": "dist/index.js",
2299                "ui": fixture["ui"],
2300            });
2301            let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2302            match fixture["expect"].as_str().expect("expect field") {
2303                "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2304                "reject" => {
2305                    let error = result.expect_err(&format!("{name} should be rejected"));
2306                    let reason = fixture["reason"]
2307                        .as_str()
2308                        .expect("reject fixtures need a reason");
2309                    assert!(
2310                        error.contains(reason),
2311                        "{name}: {error:?} should mention {reason:?}"
2312                    );
2313                }
2314                other => panic!("{name}: unknown expect {other:?}"),
2315            }
2316            checked += 1;
2317        }
2318        // Guards against a silently empty or mis-globbed corpus reporting success.
2319        assert!(checked >= 10, "expected the full corpus, walked {checked}");
2320    }
2321}