node-app-build 7.2.9

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! The peer plane a device runs — gossip, iroh, webv1, friends — and what the
//! bring-up does so two dev instances actually connect over it.
//!
//! The minimal core (#2939) owns none of the peer carriers: a device installs
//! `node-app-gossip`, `node-app-iroh`, `node-app-webv1` and `node-app-friends`
//! (`infra/debian/platform-depends`, #3089). The harness loads the same apps
//! from `<monorepo>/modules/`. Without them the node boots and onboards, but
//! `iroh.*`, `core.network.list_connection_states` and `core.friends.*` answer
//! "No provider registered" and `core.network.list_connections` is always
//! empty, because it lists only live iroh links.
//!
//! Two nodes on one machine still need two things a device gets for free:
//!
//! 1. **Cards that carry iroh addressing.** The iroh app writes
//!    `iroh_endpoint_id` / `iroh_direct_addresses` onto the node's card from a
//!    thread that first fires ~10 s after boot, and on a cold boot that write
//!    races other writers of the same blob (seen live: the iroh write and a
//!    concurrent core write landed in the same millisecond and the core's won,
//!    so the card went out without iroh keys until the next 60 s tick). The
//!    harness exchanges cards exactly once, so it asks the app to publish
//!    first (`iroh.publish_endpoint`, idempotent) and only shares a card that
//!    names the endpoint.
//! 2. **Time for the reconcile to dial.** iroh's mesh reconcile dials fresh
//!    gossiped peers on its own tick; [`linked_peers`] reads
//!    `core.network.list_connections` so the bring-up can wait for the link
//!    and say which path it took.
//!
//! No relay is configured. A device is not seeded one either (relays come
//! from gossip since node-app-iroh 0.7.1, and `ensure-node-secrets.sh` seeds
//! none), and the harness has no public relay node to gossip one, so the two
//! instances connect on a direct path over the machine's own addresses.

use std::path::Path;

use serde_json::Value;

/// The peer-plane apps a device installs, as `modules/<name>` directories.
pub(crate) const PEER_PLANE_APPS: [&str; 4] = ["gossip", "iroh", "webv1", "friends"];

/// The shared library the host dlopens for a native app named `name`.
fn native_library_names(name: &str) -> [String; 2] {
    let stem = format!("libnode_app_{}", name.replace('-', "_"));
    [format!("{stem}.dylib"), format!("{stem}.so")]
}

/// Which of [`PEER_PLANE_APPS`] `modules_dir` cannot serve: no
/// `manifest.json`, or no built library next to it. Either way the host
/// registers no provider for the app's capabilities.
pub(crate) fn missing_peer_plane_apps(modules_dir: &Path) -> Vec<&'static str> {
    PEER_PLANE_APPS
        .iter()
        .copied()
        .filter(|name| {
            let dir = modules_dir.join(name);
            let has_manifest = dir.join("manifest.json").is_file();
            let has_library = native_library_names(name).iter().any(|lib| dir.join(lib).is_file());
            !(has_manifest && has_library)
        })
        .collect()
}

/// The capability's own answer out of the invoke envelope
/// (`{capability, provider, result}`), then out of an app's `{ok, data}`
/// envelope when it has one.
fn app_answer(answer: &Value) -> &Value {
    let result = answer.get("result").unwrap_or(answer);
    result.get("data").filter(|d| d.is_object()).unwrap_or(result)
}

/// The endpoint id an `iroh.publish_endpoint` (or `iroh.endpoint.info`)
/// answer names, when the endpoint is bound.
pub(crate) fn iroh_endpoint_id(answer: &Value) -> Option<String> {
    app_answer(answer)
        .get("endpoint_id")
        .and_then(Value::as_str)
        .filter(|id| !id.is_empty())
        .map(str::to_string)
}

/// Whether a gossip card advertises exactly this iroh endpoint.
pub(crate) fn card_names_iroh_endpoint(card: &Value, endpoint_id: &str) -> bool {
    card.get("iroh_endpoint_id").and_then(Value::as_str) == Some(endpoint_id)
}

/// One live link from `core.network.list_connections`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Link {
    pub node_id: String,
    /// `direct` or `relayed` (the stage's "path").
    pub kind: String,
    pub transport: String,
}

/// The connected peers a `core.network.list_connections` answer lists.
pub(crate) fn linked_peers(answer: &Value) -> Vec<Link> {
    let text = |row: &Value, key: &str| row.get(key).and_then(Value::as_str).unwrap_or_default().to_string();
    app_answer(answer)
        .get("connections")
        .and_then(Value::as_array)
        .map(|rows| {
            rows.iter()
                .filter(|row| row.get("connected").and_then(Value::as_bool).unwrap_or(false))
                .map(|row| Link {
                    node_id: text(row, "node_id"),
                    kind: text(row, "kind"),
                    transport: text(row, "transport"),
                })
                .filter(|link| !link.node_id.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn every_peer_plane_app_is_missing_from_an_empty_modules_dir() {
        let tmp = tempfile::tempdir().unwrap();
        assert_eq!(missing_peer_plane_apps(tmp.path()), PEER_PLANE_APPS.to_vec());
    }

    // A manifest alone registers the capabilities but dlopen fails, and a
    // library alone registers nothing: both count as missing.
    #[test]
    fn an_app_needs_both_its_manifest_and_its_built_library() {
        let tmp = tempfile::tempdir().unwrap();
        let modules = tmp.path();
        for name in ["gossip", "iroh", "webv1"] {
            std::fs::create_dir_all(modules.join(name)).unwrap();
        }
        std::fs::write(modules.join("gossip/manifest.json"), "{}").unwrap();
        std::fs::write(modules.join("gossip/libnode_app_gossip.dylib"), "").unwrap();
        std::fs::write(modules.join("iroh/manifest.json"), "{}").unwrap();
        std::fs::write(modules.join("webv1/libnode_app_webv1.so"), "").unwrap();
        assert_eq!(missing_peer_plane_apps(modules), vec!["iroh", "webv1", "friends"]);
    }

    #[test]
    fn the_endpoint_id_is_read_through_both_envelopes() {
        let answer = json!({
            "capability": "iroh.publish_endpoint",
            "provider": "iroh",
            "result": { "ok": true, "data": { "changed": false, "endpoint_id": "8dec61" } },
        });
        assert_eq!(iroh_endpoint_id(&answer).as_deref(), Some("8dec61"));
        let unbound = json!({ "result": { "ok": true, "data": { "endpoint_id": "" } } });
        assert_eq!(iroh_endpoint_id(&unbound), None);
        let refused = json!({ "result": { "ok": false, "message": "iroh_endpoint_unavailable" } });
        assert_eq!(iroh_endpoint_id(&refused), None);
    }

    #[test]
    fn a_card_names_the_endpoint_only_when_the_ids_agree() {
        let card = json!({ "node_id": "03aa", "iroh_endpoint_id": "8dec61" });
        assert!(card_names_iroh_endpoint(&card, "8dec61"));
        assert!(!card_names_iroh_endpoint(&card, "153372"));
        assert!(!card_names_iroh_endpoint(&json!({ "node_id": "03aa" }), "8dec61"));
    }

    // Shape captured live from alice on 2026-09-22 (address trimmed).
    #[test]
    fn linked_peers_lists_connected_rows_with_their_path() {
        let answer = json!({
            "capability": "core.network.list_connections",
            "provider": "__core__",
            "result": {
                "connections": [
                    {
                        "address": "[2405:4803::1]:60610",
                        "carriers": { "iroh": true },
                        "connected": true,
                        "direction": "outbound",
                        "kind": "direct",
                        "node_id": "0306447",
                        "transport": "iroh"
                    },
                    { "node_id": "02dead", "connected": false, "kind": "relayed", "transport": "iroh" }
                ],
                "total": 2,
                "unidentified_live_connections": 0
            }
        });
        assert_eq!(
            linked_peers(&answer),
            vec![Link { node_id: "0306447".into(), kind: "direct".into(), transport: "iroh".into() }]
        );
        assert!(linked_peers(&json!({ "result": { "connections": [], "total": 0 } })).is_empty());
    }
}