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,
};
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)
}
pub fn validate_manifest(
m: &AppManifest,
is_apt_install_target: bool,
signed_pathway: bool,
) -> Result<()> {
m.validate().map_err(anyhow::Error::msg)?;
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)");
}
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);
}
if m.manifest_version >= 2 && m.abi.is_none() {
bail!("manifest_version=2 requires an `abi` field");
}
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)?;
}
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))?;
}
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'."
);
}
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 => {
if m.standalone.is_some() {
bail!(
"'standalone' block is only valid when app_type == 'standalone' \
(found app_type='{:?}')",
m.app_type
);
}
}
}
Ok(())
}
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(())
}
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");
}
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=") {
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 {
if part.is_empty() {
bail!("empty constraint segment in '{}'", s);
}
}
}
Ok(())
}
fn parse_capability_provides(s: &str) -> Result<()> {
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(),
};
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() {
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() {
let m = min_v2("foo", AppType::Native);
validate_manifest(&m, true, true).unwrap();
}
#[test]
fn accepts_native_when_bundled() {
let m = min_v2("foo", AppType::Native);
validate_manifest(&m, false, false).unwrap();
}
#[test]
fn accepts_standalone_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()],
};
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();
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() {
let m = min_v2("consumer", AppType::Standalone);
validate_manifest(&m, true, false).unwrap();
}
#[test]
fn has_ui_requires_ui_path_for_standalone_bun_fullstack() {
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());
assert!(parse_capability_requirement("core.bad:max=10sat/day:max=20sat/day").is_err());
}
#[test]
fn accepts_underscores_in_capability_segments() {
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();
parse_capability_requirement("core.conversation.send_message:max=500sat/day").unwrap();
parse_capability_provides("core.conversation.send_message").unwrap();
parse_capability_provides("core.message_queue.enqueue").unwrap();
}
#[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![],
ui_api: 1,
integrity: std::collections::BTreeMap::from([("ui/main.js".into(), "a".repeat(64))]),
});
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"));
}
}