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