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