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    /// ES module bundle hosted by the Burger (QuickJS) runtime host,
56    /// `node-app-burger host` (Burger Plan 02, Contract C7).
57    Burger,
58    /// A stage-only app: a `ui` block of kind `stage` and no backend at all
59    /// (burger-07, Plans 07a/07b Contracts F5). Never loaded, spawned or
60    /// woken; node-server only lists and serves its UI bundle. Derived by
61    /// [`AppManifest::from_json`] for a manifest with a `ui` object and neither
62    /// `app_type` nor `entrypoint`. The string `"ui-only"` is also accepted,
63    /// so a serialised manifest round-trips.
64    #[serde(rename = "ui-only")]
65    UiOnly,
66}
67
68impl AppType {
69    pub fn as_str(self) -> &'static str {
70        match self {
71            AppType::Native => "native",
72            AppType::Bun => "bun",
73            AppType::Standalone => "standalone",
74            AppType::PlatformRuntime => "platform-runtime",
75            AppType::ManagedV1 => "managed-v1",
76            AppType::Burger => "burger",
77            AppType::UiOnly => "ui-only",
78        }
79    }
80}
81
82impl std::fmt::Display for AppType {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{}", self.as_str())
85    }
86}
87
88/// Trust/distribution tier — derived at load time from the install path
89/// AND (per FR-028 cycle 4) the manifest sidecar's GPG signature.
90///
91/// This is **not** stored in the manifest; it is computed by `tier_validator`.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum AppTier {
95    /// App installed at the bundled path (`/usr/share/node/builtin-apps/`)
96    /// OR at the apt path with a manifest sidecar signed by a node project key.
97    /// May be `Native` or `Bun`. Highest trust.
98    FirstParty,
99    /// App installed at the optional apt path (`/usr/lib/node/apps/`) with
100    /// no/invalid project signature. MUST be `Bun`; `Native` at this tier
101    /// triggers `TierError` (FR-028).
102    Optional,
103    /// App loaded from a developer's local dev directory (`NODE_DEV_APPS_DIR`),
104    /// via `node-app-build dev` or manual sideload. Bypasses signature checks
105    /// because the dev directory is owned by the developer (security gate is
106    /// the file-system path: only the dev user can write to it). Permitted
107    /// for `Native` apps so cdylib developers can iterate without per-build
108    /// GPG signing.
109    ///
110    /// Daemon logs every Development-tier load at `info!` so operators of a
111    /// real node can see when a non-prod app is active. UI badges this tier
112    /// distinctly (amber/red, never green).
113    Development,
114}
115
116impl AppTier {
117    pub fn as_str(self) -> &'static str {
118        match self {
119            AppTier::FirstParty => "first_party",
120            AppTier::Optional => "optional",
121            AppTier::Development => "development",
122        }
123    }
124}
125
126impl std::fmt::Display for AppTier {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        write!(f, "{}", self.as_str())
129    }
130}
131
132/// Host ABI compatibility version declared by the app.
133///
134/// The runtime's currently supported set is `[V1]`. Apps declaring an
135/// unsupported version are rejected with `AbiIncompatible` (FR-018).
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum AbiVersion {
139    V1,
140}
141
142impl AbiVersion {
143    pub fn as_str(&self) -> &'static str {
144        match self {
145            AbiVersion::V1 => "v1",
146        }
147    }
148
149    /// Returns true if this ABI version is supported by the current runtime.
150    pub fn is_supported(&self) -> bool {
151        matches!(self, AbiVersion::V1)
152    }
153}
154
155impl std::fmt::Display for AbiVersion {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        write!(f, "{}", self.as_str())
158    }
159}
160
161/// How in-process (native) app reload is expected to behave.
162///
163/// Per research.md §R10, native hot-reload is inherently unreliable due to
164/// `dlclose` semantics. The manifest field sets correct user expectations.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum HotReloadKind {
168    /// Reload is reliable (Bun subprocess restart). Default for `Bun` apps.
169    Supported,
170    /// Reload is attempted but its result depends on whether the on-disk
171    /// library image actually changed. When the daemon has this app's OLD
172    /// image mapped (Linux's `dlopen` returns the cached handle for an
173    /// already-`dlopen`'d path, so a package upgrade landing new bytes at
174    /// the same path goes undetected without an explicit staleness check),
175    /// `app.reload` succeeds and reports `status: "restart_required"`; the
176    /// process keeps serving the OLD mapped image (with its last known-good
177    /// `provides`) until the node actually restarts, at which point the new
178    /// version activates. Default for `Native` apps (FR-024).
179    Experimental,
180    /// App must be restarted to pick up changes.
181    Unsupported,
182}
183
184impl HotReloadKind {
185    pub fn default_for(app_type: AppType) -> Self {
186        match app_type {
187            AppType::Native => HotReloadKind::Experimental,
188            AppType::Bun => HotReloadKind::Supported,
189            // Standalone apps are restarted by systemd, not the daemon —
190            // from the daemon's perspective they are never hot-reloaded.
191            AppType::Standalone => HotReloadKind::Unsupported,
192            AppType::PlatformRuntime => HotReloadKind::Unsupported,
193            AppType::ManagedV1 => HotReloadKind::Supported,
194            AppType::Burger => HotReloadKind::Supported,
195            // A stage bundle reloads with the page; there is no process to reload.
196            AppType::UiOnly => HotReloadKind::Unsupported,
197        }
198    }
199}
200
201// ── Sub-types ─────────────────────────────────────────────────────────────────
202
203/// Capability declarations from the v2 manifest `capabilities` block.
204///
205/// Semantic equivalent of the existing `permissions` + `provides` fields;
206/// v2 manifests may use either or both (backward compat preserved).
207#[derive(Debug, Clone, Default, Serialize, Deserialize)]
208pub struct ManifestCapabilities {
209    /// Capabilities this app requests from the host or other apps.
210    /// Format: `"core.lightning.payment.send:max=1000sat/day"` (see §1.2).
211    #[serde(default)]
212    pub requires: Vec<String>,
213
214    /// Capabilities this app provides to other apps.
215    /// Format: `"core.cron.register"`.
216    #[serde(default)]
217    pub provides: Vec<String>,
218}
219
220/// A single scope provided by an app (existing v1 model, preserved verbatim).
221#[derive(Debug, Clone, Serialize, Deserialize, Default)]
222pub struct ProvidedScope {
223    pub scope: String,
224    pub description: String,
225    pub resource_pattern: String,
226}
227
228/// Declarative per-endpoint access policy (existing v1 model, preserved verbatim).
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct EndpointPolicy {
231    pub method: String,
232    pub path: String,
233    pub required_permissions: Vec<String>,
234}
235
236/// Capability provider declaration (existing v1 model, preserved verbatim).
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ProvidedCapability {
239    #[serde(default)]
240    pub description: String,
241    #[serde(default)]
242    pub schema: Option<serde_json::Value>,
243}
244
245/// Configuration for `AppType::Standalone` apps.
246///
247/// Carried only by manifests whose `app_type == "standalone"`. The daemon uses
248/// `socket_path` to route capability invocations as line-delimited JSON-RPC 2.0
249/// over the standalone daemon's own Unix domain socket.
250///
251/// Path-safety rules (validated by `AppManifest::validate`):
252/// - Absolute path.
253/// - Lives under `/run/`.
254/// - No `..` segments.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct StandaloneConfig {
257    pub socket_path: std::path::PathBuf,
258}
259
260/// Browser UI unit shipped by an app package.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum AppUiKind {
264    Stage,
265    Widget,
266}
267
268fn default_app_ui_kind() -> AppUiKind {
269    AppUiKind::Stage
270}
271
272fn default_nav_section() -> String {
273    "default".to_string()
274}
275
276/// Shell-owned navigation metadata for a top-level stage.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct AppUiNav {
279    #[serde(default = "default_nav_section")]
280    pub section: String,
281    #[serde(default)]
282    pub order: i32,
283}
284
285/// Shell-chrome regions an app may contribute a surface to.
286///
287/// The shell owns this vocabulary; an app requests a region by name. Keep this
288/// list to slots that have a real occupant — a speculative slot is a contract
289/// nobody has had to honour yet.
290///
291/// Checked in TWO places on purpose. `node-app package` rejects an unknown slot
292/// so an author sees a typo while they can still fix it; the shell ALSO ignores
293/// surfaces whose slot it does not recognise, because an app packaged against a
294/// newer SDK can be installed on an older shell, and that shell must degrade by
295/// dropping the surface rather than failing the app.
296pub const KNOWN_SURFACE_SLOTS: &[&str] = &["status-rail"];
297
298/// A UI unit an app contributes to a named region of the shell's own chrome.
299///
300/// Not a route: it has no nav entry, and it is mounted by the shell rather than
301/// by any stage. `requires` is the surface's OWN authorization scope — the
302/// primary containment control, since a surface otherwise receives the same
303/// `StageContext` a stage receives. A wallet chip declares `wallet.balance.get`
304/// and is refused `wallet.payment.send` even though the app provides it.
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct AppUiSurface {
307    pub id: String,
308    pub slot: String,
309    pub entry: String,
310    pub title: String,
311    #[serde(default)]
312    pub order: i32,
313    #[serde(default)]
314    pub requires: AppUiRequirements,
315}
316
317/// How the client shell may behave when the home node is unavailable.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "kebab-case")]
320pub enum AppDataOfflinePolicy {
321    /// A stage may render the last verified cached projection with stale/offline labeling.
322    LastKnown,
323    /// A stage must fail clearly when the home node is unavailable.
324    OnlineOnly,
325}
326
327/// Generic query declaration shape for app-owned cached projections.
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "kebab-case")]
330pub enum AppDataQueryKind {
331    Collection,
332    Detail,
333    Snapshot,
334}
335
336/// Generic stream declaration shape for app-owned invalidation/cursor feeds.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "kebab-case")]
339pub enum AppDataStreamKind {
340    Changes,
341    Events,
342}
343
344/// How the client shell refreshes app-owned data.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum AppDataSyncKind {
348    Cursor,
349    Snapshot,
350}
351
352/// Bounded synchronization policy for generic app data.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct AppDataSyncPolicy {
355    pub kind: AppDataSyncKind,
356    pub cursor_ttl_secs: Option<u32>,
357    pub full_refresh_interval_secs: Option<u32>,
358    pub retention_secs: Option<u32>,
359}
360
361impl Serialize for AppDataSyncPolicy {
362    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
363    where
364        S: serde::Serializer,
365    {
366        use serde::ser::SerializeStruct;
367
368        if self.cursor_ttl_secs.is_none()
369            && self.full_refresh_interval_secs.is_none()
370            && self.retention_secs.is_none()
371        {
372            return self.kind.serialize(serializer);
373        }
374
375        let mut state = serializer.serialize_struct("AppDataSyncPolicy", 4)?;
376        state.serialize_field("kind", &self.kind)?;
377        if let Some(cursor_ttl_secs) = self.cursor_ttl_secs {
378            state.serialize_field("cursor_ttl_secs", &cursor_ttl_secs)?;
379        }
380        if let Some(full_refresh_interval_secs) = self.full_refresh_interval_secs {
381            state.serialize_field("full_refresh_interval_secs", &full_refresh_interval_secs)?;
382        }
383        if let Some(retention_secs) = self.retention_secs {
384            state.serialize_field("retention_secs", &retention_secs)?;
385        }
386        state.end()
387    }
388}
389
390impl<'de> Deserialize<'de> for AppDataSyncPolicy {
391    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
392    where
393        D: serde::Deserializer<'de>,
394    {
395        #[derive(Deserialize)]
396        #[serde(deny_unknown_fields)]
397        struct ObjectPolicy {
398            kind: AppDataSyncKind,
399            #[serde(default)]
400            cursor_ttl_secs: Option<u32>,
401            #[serde(default)]
402            full_refresh_interval_secs: Option<u32>,
403            #[serde(default)]
404            retention_secs: Option<u32>,
405        }
406
407        #[derive(Deserialize)]
408        #[serde(untagged)]
409        enum WirePolicy {
410            Kind(AppDataSyncKind),
411            Object(ObjectPolicy),
412        }
413
414        match WirePolicy::deserialize(deserializer)? {
415            WirePolicy::Kind(kind) => Ok(Self {
416                kind,
417                cursor_ttl_secs: None,
418                full_refresh_interval_secs: None,
419                retention_secs: None,
420            }),
421            WirePolicy::Object(policy) => Ok(Self {
422                kind: policy.kind,
423                cursor_ttl_secs: policy.cursor_ttl_secs,
424                full_refresh_interval_secs: policy.full_refresh_interval_secs,
425                retention_secs: policy.retention_secs,
426            }),
427        }
428    }
429}
430
431/// A namespaced app-owned query exposed through the generic stage data plane.
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct AppDataQueryDeclaration {
435    pub name: String,
436    pub capability: String,
437    pub kind: AppDataQueryKind,
438}
439
440/// A namespaced app-owned stream exposed through the generic stage data plane.
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(deny_unknown_fields)]
443pub struct AppDataStreamDeclaration {
444    pub name: String,
445    pub kind: AppDataStreamKind,
446}
447
448/// Generic, app-owned data contract declared by a stage manifest.
449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(deny_unknown_fields)]
451pub struct AppDataManifest {
452    pub namespace: String,
453    pub offline: AppDataOfflinePolicy,
454    pub sync: AppDataSyncPolicy,
455    #[serde(default)]
456    pub queries: Vec<AppDataQueryDeclaration>,
457    #[serde(default)]
458    pub streams: Vec<AppDataStreamDeclaration>,
459}
460
461/// Capability, query, and stream contracts exposed to an app-delivered UI
462/// stage. This is intentionally separate from the app's backend dependency
463/// declaration (`requires` / `capabilities.requires`): backend providers may
464/// need capabilities that must never be delegated to browser UI code.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
466#[serde(deny_unknown_fields)]
467pub struct AppUiRequirements {
468    #[serde(default)]
469    pub capabilities: Vec<String>,
470    #[serde(default)]
471    pub queries: Vec<String>,
472    #[serde(default)]
473    pub streams: Vec<String>,
474}
475
476impl AppUiRequirements {
477    /// Return the UI's complete declared contract in stable, de-duplicated
478    /// order. Query and stream names are included because they are separately
479    /// authorized stage declarations at the client RPC boundary.
480    pub fn resolved(&self) -> Result<Vec<String>, String> {
481        let mut resolved = Vec::new();
482        let mut seen = HashSet::new();
483        for (values, allow_wildcard) in [
484            (&self.capabilities, true),
485            (&self.queries, false),
486            (&self.streams, false),
487        ] {
488            for value in values {
489                let requirement = value.trim();
490                if requirement.is_empty() {
491                    return Err("ui.requires entries must not be blank".to_string());
492                }
493                validate_ui_requirement_name(requirement, allow_wildcard).map_err(|error| {
494                    format!("ui.requires entry '{requirement}' invalid: {error}")
495                })?;
496                if seen.insert(requirement.to_string()) {
497                    resolved.push(requirement.to_string());
498                }
499            }
500        }
501        Ok(resolved)
502    }
503}
504
505/// Optional stage metadata carried by the canonical app manifest.
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct AppUiManifest {
508    #[serde(default = "default_app_ui_kind")]
509    pub kind: AppUiKind,
510    pub entry: String,
511    pub title: String,
512    #[serde(default)]
513    pub icon: Option<String>,
514    #[serde(default)]
515    pub nav: Option<AppUiNav>,
516    #[serde(default)]
517    pub composes: Vec<String>,
518    /// Shell-chrome contributions. Empty for the overwhelming majority of apps.
519    #[serde(default)]
520    pub surfaces: Vec<AppUiSurface>,
521    pub ui_api: u8,
522    #[serde(default)]
523    pub integrity: BTreeMap<String, String>,
524    /// Stage-specific description that overrides the app-level
525    /// `AppManifest::description` when the stage's UI purpose differs from the
526    /// app's. Optional; when absent the app-level description is used.
527    #[serde(default)]
528    pub description: Option<String>,
529    /// Author-supplied synonyms for this stage (search/intent phrasings).
530    /// Optional; defaults to empty.
531    #[serde(default)]
532    pub keywords: Vec<String>,
533    /// The browser stage contract. Do not populate this from the app's
534    /// backend `requires` declaration.
535    #[serde(default)]
536    pub requires: AppUiRequirements,
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub data: Option<AppDataManifest>,
539}
540
541// ── Path-safety helpers ───────────────────────────────────────────────────────
542
543/// Validate a relative file path declared in a manifest (`entrypoint`, `ui_path`).
544///
545/// Rules (SEC-H3):
546/// 1. Matches `^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$` — rejects shell metacharacters,
547///    leading `.`, leading `/`, etc.
548/// 2. No `..` segment anywhere.
549/// 3. Does not begin with `/` (absolute paths).
550///
551/// Returns `Ok(())` if valid, `Err(reason)` describing the violation.
552pub fn validate_manifest_path(path: &str) -> Result<(), String> {
553    if path.is_empty() {
554        return Err("path must not be empty".to_string());
555    }
556
557    // Rule 3: no absolute paths
558    if path.starts_with('/') {
559        return Err(format!(
560            "path '{}' must not be absolute (starts with /)",
561            path
562        ));
563    }
564
565    // Rule 1: allowed character set
566    // ^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$
567    let first = path.chars().next().unwrap();
568    if !first.is_ascii_alphanumeric() && first != '_' {
569        return Err(format!(
570            "path '{}' must begin with an alphanumeric character or underscore",
571            path
572        ));
573    }
574    for ch in path.chars().skip(1) {
575        if !ch.is_ascii_alphanumeric() && !matches!(ch, '_' | '.' | '/' | '-') {
576            return Err(format!(
577                "path '{}' contains disallowed character '{}'",
578                path, ch
579            ));
580        }
581    }
582
583    // Rule 2: no `..` segment
584    for segment in path.split('/') {
585        if segment == ".." {
586            return Err(format!(
587                "path '{}' contains a '..' segment (path traversal rejected)",
588                path
589            ));
590        }
591    }
592
593    Ok(())
594}
595
596// ── AppManifest ───────────────────────────────────────────────────────────────
597
598/// Canonical manifest entity — unified v1/v2 format.
599///
600/// Deserializes both old (v1, no `manifest_version`) and new (v2) manifests.
601/// All v2-only fields use `#[serde(default)]` so that v1 manifests parse
602/// correctly without any field changes.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct AppManifest {
605    /// Schema version. Absent or 1 = legacy v1; 2 = extended v2.
606    #[serde(default = "default_manifest_version", rename = "manifest_version")]
607    pub manifest_version: u8,
608
609    pub name: String,
610    pub version: String,
611
612    #[serde(default = "default_app_type_native")]
613    pub app_type: AppType,
614
615    #[serde(default)]
616    pub description: String,
617
618    // ── v2-only additions (all optional, v1-compatible defaults) ─────────────
619    /// Host ABI compatibility version. Required when `manifest_version == 2`.
620    pub abi: Option<AbiVersion>,
621
622    /// Payload entry point relative to the app directory.
623    /// Default: `app.so` for Native, `dist/index.js` for Bun.
624    pub entrypoint: Option<String>,
625
626    /// Hot-reload behaviour classification.
627    /// Default: `experimental` for Native, `supported` for Bun.
628    pub hot_reload: Option<HotReloadKind>,
629
630    // ── Existing v1 fields (preserved verbatim — DO NOT RENAME) ──────────────
631    #[serde(default)]
632    pub critical: bool,
633
634    #[serde(
635        default = "default_auto_start",
636        deserialize_with = "deserialize_auto_start"
637    )]
638    pub auto_start: bool,
639
640    #[serde(default)]
641    pub has_ui: bool,
642
643    #[serde(default = "default_ui_path")]
644    pub ui_path: String,
645
646    #[serde(default)]
647    pub permissions: Vec<String>,
648
649    /// Capability requirements in the v2 top-level vocabulary. This is an
650    /// alias for `capabilities.requires`, not a second permission system.
651    #[serde(default)]
652    pub requires: Vec<String>,
653
654    #[serde(default)]
655    pub optional_permissions: Vec<String>,
656
657    #[serde(default)]
658    pub provides_scopes: Vec<ProvidedScope>,
659
660    #[serde(default)]
661    pub endpoint_policies: Vec<EndpointPolicy>,
662
663    #[serde(default)]
664    pub capability_scopes: HashMap<String, String>,
665
666    #[serde(default)]
667    pub provides: HashMap<String, ProvidedCapability>,
668
669    // ── v2 capabilities block (semantic alias for permissions + provides) ─────
670    #[serde(default)]
671    pub capabilities: ManifestCapabilities,
672
673    /// App-delivered browser UI metadata. Legacy `has_ui`/`ui_path` remains
674    /// readable but does not synthesize this block.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub ui: Option<AppUiManifest>,
677
678    // ── Optional metadata fields ──────────────────────────────────────────────
679    #[serde(default)]
680    pub author: Option<String>,
681
682    #[serde(default)]
683    pub homepage: Option<String>,
684
685    #[serde(default)]
686    pub depends_on: Option<Vec<String>>,
687
688    #[serde(default)]
689    pub boot_priority: Option<u32>,
690
691    /// App-governor idle-termination policy (issue #811 SP1). Absent means
692    /// the app is subject to the default eligibility rules with no explicit
693    /// opt-out and no minimum-idle override.
694    #[serde(default)]
695    pub governor: Option<GovernorManifest>,
696
697    /// Event-bus topics this app listens for while lazily started. Only
698    /// meaningful for apps holding the `EVENT_LISTENER` capability — a
699    /// listener with no declared `subscribes` topics is exempt from idle
700    /// termination because the governor cannot know what would need to wake
701    /// it back up (see `node-app-host::governor_eligibility`).
702    #[serde(default)]
703    pub subscribes: Vec<String>,
704
705    /// Required when `app_type == "standalone"` and the manifest declares any
706    /// `provides` / `capabilities.provides` entries. Carries the Unix domain
707    /// socket path the daemon dispatches capability calls to.
708    #[serde(default)]
709    pub standalone: Option<StandaloneConfig>,
710
711    /// Optional TCP-binding block — feature 470 (port registry).
712    /// Absence means the app does not bind a TCP port the registry manages.
713    #[serde(default, skip_serializing_if = "Option::is_none")]
714    pub tcp: Option<TcpManifest>,
715
716    /// Runtime resource requests (Burger Plan 02, Contracts C2/C7). Absence means
717    /// runtime defaults (Burger: 32 MB QuickJS memory limit, 5000 ms callback deadline).
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub resources: Option<ResourcesManifest>,
720
721    /// Recurring jobs this app needs on the node, declared so the host can put
722    /// the cron rows there without the app ever having run (econ-v1/node#3185).
723    ///
724    /// Empty — the default — is exactly the behaviour that shipped before: the
725    /// app owns its own registration and nothing happens until it starts. See
726    /// [`AppScheduleManifest`] for why declaring one does not pin the isolate.
727    #[serde(default, skip_serializing_if = "Vec::is_empty")]
728    pub schedules: Vec<AppScheduleManifest>,
729
730    /// Host contract features this manifest needs, stamped by
731    /// `node-app contract stamp`. Absent in manifests built before stamping
732    /// existed; see `check_host_contract`.
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub host_contract: Option<crate::HostContractStamp>,
735}
736
737/// Upper bound for `resources.callback_deadline_ms` (10 minutes).
738pub const MAX_CALLBACK_DEADLINE_MS: u32 = 600_000;
739
740/// Runtime resource requests (Contract C2). `memory_mb` becomes the Burger
741/// host's per-app QuickJS memory limit (`load_app.memory_limit_mb`);
742/// `callback_deadline_ms` becomes its per-callback CPU watchdog
743/// (`load_app.callback_deadline_ms`). Both are omitted from `load_app` when
744/// absent so the Burger host applies its own defaults (32 MB, 5000 ms).
745#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
746pub struct ResourcesManifest {
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub memory_mb: Option<u32>,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub callback_deadline_ms: Option<u32>,
751}
752
753/// Declared responsiveness expectation for an app's lease engine decisions
754/// (app lease engine design §8, Task 1). `None` on [`GovernorManifest`] means
755/// the app has not declared a preference — the lease engine (Task 5) then
756/// falls back to its own default rather than treating an unset field as
757/// either variant.
758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
759#[serde(rename_all = "lowercase")]
760pub enum LatencyClass {
761    /// The app serves latency-sensitive, user-facing requests — the lease
762    /// engine should prefer to keep it warm.
763    Interactive,
764    /// The app only does deferred/background work — the lease engine may
765    /// treat it as a lower priority to keep resident.
766    Background,
767}
768
769impl LatencyClass {
770    pub fn as_str(&self) -> &'static str {
771        match self {
772            LatencyClass::Interactive => "interactive",
773            LatencyClass::Background => "background",
774        }
775    }
776
777    #[allow(clippy::should_implement_trait)]
778    pub fn from_str(s: &str) -> Result<Self, String> {
779        match s {
780            "interactive" => Ok(LatencyClass::Interactive),
781            "background" => Ok(LatencyClass::Background),
782            _ => Err(format!("Invalid LatencyClass: {}", s)),
783        }
784    }
785}
786
787impl std::fmt::Display for LatencyClass {
788    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        write!(f, "{}", self.as_str())
790    }
791}
792
793/// Idle-termination policy for a lazily-started app (issue #811 SP1 — the
794/// app governor). Nested under `AppManifest::governor`.
795#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
796pub struct GovernorManifest {
797    /// Explicit opt-out. `Some(false)` exempts the app from idle termination
798    /// regardless of any other eligibility rule. `None`/`Some(true)` defers
799    /// to the other eligibility rules.
800    #[serde(default)]
801    pub terminable: Option<bool>,
802
803    /// Minimum idle duration, in seconds, before the governor may terminate
804    /// this app — overrides the governor's default sweep threshold. `None`
805    /// defers to the default.
806    #[serde(default)]
807    pub min_idle_secs: Option<u64>,
808
809    /// Memory budget in KB. When the app's measured footprint — on the basis
810    /// selected by its measurement attribution, see
811    /// `node_app_host::app_memory::budget` — exceeds this, the owner is warned
812    /// in the shell.
813    ///
814    /// # What `None` defers to
815    ///
816    /// NOT one number. The default is chosen PER BASIS
817    /// (`node_app_host::app_memory::budget::default_budget_kb`), because the
818    /// bases are not comparable quantities:
819    ///
820    /// | basis                | default   | why                                     |
821    /// |----------------------|-----------|-----------------------------------------|
822    /// | `heap_used`, `pss`   | 10,240 KB | the app and nothing else                |
823    /// | `rss`                | 61,440 KB | the whole OS process, runtime included  |
824    /// | `not_attributable`   | 10,240 KB | never `over`; carried only for the wire |
825    ///
826    /// A shared-runtime Bun worker is compared on `heap_used`; a dedicated
827    /// process or cgroup-scoped standalone on `rss`, which charges it for a
828    /// JavaScript engine it did not choose and cannot shed.
829    ///
830    /// On top of that, a host-side runtime-critical entry
831    /// (`RUNTIME_CRITICAL_BUDGETS`) acts as a FLOOR, never a ceiling: it can
832    /// only raise an app above the per-basis default, never pull it below one.
833    ///
834    /// A value declared HERE is the one thing that overrides both, in either
835    /// direction — it is a deliberate choice by the app author, not a fallback,
836    /// so it is honoured unchanged even when it is lower than the default.
837    ///
838    /// Apps that legitimately need more than their basis default MUST declare a
839    /// realistic budget here; otherwise the warning is permanently lit and
840    /// stops meaning anything.
841    #[serde(default)]
842    pub memory_budget_kb: Option<u64>,
843
844    /// Declared responsiveness expectation (app lease engine design §8,
845    /// Task 1). `None` when the app declares no preference — see
846    /// [`LatencyClass`] for what each variant means and what `None` defers
847    /// to.
848    #[serde(default)]
849    pub latency_class: Option<LatencyClass>,
850}
851
852/// TCP port preferences for standalone apps that bind their own port.
853/// Consumed by the port registry (`system/server/src/services/port_registry/`)
854/// at install time.
855#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
856pub struct TcpManifest {
857    /// The TCP port the app would like to bind. Honored when free;
858    /// otherwise the registry assigns the next free port from the pool
859    /// (default 7000–7099). Absent → registry picks any free pool slot.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub preferred_port: Option<u16>,
862
863    /// When `true`, the platform UI shell builds iframe URLs as direct LAN
864    /// connections to the assigned port rather than routing via the
865    /// `/api/v2/node-apps/{name}/ui/` reverse-proxy. Intended only for apps
866    /// that must outlive a platform restart (e.g. OTA self-upgrade). Remote
867    /// users may see a degraded experience — owned by the consuming app's UI,
868    /// not this spec (see `specs/470-port-registry/spec.md` Clarifications Q5b).
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub direct_bind: Option<bool>,
871}
872
873/// One recurring job an app declares in its own manifest, so the host can put
874/// the cron row on the node without the app ever having run (econ-v1/node#3185).
875///
876/// Before this existed, an app that records on a schedule had to register its
877/// own row when it started — which a `lazy` app only does once something first
878/// invokes it. On a node whose owner never opens that app, the row was never
879/// created, nothing was ever recorded, and nothing said so.
880///
881/// Declaring the schedule here does NOT make the app resident. The host
882/// registers a CAPABILITY-triggered row pointing at [`Self::capability`]; when
883/// it fires, the capability router resolves the provider from the registry
884/// (seeded at boot for unloaded apps) and cold-starts the app for the duration
885/// of the call. Between firings the isolate can be reclaimed exactly as before.
886/// `auto_start: "auto"` would also produce the row, and is NOT the answer: it
887/// pins the isolate permanently, which is the cost a sparse cadence exists to
888/// avoid.
889#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
890pub struct AppScheduleManifest {
891    /// Stable identifier, unique within this app. Becomes the cron row's
892    /// `external_id` (paired with the app name as `external_type`), which is
893    /// what lets the host find its own row again without storing anything.
894    ///
895    /// Changing it retires the old row and creates a new one — it is an
896    /// identity, not a label.
897    pub id: String,
898
899    /// 6-field cron expression: `sec min hour day month weekday`.
900    ///
901    /// Checked here only for shape (six non-empty fields over the permitted
902    /// character set). The authoritative parse lives in the host, which refuses
903    /// the whole manifest on a bad expression — this crate is the schema
904    /// contract that app authors compile against, and is deliberately kept to
905    /// `serde` alone rather than pulling a cron parser and its date-time
906    /// dependencies into every app build.
907    pub cron: String,
908
909    /// The capability the row dispatches.
910    ///
911    /// MUST be one this same app declares in `provides` / `capabilities.provides`,
912    /// and the manifest is refused otherwise. Without that restriction any
913    /// manifest could schedule repeated dispatches at any capability on the node
914    /// — `core.lightning.send_payment`, say — and a manifest is not a surface
915    /// the owner reviews.
916    pub capability: String,
917
918    /// JSON object handed to the capability on each firing. Absent means `{}`.
919    /// A non-object payload is refused: every capability on the dispatch path
920    /// takes an object, and the router stamps `caller` into it.
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub payload: Option<serde_json::Value>,
923}
924
925impl AppScheduleManifest {
926    /// The payload to dispatch with, defaulting to an empty object.
927    pub fn effective_payload(&self) -> serde_json::Value {
928        self.payload
929            .clone()
930            .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()))
931    }
932}
933
934/// Characters permitted in a cron field. Covers the standard vocabulary
935/// (`* , - /`), named months/weekdays, and the `?` / `L` / `W` / `#` forms
936/// extended syntaxes use — the host's real parser decides what it accepts, so
937/// this only rejects input that could not be a cron field at all.
938fn cron_field_char_allowed(ch: char) -> bool {
939    ch.is_ascii_alphanumeric() || matches!(ch, '*' | ',' | '-' | '/' | '?' | 'L' | 'W' | '#')
940}
941
942/// Shape check for a 6-field cron expression. See [`AppScheduleManifest::cron`]
943/// for why the authoritative parse is the host's and not this crate's.
944fn validate_cron_shape(expression: &str) -> Result<(), String> {
945    let fields: Vec<&str> = expression.split_whitespace().collect();
946    if fields.len() != 6 {
947        return Err(format!(
948            "cron '{}' must have 6 fields (sec min hour day month weekday), found {}",
949            expression,
950            fields.len()
951        ));
952    }
953    for field in fields {
954        if let Some(ch) = field.chars().find(|c| !cron_field_char_allowed(*c)) {
955            return Err(format!(
956                "cron '{}' contains disallowed character '{}'",
957                expression, ch
958            ));
959        }
960    }
961    Ok(())
962}
963
964/// A schedule id must be a stable, filesystem- and URL-safe token: it travels
965/// as the cron row's `external_id` and is matched verbatim on every reconcile.
966fn validate_schedule_id(id: &str) -> Result<(), String> {
967    if id.is_empty() {
968        return Err("schedule id must not be empty".to_string());
969    }
970    let first = id.chars().next().unwrap();
971    if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
972        return Err(format!(
973            "schedule id '{}' must begin with a lowercase letter or digit",
974            id
975        ));
976    }
977    for ch in id.chars() {
978        if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && !matches!(ch, '-' | '_' | '.') {
979            return Err(format!(
980                "schedule id '{}' contains disallowed character '{}' (allowed: a-z 0-9 - _ .)",
981                id, ch
982            ));
983        }
984    }
985    Ok(())
986}
987
988fn default_manifest_version() -> u8 {
989    1
990}
991
992fn default_auto_start() -> bool {
993    true
994}
995
996/// Deserialize `auto_start` from either a bool (manifest v1) or a load-mode
997/// string (v2, e.g. `"lazy"`/`"eager"`/`"active"`). Eager-start modes map to
998/// `true`; `"lazy"` and other on-demand/inactive states map to `false` (the app
999/// is started on first capability use, not at boot). This keeps both manifest
1000/// schema generations parseable by `AppManifest::from_json`.
1001fn deserialize_auto_start<'de, D>(deserializer: D) -> Result<bool, D::Error>
1002where
1003    D: serde::Deserializer<'de>,
1004{
1005    #[derive(Deserialize)]
1006    #[serde(untagged)]
1007    enum BoolOrStr {
1008        Bool(bool),
1009        Str(String),
1010    }
1011    Ok(match BoolOrStr::deserialize(deserializer)? {
1012        BoolOrStr::Bool(b) => b,
1013        BoolOrStr::Str(s) => matches!(
1014            s.trim().to_ascii_lowercase().as_str(),
1015            "true" | "eager" | "active" | "auto" | "on" | "1"
1016        ),
1017    })
1018}
1019
1020fn default_ui_path() -> String {
1021    "dist".to_string()
1022}
1023
1024fn default_app_type_native() -> AppType {
1025    AppType::Native
1026}
1027
1028impl AppManifest {
1029    /// Resolve top-level `requires` and `capabilities.requires` into one
1030    /// canonical declaration list. Equal aliases are accepted regardless of
1031    /// order or duplicates; differing aliases are rejected.
1032    pub fn resolved_requires(&self) -> Result<Vec<String>, String> {
1033        let top = normalized_requirements(&self.requires)?;
1034        let nested = normalized_requirements(&self.capabilities.requires)?;
1035        if !top.is_empty()
1036            && !nested.is_empty()
1037            && top.iter().cloned().collect::<BTreeSet<_>>()
1038                != nested.iter().cloned().collect::<BTreeSet<_>>()
1039        {
1040            return Err("top-level 'requires' conflicts with 'capabilities.requires'".to_string());
1041        }
1042        Ok(if !top.is_empty() { top } else { nested })
1043    }
1044
1045    /// Returns the effective `HotReloadKind` — explicit field or the default
1046    /// for the app type.
1047    pub fn effective_hot_reload(&self) -> HotReloadKind {
1048        self.hot_reload
1049            .unwrap_or_else(|| HotReloadKind::default_for(self.app_type))
1050    }
1051
1052    /// Returns the effective entrypoint — explicit field or the type-specific default.
1053    ///
1054    /// Standalone and UI-only apps have no daemon-managed entrypoint (systemd
1055    /// owns a standalone's lifecycle; a UI-only stage has no process); the
1056    /// empty string signals "not applicable".
1057    pub fn effective_entrypoint(&self) -> &str {
1058        if let Some(ref ep) = self.entrypoint {
1059            ep.as_str()
1060        } else {
1061            match self.app_type {
1062                AppType::Native => "app.so",
1063                AppType::Bun => "dist/index.js",
1064                AppType::Standalone => "",
1065                AppType::PlatformRuntime => "bun",
1066                AppType::ManagedV1 => "llmc-generated-app",
1067                AppType::Burger => "dist/index.js",
1068                AppType::UiOnly => "",
1069            }
1070        }
1071    }
1072
1073    /// True iff this manifest declares at least one capability provider
1074    /// (via either the v1 `provides` map or the v2 `capabilities.provides` list).
1075    pub fn has_capability_providers(&self) -> bool {
1076        !self.provides.is_empty() || !self.capabilities.provides.is_empty()
1077    }
1078
1079    /// Merges the v1 `provides` map and the v2 `capabilities.provides` name
1080    /// list into a single capability→declaration map (composition-root
1081    /// cleanup Round 4 T28 — extracted from
1082    /// `control_ipc::handlers::handle_app_register_standalone`, which uses
1083    /// this to shape a standalone app's declared providers for capability
1084    /// registration).
1085    ///
1086    /// - v1 entries (the `provides` map) carry their real
1087    ///   description/schema and always win on a name conflict.
1088    /// - v2-only names (declared only via `capabilities.provides`, format
1089    ///   `"name"` or `"name:extra"` — only the part before the first `:` is
1090    ///   used) get a blank declaration, inserted only if the name is not
1091    ///   already present from v1. Blank/whitespace-only names are skipped.
1092    pub fn resolved_capability_provides(&self) -> HashMap<String, ProvidedCapability> {
1093        let mut out: HashMap<String, ProvidedCapability> = self.provides.clone();
1094        for raw in &self.capabilities.provides {
1095            let name = raw.split(':').next().unwrap_or(raw).trim().to_string();
1096            if name.is_empty() {
1097                continue;
1098            }
1099            out.entry(name).or_insert(ProvidedCapability {
1100                description: String::new(),
1101                schema: None,
1102            });
1103        }
1104        out
1105    }
1106
1107    /// Refuse a `schedules` block that the host could not honour, or should not.
1108    ///
1109    /// Three distinct refusals, and the third is the one that matters for
1110    /// security: a schedule may only name a capability THIS app provides.
1111    /// The host registers the row as the app and the cron app records the app
1112    /// as its `created_by`, so an unrestricted `capability` field would let any
1113    /// package schedule repeated dispatches at anything on the node under its
1114    /// own name. A manifest is not a surface the owner reviews, so the gate is
1115    /// here, at install, and loud — not at 03:00 and silent.
1116    fn validate_schedules(&self) -> Result<(), String> {
1117        if self.schedules.is_empty() {
1118            return Ok(());
1119        }
1120        let provided = self.resolved_capability_provides();
1121        let mut seen: HashSet<&str> = HashSet::new();
1122        for schedule in &self.schedules {
1123            validate_schedule_id(&schedule.id)?;
1124            if !seen.insert(schedule.id.as_str()) {
1125                return Err(format!("duplicate schedule id '{}'", schedule.id));
1126            }
1127            validate_cron_shape(&schedule.cron)
1128                .map_err(|e| format!("schedule '{}': {}", schedule.id, e))?;
1129            if schedule.capability.trim().is_empty() {
1130                return Err(format!(
1131                    "schedule '{}': capability must not be blank",
1132                    schedule.id
1133                ));
1134            }
1135            if !provided.contains_key(schedule.capability.trim()) {
1136                return Err(format!(
1137                    "schedule '{}' names capability '{}', which this app does not provide \
1138                     (a schedule may only dispatch a capability declared in this manifest's \
1139                     'provides')",
1140                    schedule.id, schedule.capability
1141                ));
1142            }
1143            if let Some(payload) = &schedule.payload {
1144                if !payload.is_object() {
1145                    return Err(format!(
1146                        "schedule '{}': payload must be a JSON object",
1147                        schedule.id
1148                    ));
1149                }
1150            }
1151        }
1152        Ok(())
1153    }
1154
1155    /// Validate the manifest for structural correctness.
1156    ///
1157    /// Returns `Ok(())` on success, or a human-readable error string.
1158    /// Called by the manifest parser after deserialization.
1159    pub fn validate(&self) -> Result<(), String> {
1160        self.validate_with_socket_path_policy(false)
1161    }
1162
1163    /// Validate this manifest with an explicit standalone socket-path policy.
1164    ///
1165    /// Runtime adapters may opt into non-`/run` paths for development without
1166    /// making the domain model read process configuration.
1167    pub fn validate_with_socket_path_policy(&self, allow_non_run: bool) -> Result<(), String> {
1168        // v2 requires abi field
1169        if self.manifest_version == 2 && self.abi.is_none() {
1170            return Err("manifest_version 2 requires an 'abi' field".to_string());
1171        }
1172
1173        // Name validation: ^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$
1174        // (publisher/name form accepted but not yet semantically used — FR-019)
1175        validate_app_name(&self.name)?;
1176        self.resolved_requires()?;
1177
1178        // Path-safety on entrypoint and ui_path
1179        if let Some(ref ep) = self.entrypoint {
1180            validate_manifest_path(ep).map_err(|e| format!("entrypoint invalid: {}", e))?;
1181        }
1182        // ui_path is only meaningful when has_ui is true, but validate always
1183        if !self.ui_path.is_empty() && self.ui_path != "dist" {
1184            validate_manifest_path(&self.ui_path).map_err(|e| format!("ui_path invalid: {}", e))?;
1185        }
1186
1187        if let Some(ui) = &self.ui {
1188            validate_app_ui(&self.name, ui)?;
1189        }
1190
1191        // Homepage scheme validation (if present)
1192        if let Some(ref hp) = self.homepage {
1193            if !hp.starts_with("https://") && !hp.starts_with("http://") {
1194                return Err(format!(
1195                    "homepage '{}' must use https:// or http:// scheme",
1196                    hp
1197                ));
1198            }
1199        }
1200
1201        // Standalone-app rules:
1202        // - When `app_type == "standalone"` AND the manifest declares any
1203        //   capability providers, `standalone.socket_path` is required and
1204        //   must be an absolute path under `/run/` with no `..` segments.
1205        // - Non-standalone manifests MUST NOT carry a `standalone` block
1206        //   (rejected to surface accidental schema misuse).
1207        match self.app_type {
1208            AppType::Standalone => {
1209                if self.has_capability_providers() {
1210                    let cfg = self.standalone.as_ref().ok_or_else(|| {
1211                        "standalone apps that declare 'provides' require a \
1212                         'standalone.socket_path' field"
1213                            .to_string()
1214                    })?;
1215                    validate_standalone_socket_path_with_policy(&cfg.socket_path, allow_non_run)?;
1216                }
1217            }
1218            AppType::Native
1219            | AppType::Bun
1220            | AppType::PlatformRuntime
1221            | AppType::ManagedV1
1222            | AppType::Burger
1223            | AppType::UiOnly => {
1224                if self.standalone.is_some() {
1225                    return Err(format!(
1226                        "'standalone' block is only valid when app_type == 'standalone' \
1227                         (found app_type='{}')",
1228                        self.app_type
1229                    ));
1230                }
1231            }
1232        }
1233
1234        if self.app_type == AppType::UiOnly {
1235            validate_ui_only(self)?;
1236        }
1237
1238        if let Some(resources) = &self.resources {
1239            if resources.memory_mb == Some(0) {
1240                return Err("resources.memory_mb must be at least 1".to_string());
1241            }
1242            if let Some(deadline) = resources.callback_deadline_ms {
1243                if !(1..=MAX_CALLBACK_DEADLINE_MS).contains(&deadline) {
1244                    return Err(format!(
1245                        "resources.callback_deadline_ms must be between 1 and {MAX_CALLBACK_DEADLINE_MS} (found {deadline})"
1246                    ));
1247                }
1248            }
1249        }
1250
1251        self.validate_schedules()?;
1252
1253        if self.app_type == AppType::PlatformRuntime
1254            && !self.resolved_capability_provides().is_empty()
1255        {
1256            return Err(
1257                "platform-runtime packages cannot provide runtime capabilities".to_string(),
1258            );
1259        }
1260
1261        Ok(())
1262    }
1263
1264    /// Parse from a JSON string, validate, and return the manifest.
1265    pub fn from_json(json: &str) -> Result<Self, String> {
1266        Self::from_json_with_socket_path_policy(json, false)
1267    }
1268
1269    /// Parse and validate with an explicit standalone socket-path policy.
1270    pub fn from_json_with_socket_path_policy(
1271        json: &str,
1272        allow_non_run: bool,
1273    ) -> Result<Self, String> {
1274        let mut manifest: Self =
1275            serde_json::from_str(json).map_err(|e| format!("manifest JSON parse error: {}", e))?;
1276        if serde_json::from_str::<serde_json::Value>(json).is_ok_and(|raw| declares_ui_only(&raw)) {
1277            manifest.app_type = AppType::UiOnly;
1278        }
1279        if manifest.ui.is_some() {
1280            manifest.has_ui = true;
1281        }
1282        manifest.validate_with_socket_path_policy(allow_non_run)?;
1283        Ok(manifest)
1284    }
1285}
1286
1287fn normalized_requirements(values: &[String]) -> Result<Vec<String>, String> {
1288    let mut seen = HashSet::new();
1289    let mut resolved = Vec::new();
1290    for value in values {
1291        let requirement = value.trim();
1292        if requirement.is_empty() {
1293            return Err("capability requirements must not be blank".to_string());
1294        }
1295        if seen.insert(requirement.to_string()) {
1296            resolved.push(requirement.to_string());
1297        }
1298    }
1299    Ok(resolved)
1300}
1301
1302/// Contracts F5: a UI-only app declares a stage and nothing that implies a process.
1303fn validate_ui_only(manifest: &AppManifest) -> Result<(), String> {
1304    match &manifest.ui {
1305        Some(ui) if ui.kind == AppUiKind::Stage => {}
1306        _ => return Err("ui-only apps must declare a ui block of kind \"stage\"".to_string()),
1307    }
1308    match ui_only_process_declaration(manifest) {
1309        Some(what) => Err(format!(
1310            "ui-only apps have no backend process and must not declare {what}"
1311        )),
1312        None => Ok(()),
1313    }
1314}
1315
1316/// Contracts F5: the first thing `manifest` declares that implies a backend
1317/// process, which a UI-only app cannot have. A UI-only app is never started,
1318/// so it can neither receive events nor wait on dependencies. Shared by
1319/// [`AppManifest::validate`] and `node-app audit`.
1320pub fn ui_only_process_declaration(manifest: &AppManifest) -> Option<&'static str> {
1321    if manifest.entrypoint.is_some() {
1322        Some("'entrypoint'")
1323    } else if manifest.has_capability_providers() {
1324        Some("capability providers ('provides' / 'capabilities.provides')")
1325    } else if manifest.tcp.is_some() {
1326        Some("a 'tcp' block")
1327    } else if manifest.resources.is_some() {
1328        Some("a 'resources' block")
1329    } else if manifest.standalone.is_some() {
1330        Some("a 'standalone' block")
1331    } else if !manifest.subscribes.is_empty() {
1332        Some("event subscriptions ('subscribes')")
1333    } else if manifest
1334        .depends_on
1335        .as_ref()
1336        .is_some_and(|deps| !deps.is_empty())
1337    {
1338        Some("dependencies ('depends_on')")
1339    } else {
1340        None
1341    }
1342}
1343
1344/// Contracts F5: whether a raw manifest is UI-only — a `ui` object with
1345/// neither `app_type` nor `entrypoint`, or an explicit `"app_type":
1346/// "ui-only"`. Read off the raw JSON, because serde's `app_type` default
1347/// (`native`) hides whether the key was there. The one definition shared by
1348/// [`AppManifest::from_json`], `node-app audit` and `node-app package`.
1349///
1350/// Precedence: an explicit `app_type` other than `"ui-only"` always wins
1351/// over the derived form. A manifest with `"app_type": "bun"`, a `ui` block
1352/// and no `entrypoint` is a Bun app, not UI-only — the derived branch's
1353/// `!object.contains_key("app_type")` check is false, so it never fires, and
1354/// [`validate_ui_only`] is skipped for it. The derived form only applies
1355/// when `app_type` is absent entirely.
1356pub fn declares_ui_only(manifest: &serde_json::Value) -> bool {
1357    manifest.as_object().is_some_and(|object| {
1358        let derived = object.get("ui").is_some_and(serde_json::Value::is_object)
1359            && !object.contains_key("app_type")
1360            && !object.contains_key("entrypoint");
1361        derived || object.get("app_type").and_then(serde_json::Value::as_str) == Some("ui-only")
1362    })
1363}
1364
1365fn validate_app_ui(app_name: &str, ui: &AppUiManifest) -> Result<(), String> {
1366    if ui.ui_api != 1 && ui.ui_api != 2 {
1367        return Err(format!(
1368            "ui.ui_api {} is unsupported; only versions 1 and 2 are supported",
1369            ui.ui_api
1370        ));
1371    }
1372    if ui.title.trim().is_empty() {
1373        return Err("ui.title must not be blank".to_string());
1374    }
1375    ui.requires.resolved()?;
1376    validate_manifest_path(&ui.entry).map_err(|error| format!("ui.entry invalid: {error}"))?;
1377    if let Some(icon) = &ui.icon {
1378        validate_manifest_path(icon).map_err(|error| format!("ui.icon invalid: {error}"))?;
1379    }
1380    if let Some(nav) = &ui.nav {
1381        if ui.kind == AppUiKind::Widget {
1382            return Err("widget ui must omit nav metadata".to_string());
1383        }
1384        if nav.section.trim().is_empty() {
1385            return Err("ui.nav.section must not be blank".to_string());
1386        }
1387    }
1388    // Widgets own app data under exactly the stage rules. Which hosts accept
1389    // that is decided by the `ui.widget-data` host feature, not here
1390    // (see `host_contract::check_host_contract`).
1391    if let Some(data) = &ui.data {
1392        validate_app_data(app_name, data, &ui.requires.resolved()?)?;
1393    }
1394
1395    let mut composed = HashSet::new();
1396    for name in &ui.composes {
1397        validate_app_name(name).map_err(|error| format!("ui.composes entry invalid: {error}"))?;
1398        if name == app_name {
1399            return Err("ui.composes must not contain the app itself".to_string());
1400        }
1401        if !composed.insert(name) {
1402            return Err(format!("ui.composes contains duplicate app '{name}'"));
1403        }
1404    }
1405
1406    let mut surface_ids = HashSet::new();
1407    for surface in &ui.surfaces {
1408        let id = surface.id.trim();
1409        if id.is_empty() {
1410            return Err("ui.surfaces entry id must not be blank".to_string());
1411        }
1412        if !surface_ids.insert(id.to_string()) {
1413            return Err(format!("ui.surfaces contains duplicate id '{id}'"));
1414        }
1415        if !KNOWN_SURFACE_SLOTS.contains(&surface.slot.as_str()) {
1416            return Err(format!(
1417                "ui.surfaces entry '{id}' requests unknown slot '{}'; known slots: {}",
1418                surface.slot,
1419                KNOWN_SURFACE_SLOTS.join(", ")
1420            ));
1421        }
1422        if surface.title.trim().is_empty() {
1423            return Err(format!("ui.surfaces entry '{id}' title must not be blank"));
1424        }
1425        validate_manifest_path(&surface.entry)
1426            .map_err(|error| format!("ui.surfaces entry '{id}' entry invalid: {error}"))?;
1427        // Same rule `ui.entry` and `ui.icon` get below, and for a sharper reason: the client
1428        // kernel's `ensureIntegrityForUi` (`client/kernel/src/stages/stage-registry-service.js`)
1429        // REQUIRES a digest for every surface entry, and the throw there propagates out of
1430        // `parseCatalogEntry` through `parseCatalogResponse`'s `value.map(...)` — failing the
1431        // whole catalog snapshot, every stage on the node, and looping on retry. Without this
1432        // check a typo, or an entry emitted outside `ui_path` (which is the only tree
1433        // `generate_staged_integrity` stamps), packages cleanly, installs cleanly, and then
1434        // bricks every client's stage list. Refuse it here, where the author can still fix it.
1435        if !ui.integrity.contains_key(&surface.entry) {
1436            return Err(format!("ui.integrity must include surface '{id}' entry"));
1437        }
1438        surface
1439            .requires
1440            .resolved()
1441            .map_err(|error| format!("ui.surfaces entry '{id}' requires invalid: {error}"))?;
1442    }
1443
1444    for (path, digest) in &ui.integrity {
1445        validate_manifest_path(path)
1446            .map_err(|error| format!("ui.integrity path invalid: {error}"))?;
1447        if !is_lowercase_sha256(digest) {
1448            return Err(format!(
1449                "ui.integrity digest for '{path}' must be a lowercase 64-character SHA-256"
1450            ));
1451        }
1452    }
1453    if !ui.integrity.contains_key(&ui.entry) {
1454        return Err("ui.integrity must include the declared entry".to_string());
1455    }
1456    if let Some(icon) = &ui.icon {
1457        if !ui.integrity.contains_key(icon) {
1458            return Err("ui.integrity must include the declared icon".to_string());
1459        }
1460    }
1461    Ok(())
1462}
1463
1464fn validate_app_data(
1465    app_name: &str,
1466    data: &AppDataManifest,
1467    resolved_requires: &[String],
1468) -> Result<(), String> {
1469    validate_app_data_namespace(&data.namespace)?;
1470    validate_app_data_namespace_owner(app_name, &data.namespace)?;
1471    validate_app_data_sync_policy(&data.sync)?;
1472    if data.queries.is_empty() && data.offline != AppDataOfflinePolicy::OnlineOnly {
1473        return Err("ui.data.queries must declare at least one query for last-known data".to_string());
1474    }
1475
1476    let requires: BTreeSet<&str> = resolved_requires.iter().map(String::as_str).collect();
1477    let mut names = BTreeSet::new();
1478    for query in &data.queries {
1479        validate_namespaced_data_name(&query.name, &data.namespace)
1480            .map_err(|error| format!("ui.data query '{}' invalid: {error}", query.name))?;
1481        validate_capability_name(&query.capability).map_err(|error| {
1482            format!(
1483                "ui.data query '{}' capability '{}' invalid: {error}",
1484                query.name, query.capability
1485            )
1486        })?;
1487        if !requires.contains(query.capability.as_str()) {
1488            return Err(format!(
1489                "ui.data query '{}' capability '{}' must be declared in requires",
1490                query.name, query.capability
1491            ));
1492        }
1493        if !names.insert(query.name.as_str()) {
1494            return Err(format!("ui.data contains duplicate query '{}'", query.name));
1495        }
1496    }
1497
1498    for stream in &data.streams {
1499        validate_namespaced_data_name(&stream.name, &data.namespace)
1500            .map_err(|error| format!("ui.data stream '{}' invalid: {error}", stream.name))?;
1501        if !names.insert(stream.name.as_str()) {
1502            return Err(format!(
1503                "ui.data contains duplicate declaration '{}'",
1504                stream.name
1505            ));
1506        }
1507    }
1508
1509    Ok(())
1510}
1511
1512fn validate_app_data_namespace(namespace: &str) -> Result<(), String> {
1513    if !is_safe_name_segment(namespace) {
1514        return Err(format!(
1515            "ui.data namespace '{}' must match [a-z][a-z0-9-]*",
1516            namespace
1517        ));
1518    }
1519    if matches!(
1520        namespace,
1521        "core" | "internal" | "node" | "platform" | "system"
1522    ) {
1523        return Err(format!("ui.data namespace '{namespace}' is reserved"));
1524    }
1525    Ok(())
1526}
1527
1528/// A stage's projections are stored under `ui.data.namespace`, and the
1529/// Client Node PWA only accepts a namespace the app owns
1530/// (`validateEntryCompatibility`,
1531/// `client/kernel/src/stages/offline-readiness-coordinator.js`): any other
1532/// stage fails there as `schema-incompatible` and never reaches the nav. The
1533/// client also admits `<app>.`-prefixed namespaces, but a namespace cannot
1534/// contain a dot ([`validate_app_data_namespace`]), so here the rule is plain
1535/// equality. node-app-burger 0.2.0 shipped `burger` for `burger-runtime`.
1536///
1537/// Public so `node-app audit` reports the same rule, with the same message.
1538pub fn validate_app_data_namespace_owner(app_name: &str, namespace: &str) -> Result<(), String> {
1539    if namespace != app_name {
1540        return Err(format!(
1541            "ui.data namespace '{namespace}' must equal the app name '{app_name}' — the Client \
1542             Node PWA refuses a stage whose data namespace it does not own; rename the namespace \
1543             to '{app_name}' and its query and stream names to '{app_name}.<name>.v<N>'"
1544        ));
1545    }
1546    Ok(())
1547}
1548
1549fn validate_app_data_sync_policy(sync: &AppDataSyncPolicy) -> Result<(), String> {
1550    validate_optional_range("cursor_ttl_secs", sync.cursor_ttl_secs, 60, 86_400)?;
1551    validate_optional_range(
1552        "full_refresh_interval_secs",
1553        sync.full_refresh_interval_secs,
1554        60,
1555        604_800,
1556    )?;
1557    validate_optional_range("retention_secs", sync.retention_secs, 300, 31_536_000)?;
1558    if sync.kind == AppDataSyncKind::Snapshot && sync.cursor_ttl_secs.is_some() {
1559        return Err("ui.data.sync cursor_ttl_secs is only valid for cursor sync".to_string());
1560    }
1561    Ok(())
1562}
1563
1564fn validate_optional_range(
1565    field: &str,
1566    value: Option<u32>,
1567    min: u32,
1568    max: u32,
1569) -> Result<(), String> {
1570    if let Some(value) = value {
1571        if value < min || value > max {
1572            return Err(format!(
1573                "ui.data.sync {field} must be between {min} and {max} seconds"
1574            ));
1575        }
1576    }
1577    Ok(())
1578}
1579
1580fn validate_namespaced_data_name(name: &str, namespace: &str) -> Result<(), String> {
1581    validate_capability_name(name)?;
1582    let Some(rest) = name
1583        .strip_prefix(namespace)
1584        .and_then(|suffix| suffix.strip_prefix('.'))
1585    else {
1586        return Err(format!("name must use namespace '{namespace}'"));
1587    };
1588    if rest.is_empty() {
1589        return Err("name must include a value after its namespace".to_string());
1590    }
1591    if !has_version_suffix(name) {
1592        return Err("name must end with a .vN version suffix".to_string());
1593    }
1594    Ok(())
1595}
1596
1597fn validate_capability_name(name: &str) -> Result<(), String> {
1598    if name.is_empty() {
1599        return Err("name must not be empty".to_string());
1600    }
1601    if name.contains('/') || name.contains("..") {
1602        return Err("name must not contain path separators or traversal".to_string());
1603    }
1604    if !name.split('.').all(is_safe_declaration_segment) {
1605        return Err("name must contain only lowercase dot-separated segments".to_string());
1606    }
1607    Ok(())
1608}
1609
1610fn validate_ui_requirement_name(name: &str, allow_wildcard: bool) -> Result<(), String> {
1611    if allow_wildcard && name.ends_with(".*") {
1612        return validate_capability_name(&name[..name.len() - 2]);
1613    }
1614    validate_capability_name(name)
1615}
1616
1617fn has_version_suffix(name: &str) -> bool {
1618    let Some(version) = name.rsplit('.').next() else {
1619        return false;
1620    };
1621    let Some(digits) = version.strip_prefix('v') else {
1622        return false;
1623    };
1624    !digits.is_empty()
1625        && !digits.starts_with('0')
1626        && digits.bytes().all(|byte| byte.is_ascii_digit())
1627}
1628
1629fn is_safe_name_segment(segment: &str) -> bool {
1630    if segment.is_empty() {
1631        return false;
1632    }
1633    let mut chars = segment.chars();
1634    let Some(first) = chars.next() else {
1635        return false;
1636    };
1637    first.is_ascii_lowercase()
1638        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1639}
1640
1641/// A segment of a capability, query, or stream name.
1642///
1643/// Deliberately looser than [`is_safe_name_segment`] by exactly one character:
1644/// `_`. Capability actions in this codebase are snake_case almost without
1645/// exception (`core.lightning.create_invoice`, `core.did.current_did`,
1646/// `contest.world.studio_state`), and app-event resources are too
1647/// (`app.agent_session` — `APP_EVENT_RESOURCE_PATTERN` in `@econ-v1/domain`
1648/// admits `_` for precisely these). Rejecting `_` here did not make a stage
1649/// safer, it made `ui.requires` unusable: a stage that declared any real
1650/// capability failed `resolved()`, and `build_ui_stage_catalog` then dropped
1651/// that stage from the shell entirely. The characters that actually matter —
1652/// path separators, traversal, uppercase, leading digits — are still refused.
1653fn is_safe_declaration_segment(segment: &str) -> bool {
1654    let mut chars = segment.chars();
1655    let Some(first) = chars.next() else {
1656        return false;
1657    };
1658    first.is_ascii_lowercase()
1659        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
1660}
1661
1662fn is_lowercase_sha256(value: &str) -> bool {
1663    value.len() == 64
1664        && value
1665            .bytes()
1666            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1667}
1668
1669/// Validate a `StandaloneConfig::socket_path`.
1670///
1671/// Rules:
1672/// 1. Absolute path (starts with `/`).
1673/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc. — pins the
1674///    socket to a tmpfs path predictably writable by the standalone daemon).
1675///    Runtime adapters can explicitly bypass this restriction for development.
1676/// 3. No `..` segments anywhere in the path.
1677pub fn validate_standalone_socket_path(path: &std::path::Path) -> Result<(), String> {
1678    validate_standalone_socket_path_with_policy(path, false)
1679}
1680
1681/// Validate a standalone socket path with an explicit runtime policy.
1682pub fn validate_standalone_socket_path_with_policy(
1683    path: &std::path::Path,
1684    allow_non_run: bool,
1685) -> Result<(), String> {
1686    if !path.is_absolute() {
1687        return Err(format!(
1688            "standalone.socket_path '{}' must be absolute",
1689            path.display()
1690        ));
1691    }
1692    if !allow_non_run && !path.starts_with("/run/") {
1693        return Err(format!(
1694            "standalone.socket_path '{}' must live under /run/",
1695            path.display()
1696        ));
1697    }
1698    if path
1699        .components()
1700        .any(|c| matches!(c, std::path::Component::ParentDir))
1701    {
1702        return Err(format!(
1703            "standalone.socket_path '{}' must not contain '..' segments",
1704            path.display()
1705        ));
1706    }
1707    Ok(())
1708}
1709
1710/// Validate an app name string.
1711///
1712/// Accepts `app-name` (simple) and `publisher/app-name` (publisher-prefixed, FR-019).
1713fn validate_app_name(name: &str) -> Result<(), String> {
1714    let (publisher, app) = if let Some(slash) = name.find('/') {
1715        let (p, rest) = name.split_at(slash);
1716        (Some(p), &rest[1..])
1717    } else {
1718        (None, name)
1719    };
1720
1721    let valid_segment = |s: &str| -> bool {
1722        if s.is_empty() {
1723            return false;
1724        }
1725        let mut chars = s.chars();
1726        let first = chars.next().unwrap();
1727        if !first.is_ascii_lowercase() {
1728            return false;
1729        }
1730        chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
1731    };
1732
1733    if let Some(pub_name) = publisher {
1734        if !valid_segment(pub_name) {
1735            return Err(format!(
1736                "publisher segment '{}' must match [a-z][a-z0-9-]*",
1737                pub_name
1738            ));
1739        }
1740    }
1741
1742    if !valid_segment(app) {
1743        return Err(format!(
1744            "app name segment '{}' must match [a-z][a-z0-9-]*",
1745            app
1746        ));
1747    }
1748
1749    Ok(())
1750}
1751
1752// ── Tests ─────────────────────────────────────────────────────────────────────
1753
1754#[cfg(test)]
1755mod tests {
1756    use super::*;
1757
1758    fn parse_ok(json: &str) -> AppManifest {
1759        AppManifest::from_json(json).expect("should parse")
1760    }
1761
1762    fn parse_err(json: &str) -> String {
1763        AppManifest::from_json(json).expect_err("should fail")
1764    }
1765
1766    // ── schedules (econ-v1/node#3185) ────────────────────────────────────────
1767
1768    const SCHEDULED_APP: &str = r#"{
1769        "name":"network","version":"1.0.0","app_type":"bun","auto_start":"lazy",
1770        "provides":{"network.ledger.poll":{"description":"Record connections"}},
1771        "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1772                      "capability":"network.ledger.poll"}]
1773    }"#;
1774
1775    #[test]
1776    fn a_manifest_with_no_schedules_block_parses_to_an_empty_list() {
1777        // The default has to stay byte-for-byte the old behaviour: every
1778        // manifest on every installed node predates this field.
1779        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1780        assert!(m.schedules.is_empty());
1781    }
1782
1783    #[test]
1784    fn a_schedule_is_parsed_whole() {
1785        let m = parse_ok(SCHEDULED_APP);
1786        assert_eq!(m.schedules.len(), 1);
1787        let s = &m.schedules[0];
1788        assert_eq!(s.id, "ledger-poll");
1789        assert_eq!(s.cron, "0 */15 * * * *");
1790        assert_eq!(s.capability, "network.ledger.poll");
1791        // An absent payload is an empty object, not null — the router stamps
1792        // `caller` into it, which requires an object.
1793        assert_eq!(s.effective_payload(), serde_json::json!({}));
1794    }
1795
1796    #[test]
1797    fn a_schedule_may_only_dispatch_a_capability_this_app_provides() {
1798        // The security gate. Without it any package could schedule repeated
1799        // dispatches at anything on the node under its own name.
1800        let err = parse_err(
1801            r#"{"name":"network","version":"1.0.0","app_type":"bun",
1802                "provides":{"network.ledger.poll":{"description":"x"}},
1803                "schedules":[{"id":"drain","cron":"0 0 * * * *",
1804                              "capability":"core.lightning.send_payment"}]}"#,
1805        );
1806        assert!(err.contains("core.lightning.send_payment"), "{err}");
1807        assert!(err.contains("does not provide"), "{err}");
1808    }
1809
1810    #[test]
1811    fn a_v2_capabilities_provides_entry_also_satisfies_the_gate() {
1812        // v2 manifests list provided capability NAMES under `capabilities.provides`
1813        // rather than the v1 `provides` map; both are the same declaration.
1814        let m = parse_ok(
1815            r#"{"manifest_version":2,"abi":"v1","name":"network","version":"1.0.0",
1816                "app_type":"bun",
1817                "capabilities":{"provides":["network.ledger.poll"]},
1818                "schedules":[{"id":"ledger-poll","cron":"0 */15 * * * *",
1819                              "capability":"network.ledger.poll"}]}"#,
1820        );
1821        assert_eq!(m.schedules.len(), 1);
1822    }
1823
1824    #[test]
1825    fn a_cron_expression_without_six_fields_is_refused() {
1826        // 5 fields is the Unix crontab shape; this platform's cron takes 6.
1827        let err = parse_err(
1828            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1829                "provides":{"n.poll":{"description":"x"}},
1830                "schedules":[{"id":"p","cron":"*/15 * * * *","capability":"n.poll"}]}"#,
1831        );
1832        assert!(err.contains("6 fields"), "{err}");
1833    }
1834
1835    #[test]
1836    fn a_cron_expression_with_junk_in_it_is_refused() {
1837        let err = parse_err(
1838            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1839                "provides":{"n.poll":{"description":"x"}},
1840                "schedules":[{"id":"p","cron":"0 0 2 * * $(whoami)","capability":"n.poll"}]}"#,
1841        );
1842        assert!(err.contains("disallowed character"), "{err}");
1843    }
1844
1845    #[test]
1846    fn two_schedules_cannot_share_an_id() {
1847        // The id is the cron row's `external_id`; a duplicate would make the
1848        // reconcile's read-before-write lookup ambiguous.
1849        let err = parse_err(
1850            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1851                "provides":{"n.poll":{"description":"x"}},
1852                "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll"},
1853                             {"id":"p","cron":"0 30 * * * *","capability":"n.poll"}]}"#,
1854        );
1855        assert!(err.contains("duplicate schedule id"), "{err}");
1856    }
1857
1858    #[test]
1859    fn a_schedule_id_must_be_a_safe_token() {
1860        let err = parse_err(
1861            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1862                "provides":{"n.poll":{"description":"x"}},
1863                "schedules":[{"id":"../escape","cron":"0 0 * * * *","capability":"n.poll"}]}"#,
1864        );
1865        assert!(err.contains("schedule id"), "{err}");
1866    }
1867
1868    #[test]
1869    fn a_non_object_payload_is_refused() {
1870        let err = parse_err(
1871            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1872                "provides":{"n.poll":{"description":"x"}},
1873                "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1874                              "payload":"not-an-object"}]}"#,
1875        );
1876        assert!(err.contains("JSON object"), "{err}");
1877    }
1878
1879    #[test]
1880    fn a_declared_payload_survives_the_round_trip() {
1881        let m = parse_ok(
1882            r#"{"name":"n","version":"1.0.0","app_type":"bun",
1883                "provides":{"n.poll":{"description":"x"}},
1884                "schedules":[{"id":"p","cron":"0 0 * * * *","capability":"n.poll",
1885                              "payload":{"depth":2}}]}"#,
1886        );
1887        assert_eq!(m.schedules[0].effective_payload(), serde_json::json!({"depth": 2}));
1888        let round_tripped: AppManifest =
1889            serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
1890        assert_eq!(round_tripped.schedules, m.schedules);
1891    }
1892
1893    #[test]
1894    fn an_empty_schedules_list_is_omitted_from_the_serialized_form() {
1895        // So re-serializing an old manifest does not grow a field it never had.
1896        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1897        let json = serde_json::to_string(&m).unwrap();
1898        assert!(!json.contains("schedules"), "{json}");
1899    }
1900
1901    // ── v1 manifests ──────────────────────────────────────────────────────────
1902
1903    #[test]
1904    fn v1_minimal_native() {
1905        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
1906        assert_eq!(m.manifest_version, 1);
1907        assert_eq!(m.app_type, AppType::Native);
1908        assert!(m.abi.is_none());
1909    }
1910
1911    #[test]
1912    fn v1_minimal_bun() {
1913        let m = parse_ok(r#"{"name":"my-app","version":"0.1.0","app_type":"bun"}"#);
1914        assert_eq!(m.app_type, AppType::Bun);
1915        assert_eq!(m.effective_entrypoint(), "dist/index.js");
1916    }
1917
1918    #[test]
1919    fn v1_no_manifest_version_field_defaults_to_1() {
1920        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1921        assert_eq!(m.manifest_version, 1);
1922    }
1923
1924    #[test]
1925    fn v1_all_optional_fields_missing() {
1926        let m = parse_ok(r#"{"name":"example","version":"1.0.0","app_type":"bun"}"#);
1927        assert!(!m.critical);
1928        assert!(m.auto_start);
1929        assert!(!m.has_ui);
1930        assert_eq!(m.ui_path, "dist");
1931        assert!(m.permissions.is_empty());
1932        assert!(m.optional_permissions.is_empty());
1933        // #1556: governor/subscribes absent → today's implicit behavior
1934        // (no opt-out, no min-idle override, no declared subscriptions).
1935        assert!(m.governor.is_none());
1936        assert!(m.subscribes.is_empty());
1937    }
1938
1939    #[test]
1940    fn v1_with_permissions_and_provides() {
1941        let json = r#"{
1942            "name": "example",
1943            "version": "1.0.0",
1944            "app_type": "bun",
1945            "permissions": ["core.storage.kv"],
1946            "optional_permissions": ["core.notifications.create"],
1947            "provides": {
1948                "core.example.run": { "description": "Run example job" }
1949            }
1950        }"#;
1951        let m = parse_ok(json);
1952        assert_eq!(m.permissions, vec!["core.storage.kv"]);
1953        assert_eq!(m.optional_permissions, vec!["core.notifications.create"]);
1954        assert!(m.provides.contains_key("core.example.run"));
1955    }
1956
1957    // ── v2 manifests ──────────────────────────────────────────────────────────
1958
1959    #[test]
1960    fn v2_minimal_native() {
1961        let json = r#"{
1962            "manifest_version": 2,
1963            "name": "cron",
1964            "version": "1.0.0",
1965            "app_type": "native",
1966            "abi": "v1",
1967            "entrypoint": "app.so",
1968            "hot_reload": "experimental"
1969        }"#;
1970        let m = parse_ok(json);
1971        assert_eq!(m.manifest_version, 2);
1972        assert_eq!(m.abi, Some(AbiVersion::V1));
1973        assert_eq!(m.entrypoint.as_deref(), Some("app.so"));
1974        assert_eq!(m.hot_reload, Some(HotReloadKind::Experimental));
1975    }
1976
1977    #[test]
1978    fn v2_minimal_bun_with_capabilities() {
1979        let json = r#"{
1980            "manifest_version": 2,
1981            "name": "example-fullstack",
1982            "version": "1.0.0",
1983            "app_type": "bun",
1984            "abi": "v1",
1985            "entrypoint": "dist/index.js",
1986            "hot_reload": "supported",
1987            "has_ui": true,
1988            "ui_path": "ui/dist",
1989            "capabilities": {
1990                "requires": ["core.storage.kv", "core.lightning.payment.send:max=500sat/day"],
1991                "provides": []
1992            },
1993            "governor": { "terminable": false, "min_idle_secs": 300 },
1994            "subscribes": ["core.chat.message.received"]
1995        }"#;
1996        let m = parse_ok(json);
1997        assert_eq!(m.manifest_version, 2);
1998        assert_eq!(m.capabilities.requires.len(), 2);
1999        // #1556: governor/subscribes present → parsed through verbatim.
2000        let governor = m.governor.expect("governor block should parse");
2001        assert_eq!(governor.terminable, Some(false));
2002        assert_eq!(governor.min_idle_secs, Some(300));
2003        assert_eq!(m.subscribes, vec!["core.chat.message.received"]);
2004    }
2005
2006    #[test]
2007    fn stage_contract_fixture_parses_with_normalized_requirements() {
2008        let json = include_str!(
2009            "../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
2010        );
2011        let m = parse_ok(json);
2012        assert!(m.has_ui);
2013        assert_eq!(m.resolved_requires().unwrap(), vec!["core.metrics.latest"]);
2014        let ui = m.ui.expect("fixture should declare ui");
2015        assert_eq!(ui.kind, AppUiKind::Stage);
2016        assert_eq!(ui.entry, "ui/main.js");
2017        assert_eq!(ui.nav.unwrap().order, 10);
2018    }
2019
2020    #[test]
2021    fn omitted_ui_keeps_legacy_flags_without_fabricating_a_stage() {
2022        let m = parse_ok(
2023            r#"{"name":"legacy","version":"1.0.0","app_type":"bun","has_ui":true,"ui_path":"ui/dist"}"#,
2024        );
2025        assert!(m.has_ui);
2026        assert_eq!(m.ui_path, "ui/dist");
2027        assert!(m.ui.is_none());
2028    }
2029
2030    #[test]
2031    fn ui_requirements_are_typed_serialized_and_separate_from_backend_requires() {
2032        let manifest = parse_ok(
2033            r#"{
2034                "name":"ui-contract","version":"1.0.0","app_type":"bun",
2035                "requires":["core.cron.register"],
2036                "ui":{
2037                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2038                    "requires":{
2039                        "capabilities":["ui.snapshot.v1"],
2040                        "queries":["ui.query.v1"],
2041                        "streams":["ui.event.v1"]
2042                    },
2043                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2044                }
2045            }"#,
2046        );
2047
2048        assert_eq!(
2049            manifest.resolved_requires().unwrap(),
2050            vec!["core.cron.register"]
2051        );
2052        let ui = manifest.ui.as_ref().expect("ui requirements should parse");
2053        assert_eq!(ui.requires.capabilities, vec!["ui.snapshot.v1"]);
2054        assert_eq!(ui.requires.queries, vec!["ui.query.v1"]);
2055        assert_eq!(ui.requires.streams, vec!["ui.event.v1"]);
2056        assert_eq!(
2057            ui.requires.resolved().unwrap(),
2058            vec!["ui.snapshot.v1", "ui.query.v1", "ui.event.v1"]
2059        );
2060        let serialized = serde_json::to_value(ui).unwrap();
2061        assert_eq!(
2062            serialized["requires"]["queries"],
2063            serde_json::json!(["ui.query.v1"])
2064        );
2065    }
2066
2067    #[test]
2068    fn ui_requirements_reject_blank_entries() {
2069        let error = parse_err(
2070            r#"{
2071                "name":"ui-contract","version":"1.0.0","app_type":"bun",
2072                "ui":{
2073                    "kind":"stage","entry":"ui/main.js","title":"UI contract","ui_api":1,
2074                    "requires":{"capabilities":[""],"queries":[],"streams":[]},
2075                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
2076                }
2077            }"#,
2078        );
2079        assert!(error.contains("ui.requires entries must not be blank"));
2080    }
2081
2082    #[test]
2083    fn ui_data_namespace_stays_hyphen_only_when_declarations_allow_underscore() {
2084        // `is_safe_declaration_segment` deliberately admits `_` so `ui.requires`
2085        // can name real capabilities. `ui.data.namespace` is a different thing —
2086        // a storage key, contract `[a-z][a-z0-9-]*` — and keeps the stricter
2087        // `is_safe_name_segment`. Nothing else pins that separation, so a future
2088        // refactor collapsing the two predicates back together would silently
2089        // widen the namespace rule. This is the tripwire for that.
2090        assert!(validate_app_data_namespace("obs-viewer").is_ok());
2091
2092        let error = validate_app_data_namespace("obs_viewer")
2093            .expect_err("underscore must not be admitted into a storage namespace");
2094        assert!(
2095            error.contains("must match [a-z][a-z0-9-]*"),
2096            "unexpected error: {error}"
2097        );
2098    }
2099
2100    #[test]
2101    fn ui_data_query_capability_does_not_fall_back_to_backend_requires() {
2102        let error = parse_err(
2103            r#"{
2104                "name":"ui-data","version":"1.0.0","app_type":"bun",
2105                "requires":["ui.snapshot.v1"],
2106                "ui":{
2107                    "kind":"stage","entry":"ui/main.js","title":"UI data","ui_api":1,
2108                    "requires":{"capabilities":[],"queries":[],"streams":[]},
2109                    "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
2110                    "data":{
2111                        "namespace":"ui-data","offline":"last-known","sync":{"kind":"cursor"},
2112                        "queries":[{"name":"ui-data.snapshot.v1","capability":"ui.snapshot.v1","kind":"snapshot"}],
2113                        "streams":[]
2114                    }
2115                }
2116            }"#,
2117        );
2118        assert!(error.contains("must be declared in requires"));
2119    }
2120
2121    /// node-app-burger 0.2.0 shipped `ui.data.namespace: "burger"` for the app
2122    /// `burger-runtime`. The host accepted it, the Client Node PWA refused it
2123    /// as `schema-incompatible`, and the stage silently never appeared. The
2124    /// rule now fails `node-app validate` instead.
2125    #[test]
2126    fn ui_data_namespace_must_equal_the_app_name() {
2127        let manifest = |namespace: &str| {
2128            format!(
2129                r#"{{
2130                    "name":"burger-runtime","version":"0.2.0",
2131                    "ui":{{
2132                        "kind":"stage","entry":"ui/dist/main.js","title":"Burger","ui_api":1,
2133                        "requires":{{"capabilities":["core.runtime.burger_snapshot"]}},
2134                        "integrity":{{"ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},
2135                        "data":{{
2136                            "namespace":"{namespace}","offline":"online-only","sync":"snapshot",
2137                            "queries":[{{"name":"{namespace}.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}}],
2138                            "streams":[{{"name":"{namespace}.metrics.v1","kind":"events"}}]
2139                        }}
2140                    }}
2141                }}"#
2142            )
2143        };
2144
2145        let error = parse_err(&manifest("burger"));
2146        assert!(
2147            error.contains("ui.data namespace 'burger' must equal the app name 'burger-runtime'"),
2148            "unexpected error: {error}"
2149        );
2150
2151        let accepted = parse_ok(&manifest("burger-runtime"));
2152        let data = accepted.ui.as_ref().and_then(|ui| ui.data.as_ref());
2153        assert_eq!(
2154            data.map(|data| data.namespace.as_str()),
2155            Some("burger-runtime")
2156        );
2157    }
2158
2159    #[test]
2160    fn top_level_and_nested_requires_must_resolve_to_the_same_set() {
2161        let accepted = parse_ok(
2162            r#"{
2163                "name":"aliases","version":"1.0.0","app_type":"bun",
2164                "requires":["core.chat.read","core.chat.read","core.chat.send"],
2165                "capabilities":{"requires":["core.chat.send","core.chat.read"]}
2166            }"#,
2167        );
2168        assert_eq!(
2169            accepted.resolved_requires().unwrap(),
2170            vec!["core.chat.read", "core.chat.send"]
2171        );
2172
2173        let err = parse_err(
2174            r#"{
2175                "name":"aliases","version":"1.0.0","app_type":"bun",
2176                "requires":["core.chat.read"],
2177                "capabilities":{"requires":["core.wallet.pay"]}
2178            }"#,
2179        );
2180        assert!(err.contains("conflicts"), "unexpected error: {err}");
2181    }
2182
2183    #[test]
2184    fn stage_and_widget_ui_kinds_have_distinct_navigation_rules() {
2185        let base = |ui: &str| {
2186            format!(r#"{{"name":"stage","version":"1.0.0","app_type":"bun","ui":{ui}}}"#)
2187        };
2188        let widget = base(
2189            r#"{"kind":"widget","entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2190        );
2191        assert_eq!(
2192            parse_ok(&widget).ui.expect("widget ui").kind,
2193            AppUiKind::Widget
2194        );
2195        let widget_nav = base(
2196            r#"{"kind":"widget","entry":"ui/main.js","title":"Widget","nav":{"section":"default","order":1},"ui_api":1,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2197        );
2198        assert!(parse_err(&widget_nav).contains("must omit nav"));
2199        let api = base(
2200            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":2,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2201        );
2202        assert_eq!(parse_ok(&api).ui.expect("v2 stage ui").ui_api, 2);
2203        let unsupported_api = base(
2204            r#"{"kind":"stage","entry":"ui/main.js","title":"Stage","ui_api":3,"integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2205        );
2206        assert!(parse_err(&unsupported_api).contains("ui_api"));
2207        let path = base(
2208            r#"{"kind":"stage","entry":"../main.js","title":"Stage","ui_api":1,"integrity":{"../main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#,
2209        );
2210        assert!(parse_err(&path).contains("entry"));
2211    }
2212
2213    #[test]
2214    fn stage_requires_integrity_for_entry_and_icon() {
2215        let missing_entry = r#"{
2216            "name":"stage","version":"1.0.0","app_type":"bun",
2217            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,"integrity":{}}
2218        }"#;
2219        assert!(parse_err(missing_entry).contains("entry"));
2220        let missing_icon = r#"{
2221            "name":"stage","version":"1.0.0","app_type":"bun",
2222            "ui":{"entry":"ui/main.js","icon":"ui/icon.svg","title":"Stage","ui_api":1,
2223            "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
2224        }"#;
2225        assert!(parse_err(missing_icon).contains("icon"));
2226        let uppercase = r#"{
2227            "name":"stage","version":"1.0.0","app_type":"bun",
2228            "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2229            "integrity":{"ui/main.js":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}
2230        }"#;
2231        assert!(parse_err(uppercase).contains("lowercase"));
2232    }
2233
2234    #[test]
2235    fn stage_composes_rejects_self_duplicate_and_unsafe_names() {
2236        let manifest = |composes: &str| {
2237            format!(
2238                r#"{{
2239                    "name":"stage","version":"1.0.0","app_type":"bun",
2240                    "ui":{{"entry":"ui/main.js","title":"Stage","ui_api":1,
2241                    "composes":{composes},
2242                    "integrity":{{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}}
2243                }}"#
2244            )
2245        };
2246        assert!(parse_err(&manifest(r#"["stage"]"#)).contains("itself"));
2247        assert!(parse_err(&manifest(r#"["chat","chat"]"#)).contains("duplicate"));
2248        assert!(parse_err(&manifest(r#"["../chat"]"#)).contains("invalid"));
2249    }
2250
2251    // ── ui.surfaces[] ────────────────────────────────────────────────────────
2252
2253    /// A minimal valid manifest with a `ui` block, for tests that only care
2254    /// about `ui.surfaces`. Mirrors the fixture used by
2255    /// `stage_requires_integrity_for_entry_and_icon` above.
2256    ///
2257    /// The integrity map covers `surface()`'s entry as well as `ui.entry`, because a surface
2258    /// entry must be integrity-pinned exactly like the stage entry and the icon — see
2259    /// `surface_entry_missing_from_integrity_is_rejected`. Before that rule existed this fixture
2260    /// declared a surface no digest covered, which is precisely the manifest the client kernel
2261    /// refuses.
2262    fn manifest_with_ui() -> AppManifest {
2263        parse_ok(
2264            r#"{
2265                "name":"stage","version":"1.0.0","app_type":"bun",
2266                "ui":{"entry":"ui/main.js","title":"Stage","ui_api":1,
2267                "integrity":{"ui/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2268                "ui/dist/surfaces/chip.js":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}
2269            }"#,
2270        )
2271    }
2272
2273    fn surface(id: &str, slot: &str) -> AppUiSurface {
2274        AppUiSurface {
2275            id: id.to_string(),
2276            slot: slot.to_string(),
2277            entry: "ui/dist/surfaces/chip.js".to_string(),
2278            title: "Chip".to_string(),
2279            order: 10,
2280            requires: AppUiRequirements {
2281                capabilities: vec!["wallet.balance.get".to_string()],
2282                ..Default::default()
2283            },
2284        }
2285    }
2286
2287    #[test]
2288    fn manifest_without_surfaces_still_parses() {
2289        let manifest = manifest_with_ui();
2290        assert!(manifest.ui.as_ref().unwrap().surfaces.is_empty());
2291        assert!(manifest.validate().is_ok());
2292    }
2293
2294    #[test]
2295    fn surface_in_a_known_slot_is_accepted() {
2296        let mut manifest = manifest_with_ui();
2297        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "status-rail")];
2298        assert!(manifest.validate().is_ok());
2299    }
2300
2301    #[test]
2302    fn surface_in_an_unknown_slot_is_rejected() {
2303        let mut manifest = manifest_with_ui();
2304        manifest.ui.as_mut().unwrap().surfaces = vec![surface("balance-chip", "menu-bar")];
2305        let error = manifest.validate().unwrap_err();
2306        assert!(error.contains("menu-bar"), "unexpected error: {error}");
2307    }
2308
2309    #[test]
2310    fn duplicate_surface_ids_are_rejected() {
2311        let mut manifest = manifest_with_ui();
2312        manifest.ui.as_mut().unwrap().surfaces = vec![
2313            surface("chip", "status-rail"),
2314            surface("chip", "status-rail"),
2315        ];
2316        let error = manifest.validate().unwrap_err();
2317        assert!(error.contains("duplicate"), "unexpected error: {error}");
2318    }
2319
2320    #[test]
2321    fn surface_with_a_blank_id_is_rejected() {
2322        let mut manifest = manifest_with_ui();
2323        manifest.ui.as_mut().unwrap().surfaces = vec![surface("  ", "status-rail")];
2324        assert!(manifest.validate().is_err());
2325    }
2326
2327    #[test]
2328    fn surface_with_an_unsafe_entry_path_is_rejected() {
2329        let mut manifest = manifest_with_ui();
2330        let mut bad = surface("chip", "status-rail");
2331        bad.entry = "../../etc/passwd".to_string();
2332        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2333        assert!(manifest.validate().is_err());
2334    }
2335
2336    #[test]
2337    fn surface_entry_missing_from_integrity_is_rejected() {
2338        // The client kernel requires a digest for every surface entry and fails the WHOLE
2339        // catalog snapshot when one is missing, so a manifest that packages without one bricks
2340        // every installing node's stage list. Catch it at package time instead.
2341        let mut manifest = manifest_with_ui();
2342        let mut unpinned = surface("chip", "status-rail");
2343        unpinned.entry = "ui/dist/surfaces/typo.js".to_string();
2344        manifest.ui.as_mut().unwrap().surfaces = vec![unpinned];
2345        let error = manifest.validate().unwrap_err();
2346        assert!(
2347            error.contains("ui.integrity must include surface 'chip' entry"),
2348            "unexpected error: {error}"
2349        );
2350    }
2351
2352    #[test]
2353    fn surface_with_a_blank_required_capability_is_rejected() {
2354        let mut manifest = manifest_with_ui();
2355        let mut bad = surface("chip", "status-rail");
2356        bad.requires.capabilities = vec!["   ".to_string()];
2357        manifest.ui.as_mut().unwrap().surfaces = vec![bad];
2358        assert!(manifest.validate().is_err());
2359    }
2360
2361    #[test]
2362    fn v2_missing_abi_is_error() {
2363        let json = r#"{
2364            "manifest_version": 2,
2365            "name": "example",
2366            "version": "1.0.0",
2367            "app_type": "bun"
2368        }"#;
2369        let err = parse_err(json);
2370        assert!(err.contains("abi"), "expected abi error, got: {}", err);
2371    }
2372
2373    // ── Publisher-prefixed name (FR-019) ──────────────────────────────────────
2374
2375    #[test]
2376    fn publisher_prefixed_name_accepted() {
2377        let m = parse_ok(r#"{"name":"alice/weather","version":"1.0.0","app_type":"bun"}"#);
2378        assert_eq!(m.name, "alice/weather");
2379    }
2380
2381    #[test]
2382    fn double_slash_name_rejected() {
2383        let err = parse_err(r#"{"name":"a/b/c","version":"1.0.0","app_type":"bun"}"#);
2384        assert!(!err.is_empty());
2385    }
2386
2387    // ── Malformed names ───────────────────────────────────────────────────────
2388
2389    #[test]
2390    fn name_starting_with_digit_rejected() {
2391        let err = parse_err(r#"{"name":"1bad","version":"1.0.0","app_type":"bun"}"#);
2392        assert!(!err.is_empty());
2393    }
2394
2395    #[test]
2396    fn name_with_uppercase_rejected() {
2397        let err = parse_err(r#"{"name":"MyApp","version":"1.0.0","app_type":"bun"}"#);
2398        assert!(!err.is_empty());
2399    }
2400
2401    #[test]
2402    fn empty_name_rejected() {
2403        let err = parse_err(r#"{"name":"","version":"1.0.0","app_type":"bun"}"#);
2404        assert!(!err.is_empty());
2405    }
2406
2407    // ── Path-safety (SEC-H3) ─────────────────────────────────────────────────
2408
2409    #[test]
2410    fn path_traversal_double_dot_rejected() {
2411        let json = r#"{
2412            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2413            "app_type": "bun", "abi": "v1",
2414            "entrypoint": "../etc/passwd"
2415        }"#;
2416        let err = parse_err(json);
2417        assert!(err.contains(".."), "expected traversal error, got: {}", err);
2418    }
2419
2420    #[test]
2421    fn path_traversal_encoded_dot_not_decoded() {
2422        // The regex rejects '%' so encoded traversal fails at char check
2423        let json = r#"{
2424            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2425            "app_type": "bun", "abi": "v1",
2426            "entrypoint": "foo/../bar"
2427        }"#;
2428        let err = parse_err(json);
2429        assert!(!err.is_empty(), "should have failed: {}", err);
2430    }
2431
2432    #[test]
2433    fn absolute_path_rejected() {
2434        let json = r#"{
2435            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2436            "app_type": "bun", "abi": "v1",
2437            "entrypoint": "/usr/bin/sh"
2438        }"#;
2439        let err = parse_err(json);
2440        assert!(
2441            err.contains("absolute"),
2442            "expected absolute error, got: {}",
2443            err
2444        );
2445    }
2446
2447    #[test]
2448    fn shell_metachar_in_path_rejected() {
2449        let json = r#"{
2450            "manifest_version": 2, "name": "evil", "version": "1.0.0",
2451            "app_type": "bun", "abi": "v1",
2452            "entrypoint": "dist/index.js;rm -rf /"
2453        }"#;
2454        let err = parse_err(json);
2455        assert!(!err.is_empty());
2456    }
2457
2458    #[test]
2459    fn valid_nested_path_accepted() {
2460        let json = r#"{
2461            "manifest_version": 2, "name": "my-app", "version": "1.0.0",
2462            "app_type": "bun", "abi": "v1",
2463            "entrypoint": "dist/index.js",
2464            "ui_path": "ui/dist"
2465        }"#;
2466        parse_ok(json);
2467    }
2468
2469    // ── Homepage scheme ───────────────────────────────────────────────────────
2470
2471    #[test]
2472    fn homepage_https_accepted() {
2473        let json = r#"{
2474            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2475            "homepage": "https://example.com"
2476        }"#;
2477        parse_ok(json);
2478    }
2479
2480    #[test]
2481    fn homepage_javascript_scheme_rejected() {
2482        let json = r#"{
2483            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2484            "homepage": "javascript:alert(1)"
2485        }"#;
2486        let err = parse_err(json);
2487        assert!(
2488            err.contains("scheme"),
2489            "expected scheme error, got: {}",
2490            err
2491        );
2492    }
2493
2494    #[test]
2495    fn homepage_file_scheme_rejected() {
2496        let json = r#"{
2497            "name": "my-app", "version": "1.0.0", "app_type": "bun",
2498            "homepage": "file:///etc/passwd"
2499        }"#;
2500        let err = parse_err(json);
2501        assert!(!err.is_empty());
2502    }
2503
2504    // ── Effective defaults ────────────────────────────────────────────────────
2505
2506    #[test]
2507    fn effective_entrypoint_native_default() {
2508        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2509        assert_eq!(m.effective_entrypoint(), "app.so");
2510    }
2511
2512    #[test]
2513    fn effective_entrypoint_bun_default() {
2514        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2515        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2516    }
2517
2518    #[test]
2519    fn effective_hot_reload_native_default_is_experimental() {
2520        let m = parse_ok(r#"{"name":"cron","version":"1.0.0","app_type":"native"}"#);
2521        assert_eq!(m.effective_hot_reload(), HotReloadKind::Experimental);
2522    }
2523
2524    #[test]
2525    fn effective_hot_reload_bun_default_is_supported() {
2526        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2527        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2528    }
2529
2530    #[test]
2531    fn hot_reload_unsupported_explicit() {
2532        let json = r#"{
2533            "manifest_version": 2, "name": "myapp", "version": "1.0.0",
2534            "app_type": "bun", "abi": "v1", "hot_reload": "unsupported"
2535        }"#;
2536        let m = parse_ok(json);
2537        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2538    }
2539
2540    // ── validate_manifest_path unit tests ─────────────────────────────────────
2541
2542    #[test]
2543    fn validate_path_simple_valid() {
2544        assert!(validate_manifest_path("dist/index.js").is_ok());
2545        assert!(validate_manifest_path("app.so").is_ok());
2546        assert!(validate_manifest_path("ui/dist/bundle.js").is_ok());
2547        assert!(validate_manifest_path("build_output/main").is_ok());
2548    }
2549
2550    #[test]
2551    fn validate_path_empty_rejected() {
2552        assert!(validate_manifest_path("").is_err());
2553    }
2554
2555    #[test]
2556    fn validate_path_absolute_rejected() {
2557        assert!(validate_manifest_path("/usr/bin/sh").is_err());
2558    }
2559
2560    #[test]
2561    fn validate_path_double_dot_segment_rejected() {
2562        assert!(validate_manifest_path("foo/../bar").is_err());
2563        assert!(validate_manifest_path("../etc/passwd").is_err());
2564    }
2565
2566    #[test]
2567    fn validate_path_leading_dot_rejected() {
2568        assert!(validate_manifest_path(".hidden").is_err());
2569    }
2570
2571    #[test]
2572    fn validate_path_null_byte_rejected() {
2573        // null byte is non-ASCII, rejected by char check
2574        let path = "foo\0bar";
2575        assert!(validate_manifest_path(path).is_err());
2576    }
2577
2578    #[test]
2579    fn standalone_socket_path_development_override_is_explicit_and_pure() {
2580        let path = std::path::Path::new("/tmp/node-app/example.sock");
2581        assert!(validate_standalone_socket_path(path).is_err());
2582        assert!(validate_standalone_socket_path_with_policy(path, true).is_ok());
2583        assert!(validate_standalone_socket_path_with_policy(
2584            std::path::Path::new("/tmp/node-app/../escape.sock"),
2585            true,
2586        )
2587        .is_err());
2588    }
2589
2590    // ── ManifestCapabilities defaults ─────────────────────────────────────────
2591
2592    #[test]
2593    fn manifest_capabilities_defaults_to_empty() {
2594        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2595        assert!(m.capabilities.requires.is_empty());
2596        assert!(m.capabilities.provides.is_empty());
2597    }
2598
2599    // ── resolved_capability_provides (T28 standalone-registration shaping) ────
2600
2601    #[test]
2602    fn resolved_capability_provides_v1_only() {
2603        let json = r#"{
2604            "name": "example", "version": "1.0.0", "app_type": "bun",
2605            "provides": { "core.example.run": { "description": "Run example job" } }
2606        }"#;
2607        let m = parse_ok(json);
2608        let out = m.resolved_capability_provides();
2609        assert_eq!(out.len(), 1);
2610        assert_eq!(
2611            out.get("core.example.run").unwrap().description,
2612            "Run example job"
2613        );
2614    }
2615
2616    #[test]
2617    fn resolved_capability_provides_v2_names_get_blank_declaration() {
2618        let json = r#"{
2619            "manifest_version": 2, "name": "example", "version": "1.0.0",
2620            "app_type": "bun", "abi": "v1",
2621            "capabilities": { "requires": [], "provides": ["core.example.run", "core.example.other:extra"] }
2622        }"#;
2623        let m = parse_ok(json);
2624        let out = m.resolved_capability_provides();
2625        assert_eq!(out.len(), 2);
2626        assert_eq!(out.get("core.example.run").unwrap().description, "");
2627        assert!(out.get("core.example.run").unwrap().schema.is_none());
2628        // Only the part before the first ':' is used as the name.
2629        assert!(out.contains_key("core.example.other"));
2630        assert!(!out.contains_key("core.example.other:extra"));
2631    }
2632
2633    #[test]
2634    fn resolved_capability_provides_v1_wins_on_conflict() {
2635        let json = r#"{
2636            "manifest_version": 2, "name": "example", "version": "1.0.0",
2637            "app_type": "bun", "abi": "v1",
2638            "provides": { "core.example.run": { "description": "v1 wins" } },
2639            "capabilities": { "requires": [], "provides": ["core.example.run"] }
2640        }"#;
2641        let m = parse_ok(json);
2642        let out = m.resolved_capability_provides();
2643        assert_eq!(out.len(), 1);
2644        assert_eq!(out.get("core.example.run").unwrap().description, "v1 wins");
2645    }
2646
2647    #[test]
2648    fn resolved_capability_provides_blank_v2_name_skipped() {
2649        let json = r#"{
2650            "manifest_version": 2, "name": "example", "version": "1.0.0",
2651            "app_type": "bun", "abi": "v1",
2652            "capabilities": { "requires": [], "provides": ["  ", "core.example.run"] }
2653        }"#;
2654        let m = parse_ok(json);
2655        let out = m.resolved_capability_provides();
2656        assert_eq!(out.len(), 1);
2657        assert!(out.contains_key("core.example.run"));
2658    }
2659
2660    #[test]
2661    fn resolved_capability_provides_empty_manifest_yields_empty_map() {
2662        let m = parse_ok(r#"{"name":"myapp","version":"1.0.0","app_type":"bun"}"#);
2663        assert!(m.resolved_capability_provides().is_empty());
2664    }
2665
2666    // ── ABI version ───────────────────────────────────────────────────────────
2667
2668    #[test]
2669    fn abi_v1_is_supported() {
2670        assert!(AbiVersion::V1.is_supported());
2671    }
2672
2673    // ── AppTier display ───────────────────────────────────────────────────────
2674
2675    #[test]
2676    fn app_tier_display() {
2677        assert_eq!(AppTier::FirstParty.to_string(), "first_party");
2678        assert_eq!(AppTier::Optional.to_string(), "optional");
2679        assert_eq!(AppTier::Development.to_string(), "development");
2680    }
2681
2682    #[test]
2683    fn app_tier_serde_roundtrip() {
2684        // Wire format must stay snake_case for the existing API contract.
2685        for tier in [AppTier::FirstParty, AppTier::Optional, AppTier::Development] {
2686            let json = serde_json::to_string(&tier).unwrap();
2687            let back: AppTier = serde_json::from_str(&json).unwrap();
2688            assert_eq!(
2689                tier, back,
2690                "roundtrip failed for {:?}: serialized as {}",
2691                tier, json
2692            );
2693        }
2694        assert_eq!(
2695            serde_json::to_string(&AppTier::Development).unwrap(),
2696            "\"development\""
2697        );
2698    }
2699
2700    // ── AppType display ───────────────────────────────────────────────────────
2701
2702    #[test]
2703    fn app_type_display() {
2704        assert_eq!(AppType::Native.to_string(), "native");
2705        assert_eq!(AppType::Bun.to_string(), "bun");
2706        assert_eq!(AppType::PlatformRuntime.to_string(), "platform-runtime");
2707    }
2708
2709    #[test]
2710    fn platform_runtime_is_a_supported_packaging_type() {
2711        let manifest: AppManifest = serde_json::from_value(serde_json::json!({
2712            "manifest_version": 2,
2713            "abi": "v1",
2714            "name": "bun-runtime",
2715            "version": "1.0.0",
2716            "app_type": "platform-runtime",
2717            "entrypoint": "bun"
2718        }))
2719        .expect("platform runtime manifest should parse");
2720
2721        assert_eq!(manifest.app_type, AppType::PlatformRuntime);
2722        assert_eq!(manifest.effective_hot_reload(), HotReloadKind::Unsupported);
2723    }
2724
2725    // ── GovernorManifest memory budget ──────────────────────────────────────────
2726
2727    #[test]
2728    fn governor_manifest_memory_budget_defaults_to_none() {
2729        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2730        assert_eq!(parsed.memory_budget_kb, None);
2731    }
2732
2733    #[test]
2734    fn governor_manifest_parses_declared_memory_budget() {
2735        let parsed: GovernorManifest =
2736            serde_json::from_str(r#"{"memory_budget_kb": 40960}"#).unwrap();
2737        assert_eq!(parsed.memory_budget_kb, Some(40_960));
2738    }
2739
2740    // ── GovernorManifest latency_class (app lease engine §8, Task 1) ────────────
2741
2742    #[test]
2743    fn latency_class_parses_and_defaults_none() {
2744        let parsed: GovernorManifest = serde_json::from_str(r#"{"terminable": true}"#).unwrap();
2745        assert_eq!(parsed.latency_class, None);
2746    }
2747
2748    #[test]
2749    fn latency_class_parses_declared_interactive() {
2750        let json = r#"{
2751            "name": "example",
2752            "version": "1.0.0",
2753            "app_type": "bun",
2754            "governor": {"latency_class": "interactive"}
2755        }"#;
2756        let m = parse_ok(json);
2757        let governor = m.governor.expect("governor block should parse");
2758        assert_eq!(governor.latency_class, Some(LatencyClass::Interactive));
2759    }
2760
2761    #[test]
2762    fn latency_class_parses_declared_background() {
2763        let parsed: GovernorManifest =
2764            serde_json::from_str(r#"{"latency_class": "background"}"#).unwrap();
2765        assert_eq!(parsed.latency_class, Some(LatencyClass::Background));
2766    }
2767
2768    #[test]
2769    fn latency_class_invalid_value_is_parse_error() {
2770        let result: Result<GovernorManifest, _> =
2771            serde_json::from_str(r#"{"latency_class": "urgent"}"#);
2772        assert!(result.is_err(), "unknown latency_class value must fail to parse");
2773    }
2774
2775    #[test]
2776    fn latency_class_as_str_and_from_str_roundtrip() {
2777        for class in [LatencyClass::Interactive, LatencyClass::Background] {
2778            let s = class.as_str();
2779            assert_eq!(LatencyClass::from_str(s), Ok(class));
2780        }
2781        assert!(LatencyClass::from_str("urgent").is_err());
2782    }
2783
2784    // ── UI-only stage apps (burger-07, Plans 07a/07b Contracts F5) ───────────
2785
2786    fn ui_only_manifest() -> serde_json::Value {
2787        serde_json::json!({
2788            "manifest_version": 2, "abi": "v1", "name": "burger-runtime", "version": "1.0.0",
2789            "auto_start": false, "has_ui": true, "ui_path": "ui/dist",
2790            "ui": {
2791                "kind": "stage", "entry": "ui/dist/main.js", "title": "Burger", "icon": "ui/dist/icon.svg",
2792                "nav": { "section": "system", "order": 90 }, "ui_api": 1,
2793                "requires": {
2794                    "capabilities": ["core.runtime.burger_snapshot", "core.runtime.burger_logs"],
2795                    "queries": [], "streams": ["app.burger_metrics"]
2796                },
2797                "data": {
2798                    "namespace": "burger-runtime", "offline": "online-only", "sync": "snapshot",
2799                    "queries": [{ "name": "burger-runtime.snapshot.v1", "capability": "core.runtime.burger_snapshot", "kind": "snapshot" }],
2800                    "streams": [{ "name": "burger-runtime.metrics.v1", "kind": "events" }]
2801                },
2802                "integrity": { "ui/dist/main.js": "a".repeat(64), "ui/dist/icon.svg": "b".repeat(64) }
2803            }
2804        })
2805    }
2806
2807    fn ui_only_with(key: &str, value: serde_json::Value) -> String {
2808        let mut manifest = ui_only_manifest();
2809        manifest[key] = value;
2810        manifest.to_string()
2811    }
2812
2813    #[test]
2814    fn a_stage_manifest_without_app_type_or_entrypoint_is_ui_only() {
2815        let m = parse_ok(&ui_only_manifest().to_string());
2816        assert_eq!(m.app_type, AppType::UiOnly);
2817        assert_eq!(m.app_type.as_str(), "ui-only");
2818        assert_eq!(m.effective_entrypoint(), "", "no backend, no entrypoint");
2819        assert_eq!(m.effective_hot_reload(), HotReloadKind::Unsupported);
2820        assert!(m.has_ui);
2821        let ui = m.ui.as_ref().expect("a ui block");
2822        assert_eq!(ui.kind, AppUiKind::Stage);
2823        let data = ui.data.as_ref().expect("the F5 ui.data block validates");
2824        assert_eq!(data.namespace, "burger-runtime");
2825        assert_eq!(data.offline, AppDataOfflinePolicy::OnlineOnly);
2826        assert_eq!(data.queries[0].capability, "core.runtime.burger_snapshot");
2827        assert_eq!(data.streams[0].name, "burger-runtime.metrics.v1");
2828    }
2829
2830    #[test]
2831    fn a_ui_only_manifest_round_trips_through_its_serialized_form() {
2832        let m = parse_ok(&ui_only_manifest().to_string());
2833        let wire = serde_json::to_string(&m).unwrap();
2834        assert!(wire.contains(r#""app_type":"ui-only""#), "{wire}");
2835        assert_eq!(parse_ok(&wire).app_type, AppType::UiOnly);
2836    }
2837
2838    #[test]
2839    fn a_ui_only_manifest_refuses_everything_that_implies_a_process() {
2840        for (key, value) in [
2841            (
2842                "provides",
2843                serde_json::json!({ "burger.runtime.peek": { "description": "x" } }),
2844            ),
2845            (
2846                "capabilities",
2847                serde_json::json!({ "provides": ["burger.runtime.peek"] }),
2848            ),
2849            ("tcp", serde_json::json!({ "preferred_port": 7010 })),
2850            ("resources", serde_json::json!({ "memory_mb": 16 })),
2851            (
2852                "standalone",
2853                serde_json::json!({ "socket_path": "/run/node/x.sock" }),
2854            ),
2855            ("subscribes", serde_json::json!(["system.network.changed"])),
2856            ("depends_on", serde_json::json!(["cron"])),
2857        ] {
2858            let err = parse_err(&ui_only_with(key, value));
2859            assert!(err.contains("ui-only"), "{key}: {err}");
2860        }
2861        let mut explicit_with_entry = ui_only_manifest();
2862        explicit_with_entry["app_type"] = serde_json::json!("ui-only");
2863        explicit_with_entry["entrypoint"] = serde_json::json!("dist/index.js");
2864        assert!(parse_err(&explicit_with_entry.to_string()).contains("'entrypoint'"));
2865    }
2866
2867    #[test]
2868    fn a_ui_only_manifest_must_declare_a_stage() {
2869        let mut widget = ui_only_manifest();
2870        widget["ui"]["kind"] = serde_json::json!("widget");
2871        let widget_ui = widget["ui"].as_object_mut().unwrap();
2872        widget_ui.remove("nav");
2873        // A widget may not declare app data at all; drop it so the refusal is
2874        // the UI-only rule, not validate_app_ui's widget rule.
2875        widget_ui.remove("data");
2876        assert!(parse_err(&widget.to_string()).contains(r#"kind "stage""#));
2877        let no_ui = r#"{"manifest_version":2,"abi":"v1","name":"x","version":"1.0.0","app_type":"ui-only"}"#;
2878        assert!(parse_err(no_ui).contains(r#"kind "stage""#));
2879    }
2880
2881    #[test]
2882    fn declaring_app_type_or_entrypoint_keeps_the_existing_defaults() {
2883        assert_eq!(
2884            parse_ok(r#"{"name":"cron","version":"1.0.0"}"#).app_type,
2885            AppType::Native
2886        );
2887        let mut with_entry = ui_only_manifest();
2888        with_entry["entrypoint"] = serde_json::json!("app.so");
2889        assert_eq!(parse_ok(&with_entry.to_string()).app_type, AppType::Native);
2890        let mut bun = ui_only_manifest();
2891        bun["app_type"] = serde_json::json!("bun");
2892        assert_eq!(parse_ok(&bun.to_string()).app_type, AppType::Bun);
2893    }
2894
2895    #[test]
2896    fn declares_ui_only_accepts_the_derived_and_the_explicit_form_only() {
2897        assert!(declares_ui_only(&ui_only_manifest()));
2898        assert!(declares_ui_only(
2899            &serde_json::json!({ "app_type": "ui-only" })
2900        ));
2901        assert!(!declares_ui_only(
2902            &serde_json::json!({ "app_type": "bun", "ui": {} })
2903        ));
2904        assert!(!declares_ui_only(
2905            &serde_json::json!({ "entrypoint": "app.so", "ui": {} })
2906        ));
2907        assert!(!declares_ui_only(&serde_json::json!({ "name": "cron" })));
2908        assert!(!declares_ui_only(&serde_json::json!("ui-only")));
2909    }
2910}
2911
2912#[cfg(test)]
2913mod shared_app_data_corpus_tests {
2914    use super::*;
2915
2916    /// The SAME corpus the kernel validator runs
2917    /// (`client/kernel/src/stages/stage-registry-contract.test.js`). Two independent
2918    /// implementations of one contract, with nothing but this comparing them —
2919    /// whichever side drifts fails here.
2920    #[test]
2921    fn shared_app_data_corpus_matches_the_host_validator() {
2922        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/app-data");
2923        let mut checked = 0;
2924        for entry in std::fs::read_dir(&dir).expect("fixture directory must exist") {
2925            let path = entry.expect("readable entry").path();
2926            if path.extension().and_then(|e| e.to_str()) != Some("json") {
2927                continue;
2928            }
2929            let fixture: serde_json::Value =
2930                serde_json::from_str(&std::fs::read_to_string(&path).expect("readable fixture"))
2931                    .expect("valid fixture json");
2932            let name = path
2933                .file_name()
2934                .and_then(|n| n.to_str())
2935                .unwrap_or("?")
2936                .to_string();
2937            // `notes`: the app name the kernel half of this corpus uses, and the
2938            // namespace every corpus fixture declares — a data namespace must
2939            // equal its app name (`validate_app_data_namespace_owner`).
2940            let manifest = serde_json::json!({
2941                "manifest_version": 2, "abi": "v1", "name": "notes", "version": "0.1.0",
2942                "app_type": "bun", "entrypoint": "dist/index.js",
2943                "ui": fixture["ui"],
2944            });
2945            let result = AppManifest::from_json(&manifest.to_string()).and_then(|m| m.validate());
2946            match fixture["expect"].as_str().expect("expect field") {
2947                "accept" => assert!(result.is_ok(), "{name} should be accepted: {result:?}"),
2948                "reject" => {
2949                    let error = result.expect_err(&format!("{name} should be rejected"));
2950                    let reason = fixture["reason"]
2951                        .as_str()
2952                        .expect("reject fixtures need a reason");
2953                    assert!(
2954                        error.contains(reason),
2955                        "{name}: {error:?} should mention {reason:?}"
2956                    );
2957                }
2958                other => panic!("{name}: unknown expect {other:?}"),
2959            }
2960            checked += 1;
2961        }
2962        // Guards against a silently empty or mis-globbed corpus reporting success.
2963        assert!(checked >= 10, "expected the full corpus, walked {checked}");
2964    }
2965}
2966
2967#[cfg(test)]
2968mod burger_manifest_tests {
2969    use super::*;
2970
2971    #[test]
2972    fn burger_app_type_parses_with_bundle_entrypoint_default() {
2973        let m = AppManifest::from_json(r#"{"name":"did","version":"2.0.0","app_type":"burger"}"#)
2974            .expect("burger manifest parses");
2975        assert_eq!(m.app_type, AppType::Burger);
2976        assert_eq!(m.app_type.as_str(), "burger");
2977        assert_eq!(m.effective_entrypoint(), "dist/index.js");
2978        assert_eq!(m.effective_hot_reload(), HotReloadKind::Supported);
2979        assert!(m.resources.is_none());
2980    }
2981
2982    #[test]
2983    fn resources_memory_mb_is_carried() {
2984        let m = AppManifest::from_json(
2985            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":48}}"#,
2986        )
2987        .expect("resources block parses");
2988        assert_eq!(
2989            m.resources,
2990            Some(ResourcesManifest { memory_mb: Some(48), callback_deadline_ms: None })
2991        );
2992    }
2993
2994    #[test]
2995    fn resources_callback_deadline_ms_is_carried() {
2996        let m = AppManifest::from_json(
2997            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":750}}"#,
2998        )
2999        .expect("callback deadline parses");
3000        assert_eq!(
3001            m.resources,
3002            Some(ResourcesManifest { memory_mb: None, callback_deadline_ms: Some(750) })
3003        );
3004        let max = AppManifest::from_json(
3005            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"callback_deadline_ms":600000}}"#,
3006        )
3007        .expect("the maximum is inclusive");
3008        assert_eq!(
3009            max.resources.and_then(|r| r.callback_deadline_ms),
3010            Some(MAX_CALLBACK_DEADLINE_MS)
3011        );
3012    }
3013
3014    #[test]
3015    fn resources_callback_deadline_ms_out_of_range_is_rejected() {
3016        for bad in ["0", "600001"] {
3017            let json = format!(
3018                r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3019            );
3020            let err = AppManifest::from_json(&json).expect_err("out-of-range callback deadline");
3021            assert!(err.contains("resources.callback_deadline_ms"), "{bad}: {err}");
3022        }
3023    }
3024
3025    #[test]
3026    fn resources_callback_deadline_ms_must_be_a_positive_integer() {
3027        for bad in ["-1", "1.5", "\"5000\""] {
3028            let json = format!(
3029                r#"{{"name":"did","version":"2.0.0","app_type":"burger","resources":{{"callback_deadline_ms":{bad}}}}}"#
3030            );
3031            assert!(
3032                AppManifest::from_json(&json).is_err(),
3033                "callback_deadline_ms={bad} must be rejected"
3034            );
3035        }
3036    }
3037
3038    #[test]
3039    fn resources_memory_mb_zero_is_rejected() {
3040        let err = AppManifest::from_json(
3041            r#"{"name":"did","version":"2.0.0","app_type":"burger","resources":{"memory_mb":0}}"#,
3042        )
3043        .expect_err("a zero memory limit is invalid");
3044        assert!(err.contains("resources.memory_mb"), "{err}");
3045    }
3046
3047    #[test]
3048    fn burger_manifest_rejects_a_standalone_block() {
3049        let err = AppManifest::from_json(
3050            r#"{"name":"did","version":"2.0.0","app_type":"burger","standalone":{"socket_path":"/run/node-app-did.sock"}}"#,
3051        )
3052        .expect_err("standalone block is only valid for standalone apps");
3053        assert!(err.contains("only valid when app_type == 'standalone'"), "{err}");
3054    }
3055}