//! CLI validation around the canonical app manifest contract.
use anyhow::{anyhow, bail, Context, Result};
use regex::Regex;
use std::path::Path;
pub use node_app_manifest::{AppManifest, AppType};
#[cfg(test)]
pub use node_app_manifest::{
AppUiKind, AppUiManifest, AppUiNav, ManifestCapabilities, StandaloneConfig,
};
/// The Contracts F5 UI-only stage manifest (`burger-runtime`, `ui.data`
/// included, fixed digests, version `1.0.0`): the shared test fixture for
/// `node-app validate` and `node-app audit`.
#[cfg(test)]
pub(crate) const UI_ONLY_MANIFEST: &str = r#"{"manifest_version":2,"abi":"v1","name":"burger-runtime","version":"1.0.0","auto_start":false,"has_ui":true,"ui_path":"ui/dist","ui":{"kind":"stage","entry":"ui/dist/main.js","title":"Burger","icon":"ui/dist/icon.svg","nav":{"section":"system","order":90},"ui_api":1,"requires":{"capabilities":["core.runtime.burger_snapshot","core.runtime.burger_logs"],"queries":[],"streams":["app.burger_metrics"]},"data":{"namespace":"burger","offline":"online-only","sync":"snapshot","queries":[{"name":"burger.snapshot.v1","capability":"core.runtime.burger_snapshot","kind":"snapshot"}],"streams":[{"name":"burger.metrics.v1","kind":"events"}]},"integrity":{"ui/dist/icon.svg":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","ui/dist/main.js":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}"#;
pub fn parse_manifest(path: &Path) -> Result<AppManifest> {
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let manifest = AppManifest::from_json(&raw)
.map_err(anyhow::Error::msg)
.with_context(|| format!("parse {} as manifest.json", path.display()))?;
Ok(manifest)
}
/// Run all v2-aware validations on a manifest. Returns Ok(()) on full pass,
/// or an error describing the first failure.
///
/// `is_apt_install_target` indicates whether the package is intended to be
/// distributed at the apt path (`/usr/lib/node/apps/<name>/`).
///
/// `signed_pathway` indicates whether this app ships through the org-signed
/// FirstParty pipeline (FR-028 cycle 4): a per-repo release workflow that
/// GPG-signs the manifest sidecar. The runtime's `tier_validator` grants
/// FirstParty trust to a native app at the apt path when that signature is
/// present, so a native app on the signed pathway is accepted here; a native
/// app at the apt path WITHOUT the signed pathway is a genuine unsigned
/// sideload and is rejected (mirrors the runtime's Optional-tier rule, where
/// native is not permitted — T118 path-based tier rule, R8).
pub fn validate_manifest(
m: &AppManifest,
is_apt_install_target: bool,
signed_pathway: bool,
) -> Result<()> {
m.validate().map_err(anyhow::Error::msg)?;
// Name pattern (lowercase alphanumeric + hyphens, with optional publisher prefix).
static NAME_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-z][a-z0-9-]*(/([a-z][a-z0-9-]*))?$").unwrap()
});
if !NAME_RE.is_match(&m.name) {
bail!(
"manifest name '{}' is invalid (expected lowercase + hyphens, optional publisher/ prefix)",
m.name
);
}
if m.name.starts_with("node-app-") {
bail!("manifest name must not start with 'node-app-' (the .deb script adds the prefix)");
}
// SemVer-ish.
static SEMVER_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?$").unwrap()
});
if !SEMVER_RE.is_match(&m.version) {
bail!("manifest version '{}' is not a valid SemVer", m.version);
}
// v2 manifests must declare an ABI in {v1}.
if m.manifest_version >= 2 && m.abi.is_none() {
bail!("manifest_version=2 requires an `abi` field");
}
// Path safety on entrypoint and ui_path. Same rule both fields:
// - Match path-component regex.
// - No '..' segments.
// - No leading '/'.
static PATH_RE: once_cell::sync::Lazy<Regex> =
once_cell::sync::Lazy::new(|| Regex::new(r"^[a-zA-Z0-9_][a-zA-Z0-9_./-]*$").unwrap());
let check_path = |label: &str, p: &str| -> Result<()> {
if !PATH_RE.is_match(p) {
bail!("{} path '{}' has illegal characters", label, p);
}
if p.split('/').any(|seg| seg == "..") {
bail!("{} path '{}' contains '..' segment", label, p);
}
if p.starts_with('/') {
bail!("{} path '{}' must be relative", label, p);
}
Ok(())
};
if let Some(ep) = m.entrypoint.as_deref() {
check_path("entrypoint", ep)?;
}
if m.has_ui {
if m.ui_path.is_empty() {
bail!("has_ui=true but ui_path is missing");
}
check_path("ui_path", &m.ui_path)?;
}
// Capability strings parse correctly.
for c in &m.capabilities.requires {
parse_capability_requirement(c)
.with_context(|| format!("invalid capability requirement: '{}'", c))?;
}
for c in &m.capabilities.provides {
parse_capability_provides(c)
.with_context(|| format!("invalid capability provides: '{}'", c))?;
}
// Tier check: a native (cdylib) app at the apt path must ship through the
// org-signed FirstParty pipeline. The signed manifest sidecar is what the
// runtime's `tier_validator` uses to grant FirstParty trust; without it, a
// native app at the apt path is a genuine unsigned sideload that the runtime
// would reject as Optional-tier (native is not permitted at Optional —
// FR-028). We surface that failure at `validate` time rather than at load
// time on-device.
if is_apt_install_target && m.app_type == AppType::Native && !signed_pathway {
bail!(
"native (cdylib) apps at the apt-install path (/usr/lib/node/apps/) \
must ship through the org-signed FirstParty pipeline, but no signing \
workflow was detected in this repo. The FirstParty tier is granted by \
a GPG-signed manifest sidecar produced in CI (.github/workflows/*.yml \
— see FR-028). Add the signing release.yml (native apps in econ-v1 \
sub-repos are first-party) or, for an unsigned Optional-tier sideload, \
switch app_type to 'bun'."
);
}
// Standalone-app rules (mirror core/domain/src/models/app_manifest.rs):
// - app_type == Standalone with any `provides` requires `standalone.socket_path`,
// absolute, under /run/, no `..` segments.
// - Non-standalone manifests MUST NOT carry a `standalone` block.
let provides_count = m.capabilities.provides.len();
match m.app_type {
AppType::Standalone => {
if provides_count > 0 {
let cfg = m.standalone.as_ref().ok_or_else(|| {
anyhow!(
"standalone apps that declare 'provides' require a \
'standalone.socket_path' field"
)
})?;
validate_standalone_socket_path(&cfg.socket_path)
.context("standalone.socket_path invalid")?;
}
}
AppType::Native
| AppType::Bun
| AppType::PlatformRuntime
| AppType::ManagedV1
| AppType::Burger
| AppType::UiOnly => {
if m.standalone.is_some() {
bail!(
"'standalone' block is only valid when app_type == 'standalone' \
(found app_type='{:?}')",
m.app_type
);
}
}
}
Ok(())
}
/// Validate a standalone socket path string.
///
/// Rules:
/// 1. Absolute path (starts with `/`).
/// 2. Lives under `/run/` (rejects `/etc/...`, `/tmp/...`, etc.).
/// 3. No `..` segments.
fn validate_standalone_socket_path(p: &Path) -> Result<()> {
if !p.is_absolute() {
bail!("socket_path '{}' must be absolute", p.display());
}
if !p.starts_with("/run/") {
bail!("socket_path '{}' must live under /run/", p.display());
}
if p.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
bail!("socket_path '{}' contains '..' segment", p.display());
}
Ok(())
}
/// Tiny capability-requirement parser used during validate. Mirrors the
/// grammar in `core/domain/src/models/capability.rs::parse_capability` —
/// we keep this in sync manually for now (single source of truth lives in
/// the domain crate; CI cross-checks the two parsers in T128).
fn parse_capability_requirement(s: &str) -> Result<()> {
let mut parts = s.split(':');
let ns = parts
.next()
.ok_or_else(|| anyhow!("empty capability"))?
.trim();
if ns.is_empty() {
bail!("capability namespace is empty");
}
// Each dotted segment is `[a-z][a-z0-9_-]*` — underscores are permitted to
// match the runtime capability contract (CapabilityRouter / core_handler.rs)
// and the canonical domain parser (`core/domain/src/models/capability.rs`),
// which accept names like `core.conversation.send_message`.
static NS_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$").unwrap()
});
if !NS_RE.is_match(ns) {
bail!(
"capability namespace '{}' must be dotted lowercase (e.g. core.lightning.payment.send)",
ns
);
}
let mut seen_daily = false;
for part in parts {
if let Some(rest) = part.strip_prefix("max=") {
// <N>(sat|msat)/(day|tx)
let (num_unit, period) = rest
.split_once('/')
.ok_or_else(|| anyhow!("max constraint missing /period: '{}'", part))?;
let (num, unit) = num_unit
.strip_suffix("msat")
.map(|n| (n, "msat"))
.or_else(|| num_unit.strip_suffix("sat").map(|n| (n, "sat")))
.ok_or_else(|| anyhow!("max value must end in 'sat' or 'msat': '{}'", num_unit))?;
let _: u64 = num
.parse()
.with_context(|| format!("max value '{}' must be an integer", num))?;
match (unit, period) {
("sat", "day") | ("msat", "day") => {
if seen_daily {
bail!("duplicate max=Nsat/day constraint on '{}'", ns);
}
seen_daily = true;
}
("sat", "tx") | ("msat", "tx") => {}
_ => bail!(
"unsupported constraint period '{}' (expected day or tx)",
period
),
}
} else {
// Plain `:scope` token — accepted, no semantic validation here.
if part.is_empty() {
bail!("empty constraint segment in '{}'", s);
}
}
}
Ok(())
}
fn parse_capability_provides(s: &str) -> Result<()> {
// Mirror `parse_capability_requirement`: underscores allowed per segment.
static NS_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
Regex::new(r"^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$").unwrap()
});
if !NS_RE.is_match(s) {
bail!("capability provides '{}' must be dotted lowercase", s);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn min_v2(name: &str, app_type: AppType) -> AppManifest {
let entrypoint: String = match app_type {
AppType::Native => "app.so".into(),
AppType::Bun => "dist/index.js".into(),
AppType::Standalone => "app".into(),
AppType::PlatformRuntime => "bun".into(),
AppType::ManagedV1 => "llmc-generated-app".into(),
AppType::Burger => "dist/index.js".into(),
AppType::UiOnly => {
unreachable!("UI-only manifests declare no entrypoint; build them from a ui block")
}
};
serde_json::from_value(serde_json::json!({
"manifest_version": 2,
"name": name,
"version": "1.0.0",
"app_type": app_type.as_str(),
"abi": "v1",
"entrypoint": entrypoint
}))
.unwrap()
}
#[test]
fn accepts_v1_manifest_without_abi() {
let mut m = min_v2("foo", AppType::Bun);
m.manifest_version = 1;
m.abi = None;
validate_manifest(&m, true, false).unwrap();
}
#[test]
fn rejects_v2_without_abi() {
let mut m = min_v2("foo", AppType::Bun);
m.abi = None;
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains("requires an 'abi'"));
}
#[test]
fn rejects_invalid_name() {
let mut m = min_v2("FOO", AppType::Bun);
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains("must match"));
m.name = "node-app-bar".into();
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains("must not start with 'node-app-'"));
}
#[test]
fn rejects_traversal_in_entrypoint() {
let mut m = min_v2("foo", AppType::Bun);
m.entrypoint = Some("../etc/passwd".into());
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains(".."));
}
#[test]
fn rejects_unsigned_native_at_apt_path() {
// Native at the apt path WITHOUT the signed FirstParty pipeline is a
// genuine unsigned sideload — rejected, and the message points at the
// signing requirement (FR-028) so the author knows the remedy.
let m = min_v2("foo", AppType::Native);
let err = validate_manifest(&m, true, false).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("native"), "expected 'native' in: {msg}");
assert!(
msg.contains("FirstParty") || msg.contains("signing"),
"expected a signing pointer in: {msg}"
);
}
#[test]
fn accepts_signed_native_at_apt_path() {
// Native at the apt path IS accepted when the repo ships through the
// org-signed FirstParty pipeline (FR-028 cycle 4).
let m = min_v2("foo", AppType::Native);
validate_manifest(&m, true, true).unwrap();
}
#[test]
fn accepts_native_when_bundled() {
// Bundled path (not apt) never needs a signature — the install path
// itself is the trust anchor.
let m = min_v2("foo", AppType::Native);
validate_manifest(&m, false, false).unwrap();
}
#[test]
fn accepts_standalone_at_apt_path() {
// Standalone apps are distributed via apt as binaries — allowed at apt path.
let m = min_v2("my-service", AppType::Standalone);
validate_manifest(&m, true, false).unwrap();
}
#[test]
fn standalone_with_provides_requires_socket_path() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
};
// No `standalone` block → rejected.
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains("socket_path"));
}
#[test]
fn standalone_with_provides_and_socket_path_passes() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
};
m.standalone = Some(StandaloneConfig {
socket_path: "/run/node-app-led.sock".into(),
});
validate_manifest(&m, true, false).unwrap();
}
#[test]
fn rejects_socket_path_outside_run() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
};
m.standalone = Some(StandaloneConfig {
socket_path: "/tmp/led.sock".into(),
});
let err = validate_manifest(&m, true, false).unwrap_err();
// `.context()` wraps the underlying message; format with `{:#}` to
// get the full chain in one string.
let msg = format!("{:#}", err);
assert!(msg.contains("/run/"), "expected /run/ in chain: {msg}");
}
#[test]
fn rejects_relative_socket_path() {
let mut m = min_v2("led", AppType::Standalone);
m.capabilities = ManifestCapabilities {
requires: vec![],
provides: vec!["led.event.set".into()],
};
m.standalone = Some(StandaloneConfig {
socket_path: "led.sock".into(),
});
let err = validate_manifest(&m, true, false).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("absolute"),
"expected 'absolute' in chain: {msg}"
);
}
#[test]
fn rejects_standalone_block_on_non_standalone_app() {
let mut m = min_v2("foo", AppType::Bun);
m.standalone = Some(StandaloneConfig {
socket_path: "/run/foo.sock".into(),
});
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains("standalone"));
}
#[test]
fn standalone_without_provides_skips_socket_check() {
// A standalone app that only consumes capabilities (no provides) can
// omit the standalone.socket_path field entirely.
let m = min_v2("consumer", AppType::Standalone);
validate_manifest(&m, true, false).unwrap();
}
#[test]
fn has_ui_requires_ui_path_for_standalone_bun_fullstack() {
// Bun standalone-fullstack: has_ui=true requires ui_path. Already
// enforced by the generic check, but assert it explicitly.
let mut m = min_v2("bun-fs", AppType::Standalone);
m.has_ui = true;
m.ui_path.clear();
let err = validate_manifest(&m, true, false).unwrap_err();
assert!(err.to_string().contains("ui_path"));
m.ui_path = "ui-dist".into();
validate_manifest(&m, true, false).unwrap();
}
#[test]
fn accepts_platform_runtime_without_capability_providers() {
let runtime = min_v2("bun-runtime", AppType::PlatformRuntime);
validate_manifest(&runtime, true, false).unwrap();
}
#[test]
fn rejects_platform_runtime_capability_providers() {
let mut runtime = min_v2("bun-runtime", AppType::PlatformRuntime);
runtime.capabilities.provides = vec!["runtime.execute".into()];
let error = validate_manifest(&runtime, true, false).unwrap_err();
assert!(error.to_string().contains("cannot provide"));
}
#[test]
fn parses_constraint_strings() {
parse_capability_requirement("core.storage.kv").unwrap();
parse_capability_requirement("core.lightning.payment.send:max=500sat/day").unwrap();
parse_capability_requirement("core.lightning.payment.send:max=1000msat/tx").unwrap();
parse_capability_requirement("core.lightning.payment.send:max=500sat/day:max=10000msat/tx")
.unwrap();
assert!(parse_capability_requirement("CORE.bad").is_err());
assert!(parse_capability_requirement("core.bad:max=foo/day").is_err());
assert!(parse_capability_requirement("core.bad:max=10sat/year").is_err());
// duplicate daily caps rejected
assert!(parse_capability_requirement("core.bad:max=10sat/day:max=20sat/day").is_err());
}
#[test]
fn accepts_underscores_in_capability_segments() {
// Underscores are used pervasively by the runtime capability contract
// (CapabilityRouter / core_handler.rs) and are accepted by the canonical
// domain parser (`core/domain/src/models/capability.rs::normalize_namespace`).
// The CLI validator must match — underscores per dotted segment are valid.
parse_capability_requirement("core.conversation.send_message").unwrap();
parse_capability_requirement("core.did.current_did").unwrap();
parse_capability_requirement("core.message_queue.enqueue").unwrap();
parse_capability_requirement("core.search.bm25.query_capabilities").unwrap();
// Constraints still parse alongside an underscored namespace.
parse_capability_requirement("core.conversation.send_message:max=500sat/day").unwrap();
// Same rule applies to the provides side.
parse_capability_provides("core.conversation.send_message").unwrap();
parse_capability_provides("core.message_queue.enqueue").unwrap();
}
// ── Stage UI contract (client-node #1462) ───────────────────────────────
//
// `validate_manifest` delegates UI-block validation entirely to
// `AppManifest::validate()` (the shared `node_app_manifest` crate), so
// these tests exercise that delegation through the CLI entry points
// rather than re-implementing the checks here.
#[test]
fn shared_stage_fixture_matches_cli_contract() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("manifest.json");
std::fs::write(
&path,
include_str!(
"../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
),
)
.unwrap();
let manifest = parse_manifest(&path).unwrap();
validate_manifest(&manifest, true, false).unwrap();
assert!(manifest.has_ui);
assert_eq!(
manifest.resolved_requires().unwrap(),
vec!["core.metrics.latest"]
);
assert_eq!(manifest.ui.unwrap().kind, AppUiKind::Stage);
}
#[test]
fn shared_stage_fixture_matches_json_schema() {
let schema: serde_json::Value = serde_json::from_str(include_str!(
"../../../specs/456-node-app-distribution-infrastructure/contracts/manifest-v2.json"
))
.unwrap();
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../specs/456-node-app-distribution-infrastructure/contracts/fixtures/stage-manifest-v2.json"
))
.unwrap();
let validator = jsonschema::JSONSchema::compile(&schema).unwrap();
assert!(validator.is_valid(&fixture));
let mut unsafe_fixture = fixture.clone();
unsafe_fixture["ui"]["entry"] = serde_json::json!("ui/../main.js");
unsafe_fixture["ui"]["integrity"] = serde_json::json!({
"ui/../main.js": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"ui/icon.svg": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
});
assert!(!validator.is_valid(&unsafe_fixture));
if let Err(errors) = validator.validate(&fixture) {
panic!(
"shared stage fixture failed schema validation: {}",
errors
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("; ")
);
};
}
#[test]
fn cli_rejects_conflicting_requires_aliases() {
let mut manifest = min_v2("stage", AppType::Bun);
manifest.requires = vec!["core.chat.read".into()];
manifest.capabilities = ManifestCapabilities {
requires: vec!["core.wallet.pay".into()],
provides: vec![],
};
let error = validate_manifest(&manifest, true, false).unwrap_err();
assert!(error.to_string().contains("conflicts"));
}
#[test]
fn cli_accepts_widgets_but_rejects_widget_navigation_and_invalid_integrity() {
let mut manifest = min_v2("stage", AppType::Bun);
manifest.ui = Some(AppUiManifest {
kind: AppUiKind::Widget,
entry: "ui/main.js".into(),
title: "Stage".into(),
icon: None,
nav: None,
composes: vec![],
surfaces: vec![],
ui_api: 1,
integrity: std::collections::BTreeMap::from([("ui/main.js".into(), "a".repeat(64))]),
description: None,
keywords: vec![],
requires: node_app_manifest::AppUiRequirements::default(),
data: None,
});
validate_manifest(&manifest, true, false).unwrap();
manifest.ui.as_mut().unwrap().nav = Some(AppUiNav {
section: "default".into(),
order: 1,
});
assert!(validate_manifest(&manifest, true, false)
.unwrap_err()
.to_string()
.contains("widget ui must omit nav"));
let ui = manifest.ui.as_mut().unwrap();
ui.kind = AppUiKind::Stage;
ui.nav = None;
ui.integrity.insert("ui/main.js".into(), "A".repeat(64));
assert!(validate_manifest(&manifest, true, false)
.unwrap_err()
.to_string()
.contains("lowercase"));
}
}