feat: card standalone — primitiva de identidad soberana (hoja, autocontenida)

Tarjeta de Presentación canónica: identidad arje + flujos tipados brahman,
content-addressed. Hoja pura sin deps internas — la base sobre la que se
montan red (chasqui/minga) y escritorio. cargo check pasa (3 crates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:05:09 +00:00
commit e6d21c6027
15 changed files with 6739 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# card — identidad agnóstica de transporte
Contrato de identidad y membresía **independiente del transporte**: claves
Ed25519, `EspinaId` (hash de la clave pública), firmas y handshake de membresía
de una "espina" (red privada). El mismo contrato lo implementan dos transportes
distintos: `card-net` (libp2p) y `wawa-akasha` (protocolo propio de wawa).
## Subcrates
- **`card-core`** — el contrato agnóstico real: par Ed25519 → `EspinaId`,
`Card` firmada, `handshake` de membresía (un miembro presenta su tarjeta
firmada; el anfitrión la verifica contra la raíz de confianza de la espina).
Sólo bytes firmados — no sabe de libp2p ni Akasha.
- **`card-net`** — espina dorsal P2P sobre **libp2p**: discovery (mDNS +
Kademlia DHT), gossipsub, NAT traversal (Circuit Relay v2 + DCUtR + AutoNAT).
Implementa el contrato de `card-core` sobre libp2p. Lo consume `khipu`.
- **`card-wit`** — **[DORMIDO]** binding WIT/wasm del contrato. Se reactiva
cuando `card` cruce a apps WASM; hoy el contrato real es `card-core`.
## Estado (2026-05-31)
### Hecho
- `card-core`: identidad Ed25519 + `EspinaId` + handshake de membresía (contrato
agnóstico declarado como la fuente de verdad).
- `card-net`: discovery (mDNS+DHT), gossipsub y NAT traversal completo
(Relay v2 + DCUtR + AutoNAT); discovery de personas por `DhtKey::Persona`.
### Pendiente
- `card-wit`: dormido — binding WASM pendiente de reactivación.
- Espejo del contrato sobre `wawa-akasha` (transporte wawa) aún por cablear.
- Endurecer revocación / rotación de membresía de espina.
## Lugar en el repo
`shared/card` — contrato de identidad. `card-net` lo lleva a libp2p (khipu);
`agora` cubre firma/confianza de más alto nivel.
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "card-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — Tarjeta de Presentación canónica (identidad arje + flujos tipados brahman)."
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
[dev-dependencies]
postcard = { workspace = true }
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "card-net"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — capa de transporte P2P compartida (libp2p TCP+Noise+Yamux+Kad+Identify+Stream). Cualquier protocolo (handshake brahman, sync minga, futuros) puede registrar su StreamProtocol y abrir/aceptar streams sobre la malla común."
[dependencies]
futures = { workspace = true }
libp2p = { workspace = true }
libp2p-stream = { workspace = true }
libp2p-allow-block-list = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
blake3 = { workspace = true }
serde = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
+167
View File
@@ -0,0 +1,167 @@
//! Claves namespaced del DHT compartido — la **primitiva unificadora** de
//! Brahman (Capa 1).
//!
//! El ecosistema brahman corre UN solo Kademlia (en este crate, `card-net`).
//! Para que distintos dominios — código indexado (minga), Cards
//! (card-discovery), Personas (ágora), servicios — coexistan sin colisión,
//! cada clave lleva un byte de `kind` como prefijo. La representación en wire
//! es de longitud fija: `[kind_tag] ++ blake3(id)` = 33 bytes.
//!
//! Vive en `card-net` (y no en un dominio concreto) precisamente porque es el
//! namespace COMÚN: minga, agora y card-discovery la comparten. `minga-dht`
//! la re-exporta por compatibilidad histórica.
use serde::{Deserialize, Serialize};
/// Tipo de registro — el namespace de una clave en el DHT.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecordKind {
/// Bloque de código indexado (minga).
Code,
/// `Card` brahman (módulo, ente, etc.).
Card,
/// `Persona` de ágora (identidad humana federada).
Persona,
/// Endpoint de servicio.
Service,
/// Dominio definido por el consumidor.
Custom(u8),
}
impl RecordKind {
/// Byte de etiqueta. `Custom(n)` ocupa `0x80 | n` (top bit) para no
/// chocar nunca con los kinds estándar (`0x00..`).
pub fn tag(&self) -> u8 {
match self {
RecordKind::Code => 0x01,
RecordKind::Card => 0x02,
RecordKind::Persona => 0x03,
RecordKind::Service => 0x04,
RecordKind::Custom(n) => 0x80 | (n & 0x7f),
}
}
}
/// Longitud fija de la clave en wire: 1 byte de kind + 32 de hash.
pub const DHT_KEY_LEN: usize = 33;
/// Clave de DHT namespaced. Dos formas de construcción:
///
/// - Con un `id` legible (`new`, `code`, `card`, `persona`) — el wire
/// hashea el id con blake3. Útil cuando el consumidor publica
/// identidades simbólicas (nombres de módulos, slugs de personas).
/// - Con `for_hash` — el wire usa los 32 bytes del hash directamente.
/// Útil cuando el id YA es un blake3 (como en minga, que indexa
/// contenido por su α-hash, o ágora, cuyo `IdentityId` ya es
/// `blake3(pubkey)`) — evita una segunda pasada de blake3.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DhtKey {
kind: RecordKind,
/// Representación canónica de los 32 bytes que forman la "id" en wire.
/// Si se construyó por nombre simbólico, son `blake3(id_string)`.
/// Si se construyó por hash directo, son el hash tal cual.
body: [u8; 32],
/// `id` legible — para `Display`. `None` si se construyó por hash.
label: Option<String>,
}
impl DhtKey {
pub fn new(kind: RecordKind, id: impl Into<String>) -> Self {
let id = id.into();
let body = *blake3::hash(id.as_bytes()).as_bytes();
Self {
kind,
body,
label: Some(id),
}
}
/// Clave para un bloque de código (id legible).
pub fn code(id: impl Into<String>) -> Self {
Self::new(RecordKind::Code, id)
}
/// Clave para una Card.
pub fn card(id: impl Into<String>) -> Self {
Self::new(RecordKind::Card, id)
}
/// Clave para una Persona.
pub fn persona(id: impl Into<String>) -> Self {
Self::new(RecordKind::Persona, id)
}
/// Clave a partir de un hash ya computado (32 bytes). El wire usa
/// esos bytes directamente, **sin re-hashear**.
pub fn for_hash(kind: RecordKind, hash: [u8; 32]) -> Self {
Self {
kind,
body: hash,
label: None,
}
}
pub fn kind(&self) -> RecordKind {
self.kind
}
pub fn id(&self) -> Option<&str> {
self.label.as_deref()
}
/// Representación en wire: `[kind_tag] ++ body`, 33 bytes.
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(DHT_KEY_LEN);
out.push(self.kind.tag());
out.extend_from_slice(&self.body);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_key_has_fixed_length() {
assert_eq!(DhtKey::card("modulo-x").to_bytes().len(), DHT_KEY_LEN);
assert_eq!(DhtKey::code("fn-hash").to_bytes().len(), DHT_KEY_LEN);
}
#[test]
fn same_id_different_kind_does_not_collide() {
let a = DhtKey::card("foo").to_bytes();
let b = DhtKey::code("foo").to_bytes();
let c = DhtKey::persona("foo").to_bytes();
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
// El hash del id es el mismo; sólo difiere el byte de kind.
assert_eq!(a[1..], b[1..]);
assert_ne!(a[0], b[0]);
}
#[test]
fn same_kind_and_id_is_stable() {
assert_eq!(DhtKey::card("x").to_bytes(), DhtKey::card("x").to_bytes());
}
#[test]
fn for_hash_no_rehashea() {
// for_hash debe usar los bytes tal cual: body = hash, no blake3(hash).
let h = [7u8; 32];
let k = DhtKey::for_hash(RecordKind::Persona, h);
let wire = k.to_bytes();
assert_eq!(wire[0], RecordKind::Persona.tag());
assert_eq!(&wire[1..], &h);
}
#[test]
fn custom_kind_never_collides_with_standard() {
for std in [RecordKind::Code, RecordKind::Card, RecordKind::Persona, RecordKind::Service] {
for n in 0..=127u8 {
assert_ne!(std.tag(), RecordKind::Custom(n).tag());
}
}
}
}
+518
View File
@@ -0,0 +1,518 @@
//! `brahman-net` — capa P2P compartida de la red Brahman.
//!
//! Provee un nodo libp2p genérico que cualquier protocolo de la
//! familia (handshake brahman remoto, sync minga, futuros) puede
//! reusar. La idea: una sola malla, múltiples sub-protocolos
//! multiplexados por `StreamProtocol`.
//!
//! ## Stack
//!
//! - **TCP + Noise + Yamux**: transporte autenticado y multiplexado.
//! - **`stream::Behaviour`**: streams bidireccionales por
//! `StreamProtocol`. Cada protocolo (`/brahman/handshake/1.0.0`,
//! `/minga/sync/1.0.0`, …) se registra independientemente vía el
//! `stream::Control` que `BrahmanNet` expone.
//! - **`kad::Behaviour<MemoryStore>`**: Kademlia DHT en modo Server
//! para discovery (peers cercanos + content providers).
//! - **`identify::Behaviour`**: cada peer anuncia sus listen-addrs
//! reales; las inyectamos automáticamente al routing table de Kad.
//!
//! ## Modelo
//!
//! El swarm corre en una task tokio dedicada. La interfaz pública son:
//! 1. **Comandos** (canal mpsc): `dial`, `listen`, `add_dht_peer`,
//! `find_closest_peers`, `start_providing`, `find_providers`.
//! 2. **`stream::Control`** (acceso directo): para abrir/aceptar
//! streams de un protocolo concreto. Cada protocolo se ocupa de
//! su propia lógica sobre el stream resultante.
//!
//! La separación entre comandos y control permite que la lógica de
//! red (DHT, dial, listen) y la lógica de protocolos (handshake/sync)
//! evolucionen independientes — el protocolo no necesita conocer al
//! swarm, sólo pide streams.
//!
//! ## Identidad
//!
//! Por defecto se genera una keypair Ed25519 efímera. Para identidad
//! persistente (la misma `peer_id` across reboots), pasar la keypair
//! con [`BrahmanNet::with_keypair`]. Esa misma keypair puede ser la
//! base para firmas de Cards (cuando se implemente trust remoto).
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use libp2p::{
autonat, dcutr, identify, identity, kad, mdns, noise, relay,
swarm::{behaviour::toggle::Toggle, NetworkBehaviour, SwarmEvent},
tcp, yamux, Swarm, SwarmBuilder,
};
use libp2p_allow_block_list::{self as allow_block_list, BlockedPeers};
use libp2p_stream as stream;
use tokio::sync::{mpsc, oneshot, Mutex};
pub mod key;
pub use key::{DhtKey, RecordKind, DHT_KEY_LEN};
pub use libp2p::{
identity::{Keypair, PublicKey},
multiaddr::Protocol,
Multiaddr, PeerId, Stream, StreamProtocol,
};
pub use libp2p_stream::OpenStreamError;
const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0";
const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(NetworkBehaviour)]
struct BrahmanBehaviour {
/// Block-list a nivel de swarm: peers en este behaviour son
/// rechazados ANTES del handshake Noise. Más eficiente que
/// rechazar al nivel del handshake brahman (ahorra round-trip
/// TCP+Noise por intento denegado). Sincronizado con la
/// `PeerPolicy.deny` vía `block_peer`/`unblock_peer` exposed
/// en `BrahmanNet`.
block_list: allow_block_list::Behaviour<BlockedPeers>,
stream: stream::Behaviour,
kad: kad::Behaviour<kad::store::MemoryStore>,
identify: identify::Behaviour,
/// Relay server (Circuit Relay v2): un nodo alcanzable presta su
/// conexión para que dos pares detrás de NAT se contacten. Pasivo —
/// sólo actúa ante reservas/solicitudes de circuito.
relay: relay::Behaviour,
/// Relay client: permite reservar un circuito en un relay y ser
/// alcanzable vía `…/p2p/<relay>/p2p-circuit/p2p/<self>`.
relay_client: relay::client::Behaviour,
/// DCUtR: tras conectar por relay, intenta promover a conexión
/// directa (hole-punching) coordinando por el circuito.
dcutr: dcutr::Behaviour,
/// AutoNAT: confirma qué direcciones externas son realmente alcanzables
/// pidiéndole a otros peers que nos disquen de vuelta. Sustituye el
/// "confiar a ciegas en el observed_addr de identify" por direcciones
/// verificadas — sólo esas se anuncian (y entran en reservas de relay).
autonat: autonat::Behaviour,
/// mDNS: descubrimiento de pares en la MISMA LAN sin bootstrap ni IP
/// conocida (multicast 224.0.0.251). Los pares descubiertos se inyectan
/// a la routing table de Kad (igual que las listen-addrs de identify), así
/// el DHT y los protocolos de stream funcionan zero-config en LAN.
/// `Toggle`: si el socket multicast no se puede abrir (sandbox, red sin
/// multicast), queda deshabilitado y el nodo sigue andando igual.
mdns: Toggle<mdns::tokio::Behaviour>,
}
#[derive(Debug, thiserror::Error)]
pub enum NodeError {
#[error("transport build failed: {0}")]
Build(String),
}
#[derive(Debug)]
enum Command {
Dial(Multiaddr),
Listen(Multiaddr),
AddDhtPeer(PeerId, Multiaddr),
FindClosestPeers(PeerId, oneshot::Sender<Vec<DiscoveredPeer>>),
StartProviding(Vec<u8>),
StopProviding(Vec<u8>),
GetProviders(Vec<u8>, oneshot::Sender<Vec<PeerId>>),
BlockPeer(PeerId),
UnblockPeer(PeerId),
}
/// Peer descubierto vía DHT: identidad + direcciones conocidas.
#[derive(Debug, Clone)]
pub struct DiscoveredPeer {
pub peer_id: PeerId,
pub addrs: Vec<Multiaddr>,
}
/// Nodo Brahman en la malla P2P. Maneja el swarm libp2p y expone
/// API uniforme para listen/dial/DHT/streams.
pub struct BrahmanNet {
/// Identidad libp2p de este nodo. Estable mientras viva la
/// keypair (efímera por default; persistente si pasaste una
/// vía [`with_keypair`]).
pub peer_id: PeerId,
/// Keypair compartida (Arc para compartir con consumers que
/// necesitan firmar mensajes con la misma identidad — p. ej.
/// `card_handshake::network::connect_libp2p` que firma el
/// Hello). NO se expone públicamente; usar [`Self::keypair`].
keypair: Arc<Keypair>,
cmd_tx: mpsc::UnboundedSender<Command>,
listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
/// Control para abrir y aceptar streams. Cada protocolo
/// (handshake brahman, sync minga, etc.) llama
/// `control.accept(StreamProtocol::new("/foo/1.0.0"))` para
/// recibir streams entrantes, o `control.open_stream(peer, proto)`
/// para abrirlos. Multiplexado y demultiplexado lo hace libp2p.
pub control: stream::Control,
}
impl BrahmanNet {
/// Crea un nodo con keypair Ed25519 generada al vuelo (peer_id
/// efímero — cambia en cada arranque).
pub fn new() -> Result<Self, NodeError> {
Self::with_keypair(identity::Keypair::generate_ed25519())
}
/// Crea un nodo con una keypair libp2p específica. Usá esto para
/// `peer_id` estable (por ejemplo si tu identidad se persiste a
/// disco, o si la derivás de la identidad criptográfica del
/// módulo).
///
/// Sólo Ed25519 se soporta — la `keypair` se duplica internamente
/// vía clone del `ed25519::Keypair` para que tanto el swarm
/// (Noise auth) como el caller (firma de Cards) compartan la
/// misma identidad sin la fricción de que `identity::Keypair` no
/// implemente `Clone`.
pub fn with_keypair(keypair: identity::Keypair) -> Result<Self, NodeError> {
let ed_kp = keypair
.try_into_ed25519()
.map_err(|_| NodeError::Build("brahman-net sólo soporta keypairs Ed25519".into()))?;
let kp_for_swarm = identity::Keypair::from(ed_kp.clone());
let kp_for_storage = Arc::new(identity::Keypair::from(ed_kp));
let peer_id = kp_for_swarm.public().to_peer_id();
let mut swarm: Swarm<BrahmanBehaviour> = SwarmBuilder::with_existing_identity(kp_for_swarm)
.with_tokio()
.with_tcp(
tcp::Config::default(),
noise::Config::new,
yamux::Config::default,
)
.map_err(|e| NodeError::Build(format!("{e}")))?
// Inyecta el transporte de relay-client: habilita marcar y
// escuchar direcciones `…/p2p-circuit`. Provee el
// `relay_client` behaviour al closure de abajo.
.with_relay_client(noise::Config::new, yamux::Config::default)
.map_err(|e| NodeError::Build(format!("{e}")))?
.with_behaviour(|key, relay_client| {
let local = key.public().to_peer_id();
let mut kad =
kad::Behaviour::new(local, kad::store::MemoryStore::new(local));
// Modo Server: respondemos a queries del DHT. Auto
// requiere detectar reachability; para entornos
// controlados (localhost, redes privadas) Server es
// lo correcto.
kad.set_mode(Some(kad::Mode::Server));
let identify = identify::Behaviour::new(
identify::Config::new(IDENTIFY_PROTOCOL.to_string(), key.public())
.with_agent_version(format!("brahman-net/{}", env!("CARGO_PKG_VERSION"))),
);
// mDNS resiliente: si el socket multicast no abre, seguimos sin
// descubrimiento LAN en vez de fallar el nodo entero.
let depurar = std::env::var("BRAHMAN_DEBUG").is_ok();
let mdns = match mdns::tokio::Behaviour::new(mdns::Config::default(), local) {
Ok(b) => {
if depurar {
eprintln!("brahman: mDNS ACTIVO (descubrimiento LAN)");
}
Toggle::from(Some(b))
}
Err(e) => {
if depurar {
eprintln!("brahman: mDNS DESACTIVADO ({e})");
}
Toggle::from(None)
}
};
BrahmanBehaviour {
block_list: allow_block_list::Behaviour::default(),
stream: stream::Behaviour::new(),
kad,
identify,
relay: relay::Behaviour::new(local, Default::default()),
relay_client,
dcutr: dcutr::Behaviour::new(local),
mdns,
autonat: autonat::Behaviour::new(
local,
autonat::Config {
// Confirmamos también IPs privadas/loopback: la
// malla Brahman vive tanto en LAN como en WAN, no
// sólo en IPs globales (default `true` las
// ignoraría y nada se confirmaría en LAN).
only_global_ips: false,
// Sondeo pronto tras arrancar (default ~15 s es
// demasiado para el flujo de reservas de relay).
boot_delay: Duration::from_secs(2),
retry_interval: Duration::from_secs(5),
..Default::default()
},
),
}
})
.map_err(|e| NodeError::Build(format!("{e}")))?
.with_swarm_config(|c| c.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT))
.build();
let control = swarm.behaviour().stream.new_control();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
let (listen_tx, listen_rx) = mpsc::unbounded_channel::<Multiaddr>();
tokio::spawn(async move {
let mut pending_finds: HashMap<
kad::QueryId,
oneshot::Sender<Vec<DiscoveredPeer>>,
> = HashMap::new();
let mut pending_providers: HashMap<
kad::QueryId,
(Vec<PeerId>, oneshot::Sender<Vec<PeerId>>),
> = HashMap::new();
loop {
tokio::select! {
Some(cmd) = cmd_rx.recv() => {
match cmd {
Command::Dial(addr) => {
let _ = swarm.dial(addr);
}
Command::Listen(addr) => {
let _ = swarm.listen_on(addr);
}
Command::AddDhtPeer(peer, addr) => {
swarm.behaviour_mut().kad.add_address(&peer, addr);
}
Command::FindClosestPeers(target, tx) => {
let qid = swarm.behaviour_mut().kad.get_closest_peers(target);
pending_finds.insert(qid, tx);
}
Command::StartProviding(key) => {
// Best-effort: si falla (sin peers cercanos para
// replicar), seguirá viviendo en el local store
// y se servirá vía get_providers de quien tenga
// conexión con nosotros.
let _ = swarm.behaviour_mut().kad.start_providing(key.into());
}
Command::StopProviding(key) => {
// Quitamos el record local del provider store.
// Los peers cercanos eventualmente expiran su
// copia replicada por TTL natural (~24h en
// libp2p kad default); para retiro inmediato
// habría que enviar un republish con sentinel,
// pero kad no expone esa primitiva. Aceptable
// para el caso "el provider local desapareció":
// queries que pasen por nosotros dejan de
// listarnos al instante.
swarm.behaviour_mut().kad.stop_providing(&key.into());
}
Command::GetProviders(key, tx) => {
let qid = swarm.behaviour_mut().kad.get_providers(key.into());
pending_providers.insert(qid, (Vec::new(), tx));
}
Command::BlockPeer(peer) => {
swarm.behaviour_mut().block_list.block_peer(peer);
}
Command::UnblockPeer(peer) => {
swarm.behaviour_mut().block_list.unblock_peer(peer);
}
}
}
event = swarm.select_next_some() => {
match event {
SwarmEvent::NewListenAddr { address, .. } => {
let _ = listen_tx.send(address);
}
// Identify nos dice las listen-addrs reales del
// peer. Las inyectamos a Kad para poblar el
// routing table sin necesidad de add_dht_peer
// manual — la propagación pasa a ser automática.
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Identify(
identify::Event::Received { peer_id, info, .. }
)) => {
// El observed_addr que nos reporta identify se
// emite como CANDIDATO a externa; AutoNAT lo
// prueba pidiendo dial-backs y, si es
// alcanzable, dispara StatusChanged(Public) —
// ahí recién lo confirmamos (abajo). Las
// listen-addrs del peer pueblan la routing
// table de Kad.
for addr in info.listen_addrs {
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
}
}
// mDNS descubrió pares en la LAN: inyectamos sus
// direcciones a Kad (igual que identify). Con eso el
// DHT y los streams funcionan sin bootstrap manual.
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Mdns(
mdns::Event::Discovered(list)
)) => {
for (peer_id, addr) in list {
if std::env::var("BRAHMAN_DEBUG").is_ok() {
eprintln!("brahman: mDNS descubrió {peer_id} en {addr}");
}
swarm.behaviour_mut().kad.add_address(&peer_id, addr);
}
}
// AutoNAT confirmó (vía dial-back de otros peers)
// que somos alcanzables en `addr`: recién entonces
// la anunciamos como externa — la usa el relay
// server en las reservas y la ven los pares.
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Autonat(
autonat::Event::StatusChanged { new: autonat::NatStatus::Public(addr), .. }
)) => {
swarm.add_external_address(addr);
}
SwarmEvent::Behaviour(BrahmanBehaviourEvent::Kad(
kad::Event::OutboundQueryProgressed { id, result, step, .. }
)) => {
match result {
kad::QueryResult::GetClosestPeers(Ok(ok)) if step.last => {
if let Some(tx) = pending_finds.remove(&id) {
let infos = ok.peers.into_iter()
.map(|p| DiscoveredPeer {
peer_id: p.peer_id,
addrs: p.addrs,
})
.collect();
let _ = tx.send(infos);
}
}
kad::QueryResult::GetClosestPeers(Err(_)) if step.last => {
if let Some(tx) = pending_finds.remove(&id) {
let _ = tx.send(Vec::new());
}
}
kad::QueryResult::GetProviders(Ok(ok)) => {
if let Some((collected, _)) =
pending_providers.get_mut(&id)
{
if let kad::GetProvidersOk::FoundProviders {
providers, ..
} = ok
{
for p in providers {
if !collected.contains(&p) {
collected.push(p);
}
}
}
}
if step.last {
if let Some((providers, tx)) =
pending_providers.remove(&id)
{
let _ = tx.send(providers);
}
}
}
kad::QueryResult::GetProviders(Err(_)) if step.last => {
if let Some((providers, tx)) =
pending_providers.remove(&id)
{
let _ = tx.send(providers);
}
}
_ => {}
}
}
_ => {}
}
}
}
}
});
Ok(Self {
peer_id,
keypair: kp_for_storage,
cmd_tx,
listen_rx: Mutex::new(listen_rx),
control,
})
}
/// Acceso a la keypair de identidad del nodo. Usar para firmar
/// payloads que viajan asociados al `peer_id` (handshake brahman
/// firmado, futuros sub-protocolos con autenticación). El `Arc`
/// permite compartir sin copia — la keypair libp2p no es `Clone`.
pub fn keypair(&self) -> Arc<Keypair> {
self.keypair.clone()
}
/// Bloquea conexiones desde/hacia `peer` a nivel del swarm.
/// Conexiones existentes se cierran y nuevos intentos son
/// rechazados ANTES del Noise handshake — más eficiente que
/// rechazar al nivel del handshake brahman (ahorra round-trip
/// TCP+Noise por intento). Idempotente.
pub fn block_peer(&self, peer: PeerId) {
let _ = self.cmd_tx.send(Command::BlockPeer(peer));
}
/// Quita a `peer` de la block-list del swarm. Conexiones futuras
/// son aceptadas con normalidad. Idempotente.
pub fn unblock_peer(&self, peer: PeerId) {
let _ = self.cmd_tx.send(Command::UnblockPeer(peer));
}
/// Empieza a escuchar en `addr`. Bloquea hasta que el listener
/// publique su dirección real (Multiaddr resuelta — útil cuando
/// pediste `/ip4/0.0.0.0/tcp/0` y querés saber qué puerto te tocó).
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
self.cmd_tx
.send(Command::Listen(addr))
.expect("swarm task alive");
let mut rx = self.listen_rx.lock().await;
rx.recv().await.expect("listen address arrives")
}
/// Inicia conexión con un peer en `addr`. No-op si ya hay
/// conexión. Best-effort — fallos se loggean al swarm pero no se
/// propagan al caller (consistente con libp2p).
pub fn dial(&self, addr: Multiaddr) {
let _ = self.cmd_tx.send(Command::Dial(addr));
}
/// Añade un peer al routing table de Kademlia. Punto de entrada
/// para bootstrap: tras esto, el nodo puede dirigir queries DHT
/// a través de este peer.
pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) {
let _ = self.cmd_tx.send(Command::AddDhtPeer(peer, addr));
}
/// Consulta el DHT por los peers más cercanos al `target` PeerId.
/// Devuelve la lista resuelta (vacía si la query falla o si no
/// hay peers conocidos). Bloquea hasta que la query completa.
pub async fn find_closest_peers(&self, target: PeerId) -> Vec<DiscoveredPeer> {
let (tx, rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(Command::FindClosestPeers(target, tx));
rx.await.unwrap_or_default()
}
/// Anuncia en el DHT que este peer tiene el contenido identificado
/// por `key`. Otros peers pueden luego descubrirlo vía
/// [`find_providers`](Self::find_providers). Best-effort: si la
/// replicación falla inicialmente, el record vive en el store
/// local hasta que llegue conexión.
pub fn start_providing(&self, key: &[u8]) {
let _ = self.cmd_tx.send(Command::StartProviding(key.to_vec()));
}
/// Retira el anuncio previo de [`start_providing`] para `key`.
/// El record local se borra al instante (queries que lleguen a
/// nosotros dejan de listarnos). Los records replicados en peers
/// remotos viven hasta su TTL — kad no expone primitiva para
/// retracción inmediata cross-peer. Aceptable: simétrico al
/// caso "el provider apareció" (también propagación eventual).
pub fn stop_providing(&self, key: &[u8]) {
let _ = self.cmd_tx.send(Command::StopProviding(key.to_vec()));
}
/// Consulta el DHT por peers que han anunciado proveer `key`.
/// Devuelve la lista de `PeerId`s que se reportan como providers.
/// Lista vacía si nadie anuncia.
pub async fn find_providers(&self, key: &[u8]) -> Vec<PeerId> {
let (tx, rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(Command::GetProviders(key.to_vec(), tx));
rx.await.unwrap_or_default()
}
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "card-wit"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — extractor opcional: parsea contratos WIT y devuelve `WitInterface` listo para acoplar a una `Card`."
[dependencies]
card-core = { path = "../card-core" }
wit-parser = "0.230"
thiserror = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
[[example]]
name = "brahman-wit-info"
path = "examples/brahman-wit-info.rs"
+34
View File
@@ -0,0 +1,34 @@
# brahman-card-wit
> **DORMIDO (2026-05-30).** Capa 3 de Brahman. Ver `/BRAHMAN.md`.
Parser opcional de contratos WIT (`.wit` texto → `Vec<card_core::WitInterface>`, uno por `world`),
sobre `wit-parser` (sin `wasm-tools`/`wit-component`).
## Estado: relegado, no borrado
La visión original de Brahman incluía **módulos agnósticos descritos por interfaz WIT** (eventualmente WASM).
Esa capa **nunca se ejecutó**:
- No existe ningún archivo `.wit` en el workspace.
- Ningún crate de producción depende de este crate — sólo `examples/brahman-wit-info.rs` y la
**dev-dependency** de `card-sidecar`.
Se conserva (funciona, 210 LOC, reversible) por si algún día aparecen `.wit` reales. **No asumir que está
en ninguna ruta de build.**
## El contrato agnóstico real y vigente
```
shared/card (formato Card) + card-handshake (handshake nativo Rust) + DhtKey (namespacing en la DHT)
```
El tipo de metadata `WitInterface` vive en **`card-core`** (no aquí) y **sí** lo usa el broker
(`chasqui-broker`) para matching estructural — es metadata opcional viva. Lo único dormido es *este parser*
de archivos `.wit` inexistentes.
## Si se decide revivir WIT
Requeriría: (1) que el Init lea `<modulo>/wit/protocol.wit` en el descubrimiento y construya
`ResolvedCard::from_conscious(card, wit)`; (2) que algún módulo de producción publique un `.wit`.
Hasta entonces, este crate es tooling latente (`cargo run -p card-wit --example brahman-wit-info -- <archivo.wit>`).
@@ -0,0 +1,45 @@
//! `brahman-wit-info` — inspecciona un archivo WIT y lista sus worlds.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-card-wit --example brahman-wit-info -- shared_wit/protocol.wit
//! ```
use std::process::ExitCode;
fn main() -> ExitCode {
let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
eprintln!("uso: brahman-wit-info <ruta.wit>");
return ExitCode::from(2);
}
};
let worlds = match card_wit::parse_wit_file(&path) {
Ok(w) => w,
Err(e) => {
eprintln!("error parseando {path}: {e}");
return ExitCode::from(1);
}
};
if worlds.is_empty() {
println!("(ningún world declarado)");
return ExitCode::SUCCESS;
}
println!("{} world(s):", worlds.len());
for w in &worlds {
println!();
println!(" package: {}", w.package);
println!(" world: {}", w.world);
if !w.imports.is_empty() {
println!(" imports: {}", w.imports.join(", "));
}
if !w.exports.is_empty() {
println!(" exports: {}", w.exports.join(", "));
}
}
ExitCode::SUCCESS
}
+178
View File
@@ -0,0 +1,178 @@
//! `brahman-card-wit` — extractor de contratos WIT.
//!
//! **DORMIDO (2026-05-30).** No existe ningún archivo `.wit` en el workspace
//! y ningún crate de producción depende de éste (sólo `examples/` y la
//! dev-dependency de `card-sidecar`). Es la "Capa 3" de Brahman (ver `/BRAHMAN.md`):
//! la idea de módulos agnósticos descritos por interfaz WIT nunca se ejecutó.
//! El **contrato agnóstico real y vigente** es `shared/card` (formato `Card`) +
//! el handshake nativo Rust de `card-handshake` + el namespacing de `DhtKey`.
//! Se conserva como herramienta opcional por si algún día aparecen `.wit`
//! reales; no asumir que está en ninguna ruta de build.
//!
//! Nota: el tipo de metadata [`WitInterface`] vive en `card-core` y SÍ lo usa
//! el broker para matching estructural — eso es metadata opcional viva. Lo
//! dormido es este *parser* de archivos `.wit` inexistentes.
//!
//! Crate **opcional** (no es dep de `brahman-card`). Parsea texto WIT
//! mediante [`wit-parser`] y devuelve una lista de [`WitInterface`]
//! (uno por `world`) lista para acoplarse a una [`card_core::Card`]
//! cuando se construye una [`card_core::ResolvedCard`].
//!
//! Casos de uso (hipotéticos hasta que existan `.wit`):
//!
//! - El Init lee `<modulo>/wit/protocol.wit` durante el descubrimiento
//! y lo combina con la Card del módulo para obtener una
//! `ResolvedCard::from_conscious(card, wit)`.
//! - Tooling (`brahman-wit-info`) inspecciona un `.wit` y muestra
//! sus mundos, exports e imports.
//!
//! No depende de `wasm-tools`/`wit-component` — sólo del parser texto.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::path::{Path, PathBuf};
use card_core::WitInterface;
use thiserror::Error;
use wit_parser::{Resolve, WorldKey};
#[derive(Debug, Error)]
pub enum WitError {
#[error("parse: {0}")]
Parse(String),
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
}
/// Parsea WIT desde una string. Devuelve un `WitInterface` por cada
/// `world` declarado.
pub fn parse_wit(source: &str) -> Result<Vec<WitInterface>, WitError> {
parse_with_path(source, Path::new("inline.wit"))
}
/// Parsea WIT desde un archivo. Útil para `module/wit/protocol.wit`.
pub fn parse_wit_file(path: impl AsRef<Path>) -> Result<Vec<WitInterface>, WitError> {
let p = path.as_ref();
let source = std::fs::read_to_string(p)?;
parse_with_path(&source, p)
}
fn parse_with_path(source: &str, path: &Path) -> Result<Vec<WitInterface>, WitError> {
let mut resolve = Resolve::new();
let path_buf: PathBuf = path.to_path_buf();
resolve
.push_str(&path_buf, source)
.map_err(|e| WitError::Parse(e.to_string()))?;
let mut out = Vec::new();
for (_pkg_id, pkg) in resolve.packages.iter() {
let pkg_name = pkg.name.to_string();
for (_name, &world_id) in &pkg.worlds {
let world = &resolve.worlds[world_id];
let exports = collect_keys(world.exports.iter().map(|(k, _)| k), &resolve);
let imports = collect_keys(world.imports.iter().map(|(k, _)| k), &resolve);
out.push(WitInterface {
package: pkg_name.clone(),
world: world.name.clone(),
exports,
imports,
});
}
}
Ok(out)
}
fn collect_keys<'a, I>(keys: I, resolve: &Resolve) -> Vec<String>
where
I: Iterator<Item = &'a WorldKey>,
{
keys.map(|k| match k {
WorldKey::Name(n) => n.clone(),
WorldKey::Interface(id) => resolve.interfaces[*id]
.name
.clone()
.unwrap_or_else(|| format!("<interface#{}>", id.index())),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
package brahman:test@0.1.0;
interface handshake {
hello: func() -> result<_, string>;
}
interface lifecycle {
report: func();
}
world module {
import handshake;
import lifecycle;
export run: func() -> result<_, string>;
}
"#;
#[test]
fn parses_inline_wit() {
let worlds = parse_wit(SAMPLE).unwrap();
assert_eq!(worlds.len(), 1, "esperaba un único world");
let w = &worlds[0];
assert!(w.package.starts_with("brahman:test"));
assert_eq!(w.world, "module");
assert!(
w.imports.iter().any(|i| i == "handshake"),
"imports={:?}",
w.imports
);
assert!(
w.imports.iter().any(|i| i == "lifecycle"),
"imports={:?}",
w.imports
);
assert!(
w.exports.iter().any(|e| e == "run"),
"exports={:?}",
w.exports
);
}
#[test]
fn parses_shared_protocol() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../shared_wit/protocol.wit");
let worlds = parse_wit_file(path).unwrap();
assert!(
worlds.iter().any(|w| w.world == "module"),
"no encontró world 'module' en {:?}",
worlds.iter().map(|w| &w.world).collect::<Vec<_>>()
);
assert!(
worlds.iter().any(|w| w.world == "admin-host"),
"no encontró world 'admin-host'"
);
}
#[test]
fn parse_error_on_garbage() {
let bad = "this is not wit at all { } } ;;;;";
assert!(matches!(parse_wit(bad), Err(WitError::Parse(_))));
}
#[test]
fn empty_world_handled() {
let src = r#"
package brahman:empty@0.1.0;
world hollow {}
"#;
let worlds = parse_wit(src).unwrap();
assert_eq!(worlds.len(), 1);
assert!(worlds[0].exports.is_empty());
assert!(worlds[0].imports.is_empty());
}
}