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