diff --git a/Cargo.lock b/Cargo.lock index 5689dce..416e4a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.7.0" +version = "0.8.0-alpha" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.7.0" +version = "0.8.0-alpha" dependencies = [ "anyhow", "base64 0.22.1", @@ -2753,8 +2753,10 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "hex", - "igd-next", "iroh", + "jni", + "ndk-context", + "portmapper", "rand 0.9.2", "rusqlite", "serde", @@ -2767,7 +2769,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.7.0" +version = "0.8.0-alpha" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 8515386..41b245a 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.7.0" +version = "0.8.0-alpha" edition = "2021" [[bin]] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 61c959c..e81885b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -27,6 +27,7 @@ async fn main() -> anyhow::Result<()> { let mut web_port: Option = None; let mut print_identity = false; let mut announce = false; + let mut publish_registry = false; let mut ann_category: Option = None; let mut ann_title: Option = None; let mut ann_body: Option = None; @@ -76,6 +77,7 @@ async fn main() -> anyhow::Result<()> { } "--print-identity" => { print_identity = true; i += 1; } "--announce" => { announce = true; i += 1; } + "--publish-registry" => { publish_registry = true; i += 1; } "--ann-category" => { ann_category = args.get(i + 1).cloned(); i += 2; } "--ann-title" => { ann_title = args.get(i + 1).cloned(); i += 2; } "--ann-body" => { ann_body = args.get(i + 1).cloned(); i += 2; } @@ -152,6 +154,29 @@ async fn main() -> anyhow::Result<()> { return Ok(()); } + // One-shot: --publish-registry — genesis publish of the network + // registry post. Refuses unless this node's default posting identity + // is the bootstrap anchor's (debug builds may override for tests via + // ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1). Prints the registry post ID + // for verification against the shipped constant. + if publish_registry { + let node = Node::open_with_bind(&data_dir, bind_addr, profile).await?; + match node.publish_registry_genesis().await { + Ok(post_id) => { + println!("registry_post_id: {}", hex::encode(post_id)); + println!( + "shipped_constant: {}", + hex::encode(itsgoin_core::registry::REGISTRY_POST_ID) + ); + } + Err(e) => { + eprintln!("Failed to publish registry genesis: {}", e); + std::process::exit(1); + } + } + return Ok(()); + } + println!("Starting ItsGoin node (data: {}, profile: {:?})...", data_dir, profile); let node = Arc::new(Node::open_with_bind(&data_dir, bind_addr, profile).await?); @@ -162,8 +187,9 @@ async fn main() -> anyhow::Result<()> { let addr = node.endpoint_addr(); let sockets: Vec<_> = addr.ip_addrs().collect(); - // Show our display name if set - let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None); + // Show our display name if set (profiles are keyed by POSTING identity, + // not the network NodeId). + let my_name = node.get_display_name(&node.default_posting_id).await.unwrap_or(None); let name_display = my_name.as_deref().unwrap_or("(not set)"); println!(); @@ -204,8 +230,21 @@ async fn main() -> anyhow::Result<()> { println!(" redundancy Show replica counts for your posts"); println!(" worm Worm lookup (find peer beyond 3-hop map)"); println!(" connections Show mesh connections"); + println!(" slots Show mesh / temp-referral slot usage"); + println!(" uniques [node_id_hex] Uniques index counts, or where an ID is known"); + println!(" convection Adaptive convection weights + pool-mined anchors"); + println!(" convect Ask an anchor for peers (convection)"); + println!(" uniques-pull Exchange uniques indexes with mesh peers"); println!(" social-routes Show social routing cache"); println!(" name Set your display name"); + println!(" register [keywords...] Register default persona in the network registry (30d, re-run to renew)"); + println!(" unregister Remove your registry entry (signed delete)"); + println!(" search Search the registry (pulls chain, queries locally)"); + println!(" greet Send a sealed greeting to a bio post's author"); + println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)"); + println!(" reply Sealed reply to a greeting's return path (fresh reply key)"); + println!(" dismiss Dismiss a greeting (local only)"); + println!(" greetings-open Toggle the greeting slot on your bio (republishes bio)"); println!(" stats Show node stats"); println!(" export-key Export identity key (KEEP SECRET)"); println!(" id Show this node's ID"); @@ -214,14 +253,22 @@ async fn main() -> anyhow::Result<()> { // Start background tasks (v2: mesh connections) let _accept_handle = node.start_accept_loop(); - let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle + let _sync_handle = node.start_sync_cycle(); // 60s: uniques-index exchange + content sync let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance - let _growth_handle = node.start_growth_loop(); // reactive mesh growth + // Reactive mesh growth. `ITSGOIN_TEST_NO_GROWTH` suppresses it so an + // integration test can hold an exact topology (on loopback the growth loop + // otherwise collapses any chain into a full mesh within seconds, and a + // full mesh has no N2 at all — every peer is a direct). Test gate only. + let _growth_handle = if std::env::var("ITSGOIN_TEST_NO_GROWTH").is_ok() { + eprintln!("[test gate] growth loop disabled (ITSGOIN_TEST_NO_GROWTH)"); + None + } else { + Some(node.start_growth_loop()) + }; let _recovery_handle = node.start_recovery_loop(); // reactive anchor reconnect on mesh loss let _checkin_handle = node.start_social_checkin_cycle(3600); // 1 hour social checkin - let _anchor_handle = node.start_anchor_register_cycle(600); // 10 min anchor register - let _upnp_handle = node.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped) + let _convection_handle = node.start_convection_loop(); // stochastic anchor convection + anchor self-probe let _upnp_tcp_handle = node.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal let _http_handle = node.start_http_server(); // HTTP post delivery (if publicly reachable) let _bootstrap_check = node.start_bootstrap_connectivity_check(); // 24h isolation check @@ -254,6 +301,10 @@ async fn main() -> anyhow::Result<()> { let stdin = io::stdin(); let reader = stdin.lock(); + // A3: cached greeting list so `reply ` / `dismiss ` indices + // refer to the last `greetings` output. + let mut last_greetings: Vec = Vec::new(); + print!("> "); io::stdout().flush()?; @@ -342,9 +393,13 @@ async fn main() -> anyhow::Result<()> { if follows.is_empty() { println!("(not following anyone)"); } + let own_ids: Vec = node + .list_posting_identities().await.unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); for nid in follows { let name = node.get_display_name(&nid).await.unwrap_or(None); - let label = if nid == node.node_id { " (you)" } else { "" }; + // Follows are posting ids — "you" = any of our personas. + let label = if own_ids.contains(&nid) { " (you)" } else { "" }; if let Some(name) = name { println!(" {} ({}){}", name, &hex::encode(nid)[..12], label); } else { @@ -697,7 +752,7 @@ async fn main() -> anyhow::Result<()> { "create-persona" => { let name = arg.unwrap_or("").to_string(); - match node.create_posting_identity(name).await { + match node.create_posting_identity(name, None).await { Ok(id) => { println!("Created posting identity: {}", hex::encode(id.node_id)); } @@ -817,7 +872,7 @@ async fn main() -> anyhow::Result<()> { println!("(no mesh connections)"); } else { println!("Mesh connections ({}):", conns.len()); - for (nid, slot_kind, connected_at) in conns { + for (nid, slot, connected_at) in conns { let name = node.get_display_name(&nid).await.unwrap_or(None); let id_short = &hex::encode(nid)[..12]; let label = name.map(|n| format!("{} ({})", n, id_short)) @@ -829,7 +884,95 @@ async fn main() -> anyhow::Result<()> { .as_millis() as u64; (now.saturating_sub(connected_at)) / 1000 }; - println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs); + println!(" {} [{}] connected {}s ago", label, slot, duration_secs); + } + } + } + + // v0.8 Iteration C observability: slot pool + uniques index. + "slots" => { + let conns = node.list_connections().await; + let mesh = conns.iter().filter(|(_, s, _)| s.is_mesh()).count(); + let temp = conns.len() - mesh; + println!("mesh {}/{} temp-referral {}/{}", + mesh, node.device_profile().mesh_slots(), + temp, node.device_profile().temp_referral_slots()); + for (nid, slot, _) in conns { + println!(" {} {}", &hex::encode(nid)[..12], slot); + } + } + + "uniques" => { + let storage = node.storage.get().await; + if parts.len() > 1 { + match itsgoin_core::parse_node_id_hex(parts[1]) { + Ok(target) => { + let mut any = false; + for bounce in 2u8..=4 { + for reporter in storage.find_reporters_at(&target, bounce).unwrap_or_default() { + println!(" N{} via {}", bounce, &hex::encode(reporter)[..12]); + any = true; + } + } + if !any { + println!("(not in the uniques index)"); + } + } + Err(e) => println!("Bad node id: {}", e), + } + } else { + println!("N2 {} N3 {} N4 {} (anchor density {:.2})", + storage.count_distinct_n2().unwrap_or(0), + storage.count_distinct_n3().unwrap_or(0), + storage.count_distinct_n4().unwrap_or(0), + storage.anchor_density().unwrap_or(0.0)); + } + } + + // The v0.8 "pull": exchange uniques indexes with every mesh peer. + "uniques-pull" => { + let n = node.uniques_pull().await; + println!("uniques-pull: exchanged with {} mesh peers", n); + } + + // On-demand convection against a named anchor. + "convect" => { + if parts.len() < 2 { + println!("Usage: convect "); + } else { + match itsgoin_core::parse_node_id_hex(parts[1]) { + Ok(anchor) => match node.convection_request(anchor).await { + Ok((_, true, ms)) => println!("convection: refused in {}ms", ms), + Ok((n, false, ms)) => println!("convection: {} new peers in {}ms", n, ms), + Err(e) => println!("convection failed: {}", e), + }, + Err(e) => println!("Bad node id: {}", e), + } + } + } + + // Convection diagnostics: the adaptive action weights, the + // pool-mined anchor directory, and a manual entry-class request. + "convection" => { + let (density, bias, mesh) = node.network.conn_handle().convection_state().await; + let cap = node.device_profile().mesh_slots(); + let fill = if cap == 0 { 1.0 } else { mesh as f64 / cap as f64 }; + let w = itsgoin_core::connection::convection_weights(density, bias, fill); + let total = w.nothing + w.anchor + w.mesh; + println!("anchor_density {:.3} anchor_bias {:.2} mesh {}/{}", density, bias, mesh, cap); + println!("weights: nothing {:.3} anchor {:.3} mesh {:.3} (P: {:.0}% / {:.0}% / {:.0}%)", + w.nothing, w.anchor, w.mesh, + 100.0 * w.nothing / total, 100.0 * w.anchor / total, 100.0 * w.mesh / total); + let anchors = { + let storage = node.storage.get().await; + storage.list_pool_anchors(16).unwrap_or_default() + }; + if anchors.is_empty() { + println!("pool anchors: (none)"); + } else { + println!("pool anchors ({}):", anchors.len()); + for (nid, addrs) in anchors { + println!(" {} {}", &hex::encode(nid)[..12], addrs.join(",")); } } } @@ -861,6 +1004,166 @@ async fn main() -> anyhow::Result<()> { } } + "register" => { + if let Some(rest) = arg { + let mut words = rest.split_whitespace(); + let name = words.next().unwrap_or("").to_string(); + let keywords: Vec = words.map(|w| w.to_string()).collect(); + if name.is_empty() { + println!("Usage: register [keywords...]"); + } else { + let pid = node.default_posting_id; + match node.register_persona(&pid, &name, &keywords).await { + Ok(()) => println!( + "Registered '{}' ({} keywords). Listed for 30 days; auto-renews while listed.", + name, keywords.len() + ), + Err(e) => println!("Error: {}", e), + } + } + } else { + println!("Usage: register [keywords...]"); + } + } + + "unregister" => { + let pid = node.default_posting_id; + match node.unregister_persona(&pid).await { + Ok(()) => println!("Registry entry removed (signed delete propagated)."), + Err(e) => println!("Error: {}", e), + } + } + + "search" => { + let query = arg.unwrap_or(""); + match node.search_registry(query).await { + Ok(hits) => { + if hits.is_empty() { + println!("(no registry matches)"); + } + for h in hits { + println!( + " {} {} [{}]", + h.name, + hex::encode(h.author), + h.keywords.join(", "), + ); + } + } + Err(e) => println!("Error: {}", e), + } + } + + "greet" => { + if let Some(rest) = arg { + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + if parts.len() < 2 { + println!("Usage: greet "); + } else { + match itsgoin_core::parse_node_id_hex(parts[0]) { + Ok(bio_post_id) => { + match node.send_greeting(bio_post_id, parts[1].to_string()).await { + Ok(()) => println!("Greeting sent (sealed — only the bio author can read it)."), + Err(e) => println!("Error: {}", e), + } + } + Err(e) => println!("Invalid post ID: {}", e), + } + } + } else { + println!("Usage: greet "); + } + } + + "greetings" => { + match node.list_greetings().await { + Ok(greetings) => { + if greetings.is_empty() { + println!("(no greetings)"); + } + for (i, g) in greetings.iter().enumerate() { + let name = if g.sender_name.is_empty() { + hex::encode(g.sender_persona)[..12].to_string() + } else { + g.sender_name.clone() + }; + println!( + " [{}] {} ({}): {}", + i, name, &hex::encode(g.sender_persona)[..12], g.text + ); + } + last_greetings = greetings; + } + Err(e) => println!("Error: {}", e), + } + } + + "reply" => { + if let Some(rest) = arg { + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); + match (parts.first().and_then(|s| s.parse::().ok()), parts.get(1)) { + (Some(idx), Some(text)) => { + match last_greetings.get(idx) { + Some(g) => { + match node.reply_to_greeting( + g.comment_author, g.post_id, g.timestamp_ms, + text.to_string(), + ).await { + Ok(()) => println!("Reply sent (sealed to the greeting's fresh reply key)."), + Err(e) => println!("Error: {}", e), + } + } + None => println!("No greeting at index {} — run `greetings` first.", idx), + } + } + _ => println!("Usage: reply "), + } + } else { + println!("Usage: reply "); + } + } + + "dismiss" => { + match arg.and_then(|a| a.trim().parse::().ok()) { + Some(idx) => match last_greetings.get(idx) { + Some(g) => { + match node.dismiss_greeting(g.comment_author, g.post_id, g.timestamp_ms).await { + Ok(()) => println!("Greeting dismissed (local only)."), + Err(e) => println!("Error: {}", e), + } + } + None => println!("No greeting at index {} — run `greetings` first.", idx), + }, + None => println!("Usage: dismiss "), + } + } + + "greetings-open" => { + match arg.map(|a| a.trim()) { + Some("on") | Some("off") => { + let open = arg.map(|a| a.trim()) == Some("on"); + let pid = node.default_posting_id; + match node.set_greetings_open(&pid, open).await { + Ok(()) => println!( + "Greetings {} (bio republished).", + if open { "OPEN — strangers can send sealed greetings" } else { "CLOSED" } + ), + Err(e) => println!("Error: {}", e), + } + } + _ => { + let pid = node.default_posting_id; + match node.get_greetings_open(&pid).await { + Ok(open) => println!( + "Greetings are {}. Usage: greetings-open ", + if open { "open" } else { "closed" } + ), + Err(e) => println!("Error: {}", e), + } + } + } + } + "quit" | "exit" | "q" => { println!("Shutting down..."); break; @@ -887,7 +1190,9 @@ async fn print_post( ) { let author_hex = hex::encode(post.author); let author_short = &author_hex[..12]; - let is_me = &post.author == &node.node_id; + // Posts are authored by posting identities — "me" = any of our personas. + let is_me = node.list_posting_identities().await.unwrap_or_default() + .iter().any(|p| p.node_id == post.author); let author_name = node.get_display_name(&post.author).await.unwrap_or(None); let author_label = match (author_name, is_me) { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 67d7750..eed04d9 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.7.0" +version = "0.8.0-alpha" edition = "2021" [dependencies] @@ -19,7 +19,11 @@ ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core", "zeroize"] chacha20poly1305 = "0.10" base64 = "0.22" zip = { version = "2", default-features = false, features = ["deflate"] } -igd-next = { version = "0.16", features = ["tokio"] } +portmapper = "0.14" + +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk-context = "0.1" [dev-dependencies] tempfile = "3" diff --git a/crates/core/src/android_wifi.rs b/crates/core/src/android_wifi.rs new file mode 100644 index 0000000..bee1dcb --- /dev/null +++ b/crates/core/src/android_wifi.rs @@ -0,0 +1,256 @@ +//! Android-only helpers for WiFi detection and MulticastLock acquisition. +//! +//! SSDP discovery (UPnP) requires receiving multicast UDP on 239.255.255.250:1900. +//! Android filters incoming multicast unless a WifiManager.MulticastLock is held. +//! These helpers acquire the lock for the duration of an SSDP attempt. +//! +//! Cellular networks (CGNAT) almost never expose UPnP/PCP gateways, so we gate +//! UPnP attempts on "is on WiFi" to avoid wasting a 3s discovery timeout on +//! every cellular startup. + +#![cfg(target_os = "android")] + +use jni::objects::{JObject, JString, JValue}; +use jni::JavaVM; +use tracing::{debug, warn}; + +/// Returns true if the active network is WiFi (or Ethernet). +/// Returns false on cellular, no-network, or any error. +pub fn is_on_wifi() -> bool { + match check_wifi_inner() { + Ok(v) => v, + Err(e) => { + debug!("Android WiFi check failed: {}", e); + false + } + } +} + +fn check_wifi_inner() -> Result { + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() { + return Err("ndk_context: null JavaVM (not initialized?)".into()); + } + if ctx.context().is_null() { + return Err("ndk_context: null activity context".into()); + } + let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } + .map_err(|e| format!("JavaVM init: {:?}", e))?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach_current_thread: {:?}", e))?; + let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; + + // service = activity.getSystemService(Context.CONNECTIVITY_SERVICE) + let svc_name = env + .new_string("connectivity") + .map_err(|e| format!("new_string: {:?}", e))?; + let svc = env + .call_method( + &activity, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&svc_name)], + ) + .map_err(|e| format!("getSystemService: {:?}", e))? + .l() + .map_err(|e| format!("getSystemService cast: {:?}", e))?; + + // network = service.getActiveNetwork() + let network = env + .call_method(&svc, "getActiveNetwork", "()Landroid/net/Network;", &[]) + .map_err(|e| format!("getActiveNetwork: {:?}", e))? + .l() + .map_err(|e| format!("getActiveNetwork cast: {:?}", e))?; + + if network.is_null() { + return Ok(false); + } + + // caps = service.getNetworkCapabilities(network) + let caps = env + .call_method( + &svc, + "getNetworkCapabilities", + "(Landroid/net/Network;)Landroid/net/NetworkCapabilities;", + &[JValue::Object(&network)], + ) + .map_err(|e| format!("getNetworkCapabilities: {:?}", e))? + .l() + .map_err(|e| format!("getNetworkCapabilities cast: {:?}", e))?; + + if caps.is_null() { + return Ok(false); + } + + // NetworkCapabilities.TRANSPORT_WIFI = 1, TRANSPORT_ETHERNET = 3 + let has_wifi = env + .call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(1)]) + .map_err(|e| format!("hasTransport(WIFI): {:?}", e))? + .z() + .map_err(|e| format!("hasTransport(WIFI) cast: {:?}", e))?; + let has_eth = env + .call_method(&caps, "hasTransport", "(I)Z", &[JValue::Int(3)]) + .map_err(|e| format!("hasTransport(ETH): {:?}", e))? + .z() + .map_err(|e| format!("hasTransport(ETH) cast: {:?}", e))?; + + Ok(has_wifi || has_eth) +} + +/// RAII guard that holds an Android WifiManager.MulticastLock for its lifetime. +/// Release happens on Drop. +pub struct MulticastLockGuard { + vm: JavaVM, + lock: jni::objects::GlobalRef, +} + +impl MulticastLockGuard { + pub fn acquire(tag: &str) -> Option { + match Self::acquire_inner(tag) { + Ok(g) => Some(g), + Err(e) => { + debug!("MulticastLock acquire failed: {}", e); + None + } + } + } + + fn acquire_inner(tag: &str) -> Result { + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() { + return Err("ndk_context: null JavaVM (not initialized?)".into()); + } + if ctx.context().is_null() { + return Err("ndk_context: null activity context".into()); + } + let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } + .map_err(|e| format!("JavaVM init: {:?}", e))?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach_current_thread: {:?}", e))?; + let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; + + // wifi = activity.getApplicationContext().getSystemService(Context.WIFI_SERVICE) + // Application context is important: WifiManager from Activity context can leak the activity. + let app_ctx = env + .call_method( + &activity, + "getApplicationContext", + "()Landroid/content/Context;", + &[], + ) + .map_err(|e| format!("getApplicationContext: {:?}", e))? + .l() + .map_err(|e| format!("getApplicationContext cast: {:?}", e))?; + + let svc_name: JString = env + .new_string("wifi") + .map_err(|e| format!("new_string: {:?}", e))?; + let wifi = env + .call_method( + &app_ctx, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&svc_name)], + ) + .map_err(|e| format!("getSystemService(wifi): {:?}", e))? + .l() + .map_err(|e| format!("getSystemService(wifi) cast: {:?}", e))?; + + if wifi.is_null() { + return Err("WifiManager is null".into()); + } + + // lock = wifi.createMulticastLock(tag) + let tag_str = env + .new_string(tag) + .map_err(|e| format!("new_string(tag): {:?}", e))?; + let lock = env + .call_method( + &wifi, + "createMulticastLock", + "(Ljava/lang/String;)Landroid/net/wifi/WifiManager$MulticastLock;", + &[JValue::Object(&tag_str)], + ) + .map_err(|e| format!("createMulticastLock: {:?}", e))? + .l() + .map_err(|e| format!("createMulticastLock cast: {:?}", e))?; + + // lock.setReferenceCounted(false) + let _ = env.call_method( + &lock, + "setReferenceCounted", + "(Z)V", + &[JValue::Bool(0)], + ); + + // lock.acquire() + env.call_method(&lock, "acquire", "()V", &[]) + .map_err(|e| format!("acquire: {:?}", e))?; + + let global = env + .new_global_ref(&lock) + .map_err(|e| format!("new_global_ref: {:?}", e))?; + + drop(env); + Ok(MulticastLockGuard { vm, lock: global }) + } +} + +impl Drop for MulticastLockGuard { + fn drop(&mut self) { + let env = match self.vm.attach_current_thread() { + Ok(e) => e, + Err(e) => { + warn!("MulticastLock release: attach failed: {:?}", e); + return; + } + }; + let mut env = env; + if let Err(e) = env.call_method(&self.lock, "release", "()V", &[]) { + warn!("MulticastLock release failed: {:?}", e); + } + } +} + +/// Stop the Android `NodeService` foreground service. Called from the +/// in-app close button so the network process actually exits rather +/// than continuing to run as a foreground service after the Activity +/// closes (foreground services are kept alive across Activity exit by +/// design). +/// +/// Errors are logged but not propagated — best-effort cleanup before +/// `AppHandle::exit(0)` finishes the Activity. +pub fn stop_node_service() { + if let Err(e) = stop_node_service_inner() { + warn!("stop_node_service failed (will exit anyway): {}", e); + } +} + +fn stop_node_service_inner() -> Result<(), String> { + let ctx = ndk_context::android_context(); + if ctx.vm().is_null() { + return Err("ndk_context: null JavaVM".into()); + } + if ctx.context().is_null() { + return Err("ndk_context: null activity context".into()); + } + let vm = unsafe { JavaVM::from_raw(ctx.vm() as *mut _) } + .map_err(|e| format!("JavaVM init: {:?}", e))?; + let mut env = vm + .attach_current_thread() + .map_err(|e| format!("attach_current_thread: {:?}", e))?; + let activity = unsafe { JObject::from_raw(ctx.context() as *mut _) }; + + // NodeService.stopFromNative(activity) + env.call_static_method( + "com/itsgoin/app/NodeService", + "stopFromNative", + "(Landroid/content/Context;)V", + &[JValue::Object(&activity)], + ) + .map_err(|e| format!("stopFromNative: {:?}", e))?; + + Ok(()) +} diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 9a022a5..f1367ac 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::net::SocketAddr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -11,56 +11,90 @@ use crate::blob::BlobStore; use crate::content::verify_post_id; use crate::crypto; use crate::protocol::{ - read_message_type, read_payload, write_typed_message, AnchorReferral, - AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, + read_message_type, read_payload, write_typed_message, + ConvectionReferral, ConvectionRequestPayload, ConvectionResponsePayload, BlobHeaderDiffPayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, - CircleProfileUpdatePayload, GroupKeyRequestPayload, - GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, - MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, - ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, + CircleProfileUpdatePayload, InitialExchangePayload, + MessageType, PostDownstreamRegisterPayload, + ProfileUpdatePayload, ContentSyncRequestPayload, ContentSyncResponsePayload, UniquesPullPayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, - SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload, - SyncPost, VisibilityUpdatePayload, WormQueryPayload, WormResponsePayload, - ReplicationRequestPayload, ReplicationResponsePayload, ALPN_V2, + SocialAddressUpdatePayload, SocialCheckinPayload, + SyncPost, WormQueryPayload, WormResponsePayload, + ReplicationRequestPayload, ReplicationResponsePayload, ALPN, }; use crate::storage::StoragePool; +use crate::protocol::{ + pack_ids, unpack_ids, AnchorEntry, UniquesAnnouncePayload, UniquesSlice, +}; use crate::types::{ - DeviceProfile, NodeId, PeerSlotKind, PeerWithAddress, PostId, PostVisibility, ReachMethod, - SessionReachMethod, SocialRouteEntry, SocialStatus, WormId, WormResult, + DeviceProfile, IdClass, MeshSlot, NodeId, PeerWithAddress, PostId, PostVisibility, + ReachEntry, ReachMethod, SessionReachMethod, SocialRouteEntry, SocialStatus, UniquesPools, + WormId, WormResult, }; const MAX_PAYLOAD: usize = 64 * 1024 * 1024; // 64 MB +/// Read ceiling for the uniques opcodes (0x01 announce, 0x40/0x41 pull). +/// +/// `MAX_PAYLOAD` exists for FILE transfer. Applying it to the index exchange +/// let one announce decode into ~1.5M packed IDs and the same number of row +/// inserts, per bounce, per reporter, across ~20 reporters — a storage/CPU +/// amplifier reachable by any mesh peer. design.html §layers budgets N2 <= 400 +/// and N3 <= 8,000, i.e. well under 400 KB of packed IDs; 2 MB is an order of +/// magnitude of headroom over the design ceiling and 32x under the file cap. +const MAX_UNIQUES_PAYLOAD: usize = 2 * 1024 * 1024; // 2 MB + const WORM_FAN_OUT_TIMEOUT_MS: u64 = 500; const WORM_BLOOM_TIMEOUT_MS: u64 = 1500; const WORM_TOTAL_TIMEOUT_MS: u64 = 3000; const WORM_COOLDOWN_MS: i64 = 300_000; // 5 min const WORM_DEDUP_EXPIRY_MS: u64 = 10_000; // 10 sec const SESSION_IDLE_TIMEOUT_MS: u64 = 300_000; // 5 min -#[allow(dead_code)] -const RELAY_COOLDOWN_MS: i64 = 300_000; // 5 min const RELAY_INTRO_DEDUP_EXPIRY_MS: u64 = 30_000; // 30 sec -#[allow(dead_code)] -const RELAY_INTRO_TIMEOUT_MS: u64 = 15_000; // 15 sec const HOLE_PUNCH_TIMEOUT_MS: u64 = 30_000; // 30 sec overall window const HOLE_PUNCH_ATTEMPT_MS: u64 = 2_000; // 2 sec per attempt before retry /// Max bytes relayed per pipe before closing const RELAY_MAX_BYTES: u64 = 50 * 1024 * 1024; // 50 MB /// Relay pipe idle timeout const RELAY_PIPE_IDLE_MS: u64 = 120_000; // 2 min -/// How long a preferred peer can be unreachable before being pruned (7 days) -const PREFERRED_UNREACHABLE_PRUNE_MS: u64 = 7 * 24 * 60 * 60 * 1000; /// How long reconnect watchers live before expiry (30 days) const WATCHER_EXPIRY_MS: i64 = 30 * 24 * 60 * 60 * 1000; -/// Max pending introductions per target in 5 minutes -#[allow(dead_code)] -const RELAY_TARGET_RATE_LIMIT: usize = 5; -/// Grace period before removing disconnected peers from referral list -const REFERRAL_DISCONNECT_GRACE_MS: u64 = 120_000; // 2 min -/// Soft cap on referral list size (affects max_uses tiering) -const REFERRAL_LIST_CAP: usize = 50; +// --- Anchor convection (design.html §anchors) --- +// +// No registration database. The anchor keeps a small ROLLING WINDOW of recent +// callers, fed by the convection request itself. A caller gets the 2 most +// recent viable prior callers; its own address then goes to the next 2 callers +// and rotates out. That is the whole service. + +/// How many recent callers the rolling window holds. Small on purpose: the +/// point is *recency*, not coverage — a stale address is worse than none, +/// because it costs the caller a hole-punch timeout. +const CONVECTION_WINDOW_SIZE: usize = 16; +/// How many callers an entry is handed to before it rotates out ("2 before, +/// 2 after"). +const CONVECTION_HANDOUT_CAP: u32 = 2; +/// How many referrals a served request gets. +const CONVECTION_REFERRALS: usize = 2; +/// An entry older than this is not worth handing out — the caller has probably +/// moved or gone. Bounds the window in time as well as in count. +const CONVECTION_ENTRY_MAX_AGE_MS: u64 = 15 * 60 * 1000; // 15 min +/// How often the anchor-density prior is recomputed (it changes slowly, and +/// the disconnect path must never pay for a COUNT DISTINCT). +const ANCHOR_DENSITY_REFRESH_MS: u64 = 60_000; // 1 min +/// How long a refusing anchor stays in the penalty box. +const ANCHOR_REFUSAL_PENALTY_MS: u64 = 5 * 60 * 1000; // 5 min +/// A young anchor with an empty window may supplement referrals from its own +/// live mesh peers. Those peers never called and never offered themselves for +/// redistribution, so the supplement gets its own hand-out budget: without one +/// it spent no window budget at all and the same peer could be disclosed to +/// every caller, turning unconditional entry-class service into a topology +/// enumeration oracle. +const CONVECTION_SUPPLEMENT_CAP: u32 = 2; +const CONVECTION_SUPPLEMENT_WINDOW_MS: u64 = 15 * 60 * 1000; // 15 min +/// How many self-reported addresses a caller may add beyond the observed one. +const CONVECTION_MAX_SELF_REPORTED: usize = 2; /// Zombie connection timeout: no stream activity for this long = dead const ZOMBIE_TIMEOUT_MS: u64 = 600_000; // 10 minutes @@ -85,7 +119,7 @@ pub(crate) async fn hole_punch_parallel( target: &NodeId, addresses: &[String], ) -> Option { - use crate::protocol::ALPN_V2; + use crate::protocol::ALPN; // Filter to address families this endpoint can actually reach let reachable = filter_reachable_families(endpoint, addresses); @@ -117,7 +151,7 @@ pub(crate) async fn hole_punch_parallel( handles.push(tokio::spawn(async move { tokio::time::timeout( std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS), - ep.connect(a, ALPN_V2), + ep.connect(a, ALPN), ).await })); } @@ -145,14 +179,22 @@ pub(crate) async fn hole_punch_parallel( None } +// EDM port scanner — DISABLED in v0.7.3 (see hole_punch_with_scanning). +// Constants and helpers preserved as the refactor target for a raw-UDP +// scanner that bypasses iroh's path-store accumulation. + /// Timeout for each individual scan connect attempt (200ms → ~20 in-flight at 100/sec) +#[allow(dead_code)] const SCAN_CONNECT_TIMEOUT_MS: u64 = 200; /// Scan rate: one attempt every 10ms = 100 ports/sec +#[allow(dead_code)] const SCAN_INTERVAL_MS: u64 = 10; /// How often to punch peer's anchor-observed address during scanning (seconds). /// Each punch checks if the peer has opened a firewall port matching our actual port. +#[allow(dead_code)] const SCAN_PUNCH_INTERVAL_SECS: u64 = 2; /// Maximum scan duration (seconds) — accept the cost for otherwise-impossible connections +#[allow(dead_code)] const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes /// Global cap on concurrent port-scan hole punches. Each scanner fires @@ -164,11 +206,63 @@ const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes /// at proxy timeouts. A permit is acquired before the scanning loop /// starts and held until the scanner returns; extra callers fall back /// to the cheaper `hole_punch_parallel`. +#[allow(dead_code)] fn scanner_semaphore() -> &'static tokio::sync::Semaphore { static SEM: std::sync::OnceLock = std::sync::OnceLock::new(); SEM.get_or_init(|| tokio::sync::Semaphore::new(1)) } +/// Hole punch orchestrator. +/// +/// **v0.7.3:** the EDM port scanner is DISABLED. We do Step 1 (quick punch to +/// the anchor-observed address) → Step 2 (parallel punch over the 30s window +/// to all known addresses). No port scan. +/// +/// **Why disabled:** iroh's `Endpoint` accumulates every `endpoint.connect()` +/// target into a per-endpoint paths set and probes them all in the background +/// under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserts ~30,000 +/// paths; iroh then probes all of them. Observed at 22MB/s outbound from a +/// single client. Disabled until we replace per-probe `endpoint.connect()` +/// with a raw `socket.send_to()` on the endpoint's bound UDP socket — see +/// `edm_port_scan_disabled_v0_7_3` for the preserved scanner logic to +/// refactor against. +/// +/// Original docstring is preserved on `edm_port_scan_disabled_v0_7_3`. +pub(crate) async fn hole_punch_with_scanning( + endpoint: &iroh::Endpoint, + target: &NodeId, + addresses: &[String], + _our_profile: crate::types::NatProfile, + _peer_profile: crate::types::NatProfile, +) -> Option { + if let Some(conn) = hole_punch_single(endpoint, target, addresses).await { + return Some(conn); + } + hole_punch_parallel(endpoint, target, addresses).await +} + +/// **DISABLED in v0.7.3** — kept as the refactor target for a safe replacement. +/// +/// **Why disabled:** iroh's `Endpoint` accumulates every `endpoint.connect()` +/// target into a per-endpoint paths set and probes them all in the background +/// under QUIC NAT-traversal. A 100-probes/sec / 5-min scan inserts ~30,000 +/// paths; iroh then probes all of them. Observed at 22MB/s outbound from a +/// single client (DoS-grade). +/// +/// **Refactor target:** replace `endpoint.connect()` in the per-probe path +/// with a raw `socket.send_to(...)` on the endpoint's bound UDP socket. The +/// probe still opens a NAT mapping on our side; we just don't ask iroh to +/// manage the path. The every-2s punch retains `endpoint.connect()` so the +/// real handshake completes when the peer's punch arrives. +/// +/// Logic worth preserving below: role-based scanner/puncher split, +/// `PortWalkIter`, `scanner_semaphore`, `found_tx`/`found_rx` channel +/// pattern, deadline + `tokio::select!` orchestration. +/// +/// --- +/// +/// Original docstring: +/// /// Advanced hole punch with port scanning fallback for EDM/port-restricted NAT. /// /// **Role-based behavior** (each side calls this independently): @@ -183,7 +277,8 @@ fn scanner_semaphore() -> &'static tokio::sync::Semaphore { /// NAT mapping alive and checks if the peer's scan has opened their firewall for us. /// /// For both-EDM pairs: both sides scan + punch simultaneously. -pub(crate) async fn hole_punch_with_scanning( +#[allow(dead_code)] +async fn edm_port_scan_disabled_v0_7_3( endpoint: &iroh::Endpoint, target: &NodeId, addresses: &[String], @@ -288,7 +383,7 @@ pub(crate) async fn hole_punch_with_scanning( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN_V2), + ep.connect(addr, ALPN), ).await { let _ = tx.send(conn).await; } @@ -318,7 +413,7 @@ pub(crate) async fn hole_punch_with_scanning( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN_V2), + ep.connect(addr, ALPN), ).await { let _ = tx.send(conn).await; } @@ -359,7 +454,7 @@ pub(crate) async fn hole_punch_with_scanning( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN_V2), + ep.connect(addr, ALPN), ).await { let _ = tx.send(conn).await; } @@ -389,12 +484,17 @@ pub(crate) async fn hole_punch_with_scanning( /// Iterator that walks outward from a base port: base, base+1, base-1, base+2, base-2, ... /// Skips ports outside [1, 65535]. +/// +/// Used by `edm_port_scan_disabled_v0_7_3` — preserved for the future +/// raw-UDP scanner refactor. +#[allow(dead_code)] struct PortWalkIter { base: u16, offset: u32, tried_plus: bool, // within current offset, have we tried base+offset? } +#[allow(dead_code)] impl PortWalkIter { fn new(base: u16) -> Self { Self { base, offset: 0, tried_plus: false } @@ -460,7 +560,7 @@ async fn hole_punch_single( match tokio::time::timeout( std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS), - endpoint.connect(endpoint_addr, ALPN_V2), + endpoint.connect(endpoint_addr, ALPN), ).await { Ok(Ok(conn)) => { tracing::info!(peer = hex::encode(target), "Quick single punch succeeded"); @@ -509,10 +609,23 @@ pub fn normalize_addr(addr: std::net::SocketAddr) -> std::net::SocketAddr { } } +/// How long a temporary referral slot lives before it is reaped, if it has not +/// graduated into a freed mesh slot by then. Expiry is enforced in the rebalance +/// dead-sweep — no separate loop. +pub const TEMP_REFERRAL_TTL_MS: u64 = 5 * 60 * 1000; + +/// Default depth assumed for a peer that hasn't told us its retention horizon. +const DEFAULT_PEER_DEPTH: u8 = 4; + pub struct MeshConnection { pub node_id: NodeId, pub connection: iroh::endpoint::Connection, - pub slot_kind: PeerSlotKind, + /// Mesh vs temporary referral. Temp slots live ABOVE the mesh cap, carry no + /// knowledge exchange, and are never persisted to `mesh_peers`. + pub slot: MeshSlot, + /// Deepest bounce this peer retains (from their announce). We skip building + /// the terminal pool for peers that advertise 3. + pub peer_depth: u8, pub connected_at: u64, /// Remote address as seen from our side of the QUIC connection pub remote_addr: Option, @@ -530,19 +643,309 @@ pub struct SessionConnection { pub remote_addr: Option, } +/// Result of a transitional content sync (0x46/0x47). pub struct PullSyncStats { pub posts_received: usize, - pub visibility_updates: usize, } -/// Entry in the anchor's referral list — connection-backed, self-pruning. -struct ReferralEntry { +/// One caller in the anchor's rolling convection window. +/// +/// Deliberately in-memory only: an anchor that restarts should forget, because +/// everything it knew is by then a cold address. Liveness is NOT stored — it is +/// resolved against `connections`/`sessions` at hand-out time, so an entry can +/// never claim a peer is connected when it isn't. +#[derive(Clone)] +pub(crate) struct ConvectionEntry { + pub(crate) node_id: NodeId, + pub(crate) addresses: Vec, + pub(crate) arrived_at: u64, + /// Times this entry has been handed to a later caller (cap + /// `CONVECTION_HANDOUT_CAP`, then it rotates out). + pub(crate) handouts: u32, +} + +/// May this address go into the convection window / a referral? +/// +/// Publicly routable only (bugs-fixed #8: an unroutable referral address costs +/// the next caller a hole-punch timeout), with one TEST-ONLY escape hatch: +/// `ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS` lets the whole topology run on +/// 127.0.0.1. Same debug-gate pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`. +pub(crate) fn convection_addr_ok(sa: &SocketAddr) -> bool { + if crate::network::is_publicly_routable(sa) { + return true; + } + static ALLOW_LOOPBACK: std::sync::OnceLock = std::sync::OnceLock::new(); + // `== "1"`, not `.is_ok()`: an empty or accidental value must not silently + // open loopback referral circulation in a shipped build. Matches the + // `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS` check. + *ALLOW_LOOPBACK + .get_or_init(|| std::env::var("ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS").as_deref() == Ok("1")) + && sa.ip().is_loopback() +} + +/// Everything the uniques-index exchange needs from connection state, captured +/// under a brief lock so the storage half runs with the lock released. +pub(crate) struct UniquesCtx { + storage: Arc, + our_node_id: NodeId, + max_bounce: u8, + anchor_addr: Option, + seq: u64, + pub(crate) is_mesh: bool, + pub(crate) peer_depth: u8, + /// Snapshot of who we are connected to — used only to keep already-meshed + /// peers out of the bounce-2 growth-candidate pool. + connected: HashSet, +} + +impl UniquesCtx { + pub(crate) async fn build(&self, peer_depth: u8) -> Option { + let storage = self.storage.get().await; + build_uniques_payload( + &storage, + &self.our_node_id, + self.anchor_addr.as_deref(), + self.seq, + self.max_bounce, + peer_depth, + ) + .ok() + } + + pub(crate) async fn apply( + &self, + reporter: &NodeId, + payload: &UniquesAnnouncePayload, + ) -> anyhow::Result { + let storage = self.storage.get().await; + apply_uniques_to_storage( + &storage, + &self.our_node_id, + reporter, + payload, + self.max_bounce, + |nid| self.connected.contains(nid), + ) + } +} + +/// Everything a convection response needs, gathered under a brief lock so the +/// write and the coordinated introductions happen outside it. +pub struct ConvectionGathered { + pub response: ConvectionResponsePayload, + /// Referral targets that are live on us right now — each gets an + /// anchor-initiated RelayIntroduce naming the caller as requester. + pub introductions: Vec<(NodeId, iroh::endpoint::Connection)>, + pub requester: NodeId, + /// The caller's usable (observed, routable) addresses, for the intro push. + pub requester_addresses: Vec, + pub nat_mapping: Option, + pub nat_filtering: Option, +} + +/// What the stochastic per-disconnect trigger decided to do (round-4/5). +/// +/// There is no threshold and no jitter timer: the randomness itself is what +/// de-synchronises the network's response to a shared disconnect event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConvectionAction { + /// Sit this one out. Churn is normal; not every disconnect needs a reaction. + Nothing, + /// Ask a random known anchor for an introduction. + AskAnchor, + /// Ask via a connected mesh peer (this is the existing growth loop, whose + /// introduction arm already exists). + AskMesh, +} + +/// Adaptive action weights for the stochastic trigger (round-5 ruling). +/// +/// The anchor-vs-mesh balance is NOT fixed at design time — the willing-anchor +/// ratio is unknowable before deployment. Two runtime inputs set it: +/// * the local anchor-density prior — anchor entries are the only +/// address-bearing rows in the uniques pools, so their share of known +/// node-class uniques is a free local statistic, no census needed; +/// * refusal feedback — an anchor refusing a top-up shifts weight toward +/// mesh introductions and other anchors; successes drift it back (AIMD). +#[derive(Debug, Clone, Copy)] +pub struct ConvectionWeights { + pub nothing: f64, + pub anchor: f64, + pub mesh: f64, +} + +/// Pure, directly-testable action choice. +/// +/// * `roll` — uniform in [0, 1). +/// * `density` — anchor-flagged share of known node-class uniques, [0, 1]. +/// * `anchor_bias` — AIMD feedback term, (0, 1]. Halved on refusal, drifts back +/// up on success. +/// * `fill_ratio` — mesh_count / mesh_slots, [0, 1]. Leaning toward action when +/// far under target is the "lean toward action" half of the ruling: at +/// fill_ratio 0 the do-nothing weight is 0, so we always act. +pub fn convection_weights(density: f64, anchor_bias: f64, fill_ratio: f64) -> ConvectionWeights { + let density = density.clamp(0.0, 1.0); + let anchor_bias = anchor_bias.clamp(0.05, 1.0); + let fill_ratio = fill_ratio.clamp(0.0, 1.0); + ConvectionWeights { + // Quadratic so a nearly-full mesh mostly ignores churn while a badly + // depleted one almost always reacts. + nothing: 2.0 * fill_ratio * fill_ratio, + // Abundant anchors (IPv6 world) → anchor-led growth keeps convection + // windows fresh. Scarce or unhelpful anchors → this collapses toward 0 + // and mesh introductions carry growth. Same code, both worlds. + anchor: anchor_bias * (0.25 + 1.75 * density), + mesh: 1.0, + } +} + +pub fn choose_convection_action( + roll: f64, + density: f64, + anchor_bias: f64, + fill_ratio: f64, +) -> ConvectionAction { + let w = convection_weights(density, anchor_bias, fill_ratio); + let total = w.nothing + w.anchor + w.mesh; + if total <= 0.0 { + return ConvectionAction::Nothing; + } + let point = roll.clamp(0.0, 1.0) * total; + if point < w.nothing { + ConvectionAction::Nothing + } else if point < w.nothing + w.anchor { + ConvectionAction::AskAnchor + } else { + ConvectionAction::AskMesh + } +} + +/// How a mesh disconnect is answered. Recovery PRE-EMPTS the dice: below 2 mesh +/// peers there is nothing stochastic to decide. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisconnectResponse { + /// mesh < 2 — act immediately and unconditionally. + Recover, + /// Healthy mesh with room — roll the stochastic action. + Roll, + /// Healthy and full — nothing to do. + Idle, +} + +pub fn disconnect_response(mesh_count: usize, available_mesh_slots: usize) -> DisconnectResponse { + if mesh_count < 2 { + DisconnectResponse::Recover + } else if available_mesh_slots > 0 { + DisconnectResponse::Roll + } else { + DisconnectResponse::Idle + } +} + +// ---- Rolling convection window, as free functions over the deque ---- +// +// Split out of `ConnectionManager` so the rotation rules are directly testable +// without standing up an iroh endpoint. + +/// Admit or refresh a caller. A fresh arrival replaces any older entry and +/// resets its hand-out budget — this caller is available again. +pub(crate) fn convection_window_admit( + window: &mut VecDeque, node_id: NodeId, addresses: Vec, - #[allow(dead_code)] - registered_at: u64, - use_count: u32, - disconnected_at: Option, + now: u64, +) { + if addresses.is_empty() { + return; + } + window.retain(|e| e.node_id != node_id); + window.push_back(ConvectionEntry { node_id, addresses, arrived_at: now, handouts: 0 }); + convection_window_prune(window, now); + while window.len() > CONVECTION_WINDOW_SIZE { + window.pop_front(); + } +} + +/// Drop entries that aged out or used up their hand-out budget. +pub(crate) fn convection_window_prune(window: &mut VecDeque, now: u64) { + window.retain(|e| { + e.handouts < CONVECTION_HANDOUT_CAP + && now.saturating_sub(e.arrived_at) <= CONVECTION_ENTRY_MAX_AGE_MS + }); +} + +/// How many entries could be handed out right now. +pub(crate) fn convection_window_viable( + window: &VecDeque, + exclude: &NodeId, + now: u64, +) -> usize { + window + .iter() + .filter(|e| { + e.node_id != *exclude + && e.handouts < CONVECTION_HANDOUT_CAP + && now.saturating_sub(e.arrived_at) <= CONVECTION_ENTRY_MAX_AGE_MS + }) + .count() +} + +/// Take up to `count` referrals: most recent first, still-connected preferred. +/// Each pick spends one hand-out; an entry that reaches the cap rotates out. +pub(crate) fn convection_window_pick( + window: &mut VecDeque, + exclude: &NodeId, + count: usize, + now: u64, + is_live: impl Fn(&NodeId) -> bool, +) -> Vec { + convection_window_prune(window, now); + + // Newest first. + let ordered: Vec = window + .iter() + .rev() + .filter(|e| e.node_id != *exclude) + .map(|e| e.node_id) + .collect(); + // Still-connected callers first: their address is provably current, and + // they are the case where an introduction can be coordinated instead of a + // cold address handed over. + let mut chosen: Vec = ordered.iter().copied().filter(|n| is_live(n)).collect(); + for n in ordered { + if chosen.len() >= count { + break; + } + if !chosen.contains(&n) { + chosen.push(n); + } + } + chosen.truncate(count); + + let mut picked = Vec::with_capacity(chosen.len()); + for n in &chosen { + if let Some(e) = window.iter_mut().find(|e| e.node_id == *n) { + e.handouts += 1; + picked.push(e.clone()); + } + } + convection_window_prune(window, now); + picked +} + +/// Should this request be served? ENTRY is unconditional (round-5 ruling). +pub fn convection_should_serve( + class: crate::protocol::ConvectionClass, + viable_referrals: usize, + saturated: bool, +) -> bool { + if class.is_entry() { + // A node that cannot get in cannot come back, and a network that + // refuses re-entry partitions permanently. Always served — even with + // an empty window, where the mesh-peer supplement takes over. + return true; + } + viable_referrals > 0 && !saturated } /// Data gathered under brief lock for anchor probe I/O. @@ -601,21 +1004,19 @@ pub struct ConnectionManager { #[allow(dead_code)] is_anchor: Arc, diff_seq: AtomicU64, - #[allow(dead_code)] - secret_seed: [u8; 32], blob_store: Arc, /// Dedup map for worm queries: worm_id → timestamp_ms seen_worms: HashMap, - /// Last broadcast N1 set (for computing diffs) - last_n1_set: HashSet, - /// Last broadcast N2 set (for computing diffs) - last_n2_set: HashSet, - /// Max preferred (bilateral) mesh slots - preferred_slots: usize, - /// Max local (diverse) mesh slots - local_slots: usize, - /// Max wide (bloom-sourced) mesh slots - wide_slots: usize, + /// Digest of the last uniques announce we broadcast. The announce is always + /// a full snapshot; this is what lets us skip the send when nothing changed, + /// which recovers most of what incremental diffing would have bought. + last_announce_digest: u64, + /// The single mesh pool cap (v0.8: 20 desktop / 15 mobile) + mesh_slots: usize, + /// Temporary referral slots, allocated strictly ABOVE `mesh_slots` + temp_referral_slots: usize, + /// Deepest bounce WE retain (4 desktop / 3 mobile) + max_bounce: u8, /// Session connections: short-lived, tracked, separate from mesh slots sessions: HashMap, /// Max session slots @@ -626,18 +1027,36 @@ pub struct ConnectionManager { active_relay_pipes: Arc, /// Max concurrent relay pipes max_relay_pipes: usize, + /// User opt-in for session relay. Gates both serving as a relay + /// (`can_accept_relay_pipe`) and using a peer as a relay on hole-punch + /// failure (auto-fallback in node.rs). Default false — relay is opt-in. + /// Loaded from the `relay.session_relay_enabled` setting at startup. + session_relay_enabled: Arc, /// Device profile (for resource limits) #[allow(dead_code)] device_profile: DeviceProfile, /// Peers known to be unreachable directly: node_id → last_failed_at_ms /// Learned over the session — cleared on restart, updated on connect success/failure unreachable_peers: HashMap, - /// Anchor-side referral list: connected peers available for referral - referral_list: HashMap, + /// Anchor-side rolling window of recent callers. Newest at the back. + /// In-memory only — no registration DB, no persistence. + convection_window: VecDeque, + /// Cached anchor-density prior for the stochastic trigger, refreshed on a + /// throttle so the hot disconnect path never pays for the query. + anchor_density: f64, + anchor_density_at_ms: u64, + /// AIMD feedback term on the anchor arm of the action choice. + anchor_bias: f64, + /// Anchors that recently refused us: node_id → penalty expiry (ms). + /// Shifts the *choice of anchor*, while `anchor_bias` shifts the choice of + /// arm — a single unhelpful anchor should not make us stop using anchors. + refused_anchors: HashMap, /// Channel to signal the growth loop to wake up and seek diverse peers growth_tx: Option>, /// Channel to signal the recovery loop when mesh drops below threshold recovery_tx: Option>, + /// Channel to signal the convection loop to ask an anchor for peers. + convection_tx: Option>, activity_log: Arc>, /// UPnP external address (prepended to self-reported addresses in anchor registration) upnp_external_addr: Option, @@ -655,7 +1074,10 @@ pub struct ConnectionManager { last_probe_success_ms: u64, /// Anchor probe: consecutive failure count probe_failure_streak: u8, - /// When this ConnectionManager was created + /// When this ConnectionManager was created. Kept for diagnostics; the + /// 2-hour uptime bar it used to gate anchor candidacy on was the retired + /// popularity model (round-2 ruling: candidacy = reachability + opt-in). + #[allow(dead_code)] started_at_ms: u64, /// Dedup map for anchor probes: probe_id → timestamp_ms seen_probes: HashMap<[u8; 16], u64>, @@ -663,9 +1085,11 @@ pub struct ConnectionManager { pub(crate) http_capable: bool, /// External HTTP address (ip:port) if known pub(crate) http_addr: Option, - /// Sticky N1 entries: NodeIds to report in N1 share until expiry (ms). - /// Used to advertise the bootstrap anchor for 24h after isolation recovery. - sticky_n1: HashMap, + /// Hand-out ledger for convection referrals SUPPLEMENTED from our live + /// mesh (node id → (count, window start ms)). Window entries have their own + /// `handouts` counter; supplemented peers had none, so a single peer could + /// be named to unlimited callers. + supplement_handouts: HashMap, /// NodeIds with an outgoing connect attempt currently in flight. /// Used by `try_begin_connect` to suppress duplicate concurrent outgoing /// connects from racing paths (auto-reconnect, rebalance, relay @@ -699,7 +1123,6 @@ impl ConnectionManager { storage: Arc, our_node_id: NodeId, is_anchor: Arc, - secret_seed: [u8; 32], blob_store: Arc, profile: DeviceProfile, activity_log: Arc>, @@ -707,11 +1130,13 @@ impl ConnectionManager { bind_addr: Option, nat_type: crate::types::NatType, nat_mapping: crate::types::NatMapping, + session_relay_enabled: bool, ) -> Self { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; + let session_relay_enabled = Arc::new(AtomicBool::new(session_relay_enabled)); Self { connections: HashMap::new(), endpoint, @@ -719,24 +1144,28 @@ impl ConnectionManager { our_node_id, is_anchor, diff_seq: AtomicU64::new(0), - secret_seed, blob_store, seen_worms: HashMap::new(), - last_n1_set: HashSet::new(), - last_n2_set: HashSet::new(), - preferred_slots: profile.preferred_slots(), - local_slots: profile.local_slots(), - wide_slots: profile.wide_slots(), + last_announce_digest: 0, + mesh_slots: profile.mesh_slots(), + temp_referral_slots: profile.temp_referral_slots(), + max_bounce: profile.max_bounce(), sessions: HashMap::new(), session_slots: profile.session_slots(), seen_intros: HashMap::new(), active_relay_pipes: Arc::new(AtomicU64::new(0)), max_relay_pipes: profile.max_relay_pipes(), + session_relay_enabled, device_profile: profile, unreachable_peers: HashMap::new(), - referral_list: HashMap::new(), + convection_window: VecDeque::new(), + anchor_density: 0.0, + anchor_density_at_ms: 0, + anchor_bias: 1.0, + refused_anchors: HashMap::new(), growth_tx: None, recovery_tx: None, + convection_tx: None, activity_log, upnp_external_addr, observed_external_addr: std::sync::Mutex::new(None), @@ -752,7 +1181,7 @@ impl ConnectionManager { seen_probes: HashMap::new(), http_capable: false, http_addr: None, - sticky_n1: HashMap::new(), + supplement_handouts: HashMap::new(), pending_connects: Arc::new(std::sync::Mutex::new(HashSet::new())), } } @@ -798,52 +1227,37 @@ impl ConnectionManager { crate::types::NatProfile::new(self.nat_mapping, self.nat_filtering) } - /// Whether this node is a candidate for anchor status (has UPnP/public, enough connections, uptime) + /// Whether this node is a candidate for anchor status. + /// + /// v0.8 (round-2 ruling): candidacy is **stable direct reachability**, full + /// stop. The old ">=50 connections and 2h uptime" bar was the retired + /// popularity model — an anchor's job is to be dialable for network + /// (re)entry, and a node with 3 peers and a public address does that job + /// exactly as well as one with 80. Mobile is still excluded (battery and + /// roaming addresses, not popularity). pub fn is_anchor_candidate(&self) -> bool { - let now = now_ms(); - // Must have UPnP mapping or public IPv6 let has_public_addr = self.upnp_external_addr.is_some() || self.endpoint.addr().ip_addrs().any(|s| crate::network::is_publicly_routable(&s)); - if !has_public_addr { - return false; - } - // Must have enough connections (at least 50) - if self.connections.len() < 50 { - return false; - } - // Must have been running for at least 2 hours - let two_hours_ms = 2 * 60 * 60 * 1000; - if now.saturating_sub(self.started_at_ms) < two_hours_ms { - return false; - } - // Not mobile - if self.device_profile == crate::types::DeviceProfile::Mobile { - return false; - } - true + has_public_addr && self.device_profile != crate::types::DeviceProfile::Mobile } - /// Whether an anchor probe is due (candidate minus probe-success check, last probe > 30 min ago) + /// Whether an anchor self-verification probe is due: we are a candidate and + /// the last successful probe was more than 30 minutes ago. + /// + /// Driven by the convection loop's maintenance tick (and, on address + /// change, by the reachability watcher) now that the register cycle it used + /// to hang off is retired. pub fn probe_due(&self) -> bool { - let now = now_ms(); - let has_public_addr = self.upnp_external_addr.is_some() - || self.endpoint.addr().ip_addrs().any(|s| crate::network::is_publicly_routable(&s)); - if !has_public_addr { + if !self.is_anchor_candidate() { return false; } - if self.connections.len() < 50 { + // A probe needs a witness from the index and a reporter to route + // through — with no peers at all there is nobody to ask. + if self.connections.is_empty() { return false; } - let two_hours_ms = 2 * 60 * 60 * 1000; - if now.saturating_sub(self.started_at_ms) < two_hours_ms { - return false; - } - if self.device_profile == crate::types::DeviceProfile::Mobile { - return false; - } - // Last probe must be > 30 min ago let thirty_min_ms = 30 * 60 * 1000; - now.saturating_sub(self.last_probe_success_ms) > thirty_min_ms + now_ms().saturating_sub(self.last_probe_success_ms) > thirty_min_ms } /// Initiate an anchor self-verification probe. @@ -1115,7 +1529,7 @@ impl ConnectionManager { let addr = iroh::EndpointAddr::from(eid).with_ip_addr(sock_addr); match tokio::time::timeout( std::time::Duration::from_secs(15), - self.endpoint.connect(addr, ALPN_V2), + self.endpoint.connect(addr, ALPN), ).await { Ok(Ok(_conn)) => true, _ => false, @@ -1268,9 +1682,18 @@ impl ConnectionManager { && !self.connections.contains_key(nid) && !self.is_likely_unreachable(nid) }) - .map(|(nid, reporter_count, in_n3)| { - let score = 1.0 / (reporter_count as f64) - + if !in_n3 { 0.3 } else { 0.0 }; + // Depth is a GRADED bonus, not a boolean. With the horizon widened + // to 2..4, `+0.3 unless deeper` let a bounce-4 ID seen by ONE + // reporter (1.0) outrank a genuine bounce-2 ID seen by three + // (0.33 + 0.3 = 0.63) — i.e. the growth loop preferentially picked + // the deepest, least-resolvable candidates. + .map(|(nid, reporter_count, shallowest)| { + let depth_bonus = match shallowest { + 0..=2 => 0.3, + 3 => 0.1, + _ => 0.0, + }; + let score = 1.0 / (reporter_count.max(1) as f64) + depth_bonus; (nid, score) }) .collect(); @@ -1279,69 +1702,104 @@ impl ConnectionManager { scored } - /// How many local slots are available for the growth loop to fill. - pub fn available_local_slots(&self) -> usize { - let local_count = self.count_kind(PeerSlotKind::Local); - self.local_slots.saturating_sub(local_count) + /// Live count of MESH-slot connections (temp referral slots excluded). + pub fn mesh_count(&self) -> usize { + self.connections.values().filter(|pc| pc.slot.is_mesh()).count() } - /// Accept an incoming connection, returning false if slots are full. + /// Live count of temporary referral slots. + pub fn temp_count(&self) -> usize { + self.connections.values().filter(|pc| pc.slot.is_temp()).count() + } + + /// How many mesh slots are free for the growth loop to fill. + /// + /// v0.8: this counts the WHOLE pool. Under the old Local/Wide split the + /// growth gate counted Local only while every outbound path hardcoded + /// Local — so growth stopped at the Local cap and the Wide slots could + /// only ever be filled by inbound connections. Collapsing to one pool + /// removes that bug structurally. + pub fn available_mesh_slots(&self) -> usize { + self.mesh_slots.saturating_sub(self.mesh_count()) + } + + /// Decide which slot class a new connection may occupy. + /// `None` = no room at all, refuse. + fn allocate_slot(&self, peer_id: &NodeId) -> Option { + if let Some(existing) = self.connections.get(peer_id) { + // Replacing a live connection keeps its class. + return Some(existing.slot); + } + decide_slot( + self.mesh_count(), + self.mesh_slots, + self.temp_count(), + self.temp_referral_slots, + now_ms(), + ) + } + + /// Accept an incoming connection. Returns the slot class it was given, or + /// `None` if both the mesh pool and the temp referral band are full. + /// + /// The caller MUST propagate `slot.is_mesh()` into the initial exchange: + /// a temp referral slot carries no uniques announce in either direction. pub fn accept_connection( &mut self, conn: iroh::endpoint::Connection, remote_node_id: NodeId, remote_addr: Option, - ) -> bool { + ) -> Option { if self.connections.contains_key(&remote_node_id) { debug!(peer = hex::encode(remote_node_id), "Replacing existing connection"); } - let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; - let total = self.connections.len(); - if total >= total_slots && !self.connections.contains_key(&remote_node_id) { - debug!(peer = hex::encode(remote_node_id), "Slots full, rejecting"); - return false; - } - - let now = now_ms(); - let slot_kind = if self.count_kind(PeerSlotKind::Local) < self.local_slots { - PeerSlotKind::Local - } else { - PeerSlotKind::Wide + let slot = match self.allocate_slot(&remote_node_id) { + Some(s) => s, + None => { + debug!(peer = hex::encode(remote_node_id), "Slots full (mesh + temp), rejecting"); + return None; + } }; + let now = now_ms(); self.connections.insert( remote_node_id, MeshConnection { node_id: remote_node_id, connection: conn, - slot_kind, + slot, + peer_depth: DEFAULT_PEER_DEPTH, connected_at: now, remote_addr: remote_addr.map(normalize_addr), last_activity: Arc::new(AtomicU64::new(now)), }, ); - // If peer was on referral list and reconnected, clear disconnected_at - self.mark_referral_reconnected(&remote_node_id); - - true + Some(slot) } /// Register an already-established connection into the mesh. /// The QUIC connect must happen OUTSIDE the conn_mgr lock to avoid blocking /// all other tasks (diff cycle, accept loop, keepalive, rebalance) during /// the potentially long connection timeout. + /// + /// The slot class is DECIDED here rather than supplied by the caller: every + /// outbound path used to pass `Local` unconditionally and none of them + /// checked capacity, so only inbound connections were ever capped. At 20 + /// slots that overshoot would be load-bearing. + /// Returns the slot the connection was placed in, or `None` if refused. pub async fn register_connection( &mut self, peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: &[std::net::SocketAddr], - slot_kind: PeerSlotKind, - ) { + ) -> Option { let now = now_ms(); let connect_addr = addrs.first().copied(); + let slot = self.allocate_slot(&peer_id)?; + // Direct connect succeeded — mark peer as directly reachable self.mark_reachable(&peer_id); @@ -1350,27 +1808,31 @@ impl ConnectionManager { MeshConnection { node_id: peer_id, connection: conn, - slot_kind, + slot, + peer_depth: DEFAULT_PEER_DEPTH, connected_at: now, remote_addr: connect_addr.map(normalize_addr), last_activity: Arc::new(AtomicU64::new(now)), }, ); - // If peer was on referral list and reconnected, clear disconnected_at - self.mark_referral_reconnected(&peer_id); - - // Persist address to peers table so it survives restart + // Persist address to peers table so it survives restart. + // bugs-fixed #2: ALWAYS do this, temp referral slots included — the + // `peers` table is addresses, `mesh_peers` is knowledge membership. if !addrs.is_empty() { let storage = self.storage.get().await; let _ = storage.upsert_peer(&peer_id, addrs, None); drop(storage); } - // Record in mesh_peers table + touch social route + // Record in mesh_peers table + touch social route. + // Temp referral slots are deliberately NOT written to mesh_peers — + // that omission is what keeps them out of our uniques announce. { let storage = self.storage.get().await; - let _ = storage.add_mesh_peer(&peer_id, slot_kind, 0); + if slot.is_mesh() { + let _ = storage.add_mesh_peer(&peer_id); + } if storage.has_social_route(&peer_id).unwrap_or(false) { let _ = storage.touch_social_route_connect(&peer_id, addrs, ReachMethod::Direct); // Gather watcher data before dropping storage @@ -1387,7 +1849,8 @@ impl ConnectionManager { } } - info!(peer = hex::encode(peer_id), kind = %slot_kind, "Connected to peer"); + info!(peer = hex::encode(peer_id), slot = %slot, "Connected to peer"); + Some(slot) } /// Establish an outgoing mesh connection with a 15s timeout on the QUIC connect. @@ -1398,12 +1861,18 @@ impl ConnectionManager { peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: &[std::net::SocketAddr], - slot_kind: PeerSlotKind, - ) { - if self.connections.contains_key(&peer_id) { - return; // Already connected + ) -> Option { + if let Some(pc) = self.connections.get(&peer_id) { + return Some(pc.slot); // Already connected + } + self.register_connection(peer_id, conn, addrs).await + } + + /// Record a peer's advertised retention depth (from their announce). + pub fn set_peer_depth(&mut self, peer_id: &NodeId, depth: u8) { + if let Some(pc) = self.connections.get_mut(peer_id) { + pc.peer_depth = depth.clamp(2, 4); } - self.register_connection(peer_id, conn, addrs, slot_kind).await; } /// QUIC connect with 15s timeout — call this OUTSIDE the conn_mgr lock. @@ -1413,16 +1882,23 @@ impl ConnectionManager { ) -> anyhow::Result { let conn = tokio::time::timeout( std::time::Duration::from_secs(15), - endpoint.connect(addr, ALPN_V2), + endpoint.connect(addr, ALPN), ).await .map_err(|_| anyhow::anyhow!("connect timed out (15s)"))? .map_err(|e| anyhow::anyhow!("connect failed: {e}"))?; Ok(conn) } - /// Pull posts from a peer — standalone version that doesn't require conn_mgr lock. - /// Takes a cloned connection and storage Arc. - pub async fn pull_from_peer_unlocked( + /// TRANSITIONAL content sync from a peer (0x46/0x47) — standalone version + /// that doesn't require the conn_mgr lock. Takes a cloned connection and + /// storage Arc. + /// + /// This is NOT the pull. A pull is the uniques-index exchange + /// (`uniques_pull_unlocked`). Content moves here only until Iteration D + /// gives the CDN a carrier for non-public visibilities — `PostFetchRequest` + /// (0xD4) serves Public posts only, so DMs, group posts, Friends and + /// FoFClosed posts have no other path today. + pub async fn content_sync_unlocked( conn: iroh::endpoint::Connection, storage: &Arc, peer_id: &NodeId, @@ -1447,24 +1923,22 @@ impl ConnectionManager { } } - let request = PullSyncRequestPayload { + let request = ContentSyncRequestPayload { follows: query_list, - have_post_ids: vec![], since_ms: follows_sync, }; let (mut send, mut recv) = conn.open_bi().await?; - write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?; + write_typed_message(&mut send, MessageType::ContentSyncRequest, &request).await?; send.finish()?; let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::PullSyncResponse { - anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type); + if msg_type != MessageType::ContentSyncResponse { + anyhow::bail!("expected ContentSyncResponse, got {:?}", msg_type); } - let response: PullSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + let response: ContentSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let mut posts_received = 0; - let mut vis_updates = 0; let mut new_post_ids: Vec = Vec::new(); let now_ms = crate::connection::now_ms(); let mut synced_authors: HashSet = HashSet::new(); @@ -1492,7 +1966,7 @@ impl ConnectionManager { } } - // Brief storage lock: upstream + last_sync + visibility updates + // Brief storage lock: upstream + last_sync { let s = storage.get().await; for pid in &new_post_ids { @@ -1506,15 +1980,6 @@ impl ConnectionManager { for author in &synced_authors { let _ = s.update_follow_last_sync(author, now_ms); } - for vu in response.visibility_updates { - if let Some(post) = s.get_post(&vu.post_id)? { - if post.author == vu.author { - if s.update_post_visibility(&vu.post_id, &vu.visibility)? { - vis_updates += 1; - } - } - } - } } // Register as downstream (spawned, no lock needed) @@ -1531,7 +1996,7 @@ impl ConnectionManager { }); } - Ok(PullSyncStats { posts_received, visibility_updates: vis_updates }) + Ok(PullSyncStats { posts_received }) } /// Fetch engagement headers from a peer — standalone version that doesn't require conn_mgr lock. @@ -1585,8 +2050,12 @@ impl ConnectionManager { if let Some((json, header)) = header_opt { let _ = s.store_blob_header(&header.post_id, &header.author, json, header.updated_at); for reaction in &header.reactions { let _ = s.store_reaction(reaction); } - for comment in &header.comments { let _ = s.store_comment(comment); } - let _ = s.set_comment_policy(&header.post_id, &header.policy); + // v0.8 (A3): pull-path comments go through the + // SAME accept gate as the diff path (this site + // historically stored with no verification). + let _ = ingest_header_comments(&s, header, now_ms); + // v0.8: peer-supplied header.policy is NOT applied + // (unverifiable — see the SetPolicy arm note). let _ = s.update_post_last_engagement(post_id, now_ms); updated += 1; } @@ -1597,505 +2066,133 @@ impl ConnectionManager { Ok(updated) } - /// Do the initial exchange after connecting: N1/N2 node lists + profile + deletes + peer addresses (both directions). - pub async fn do_initial_exchange( - &self, - conn: &iroh::endpoint::Connection, - remote_node_id: NodeId, - ) -> anyhow::Result<()> { - // Build our payload - let our_payload = { - let storage = self.storage.get().await; - let n1 = storage.build_n1_share()?; - let n2 = storage.build_n2_share()?; - // Profile keyed by network id (used for N1/N2/N3 routing). - // Strip persona display data before sending so peers don't learn - // a human-readable name for our network id. - let profile = storage.get_profile(&self.our_node_id)? - .map(|p| p.sanitized_for_network_broadcast()); - let deletes = storage.list_delete_records()?; - let post_ids = storage.list_post_ids()?; - let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; - let our_profile = crate::types::NatProfile::from_nat_type(self.nat_type); - InitialExchangePayload { - n1_node_ids: n1, - n2_node_ids: n2, - profile, - deletes, - post_ids, - peer_addresses, - anchor_addr: self.build_anchor_advertised_addr(), - your_observed_addr: None, - nat_type: Some(self.nat_type.to_string()), - nat_mapping: Some(our_profile.mapping.to_string()), - nat_filtering: Some(our_profile.filtering.to_string()), - http_capable: self.http_capable, - http_addr: self.http_addr.clone(), - device_role: None, - cache_pressure: None, - duplicate_active: None, - } + /// Snapshot everything the uniques exchange needs from connection state, so + /// the (bulk, slow) storage half can run with the ConnectionManager mutex + /// released. See `handle_uniques_pull`. + pub(crate) fn uniques_ctx(&self, peer: &NodeId) -> UniquesCtx { + let (is_mesh, peer_depth) = match self.connections.get(peer) { + Some(pc) => (pc.slot.is_mesh(), pc.peer_depth), + // Not a mesh connection at all (ephemeral/session) — no knowledge + // crosses. + None => (false, DEFAULT_PEER_DEPTH), }; - - // Open bi-stream for initial exchange - let (mut send, mut recv) = conn.open_bi().await?; - - // Send our payload - write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?; - send.finish()?; - - // Read their payload - let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::InitialExchange { - anyhow::bail!("expected InitialExchange, got {:?}", msg_type); + UniquesCtx { + storage: Arc::clone(&self.storage), + our_node_id: self.our_node_id, + max_bounce: self.max_bounce, + anchor_addr: self.build_anchor_advertised_addr(), + seq: self.diff_seq.fetch_add(1, Ordering::Relaxed) + 1, + is_mesh, + peer_depth, + connected: self.connections.keys().copied().collect(), } - let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; - - // Process their data - let storage = self.storage.get().await; - - // Their N1 → our N2 (tagged to this reporter) - // Filter out our own ID and already-connected peers (they'd waste candidate slots) - let filtered_n1: Vec = their_payload.n1_node_ids.iter() - .filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid)) - .copied() - .collect(); - storage.set_peer_n1(&remote_node_id, &filtered_n1)?; - debug!(peer = hex::encode(remote_node_id), raw = their_payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self+mesh)"); - - // Their N2 → our N3 (tagged to this reporter) - let filtered_n2: Vec = their_payload.n2_node_ids.iter() - .filter(|nid| **nid != self.our_node_id) - .copied() - .collect(); - storage.set_peer_n2(&remote_node_id, &filtered_n2)?; - debug!(peer = hex::encode(remote_node_id), raw = their_payload.n2_node_ids.len(), stored = filtered_n2.len(), "Stored peer N2 as our N3 (filtered self)"); - - // Store their profile - if let Some(profile) = their_payload.profile { - let _ = storage.store_profile(&profile); - } - - // Process delete records - for dr in their_payload.deletes { - if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { - let _ = storage.store_delete(&dr); - let _ = storage.apply_delete(&dr); - } - } - - // Store their peer_addresses (N+10:Addresses) - for pa in &their_payload.peer_addresses { - if let Ok(nid) = crate::parse_node_id_hex(&pa.n) { - let addrs: Vec = pa.a.iter().filter_map(|a| a.parse().ok()).collect(); - if !addrs.is_empty() { - let _ = storage.upsert_peer(&nid, &addrs, None); - } - } - } - - // Record replicas from overlapping post_ids - let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect(); - for pid in &their_payload.post_ids { - if our_post_ids.contains(pid) { - let _ = storage.record_replica(pid, &remote_node_id); - } - } - - // Process anchor's advertised address - if let Some(ref anchor_addr_str) = their_payload.anchor_addr { - if let Ok(sock) = anchor_addr_str.parse::() { - let _ = storage.upsert_known_anchor(&remote_node_id, &[sock]); - let _ = storage.upsert_peer(&remote_node_id, &[sock], None); - info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address"); - } - } - - // Store observed address (STUN-like feedback from peer) - if let Some(ref observed) = their_payload.your_observed_addr { - info!(observed_addr = %observed, reporter = hex::encode(remote_node_id), "Peer reports our address as"); - if let Ok(addr) = observed.parse::() { - *self.observed_external_addr.lock().unwrap() = Some(addr); - } - } - - // Store peer's NAT type - if let Some(ref nat_str) = their_payload.nat_type { - let nat = crate::types::NatType::from_str_label(nat_str); - let _ = storage.set_peer_nat_type(&remote_node_id, nat); - } - - // Store peer's NAT profile (mapping + filtering) if provided - if their_payload.nat_mapping.is_some() || their_payload.nat_filtering.is_some() { - let mapping = their_payload.nat_mapping.as_deref() - .map(crate::types::NatMapping::from_str_label) - .unwrap_or(crate::types::NatMapping::Unknown); - let filtering = their_payload.nat_filtering.as_deref() - .map(crate::types::NatFiltering::from_str_label) - .unwrap_or(crate::types::NatFiltering::Unknown); - let profile = crate::types::NatProfile::new(mapping, filtering); - let _ = storage.set_peer_nat_profile(&remote_node_id, &profile); - } - - Ok(()) } - /// Handle the responder side of initial exchange. - pub async fn handle_initial_exchange( - &self, - mut send: iroh::endpoint::SendStream, + /// Responder for 0x40 — the uniques-index exchange. + /// + /// design.html §sync: a pull is not a post transfer. It is the peer's list + /// of unique IDs they have a live path to — "if you want these IDs, talk to + /// me and I'll help you find them". Symmetric: their pools arrive in the + /// request, ours go back in the response, so one round trip refreshes both + /// indexes. Content travels via the CDN (and, until Iteration D, via the + /// transitional 0x46/0x47 content sync). + pub async fn handle_uniques_pull( + conn_mgr: &Arc>, mut recv: iroh::endpoint::RecvStream, + mut send: iroh::endpoint::SendStream, remote_node_id: NodeId, ) -> anyhow::Result<()> { - // Read their payload (message type byte already consumed by caller) - let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + let request: UniquesPullPayload = read_payload(&mut recv, MAX_UNIQUES_PAYLOAD).await?; - // Build and send our payload - let our_payload = { - let storage = self.storage.get().await; - let n1 = storage.build_n1_share()?; - let n2 = storage.build_n2_share()?; - // Profile keyed by network id (used for N1/N2/N3 routing). - // Strip persona display data before sending so peers don't learn - // a human-readable name for our network id. - let profile = storage.get_profile(&self.our_node_id)? - .map(|p| p.sanitized_for_network_broadcast()); - let deletes = storage.list_delete_records()?; - let post_ids = storage.list_post_ids()?; - let peer_addresses = storage.build_peer_addresses_for(&self.our_node_id)?; - let our_profile = crate::types::NatProfile::from_nat_type(self.nat_type); - InitialExchangePayload { - n1_node_ids: n1, - n2_node_ids: n2, - profile, - deletes, - post_ids, - peer_addresses, - anchor_addr: self.build_anchor_advertised_addr(), - your_observed_addr: None, // no remote_addr available in this method - nat_type: Some(self.nat_type.to_string()), - nat_mapping: Some(our_profile.mapping.to_string()), - nat_filtering: Some(our_profile.filtering.to_string()), - http_capable: self.http_capable, - http_addr: self.http_addr.clone(), - device_role: None, - cache_pressure: None, - duplicate_active: None, - } + // Gather the SMALL bits of connection state under a brief lock, then + // drop it and do every storage operation against the pool directly — + // the `gather_anchor_probe_data` / `run_anchor_probe_unlocked` pattern. + // + // Both the build and the apply are bulk SQLite work (the apply is a + // per-row upsert loop sized in thousands). Doing them under the + // ConnectionManager mutex blocked slot allocation, disconnects, + // referral serving and relay introductions for the whole write, once + // per pull per peer across ~20 mesh peers. + // + // Knowledge boundary: a temp referral slot exchanges no knowledge in + // either direction. We answer (so the caller isn't left hanging) but + // with `uniques: None`, and we drop whatever they sent. + let ctx = { + let cm = conn_mgr.lock().await; + cm.uniques_ctx(&remote_node_id) }; - write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?; + let ours = if ctx.is_mesh { + let depth = request.uniques.as_ref().map(|u| u.depth).unwrap_or(ctx.peer_depth); + ctx.build(depth).await + } else { + None + }; + + // Write BEFORE applying theirs — applying is a storage write of up to + // a few thousand rows and the caller should not wait on it. + let response = UniquesPullPayload { uniques: ours }; + write_typed_message(&mut send, MessageType::UniquesPullResponse, &response).await?; send.finish()?; - // Process their data - let storage = self.storage.get().await; - - // Their N1 → our N2 (filter out self + already-connected peers) - let filtered_n1: Vec = their_payload.n1_node_ids.iter() - .filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid)) - .copied() - .collect(); - storage.set_peer_n1(&remote_node_id, &filtered_n1)?; - debug!(peer = hex::encode(remote_node_id), raw = their_payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self+mesh)"); - - // Their N2 → our N3 (filter out self) - let filtered_n2: Vec = their_payload.n2_node_ids.iter() - .filter(|nid| **nid != self.our_node_id) - .copied() - .collect(); - storage.set_peer_n2(&remote_node_id, &filtered_n2)?; - - if let Some(profile) = their_payload.profile { - let _ = storage.store_profile(&profile); - } - - for dr in their_payload.deletes { - if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { - let _ = storage.store_delete(&dr); - let _ = storage.apply_delete(&dr); - } - } - - // Store their peer_addresses (N+10:Addresses) - for pa in &their_payload.peer_addresses { - if let Ok(nid) = crate::parse_node_id_hex(&pa.n) { - let addrs: Vec = pa.a.iter().filter_map(|a| a.parse().ok()).collect(); - if !addrs.is_empty() { - let _ = storage.upsert_peer(&nid, &addrs, None); + if ctx.is_mesh { + if let Some(theirs) = request.uniques { + let applied = ctx.apply(&remote_node_id, &theirs).await?; + let mut cm = conn_mgr.lock().await; + cm.set_peer_depth(&remote_node_id, theirs.depth); + if applied > 0 { + debug!(peer = hex::encode(remote_node_id), applied, "Uniques pull: applied peer index"); + cm.notify_growth(); } } } - - let our_post_ids: HashSet<[u8; 32]> = storage.list_post_ids()?.into_iter().collect(); - for pid in &their_payload.post_ids { - if our_post_ids.contains(pid) { - let _ = storage.record_replica(pid, &remote_node_id); - } - } - Ok(()) } - /// Compute N1/N2 changes and broadcast NodeListUpdate to all connected peers. - pub async fn broadcast_routing_diff(&mut self) -> anyhow::Result { - let seq = self.diff_seq.fetch_add(1, Ordering::Relaxed) + 1; - - let (current_n1, current_n2) = { - let storage = self.storage.get().await; - let n1: HashSet = storage.build_n1_share()?.into_iter().collect(); - let n2: HashSet = storage.build_n2_share()?.into_iter().collect(); - (n1, n2) - }; - - let n1_added: Vec = current_n1.difference(&self.last_n1_set).copied().collect(); - let n1_removed: Vec = self.last_n1_set.difference(¤t_n1).copied().collect(); - let n2_added: Vec = current_n2.difference(&self.last_n2_set).copied().collect(); - let n2_removed: Vec = self.last_n2_set.difference(¤t_n2).copied().collect(); - - if n1_added.is_empty() && n1_removed.is_empty() && n2_added.is_empty() && n2_removed.is_empty() { - return Ok(0); - } - - let payload = NodeListUpdatePayload { - seq, - n1_added, - n1_removed, - n2_added, - n2_removed, - }; - let mut sent_count = 0; - - for (peer_id, pc) in &self.connections { - let result = async { - let mut send = pc.connection.open_uni().await?; - write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?; - send.finish()?; - anyhow::Ok(()) - } - .await; - - match result { - Ok(()) => sent_count += 1, - Err(e) => { - debug!( - peer = hex::encode(peer_id), - error = %e, - "Failed to send node list update" - ); - } - } - } - - // Update last sets - self.last_n1_set = current_n1; - self.last_n2_set = current_n2; - - Ok(sent_count) - } - - /// Process a node list update from a peer: their N1 changes → our N2, their N2 changes → our N3. - pub async fn process_routing_diff( - &self, - reporter: &NodeId, - diff: NodeListUpdatePayload, + /// Client side of the uniques-index exchange — standalone, no conn_mgr lock + /// held across I/O. Returns how many index rows the peer's pools produced. + pub async fn uniques_pull_unlocked( + conn_mgr: &Arc>, + conn: iroh::endpoint::Connection, + peer_id: &NodeId, ) -> anyhow::Result { - let storage = self.storage.get().await; - let mut count = 0; - - // Their N1 added → add to our N2 (filter self + already-connected) - if !diff.n1_added.is_empty() { - let filtered: Vec = diff.n1_added.iter() - .filter(|nid| **nid != self.our_node_id && !self.connections.contains_key(*nid)) - .copied() - .collect(); - if !filtered.is_empty() { - storage.add_peer_n1(reporter, &filtered)?; - } - count += filtered.len(); - } - - // Their N1 removed → remove from our N2 - if !diff.n1_removed.is_empty() { - storage.remove_peer_n1(reporter, &diff.n1_removed)?; - count += diff.n1_removed.len(); - } - - // Their N2 added → add to our N3 (filter self) - if !diff.n2_added.is_empty() { - let filtered: Vec = diff.n2_added.iter() - .filter(|nid| **nid != self.our_node_id) - .copied() - .collect(); - if !filtered.is_empty() { - storage.add_peer_n2(reporter, &filtered)?; - } - count += filtered.len(); - } - - // Their N2 removed → remove from our N3 - if !diff.n2_removed.is_empty() { - storage.remove_peer_n2(reporter, &diff.n2_removed)?; - count += diff.n2_removed.len(); - } - - // Update last_diff_seq - let _ = storage.update_mesh_peer_seq(reporter, diff.seq); - - Ok(count) - } - - /// Broadcast a visibility update to all connected peers. - pub async fn broadcast_visibility_update( - &self, - update: &crate::types::VisibilityUpdate, - ) -> usize { - let payload = VisibilityUpdatePayload { - updates: vec![update.clone()], + let ctx = { + let cm = conn_mgr.lock().await; + cm.uniques_ctx(peer_id) }; - - let mut sent = 0; - for (peer_id, pc) in &self.connections { - let result = async { - let mut send = pc.connection.open_uni().await?; - write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?; - send.finish()?; - anyhow::Ok(()) - } - .await; - - match result { - Ok(()) => sent += 1, - Err(e) => { - debug!(peer = hex::encode(peer_id), error = %e, "Failed to push visibility update"); - } - } + if !ctx.is_mesh { + anyhow::bail!("uniques pull only runs on mesh slots"); } - sent - } + // Built with the lock RELEASED — see `handle_uniques_pull`. + let ours = ctx.build(ctx.peer_depth).await; - /// Pull posts from a connected peer. - pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { - let pc = self - .connections - .get(peer_id) - .ok_or_else(|| anyhow::anyhow!("not connected to {}", hex::encode(peer_id)))?; - - let (our_follows, follows_sync, our_personas) = { - let storage = self.storage.get().await; - ( - storage.list_follows()?, - storage.get_follows_with_last_sync().unwrap_or_default(), - storage.list_posting_identities().unwrap_or_default(), - ) - }; - - // Merged pull: include every posting identity so DMs match recipient. - let mut query_list = our_follows; - for pi in &our_personas { - if !query_list.contains(&pi.node_id) { - query_list.push(pi.node_id); - } - } - - let request = PullSyncRequestPayload { - follows: query_list, - have_post_ids: vec![], // v4: empty, using since_ms instead - since_ms: follows_sync, - }; - - let (mut send, mut recv) = pc.connection.open_bi().await?; - write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?; + let (mut send, mut recv) = conn.open_bi().await?; + write_typed_message( + &mut send, + MessageType::UniquesPullRequest, + &UniquesPullPayload { uniques: ours }, + ) + .await?; send.finish()?; let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::PullSyncResponse { - anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type); + if msg_type != MessageType::UniquesPullResponse { + anyhow::bail!("expected UniquesPullResponse, got {:?}", msg_type); } - let response: PullSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + let response: UniquesPullPayload = read_payload(&mut recv, MAX_UNIQUES_PAYLOAD).await?; - let mut posts_received = 0; - let mut vis_updates = 0; - let mut new_post_ids: Vec = Vec::new(); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - - // Brief lock 1: store posts - let mut synced_authors: HashSet = HashSet::new(); - { - let storage = self.storage.get().await; - for sp in &response.posts { - if storage.is_deleted(&sp.id)? { - continue; - } - if verify_post_id(&sp.id, &sp.post) { - match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { - Ok(true) => { - new_post_ids.push(sp.id); - posts_received += 1; - synced_authors.insert(sp.post.author); - } - Ok(false) => { - synced_authors.insert(sp.post.author); - } - Err(e) => { - warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); - } - } - } - } + let theirs = match response.uniques { + Some(t) => t, + None => return Ok(0), + }; + let applied = ctx.apply(peer_id, &theirs).await?; + let mut cm = conn_mgr.lock().await; + cm.set_peer_depth(peer_id, theirs.depth); + if applied > 0 { + cm.notify_growth(); } - // Lock RELEASED - - // Brief lock 2: upstream + last_sync + visibility updates - { - let storage = self.storage.get().await; - for pid in &new_post_ids { - let _ = storage.touch_file_holder( - pid, - peer_id, - &[], - crate::storage::HolderDirection::Received, - ); - } - for author in &synced_authors { - let _ = storage.update_follow_last_sync(author, now_ms); - } - for vu in response.visibility_updates { - if vu.author != *peer_id { - // Only accept visibility updates authored by the responding peer - // (or their forwarded data — but for now, only from the peer) - } - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - if storage.update_post_visibility(&vu.post_id, &vu.visibility)? { - vis_updates += 1; - } - } - } - } - } - - // Register as downstream with the sender for new posts (cap at 50 to avoid flooding) - if !new_post_ids.is_empty() { - let conn = pc.connection.clone(); - tokio::spawn(async move { - for post_id in new_post_ids.into_iter().take(50) { - let payload = PostDownstreamRegisterPayload { post_id }; - if let Ok(mut send) = conn.open_uni().await { - let _ = write_typed_message(&mut send, MessageType::PostDownstreamRegister, &payload).await; - let _ = send.finish(); - } - } - }); - } - - Ok(PullSyncStats { - posts_received, - visibility_updates: vis_updates, - }) + Ok(applied) } /// Fetch engagement headers (reactions, comments, policies) for posts due for check from a peer. @@ -2187,10 +2284,12 @@ impl ConnectionManager { for reaction in &header.reactions { let _ = storage.store_reaction(reaction); } - for comment in &header.comments { - let _ = storage.store_comment(comment); - } - let _ = storage.set_comment_policy(&header.post_id, &header.policy); + // v0.8 (A3): pull-path comments go through the + // SAME accept gate as the diff path (this site + // historically stored with no verification). + let _ = ingest_header_comments(&storage, header, now_ms); + // v0.8: peer-supplied header.policy is NOT applied + // (unverifiable — see the SetPolicy arm note). let _ = storage.update_post_last_engagement(post_id, now_ms); updated += 1; } @@ -2203,23 +2302,25 @@ impl ConnectionManager { Ok(updated) } - /// Handle an incoming pull request from a peer. - /// Handle a pull sync request — no conn_mgr lock needed, only storage + our_node_id. - pub async fn handle_pull_request_unlocked( + /// Handle a TRANSITIONAL content sync request (0x46) — no conn_mgr lock + /// needed, only storage. `should_send_post` is the ONLY non-public content + /// gate in the tree and it is wired only here; do not lose it when + /// Iteration D moves content onto the CDN. + /// (`_our_node_id` is the NETWORK id; own-post checks inside use posting + /// identities from storage instead.) + pub async fn handle_content_sync_request_unlocked( storage: &StoragePool, - our_node_id: NodeId, + _our_node_id: NodeId, remote_node_id: NodeId, mut recv: iroh::endpoint::RecvStream, mut send: iroh::endpoint::SendStream, ) -> anyhow::Result<()> { - let request: PullSyncRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?; + let request: ContentSyncRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let their_follows: HashSet = request.follows.into_iter().collect(); - let their_post_ids: HashSet<[u8; 32]> = request.have_post_ids.into_iter().collect(); - // Protocol v4: build per-author since_ms lookup + // Per-author since_ms lookup let since_ms_map: HashMap = request.since_ms.into_iter().collect(); - let use_since_ms = !since_ms_map.is_empty(); // Phase 1: Brief lock — load data let (all_posts, group_members) = { @@ -2231,7 +2332,6 @@ impl ConnectionManager { // Phase 2: Filter without lock (pure CPU) let mut candidates_to_send = Vec::new(); - let mut vis_updates_to_send = Vec::new(); for (id, post, visibility) in all_posts { let should_send = @@ -2241,31 +2341,19 @@ impl ConnectionManager { continue; } - let peer_has_post = if use_since_ms { - if let Some(&since) = since_ms_map.get(&post.author) { - post.timestamp_ms <= since + 60_000 - } else { - false - } + let peer_has_post = if let Some(&since) = since_ms_map.get(&post.author) { + post.timestamp_ms <= since + 60_000 } else { - their_post_ids.contains(&id) + false }; if !peer_has_post { candidates_to_send.push((id, post, visibility)); - } else { - if post.author == our_node_id { - vis_updates_to_send.push(crate::types::VisibilityUpdate { - post_id: id, - author: our_node_id, - visibility, - }); - } } } // Phase 3: Brief re-lock for is_deleted checks + intent fetch on filtered posts - let (posts, vis_updates) = { + let posts = { let s = storage.get().await; let posts_to_send: Vec = candidates_to_send.into_iter() .filter(|(id, _, _)| !s.is_deleted(id).unwrap_or(false)) @@ -2274,15 +2362,14 @@ impl ConnectionManager { SyncPost { id, post, visibility, intent } }) .collect(); - (posts_to_send, vis_updates_to_send) + posts_to_send }; - let response = PullSyncResponsePayload { + let response = ContentSyncResponsePayload { posts, - visibility_updates: vis_updates, }; - write_typed_message(&mut send, MessageType::PullSyncResponse, &response).await?; + write_typed_message(&mut send, MessageType::ContentSyncResponse, &response).await?; send.finish()?; Ok(()) @@ -2311,35 +2398,24 @@ impl ConnectionManager { } } - // N2 lookup: ask tagged reporter for address - let n2_reporters = { + // Referral cascade over the whole stored horizon, shallowest first: + // N2 (bounce 2) → N3 → N4. N4 is USED here even though it is never + // re-announced — that is precisely the "terminal, but not useless" + // property from design.html §layers. + let reporters: Vec = { let storage = self.storage.get().await; - storage.find_in_n2(target)? - }; - for reporter in &n2_reporters { - if let Some(pc) = self.connections.get(reporter) { - let result = async { - let (mut send, mut recv) = pc.connection.open_bi().await?; - let req = crate::protocol::AddressRequestPayload { target: *target }; - write_typed_message(&mut send, MessageType::AddressRequest, &req).await?; - send.finish()?; - let _resp_type = read_message_type(&mut recv).await?; - let resp: crate::protocol::AddressResponsePayload = - read_payload(&mut recv, MAX_PAYLOAD).await?; - anyhow::Ok(resp.address) - }.await; - if let Ok(Some(addr)) = result { - return Ok(Some(addr)); + let mut seen: HashSet = HashSet::new(); + let mut ordered = Vec::new(); + for bounce in 2u8..=4 { + for r in storage.find_reporters_at(target, bounce).unwrap_or_default() { + if seen.insert(r) { + ordered.push(r); + } } } - } - - // N3 lookup: ask tagged reporter (chains one more hop) - let n3_reporters = { - let storage = self.storage.get().await; - storage.find_in_n3(target)? + ordered }; - for reporter in &n3_reporters { + for reporter in &reporters { if let Some(pc) = self.connections.get(reporter) { let result = async { let (mut send, mut recv) = pc.connection.open_bi().await?; @@ -2538,7 +2614,7 @@ impl ConnectionManager { // Check N2/N3 let storage = self.storage.get().await; - let found_entries = storage.find_any_in_n2_n3(all_needles)?; + let found_entries = storage.find_any_reachable(all_needles)?; if let Some((found_id, _reporter, _level)) = found_entries.first() { drop(storage); let address = self.resolve_address(found_id).await.unwrap_or(None); @@ -2727,7 +2803,7 @@ impl ConnectionManager { addr = addr.with_ip_addr(sock); } - let conn = endpoint.connect(addr, ALPN_V2).await.ok()?; + let conn = endpoint.connect(addr, ALPN).await.ok()?; let resp = Self::send_worm_query_raw(&conn, &payload).await.ok()?; let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some(); @@ -2879,7 +2955,7 @@ impl ConnectionManager { } if found.is_none() { let storage = self.storage.get().await; - let entries = storage.find_any_in_n2_n3(&all_needles)?; + let entries = storage.find_any_reachable(&all_needles)?; if let Some((found_id, _reporter, _level)) = entries.first() { drop(storage); let address = self.resolve_address(found_id).await.unwrap_or(None); @@ -2997,38 +3073,26 @@ impl ConnectionManager { Ok(()) } - /// Pick a random wide-connected peer to include as a referral in worm responses. - /// Returns (node_id, address_string) if a suitable peer with a known address is found. + /// Pick a random mesh peer with a known address, to include as a referral + /// in worm responses. + /// + /// v0.8: "prefer Wide" is meaningless with one pool. Temporary referral + /// slots are excluded — we should never point a stranger at a peer we + /// ourselves hold only provisionally. async fn pick_random_wide_referral(&self, exclude: &NodeId) -> Option<(NodeId, String)> { - // Prefer wide-slot peers, but any connected peer with a known address works - let candidates: Vec<(NodeId, PeerSlotKind)> = self + let candidates: Vec = self .connections .iter() - .filter(|(nid, _)| *nid != exclude && **nid != self.our_node_id) - .map(|(nid, pc)| (*nid, pc.slot_kind)) + .filter(|(nid, pc)| *nid != exclude && **nid != self.our_node_id && pc.slot.is_mesh()) + .map(|(nid, _)| *nid) .collect(); if candidates.is_empty() { return None; } - // Prefer wide peers - let wide: Vec<_> = candidates - .iter() - .filter(|(_, kind)| *kind == PeerSlotKind::Wide) - .collect(); - - // Shuffle candidates to pick randomly - let ordered: Vec<&(NodeId, PeerSlotKind)> = if !wide.is_empty() { - wide - } else { - candidates.iter().collect() - }; - - // Try each candidate until we find one with a known address let storage = self.storage.get().await; - for candidate in ordered { - let nid = candidate.0; + for nid in candidates { if let Ok(Some(rec)) = storage.get_peer_record(&nid) { if let Some(addr) = rec.addresses.first() { return Some((nid, addr.to_string())); @@ -3039,44 +3103,116 @@ impl ConnectionManager { None } - /// Disconnect a peer, cleaning up N2/N3 entries. + /// Disconnect a peer. + /// + /// The departing peer's index rows are DELIBERATELY RETAINED. Round-4/5 + /// ruling and design.html §anchors: "Pool knowledge is overwritten memory: + /// a slot's contributed entries are wiped only when a new handshake + /// replaces them — so a node knocked down from 20 peers to 4 still holds 16 + /// dead peers' pools full of anchor addresses to reconnect through." + /// Wiping on disconnect defeated the exact recovery scenario the design was + /// written for: a mass disconnect emptied the index, so `list_pool_anchors` + /// (phase 0 of the anchor mine) and `score_n2_candidates_batch` both + /// returned nothing precisely when the node needed them most, and + /// `anchor_density` collapsed toward 0 — steering the adaptive weights + /// toward mesh introductions at the moment the mesh was gone. + /// + /// Retention is safe without any new bookkeeping: `set_reach` DELETEs per + /// (reporter, bounce) before inserting, so a returning peer's rows are + /// REPLACED rather than duplicated, and the 5h `prune_reach` sweep in + /// `rebalance_slots` ages out anything genuinely stale. pub async fn disconnect_peer(&mut self, peer_id: &NodeId) { + // The slot class decides whether this teardown rolls the growth dice. + // Captured BEFORE the remove. + let was_mesh = self + .connections + .get(peer_id) + .map(|pc| pc.slot.is_mesh()) + .unwrap_or(false); if let Some(pc) = self.connections.remove(peer_id) { drop(pc); } - // Mark disconnected in referral list (anchor-side) - self.mark_referral_disconnected(peer_id); - let storage = self.storage.get().await; - // Remove their N2 contributions (their N1 share → our N2) - let _ = storage.clear_peer_n2(peer_id); - // Remove their N3 contributions (their N2 share → our N3) - let _ = storage.clear_peer_n3(peer_id); // Remove from active mesh peers (but keep in peers table for reconnection) let _ = storage.remove_mesh_peer(peer_id); // Mark social route as disconnected if storage.has_social_route(peer_id).unwrap_or(false) { let _ = storage.set_social_route_status(peer_id, SocialStatus::Disconnected); } + // Piggyback the anchor-density prior on a guard we already hold, on a + // throttle — the stochastic roll below must never trigger a + // COUNT DISTINCT of its own on this hot path. + let fresh_density = if self.anchor_density_refresh_due() { + storage.anchor_density().ok() + } else { + None + }; + + // Release the storage guard BEFORE graduation — StoragePool is a + // non-reentrant tokio Mutex, so re-locking under it would deadlock. + drop(storage); + if let Some(d) = fresh_density { + self.set_anchor_density(d); + } + + // A freed mesh slot is the trigger for temp referral graduation. + // Doing it here rather than only in the 10-minute rebalance means a + // temp peer is promoted the moment room appears. + self.graduate_temp_referrals().await; let remaining = self.connections.len(); debug!(peer = hex::encode(peer_id), remaining, "Disconnected peer"); self.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Disconnected, {} remaining", remaining), Some(*peer_id)); - // If mesh is completely empty, trigger immediate recovery - if self.connections.is_empty() { - info!("Mesh empty, triggering recovery"); - self.log_activity(ActivityLevel::Warn, ActivityCategory::Connection, "Mesh empty".into(), None); - self.notify_recovery(); + // The ruling is "on EACH MESH-PEER disconnect". A temp referral slot + // expiring (5 min TTL, up to 10 of them on desktop) is not mesh churn, + // and letting temp churn roll the dice would inflate convection and + // growth traffic well past the rate the design intends. Graduation + // above still runs for every teardown — a freed mesh slot is a freed + // mesh slot however it was freed. + if !was_mesh { + return; } - // Signal growth loop to fill the empty slot (don't wait 10min for rebalance) - let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; - if remaining < total_slots { - self.notify_growth(); + // RECOVERY stays non-stochastic: below 2 mesh peers a node cannot + // function, so it always acts immediately. The threshold counts MESH + // peers, not connections — a pool of temp referral slots is not a mesh. + match disconnect_response(self.mesh_count(), self.available_mesh_slots()) { + DisconnectResponse::Recover => { + info!(mesh = self.mesh_count(), "Mesh below 2, triggering recovery"); + self.log_activity(ActivityLevel::Warn, ActivityCategory::Connection, "Mesh below 2".into(), None); + self.notify_recovery(); + return; + } + DisconnectResponse::Idle => return, + DisconnectResponse::Roll => {} } + // STOCHASTIC per-disconnect action (round-4/5). No threshold, no jitter + // timer: the randomness itself de-synchronises the network's response + // to a shared disconnect event, which is what a timer with jitter was + // only approximating. Weights are adaptive — anchor-density prior plus + // refusal feedback — and lean toward acting the further under target we + // are. + // + // The dice are rolled here, under the lock (pure state read + RNG); the + // resulting network action is dispatched by a loop on the other end of + // a channel, never inline. `disconnect_peer` is on the hot path of + // every teardown. + match self.roll_convection_action() { + ConvectionAction::Nothing => { + debug!("Convection: rolled do-nothing on disconnect"); + } + ConvectionAction::AskAnchor => { + debug!("Convection: rolled anchor introduction on disconnect"); + self.notify_convection(); + } + ConvectionAction::AskMesh => { + debug!("Convection: rolled mesh introduction on disconnect"); + self.notify_growth(); + } + } } /// Notify watchers that a previously disconnected peer has reconnected. @@ -3137,18 +3273,78 @@ impl ConnectionManager { Ok(reply) } - /// Rebalance connection slots: remove dead connections, prune stale N2/N3 entries. + /// Promote temporary referral slots into freed mesh slots, oldest first. + /// + /// Graduation is in-place: the connection already lives in `connections`, + /// so there is no new QUIC connect, no reconnect, and no protocol round + /// trip. It becomes a knowledge-exchanging peer at the next announce, which + /// is exactly when `mesh_peers` starts feeding `build_own_uniques`. + /// + /// This can NEVER evict an established mesh peer: it only ever fills slots + /// that are already free. + pub async fn graduate_temp_referrals(&mut self) -> Vec { + let free = self.available_mesh_slots(); + if free == 0 { + return Vec::new(); + } + let mut temps: Vec<(NodeId, u64)> = self + .connections + .values() + .filter(|pc| pc.slot.is_temp()) + .map(|pc| (pc.node_id, pc.connected_at)) + .collect(); + if temps.is_empty() { + return Vec::new(); + } + temps.sort_by_key(|(_, at)| *at); + + let mut promoted = Vec::new(); + for (peer_id, _) in temps.into_iter().take(free) { + if let Some(pc) = self.connections.get_mut(&peer_id) { + pc.slot = MeshSlot::Mesh; + promoted.push(peer_id); + } + } + if !promoted.is_empty() { + let storage = self.storage.get().await; + for peer_id in &promoted { + let _ = storage.add_mesh_peer(peer_id); + info!(peer = hex::encode(peer_id), "Temp referral graduated to mesh slot"); + } + drop(storage); + for peer_id in &promoted { + self.log_activity( + ActivityLevel::Info, + ActivityCategory::Rebalance, + "Temp referral graduated to mesh".into(), + Some(*peer_id), + ); + } + } + promoted + } + + /// Rebalance connection slots: reap dead/zombie/expired connections, prune + /// the stale uniques index, graduate temp referral slots, refill the mesh. /// Returns (newly_connected, pending_connects). Caller should QUIC-connect the pending /// list outside the lock, then register them. - pub async fn rebalance_slots(&mut self) -> anyhow::Result<(Vec, Vec<(NodeId, iroh::EndpointAddr, String, PeerSlotKind)>)> { + pub async fn rebalance_slots(&mut self) -> anyhow::Result<(Vec, Vec<(NodeId, iroh::EndpointAddr, String)>)> { self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Rebalance started".into(), None); - // 1. Remove dead + zombie connections + // 1. Remove dead + zombie + expired-temp connections. + // Temp referral expiry is one extra condition in this sweep — that + // IS the whole expiry mechanism, no separate loop. let mut dead = Vec::new(); let now = now_ms(); for (peer_id, pc) in &self.connections { if pc.connection.close_reason().is_some() { dead.push(*peer_id); + } else if let MeshSlot::TempReferral { expires_at_ms } = pc.slot { + if now >= expires_at_ms { + info!(peer = hex::encode(peer_id), "Temp referral slot expired"); + self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Temp referral expired".into(), Some(*peer_id)); + dead.push(*peer_id); + } } else if now.saturating_sub(pc.last_activity.load(Ordering::Relaxed)) > ZOMBIE_TIMEOUT_MS { let idle_secs = now.saturating_sub(pc.last_activity.load(Ordering::Relaxed)) / 1000; info!(peer = hex::encode(peer_id), idle_secs, "Zombie connection detected (no activity)"); @@ -3163,117 +3359,38 @@ impl ConnectionManager { self.disconnect_peer(peer_id).await; } - // 2. Prune stale N2/N3 entries (5 hours) + stale watchers (30 days) + // 2. Graduate temp referral slots into freed mesh slots. + // Oldest temp first. This NEVER evicts an established mesh peer: + // graduation is gated purely on a mesh slot already being free, and + // no path in the tree evicts a mesh peer at all. + self.graduate_temp_referrals().await; + + // 3. Prune the stale uniques index (5 hours, every bounce incl. N4) + // + stale watchers (30 days) { let storage = self.storage.get().await; - let pruned = storage.prune_n2_n3(5 * 60 * 60 * 1000)?; + let pruned = storage.prune_reach(5 * 60 * 60 * 1000)?; if pruned > 0 { - info!(pruned, "Pruned stale N2/N3 entries"); + info!(pruned, "Pruned stale uniques entries"); } let _ = storage.prune_stale_watchers(WATCHER_EXPIRY_MS); } - // 3. Diversity scoring: find low-diversity peers for potential eviction - { - let storage = self.storage.get().await; - let connected: Vec = self.connections.keys().copied().collect(); - let mut zero_diversity = Vec::new(); - for peer_id in &connected { - let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0); - if unique == 0 { - zero_diversity.push(*peer_id); - } - } - if !zero_diversity.is_empty() { - debug!(count = zero_diversity.len(), "Peers with zero unique N2 contributions"); - } - } - let newly_connected: Vec = Vec::new(); - let mut pending_connects: Vec<(NodeId, iroh::EndpointAddr, String, PeerSlotKind)> = Vec::new(); + let mut pending_connects: Vec<(NodeId, iroh::EndpointAddr, String)> = Vec::new(); - // Priority 0 (NEW): Reconnect preferred peers - { - let preferred_peers = { - let storage = self.storage.get().await; - storage.list_preferred_peers().unwrap_or_default() - }; - - let now = now_ms(); - for peer_id in &preferred_peers { - if self.connections.contains_key(peer_id) || *peer_id == self.our_node_id { - continue; - } - // Prune preferred peers unreachable for 7+ days - if let Some(&failed_at) = self.unreachable_peers.get(peer_id) { - if now.saturating_sub(failed_at) > PREFERRED_UNREACHABLE_PRUNE_MS { - info!(peer = hex::encode(peer_id), "Removing preferred peer unreachable for 7 days+"); - let storage = self.storage.get().await; - let _ = storage.remove_preferred_peer(peer_id); - continue; - } - } - - // Evict lowest-diversity non-preferred peer if at capacity - let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; - if self.connections.len() >= total_slots { - let evict_candidate = self.find_non_preferred_eviction_candidate().await; - if let Some(evict_id) = evict_candidate { - info!( - evicting = hex::encode(evict_id), - for_preferred = hex::encode(peer_id), - "Evicting non-preferred peer for preferred reconnection" - ); - self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, format!("Evicting {} for preferred {}", &hex::encode(evict_id)[..8], &hex::encode(peer_id)[..8]), Some(evict_id)); - self.disconnect_peer(&evict_id).await; - } else { - debug!(peer = hex::encode(peer_id), "No non-preferred peer to evict"); - continue; - } - } - - // Collect for connection outside the lock - let addr_str = if !self.is_likely_unreachable(peer_id) { - let storage = self.storage.get().await; - let addr = storage.get_peer_record(peer_id).ok().flatten() - .and_then(|r| r.addresses.first().map(|a| a.to_string())) - .or_else(|| { - storage.get_social_route(peer_id).ok().flatten() - .and_then(|r| r.addresses.first().map(|a| a.to_string())) - }); - drop(storage); - addr - } else { - None - }; - if let Some(addr_s) = addr_str { - if let Ok(eid) = iroh::EndpointId::from_bytes(peer_id) { - let mut addr = iroh::EndpointAddr::from(eid); - if let Ok(sock) = addr_s.parse::() { - addr = addr.with_ip_addr(sock); - } - pending_connects.push((*peer_id, addr, addr_s, PeerSlotKind::Preferred)); - } - } - } - } - - // Priority 1+2: Fill empty local slots with diverse candidates - let local_count = self.count_kind(PeerSlotKind::Local); - if local_count < self.local_slots { + // Priority 1+2: Refill free MESH slots with diverse candidates + let mesh_count = self.mesh_count(); + if mesh_count < self.mesh_slots { let candidates: Vec<(NodeId, Option)> = { let storage = self.storage.get().await; let mut cands = Vec::new(); - // Priority 1: reconnect recently-dead non-preferred peers + // Priority 1: reconnect recently-dead peers for peer_id in &dead { if *peer_id == self.our_node_id { continue; } - // Skip preferred peers — handled above - if storage.is_preferred_peer(peer_id).unwrap_or(false) { - continue; - } if let Ok(Some(rec)) = storage.get_peer_record(peer_id) { let addr = rec.addresses.first().map(|a| a.to_string()); cands.push((*peer_id, addr)); @@ -3284,10 +3401,10 @@ impl ConnectionManager { cands }; - let slots_available = self.local_slots - local_count; + let slots_available = self.mesh_slots - mesh_count; let to_connect = candidates.len().min(slots_available); if to_connect > 0 { - debug!(candidates = candidates.len(), connecting = to_connect, "Filling local slots"); + debug!(candidates = candidates.len(), connecting = to_connect, "Filling mesh slots"); } for (peer_id, addr_str) in candidates.into_iter().take(slots_available) { @@ -3310,7 +3427,7 @@ impl ConnectionManager { addr = addr.with_ip_addr(sock); } // Collect for connection outside the lock - pending_connects.push((peer_id, addr, addr_s, PeerSlotKind::Local)); + pending_connects.push((peer_id, addr, addr_s)); } } } @@ -3321,13 +3438,18 @@ impl ConnectionManager { // 5. Reap idle session connections (keeps anchor + referral sessions alive) self.reap_idle_sessions(SESSION_IDLE_TIMEOUT_MS).await; - // 6. Prune stale relay intro dedup entries + // 6. Prune stale relay intro dedup entries + the convection penalty box + // and the supplement hand-out ledger. All three are network-driven + // maps keyed by node id, so all three need a sweep. { let now = now_ms(); if self.seen_intros.len() > 500 { self.seen_intros .retain(|_, ts| now - *ts < RELAY_INTRO_DEDUP_EXPIRY_MS); } + self.refused_anchors.retain(|_, until| now < *until); + self.supplement_handouts + .retain(|_, (_, since)| now.saturating_sub(*since) < CONVECTION_SUPPLEMENT_WINDOW_MS); } if !dead.is_empty() { @@ -3337,30 +3459,12 @@ impl ConnectionManager { self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, "Complete, no changes".into(), None); } - // Backstop: signal growth loop to fill any remaining local slots + // Backstop: signal growth loop to fill any remaining mesh slots self.notify_growth(); Ok((newly_connected, pending_connects)) } - /// Find the lowest-diversity non-preferred peer to evict. - async fn find_non_preferred_eviction_candidate(&self) -> Option { - let storage = self.storage.get().await; - let mut worst: Option<(NodeId, usize)> = None; - for (peer_id, mc) in &self.connections { - if mc.slot_kind == PeerSlotKind::Preferred { - continue; // Never evict preferred - } - let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0); - match &worst { - None => worst = Some((*peer_id, unique)), - Some((_, worst_unique)) if unique < *worst_unique => worst = Some((*peer_id, unique)), - _ => {} - } - } - worst.map(|(nid, _)| nid) - } - pub fn is_connected(&self, peer_id: &NodeId) -> bool { self.connections.contains_key(peer_id) } @@ -3379,131 +3483,13 @@ impl ConnectionManager { &self.connections } - pub fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { + pub fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> { self.connections .values() - .map(|pc| (pc.node_id, pc.slot_kind, pc.connected_at)) + .map(|pc| (pc.node_id, pc.slot, pc.connected_at)) .collect() } - fn count_kind(&self, kind: PeerSlotKind) -> usize { - self.connections.values().filter(|pc| pc.slot_kind == kind).count() - } - - // ---- Preferred peer negotiation ---- - - /// Request bilateral preferred peer status with a connected mesh peer. - /// On success, both sides persist the agreement and upgrade the slot. - pub async fn request_prefer(&mut self, peer_id: &NodeId) -> anyhow::Result { - let pc = self.connections.get(peer_id) - .ok_or_else(|| anyhow::anyhow!("peer not connected"))?; - - // Check if we have room - let preferred_count = self.count_kind(PeerSlotKind::Preferred); - if preferred_count >= self.preferred_slots { - anyhow::bail!("preferred slots full ({}/{})", preferred_count, self.preferred_slots); - } - - let request = MeshPreferPayload { - requesting: true, - accepted: false, - reject_reason: None, - }; - - let (mut send, mut recv) = pc.connection.open_bi().await?; - write_typed_message(&mut send, MessageType::MeshPrefer, &request).await?; - send.finish()?; - - let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::MeshPrefer { - anyhow::bail!("expected MeshPrefer response, got {:?}", msg_type); - } - let response: MeshPreferPayload = read_payload(&mut recv, 4096).await?; - - if response.accepted { - // Persist agreement - let storage = self.storage.get().await; - storage.add_preferred_peer(peer_id)?; - storage.add_mesh_peer(peer_id, PeerSlotKind::Preferred, 100)?; - drop(storage); - - // Upgrade slot in-memory - if let Some(mc) = self.connections.get_mut(peer_id) { - mc.slot_kind = PeerSlotKind::Preferred; - } - info!(peer = hex::encode(peer_id), "Preferred peer agreement established"); - Ok(true) - } else { - debug!( - peer = hex::encode(peer_id), - reason = ?response.reject_reason, - "Preferred peer request rejected" - ); - Ok(false) - } - } - - /// Handle an incoming MeshPrefer request from a connected peer. - pub async fn handle_mesh_prefer( - &mut self, - from_peer: NodeId, - mut send: iroh::endpoint::SendStream, - mut recv: iroh::endpoint::RecvStream, - ) -> anyhow::Result<()> { - let request: MeshPreferPayload = read_payload(&mut recv, 4096).await?; - - if !request.requesting { - // Not a request — ignore - return Ok(()); - } - - // Check if we have room for a preferred peer - let preferred_count = self.count_kind(PeerSlotKind::Preferred); - let can_accept = preferred_count < self.preferred_slots - && self.connections.contains_key(&from_peer); - - let response = if can_accept { - // Persist agreement - let storage = self.storage.get().await; - storage.add_preferred_peer(&from_peer)?; - storage.add_mesh_peer(&from_peer, PeerSlotKind::Preferred, 100)?; - drop(storage); - - // Upgrade slot in-memory - if let Some(mc) = self.connections.get_mut(&from_peer) { - mc.slot_kind = PeerSlotKind::Preferred; - } - - info!(peer = hex::encode(from_peer), "Accepted preferred peer request"); - - MeshPreferPayload { - requesting: false, - accepted: true, - reject_reason: None, - } - } else { - let reason = if !self.connections.contains_key(&from_peer) { - "not connected".to_string() - } else { - format!("preferred slots full ({}/{})", preferred_count, self.preferred_slots) - }; - debug!(peer = hex::encode(from_peer), reason = %reason, "Rejecting preferred peer request"); - MeshPreferPayload { - requesting: false, - accepted: false, - reject_reason: Some(reason), - } - }; - - write_typed_message(&mut send, MessageType::MeshPrefer, &response).await?; - send.finish()?; - Ok(()) - } - - // reconnect_preferred removed — direct connect now happens outside the lock - // via pending_connects in the actor dispatch. Relay fallback for preferred peers - // is handled by the growth loop's normal relay introduction path. - // ---- Session connection management ---- /// Add a session connection. Evicts oldest idle session if at capacity. @@ -3569,10 +3555,13 @@ impl ConnectionManager { // Build set of sessions that have a reason to stay alive let mut keep_alive: std::collections::HashSet = std::collections::HashSet::new(); - // Anchor side: peers on our referral list need their session kept - for nid in self.referral_list.keys() { - if self.sessions.contains_key(nid) && !self.connections.contains_key(nid) { - keep_alive.insert(*nid); + // Anchor side: a caller sitting in the convection window is exactly the + // session we need alive — it is the end we coordinate introductions + // through. Reaping it would make the anchor hand out cold addresses + // instead. + for e in &self.convection_window { + if self.sessions.contains_key(&e.node_id) && !self.connections.contains_key(&e.node_id) { + keep_alive.insert(e.node_id); } } @@ -3631,11 +3620,28 @@ impl ConnectionManager { &self.active_relay_pipes } - /// Check if we can accept more relay pipes. + /// Check if we can accept more relay pipes. Gated on user opt-in + /// (`relay.session_relay_enabled`) — returns false if the user has + /// not enabled serving as a session relay, regardless of capacity. pub fn can_accept_relay_pipe(&self) -> bool { + if !self.session_relay_enabled.load(Ordering::Relaxed) { + return false; + } self.active_relay_pipes.load(Ordering::Relaxed) < self.max_relay_pipes as u64 } + /// Whether the user has opted in to session relay (both serving and using). + pub fn is_session_relay_enabled(&self) -> bool { + self.session_relay_enabled.load(Ordering::Relaxed) + } + + /// Update the session-relay opt-in flag. The caller is responsible for + /// persisting the setting to storage; this only updates the in-memory + /// flag that gates the relay accept and auto-use paths. + pub fn set_session_relay_enabled(&self, enabled: bool) { + self.session_relay_enabled.store(enabled, Ordering::Relaxed); + } + /// Get our node ID. pub fn our_node_id(&self) -> &NodeId { &self.our_node_id @@ -3715,38 +3721,7 @@ impl ConnectionManager { let mut candidates = Vec::new(); let storage = self.storage.get().await; - // Step 1 (NEW): Check target's preferred_tree from social_routes (~100 NodeIds) - // Intersect with our connections → TTL=0 candidates (they know target or are stably nearby) - if let Ok(Some(route)) = storage.get_social_route(target) { - if !route.preferred_tree.is_empty() { - // Prefer our own preferred peers first within the tree - for tree_node in &route.preferred_tree { - if candidates.len() >= 3 { - break; - } - if let Some(mc) = self.connections.get(tree_node) { - if mc.slot_kind == PeerSlotKind::Preferred - && !candidates.iter().any(|(nid, _)| nid == tree_node) - { - candidates.push((*tree_node, 0)); - } - } - } - // Then any connected tree node - for tree_node in &route.preferred_tree { - if candidates.len() >= 3 { - break; - } - if self.connections.contains_key(tree_node) - && !candidates.iter().any(|(nid, _)| nid == tree_node) - { - candidates.push((*tree_node, 0)); - } - } - } - } - - // Step 2: Our mesh peers that have target in their N1 (our N2 entry tagged to them) + // Step 1: Our mesh peers that have target in their N1 (our N2 entry tagged to them) if candidates.len() < 3 { if let Ok(reporters) = storage.find_in_n2(target) { for reporter in reporters { @@ -3760,28 +3735,13 @@ impl ConnectionManager { } } - // Step 3: Intersect preferred_tree with N3 → TTL=1 candidates - if candidates.len() < 3 { - if let Ok(Some(route)) = storage.get_social_route(target) { - for tree_node in &route.preferred_tree { - if candidates.len() >= 3 { - break; - } - if let Ok(reporters) = storage.find_in_n3(tree_node) { - for reporter in reporters { - if self.connections.contains_key(&reporter) - && !candidates.iter().any(|(nid, _)| *nid == reporter) - && candidates.len() < 3 - { - candidates.push((reporter, 1)); - } - } - } - } - } - } - - // Step 4: Fallback — full N3 scan for target + // Step 2: Fallback — full N3 scan for target. + // + // Deliberately NOT extended to N4 (bounce 4) in Iteration C: the + // forward chain only ever forwards a relay request via N2 reporters + // (`handle_relay_introduce` + its actor twin), so a ttl=2 request would + // dead-end. The ttl semantics and the forward lookup have to be widened + // together — left for whoever does the relay-depth work. if candidates.len() < 3 { if let Ok(reporters) = storage.find_in_n3(target) { for reporter in reporters { @@ -3976,7 +3936,7 @@ impl ConnectionManager { if let Some(session) = cm.sessions.get(&requester) { let session_conn = session.connection.clone(); drop(cm); // release lock before async work - match initial_exchange_connect(&storage_clone, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr.clone(), None, None).await { + match initial_exchange_connect(&storage_clone, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr.clone(), None, None, DEFAULT_PEER_DEPTH, false).await { Ok(ExchangeResult::Accepted { .. }) => { tracing::info!(peer = hex::encode(requester), "Target-side: initial exchange after hole punch"); } @@ -4495,7 +4455,7 @@ impl ConnectionManager { } }; - // Attempt reconnect for unexpected disconnects (not intentional SocialDisconnectNotice) + // Attempt reconnect for unexpected disconnects if is_current { if let Some(addr) = peer_addr { let cm_arc = Arc::clone(&conn_mgr); @@ -4522,7 +4482,7 @@ impl ConnectionManager { Ok(conn) => { let mut cm = cm_arc.lock().await; if !cm.connections.contains_key(&remote_node_id) { - cm.register_new_connection(remote_node_id, conn, &[addr], PeerSlotKind::Local).await; + cm.register_new_connection(remote_node_id, conn, &[addr]).await; info!(peer = hex::encode(remote_node_id), "Auto-reconnected after unexpected disconnect"); cm.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Auto-reconnected to {}", &hex::encode(remote_node_id)[..8]), Some(remote_node_id)); @@ -4545,251 +4505,432 @@ impl ConnectionManager { } } - // ---- Anchor referral methods ---- + // ---- Anchor convection service (design.html §anchors) ---- + // + // The registration model is gone: there is no 0xC0, no referral database, + // no use-count tiering, no disconnect grace period. An anchor keeps a small + // rolling window of recent callers, fed by the convection request itself. + // A caller receives the 2 most recent viable prior callers; its own address + // is then handed to the next 2 callers and rotates out. - /// Anchor-side: register a peer in the referral list. - /// Uses the observed remote address from the peers table (the NAT-mapped public IP - /// seen by the anchor from the QUIC connection) rather than self-reported addresses, - /// which are often private/LAN IPs for NAT'd peers. - pub async fn handle_anchor_register(&mut self, payload: AnchorRegisterPayload) { - if !self.is_anchor.load(Ordering::Relaxed) { + /// The address we OBSERVED for a caller (their NAT-mapped public IP as seen + /// from our side of the QUIC connection). Preferred over anything + /// self-reported, which for a NAT'd peer is usually a LAN address. + /// + /// Reads the live connection first and only then the `peers` row, which + /// keeps the DB off this path entirely in the common case. + fn convection_observed_addr(&self, nid: &NodeId) -> Option { + self.connections.get(nid).and_then(|pc| pc.remote_addr) + .or_else(|| self.sessions.get(nid).and_then(|s| s.remote_addr)) + } + + /// Admit (or refresh) a caller in the rolling window. + /// + /// Addresses are normalised (bugs-fixed #4) and filtered to publicly + /// routable (bugs-fixed #8) before they can ever be handed out — a private + /// or IPv4-mapped-IPv6 address costs the next caller a punch timeout. A + /// caller with no usable address is simply not admitted. + pub(crate) fn convection_admit( + &mut self, + node_id: NodeId, + self_reported: &[String], + observed: Option, + ) { + let addresses = self.convection_usable_addrs(&node_id, self_reported, observed); + if addresses.is_empty() { + debug!(peer = hex::encode(node_id), "Convection: no routable address, not admitting"); return; } - if !self.connections.contains_key(&payload.node_id) && !self.sessions.contains_key(&payload.node_id) { - debug!(peer = hex::encode(payload.node_id), "AnchorRegister from non-connected/session peer, ignoring"); - return; - } - - // Prefer observed remote address (NAT-mapped public IP) over self-reported - let addresses = { - let storage = self.storage.get().await; - let observed = storage.get_peer_record(&payload.node_id) - .ok().flatten() - .map(|r| r.addresses).unwrap_or_default(); - if observed.is_empty() { - // Fall back to self-reported addresses - payload.addresses - } else { - // Use observed addresses (public IP as seen by anchor) - observed.iter().map(|a| a.to_string()).collect() - } - }; - - let now = now_ms(); - self.referral_list.insert(payload.node_id, ReferralEntry { - node_id: payload.node_id, - addresses, - registered_at: now, - use_count: 0, - disconnected_at: None, - }); + convection_window_admit(&mut self.convection_window, node_id, addresses, now_ms()); debug!( - peer = hex::encode(payload.node_id), - list_size = self.referral_list.len(), - "Anchor: registered peer in referral list" + peer = hex::encode(node_id), + window = self.convection_window.len(), + "Convection: admitted caller to rolling window" ); } - /// Anchor-side: pick referrals from the list, applying tiered usage and self-pruning. - pub fn pick_referrals(&mut self, exclude: &NodeId, count: usize) -> Vec { - let now = now_ms(); - - // Prune: remove entries where disconnected_at is >2 min ago - self.referral_list.retain(|_, entry| { - match entry.disconnected_at { - Some(disc_at) if now.saturating_sub(disc_at) > REFERRAL_DISCONNECT_GRACE_MS => false, - _ => true, + /// The addresses of a caller that are actually worth circulating: + /// normalised (bugs-fixed #4) and publicly routable (bugs-fixed #8), + /// observed first because that is the one that provably works. Empty means + /// "we have nothing usable for this caller" — it is neither admitted to the + /// window nor named as the requester of a coordinated introduction. + fn convection_usable_addrs( + &self, + node_id: &NodeId, + self_reported: &[String], + observed: Option, + ) -> Vec { + if *node_id == self.our_node_id { + return Vec::new(); + } + let mut out: Vec = Vec::new(); + let push = |sa: SocketAddr, out: &mut Vec| { + let sa = normalize_addr(sa); + if convection_addr_ok(&sa) { + let s = sa.to_string(); + if !out.contains(&s) { + out.push(s); + } } - }); - - // Tiered usage policy - let list_len = self.referral_list.len(); - let max_uses: u32 = if list_len < REFERRAL_LIST_CAP { - 3 - } else if list_len == REFERRAL_LIST_CAP { - 2 - } else { - 1 }; + let observed = observed.or_else(|| self.convection_observed_addr(node_id)); + if let Some(sa) = observed { + push(sa, &mut out); + } + // SELF-REPORTED addresses are a caller's unverified claim. `convection_- + // addr_ok` only proves an address is public — not that the caller owns + // it — so an unbounded self-reported list let one cheap request make the + // anchor advertise arbitrary third-party IPs under the caller's node id. + // Capped, and once we have an observed address (essentially always, for + // a caller connected over QUIC) restricted to the SAME IP: a different + // port is the legitimate EDM case, a different host is not. + let mut extra = 0usize; + for a in self_reported { + if extra >= CONVECTION_MAX_SELF_REPORTED { + break; + } + if let Ok(sa) = a.parse::() { + if let Some(obs) = observed { + if normalize_addr(sa).ip() != normalize_addr(obs).ip() { + continue; + } + } + let before = out.len(); + push(sa, &mut out); + if out.len() > before { + extra += 1; + } + } + } + out + } - // Filter eligible entries - let mut eligible: Vec<&NodeId> = self.referral_list.iter() - .filter(|(nid, entry)| { - *nid != exclude - && entry.use_count < max_uses - && entry.disconnected_at.is_none() - }) - .map(|(nid, _)| nid) + /// The ONLY address the anchor may instruct a third party to punch at: the + /// one it observed on the caller's own QUIC connection. + /// + /// `serve_convection` pushes an anchor-INITIATED `RelayIntroduce` at up to + /// two live peers naming these addresses. Feeding self-reported claims into + /// that turned one cheap `ConvectionRequest` into "make two third parties + /// start hole-punching at an attacker-chosen victim" — an amplifier the old + /// retired 0xC0 register path did not have — it never pushed anything. + /// Self-reported entries still ride the referral payload as a secondary + /// hint, so the referred peer can try them itself rather than the anchor + /// instructing others to. + fn convection_introduce_addrs( + &self, + node_id: &NodeId, + observed: Option, + ) -> Vec { + if *node_id == self.our_node_id { + return Vec::new(); + } + observed + .or_else(|| self.convection_observed_addr(node_id)) + .map(normalize_addr) + .filter(convection_addr_ok) + .map(|sa| vec![sa.to_string()]) + .unwrap_or_default() + } + + /// Take up to `count` referrals out of the window. + pub(crate) fn convection_pick( + &mut self, + exclude: &NodeId, + count: usize, + ) -> Vec { + let now = now_ms(); + let live: HashSet = self + .connections + .keys() + .chain(self.sessions.keys()) + .copied() .collect(); + convection_window_pick(&mut self.convection_window, exclude, count, now, |n| live.contains(n)) + } - // Sort: lowest use_count first, then most recent registration - eligible.sort_by(|a, b| { - let ea = &self.referral_list[*a]; - let eb = &self.referral_list[*b]; - ea.use_count.cmp(&eb.use_count) - .then(eb.registered_at.cmp(&ea.registered_at)) - }); + /// Can we serve a TOP-UP right now? (ENTRY class never consults this.) + /// + /// Two honest signals, both cheap: nothing fresh to circulate, or we are + /// already saturated — mesh full AND the session table full, so every + /// referral we serve comes back to us as load we cannot absorb. + fn convection_saturated(&self) -> bool { + self.mesh_count() >= self.mesh_slots && self.sessions.len() >= self.session_slots + } - eligible.truncate(count); - let picked_ids: Vec = eligible.into_iter().copied().collect(); + /// Everything the convection response needs, gathered under a brief lock. + /// + /// The old handler held the conn_mgr lock across `write_typed_message` + + /// `send.finish()` — the same lock-contention class as the three known + /// TODOs. Gathering here and writing outside closes that. + pub fn gather_convection( + &mut self, + payload: &ConvectionRequestPayload, + observed: Option, + ) -> ConvectionGathered { + let requester = payload.requester; - let mut result = Vec::with_capacity(picked_ids.len()); - for nid in &picked_ids { - if let Some(entry) = self.referral_list.get_mut(nid) { - entry.use_count += 1; - result.push(AnchorReferral { - node_id: entry.node_id, - addresses: entry.addresses.clone(), + // ENTRY class is ALWAYS served: a node with <2 connections cannot + // function, and a network that refuses re-entry is a network that + // partitions permanently. TOP-UP is served capacity-permitting and + // refused with a single small message — no timeout burned. + let viable = convection_window_viable(&self.convection_window, &requester, now_ms()); + if !convection_should_serve(payload.class, viable, self.convection_saturated()) { + // Still admit them: their address is fresh material for the next + // caller. Top-ups are the convection MEDIUM, not freeloading. + self.convection_admit(requester, &payload.requester_addresses, observed); + debug!(requester = hex::encode(requester), "Convection: refusing top-up (no capacity)"); + return ConvectionGathered { + response: ConvectionResponsePayload { referrals: vec![], refused: true }, + introductions: Vec::new(), + requester, + requester_addresses: Vec::new(), + nat_mapping: None, + nat_filtering: None, + }; + } + + // Can we actually coordinate an introduction? Only if we have a + // routable address for the CALLER to name as the requester. Deciding + // this up front matters: `introduced` tells the caller to skip its own + // introduction round trip, so claiming it without pushing one would + // strand the caller on a failed direct dial. + let requester_addrs = self.convection_introduce_addrs(&requester, observed); + let can_introduce = !requester_addrs.is_empty(); + + let picked = self.convection_pick(&requester, CONVECTION_REFERRALS); + + let mut referrals: Vec = Vec::with_capacity(picked.len()); + let mut introductions: Vec<(NodeId, iroh::endpoint::Connection)> = Vec::new(); + for entry in picked { + // Both ends live on us → coordinate an introduction rather than + // handing over a cold address. The peer starts punching outward + // before the caller has even read our response. + let conn = self + .connections + .get(&entry.node_id) + .map(|pc| pc.connection.clone()) + .or_else(|| self.sessions.get(&entry.node_id).map(|s| s.connection.clone())); + let introduced = conn.is_some() && can_introduce; + if introduced { + if let Some(c) = conn { + introductions.push((entry.node_id, c)); + } + } + debug!( + requester = hex::encode(requester), + referred = hex::encode(entry.node_id), + introduced, + "Convection: referring peer" + ); + referrals.push(ConvectionReferral { + node_id: entry.node_id, + addresses: entry.addresses, + introduced, + }); + } + + // Supplement from live mesh peers when the window is sparse (a young + // anchor genuinely has no prior callers yet). CONSENT-SHAPED: a mesh + // peer never called us and never offered its address for + // redistribution, unlike a window entry whose address arrived in its + // own `ConvectionRequestPayload.requester_addresses`. So: + // + // * we hand out NO address — the referral carries an empty vec and + // relies on the anchor-initiated RelayIntroduce, which makes the + // TARGET disclose its own addresses on its own terms; + // * therefore only peers we can actually introduce qualify (an + // address-free referral we cannot introduce is useless anyway); + // * and each supplemented peer gets its own hand-out budget, so + // unconditional entry-class service cannot be turned into a + // "shuffle + repeat" enumeration oracle over the anchor's mesh. + if referrals.len() < CONVECTION_REFERRALS && can_introduce { + let now = now_ms(); + let already: HashSet = referrals.iter().map(|r| r.node_id).collect(); + let mut mesh_candidates: Vec = self + .connections + .iter() + .filter(|(nid, pc)| { + **nid != requester + && **nid != self.our_node_id + && !already.contains(*nid) + && pc.slot.is_mesh() + }) + .map(|(nid, _)| *nid) + .filter(|nid| match self.supplement_handouts.get(nid) { + Some((count, since)) => { + now.saturating_sub(*since) >= CONVECTION_SUPPLEMENT_WINDOW_MS + || *count < CONVECTION_SUPPLEMENT_CAP + } + None => true, + }) + .collect(); + use rand::seq::SliceRandom; + mesh_candidates.shuffle(&mut rand::rng()); + for nid in mesh_candidates.into_iter().take(CONVECTION_REFERRALS - referrals.len()) { + if let Some(pc) = self.connections.get(&nid) { + introductions.push((nid, pc.connection.clone())); + } + let slot = self.supplement_handouts.entry(nid).or_insert((0, now)); + if now.saturating_sub(slot.1) >= CONVECTION_SUPPLEMENT_WINDOW_MS { + *slot = (0, now); + } + slot.0 += 1; + referrals.push(ConvectionReferral { + node_id: nid, + addresses: Vec::new(), + introduced: true, }); } } - // Self-prune: remove entries that reached max_uses - self.referral_list.retain(|_, entry| entry.use_count < max_uses); - - result - } - - /// Mark a peer as disconnected in the referral list. - pub fn mark_referral_disconnected(&mut self, node_id: &NodeId) { - if let Some(entry) = self.referral_list.get_mut(node_id) { - entry.disconnected_at = Some(now_ms()); - } - } - - /// Mark a peer as reconnected in the referral list. - pub fn mark_referral_reconnected(&mut self, node_id: &NodeId) { - if let Some(entry) = self.referral_list.get_mut(node_id) { - entry.disconnected_at = None; - } - } - - /// Anchor-side: handle a referral request (bi-stream). - /// Supplements from mesh peers when the explicit referral list is sparse. - pub async fn handle_anchor_referral_request( - &mut self, - payload: AnchorReferralRequestPayload, - mut send: iroh::endpoint::SendStream, - ) -> anyhow::Result<()> { - // Also register the requester (they provide addresses, they're connected) - self.handle_anchor_register(AnchorRegisterPayload { - node_id: payload.requester, - addresses: payload.requester_addresses, - }).await; - - let mut referrals = self.pick_referrals(&payload.requester, 3); - - // Auto-refer from mesh peers when referral list is sparse - if referrals.len() < 3 { - let referred_ids: std::collections::HashSet = referrals.iter().map(|r| r.node_id).collect(); - let mut mesh_candidates: Vec<_> = self.connections.iter() - .filter(|(nid, _)| **nid != payload.requester && !referred_ids.contains(*nid) && **nid != self.our_node_id) - .filter_map(|(nid, pc)| pc.remote_addr.map(|a| (*nid, a.to_string()))) - .collect(); - // Shuffle for variety - use rand::seq::SliceRandom; - mesh_candidates.shuffle(&mut rand::rng()); - for (nid, addr) in mesh_candidates.into_iter().take(3 - referrals.len()) { - referrals.push(AnchorReferral { node_id: nid, addresses: vec![addr] }); - } - } + // The request IS the registration. Admitted AFTER picking so a caller + // is never referred to itself, and so its address goes to the NEXT two + // callers rather than this one. + self.convection_admit(requester, &payload.requester_addresses, observed); info!( - requester = hex::encode(payload.requester), - referral_count = referrals.len(), - "Anchor: serving referrals" + requester = hex::encode(requester), + class = if payload.class.is_entry() { "entry" } else { "top-up" }, + referrals = referrals.len(), + introductions = introductions.len(), + window = self.convection_window.len(), + "Convection: serving referrals" ); - let response = AnchorReferralResponsePayload { referrals }; - write_typed_message(&mut send, MessageType::AnchorReferralResponse, &response).await?; + let profile = self.our_nat_profile(); + ConvectionGathered { + response: ConvectionResponsePayload { referrals, refused: false }, + introductions, + requester, + requester_addresses: requester_addrs, + nat_mapping: Some(profile.mapping.to_string()), + nat_filtering: Some(profile.filtering.to_string()), + } + } + + /// Write the convection response and fire any coordinated introductions. + /// Static: runs entirely OUTSIDE the conn_mgr lock. + pub async fn serve_convection( + gathered: ConvectionGathered, + mut send: iroh::endpoint::SendStream, + ) -> anyhow::Result<()> { + // Response first — a refusal must reach the caller immediately, and a + // served caller should start dialling while the introductions fly. + write_typed_message(&mut send, MessageType::ConvectionResponse, &gathered.response).await?; send.finish()?; + + if gathered.introductions.is_empty() || gathered.requester_addresses.is_empty() { + return Ok(()); + } + + // Anchor-INITIATED introduction. Today's introductions are always + // requester-initiated; here the anchor holds both ends, so it pushes a + // RelayIntroduce toward the referral naming the caller as requester. + // The target's handler responds with its addresses and starts punching + // (bugs-fixed #3 registration path), so the caller's direct dial lands. + // + // This is signalling, NOT a session relay — no bytes are piped. + for (target, conn) in gathered.introductions { + let payload = RelayIntroducePayload { + intro_id: rand::random(), + target, + requester: gathered.requester, + requester_addresses: gathered.requester_addresses.clone(), + ttl: 0, + nat_mapping: gathered.nat_mapping.clone(), + nat_filtering: gathered.nat_filtering.clone(), + }; + tokio::spawn(async move { + let result = async { + let (mut s, mut r) = conn.open_bi().await?; + write_typed_message(&mut s, MessageType::RelayIntroduce, &payload).await?; + s.finish()?; + let _ = read_message_type(&mut r).await?; + let _: RelayIntroduceResultPayload = read_payload(&mut r, MAX_PAYLOAD).await?; + anyhow::Ok(()) + }; + match tokio::time::timeout(std::time::Duration::from_secs(10), result).await { + Ok(Ok(())) => debug!(target = hex::encode(target), "Convection: coordinated introduction"), + Ok(Err(e)) => debug!(target = hex::encode(target), error = %e, "Convection: introduction push failed"), + Err(_) => debug!(target = hex::encode(target), "Convection: introduction push timed out"), + } + }); + } Ok(()) } - /// Client-side: request referrals from an anchor peer (mesh or session). - pub async fn request_anchor_referrals( - &mut self, - anchor_peer: &NodeId, - ) -> anyhow::Result> { - let conn = if let Some(pc) = self.connections.get(anchor_peer) { - pc.connection.clone() - } else if let Some(session) = self.sessions.get(anchor_peer) { - session.connection.clone() - } else { - anyhow::bail!("anchor peer not connected (mesh or session)"); - }; + // ---- Stochastic growth trigger (round-4/5) ---- - let our_addrs: Vec = self.endpoint.addr().ip_addrs() - .map(|s| s.to_string()) - .collect(); - - let request = AnchorReferralRequestPayload { - requester: self.our_node_id, - requester_addresses: our_addrs, - }; - - let (mut send, mut recv) = conn.open_bi().await?; - write_typed_message(&mut send, MessageType::AnchorReferralRequest, &request).await?; - send.finish()?; - - let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::AnchorReferralResponse { - anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type); - } - let response: AnchorReferralResponsePayload = read_payload(&mut recv, 4096).await?; - - // Touch session last_active to prevent idle reaping - if let Some(session) = self.sessions.get_mut(anchor_peer) { - session.last_active_at = now_ms(); - } - - Ok(response.referrals) + /// Is the cached anchor-density prior stale? Split from the setter because + /// the storage guard borrows `self`, so a caller holding it cannot also + /// take `&mut self`. + fn anchor_density_refresh_due(&self) -> bool { + now_ms().saturating_sub(self.anchor_density_at_ms) >= ANCHOR_DENSITY_REFRESH_MS } - /// Client-side: register our address with an anchor peer (mesh or session). - pub async fn send_anchor_register(&mut self, anchor_peer: &NodeId) -> anyhow::Result<()> { - let conn = if let Some(pc) = self.connections.get(anchor_peer) { - pc.connection.clone() - } else if let Some(session) = self.sessions.get(anchor_peer) { - session.connection.clone() + fn set_anchor_density(&mut self, density: f64) { + self.anchor_density = density.clamp(0.0, 1.0); + self.anchor_density_at_ms = now_ms(); + } + + /// Roll the dice for one mesh disconnect. Pure state read + RNG — no I/O, + /// no network dispatch, safe to call under the lock. + pub fn roll_convection_action(&self) -> ConvectionAction { + let fill_ratio = if self.mesh_slots == 0 { + 1.0 } else { - anyhow::bail!("anchor peer not connected (mesh or session)"); + self.mesh_count() as f64 / self.mesh_slots as f64 }; + let roll: f64 = rand::random(); + choose_convection_action(roll, self.anchor_density, self.anchor_bias, fill_ratio) + } - let mut our_addrs: Vec = self.endpoint.addr().ip_addrs() - .map(|s| s.to_string()) - .collect(); - // Prepend UPnP external address (most useful for remote peers) - if let Some(ref ext) = self.upnp_external_addr { - let ext_str = ext.to_string(); - if !our_addrs.contains(&ext_str) { - our_addrs.insert(0, ext_str); - } + /// An anchor refused us: multiplicative decrease on the anchor arm, plus a + /// penalty box for that specific anchor so we try a different one next. + pub fn record_convection_refusal(&mut self, anchor: &NodeId) { + self.anchor_bias = (self.anchor_bias * 0.5).max(0.1); + // This is the ONLY insertion site, so sweeping here bounds the map with + // no extra timer. Without it the penalty box grew one permanent entry + // per anchor that ever refused us — a network-driven map with no + // eviction, over an anchor population the design expects to be 20-30% + // of all nodes. (`rebalance_slots` sweeps it too, for long idles.) + let now = now_ms(); + self.refused_anchors.retain(|_, until| now < *until); + self.refused_anchors.insert(*anchor, now + ANCHOR_REFUSAL_PENALTY_MS); + debug!(anchor = hex::encode(anchor), bias = self.anchor_bias, "Convection: refusal feedback"); + } + + /// An anchor served us: additive increase, drifting back toward parity. + pub fn record_convection_success(&mut self, anchor: &NodeId) { + self.anchor_bias = (self.anchor_bias + 0.15).min(1.0); + self.refused_anchors.remove(anchor); + } + + /// Is this anchor in the penalty box? + pub fn is_anchor_penalized(&self, anchor: &NodeId) -> bool { + match self.refused_anchors.get(anchor) { + Some(&until) => now_ms() < until, + None => false, } - // Prepend stable anchor advertised address - if let Some(anchor_addr) = self.build_anchor_advertised_addr() { - if !our_addrs.contains(&anchor_addr) { - our_addrs.insert(0, anchor_addr); - } + } + + pub fn anchor_bias(&self) -> f64 { + self.anchor_bias + } + + pub fn cached_anchor_density(&self) -> f64 { + self.anchor_density + } + + pub fn set_convection_tx(&mut self, tx: tokio::sync::mpsc::Sender<()>) { + self.convection_tx = Some(tx); + } + + /// Wake the convection loop (the "ask a random known anchor" arm). + pub fn notify_convection(&self) { + if let Some(tx) = &self.convection_tx { + let _ = tx.try_send(()); } - - let payload = AnchorRegisterPayload { - node_id: self.our_node_id, - addresses: our_addrs, - }; - - let mut send = conn.open_uni().await?; - write_typed_message(&mut send, MessageType::AnchorRegister, &payload).await?; - send.finish()?; - - // Touch session last_active to prevent idle reaping - if let Some(session) = self.sessions.get_mut(anchor_peer) { - session.last_active_at = now_ms(); - } - - debug!(anchor = hex::encode(anchor_peer), "Registered with anchor"); - - Ok(()) } /// Handle an incoming NAT filter probe request (anchor side). @@ -4848,7 +4989,7 @@ impl ConnectionManager { // Build a temporary endpoint with a random port let secret_key = iroh::SecretKey::generate(&mut rand::rng()); let temp_ep = iroh::Endpoint::builder() - .alpns(vec![ALPN_V2.to_vec()]) + .alpns(vec![ALPN.to_vec()]) .secret_key(secret_key) .bind() .await?; @@ -4859,7 +5000,7 @@ impl ConnectionManager { // Try connecting with a short timeout (2s) let result = tokio::time::timeout( std::time::Duration::from_secs(2), - temp_ep.connect(addr, ALPN_V2), + temp_ep.connect(addr, ALPN), ).await; // Clean up the temporary endpoint @@ -4880,15 +5021,25 @@ impl ConnectionManager { let msg_type = read_message_type(recv).await?; match msg_type { - MessageType::NodeListUpdate => { - let diff: NodeListUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; - let has_n1_additions = !diff.n1_added.is_empty(); - let cm = conn_mgr.lock().await; - let count = cm.process_routing_diff(&remote_node_id, diff).await?; - if count > 0 { - debug!(peer = hex::encode(remote_node_id), count, "Applied node list update"); + MessageType::UniquesAnnounce => { + let announce: UniquesAnnouncePayload = read_payload(recv, MAX_UNIQUES_PAYLOAD).await?; + // Same gather-outside-lock discipline as the pull handlers: + // the apply is a bulk per-row write and must not block slot + // allocation, disconnects or referral serving. + let ctx = { + let mut cm = conn_mgr.lock().await; + cm.set_peer_depth(&remote_node_id, announce.depth); + cm.uniques_ctx(&remote_node_id) + }; + // A temp referral slot carries no knowledge — drop the announce. + if !ctx.is_mesh { + debug!(peer = hex::encode(remote_node_id), "Ignoring announce from temp referral slot"); + return Ok(()); } - if has_n1_additions { + let count = ctx.apply(&remote_node_id, &announce).await?; + if count > 0 { + debug!(peer = hex::encode(remote_node_id), count, "Applied uniques announce"); + let cm = conn_mgr.lock().await; cm.notify_growth(); } } @@ -4900,54 +5051,6 @@ impl ConnectionManager { let _ = storage.store_profile(&profile); } } - MessageType::DeleteRecord => { - let payload: crate::protocol::DeleteRecordPayload = - read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - - // Collect blob CIDs + CDN peers before async work - let mut blob_cleanup: Vec<([u8; 32], Vec<(NodeId, Vec)>)> = Vec::new(); - { - let storage = cm.storage.get().await; - for dr in &payload.records { - if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { - // Collect blobs for CDN cleanup before deleting - let blob_cids = storage.get_blobs_for_post(&dr.post_id).unwrap_or_default(); - for cid in blob_cids { - let holders = storage.get_file_holders(&cid).unwrap_or_default(); - blob_cleanup.push((cid, holders)); - } - let _ = storage.store_delete(dr); - let _ = storage.apply_delete(dr); - // Delete blob metadata + CDN metadata - let deleted_cids = storage.delete_blobs_for_post(&dr.post_id).unwrap_or_default(); - for cid in &deleted_cids { - let _ = storage.cleanup_cdn_for_blob(cid); - let _ = cm.blob_store.delete(cid); - } - } - } - } - - drop(cm); - // BlobDeleteNotice removed in v0.6.2: orphaned blobs on remote - // holders are evicted naturally via LRU rather than by a - // persona-signed push. - let _ = blob_cleanup; - } - MessageType::VisibilityUpdate => { - let payload: crate::protocol::VisibilityUpdatePayload = - read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - for vu in payload.updates { - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility); - } - } - } - } MessageType::SocialAddressUpdate => { let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -4977,15 +5080,20 @@ impl ConnectionManager { if !crate::crypto::verify_manifest_signature(&entry.manifest.author_manifest) { continue; } - // Only store if newer than what we have + // Only store if newer than what we have (stored rows are + // AuthorManifest JSON — the canonical stored format). let dominated = storage.get_cdn_manifest(&entry.cid).ok().flatten() - .and_then(|json| serde_json::from_str::(&json).ok()) - .map(|existing| existing.author_manifest.updated_at >= entry.manifest.author_manifest.updated_at) + .and_then(|json| serde_json::from_str::(&json).ok()) + .map(|existing| existing.updated_at >= entry.manifest.author_manifest.updated_at) .unwrap_or(false); if dominated { continue; } - let manifest_json = match serde_json::to_string(&entry.manifest) { + // Store ONLY the author-signed part. Persisting the full + // CdnManifest kept third-party host/source device metadata + // and produced dual-format rows that later readers parsed + // as AuthorManifest (silently getting updated_at = 0). + let manifest_json = match serde_json::to_string(&entry.manifest.author_manifest) { Ok(j) => j, Err(_) => continue, }; @@ -5198,19 +5306,6 @@ impl ConnectionManager { debug!(peer = hex::encode(remote_node_id), stored, relayed = relay_conns.len(), "Received manifest push"); } - MessageType::SocialDisconnectNotice => { - let payload: SocialDisconnectNoticePayload = read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - if storage.has_social_route(&payload.node_id).unwrap_or(false) { - let _ = storage.set_social_route_status(&payload.node_id, SocialStatus::Disconnected); - } - debug!( - peer = hex::encode(remote_node_id), - target = hex::encode(payload.node_id), - "Received social disconnect notice" - ); - } MessageType::CircleProfileUpdate => { let payload: CircleProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5290,11 +5385,6 @@ impl ConnectionManager { } } } - MessageType::AnchorRegister => { - let payload: AnchorRegisterPayload = read_payload(recv, 4096).await?; - let mut cm = conn_mgr.lock().await; - cm.handle_anchor_register(payload).await; - } MessageType::MeshKeepalive => { // No-op — last_activity already updated on stream accept } @@ -5348,22 +5438,28 @@ impl ConnectionManager { msg_type: MessageType, ) -> anyhow::Result<()> { match msg_type { - MessageType::PullSyncRequest => { + MessageType::ContentSyncRequest => { let (storage, our_node_id) = { let cm = conn_mgr.lock().await; (Arc::clone(&cm.storage), *cm.our_node_id()) }; // Lock RELEASED — handler does its own brief storage locks + network I/O - ConnectionManager::handle_pull_request_unlocked(&storage, our_node_id, remote_node_id, recv, send).await?; + ConnectionManager::handle_content_sync_request_unlocked(&storage, our_node_id, remote_node_id, recv, send).await?; + } + MessageType::UniquesPullRequest => { + ConnectionManager::handle_uniques_pull(conn_mgr, recv, send, remote_node_id).await?; } MessageType::InitialExchange => { - let (storage, our_node_id, anchor_addr, our_nat_type, our_http_capable, our_http_addr, is_duplicate) = { + let (storage, our_node_id, anchor_addr, our_nat_type, our_http_capable, our_http_addr, is_duplicate, our_depth, carry_knowledge) = { let cm = conn_mgr.lock().await; // Duplicate identity detection: is this NodeId already mesh-connected? let dup = cm.connections.contains_key(&remote_node_id); - (cm.storage_ref(), *cm.our_node_id(), cm.build_anchor_advertised_addr(), cm.nat_type(), cm.http_capable, cm.http_addr.clone(), dup) + // Knowledge boundary: only a MESH slot exchanges uniques. + // An unregistered peer (session/relay path) carries none either. + let carry = cm.connections.get(&remote_node_id).map(|pc| pc.slot.is_mesh()).unwrap_or(false); + (cm.storage_ref(), *cm.our_node_id(), cm.build_anchor_advertised_addr(), cm.nat_type(), cm.http_capable, cm.http_addr.clone(), dup, cm.max_bounce, carry) }; - initial_exchange_accept(&storage, &our_node_id, send, recv, remote_node_id, anchor_addr, None, our_nat_type, our_http_capable, our_http_addr, None, None, is_duplicate) + initial_exchange_accept(&storage, &our_node_id, send, recv, remote_node_id, anchor_addr, None, our_nat_type, our_http_capable, our_http_addr, None, None, is_duplicate, our_depth, carry_knowledge) .await?; } MessageType::AddressRequest => { @@ -5467,12 +5563,15 @@ impl ConnectionManager { let cm = conn_mgr.lock().await; Arc::clone(&cm.blob_store) }; - // Also snapshot wide-referral candidates (node_id, slot_kind) - let wide_candidates: Vec<(NodeId, PeerSlotKind)> = { + // Snapshot referral candidates — MESH slots only. We never + // point a stranger at a peer we hold in a temp slot. + let wide_candidates: Vec = { let cm = conn_mgr.lock().await; cm.connections.iter() - .filter(|(nid, _)| **nid != remote_node_id && **nid != cm.our_node_id) - .map(|(nid, pc)| (*nid, pc.slot_kind)) + .filter(|(nid, pc)| { + **nid != remote_node_id && **nid != cm.our_node_id && pc.slot.is_mesh() + }) + .map(|(nid, _)| *nid) .collect() }; tokio::spawn(async move { @@ -5568,10 +5667,8 @@ impl ConnectionManager { use base64::Engine; let storage = storage.get().await; let manifest: Option = storage.get_cdn_manifest(&payload.cid).ok().flatten().and_then(|json| { - if let Ok(am) = serde_json::from_str::(&json) { - let ds_count = storage.get_file_holder_count(&payload.cid).unwrap_or(0); - Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id, host_addresses: vec![], source: our_node_id, source_addresses: vec![], downstream_count: ds_count }) - } else { serde_json::from_str(&json).ok() } + serde_json::from_str::(&json).ok() + .map(|am| crate::types::CdnManifest { author_manifest: am, host: our_node_id }) }); let (cdn_registered, cdn_redirect_peers) = if !payload.requester_addresses.is_empty() { let prior_count = storage.get_file_holder_count(&payload.cid).unwrap_or(0); @@ -5617,8 +5714,7 @@ impl ConnectionManager { Some(json) => { let manifest = if let Ok(am) = serde_json::from_str::(&json) { if am.updated_at > payload.current_updated_at { - let ds_count = store.get_file_holder_count(&payload.cid).unwrap_or(0); - Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id, host_addresses: vec![], source: our_node_id, source_addresses: vec![], downstream_count: ds_count }) + Some(crate::types::CdnManifest { author_manifest: am, host: our_node_id }) } else { None } } else { None }; crate::protocol::ManifestRefreshResponsePayload { cid: payload.cid, updated: manifest.is_some(), manifest } @@ -5630,66 +5726,6 @@ impl ConnectionManager { send.finish()?; debug!(peer = hex::encode(remote_node_id), updated = response.updated, "Handled manifest refresh request"); } - MessageType::GroupKeyRequest => { - let payload: GroupKeyRequestPayload = read_payload(&mut recv, 4096).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - - let response = match storage.get_group_key(&payload.group_id)? { - Some(record) if record.admin == cm.our_node_id => { - // We're the admin — check if requester is still a circle member - let members = storage.get_circle_members(&record.circle_name)?; - if members.contains(&remote_node_id) { - // Wrap group seed for requester - let seed = storage.get_group_seed(&payload.group_id, record.epoch)?; - let member_key = if let Some(seed) = seed { - crypto::wrap_group_key_for_member( - &cm.secret_seed, - &remote_node_id, - &seed, - ).ok().map(|wrapped| crate::types::GroupMemberKey { - member: remote_node_id, - epoch: record.epoch, - wrapped_group_key: wrapped, - }) - } else { - None - }; - GroupKeyResponsePayload { - group_id: payload.group_id, - epoch: record.epoch, - group_public_key: record.group_public_key, - admin: cm.our_node_id, - member_key, - } - } else { - // Requester not a member — no key - GroupKeyResponsePayload { - group_id: payload.group_id, - epoch: record.epoch, - group_public_key: record.group_public_key, - admin: cm.our_node_id, - member_key: None, - } - } - } - _ => { - // Not admin or no record - GroupKeyResponsePayload { - group_id: payload.group_id, - epoch: 0, - group_public_key: [0u8; 32], - admin: cm.our_node_id, - member_key: None, - } - } - }; - drop(storage); - drop(cm); - write_typed_message(&mut send, MessageType::GroupKeyResponse, &response).await?; - send.finish()?; - debug!(peer = hex::encode(remote_node_id), group_id = hex::encode(payload.group_id), "Handled group key request"); - } MessageType::RelayIntroduce => { let payload: RelayIntroducePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; debug!( @@ -5812,7 +5848,7 @@ impl ConnectionManager { if let Some(session) = cm.sessions.get(&requester) { let session_conn = session.connection.clone(); drop(cm); - let _ = initial_exchange_connect(&storage, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr, None, None).await; + let _ = initial_exchange_connect(&storage, &our_node_id, &session_conn, requester, None, our_nat_type, our_http_capable, our_http_addr, None, None, DEFAULT_PEER_DEPTH, false).await; } } }); @@ -5895,14 +5931,20 @@ impl ConnectionManager { let cm = Arc::clone(conn_mgr); Self::handle_session_relay(cm, recv, send, remote_node_id).await?; } - MessageType::MeshPrefer => { - let mut cm = conn_mgr.lock().await; - cm.handle_mesh_prefer(remote_node_id, send, recv).await?; - } - MessageType::AnchorReferralRequest => { - let payload: AnchorReferralRequestPayload = read_payload(&mut recv, 4096).await?; - let mut cm = conn_mgr.lock().await; - cm.handle_anchor_referral_request(payload, send).await?; + MessageType::ConvectionRequest => { + let mut payload: ConvectionRequestPayload = read_payload(&mut recv, 4096).await?; + // The QUIC peer identity is authoritative. Without this a + // caller could enrol a THIRD party's node id in our window and + // have us circulate an address it does not own. + payload.requester = remote_node_id; + // Gather under a brief lock, write outside it. The old handler + // held the lock across write + finish. + let gathered = { + let mut cm = conn_mgr.lock().await; + let observed = cm.convection_observed_addr(&remote_node_id); + cm.gather_convection(&payload, observed) + }; + ConnectionManager::serve_convection(gathered, send).await?; } MessageType::AnchorProbeRequest => { let payload: crate::protocol::AnchorProbeRequestPayload = read_payload(&mut recv, 4096).await?; @@ -6160,93 +6202,75 @@ impl ConnectionManager { } } BlobHeaderDiffOp::AddComment(comment) => { - if policy.blocklist.contains(&comment.author) { - continue; - } - match policy.allow_comments { - crate::types::CommentPermission::None => continue, - crate::types::CommentPermission::FollowersOnly => { - if !followers_set.contains(&comment.author) { - continue; - } - } - crate::types::CommentPermission::Public => {} - crate::types::CommentPermission::FriendsOfFriends => { - // FoF Layer 2 CDN four-check accept rule: - // 1. parent post must carry fof_gating - // (otherwise the policy is ambient - // with no key material to verify); - // 2. pub_x_index must point at a real - // entry in pub_post_set; - // 3. group_sig must validate against - // pub_post_set[pub_x_index]; - // 4. revocation_list must not contain - // pub_post_set[pub_x_index]; - // 5. identity_sig (existing comment - // signature field) verified below. - // - // Failures drop the comment without - // forwarding — kills the bandwidth-DoS - // attack an admitted-but-malicious FoF - // member could otherwise mount. - let parent = match storage.get_post(&payload.post_id) { - Ok(Some(p)) => p, - _ => continue, - }; - let Some(gating) = parent.fof_gating.as_ref() else { continue; }; - if !crate::fof::verify_fof_group_sig(comment, gating) { - continue; - } - // Revocation check (step 4). Two - // sources: the post's snapshot - // revocation_list (rarely populated on - // publish) and the live local table - // fof_revocations (accumulated as - // revocation diffs arrive on the wire). - if let Some(idx) = comment.pub_x_index { - let pub_x = gating.pub_post_set - .get(idx as usize) - .copied(); - if let Some(pub_x) = pub_x { - if gating.revocation_list.iter() - .any(|r| r.revoked_pub_x == pub_x) { - continue; - } - if storage.is_fof_pub_x_revoked(&payload.post_id, &pub_x).unwrap_or(false) { - continue; - } - } - } - } - } - if !crate::crypto::verify_comment_signature( - &comment.author, - &payload.post_id, - &comment.content, - comment.timestamp_ms, - &comment.signature, - comment.ref_post_id.as_ref(), + // v0.8 (A3): the single shared accept gate — + // signature (digest v2 incl. expiry), TTL sanity, + // policy, FoF four-check gated on + // post.fof_gating (not the policy enum), + // open-slot size buckets + greeting cap, + // registry entry validation + newest-wins. + // Same gate runs on both pull-path merges. + let parent = storage.get_post(&payload.post_id).ok().flatten(); + if accept_incoming_comment( + &storage, + parent.as_ref(), + &policy, + &followers_set, + comment, + now_ms(), ) { - continue; // Skip forged comments - } - let _ = storage.store_comment(comment); - } - BlobHeaderDiffOp::EditComment { author, post_id, timestamp_ms, new_content } => { - // Trust-based: only the comment author can edit - if *author == sender || sender == payload.author { - let _ = storage.edit_comment(author, post_id, *timestamp_ms, new_content); + // Registry newest-wins pruning runs only after + // a SUCCESSFUL store, so a store error can't + // leave the persona with no entry (the gate + // itself is side-effect free). + if storage.store_comment(comment).is_ok() + && comment.post_id == crate::registry::REGISTRY_POST_ID + { + let _ = storage.prune_older_registry_entries( + &comment.post_id, + &comment.author, + comment.timestamp_ms, + ); + } } } - BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms } => { - // Trust-based: comment author or post author can delete - if *author == sender || sender == payload.author { + BlobHeaderDiffOp::EditComment { .. } => { + // v0.8: remote edits are IGNORED. The old arm + // trusted the attacker-controlled `payload.author` + // (`sender == payload.author` is trivially forged) + // and rewrote stored content without any signature + // — and post-A1 a network-id sender can never + // legitimately match a posting-id author anyway. + // Edits = the author re-sends AddComment with the + // same (author, post_id, timestamp_ms) key and a + // fresh v2 signature; the upsert replaces the row. + } + BlobHeaderDiffOp::DeleteComment { author, post_id, timestamp_ms, signature } => { + // v0.8 (A3): self-certifying delete ONLY — the + // comment author's signature verifies from the op + // alone (works on registry holders that never met + // the persona). The old parent-post-author sender + // override compared against attacker-controlled + // `payload.author` (forgeable), and could never + // fire legitimately anyway (sender is a NETWORK + // id, post authors are POSTING ids). Post-author + // moderation returns later as a SIGNED override; + // bulk moderation = the RevocationEntry off-switch. + if delete_comment_authorized(author, post_id, *timestamp_ms, signature) { let _ = storage.delete_comment(author, post_id, *timestamp_ms); } } - BlobHeaderDiffOp::SetPolicy(new_policy) => { - if sender == payload.author { - let _ = storage.set_comment_policy(&payload.post_id, new_policy); - } + BlobHeaderDiffOp::SetPolicy(_) => { + // v0.8: remote policy writes are IGNORED. The old + // `sender == payload.author` check compared against + // an attacker-controlled field, and A3's accept + // gate consumes the stored policy as its FIRST + // drop condition — one forged SetPolicy(None) + // against REGISTRY_POST_ID would poison this + // holder into rejecting ALL future registrations. + // Policy rows are written only by the author's own + // device (node.set_comment_policy / + // persist_gated_post_author_state); a signed + // policy-change op can reintroduce remote writes. } BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, @@ -6464,7 +6488,7 @@ pub enum ConnResponse { NodeId(NodeId), OptConnection(Option), Peers(Vec), - ConnectionInfo(Vec<(NodeId, PeerSlotKind, u64)>), + ConnectionInfo(Vec<(NodeId, MeshSlot, u64)>), SessionInfo(Vec<(NodeId, SessionReachMethod, u64)>), NatProfile(crate::types::NatProfile), NatType(crate::types::NatType), @@ -6473,30 +6497,36 @@ pub enum ConnResponse { OptString(Option), OptEndpointAddr(Option), Endpoint(iroh::Endpoint), - SecretSeed([u8; 32]), Storage(Arc), BlobStore(Arc), ActiveRelayPipes(Arc), - Referrals(Vec), OptSocketAddr(Option), IsAnchorAtomicBool(Arc), ActivityLogRef(Arc>), ScoreVec(Vec<(NodeId, f64)>), OptPeerWithAddress(Option), - ConnectionMap(Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc)>), - DiffData(DiffSnapshot), + ConnectionMap(Vec<(NodeId, iroh::endpoint::Connection, MeshSlot, Arc)>), + AnnounceData(AnnounceSnapshot), Unit, } -/// Snapshot of data needed for routing diff computation. -pub struct DiffSnapshot { +/// Snapshot for broadcasting the uniques announce outside the lock. +/// +/// One built payload plus the per-peer connections it goes to. Only MESH-slot +/// peers appear in `connections`: a temporary referral slot carries no +/// knowledge exchange in either direction. +pub struct AnnounceSnapshot { pub our_node_id: NodeId, - pub connections: Vec<(NodeId, iroh::endpoint::Connection)>, - pub diff_seq: u64, - pub n1_added: Vec, - pub n1_removed: Vec, - pub n2_added: Vec, - pub n2_removed: Vec, + /// (peer, connection, peer's advertised retention depth) + pub connections: Vec<(NodeId, iroh::endpoint::Connection, u8)>, + pub seq: u64, + /// Our announce, built with the full terminal pool. Peers that advertise + /// depth < 4 get it stripped at send time. + pub payload: Option, + /// True when the pools are unchanged since the last broadcast — the caller + /// skips the send. (The announce is always a full snapshot, so this is what + /// stands in for incremental diffing.) + pub unchanged: bool, } /// Commands sent to the ConnectionActor via the ConnHandle channel. @@ -6509,11 +6539,16 @@ pub enum ConnCommand { ConnectionCount { reply: oneshot::Sender, }, + /// Mesh-slot count only (temp referral slots excluded) — the number the + /// recovery threshold and the ENTRY/TOP-UP class bit are defined against. + MeshCount { + reply: oneshot::Sender, + }, ConnectedPeers { reply: oneshot::Sender>, }, ConnectionInfo { - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, GetConnection { peer: NodeId, @@ -6525,7 +6560,7 @@ pub enum ConnCommand { }, /// Get all mesh connections (node_id, connection, slot_kind, last_activity) GetConnectionMap { - reply: oneshot::Sender)>>, + reply: oneshot::Sender)>>, }, OurNodeId { reply: oneshot::Sender, @@ -6556,7 +6591,7 @@ pub enum ConnCommand { SessionPeerIds { reply: oneshot::Sender>, }, - AvailableLocalSlots { + AvailableMeshSlots { reply: oneshot::Sender, }, IsLikelyUnreachable { @@ -6566,9 +6601,6 @@ pub enum ConnCommand { GetEndpoint { reply: oneshot::Sender, }, - GetSecretSeed { - reply: oneshot::Sender<[u8; 32]>, - }, GetStorage { reply: oneshot::Sender>, }, @@ -6581,6 +6613,13 @@ pub enum ConnCommand { CanAcceptRelayPipe { reply: oneshot::Sender, }, + IsSessionRelayEnabled { + reply: oneshot::Sender, + }, + SetSessionRelayEnabled { + enabled: bool, + reply: oneshot::Sender<()>, + }, BuildAnchorAdvertisedAddr { reply: oneshot::Sender>, }, @@ -6621,14 +6660,13 @@ pub enum ConnCommand { conn: iroh::endpoint::Connection, remote_node_id: NodeId, remote_addr: Option, - reply: oneshot::Sender, + reply: oneshot::Sender>, }, RegisterConnection { peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: Vec, - slot_kind: PeerSlotKind, - reply: oneshot::Sender<()>, + reply: oneshot::Sender>, }, DisconnectPeer { peer: NodeId, @@ -6662,10 +6700,6 @@ pub enum ConnCommand { SetNatFiltering { filtering: crate::types::NatFiltering, }, - AddStickyN1 { - peer: NodeId, - duration_ms: u64, - }, SetGrowthTx { tx: tokio::sync::mpsc::Sender<()>, }, @@ -6699,33 +6733,36 @@ pub enum ConnCommand { RecordProbeSuccess, RecordProbeFailure, - // --- Referral management --- - HandleAnchorRegister { - payload: AnchorRegisterPayload, - reply: oneshot::Sender<()>, + // --- Anchor convection (adaptive weights, round-5) --- + RecordConvectionRefusal { + anchor: NodeId, }, - PickReferrals { - exclude: NodeId, - count: usize, - reply: oneshot::Sender>, + RecordConvectionSuccess { + anchor: NodeId, }, - MarkReferralDisconnected { - node_id: NodeId, + IsAnchorPenalized { + anchor: NodeId, + reply: oneshot::Sender, }, - MarkReferralReconnected { - node_id: NodeId, + ConvectionState { + reply: oneshot::Sender<(f64, f64, usize)>, + }, + SetConvectionTx { + tx: tokio::sync::mpsc::Sender<()>, }, - // --- Diff/routing --- - ProcessRoutingDiff { + // --- Uniques announce --- + ProcessUniquesAnnounce { from_peer: NodeId, - payload: NodeListUpdatePayload, + payload: UniquesAnnouncePayload, reply: oneshot::Sender>, }, - /// Get snapshot of connections + diff data for external broadcast - GetDiffData { - reply: oneshot::Sender, + /// Get snapshot of mesh connections + our built announce for broadcast + GetAnnounceData { + reply: oneshot::Sender, }, + /// Reset the change detector so the next announce is sent unconditionally. + ForceAnnounce, // --- Relay/address lookups (state reads, no network I/O) --- FindRelaysFor { @@ -6765,7 +6802,7 @@ pub enum ConnCommand { target: NodeId, reply: oneshot::Sender>>, }, - PullFromPeer { + ContentSyncFromPeer { peer: NodeId, reply: oneshot::Sender>, }, @@ -6795,19 +6832,29 @@ pub struct ConnHandle { http_addr: Arc>>, /// CDN device role (set once at startup by Network) device_role_val: Arc>>, + /// Deepest bounce this device retains (4 desktop / 3 mobile). Hoisted out + /// of the manager so the accept path can read it without a lock. + max_bounce: u8, } impl ConnHandle { /// Create a ConnHandle from a command channel sender. - pub fn new(tx: mpsc::Sender) -> Self { + pub fn new(tx: mpsc::Sender, max_bounce: u8) -> Self { Self { tx, http_capable: Arc::new(AtomicBool::new(false)), http_addr: Arc::new(std::sync::Mutex::new(None)), device_role_val: Arc::new(std::sync::Mutex::new(None)), + max_bounce, } } + /// Deepest bounce we retain — goes on the wire so peers can skip building + /// the terminal pool for us. + pub fn max_bounce(&self) -> u8 { + self.max_bounce + } + /// Set HTTP capability info (called once after HTTP server starts). pub fn set_http_info(&self, capable: bool, addr: Option) { self.http_capable.store(capable, Ordering::Relaxed); @@ -6848,13 +6895,20 @@ impl ConnHandle { rx.await.unwrap_or(0) } + /// Mesh-slot count (temp referral slots excluded). + pub async fn mesh_count(&self) -> usize { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::MeshCount { reply: tx }).await; + rx.await.unwrap_or(0) + } + pub async fn connected_peers(&self) -> Vec { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::ConnectedPeers { reply: tx }).await; rx.await.unwrap_or_default() } - pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { + pub async fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::ConnectionInfo { reply: tx }).await; rx.await.unwrap_or_default() @@ -6873,7 +6927,7 @@ impl ConnHandle { } /// Get all mesh connections as (node_id, connection, slot_kind, last_activity). - pub async fn get_connection_map(&self) -> Vec<(NodeId, iroh::endpoint::Connection, PeerSlotKind, Arc)> { + pub async fn get_connection_map(&self) -> Vec<(NodeId, iroh::endpoint::Connection, MeshSlot, Arc)> { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::GetConnectionMap { reply: tx }).await; rx.await.unwrap_or_default() @@ -6930,9 +6984,9 @@ impl ConnHandle { rx.await.unwrap_or_default() } - pub async fn available_local_slots(&self) -> usize { + pub async fn available_mesh_slots(&self) -> usize { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::AvailableLocalSlots { reply: tx }).await; + let _ = self.tx.send(ConnCommand::AvailableMeshSlots { reply: tx }).await; rx.await.unwrap_or(0) } @@ -6948,12 +7002,6 @@ impl ConnHandle { rx.await.expect("actor dropped") } - pub async fn secret_seed(&self) -> [u8; 32] { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::GetSecretSeed { reply: tx }).await; - rx.await.unwrap_or([0u8; 32]) - } - pub async fn storage(&self) -> Arc { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::GetStorage { reply: tx }).await; @@ -6978,6 +7026,18 @@ impl ConnHandle { rx.await.unwrap_or(false) } + pub async fn is_session_relay_enabled(&self) -> bool { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::IsSessionRelayEnabled { reply: tx }).await; + rx.await.unwrap_or(false) + } + + pub async fn set_session_relay_enabled(&self, enabled: bool) { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(ConnCommand::SetSessionRelayEnabled { enabled, reply: tx }).await; + let _ = rx.await; + } + pub async fn build_anchor_advertised_addr(&self) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::BuildAnchorAdvertisedAddr { reply: tx }).await; @@ -7040,12 +7100,15 @@ impl ConnHandle { // === Mutation operations === + /// Accept an inbound connection. `None` = refused (mesh + temp both full); + /// otherwise the slot class it was placed in — the caller must propagate + /// `is_mesh()` into the initial exchange as the knowledge-carry flag. pub async fn accept_connection( &self, conn: iroh::endpoint::Connection, remote_node_id: NodeId, remote_addr: Option, - ) -> bool { + ) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::AcceptConnection { conn, @@ -7053,25 +7116,25 @@ impl ConnHandle { remote_addr, reply: tx, }).await; - rx.await.unwrap_or(false) + rx.await.ok().flatten() } + /// Register an outbound connection. The slot class is decided by the + /// manager (mesh if there is room, else a temp referral slot, else `None`). pub async fn register_connection( &self, peer_id: NodeId, conn: iroh::endpoint::Connection, addrs: Vec, - slot_kind: PeerSlotKind, - ) { + ) -> Option { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::RegisterConnection { peer_id, conn, addrs, - slot_kind, reply: tx, }).await; - let _ = rx.await; + rx.await.ok().flatten() } pub async fn disconnect_peer(&self, peer: &NodeId) { @@ -7124,11 +7187,6 @@ impl ConnHandle { let _ = self.tx.try_send(ConnCommand::MarkUnreachable { peer: *peer }); } - /// Add a node to the sticky N1 set (reported in N1 share until expiry). - pub fn add_sticky_n1(&self, peer: &NodeId, duration_ms: u64) { - let _ = self.tx.try_send(ConnCommand::AddStickyN1 { peer: *peer, duration_ms }); - } - pub fn set_nat_filtering(&self, filtering: crate::types::NatFiltering) { let _ = self.tx.try_send(ConnCommand::SetNatFiltering { filtering }); } @@ -7185,35 +7243,43 @@ impl ConnHandle { // === Referral management === - pub async fn handle_anchor_register(&self, payload: AnchorRegisterPayload) { + /// Refusal feedback: multiplicative decrease on the anchor arm + penalty + /// box for that anchor. + pub fn record_convection_refusal(&self, anchor: &NodeId) { + let _ = self.tx.try_send(ConnCommand::RecordConvectionRefusal { anchor: *anchor }); + } + + /// Success feedback: additive increase, drifting back toward parity. + pub fn record_convection_success(&self, anchor: &NodeId) { + let _ = self.tx.try_send(ConnCommand::RecordConvectionSuccess { anchor: *anchor }); + } + + pub async fn is_anchor_penalized(&self, anchor: &NodeId) -> bool { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::HandleAnchorRegister { payload, reply: tx }).await; - let _ = rx.await; + let _ = self.tx.send(ConnCommand::IsAnchorPenalized { anchor: *anchor, reply: tx }).await; + rx.await.unwrap_or(false) } - pub async fn pick_referrals(&self, exclude: &NodeId, count: usize) -> Vec { + /// (anchor_density, anchor_bias, mesh_count) — diagnostics + tests. + pub async fn convection_state(&self) -> (f64, f64, usize) { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::PickReferrals { exclude: *exclude, count, reply: tx }).await; - rx.await.unwrap_or_default() + let _ = self.tx.send(ConnCommand::ConvectionState { reply: tx }).await; + rx.await.unwrap_or((0.0, 1.0, 0)) } - pub fn mark_referral_disconnected(&self, node_id: &NodeId) { - let _ = self.tx.try_send(ConnCommand::MarkReferralDisconnected { node_id: *node_id }); - } - - pub fn mark_referral_reconnected(&self, node_id: &NodeId) { - let _ = self.tx.try_send(ConnCommand::MarkReferralReconnected { node_id: *node_id }); + pub async fn set_convection_tx(&self, tx: tokio::sync::mpsc::Sender<()>) { + let _ = self.tx.send(ConnCommand::SetConvectionTx { tx }).await; } // === Diff/routing === - pub async fn process_routing_diff( + pub async fn process_uniques_announce( &self, from_peer: &NodeId, - payload: NodeListUpdatePayload, + payload: UniquesAnnouncePayload, ) -> anyhow::Result { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::ProcessRoutingDiff { + let _ = self.tx.send(ConnCommand::ProcessUniquesAnnounce { from_peer: *from_peer, payload, reply: tx, @@ -7244,20 +7310,23 @@ impl ConnHandle { let _ = self.tx.try_send(ConnCommand::TouchSessionIfExists { peer: *peer }); } - pub async fn get_diff_data(&self) -> DiffSnapshot { + pub async fn get_announce_data(&self) -> AnnounceSnapshot { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::GetDiffData { reply: tx }).await; - rx.await.unwrap_or_else(|_| DiffSnapshot { + let _ = self.tx.send(ConnCommand::GetAnnounceData { reply: tx }).await; + rx.await.unwrap_or_else(|_| AnnounceSnapshot { our_node_id: [0u8; 32], connections: vec![], - diff_seq: 0, - n1_added: vec![], - n1_removed: vec![], - n2_added: vec![], - n2_removed: vec![], + seq: 0, + payload: None, + unchanged: true, }) } + /// Force the next `get_announce_data` to send even if nothing changed. + pub async fn force_announce(&self) { + let _ = self.tx.send(ConnCommand::ForceAnnounce).await; + } + // === Complex operations === pub async fn rebalance_slots(&self) -> anyhow::Result> { @@ -7324,9 +7393,11 @@ impl ConnHandle { rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))? } - pub async fn pull_from_peer(&self, peer: &NodeId) -> anyhow::Result { + /// TRANSITIONAL content sync (0x46/0x47) — the real path: uses + /// `control::receive_post`, `touch_file_holder`, and PostDownstreamRegister. + pub async fn content_sync_from_peer(&self, peer: &NodeId) -> anyhow::Result { let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::PullFromPeer { peer: *peer, reply: tx }).await; + let _ = self.tx.send(ConnCommand::ContentSyncFromPeer { peer: *peer, reply: tx }).await; rx.await.map_err(|_| anyhow::anyhow!("actor dropped"))? } @@ -7363,7 +7434,7 @@ impl ConnectionActor { pub async fn spawn_with_arc(cm: Arc>) -> ConnHandle { let (tx, rx) = mpsc::channel(256); // Hoist frequently-needed Arcs so handlers can skip the conn_mgr lock - let (storage, blob_store, endpoint, our_node_id, activity_log, is_anchor) = { + let (storage, blob_store, endpoint, our_node_id, activity_log, is_anchor, max_bounce) = { let cm_guard = cm.lock().await; ( Arc::clone(&cm_guard.storage), @@ -7372,11 +7443,12 @@ impl ConnectionActor { cm_guard.our_node_id, Arc::clone(&cm_guard.activity_log), Arc::clone(&cm_guard.is_anchor), + cm_guard.max_bounce, ) }; let actor = ConnectionActor { cm, rx, storage, blob_store, endpoint, our_node_id, activity_log, is_anchor }; tokio::spawn(actor.run()); - ConnHandle::new(tx) + ConnHandle::new(tx, max_bounce) } /// Spawn the actor owning the ConnectionManager directly (Phase 5+). @@ -7421,42 +7493,28 @@ impl ConnectionActor { } } - // N2 lookup: brief lock to get reporters + their connections - let n2_queries: Vec = { + // Referral cascade over the whole stored horizon (bounce 2 → 3 → 4), + // shallowest first. Must stay in step with + // `ConnectionManager::resolve_address` — these two copies have diverged + // before. + let queries: Vec = { let s = storage.get().await; - let reporters = s.find_in_n2(target)?; - drop(s); - let cm_guard = cm.lock().await; - reporters.iter() - .filter_map(|r| cm_guard.connections.get(r).map(|pc| pc.connection.clone())) - .collect() - }; - for conn in &n2_queries { - let result = tokio::time::timeout(std::time::Duration::from_secs(5), async { - let (mut send, mut recv) = conn.open_bi().await?; - let req = crate::protocol::AddressRequestPayload { target: *target }; - write_typed_message(&mut send, MessageType::AddressRequest, &req).await?; - send.finish()?; - let _resp_type = read_message_type(&mut recv).await?; - let resp: crate::protocol::AddressResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; - anyhow::Ok(resp.address) - }).await; - if let Ok(Ok(Some(addr))) = result { - return Ok(Some(addr)); + let mut seen: HashSet = HashSet::new(); + let mut ordered = Vec::new(); + for bounce in 2u8..=4 { + for r in s.find_reporters_at(target, bounce).unwrap_or_default() { + if seen.insert(r) { + ordered.push(r); + } + } } - } - - // N3 lookup: same pattern - let n3_queries: Vec = { - let s = storage.get().await; - let reporters = s.find_in_n3(target)?; drop(s); let cm_guard = cm.lock().await; - reporters.iter() + ordered.iter() .filter_map(|r| cm_guard.connections.get(r).map(|pc| pc.connection.clone())) .collect() }; - for conn in &n3_queries { + for conn in &queries { let result = tokio::time::timeout(std::time::Duration::from_secs(5), async { let (mut send, mut recv) = conn.open_bi().await?; let req = crate::protocol::AddressRequestPayload { target: *target }; @@ -7574,7 +7632,7 @@ impl ConnectionActor { } { let s = ctx.storage.get().await; - let found_entries = s.find_any_in_n2_n3(all_needles)?; + let found_entries = s.find_any_reachable(all_needles)?; if let Some((found_id, _reporter, _level)) = found_entries.first() { drop(s); let address = Self::resolve_address_unlocked(&ctx.storage, &ctx.cm, &ctx.endpoint, found_id).await.ok().flatten(); @@ -7652,7 +7710,7 @@ impl ConnectionActor { let endpoint_id = iroh::EndpointId::from_bytes(&ref_id).ok()?; let mut addr = iroh::EndpointAddr::from(endpoint_id); if let Ok(sock) = ref_addr.parse::() { addr = addr.with_ip_addr(sock); } - let conn = endpoint.connect(addr, ALPN_V2).await.ok()?; + let conn = endpoint.connect(addr, ALPN).await.ok()?; let resp = ConnectionManager::send_worm_query_raw(&conn, &payload).await.ok()?; let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some(); if is_hit { @@ -7671,14 +7729,14 @@ impl ConnectionActor { None } - /// Pick a random wide referral from a pre-snapshotted list of (node_id, slot_kind). - async fn pick_wide_referral(storage: &Arc, candidates: &[(NodeId, PeerSlotKind)], exclude: &NodeId) -> Option<(NodeId, String)> { - let filtered: Vec<(NodeId, PeerSlotKind)> = candidates.iter().filter(|(nid, _)| nid != exclude).copied().collect(); + /// Pick a random mesh referral from a pre-snapshotted candidate list. + /// v0.8: one pool, so there is no "prefer wide". Temp referral slots were + /// already filtered out when the snapshot was taken. + async fn pick_wide_referral(storage: &Arc, candidates: &[NodeId], exclude: &NodeId) -> Option<(NodeId, String)> { + let filtered: Vec = candidates.iter().filter(|nid| *nid != exclude).copied().collect(); if filtered.is_empty() { return None; } - let wide: Vec<(NodeId, PeerSlotKind)> = filtered.iter().filter(|(_, kind)| *kind == PeerSlotKind::Wide).copied().collect(); - let ordered = if !wide.is_empty() { wide } else { filtered }; let s = storage.get().await; - for (nid, _) in &ordered { + for nid in &filtered { if let Ok(Some(rec)) = s.get_peer_record(nid) { if let Some(addr) = rec.addresses.first() { return Some((*nid, addr.to_string())); } } @@ -7690,7 +7748,7 @@ impl ConnectionActor { async fn handle_worm_query_unlocked( ctx: WormContext, blob_store: Arc, - wide_candidates: Vec<(NodeId, PeerSlotKind)>, + wide_candidates: Vec, payload: WormQueryPayload, mut send: iroh::endpoint::SendStream, from_peer: NodeId, @@ -7737,7 +7795,7 @@ impl ConnectionActor { } if found.is_none() { let s = ctx.storage.get().await; - let entries = s.find_any_in_n2_n3(&all_needles)?; + let entries = s.find_any_reachable(&all_needles)?; if let Some((found_id, _, _)) = entries.first() { drop(s); let address = Self::resolve_address_unlocked(&ctx.storage, &ctx.cm, &ctx.endpoint, found_id).await.ok().flatten(); @@ -7810,6 +7868,10 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.connection_count()); } + ConnCommand::MeshCount { reply } => { + let cm = self.cm.lock().await; + let _ = reply.send(cm.mesh_count()); + } ConnCommand::ConnectedPeers { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.connected_peers()); @@ -7830,7 +7892,7 @@ impl ConnectionActor { ConnCommand::GetConnectionMap { reply } => { let cm = self.cm.lock().await; let map: Vec<_> = cm.connections_ref().iter().map(|(nid, pc)| { - (*nid, pc.connection.clone(), pc.slot_kind, Arc::clone(&pc.last_activity)) + (*nid, pc.connection.clone(), pc.slot, Arc::clone(&pc.last_activity)) }).collect(); let _ = reply.send(map); } @@ -7870,9 +7932,9 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.session_peer_ids()); } - ConnCommand::AvailableLocalSlots { reply } => { + ConnCommand::AvailableMeshSlots { reply } => { let cm = self.cm.lock().await; - let _ = reply.send(cm.available_local_slots()); + let _ = reply.send(cm.available_mesh_slots()); } ConnCommand::IsLikelyUnreachable { peer, reply } => { let cm = self.cm.lock().await; @@ -7882,10 +7944,6 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.endpoint().clone()); } - ConnCommand::GetSecretSeed { reply } => { - let cm = self.cm.lock().await; - let _ = reply.send(cm.secret_seed); - } ConnCommand::GetStorage { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.storage_ref()); @@ -7902,6 +7960,15 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.can_accept_relay_pipe()); } + ConnCommand::IsSessionRelayEnabled { reply } => { + let cm = self.cm.lock().await; + let _ = reply.send(cm.is_session_relay_enabled()); + } + ConnCommand::SetSessionRelayEnabled { enabled, reply } => { + let cm = self.cm.lock().await; + cm.set_session_relay_enabled(enabled); + let _ = reply.send(()); + } ConnCommand::BuildAnchorAdvertisedAddr { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.build_anchor_advertised_addr()); @@ -7950,10 +8017,10 @@ impl ConnectionActor { let r = cm.accept_connection(conn, remote_node_id, remote_addr); let _ = reply.send(r); } - ConnCommand::RegisterConnection { peer_id, conn, addrs, slot_kind, reply } => { + ConnCommand::RegisterConnection { peer_id, conn, addrs, reply } => { let mut cm = self.cm.lock().await; - cm.register_connection(peer_id, conn, &addrs, slot_kind).await; - let _ = reply.send(()); + let r = cm.register_connection(peer_id, conn, &addrs).await; + let _ = reply.send(r); } ConnCommand::DisconnectPeer { peer, reply } => { let mut cm = self.cm.lock().await; @@ -7992,11 +8059,6 @@ impl ConnectionActor { let mut cm = self.cm.lock().await; cm.nat_filtering = filtering; } - ConnCommand::AddStickyN1 { peer, duration_ms } => { - let mut cm = self.cm.lock().await; - let expiry = now_ms() + duration_ms; - cm.sticky_n1.insert(peer, expiry); - } ConnCommand::SetGrowthTx { tx } => { let mut cm = self.cm.lock().await; cm.set_growth_tx(tx); @@ -8062,85 +8124,83 @@ impl ConnectionActor { } // --- Referral management --- - ConnCommand::HandleAnchorRegister { payload, reply } => { + ConnCommand::RecordConvectionRefusal { anchor } => { let mut cm = self.cm.lock().await; - cm.handle_anchor_register(payload).await; - let _ = reply.send(()); + cm.record_convection_refusal(&anchor); } - ConnCommand::PickReferrals { exclude, count, reply } => { + ConnCommand::RecordConvectionSuccess { anchor } => { let mut cm = self.cm.lock().await; - let r = cm.pick_referrals(&exclude, count); - let _ = reply.send(r); + cm.record_convection_success(&anchor); } - ConnCommand::MarkReferralDisconnected { node_id } => { - let mut cm = self.cm.lock().await; - cm.mark_referral_disconnected(&node_id); + ConnCommand::IsAnchorPenalized { anchor, reply } => { + let cm = self.cm.lock().await; + let _ = reply.send(cm.is_anchor_penalized(&anchor)); } - ConnCommand::MarkReferralReconnected { node_id } => { + ConnCommand::ConvectionState { reply } => { + let cm = self.cm.lock().await; + let _ = reply.send((cm.cached_anchor_density(), cm.anchor_bias(), cm.mesh_count())); + } + ConnCommand::SetConvectionTx { tx } => { let mut cm = self.cm.lock().await; - cm.mark_referral_reconnected(&node_id); + cm.set_convection_tx(tx); } - // --- Diff/routing --- - ConnCommand::ProcessRoutingDiff { from_peer, payload, reply } => { - let cm = self.cm.lock().await; - let r = cm.process_routing_diff(&from_peer, payload).await; + // --- Uniques announce --- + ConnCommand::ProcessUniquesAnnounce { from_peer, payload, reply } => { + let ctx = { + let mut cm = self.cm.lock().await; + cm.set_peer_depth(&from_peer, payload.depth); + cm.uniques_ctx(&from_peer) + }; + // Lock released for the bulk write. + let r = if ctx.is_mesh { + ctx.apply(&from_peer, &payload).await + } else { + Ok(0) + }; let _ = reply.send(r); } - ConnCommand::GetDiffData { reply } => { + ConnCommand::ForceAnnounce => { let mut cm = self.cm.lock().await; - // Prune expired sticky N1 entries first - let now = now_ms(); - cm.sticky_n1.retain(|_, expiry| *expiry > now); - let sticky_peers: Vec = cm.sticky_n1.keys().copied().collect(); - // Compute diff snapshot - let storage = cm.storage.get().await; - let current_n1: HashSet = { - let mut set = HashSet::new(); - for nid in cm.connections_ref().keys() { - set.insert(*nid); - } - if let Ok(routes) = storage.list_social_routes() { - for route in &routes { - if route.status == crate::types::SocialStatus::Online { - set.insert(route.node_id); - } - } - } - for nid in &sticky_peers { - set.insert(*nid); - } - set - }; - let current_n2: HashSet = storage.build_n2_share() - .unwrap_or_default() - .into_iter() + cm.last_announce_digest = 0; + } + ConnCommand::GetAnnounceData { reply } => { + let mut cm = self.cm.lock().await; + let our_node_id = *cm.our_node_id(); + let anchor_addr = cm.build_anchor_advertised_addr(); + let our_depth = cm.max_bounce; + let seq = cm.diff_seq.fetch_add(1, Ordering::Relaxed) + 1; + + // Knowledge boundary: MESH slots only. + let conns: Vec<(NodeId, iroh::endpoint::Connection, u8)> = cm + .connections_ref() + .iter() + .filter(|(_, pc)| pc.slot.is_mesh()) + .map(|(nid, pc)| (*nid, pc.connection.clone(), pc.peer_depth)) .collect(); + + let storage = cm.storage.get().await; + let payload = build_uniques_payload( + &storage, + &our_node_id, + anchor_addr.as_deref(), + seq, + our_depth, + 4, + ) + .ok(); drop(storage); - let n1_added: Vec = current_n1.difference(&cm.last_n1_set).copied().collect(); - let n1_removed: Vec = cm.last_n1_set.difference(¤t_n1).copied().collect(); - let n2_added: Vec = current_n2.difference(&cm.last_n2_set).copied().collect(); - let n2_removed: Vec = cm.last_n2_set.difference(¤t_n2).copied().collect(); + let digest = payload.as_ref().map(announce_digest).unwrap_or(0); + let unchanged = digest != 0 && digest == cm.last_announce_digest; + cm.last_announce_digest = digest; - cm.last_n1_set = current_n1; - cm.last_n2_set = current_n2; - - let seq = cm.diff_seq.fetch_add(1, Ordering::Relaxed); - - let conns: Vec<(NodeId, iroh::endpoint::Connection)> = cm.connections_ref() - .iter() - .map(|(nid, pc)| (*nid, pc.connection.clone())) - .collect(); - - let _ = reply.send(DiffSnapshot { - our_node_id: *cm.our_node_id(), + let _ = reply.send(AnnounceSnapshot { + our_node_id, connections: conns, - diff_seq: seq, - n1_added, - n1_removed, - n2_added, - n2_removed, + seq, + payload, + unchanged, }); } @@ -8179,7 +8239,7 @@ impl ConnectionActor { // outgoing-connect slot per peer so we don't race with // auto-reconnect / relay-introduction paths for the same // target; skip peers already mid-connect. - for (peer_id, addr, _addr_s, slot_kind) in pending_connects { + for (peer_id, addr, _addr_s) in pending_connects { let _connect_guard = { let cm = self.cm.lock().await; match cm.try_begin_connect(peer_id) { @@ -8198,7 +8258,7 @@ impl ConnectionActor { match ConnectionManager::connect_to_unlocked(&endpoint, addr).await { Ok(conn) => { let mut cm = self.cm.lock().await; - cm.register_new_connection(peer_id, conn, &addrs, slot_kind).await; + let _ = cm.register_new_connection(peer_id, conn, &addrs).await; info!(peer = hex::encode(peer_id), "Auto-connected to peer"); newly_connected.push(peer_id); } @@ -8291,7 +8351,7 @@ impl ConnectionActor { let r = Self::resolve_address_unlocked(&self.storage, &self.cm, &self.endpoint, &target).await; let _ = reply.send(r); } - ConnCommand::PullFromPeer { peer, reply } => { + ConnCommand::ContentSyncFromPeer { peer, reply } => { // Brief lock: grab connection clone + our_node_id let gather = { let cm = self.cm.lock().await; @@ -8301,7 +8361,7 @@ impl ConnectionActor { let r = match gather { Some((conn, our_node_id)) => { // All I/O outside the lock, storage accessed via hoisted Arc - ConnectionManager::pull_from_peer_unlocked(conn, &self.storage, &peer, our_node_id).await + ConnectionManager::content_sync_unlocked(conn, &self.storage, &peer, our_node_id).await } None => Err(anyhow::anyhow!("not connected to {}", hex::encode(peer))), }; @@ -8350,8 +8410,293 @@ impl ConnectionActor { } } +/// The slot-allocation rule, isolated so it is testable without an endpoint. +/// +/// Mesh first, then the temporary referral band that sits strictly ABOVE the +/// mesh cap, then refuse. Nothing here can evict anything: an established mesh +/// peer is never displaced by an arriving stranger. +pub fn decide_slot( + mesh_count: usize, + mesh_slots: usize, + temp_count: usize, + temp_slots: usize, + now: u64, +) -> Option { + if mesh_count < mesh_slots { + Some(MeshSlot::Mesh) + } else if temp_count < temp_slots { + Some(MeshSlot::TempReferral { expires_at_ms: now + TEMP_REFERRAL_TTL_MS }) + } else { + None + } +} + +// ---- Uniques announce: build / apply (design.html §layers) ---- + +/// Content hash of an announce, ignoring `seq`. Lets the broadcaster skip a +/// send when the pools have not changed. +pub fn announce_digest(p: &UniquesAnnouncePayload) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for slice in &p.fwd { + slice.nodes.hash(&mut h); + slice.authors.hash(&mut h); + for a in &slice.anchors { + a.i.hash(&mut h); + a.a.hash(&mut h); + } + } + p.term.nodes.hash(&mut h); + p.term.authors.hash(&mut h); + for a in &p.term.anchors { + a.i.hash(&mut h); + a.a.hash(&mut h); + } + let d = h.finish(); + if d == 0 { 1 } else { d } +} + +/// Serialise a slice of the pool for the wire. +/// +/// Node-class and author-class IDs go into separate packed blobs; anchors go +/// into the only address-bearing array. A receiver therefore cannot mistake a +/// persona ID for a connectable node ID, and cannot find an address on a +/// non-anchor entry — both invariants are structural, not conventional. +pub fn entries_to_slice(entries: &[ReachEntry]) -> UniquesSlice { + let mut nodes: Vec = Vec::new(); + let mut authors: Vec = Vec::new(); + let mut anchors: Vec = Vec::new(); + for e in entries { + // `id_class == Node` is required STRUCTURALLY, not incidentally: the + // class and the anchor flag are stored in independent columns, so an + // author-class row carrying `is_anchor = 1` would otherwise serialise + // as an address-bearing entry — a persona ID published next to a device + // address, the exact linkage §21 forbids. An author-class entry falls + // through to the bare `authors` array regardless of its flag. + if e.id_class == IdClass::Node && e.is_anchor && !e.addresses.is_empty() { + // bugs-fixed #8: only publicly-routable addresses go on the wire. + let addrs: Vec = e + .addresses + .iter() + .filter(|a| { + a.parse::() + .map(|s| crate::network::is_publicly_routable(&s)) + .unwrap_or(false) + }) + .cloned() + .collect(); + if !addrs.is_empty() { + anchors.push(AnchorEntry { i: hex::encode(e.id), a: addrs }); + continue; + } + // No routable address left — degrade to a bare node entry. + nodes.push(e.id); + continue; + } + match e.id_class { + IdClass::Node => nodes.push(e.id), + IdClass::Author => authors.push(e.id), + } + } + UniquesSlice { nodes: pack_ids(&nodes), authors: pack_ids(&authors), anchors } +} + +/// Deserialise a wire slice back into entries, truncated to `cap`. +/// +/// RECEIVE-SIDE ADDRESS FILTERING is mandatory here and was previously absent, +/// leaving the ingest asymmetric with `entries_to_slice`: a peer could hand us +/// `127.0.0.1:22`, `10.0.0.1:4433` or `192.168.1.1:443`, we would store it and +/// later dial it. That is attacker-chosen internal QUIC probing from the +/// victim's own host, and — per the v0.7.3 EDM lesson — every such `connect()` +/// permanently enters iroh's per-endpoint path store and is probed in the +/// background forever. An anchor entry left with no usable address degrades to +/// a bare node entry rather than being stored address-free-but-anchor-flagged. +pub fn slice_to_entries_capped(slice: &UniquesSlice, cap: usize) -> Vec { + let mut out = Vec::new(); + for id in unpack_ids(&slice.nodes) { + if out.len() >= cap { + return out; + } + out.push(ReachEntry::node(id)); + } + for id in unpack_ids(&slice.authors) { + if out.len() >= cap { + return out; + } + out.push(ReachEntry::author(id)); + } + for a in &slice.anchors { + if out.len() >= cap { + return out; + } + if let Ok(id) = crate::parse_node_id_hex(&a.i) { + // bugs-fixed #4: normalise IPv4-mapped IPv6 before storing. + // bugs-fixed #8 (receive side): routable addresses only. + let addrs: Vec = a + .a + .iter() + .filter_map(|s| s.parse::().ok()) + .map(normalize_addr) + .filter(convection_addr_ok) + .map(|s| s.to_string()) + .collect(); + if addrs.is_empty() { + out.push(ReachEntry::node(id)); + } else { + out.push(ReachEntry::anchor(id, addrs)); + } + } + } + out +} + +/// Uncapped convenience wrapper (tests and the local build path). +pub fn slice_to_entries(slice: &UniquesSlice) -> Vec { + slice_to_entries_capped(slice, usize::MAX) +} + +/// Build our announce payload from storage. +pub fn build_uniques_payload( + storage: &crate::storage::Storage, + our_node_id: &NodeId, + our_anchor_addr: Option<&str>, + seq: u64, + our_depth: u8, + peer_depth: u8, +) -> anyhow::Result { + let pools: UniquesPools = storage.build_uniques_pools(our_node_id, our_anchor_addr)?; + let fwd: Vec = pools.fwd.iter().map(|e| entries_to_slice(e)).collect(); + // A peer that only retains 3 bounces has nowhere to put the terminal pool — + // skip building it for them. Biggest single saving on a cellular link. + let term = if peer_depth >= 4 { + entries_to_slice(&pools.term) + } else { + UniquesSlice::default() + }; + Ok(UniquesAnnouncePayload { seq, full: true, fwd, term, depth: our_depth }) +} + +/// Apply a received announce, shifting pool 1 one bounce deeper and landing +/// pool 2 at the terminal bounce 4. +pub fn apply_uniques_to_storage( + storage: &crate::storage::Storage, + our_node_id: &NodeId, + reporter: &NodeId, + payload: &UniquesAnnouncePayload, + our_max_bounce: u8, + is_connected: impl Fn(&NodeId) -> bool, +) -> anyhow::Result { + // Every reader of `fwd` assumes exactly 3 slices (N0, N1, N2). Nothing + // enforced it, so a peer sending 6 slices wrote rows at bounce 5 and 6 — + // depths the rest of the system has no semantics for (invisible to + // `list_reach_entries_at(2)/(3)` and to `resolve_address`'s 2..=4 cascade, + // but very visible to `find_any_reachable`, `score_n2_candidates_batch`, + // `anchor_density` and `list_pool_anchors`). + if payload.fwd.len() != 3 { + anyhow::bail!("uniques announce: fwd must carry exactly 3 slices, got {}", payload.fwd.len()); + } + + let mut stored = 0usize; + + // fwd[0] is the reporter itself. Nothing to store at bounce 1 (they are a + // direct by definition), but this is where their own anchor address rides. + // This is the ONE first-party claim in the payload — the sender is on the + // other end of the QUIC connection we are reading — so it is the only one + // allowed to touch `peers`, and even then by UNION, never by replacement. + if let Some(self_slice) = payload.fwd.first() { + for e in slice_to_entries_capped(self_slice, 8) { + if e.is_anchor && e.id == *reporter { + persist_direct_anchor_claim(storage, &e); + } + } + } + + // fwd[i] → bounce i + 1, for i >= 1 (so bounce 2 and 3). + for (i, slice) in payload.fwd.iter().enumerate().skip(1) { + let bounce = (i + 1) as u8; + if bounce > our_max_bounce || bounce > 4 { + continue; + } + let cap = crate::storage::uniques_accept_cap(bounce); + let raw = slice_to_entries_capped(slice, cap); + let truncated = raw.len() >= cap; + let entries: Vec = raw + .into_iter() + .filter(|e| { + e.id != *our_node_id + // A peer we are already meshed with is not a growth + // candidate; keeping it out saves candidate-slot churn. + && !(bounce == 2 && e.id_class == IdClass::Node && is_connected(&e.id)) + }) + .collect(); + if truncated { + warn!( + reporter = hex::encode(reporter), + bounce, cap, "Uniques announce truncated to the §layers budget" + ); + } + stored += entries.len(); + storage.set_reach(reporter, bounce, &entries)?; + } + + // term → bounce 4. Dropped entirely when we only retain 3 bounces. + // + // Third-party anchor entries at ANY depth stay in `reachable` only. They + // are an INDEX ("someone says this ID is an anchor at this address"), not + // an address record: nothing has proven the address belongs to the node ID. + // Mirroring them into `peers`/`known_anchors` made a single hostile mesh + // peer able to (a) overwrite our stored address for any node — including + // the DNS-resolved bootstrap anchor (bugs-fixed #5, remotely triggerable), + // (b) get thousands of entries pointing at one victim IP dialled by us and + // by everyone we re-announce to, and (c) re-inflate `known_anchors` from + // gossip, undoing its round-4 demotion to a bootstrap cache. + // `list_pool_anchors` mines `reachable` directly, so the anchor directory + // is unaffected. + if our_max_bounce >= 4 { + let cap = crate::storage::uniques_accept_cap(4); + let raw = slice_to_entries_capped(&payload.term, cap); + if raw.len() >= cap { + warn!( + reporter = hex::encode(reporter), + bounce = 4, cap, "Uniques announce truncated to the §layers budget" + ); + } + let entries: Vec = + raw.into_iter().filter(|e| e.id != *our_node_id).collect(); + stored += entries.len(); + storage.set_reach(reporter, 4, &entries)?; + } + + let _ = storage.update_mesh_peer_seq(reporter, payload.seq); + Ok(stored) +} + +/// The reporter's claim about ITS OWN anchor address (payload `fwd[0]`). +/// +/// First-party and from a peer we are connected to right now — the same trust +/// class as `InitialExchangePayload.anchor_addr`. It may therefore mark the +/// peer as an anchor and add the address, but it may NOT replace addresses we +/// already have (one of which is the address we actually connected on), and it +/// does NOT seed `known_anchors`: that cache is now reserved for anchors we +/// have completed a handshake to, which is what makes its `last_seen_ms` +/// ordering meaningful again. +fn persist_direct_anchor_claim(storage: &crate::storage::Storage, e: &ReachEntry) { + let socks: Vec = e + .addresses + .iter() + .filter_map(|a| a.parse::().ok()) + .map(normalize_addr) + .filter(convection_addr_ok) + .collect(); + if socks.is_empty() { + return; + } + let _ = storage.add_peer_addresses(&e.id, &socks); + let _ = storage.set_peer_anchor(&e.id, true); +} + /// Standalone initial exchange (connector side) — does NOT require conn_mgr lock. -/// Opens a bi-stream, sends our N1/N2/profile/deletes/post_ids, reads theirs. +/// Opens a bi-stream, sends our uniques announce/profile/deletes/post_ids, reads theirs. pub async fn initial_exchange_connect( storage: &Arc, our_node_id: &NodeId, @@ -8363,20 +8708,42 @@ pub async fn initial_exchange_connect( our_http_addr: Option, our_device_role: Option, our_cache_pressure: Option, + our_depth: u8, + carry_knowledge: bool, ) -> anyhow::Result { let our_payload = { let storage = storage.get().await; - let n1 = storage.build_n1_share()?; - let n2 = storage.build_n2_share()?; + // A temporary referral slot carries NO uniques exchange — explicit + // `None` on the wire rather than empty vectors, so the peer can tell + // "nothing to share" from "not sharing". + let uniques = if carry_knowledge { + Some(build_uniques_payload( + &storage, + our_node_id, + anchor_addr.as_deref(), + 0, + our_depth, + DEFAULT_PEER_DEPTH, + )?) + } else { + None + }; // Profile keyed by network id; strip persona display before send. let profile = storage.get_profile(our_node_id)? .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; - let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; + // Knowledge boundary: a temporary referral slot carries NO knowledge + // exchange (design.html §connections). Peer addresses ARE knowledge — + // up to 10 of our mesh peers with their addresses — so they are gated + // on the same flag as the uniques pools, not sent unconditionally. + let peer_addresses = if carry_knowledge { + storage.build_peer_addresses_for(our_node_id)? + } else { + Vec::new() + }; InitialExchangePayload { - n1_node_ids: n1, - n2_node_ids: n2, + uniques, profile, deletes, post_ids, @@ -8413,7 +8780,7 @@ pub async fn initial_exchange_connect( if dup { tracing::warn!(peer = hex::encode(remote_node_id), "Anchor reports duplicate identity active on network"); } - process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload).await?; + process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload, our_depth, carry_knowledge).await?; Ok(ExchangeResult::Accepted { duplicate_active: dup }) }; @@ -8442,22 +8809,44 @@ pub async fn initial_exchange_accept( our_device_role: Option, our_cache_pressure: Option, duplicate_detected: bool, + our_depth: u8, + carry_knowledge: bool, ) -> anyhow::Result<()> { let their_payload: InitialExchangePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let our_payload = { let storage = storage.get().await; - let n1 = storage.build_n1_share()?; - let n2 = storage.build_n2_share()?; + // Temporary referral slot => no uniques in either direction. + // Their advertised depth lets us skip building the terminal pool. + let peer_depth = their_payload.uniques.as_ref().map(|u| u.depth).unwrap_or(DEFAULT_PEER_DEPTH); + let uniques = if carry_knowledge { + Some(build_uniques_payload( + &storage, + our_node_id, + anchor_addr.as_deref(), + 0, + our_depth, + peer_depth, + )?) + } else { + None + }; // Profile keyed by network id; strip persona display before send. let profile = storage.get_profile(our_node_id)? .map(|p| p.sanitized_for_network_broadcast()); let deletes = storage.list_delete_records()?; let post_ids = storage.list_post_ids()?; - let peer_addresses = storage.build_peer_addresses_for(our_node_id)?; + // Knowledge boundary: a temporary referral slot carries NO knowledge + // exchange (design.html §connections). Peer addresses ARE knowledge — + // up to 10 of our mesh peers with their addresses — so they are gated + // on the same flag as the uniques pools, not sent unconditionally. + let peer_addresses = if carry_knowledge { + storage.build_peer_addresses_for(our_node_id)? + } else { + Vec::new() + }; InitialExchangePayload { - n1_node_ids: n1, - n2_node_ids: n2, + uniques, profile, deletes, post_ids, @@ -8482,7 +8871,7 @@ pub async fn initial_exchange_accept( write_typed_message(&mut send, MessageType::InitialExchange, &our_payload).await?; send.finish()?; - process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload).await?; + process_exchange_payload(storage, our_node_id, &remote_node_id, &their_payload, our_depth, carry_knowledge).await?; Ok(()) } @@ -8492,23 +8881,34 @@ async fn process_exchange_payload( our_node_id: &NodeId, remote_node_id: &NodeId, payload: &InitialExchangePayload, + our_max_bounce: u8, + store_knowledge: bool, ) -> anyhow::Result<()> { let storage = storage.get().await; - // Filter out our own ID from their N1 before storing as our N2 - let filtered_n1: Vec = payload.n1_node_ids.iter() - .filter(|nid| *nid != our_node_id) - .copied() - .collect(); - storage.set_peer_n1(remote_node_id, &filtered_n1)?; - debug!(peer = hex::encode(remote_node_id), raw = payload.n1_node_ids.len(), stored = filtered_n1.len(), "Stored peer N1 as our N2 (filtered self)"); - - let filtered_n2: Vec = payload.n2_node_ids.iter() - .filter(|nid| *nid != our_node_id) - .copied() - .collect(); - storage.set_peer_n2(remote_node_id, &filtered_n2)?; - debug!(peer = hex::encode(remote_node_id), raw = payload.n2_node_ids.len(), stored = filtered_n2.len(), "Stored peer N2 as our N3 (filtered self)"); + // Uniques announce: shift pool 1 one bounce deeper, land pool 2 at N4. + // `uniques == None` means the sender put us in a temporary referral slot; + // `store_knowledge == false` means WE put them in one. Either way the + // connection carries no knowledge, in either direction. + match (&payload.uniques, store_knowledge) { + (Some(announce), true) => { + let n = apply_uniques_to_storage( + &storage, + our_node_id, + remote_node_id, + announce, + our_max_bounce, + |_| false, + )?; + debug!(peer = hex::encode(remote_node_id), stored = n, "Applied uniques announce"); + } + _ => { + debug!( + peer = hex::encode(remote_node_id), + "No uniques exchanged (temporary referral slot)" + ); + } + } if let Some(ref profile) = payload.profile { let _ = storage.store_profile(profile); @@ -8521,11 +8921,20 @@ async fn process_exchange_payload( } } + // Third-party address hearsay: filter to routable (bugs-fixed #8, receive + // side) and MERGE rather than replace (bugs-fixed #5 — a peer must not be + // able to overwrite the address we are successfully using for a node). for pa in &payload.peer_addresses { if let Ok(nid) = crate::parse_node_id_hex(&pa.n) { - let addrs: Vec = pa.a.iter().filter_map(|a| a.parse().ok()).collect(); + let addrs: Vec = pa + .a + .iter() + .filter_map(|a| a.parse::().ok()) + .map(normalize_addr) + .filter(convection_addr_ok) + .collect(); if !addrs.is_empty() { - let _ = storage.upsert_peer(&nid, &addrs, None); + let _ = storage.add_peer_addresses(&nid, &addrs); } } } @@ -8537,12 +8946,17 @@ async fn process_exchange_payload( } } - // Process anchor's advertised address + // Process anchor's advertised address. First-party (we are connected to + // them) but still self-declared: UNION it in, never replace the address we + // dialled. `known_anchors` is written on a proven connect, not here. if let Some(ref anchor_addr_str) = payload.anchor_addr { if let Ok(sock) = anchor_addr_str.parse::() { - let _ = storage.upsert_known_anchor(remote_node_id, &[sock]); - let _ = storage.upsert_peer(remote_node_id, &[sock], None); - info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address"); + let sock = normalize_addr(sock); + if convection_addr_ok(&sock) { + let _ = storage.add_peer_addresses(remote_node_id, &[sock]); + let _ = storage.set_peer_anchor(remote_node_id, true); + info!(peer = hex::encode(remote_node_id), addr = %sock, "Stored anchor's advertised address"); + } } } @@ -8602,6 +9016,702 @@ fn now_ms() -> u64 { .as_millis() as u64 } +// --- v0.8 (A3): the single comment accept gate --- +// +// EVERY comment ingest path — the diff handler AND both pull-side header +// merges — must go through `accept_incoming_comment`. Any cap enforced +// only in the diff handler is void: an attacker just serves the same +// comment through a header pull instead. (Historically the two pull +// merges stored comments with NO verification at all.) + +/// Per-bio unexpired greeting cap (holder-side flood limit; round 8: +/// rate caps + size buckets ARE the flood limits, no PoW anywhere). +pub const MAX_GREETINGS_PER_BIO: u64 = 64; + +/// Max accepted TTL: a comment may not claim an expiry more than 366 +/// days out (ordinary TTLs top out at 365d). +pub const MAX_COMMENT_TTL_MS: u64 = 366 * 24 * 3600 * 1000; + +/// Generous ceiling on a sealed-greeting `encrypted_payload` relative to +/// the declared plaintext bucket: sealed blob (32B eph + 4B len + bucket +/// + 16B tag) → base64 (×4/3) → JSON payload wrapper → outer AEAD. +fn greeting_max_payload_bytes(body_bucket: u16) -> usize { + (body_bucket as usize) * 2 + 512 +} + +/// The single accept/drop decision for an incoming comment, shared by +/// all three ingest sites. `parent_post` is the locally-held parent (if +/// any); `policy` + `followers_set` are the parent's engagement policy +/// inputs. Returns `true` = store, `false` = drop without forwarding. +/// +/// Checks, in order (spec A3 §T9): +/// 1. identity signature (digest v2, expiry included); +/// 2. TTL sanity: v0.8 REQUIRES a TTL — `expires_at_ms == 0` rejected, +/// already-expired rejected (stateless re-ingest guard, the +/// comment-analog of `deleted_posts`), >366d rejected; +/// 3. policy (blocklist / None / FollowersOnly) + FoF four-check gated +/// on `post.fof_gating.is_some()` — NOT the `CommentPolicy` enum +/// (closing the pre-existing dead-gate gap: nothing ever set the +/// FriendsOfFriends policy, so the enum arm was unreachable); +/// 4. open-slot size buckets + per-bio greeting cap; +/// 5. registry-post entry validation + newest-wins upsert. +pub fn accept_incoming_comment( + storage: &crate::storage::Storage, + parent_post: Option<&crate::types::Post>, + policy: &crate::types::CommentPolicy, + followers_set: &HashSet, + comment: &crate::types::InlineComment, + now: u64, +) -> bool { + // 0. Tombstone injection guard: the identity signature does NOT + // cover `deleted_at`, so an attacker could take any valid signed + // comment off the chain, set `deleted_at`, and re-send it — an + // unsigned effective delete (store_comment's upsert propagates the + // field). Tombstones may ONLY travel on the signed DeleteComment op. + if comment.deleted_at.is_some() { + return false; + } + + // 1. Identity signature — v0.8: ALWAYS verified (empty = forged). + if !crate::crypto::verify_inline_comment_signature(comment) { + return false; + } + + // 2. TTL sanity. + if comment.expires_at_ms == 0 { + return false; // v0.8 requires a TTL + } + if comment.expires_at_ms <= now { + return false; // already expired — never (re-)ingest + } + if comment.expires_at_ms > now.saturating_add(MAX_COMMENT_TTL_MS) { + return false; // TTL beyond the allowed window + } + // Future-dated timestamps: allow only small clock skew. Without this + // a registration ~336d in the future permanently wins newest-wins + // against honest renewals and pins itself atop timestamp-sorted + // search results. + if comment.timestamp_ms > now.saturating_add(crate::registry::REGISTRATION_TTL_SLACK_MS) { + return false; + } + + // 3a. Ambient policy checks. + if policy.blocklist.contains(&comment.author) { + return false; + } + match policy.allow_comments { + crate::types::CommentPermission::None => return false, + crate::types::CommentPermission::FollowersOnly => { + if !followers_set.contains(&comment.author) { + return false; + } + } + crate::types::CommentPermission::Public + | crate::types::CommentPermission::FriendsOfFriends => {} + } + + // 3b. FoF four-check, gated on the POST carrying gating (not the + // policy enum). Comments claiming a slot (`pub_x_index`) on a post + // we don't hold can't be verified — drop them. + let gating = match parent_post { + Some(p) => p.fof_gating.as_ref(), + None => { + if comment.pub_x_index.is_some() { + return false; + } + None + } + }; + if let Some(gating) = gating { + // Gated post: every comment must sign under an admitted slot. + if !crate::fof::verify_fof_group_sig(comment, gating) { + return false; + } + // Revocation check: publish-time snapshot + live local table. + if let Some(idx) = comment.pub_x_index { + if let Some(pub_x) = gating.pub_post_set.get(idx as usize).copied() { + if gating.revocation_list.iter().any(|r| r.revoked_pub_x == pub_x) { + return false; + } + if storage + .is_fof_pub_x_revoked(&comment.post_id, &pub_x) + .unwrap_or(false) + { + return false; + } + } + } + + // 4. Open-slot size buckets + rate caps. + if let Some(decl) = gating.open_slot.as_ref() { + if comment.pub_x_index == Some(decl.slot_index) { + match decl.kind { + crate::types::OpenSlotKind::Greeting => { + // Superseded-bio guard (belt-and-suspenders for + // the consent off-switch): greetings are only + // accepted on the author's CURRENT bio post. Old + // bios never expire, and each would otherwise + // contribute its own greeting slot + 64-cap even + // after the author republished without a slot. + if let Some(parent) = parent_post { + if let Ok(Some(latest)) = + storage.get_latest_profile_post_id_by_author(&parent.author) + { + if latest != comment.post_id { + return false; + } + } + } + let Some(payload) = comment.encrypted_payload.as_ref() else { + return false; + }; + if payload.len() > greeting_max_payload_bytes(decl.body_bucket) { + return false; + } + if !comment.content.is_empty() { + return false; // greeting bodies are sealed-only + } + let live = storage + .count_unexpired_open_slot_comments(&comment.post_id, decl.slot_index) + .unwrap_or(u64::MAX); + if live >= MAX_GREETINGS_PER_BIO { + return false; + } + } + crate::types::OpenSlotKind::Registry => { + if comment.content.len() > decl.body_bucket as usize { + return false; + } + } + } + } + } + } + + // 5. Registry-post entry validation + newest-wins. + if comment.post_id == crate::registry::REGISTRY_POST_ID { + if crate::registry::parse_registration(&comment.content).is_err() { + return false; + } + if !crate::registry::registration_ttl_ok(comment.timestamp_ms, comment.expires_at_ms) { + return false; + } + // Newest-wins per persona: READ-ONLY check here — drop the + // incoming comment when a newer entry is already stored. The + // destructive half (hard-deleting the older row) happens in the + // storing callers AFTER a successful store_comment, so a store + // failure can never leave the persona with no entry at all. + match storage.registry_entry_is_newest( + &comment.post_id, + &comment.author, + comment.timestamp_ms, + ) { + Ok(true) => {} + _ => return false, + } + } + + true +} + +/// v0.8 (A3): DeleteComment acceptance — a self-certifying signature by +/// the comment author (verifiable by holders that never met the persona) +/// is REQUIRED. Empty/invalid signature = drop. There is deliberately no +/// sender-identity override: senders authenticate as NETWORK ids while +/// comment/post authors are POSTING ids, so an identity match can only +/// ever be forged (via attacker-controlled payload fields), never +/// legitimate. Post-author moderation will return as a SIGNED override. +pub fn delete_comment_authorized( + author: &NodeId, + post_id: &crate::types::PostId, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + !signature.is_empty() + && crate::crypto::verify_comment_delete(author, post_id, timestamp_ms, signature) +} + +/// Pull-path merge: run every comment in a fetched `BlobHeader` through +/// the accept gate and store the survivors. Called by BOTH pull-side +/// header merges (`fetch_engagement_unlocked` and +/// `fetch_engagement_from_peer`) — the two historic zero-verification +/// bypass sites. Returns the number of comments stored. +pub fn ingest_header_comments( + storage: &crate::storage::Storage, + header: &crate::types::BlobHeader, + now: u64, +) -> usize { + let parent = storage.get_post(&header.post_id).ok().flatten(); + let policy = storage + .get_comment_policy(&header.post_id) + .ok() + .flatten() + .unwrap_or_default(); + let followers: HashSet = storage + .list_public_follows() + .unwrap_or_default() + .into_iter() + .collect(); + let mut stored = 0usize; + for comment in &header.comments { + if accept_incoming_comment(storage, parent.as_ref(), &policy, &followers, comment, now) { + if storage.store_comment(comment).is_ok() { + stored += 1; + // Registry newest-wins pruning only after a SUCCESSFUL + // store (the accept gate is side-effect free). + if comment.post_id == crate::registry::REGISTRY_POST_ID { + let _ = storage.prune_older_registry_entries( + &comment.post_id, + &comment.author, + comment.timestamp_ms, + ); + } + } + } + } + // Re-derive the stored header from vetted DB state so we never + // re-serve peer-supplied comments the gate rejected (callers store + // the peer's raw header JSON before ingesting). + let _ = storage.rebuild_blob_header_from_db(&header.post_id, &header.author, now); + stored +} + +#[cfg(test)] +mod accept_gate_tests { + //! v0.8 (A3) T9: the accept-gate matrix, exercised BOTH through the + //! gate function directly (the diff-path decision) AND through + //! `ingest_header_comments` (the pull-path merge — the two historic + //! zero-verification bypass sites share this exact code). + + use super::{accept_incoming_comment, delete_comment_authorized, ingest_header_comments, + MAX_COMMENT_TTL_MS, MAX_GREETINGS_PER_BIO}; + use crate::storage::Storage; + use crate::types::{ + BlobHeader, CommentPolicy, InlineComment, NodeId, OpenSlotKind, Post, PostId, + PostVisibility, PostingIdentity, VisibilityIntent, + }; + use ed25519_dalek::SigningKey; + use rand::RngCore; + use std::collections::HashSet; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_persona(seed_byte: u8) -> (NodeId, [u8; 32]) { + let mut seed = [0u8; 32]; + seed[0] = seed_byte; + let sk = SigningKey::from_bytes(&seed); + (*sk.verifying_key().as_bytes(), seed) + } + + fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// Storage + a stored bio post with a Greeting open slot, owned by + /// a persona with a V_me. Returns (storage, bio_post_id, bio_post). + fn setup_greeting_bio(seed_byte: u8) -> (Storage, PostId, Post) { + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(seed_byte); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, + secret_seed: alice_seed, + display_name: "Alice".into(), + created_at: 1000, + }).unwrap(); + let mut v_me = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me); + s.insert_own_vouch_key(&alice_id, 1, &v_me, 1000).unwrap(); + + let built = crate::fof::build_fof_comment_gating( + &s, &alice_id, Some((OpenSlotKind::Greeting, 1024)), + ).unwrap().expect("gating built"); + let post = Post { + author: alice_id, + content: String::new(), + attachments: vec![], + timestamp_ms: 3000, + fof_gating: Some(built.gating), + supersedes_post_id: None, + }; + let post_id = crate::content::compute_post_id(&post); + s.store_post_with_intent( + &post_id, &post, &PostVisibility::Public, &VisibilityIntent::Profile, + ).unwrap(); + (s, post_id, post) + } + + /// A valid sealed greeting comment from a fresh throwaway identity. + fn make_greeting(post: &Post, post_id: &PostId, seed_byte: u8, ts: u64, expires: u64) -> InlineComment { + use base64::Engine; + let (stranger_id, stranger_seed) = make_persona(seed_byte); + let unlock = crate::fof::derive_open_slot_unlock(post, &stranger_id).expect("unlock"); + let decl = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap(); + let recipient = crate::crypto::ed25519_pubkey_to_x25519_public(&post.author).unwrap(); + let sealed = crate::crypto::seal_greeting_body( + &recipient, post_id, b"{\"v\":1}", decl.body_bucket as usize, + ).unwrap(); + let sealed_b64 = base64::engine::general_purpose::STANDARD.encode(&sealed); + crate::fof::build_fof_comment( + post_id, + &unlock, + &post.fof_gating.as_ref().unwrap().slot_binder_nonce, + &stranger_id, + &stranger_seed, + &sealed_b64, + None, + ts, + expires, + ).unwrap() + } + + fn gate(s: &Storage, post: Option<&Post>, c: &InlineComment) -> bool { + accept_incoming_comment(s, post, &CommentPolicy::default(), &HashSet::new(), c, now()) + } + + /// Pull-path variant: wrap the comment in a BlobHeader and run the + /// shared header merge; returns whether the comment got stored. + fn gate_via_pull(s: &Storage, post_id: &PostId, author: &NodeId, c: &InlineComment) -> bool { + let header = BlobHeader { + post_id: *post_id, + author: *author, + reactions: vec![], + comments: vec![c.clone()], + policy: CommentPolicy::default(), + updated_at: now(), + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: None, + }; + let before = s.get_comments_with_tombstones(post_id).unwrap().len(); + let stored = ingest_header_comments(s, &header, now()); + let after = s.get_comments_with_tombstones(post_id).unwrap().len(); + assert_eq!(stored > 0, after > before, "store count consistent"); + stored > 0 + } + + #[test] + fn good_greeting_accepted_both_paths() { + let (s, post_id, post) = setup_greeting_bio(10); + let c = make_greeting(&post, &post_id, 50, now(), now() + 40 * 24 * 3600 * 1000); + assert!(gate(&s, Some(&post), &c), "diff path accepts a valid greeting"); + assert!(gate_via_pull(&s, &post_id, &post.author, &c), "pull path accepts too"); + } + + #[test] + fn expired_and_ttl_violations_rejected_both_paths() { + let (s, post_id, post) = setup_greeting_bio(11); + let t = now(); + + // Already expired. + let c = make_greeting(&post, &post_id, 51, t - 1000, t - 1); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + + // Zero TTL (v0.8 requires one). + let c = make_greeting(&post, &post_id, 52, t, 0); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + + // TTL beyond the 366-day window. + let c = make_greeting(&post, &post_id, 53, t, t + MAX_COMMENT_TTL_MS + 60_000); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + } + + #[test] + fn tampered_expiry_fails_signature() { + let (s, post_id, post) = setup_greeting_bio(12); + let t = now(); + let mut c = make_greeting(&post, &post_id, 54, t, t + 40 * 24 * 3600 * 1000); + // A holder tries to silently extend the TTL → digest v2 catches it. + c.expires_at_ms += 1; + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + } + + #[test] + fn oversized_greeting_rejected() { + let (s, post_id, post) = setup_greeting_bio(13); + let t = now(); + let mut c = make_greeting(&post, &post_id, 55, t, t + 40 * 24 * 3600 * 1000); + // Inflate the payload past the bucket ceiling. (Signature stays + // valid — the outer identity sig doesn't cover encrypted_payload; + // the size cap must therefore hold on its own.) group_sig breaks + // too, but check the size arm by re-signing group material is + // unnecessary: EITHER rejection means drop. + c.encrypted_payload = Some(vec![0u8; 1024 * 2 + 513]); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + } + + #[test] + fn greeting_cap_enforced() { + let (s, post_id, post) = setup_greeting_bio(14); + let t = now(); + // Fill to the cap with valid greetings from distinct throwaways. + for i in 0..MAX_GREETINGS_PER_BIO { + let c = make_greeting( + &post, &post_id, 60 + (i % 180) as u8, + t + i, t + 40 * 24 * 3600 * 1000, + ); + // distinct (author, ts) needed for distinct rows; seed_byte + // wraps but ts differs so keys are unique per row. + s.store_comment(&c).unwrap(); + } + assert_eq!( + s.count_unexpired_open_slot_comments( + &post_id, + post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index, + ).unwrap(), + MAX_GREETINGS_PER_BIO, + ); + // One more greeting → over cap → dropped on both paths. (ts kept + // within the future-skew allowance so the cap arm is what fires.) + let c = make_greeting(&post, &post_id, 55, t + 60_000, t + 40 * 24 * 3600 * 1000); + assert!(!gate(&s, Some(&post), &c), "cap enforced on diff path"); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c), "cap enforced on pull path"); + } + + #[test] + fn revoked_open_slot_pub_x_rejected() { + let (s, post_id, post) = setup_greeting_bio(15); + let t = now(); + let c = make_greeting(&post, &post_id, 56, t, t + 40 * 24 * 3600 * 1000); + assert!(gate(&s, Some(&post), &c), "accepted pre-revocation"); + + // Author revokes the open slot's pub_x — the global off-switch. + let decl_idx = post.fof_gating.as_ref().unwrap().open_slot.as_ref().unwrap().slot_index; + let pub_x = post.fof_gating.as_ref().unwrap().pub_post_set[decl_idx as usize]; + s.add_fof_revocation(&post_id, &pub_x, t, 0, &[0u8; 64]).unwrap(); + + let c2 = make_greeting(&post, &post_id, 57, t + 1, t + 40 * 24 * 3600 * 1000); + assert!(!gate(&s, Some(&post), &c2), "revoked pub_x rejected (diff)"); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c2), "revoked pub_x rejected (pull)"); + } + + #[test] + fn fof_marked_comment_without_held_parent_rejected() { + let (s, post_id, post) = setup_greeting_bio(16); + let t = now(); + let c = make_greeting(&post, &post_id, 58, t, t + 40 * 24 * 3600 * 1000); + // Parent unknown → slot claims can't be verified → drop. + assert!(!gate(&s, None, &c)); + } + + /// Registration matrix: valid entry accepted; wrong TTL rejected; + /// bad shape rejected; older-than-stored dropped — both paths. + #[test] + fn registration_matrix_both_paths() { + let s = temp_storage(); + crate::registry::materialize_registry_post(&s).unwrap(); + let registry = s.get_post(&crate::registry::REGISTRY_POST_ID).unwrap().unwrap(); + let (persona_id, persona_seed) = make_persona(70); + let t = now(); + + let build_reg = |content: &str, ts: u64, expires: u64| -> InlineComment { + let unlock = crate::fof::derive_open_slot_unlock(®istry, &persona_id).unwrap(); + let group_sig = { + use ed25519_dalek::Signer; + let signer = SigningKey::from_bytes(&unlock.priv_x_seed); + let mut to_sign = Vec::new(); + to_sign.extend_from_slice(content.as_bytes()); + to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); + to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); + signer.sign(&to_sign).to_bytes().to_vec() + }; + let signature = crate::crypto::sign_comment( + &persona_seed, &persona_id, &crate::registry::REGISTRY_POST_ID, + content, ts, None, expires, + ); + InlineComment { + author: persona_id, + post_id: crate::registry::REGISTRY_POST_ID, + content: content.to_string(), + timestamp_ms: ts, + signature, + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(unlock.slot_index), + group_sig: Some(group_sig), + encrypted_payload: None, + expires_at_ms: expires, + } + }; + + let good = r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#; + + // Wrong TTL (not fixed-30d): rejected. + let wrong_ttl = build_reg(good, t, t + crate::registry::REGISTRATION_TTL_MS / 2); + assert!(!gate(&s, Some(®istry), &wrong_ttl)); + assert!(!gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &wrong_ttl)); + + // Bad shape: rejected. + let bad_shape = build_reg( + r#"{"v":1,"name":""}"#, t, t + crate::registry::REGISTRATION_TTL_MS, + ); + assert!(!gate(&s, Some(®istry), &bad_shape)); + + // Valid: accepted via the pull path (stores it). + let valid = build_reg(good, t, t + crate::registry::REGISTRATION_TTL_MS); + assert!(gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &valid)); + + // Newer entry replaces it. (Mirror the diff-arm caller: the gate + // is side-effect free; pruning runs after a successful store.) + let newer = build_reg(good, t + 1000, t + 1000 + crate::registry::REGISTRATION_TTL_MS); + assert!(gate(&s, Some(®istry), &newer)); + s.store_comment(&newer).unwrap(); + s.prune_older_registry_entries( + &crate::registry::REGISTRY_POST_ID, &persona_id, newer.timestamp_ms, + ).unwrap(); + + // Older-than-stored: dropped on both paths (newest wins). + let older = build_reg(good, t + 500, t + 500 + crate::registry::REGISTRATION_TTL_MS); + assert!(!gate(&s, Some(®istry), &older)); + assert!(!gate_via_pull(&s, &crate::registry::REGISTRY_POST_ID, ®istry.author, &older)); + + // Exactly one live row remains, at the newest timestamp. + let rows = s.get_comments(&crate::registry::REGISTRY_POST_ID).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].timestamp_ms, t + 1000); + } + + #[test] + fn delete_comment_authorization_matrix() { + let (author_id, author_seed) = make_persona(80); + let (wrong_author, wrong_seed) = make_persona(81); + let post_id: PostId = [9u8; 32]; + let ts = 12345u64; + + let sig = crate::crypto::sign_comment_delete(&author_seed, &author_id, &post_id, ts); + + // Self-certified: accepted (regardless of sender — no sender + // identity is consulted at all). + assert!(delete_comment_authorized(&author_id, &post_id, ts, &sig)); + // Unsigned: rejected — there is NO sender override anymore (the + // old one compared against attacker-controlled payload.author). + assert!(!delete_comment_authorized(&author_id, &post_id, ts, &[])); + // Garbage signature: rejected. + assert!(!delete_comment_authorized(&author_id, &post_id, ts, &[0u8; 64])); + // Signature by a different key than the named author: rejected. + let wrong_sig = crate::crypto::sign_comment_delete(&wrong_seed, &wrong_author, &post_id, ts); + assert!(!delete_comment_authorized(&author_id, &post_id, ts, &wrong_sig)); + } + + /// Blocker regression: a valid signed comment with an injected + /// `deleted_at` (the identity signature does not cover it) must be + /// dropped on both paths, and must NOT tombstone a stored copy. + #[test] + fn injected_tombstone_rejected_both_paths() { + let (s, post_id, post) = setup_greeting_bio(20); + let t = now(); + let c = make_greeting(&post, &post_id, 90, t, t + 40 * 24 * 3600 * 1000); + + // Store the honest copy first. + assert!(gate(&s, Some(&post), &c)); + s.store_comment(&c).unwrap(); + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + + // Attacker re-sends the same signed bytes with deleted_at set. + let mut forged = c.clone(); + forged.deleted_at = Some(t); + assert!(!gate(&s, Some(&post), &forged), "diff path drops injected tombstone"); + assert!( + !gate_via_pull(&s, &post_id, &post.author, &forged), + "pull path drops injected tombstone" + ); + + // The stored copy is still live — no unsigned delete happened. + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + } + + /// Future-dated comments beyond the skew allowance are rejected + /// (ranking/newest-wins squatting guard). + #[test] + fn far_future_timestamp_rejected() { + let (s, post_id, post) = setup_greeting_bio(21); + let t = now(); + let day = 24 * 3600 * 1000u64; + // 30 days in the future — well past the 5-minute skew allowance. + let c = make_greeting(&post, &post_id, 91, t + 30 * day, t + 60 * day); + assert!(!gate(&s, Some(&post), &c)); + assert!(!gate_via_pull(&s, &post_id, &post.author, &c)); + // Within the skew allowance: accepted. + let c2 = make_greeting(&post, &post_id, 92, t + 60_000, t + 60 * day); + assert!(gate(&s, Some(&post), &c2)); + } + + /// Consent off-switch belt-and-suspenders: greetings on a bio that + /// has been superseded by a newer profile post are rejected. + #[test] + fn greeting_on_superseded_bio_rejected() { + let (s, old_bio_id, old_bio) = setup_greeting_bio(22); + let t = now(); + + // Author republishes the bio WITHOUT a greeting slot (consent + // withdrawn) — a newer Profile post by the same author. + let new_bio = Post { + author: old_bio.author, + content: String::new(), + attachments: vec![], + timestamp_ms: old_bio.timestamp_ms + 1000, + fof_gating: None, + supersedes_post_id: None, + }; + let new_bio_id = crate::content::compute_post_id(&new_bio); + s.store_post_with_intent( + &new_bio_id, &new_bio, &PostVisibility::Public, &VisibilityIntent::Profile, + ).unwrap(); + + // A greeting aimed at the OLD bio is now rejected on both paths. + let c = make_greeting(&old_bio, &old_bio_id, 93, t, t + 40 * 24 * 3600 * 1000); + assert!(!gate(&s, Some(&old_bio), &c), "diff path rejects superseded-bio greeting"); + assert!( + !gate_via_pull(&s, &old_bio_id, &old_bio.author, &c), + "pull path rejects superseded-bio greeting" + ); + } + + /// Stateless re-ingest guard: an expired comment served by a stale + /// holder does NOT re-seed after local expiry sweep. + #[test] + fn expired_comment_does_not_reseed_via_pull() { + let (s, post_id, post) = setup_greeting_bio(17); + let t = now(); + // Short-lived greeting, stored while valid. + let c = make_greeting(&post, &post_id, 59, t - 10_000, t + 5_000); + s.store_comment(&c).unwrap(); + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + + // Time passes; the sweep hard-deletes it. + let later = t + 10_000; + assert_eq!(s.expire_comments(later).unwrap(), 1); + assert!(s.get_comments(&post_id).unwrap().is_empty()); + + // A stale holder re-serves the exact same signed comment via a + // header pull → the gate's expiry check drops it statelessly. + let header = BlobHeader { + post_id, + author: post.author, + reactions: vec![], + comments: vec![c], + policy: CommentPolicy::default(), + updated_at: later, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: None, + }; + assert_eq!(ingest_header_comments(&s, &header, later), 0); + assert!(s.get_comments_with_tombstones(&post_id).unwrap().is_empty()); + } +} + #[cfg(test)] mod tests { use super::{scanner_semaphore, PendingConnectGuard}; @@ -8659,3 +9769,806 @@ mod tests { assert!(set.lock().unwrap().is_empty(), "all guards dropped — set should be empty"); } } + +#[cfg(test)] +mod uniques_tests { + //! v0.8 Iteration C: slot pool + two-pool uniques announce. + + use super::{ + announce_digest, apply_uniques_to_storage, build_uniques_payload, decide_slot, + entries_to_slice, slice_to_entries, MAX_UNIQUES_PAYLOAD, TEMP_REFERRAL_TTL_MS, + }; + use crate::protocol::{unpack_ids, AnchorEntry, UniquesAnnouncePayload, UniquesSlice}; + use crate::storage::Storage; + use crate::types::{IdClass, MeshSlot, NodeId, ReachEntry}; + + fn temp_storage() -> Storage { + let dir = std::env::temp_dir().join(format!("itsgoin-uniq-{}", rand::random::())); + std::fs::create_dir_all(&dir).unwrap(); + Storage::open(dir.join("test.db")).unwrap() + } + + fn nid(b: u8) -> NodeId { + [b; 32] + } + + // ---- Slot accounting ---- + + #[test] + fn mesh_slots_fill_before_temp_referral_slots() { + // Under the cap: always a mesh slot. + assert_eq!(decide_slot(0, 20, 0, 10, 0), Some(MeshSlot::Mesh)); + assert_eq!(decide_slot(19, 20, 0, 10, 0), Some(MeshSlot::Mesh)); + + // At the cap: temp referral, ABOVE the cap — the mesh pool is untouched. + let s = decide_slot(20, 20, 0, 10, 1_000).unwrap(); + assert_eq!(s, MeshSlot::TempReferral { expires_at_ms: 1_000 + TEMP_REFERRAL_TTL_MS }); + assert!(s.is_temp()); + + // Temp band full too: refuse. Note the total (20 + 10) exceeds the mesh + // cap by exactly the referral band — that is the point. + assert_eq!(decide_slot(20, 20, 10, 10, 0), None); + + // Mobile numbers. + assert_eq!(decide_slot(14, 15, 4, 4, 0), Some(MeshSlot::Mesh)); + assert_eq!(decide_slot(15, 15, 4, 4, 0), None); + } + + #[test] + fn graduation_requires_an_already_free_mesh_slot() { + // A temp peer can only become mesh when a mesh slot is genuinely free. + // Full mesh => the arriving peer is temp, and nothing is evicted. + assert!(decide_slot(20, 20, 0, 10, 0).unwrap().is_temp()); + // One mesh peer drops => the next allocation is mesh again. + assert!(decide_slot(19, 20, 1, 10, 0).unwrap().is_mesh()); + } + + // ---- Wire shape ---- + + #[test] + fn anchor_entries_carry_addresses_everything_else_is_bare() { + let plain = nid(1); + let persona = nid(2); + let anchor = nid(3); + + let slice = entries_to_slice(&[ + ReachEntry::node(plain), + ReachEntry::author(persona), + ReachEntry::anchor(anchor, vec!["203.0.113.5:4433".into()]), + ]); + + assert_eq!(unpack_ids(&slice.nodes), vec![plain]); + assert_eq!(unpack_ids(&slice.authors), vec![persona]); + assert_eq!(slice.anchors.len(), 1); + assert_eq!(slice.anchors[0].i, hex::encode(anchor)); + assert_eq!(slice.anchors[0].a, vec!["203.0.113.5:4433".to_string()]); + + // The wire type has no address field on the bare arrays at all, so a + // non-anchor entry structurally cannot carry one. Round-trip proves it. + let back = slice_to_entries(&slice); + for e in &back { + if e.id == anchor { + assert!(e.is_anchor && !e.addresses.is_empty()); + } else { + assert!(!e.is_anchor, "{:?} must not be an anchor", e.id); + assert!(e.addresses.is_empty(), "non-anchor entries are bare"); + } + } + + // Classes survive the round trip — a persona never becomes connectable. + assert_eq!( + back.iter().find(|e| e.id == persona).unwrap().id_class, + IdClass::Author + ); + assert_eq!(back.iter().find(|e| e.id == plain).unwrap().id_class, IdClass::Node); + } + + #[test] + fn unroutable_anchor_addresses_are_filtered_off_the_wire() { + // bugs-fixed #8: private/loopback ranges never get announced. + let anchor = nid(3); + let slice = entries_to_slice(&[ReachEntry::anchor( + anchor, + vec!["10.0.0.5:4433".into(), "192.168.1.9:4433".into()], + )]); + assert!(slice.anchors.is_empty(), "private addresses must not be announced"); + // It degrades to a bare node entry rather than vanishing. + assert_eq!(unpack_ids(&slice.nodes), vec![anchor]); + } + + // ---- Build / apply ---- + + #[test] + fn built_announce_places_pools_correctly_and_omits_n4() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + let mesh_peer = nid(2); + let n2 = nid(3); + let n3 = nid(4); + let n4 = nid(5); + + s.add_mesh_peer(&mesh_peer).unwrap(); + s.set_reach(&reporter, 2, &[ReachEntry::node(n2)]).unwrap(); + s.set_reach(&reporter, 3, &[ReachEntry::node(n3)]).unwrap(); + s.set_reach(&reporter, 4, &[ReachEntry::node(n4)]).unwrap(); + + let p = build_uniques_payload(&s, &us, None, 7, 4, 4).unwrap(); + assert!(p.full); + assert_eq!(p.seq, 7); + assert_eq!(p.depth, 4); + assert_eq!(p.fwd.len(), 3); + + assert_eq!(unpack_ids(&p.fwd[0].nodes), vec![us], "fwd[0] is N0 = ourselves"); + assert!(unpack_ids(&p.fwd[1].nodes).contains(&mesh_peer), "fwd[1] is our own uniques"); + assert_eq!(unpack_ids(&p.fwd[2].nodes), vec![n2], "fwd[2] is our N2"); + assert_eq!(unpack_ids(&p.term.nodes), vec![n3], "term is our N3"); + + // The whole point: N4 is terminal and never re-announced. + let all: Vec = p + .fwd + .iter() + .chain(std::iter::once(&p.term)) + .flat_map(|sl| { + unpack_ids(&sl.nodes) + .into_iter() + .chain(unpack_ids(&sl.authors)) + .collect::>() + }) + .collect(); + assert!(!all.contains(&n4), "N4 must never appear in an announcement"); + } + + #[test] + fn shallow_peer_depth_strips_the_terminal_pool() { + let s = temp_storage(); + let us = nid(0); + s.set_reach(&nid(1), 3, &[ReachEntry::node(nid(9))]).unwrap(); + + let full = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); + assert!(!full.term.is_empty()); + + // Peer advertised depth 3 — nowhere to put N4, so don't build it. + let trimmed = build_uniques_payload(&s, &us, None, 1, 4, 3).unwrap(); + assert!(trimmed.term.is_empty()); + // Pool 1 is unaffected — only the terminal pool is skipped. + assert_eq!(unpack_ids(&trimmed.fwd[0].nodes), vec![us]); + } + + #[test] + fn apply_shifts_pool1_one_bounce_deeper_and_lands_pool2_at_n4() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + + let their_n1 = nid(10); + let their_n2 = nid(11); + let their_n3 = nid(12); + + let payload = UniquesAnnouncePayload { + seq: 3, + full: true, + fwd: vec![ + entries_to_slice(&[ReachEntry::node(reporter)]), + entries_to_slice(&[ReachEntry::node(their_n1)]), + entries_to_slice(&[ReachEntry::node(their_n2)]), + ], + term: entries_to_slice(&[ReachEntry::node(their_n3)]), + depth: 4, + }; + + let stored = apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); + assert_eq!(stored, 3); + + assert_eq!(s.find_in_n2(&their_n1).unwrap(), vec![reporter], "fwd[1] -> our N2"); + assert_eq!(s.find_in_n3(&their_n2).unwrap(), vec![reporter], "fwd[2] -> our N3"); + assert_eq!(s.find_in_n4(&their_n3).unwrap(), vec![reporter], "term -> our N4"); + + // N4 is populated and usable... + let hits = s.find_any_reachable(&[their_n3]).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].2, 4); + + // ...but it does not leak back out into what WE announce. + let ours = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); + let announced: Vec = ours + .fwd + .iter() + .chain(std::iter::once(&ours.term)) + .flat_map(|sl| unpack_ids(&sl.nodes)) + .collect(); + assert!(announced.contains(&their_n1)); + assert!(announced.contains(&their_n2)); + assert!( + !announced.contains(&their_n3), + "an ID learned at N4 must never be forwarded (no N5)" + ); + } + + #[test] + fn shallow_device_drops_the_terminal_pool_on_receipt() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + let deep = nid(12); + + let payload = UniquesAnnouncePayload { + seq: 1, + full: true, + fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()], + term: entries_to_slice(&[ReachEntry::node(deep)]), + depth: 4, + }; + + // max_bounce = 3 (mobile): the terminal pool is discarded, not stored. + apply_uniques_to_storage(&s, &us, &reporter, &payload, 3, |_| false).unwrap(); + assert_eq!(s.count_distinct_n4().unwrap(), 0); + + // max_bounce = 4 (desktop): stored. + apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); + assert_eq!(s.count_distinct_n4().unwrap(), 1); + } + + #[test] + fn apply_filters_self_and_already_connected_peers() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + let already = nid(2); + let fresh = nid(3); + + let payload = UniquesAnnouncePayload { + seq: 1, + full: true, + fwd: vec![ + UniquesSlice::default(), + entries_to_slice(&[ + ReachEntry::node(us), + ReachEntry::node(already), + ReachEntry::node(fresh), + ]), + UniquesSlice::default(), + ], + term: UniquesSlice::default(), + depth: 4, + }; + + apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |n| *n == already).unwrap(); + assert!(s.find_in_n2(&us).unwrap().is_empty(), "never index ourselves"); + assert!(s.find_in_n2(&already).unwrap().is_empty(), "already meshed, not a candidate"); + assert_eq!(s.find_in_n2(&fresh).unwrap(), vec![reporter]); + } + + /// Third-party anchor entries are an INDEX, not an address record. They are + /// mineable from the pools and nothing else — no `peers` row, no + /// `known_anchors` seed, no `is_anchor` latch — because nothing has proven + /// the address belongs to that node ID. + #[test] + fn received_anchor_entries_are_index_only_never_authoritative() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + let anchor = nid(7); + + let payload = UniquesAnnouncePayload { + seq: 1, + full: true, + fwd: vec![ + UniquesSlice::default(), + entries_to_slice(&[ReachEntry::anchor(anchor, vec!["198.51.100.9:4433".into()])]), + UniquesSlice::default(), + ], + term: UniquesSlice::default(), + depth: 4, + }; + apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); + + // Mineable from the pools — this IS the anchor directory. + let mined = s.list_pool_anchors(10).unwrap(); + assert_eq!(mined.len(), 1); + assert_eq!(mined[0].0, anchor); + + // But authoritative address state is untouched: a hostile peer must not + // be able to overwrite our address for any node (bugs-fixed #5, + // remotely triggerable) or re-inflate the bootstrap cache from gossip. + assert!(s.get_peer_record(&anchor).unwrap().is_none(), "no peers row from hearsay"); + assert!(s.list_known_anchors().unwrap().is_empty(), "bootstrap cache untouched"); + assert!(!s.is_peer_anchor(&anchor).unwrap(), "no is_anchor latch from hearsay"); + } + + /// Round-4/5: "a node knocked down from 20 peers to 4 still holds 16 dead + /// peers' pools full of anchor addresses to reconnect through." Disconnect + /// must NOT wipe the reporter's rows; a new handshake overwrites them. + #[test] + fn a_departed_reporters_pool_is_retained_until_a_new_handshake_replaces_it() { + let s = temp_storage(); + let reporter = nid(1); + let anchor = nid(7); + let other = nid(8); + + s.set_reach( + &reporter, + 2, + &[ + ReachEntry::anchor(anchor, vec!["198.51.100.9:4433".into()]), + ReachEntry::node(other), + ], + ) + .unwrap(); + + // The peer leaves. `disconnect_peer` no longer clears anything, so the + // mine and the growth-candidate reserve survive the exact mass- + // disconnect the retention rule was written for. + assert_eq!(s.list_pool_anchors(10).unwrap().len(), 1); + assert!(!s.score_n2_candidates_batch().unwrap().is_empty()); + assert!(s.anchor_density().unwrap() > 0.0); + + // It comes back with a different view: set_reach DELETEs per + // (reporter, bounce) first, so rows are replaced, never duplicated. + s.set_reach(&reporter, 2, &[ReachEntry::node(other)]).unwrap(); + assert!(s.list_pool_anchors(10).unwrap().is_empty(), "overwritten by the new handshake"); + assert_eq!(s.find_in_n2(&other).unwrap(), vec![reporter]); + } + + /// THE terminal-pool invariant, round-tripped through an ANCHOR entry — + /// the case the earlier tests missed. An anchor learned at bounce 4 must + /// appear in NO slice of the next announce. Before the fix it was mirrored + /// into `peers.is_anchor` and then re-emitted by `build_own_uniques` at + /// pool-1 depth 1, resetting its horizon on every hop forever. + #[test] + fn an_anchor_known_only_at_n4_is_never_re_announced() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + let deep_anchor = nid(9); + + let payload = UniquesAnnouncePayload { + seq: 1, + full: true, + fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()], + term: entries_to_slice(&[ReachEntry::anchor( + deep_anchor, + vec!["198.51.100.9:4433".into()], + )]), + depth: 4, + }; + apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); + assert_eq!(s.find_in_n4(&deep_anchor).unwrap(), vec![reporter], "stored at N4"); + + let out = build_uniques_payload(&s, &us, None, 2, 4, 4).unwrap(); + for (i, slice) in out.fwd.iter().enumerate() { + let ids: Vec = slice_to_entries(slice).into_iter().map(|e| e.id).collect(); + assert!(!ids.contains(&deep_anchor), "N4 anchor leaked into fwd[{}]", i); + } + let term_ids: Vec = + slice_to_entries(&out.term).into_iter().map(|e| e.id).collect(); + assert!(!term_ids.contains(&deep_anchor), "N4 anchor leaked into the terminal pool"); + } + + /// `fwd` is assumed to be exactly 3 slices by every reader. A 6-slice + /// payload used to write rows at bounce 5 and 6 — depths with no semantics + /// anywhere, invisible to the announce builder but very visible to + /// `find_any_reachable`, the growth scorer and `anchor_density`. + #[test] + fn a_payload_with_the_wrong_fwd_length_is_rejected() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + let mut fwd = vec![UniquesSlice::default(); 6]; + fwd[4] = entries_to_slice(&[ReachEntry::node(nid(42))]); + let payload = UniquesAnnouncePayload { + seq: 1, + full: true, + fwd, + term: UniquesSlice::default(), + depth: 4, + }; + assert!(apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).is_err()); + let max: i64 = s.max_stored_bounce().unwrap(); + assert_eq!(max, 0, "nothing written at all"); + } + + /// The §layers size budget must be ENFORCED, not merely documented: with + /// `MAX_PAYLOAD` at 64 MB a single announce could decode into ~1.5M IDs and + /// the same number of row inserts, per bounce, per reporter. + #[test] + fn oversized_slices_are_truncated_and_a_built_announce_stays_under_the_ceiling() { + let s = temp_storage(); + let us = nid(0); + let reporter = nid(1); + + let cap = crate::storage::uniques_accept_cap(2); + let flood: Vec = (0..(cap + 5_000)) + .map(|i| { + let mut id = [0u8; 32]; + id[0] = (i % 251) as u8; + id[1] = ((i / 251) % 251) as u8; + id[2] = (i / (251 * 251)) as u8; + ReachEntry::node(id) + }) + .collect(); + let payload = UniquesAnnouncePayload { + seq: 1, + full: true, + fwd: vec![ + UniquesSlice::default(), + entries_to_slice(&flood), + UniquesSlice::default(), + ], + term: UniquesSlice::default(), + depth: 4, + }; + let stored = apply_uniques_to_storage(&s, &us, &reporter, &payload, 4, |_| false).unwrap(); + assert!(stored <= cap, "accepted {} rows, cap is {}", stored, cap); + + // ...and what WE build back out of that index stays under the wire cap. + let out = build_uniques_payload(&s, &us, None, 2, 4, 4).unwrap(); + let bytes = serde_json::to_vec(&out).unwrap().len(); + assert!(bytes < MAX_UNIQUES_PAYLOAD, "built announce is {} bytes", bytes); + assert!(out.fwd[2].len() <= crate::storage::UNIQUES_BUILD_CAP_SHALLOW); + } + + /// bugs-fixed #8, receive side. The send side always filtered; the receive + /// side did not, so a peer could seed us with `127.0.0.1:22` / `10.x` / + /// `192.168.x` and we would store and later dial them. + #[test] + fn unroutable_addresses_are_dropped_on_receipt() { + let bad = UniquesSlice { + nodes: crate::protocol::pack_ids(&[]), + authors: crate::protocol::pack_ids(&[]), + anchors: vec![AnchorEntry { + i: hex::encode(nid(5)), + a: vec!["127.0.0.1:22".into(), "10.0.0.1:4433".into(), "192.168.1.1:443".into()], + }], + }; + let entries = slice_to_entries(&bad); + assert_eq!(entries.len(), 1); + assert!(!entries[0].is_anchor, "degrades to a bare node entry"); + assert!(entries[0].addresses.is_empty()); + } + + /// The address invariant must be STRUCTURAL. `id_class` and `is_anchor` are + /// independent columns, so an author-class row carrying the anchor flag + /// must still serialise as a bare persona ID — never next to a device + /// address (design.html §21). + #[test] + fn an_author_class_entry_never_serialises_as_an_address_bearing_anchor() { + let e = ReachEntry { + id: nid(3), + id_class: IdClass::Author, + is_anchor: true, + addresses: vec!["198.51.100.9:4433".into()], + }; + let slice = entries_to_slice(&[e]); + assert!(slice.anchors.is_empty(), "no address-bearing entry for a persona ID"); + assert_eq!(unpack_ids(&slice.authors), vec![nid(3)]); + assert!(unpack_ids(&slice.nodes).is_empty()); + } + + #[test] + fn announce_digest_ignores_seq_but_tracks_content() { + let s = temp_storage(); + let us = nid(0); + let a = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); + let b = build_uniques_payload(&s, &us, None, 99, 4, 4).unwrap(); + assert_eq!(announce_digest(&a), announce_digest(&b), "seq must not affect the digest"); + + s.add_mesh_peer(&nid(5)).unwrap(); + let c = build_uniques_payload(&s, &us, None, 1, 4, 4).unwrap(); + assert_ne!(announce_digest(&a), announce_digest(&c)); + } +} + +#[cfg(test)] +mod convection_tests { + //! v0.8 Iteration C: anchor convection + the stochastic growth trigger. + //! + //! The window rules and the action choice are pure functions precisely so + //! they can be tested without standing up an iroh endpoint. + + use super::{ + choose_convection_action, convection_should_serve, convection_weights, + convection_window_admit, convection_window_pick, convection_window_viable, + disconnect_response, ConvectionAction, ConvectionEntry, DisconnectResponse, + CONVECTION_ENTRY_MAX_AGE_MS, CONVECTION_HANDOUT_CAP, CONVECTION_REFERRALS, + CONVECTION_WINDOW_SIZE, + }; + use crate::protocol::ConvectionClass; + use crate::storage::Storage; + use crate::types::{NodeId, ReachEntry}; + use std::collections::VecDeque; + + fn nid(b: u8) -> NodeId { + [b; 32] + } + + fn addr(b: u8) -> Vec { + vec![format!("203.0.113.{}:4433", b)] + } + + fn window() -> VecDeque { + VecDeque::new() + } + + fn nobody_live(_: &NodeId) -> bool { + false + } + + fn temp_storage() -> Storage { + let dir = std::env::temp_dir().join(format!("itsgoin-conv-{}", rand::random::())); + std::fs::create_dir_all(&dir).unwrap(); + Storage::open(dir.join("test.db")).unwrap() + } + + // ---- Rolling window: 2 before, 2 after ---- + + #[test] + fn a_caller_gets_the_two_most_recent_prior_callers() { + let mut w = window(); + // Callers arrive 1, 2, 3. + for i in 1..=3u8 { + convection_window_admit(&mut w, nid(i), addr(i), 1_000 + i as u64); + } + // Caller 4 arrives and asks. It must get the two MOST RECENT priors + // (3 and 2), not the oldest and not all three. + let picked = convection_window_pick(&mut w, &nid(4), CONVECTION_REFERRALS, 2_000, nobody_live); + let got: Vec = picked.iter().map(|e| e.node_id[0]).collect(); + assert_eq!(got, vec![3, 2], "most recent first, exactly 2"); + } + + #[test] + fn an_entry_is_handed_to_two_callers_then_rotates_out() { + let mut w = window(); + convection_window_admit(&mut w, nid(1), addr(1), 1_000); + convection_window_admit(&mut w, nid(2), addr(2), 1_001); + + // Caller A takes both. + let a = convection_window_pick(&mut w, &nid(9), 2, 2_000, nobody_live); + assert_eq!(a.len(), 2); + assert_eq!(w.len(), 2, "one hand-out each, still under the cap"); + + // Caller B takes both again — that is hand-out #2 for each. + let b = convection_window_pick(&mut w, &nid(9), 2, 2_001, nobody_live); + assert_eq!(b.len(), 2); + assert_eq!(CONVECTION_HANDOUT_CAP, 2); + assert!(w.is_empty(), "both entries rotated out after 2 hand-outs"); + + // Caller C gets nothing from the window. + let c = convection_window_pick(&mut w, &nid(9), 2, 2_002, nobody_live); + assert!(c.is_empty()); + } + + #[test] + fn a_caller_is_never_referred_to_itself() { + let mut w = window(); + convection_window_admit(&mut w, nid(1), addr(1), 1_000); + let picked = convection_window_pick(&mut w, &nid(1), 2, 1_500, nobody_live); + assert!(picked.is_empty()); + assert_eq!(w.len(), 1, "and its own entry is not spent"); + } + + #[test] + fn still_connected_callers_are_preferred_over_more_recent_cold_ones() { + let mut w = window(); + // 1 is older but still connected; 2 and 3 are newer and gone. + convection_window_admit(&mut w, nid(1), addr(1), 1_000); + convection_window_admit(&mut w, nid(2), addr(2), 2_000); + convection_window_admit(&mut w, nid(3), addr(3), 3_000); + + let live = |n: &NodeId| n[0] == 1; + let picked = convection_window_pick(&mut w, &nid(9), 2, 4_000, live); + let got: Vec = picked.iter().map(|e| e.node_id[0]).collect(); + assert_eq!(got[0], 1, "the live caller leads even though it is oldest"); + assert_eq!(got[1], 3, "then the most recent of the rest"); + } + + #[test] + fn a_refreshed_caller_gets_a_new_handout_budget_and_moves_to_the_back() { + let mut w = window(); + convection_window_admit(&mut w, nid(1), addr(1), 1_000); + convection_window_admit(&mut w, nid(2), addr(2), 1_001); + // Spend one hand-out on caller 1. + let _ = convection_window_pick(&mut w, &nid(2), 1, 1_500, |n: &NodeId| n[0] == 1); + assert_eq!(w.iter().find(|e| e.node_id == nid(1)).unwrap().handouts, 1); + + // Caller 1 calls again — it is fresh material once more. + convection_window_admit(&mut w, nid(1), addr(1), 2_000); + let e = w.iter().find(|e| e.node_id == nid(1)).unwrap(); + assert_eq!(e.handouts, 0); + assert_eq!(w.back().unwrap().node_id, nid(1), "newest sits at the back"); + assert_eq!(w.len(), 2, "refreshed, not duplicated"); + } + + #[test] + fn the_window_is_bounded_in_both_count_and_age() { + let mut w = window(); + for i in 1..=(CONVECTION_WINDOW_SIZE as u8 + 5) { + convection_window_admit(&mut w, nid(i), addr(i), 1_000 + i as u64); + } + assert_eq!(w.len(), CONVECTION_WINDOW_SIZE); + assert_eq!(w.front().unwrap().node_id[0], 6, "oldest fell off the front"); + + // A stale entry is not worth handing out — it costs the next caller a + // punch timeout. + let mut w2 = window(); + convection_window_admit(&mut w2, nid(1), addr(1), 0); + let late = CONVECTION_ENTRY_MAX_AGE_MS + 1; + assert_eq!(convection_window_viable(&w2, &nid(9), late), 0); + assert!(convection_window_pick(&mut w2, &nid(9), 2, late, nobody_live).is_empty()); + } + + #[test] + fn an_address_less_caller_is_not_admitted() { + let mut w = window(); + convection_window_admit(&mut w, nid(1), vec![], 1_000); + assert!(w.is_empty(), "nothing to hand out means nothing to store"); + } + + // ---- Request classes ---- + + #[test] + fn entry_is_always_served_and_top_up_is_refusable() { + // ENTRY: served with an empty window, served while saturated. A node + // that cannot get in cannot come back. + assert!(convection_should_serve(ConvectionClass::Entry, 0, false)); + assert!(convection_should_serve(ConvectionClass::Entry, 0, true)); + assert!(convection_should_serve(ConvectionClass::Entry, 5, true)); + + // TOP-UP: refused when there is nothing fresh to circulate, or when we + // are saturated. Served otherwise — top-ups are the convection medium, + // not freeloading, so they are not throttled beyond that. + assert!(!convection_should_serve(ConvectionClass::TopUp, 0, false)); + assert!(!convection_should_serve(ConvectionClass::TopUp, 5, true)); + assert!(convection_should_serve(ConvectionClass::TopUp, 1, false)); + + // And the class bit is derived purely from "can this node function". + assert!(ConvectionClass::for_mesh_count(1).is_entry()); + assert!(!ConvectionClass::for_mesh_count(2).is_entry()); + } + + // ---- Adaptive weights ---- + + #[test] + fn the_anchor_density_prior_sets_the_anchor_arm() { + let sparse = convection_weights(0.0, 1.0, 0.5); + let dense = convection_weights(0.30, 1.0, 0.5); + assert!( + dense.anchor > sparse.anchor, + "abundant anchors (IPv6 world) => anchor-led growth: {} vs {}", + dense.anchor, + sparse.anchor + ); + // Same code in both worlds: with no anchors known the anchor arm is + // small but never zero — we still have to be able to discover one. + assert!(sparse.anchor > 0.0); + assert!(dense.mesh > 0.0, "and the mesh arm never disappears either"); + } + + #[test] + fn refusal_feedback_shifts_weight_away_from_the_anchor_arm() { + let density = 0.30; + let served = convection_weights(density, 1.0, 0.5); + // AIMD: a refusal halves the bias. + let after_refusal = convection_weights(density, 0.5, 0.5); + let after_two = convection_weights(density, 0.25, 0.5); + assert!(after_refusal.anchor < served.anchor); + assert!(after_two.anchor < after_refusal.anchor); + assert_eq!(after_refusal.mesh, served.mesh, "mesh arm is untouched…"); + // …so the SHARE going to mesh rises, which is the point. + let share = |w: super::ConvectionWeights| w.mesh / (w.nothing + w.anchor + w.mesh); + assert!(share(after_refusal) > share(served)); + + // A roll that picked the anchor while healthy now picks mesh. + let roll = 0.45; + assert_eq!(choose_convection_action(roll, density, 1.0, 0.5), ConvectionAction::AskAnchor); + assert_eq!(choose_convection_action(roll, density, 0.1, 0.5), ConvectionAction::AskMesh); + } + + #[test] + fn the_action_leans_toward_acting_the_further_under_target_we_are() { + // Far under target: do-nothing weight is zero, so EVERY roll acts. + for i in 0..20 { + let roll = i as f64 / 20.0; + assert_ne!( + choose_convection_action(roll, 0.2, 1.0, 0.0), + ConvectionAction::Nothing, + "empty mesh must always act (roll {})", + roll + ); + } + // Near target: do-nothing dominates. + let full = convection_weights(0.2, 1.0, 1.0); + assert!(full.nothing > full.anchor + full.mesh); + + // No threshold anywhere: the choice varies continuously with the roll, + // and all three arms are reachable at a mid fill ratio. + let mut seen = std::collections::HashSet::new(); + for i in 0..100 { + seen.insert(choose_convection_action(i as f64 / 100.0, 0.3, 1.0, 0.6)); + } + assert_eq!(seen.len(), 3, "all three arms reachable: {:?}", seen); + } + + // ---- Recovery is not stochastic ---- + + #[test] + fn recovery_pre_empts_the_dice_and_never_rolls() { + // Below 2 mesh peers: always recover, whatever the slot situation. + for mesh in 0..2usize { + for free in [0usize, 5, 20] { + assert_eq!( + disconnect_response(mesh, free), + DisconnectResponse::Recover, + "mesh {} free {}", + mesh, + free + ); + } + } + // At or above 2: stochastic when there is room, idle when there isn't. + assert_eq!(disconnect_response(2, 1), DisconnectResponse::Roll); + assert_eq!(disconnect_response(19, 1), DisconnectResponse::Roll); + assert_eq!(disconnect_response(20, 0), DisconnectResponse::Idle); + } + + // ---- Anchors mined from the pools ---- + + #[test] + fn anchor_density_and_the_anchor_directory_come_from_the_pools() { + let s = temp_storage(); + let reporter = nid(200); + // 4 node-class uniques, 1 of them anchor-flagged with an address. + let entries = vec![ + ReachEntry::anchor(nid(1), vec!["203.0.113.1:4433".into()]), + ReachEntry::node(nid(2)), + ReachEntry::node(nid(3)), + ReachEntry::node(nid(4)), + // Author-class IDs are NOT connect targets and must not skew the + // prior or appear in the directory. + ReachEntry::author(nid(5)), + ]; + s.set_reach(&reporter, 2, &entries).unwrap(); + + let density = s.anchor_density().unwrap(); + assert!( + (density - 0.25).abs() < 1e-9, + "1 anchor of 4 node-class uniques, authors excluded: got {}", + density + ); + + // The pools double as the anchor directory — no separate registry, and + // only anchor entries carry an address to mine. + let anchors = s.list_pool_anchors(16).unwrap(); + assert_eq!(anchors.len(), 1); + assert_eq!(anchors[0].0, nid(1)); + assert_eq!(anchors[0].1, vec!["203.0.113.1:4433".to_string()]); + + // Zero knowledge => zero prior, and the weights still work. + let empty = temp_storage(); + assert_eq!(empty.anchor_density().unwrap(), 0.0); + assert!(empty.list_pool_anchors(16).unwrap().is_empty()); + assert!(convection_weights(0.0, 1.0, 0.5).anchor > 0.0); + } + + #[test] + fn a_disconnected_reporters_anchor_addresses_survive_for_the_cold_start_mine() { + // Round-4/5: "cold reconnect mines the RETAINED pools for anchor + // addresses" — the pool itself is the thing that survives, because + // disconnect no longer wipes it. That, not a peers/known_anchors + // mirror, is what keeps the adaptive anchor-density prior meaningful + // after churn (`anchor_density` counts `reachable` rows only). + let s = temp_storage(); + let reporter = nid(200); + let anchor = nid(1); + let sa: std::net::SocketAddr = "203.0.113.7:4433".parse().unwrap(); + s.set_reach(&reporter, 2, &[ReachEntry::anchor(anchor, vec![sa.to_string()])]).unwrap(); + + // The reporter departs. Nothing is cleared. + assert_eq!(s.list_pool_anchors(16).unwrap().len(), 1, "the mine survives the disconnect"); + assert!(s.anchor_density().unwrap() > 0.0, "and so does the adaptive prior"); + + // Only the 5h staleness sweep removes it. + s.prune_reach(0).unwrap(); + assert!(s.list_pool_anchors(16).unwrap().is_empty()); + } +} diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 9130d6b..dbf3f0a 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -923,15 +923,15 @@ pub fn rotate_group_key( // --- CDN Manifest Signing --- /// Compute the canonical digest for an AuthorManifest (for signing/verification). -/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json) +/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ previous_posts_json ‖ following_posts_json) +/// v0.8: author_addresses removed from AuthorManifest (and from this digest) — +/// posting-identity manifests must not carry device addresses. fn manifest_digest(manifest: &crate::types::AuthorManifest) -> [u8; 32] { let mut hasher = blake3::Hasher::new(); hasher.update(&manifest.post_id); hasher.update(&manifest.author); hasher.update(&manifest.created_at.to_le_bytes()); hasher.update(&manifest.updated_at.to_le_bytes()); - let addrs_json = serde_json::to_string(&manifest.author_addresses).unwrap_or_default(); - hasher.update(addrs_json.as_bytes()); let prev_json = serde_json::to_string(&manifest.previous_posts).unwrap_or_default(); hasher.update(prev_json.as_bytes()); let next_json = serde_json::to_string(&manifest.following_posts).unwrap_or_default(); @@ -1010,8 +1010,20 @@ pub fn random_slot_noise(size: usize) -> Vec { // --- Engagement crypto --- const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/v1"; -const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1"; +/// v0.8 (A3): digest v2 — expires_at_ms enters the signed digest so a +/// comment's TTL can never be silently extended by holders. v0.8 has +/// ZERO wire-compat obligations (zero-users ruling), so v1 is gone. +const COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v2"; const REACTION_SIGN_CONTEXT: &str = "itsgoin/reaction-sig/v1"; +/// v0.8 (A3): self-certifying comment-delete signature context. +const COMMENT_DELETE_CONTEXT: &str = "itsgoin/comment-delete/v1"; +/// v0.8 (A3): derivable open-slot V_x context (design §27). Anyone can +/// compute V_open from the post alone: author pubkey + slot_binder_nonce. +const OPEN_SLOT_VX_CONTEXT: &str = "itsgoin/open-slot-vx/v1"; +/// v0.8 (A3): greeting-body seal contexts. The bio post id is baked into +/// the derivation (same domain-separation style as vouch grants). +const GREETING_KEY_CONTEXT: &str = "itsgoin/greeting/v1/key"; +const GREETING_NONCE_CONTEXT: &str = "itsgoin/greeting/v1/nonce"; /// Encrypt a private reaction payload (only the post author can decrypt). /// Uses X25519 DH between reactor and author, then ChaCha20-Poly1305. @@ -1067,25 +1079,31 @@ pub fn decrypt_private_reaction( String::from_utf8(plaintext).map_err(|e| anyhow::anyhow!("invalid utf8: {}", e)) } -/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms). +/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || +/// timestamp_ms [|| ref:ref_post_id] || expires:expires_at_ms). +/// +/// Digest v2 (A3): `expires_at_ms` is part of the comment's identity — +/// holders cannot extend a comment's life without invalidating the sig. fn comment_digest( author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, + expires_at_ms: u64, ) -> blake3::Hash { let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); hasher.update(author); hasher.update(post_id); hasher.update(content.as_bytes()); hasher.update(×tamp_ms.to_le_bytes()); - // Domain-separated append: `None` yields the same digest as the v0.6.1 - // scheme, so plain comments keep verifying; `Some(ref)` adds the ref id. + // Domain-separated appends (same pattern as the v0.6.2 `ref:` field). if let Some(rid) = ref_post_id { hasher.update(b"ref:"); hasher.update(rid); } + hasher.update(b"expires:"); + hasher.update(&expires_at_ms.to_le_bytes()); hasher.finalize() } @@ -1096,13 +1114,14 @@ pub fn sign_comment( content: &str, timestamp_ms: u64, ref_post_id: Option<&PostId>, + expires_at_ms: u64, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); - let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } -/// Verify a comment's ed25519 signature. +/// Verify a comment's ed25519 signature (digest v2, expiry included). pub fn verify_comment_signature( author: &NodeId, post_id: &PostId, @@ -1110,6 +1129,7 @@ pub fn verify_comment_signature( timestamp_ms: u64, signature: &[u8], ref_post_id: Option<&PostId>, + expires_at_ms: u64, ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; @@ -1117,10 +1137,177 @@ pub fn verify_comment_signature( let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; - let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } +/// Convenience: verify an `InlineComment`'s identity signature. +pub fn verify_inline_comment_signature(comment: &crate::types::InlineComment) -> bool { + verify_comment_signature( + &comment.author, + &comment.post_id, + &comment.content, + comment.timestamp_ms, + &comment.signature, + comment.ref_post_id.as_ref(), + comment.expires_at_ms, + ) +} + +// --- v0.8 (A3): self-certifying comment deletes --- + +fn comment_delete_digest(author: &NodeId, post_id: &PostId, timestamp_ms: u64) -> blake3::Hash { + let mut hasher = blake3::Hasher::new_derive_key(COMMENT_DELETE_CONTEXT); + hasher.update(author); + hasher.update(post_id); + hasher.update(×tamp_ms.to_le_bytes()); + hasher.finalize() +} + +/// Sign a comment delete: ed25519 by the comment author's posting key +/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id || +/// timestamp_ms). Verifiable from the delete op alone — works on holders +/// that never met the persona (registry unregister path). +pub fn sign_comment_delete( + seed: &[u8; 32], + author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, +) -> Vec { + let signing_key = SigningKey::from_bytes(seed); + let digest = comment_delete_digest(author, post_id, timestamp_ms); + signing_key.sign(digest.as_bytes()).to_bytes().to_vec() +} + +/// Verify a self-certifying comment-delete signature. +pub fn verify_comment_delete( + author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; }; + let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; + let digest = comment_delete_digest(author, post_id, timestamp_ms); + verifying_key.verify(digest.as_bytes(), &sig).is_ok() +} + +// --- v0.8 (A3): derivable open-slot V_x (design §27) --- + +/// Derive the open slot's V_x from the post alone: +/// `V_open = blake3::derive_key("itsgoin/open-slot-vx/v1", +/// author_posting_pubkey || slot_binder_nonce)`. +/// Computable by any stranger (author is on the Post, nonce is in the +/// gating); distinct per publish. The CEK recovered through an open slot +/// is PUBLIC — the outer CEK layer is camouflage only; confidentiality +/// (greetings) comes from the inner HPKE-style seal. +pub fn derive_open_slot_vx( + author_posting_pubkey: &NodeId, + slot_binder_nonce: &[u8; 32], +) -> [u8; 32] { + let mut input = [0u8; 64]; + input[..32].copy_from_slice(author_posting_pubkey); + input[32..].copy_from_slice(slot_binder_nonce); + blake3::derive_key(OPEN_SLOT_VX_CONTEXT, &input) +} + +// --- v0.8 (A3): sealed greeting bodies (bucketed) --- + +fn derive_greeting_key_nonce( + shared_secret: &[u8; 32], + bio_post_id: &PostId, +) -> ([u8; 32], [u8; 12]) { + let key_ctx = format!("{}/{}", GREETING_KEY_CONTEXT, hex_lower(bio_post_id)); + let nonce_ctx = format!("{}/{}", GREETING_NONCE_CONTEXT, hex_lower(bio_post_id)); + let key = blake3::derive_key(&key_ctx, shared_secret); + let nonce_full = blake3::derive_key(&nonce_ctx, shared_secret); + let mut nonce = [0u8; 12]; + nonce.copy_from_slice(&nonce_full[..12]); + (key, nonce) +} + +/// Generate a fresh x25519 keypair `(priv_scalar, pub)` for greeting +/// reply keys / ephemeral seal keys. Same ed25519→x25519 derivation path +/// the rest of the codebase uses. +pub fn generate_x25519_keypair() -> ([u8; 32], [u8; 32]) { + generate_vouch_batch_ephemeral() +} + +/// Seal a greeting body to `recipient_x25519_pub`, padded to exactly +/// `body_bucket` plaintext bytes. Output layout: +/// `eph_x25519_pub(32) || ChaCha20-Poly1305(len_u32_le || body || pad)`. +/// Total ciphertext length is `32 + 4 + body_bucket + 16` for every +/// greeting in the same bucket — all greetings on a bio are +/// size-identical ciphertexts. +/// +/// The recipient key is the bio author's posting key converted via +/// `ed25519_pubkey_to_x25519_public` for original greetings, or the raw +/// per-greeting `reply_pubkey` for replies. `bio_post_id` is the post the +/// comment is placed ON (binds the seal to that post). +pub fn seal_greeting_body( + recipient_x25519_pub: &[u8; 32], + bio_post_id: &PostId, + plaintext: &[u8], + body_bucket: usize, +) -> Result> { + if plaintext.len() > body_bucket { + bail!( + "greeting body too large: {} > bucket {}", + plaintext.len(), + body_bucket + ); + } + let (eph_priv, eph_pub) = generate_vouch_batch_ephemeral(); + let shared = x25519_dh(&eph_priv, recipient_x25519_pub); + let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); + + // len prefix + body + random pad to the bucket. + let mut padded = Vec::with_capacity(4 + body_bucket); + padded.extend_from_slice(&(plaintext.len() as u32).to_le_bytes()); + padded.extend_from_slice(plaintext); + let mut pad = vec![0u8; body_bucket - plaintext.len()]; + rand::rng().fill_bytes(&mut pad); + padded.extend_from_slice(&pad); + + let cipher = ChaCha20Poly1305::new_from_slice(&key) + .map_err(|e| anyhow::anyhow!("greeting cipher init: {}", e))?; + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), padded.as_slice()) + .map_err(|e| anyhow::anyhow!("greeting seal: {}", e))?; + + let mut out = Vec::with_capacity(32 + ciphertext.len()); + out.extend_from_slice(&eph_pub); + out.extend_from_slice(&ciphertext); + Ok(out) +} + +/// Try to open a sealed greeting body with the recipient's x25519 +/// private scalar. Returns `None` on any shape/AEAD failure (not sealed +/// to this key, or tampered). +pub fn open_greeting_body( + recipient_x25519_priv: &[u8; 32], + bio_post_id: &PostId, + sealed: &[u8], +) -> Option> { + if sealed.len() < 32 + 4 + 16 { + return None; + } + let mut eph_pub = [0u8; 32]; + eph_pub.copy_from_slice(&sealed[..32]); + let shared = x25519_dh(recipient_x25519_priv, &eph_pub); + let (key, nonce) = derive_greeting_key_nonce(&shared, bio_post_id); + let cipher = ChaCha20Poly1305::new_from_slice(&key).ok()?; + let padded = cipher.decrypt(Nonce::from_slice(&nonce), &sealed[32..]).ok()?; + if padded.len() < 4 { + return None; + } + let real_len = u32::from_le_bytes(padded[..4].try_into().ok()?) as usize; + if 4 + real_len > padded.len() { + return None; + } + Some(padded[4..4 + real_len].to_vec()) +} + /// Sign a reaction: ed25519 over BLAKE3(reactor || post_id || emoji || timestamp_ms). pub fn sign_reaction( seed: &[u8; 32], @@ -1359,20 +1546,133 @@ mod tests { let ref_post = [2u8; 32]; let content = "preview"; let ts = 1000u64; + let exp = 5000u64; // Signature including ref_post_id. - let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post)); + let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post), exp); // Verifies only when the ref is supplied. - assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post))); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp)); // Same signature must NOT verify when the ref is dropped (binding). - assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None)); + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp)); // Nor when the ref is swapped. let other_ref = [3u8; 32]; - assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref))); + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref), exp)); - // Plain-comment signature still works (backward compat with v0.6.1). - let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None); - assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None)); + // Plain-comment signature still works. + let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None, exp); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None, exp)); + } + + /// A3: digest v2 covers expires_at_ms — mutating the expiry flips + /// verification (no silent TTL extension possible). + #[test] + fn comment_signature_binds_expiry() { + let (seed, nid) = make_keypair(8); + let post_id = [1u8; 32]; + let ts = 1000u64; + let exp = 90_000_000u64; + + let sig = sign_comment(&seed, &nid, &post_id, "hi", ts, None, exp); + assert!(verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp)); + // Extended expiry must fail. + assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, exp + 1)); + // Stripped expiry (0) must fail. + assert!(!verify_comment_signature(&nid, &post_id, "hi", ts, &sig, None, 0)); + } + + /// A3: self-certifying comment-delete roundtrip + wrong-key reject. + #[test] + fn comment_delete_sign_verify() { + let (seed, nid) = make_keypair(9); + let (_mseed, mallory) = make_keypair(10); + let post_id = [4u8; 32]; + let ts = 123_456u64; + + let sig = sign_comment_delete(&seed, &nid, &post_id, ts); + assert_eq!(sig.len(), 64); + assert!(verify_comment_delete(&nid, &post_id, ts, &sig)); + // Wrong author key. + assert!(!verify_comment_delete(&mallory, &post_id, ts, &sig)); + // Wrong tuple. + assert!(!verify_comment_delete(&nid, &post_id, ts + 1, &sig)); + assert!(!verify_comment_delete(&nid, &[5u8; 32], ts, &sig)); + // Garbage signature. + assert!(!verify_comment_delete(&nid, &post_id, ts, &[0u8; 64])); + assert!(!verify_comment_delete(&nid, &post_id, ts, &[])); + } + + /// A3: derive_open_slot_vx determinism + distinctness across nonces + /// and authors. + #[test] + fn open_slot_vx_deterministic_and_distinct() { + let (_s1, a1) = make_keypair(21); + let (_s2, a2) = make_keypair(22); + let n1 = [0xAA; 32]; + let n2 = [0xBB; 32]; + + let v = derive_open_slot_vx(&a1, &n1); + assert_eq!(v, derive_open_slot_vx(&a1, &n1), "deterministic"); + assert_ne!(v, derive_open_slot_vx(&a1, &n2), "distinct per nonce"); + assert_ne!(v, derive_open_slot_vx(&a2, &n1), "distinct per author"); + } + + /// A3: seal/open_greeting_body roundtrip, exact-bucket ciphertext + /// length equality across different plaintext lengths, tamper reject, + /// wrong-key reject. + #[test] + fn greeting_body_roundtrip_and_bucketing() { + let (bob_priv, bob_pub) = make_persona_x25519(30); + let (carol_priv, _carol_pub) = make_persona_x25519(31); + let bio_post_id: PostId = [0x77; 32]; + const BUCKET: usize = 1024; + + let body = br#"{"v":1,"sender_persona":"aa","sender_name":"Al","text":"hi","return_path":"bb","reply_pubkey":"cc"}"#; + let sealed = seal_greeting_body(&bob_pub, &bio_post_id, body, BUCKET).unwrap(); + assert_eq!(sealed.len(), 32 + 4 + BUCKET + 16, "fixed-size ciphertext"); + + // Different plaintext length → same ciphertext length. + let sealed2 = seal_greeting_body(&bob_pub, &bio_post_id, b"x", BUCKET).unwrap(); + assert_eq!(sealed.len(), sealed2.len(), "bucket hides length"); + + // Recipient opens. + let opened = open_greeting_body(&bob_priv, &bio_post_id, &sealed).unwrap(); + assert_eq!(opened, body.to_vec()); + + // Non-recipient cannot. + assert!(open_greeting_body(&carol_priv, &bio_post_id, &sealed).is_none()); + + // Wrong bio post id cannot. + assert!(open_greeting_body(&bob_priv, &[0x88; 32], &sealed).is_none()); + + // Tampered ciphertext fails AEAD. + let mut tampered = sealed.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0x01; + assert!(open_greeting_body(&bob_priv, &bio_post_id, &tampered).is_none()); + + // Oversized body refused. + assert!(seal_greeting_body(&bob_pub, &bio_post_id, &vec![0u8; BUCKET + 1], BUCKET).is_err()); + } + + /// A3: a reply sealed to a fresh `reply_pubkey` opens with the stored + /// reply private key and NOT with the recipient's long-term key. + #[test] + fn greeting_reply_sealed_to_fresh_key_only() { + // Long-term persona key of the greeting's original sender. + let (longterm_priv, _longterm_pub) = make_persona_x25519(40); + // Fresh per-greeting reply keypair minted by that sender. + let (reply_priv, reply_pub) = generate_x25519_keypair(); + let return_path_post: PostId = [0x99; 32]; + + let reply_body = b"hello back"; + let sealed = seal_greeting_body(&reply_pub, &return_path_post, reply_body, 1024).unwrap(); + + // Stored fresh reply key opens it. + let opened = open_greeting_body(&reply_priv, &return_path_post, &sealed).unwrap(); + assert_eq!(opened, reply_body.to_vec()); + + // Long-term key does NOT. + assert!(open_greeting_body(&longterm_priv, &return_path_post, &sealed).is_none()); } #[test] @@ -1383,7 +1683,6 @@ mod tests { let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, - author_addresses: vec!["10.0.0.1:4433".to_string()], created_at: 1000, updated_at: 2000, previous_posts: vec![ManifestEntry { @@ -1400,6 +1699,97 @@ mod tests { assert!(verify_manifest_signature(&manifest)); } + /// v0.8 (A2): manifests must never carry device addresses — neither the + /// author-signed part nor the CdnManifest wire wrapper. + #[test] + fn test_manifest_carries_no_addresses() { + use crate::types::{AuthorManifest, CdnManifest, ManifestEntry}; + let (seed, node_id) = make_keypair(1); + let (_hseed, host_id) = make_keypair(2); + + let mut manifest = AuthorManifest { + post_id: [42u8; 32], + author: node_id, + created_at: 1000, + updated_at: 2000, + previous_posts: vec![ManifestEntry { + post_id: [1u8; 32], + timestamp_ms: 900, + has_attachments: true, + }], + following_posts: vec![], + signature: vec![], + }; + manifest.signature = sign_manifest(&seed, &manifest); + assert!(verify_manifest_signature(&manifest)); + + let author_json = serde_json::to_string(&manifest).unwrap(); + assert!( + !author_json.contains("addresses"), + "AuthorManifest JSON must not contain any address field: {author_json}" + ); + + let cdn = CdnManifest { + author_manifest: manifest, + host: host_id, + }; + let cdn_json = serde_json::to_string(&cdn).unwrap(); + assert!( + !cdn_json.contains("addresses"), + "CdnManifest JSON must not contain any address field: {cdn_json}" + ); + assert!(!cdn_json.contains("downstream_count")); + assert!(!cdn_json.contains("\"source\"")); + } + + /// A1: crypto pairings must use the MATCHED persona's (id, seed) tuple. + /// The historical bug paired the default posting secret with the NETWORK + /// NodeId — a pair that can never unwrap anything. + #[test] + fn test_cek_unwrap_requires_matching_persona_pair() { + let (author_seed, author_id) = make_keypair(1); + let (persona_seed, persona_id) = make_keypair(2); // recipient persona + let (network_seed, network_id) = make_keypair(9); // device network key + + let (_encrypted, wrapped_keys) = + encrypt_post("hi", &author_seed, &author_id, &[persona_id]).unwrap(); + + // Mismatched pair (persona seed + network id): recipient lookup fails. + let r = unwrap_cek_for_recipient(&persona_seed, &network_id, &author_id, &wrapped_keys).unwrap(); + assert!(r.is_none(), "network id must not appear in recipients"); + + // Network pair entirely: also fails. + let r = unwrap_cek_for_recipient(&network_seed, &network_id, &author_id, &wrapped_keys).unwrap(); + assert!(r.is_none()); + + // Matched persona pair: succeeds. + let r = unwrap_cek_for_recipient(&persona_seed, &persona_id, &author_id, &wrapped_keys).unwrap(); + assert!(r.is_some(), "matched persona (id, seed) pair must unwrap the CEK"); + } + + /// A1 (revocation): rewrap_visibility works with the AUTHOR persona's + /// (seed, id) pair — and cannot work with the network pair the old code + /// passed. + #[test] + fn test_rewrap_visibility_uses_author_persona_pair() { + let (author_seed, author_id) = make_keypair(3); // non-default persona + let (_b_seed, bob_id) = make_keypair(4); + let (_c_seed, carol_id) = make_keypair(5); + let (network_seed, network_id) = make_keypair(9); + + let (_encrypted, wrapped_keys) = + encrypt_post("secret", &author_seed, &author_id, &[bob_id, carol_id]).unwrap(); + + // Old bug shape: (default/other secret, network id) — must fail. + assert!(rewrap_visibility(&network_seed, &network_id, &wrapped_keys, &[author_id, bob_id]).is_err()); + + // Correct: the author persona's own pair — succeeds and re-wraps + // for the reduced recipient set. + let new_wrapped = rewrap_visibility(&author_seed, &author_id, &wrapped_keys, &[author_id, bob_id]).unwrap(); + assert!(new_wrapped.iter().any(|wk| wk.recipient == bob_id)); + assert!(!new_wrapped.iter().any(|wk| wk.recipient == carol_id), "revoked recipient must be gone"); + } + #[test] fn test_forged_manifest_rejected() { use crate::types::AuthorManifest; @@ -1409,7 +1799,6 @@ mod tests { let mut manifest = AuthorManifest { post_id: [42u8; 32], author: node_id, - author_addresses: vec![], created_at: 1000, updated_at: 2000, previous_posts: vec![], diff --git a/crates/core/src/export.rs b/crates/core/src/export.rs index 66768dd..3594b0e 100644 --- a/crates/core/src/export.rs +++ b/crates/core/src/export.rs @@ -91,7 +91,14 @@ pub async fn export_data( let (posts, blob_cids) = if scope == ExportScope::IdentityOnly { (vec![], vec![]) } else { - gather_posts(storage, node_id).await? + // Own posts are authored by POSTING identities (personas) — the + // network NodeId never authors posts. Export across all personas. + let author_ids: Vec = { + let s = storage.get().await; + s.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect() + }; + gather_posts(storage, &author_ids).await? }; let (follows, profiles, settings) = if scope == ExportScope::Everything { @@ -245,10 +252,10 @@ pub async fn export_data( }) } -/// Gather own posts and their blob CIDs. +/// Gather own posts (authored by any of our posting identities) and their blob CIDs. async fn gather_posts( storage: &StoragePool, - node_id: &NodeId, + author_ids: &[NodeId], ) -> anyhow::Result<(Vec, Vec<[u8; 32]>)> { let s = storage.get().await; let posts_with_vis = s.list_posts_with_visibility()?; @@ -257,7 +264,7 @@ async fn gather_posts( for (id, post, vis) in &posts_with_vis { // Only export our own posts - if post.author != *node_id { + if !author_ids.contains(&post.author) { continue; } diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index f43cd90..d50d849 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -20,7 +20,11 @@ use rand::RngCore; use crate::crypto::{seal_wrap_slot, SealedWrapSlot}; use crate::storage::Storage; -use crate::types::{FoFCommentGating, NodeId, WrapSlot}; +use crate::types::{FoFCommentGating, NodeId, OpenSlotDecl, OpenSlotKind, WrapSlot}; + +/// v0.8 (A3): hard cap on a declared open-slot body bucket. Anything +/// larger on receive is presumed attacker-shaped. +pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096; /// Build the `FoFCommentGating` block for a post about to be published /// under `CommentPermission::FriendsOfFriends`. The author's keyring @@ -33,9 +37,16 @@ use crate::types::{FoFCommentGating, NodeId, WrapSlot}; /// /// Side effect: this function is pure; no storage writes. The caller /// owns persisting the resulting Post. +/// `open_slot`: when `Some((kind, body_bucket))`, one EXTRA real slot is +/// sealed under the derivable `V_open = +/// derive_open_slot_vx(author, slot_binder_nonce)` and declared in +/// `FoFCommentGating.open_slot` (A3 — greeting/registry open slots). +/// The open slot is a REAL wrap slot sealed by the normal path; what +/// makes it open is only that its V_x is derivable by anyone. pub fn build_fof_comment_gating( storage: &Storage, author_persona_id: &NodeId, + open_slot: Option<(OpenSlotKind, u16)>, ) -> Result> { // Gather the author's keyring with provenance: (V_x, owner, epoch). // The author's own V_me appears with owner=author_persona_id. @@ -70,6 +81,9 @@ pub fn build_fof_comment_gating( // (slot_index, owner, epoch, pub_x) afterward for provenance. enum EntryKind { Real { v_x_owner: NodeId, v_x_epoch: u32 }, + /// A3: the derivable open slot (greeting/registry). Not part of + /// V_x provenance — its key is public by construction. + Open, Dummy, } let mut entries: Vec<(EntryKind, [u8; 32], WrapSlot)> = Vec::with_capacity(tagged_keys.len()); @@ -88,6 +102,25 @@ pub fn build_fof_comment_gating( entries.push((EntryKind::Real { v_x_owner: *owner, v_x_epoch: *epoch }, pub_x, slot)); } + // A3: seal the open slot (if requested) under the derivable V_open. + if open_slot.is_some() { + let v_open = crate::crypto::derive_open_slot_vx(author_persona_id, &slot_binder_nonce); + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let pub_x = *signing_key.verifying_key().as_bytes(); + let sealed: SealedWrapSlot = seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &seed)?; + entries.push(( + EntryKind::Open, + pub_x, + WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }, + )); + } + // Pad to bucket with dummies. let bucket = crate::profile::next_vouch_batch_bucket(entries.len()); let mut rng = rand::rng(); @@ -117,24 +150,39 @@ pub fn build_fof_comment_gating( let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len()); let mut wrap_slots: Vec = Vec::with_capacity(entries.len()); let mut real_slot_provenance: Vec = Vec::new(); + let mut open_slot_index: Option = None; for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() { - if let EntryKind::Real { v_x_owner, v_x_epoch } = kind { - real_slot_provenance.push(RealSlotProvenance { - slot_index: idx as u32, - v_x_owner, - v_x_epoch, - pub_x, - }); + match kind { + EntryKind::Real { v_x_owner, v_x_epoch } => { + real_slot_provenance.push(RealSlotProvenance { + slot_index: idx as u32, + v_x_owner, + v_x_epoch, + pub_x, + }); + } + EntryKind::Open => open_slot_index = Some(idx as u32), + EntryKind::Dummy => {} } pub_post_set.push(pub_x); wrap_slots.push(slot); } + let open_slot_decl = match (open_slot, open_slot_index) { + (Some((kind, body_bucket)), Some(slot_index)) => Some(OpenSlotDecl { + slot_index, + kind, + body_bucket, + }), + _ => None, + }; + let gating = FoFCommentGating { slot_binder_nonce, pub_post_set, wrap_slots, revocation_list: Vec::new(), + open_slot: open_slot_decl, }; Ok(Some(FoFCommentGatingBuilt { @@ -142,6 +190,7 @@ pub fn build_fof_comment_gating( cek, slot_binder_nonce, real_slot_provenance, + open_slot_index, })) } @@ -164,6 +213,9 @@ pub struct FoFCommentGatingBuilt { /// `own_post_slot_provenance` for later cascade revocations. /// Each entry: (slot_index, v_x_owner, v_x_epoch, pub_x). pub real_slot_provenance: Vec, + /// A3: index of the derivable open slot, when one was requested. + /// Mirrors `gating.open_slot.slot_index`. + pub open_slot_index: Option, } /// One entry per real (non-dummy) slot in a published FoF post. @@ -314,6 +366,37 @@ pub fn sweep_unreadable_on_new_v_x( Ok(unlocked) } +/// v0.8 (A3): the stranger-side derive+open helper. Derives the post's +/// open-slot `V_open` from the post alone and opens the DECLARED slot, +/// returning a `PostUnlock` usable with [`build_fof_comment`]. +/// `persona_id` is filled with the caller-provided commenter id +/// (typically a throwaway identity minted per greeting). +/// +/// Returns `None` when the post has no gating, no open-slot declaration, +/// or the declared slot doesn't open under the derived key (malformed or +/// key-burned open slot = the author's global off-switch). +pub fn derive_open_slot_unlock( + post: &crate::types::Post, + commenter_persona_id: &NodeId, +) -> Option { + let gating = post.fof_gating.as_ref()?; + let decl = gating.open_slot.as_ref()?; + let slot = gating.wrap_slots.get(decl.slot_index as usize)?; + let v_open = crate::crypto::derive_open_slot_vx(&post.author, &gating.slot_binder_nonce); + let opened = crate::crypto::open_wrap_slot( + &v_open, + &gating.slot_binder_nonce, + &slot.read_ciphertext, + &slot.sign_ciphertext, + )?; + Some(PostUnlock { + persona_id: *commenter_persona_id, + slot_index: decl.slot_index, + cek: opened.cek, + priv_x_seed: opened.priv_x_seed, + }) +} + /// Inner helper: prefilter + AEAD-open against a single V_x. fn try_unlock_with_v_x( gating: &crate::types::FoFCommentGating, @@ -376,6 +459,7 @@ pub fn build_fof_comment( body: &str, parent_comment_id: Option<[u8; 32]>, now_ms: u64, + expires_at_ms: u64, ) -> Result { use ed25519_dalek::{Signer, SigningKey}; @@ -411,6 +495,7 @@ pub fn build_fof_comment( "", now_ms, None, + expires_at_ms, ); Ok(crate::types::InlineComment { @@ -424,6 +509,7 @@ pub fn build_fof_comment( pub_x_index: Some(unlock.slot_index), group_sig: Some(group_sig), encrypted_payload: Some(encrypted), + expires_at_ms, }) } @@ -439,7 +525,14 @@ pub fn verify_fof_group_sig( use ed25519_dalek::{Signature, Verifier, VerifyingKey}; let Some(pub_x_index) = comment.pub_x_index else { return false; }; let Some(group_sig) = comment.group_sig.as_ref() else { return false; }; - let Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; }; + // A3: plaintext open-slot comments (registry entries) carry their + // body in `content` with no encrypted_payload — the group_sig then + // covers the content bytes instead. FoF/greeting comments always + // carry Some(encrypted_payload) as before. + let encrypted_payload: &[u8] = match comment.encrypted_payload.as_deref() { + Some(p) => p, + None => comment.content.as_bytes(), + }; let idx = pub_x_index as usize; if idx >= gating.pub_post_set.len() { return false; } if group_sig.len() != 64 { return false; } @@ -479,9 +572,10 @@ pub fn decrypt_fof_comment_payload( // re-propagated via neighbor-manifest diffs. /// Maximum allowed wrap_slots / pub_post_set entries on an incoming -/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256; -/// at a 4096-vouchee max realistic graph that's ~4224. Round up for -/// headroom; anything larger is presumed attacker-shaped. +/// FoF post. The bucket rule (`next_vouch_batch_bucket` in profile.rs) is +/// deterministic: ≤8 → 8; ≤256 → next power of two; >256 → next multiple +/// of 128. A 4096-vouchee realistic max buckets to 4096; 8192 gives 2x +/// headroom, anything larger is presumed attacker-shaped. const MAX_FOF_WRAP_SLOTS: usize = 8192; /// Maximum allowed revocation_list entries in a t=0 published gating @@ -552,6 +646,24 @@ pub fn validate_fof_gating_on_receive(post: &crate::types::Post) -> Result<()> { ); } + // A3: open-slot declaration sanity. + if let Some(decl) = gating.open_slot.as_ref() { + if decl.slot_index as usize >= gating.wrap_slots.len() { + anyhow::bail!( + "open_slot.slot_index {} out of bounds ({} slots)", + decl.slot_index, + gating.wrap_slots.len(), + ); + } + if decl.body_bucket == 0 || decl.body_bucket > MAX_OPEN_SLOT_BODY_BUCKET { + anyhow::bail!( + "open_slot.body_bucket {} out of range (1..={})", + decl.body_bucket, + MAX_OPEN_SLOT_BODY_BUCKET, + ); + } + } + Ok(()) } @@ -955,7 +1067,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("gating built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("gating built"); // Real count = 2 (own + Bob). Bucket = 8 (minimum floor). assert_eq!(built.gating.pub_post_set.len(), 8); assert_eq!(built.gating.wrap_slots.len(), 8); @@ -1009,7 +1121,7 @@ mod tests { let s = temp_storage(); let (alice_id, _) = make_persona(5); // No V_me inserted. - let built = build_fof_comment_gating(&s, &alice_id).unwrap(); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap(); assert!(built.is_none(), "no V_me → no gating block"); } @@ -1031,7 +1143,7 @@ mod tests { s.insert_received_vouch_key(&alice_id, &bob_id, 1, &same_key, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 1, &same_key, 3000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); // Unique-key count = 2 (V_me_alice + same_key). Bucket = 8. // We can't assert real count directly without exposing internals, // but we can confirm exactly two distinct successful unwraps: @@ -1088,7 +1200,7 @@ mod tests { alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); // Alice publishes the gating block. - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); let parent_post_id = [0xCC; 32]; // Bob's device: has his V_me (which is v_x_bob since he handed it out). @@ -1128,6 +1240,7 @@ mod tests { "great post alice", None, 4000, + 4_000_000_000_000, ).unwrap(); assert!(comment.content.is_empty(), "FoF comment body is encrypted, not in content"); assert!(comment.pub_x_index.is_some()); @@ -1183,7 +1296,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xDE; 32]; // Persist the post so apply_fof_revocation_locally can resolve @@ -1228,7 +1341,7 @@ mod tests { }; let comment = build_fof_comment( &post_id, &bob_unlock, &built.slot_binder_nonce, - &bob_id, &bob_seed, "hello", None, 4000, + &bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000, ).unwrap(); s.store_comment(&comment).unwrap(); assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored"); @@ -1277,7 +1390,7 @@ mod tests { s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); // Initial gating: Alice only. - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xBC; 32]; let post = crate::types::Post { author: alice_id, content: "alice".into(), attachments: vec![], @@ -1379,7 +1492,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_old); s.insert_own_vouch_key(&alice_id, 1, &v_me_old, 1000).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xAB; 32]; let post = crate::types::Post { author: alice_id, content: "x".into(), attachments: vec![], @@ -1469,6 +1582,7 @@ mod tests { pub_post_set: (0..slot_count).map(|_| [0u8; 32]).collect(), wrap_slots: (0..slot_count).map(|_| dummy_wrap_slot()).collect(), revocation_list: vec![], + open_slot: None, } } @@ -1574,7 +1688,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_alice); s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let post_id = [0xEE; 32]; let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], @@ -1706,7 +1820,7 @@ mod tests { rand::rng().fill_bytes(&mut v_me_alice); alice_storage.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 500).unwrap(); alice_storage.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 600, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); let post = crate::types::Post { author: alice_id, content: String::new(), attachments: vec![], timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), @@ -1750,7 +1864,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_carol); alice_storage.insert_received_vouch_key(&alice_id, &carol_id, 1, &v_x_carol, 200, None).unwrap(); - let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&alice_storage, &alice_id, None).unwrap().expect("built"); // Bob's storage: holds his own V_me only (no Carol-V_x). The post // shouldn't unlock for him yet. @@ -1826,7 +1940,7 @@ mod tests { rand::rng().fill_bytes(&mut v_x_bob); s.insert_received_vouch_key(&alice_id, &bob_id, 1, &v_x_bob, 2000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); let body_plaintext = "secret to the FoF set only"; let body_ct = encrypt_fof_body(body_plaintext, &built.cek, &built.slot_binder_nonce).unwrap(); @@ -1905,7 +2019,7 @@ mod tests { s.insert_received_vouch_key(&alice_id, &bob_id, 3, &v_x_bob, 2000, None).unwrap(); s.insert_received_vouch_key(&alice_id, &carol_id, 5, &v_x_carol, 3000, None).unwrap(); - let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built"); + let built = build_fof_comment_gating(&s, &alice_id, None).unwrap().expect("built"); // 3 unique V_x's → 3 real slots, padded to bucket 8. assert_eq!(built.real_slot_provenance.len(), 3); assert_eq!(built.gating.pub_post_set.len(), 8); @@ -1962,4 +2076,105 @@ mod tests { assert!(verify_fof_revocation(&mallory_id, &post_id, &revoked_pub_x, 1000, 0, &sig)); let _ = alice_seed; } + + // --- v0.8 (A3): open-slot tests --- + + /// Gating built with an open slot: declaration present, validates on + /// receive; a stranger (no vouch keys at all) derives V_open from the + /// post alone, recovers CEK + signing seed, and a comment built with + /// the derived key passes the CDN group_sig check unmodified. + #[test] + fn open_slot_stranger_derive_and_comment() { + use crate::types::{OpenSlotKind, PostingIdentity}; + use ed25519_dalek::SigningKey; + + let s = temp_storage(); + let (alice_id, alice_seed) = make_persona(140); + s.upsert_posting_identity(&PostingIdentity { + node_id: alice_id, secret_seed: alice_seed, + display_name: "Alice".into(), created_at: 1000, + }).unwrap(); + let mut v_me_alice = [0u8; 32]; + rand::rng().fill_bytes(&mut v_me_alice); + s.insert_own_vouch_key(&alice_id, 1, &v_me_alice, 1000).unwrap(); + + let built = build_fof_comment_gating(&s, &alice_id, Some((OpenSlotKind::Greeting, 1024))) + .unwrap().expect("built"); + let decl = built.gating.open_slot.as_ref().expect("open slot declared"); + assert_eq!(decl.kind, OpenSlotKind::Greeting); + assert_eq!(decl.body_bucket, 1024); + assert_eq!(Some(decl.slot_index), built.open_slot_index); + assert!((decl.slot_index as usize) < built.gating.wrap_slots.len()); + + let post = crate::types::Post { + author: alice_id, content: String::new(), attachments: vec![], + timestamp_ms: 3000, fof_gating: Some(built.gating.clone()), + supersedes_post_id: None, + }; + + // Wire-shape validation passes. + validate_fof_gating_on_receive(&post).unwrap(); + + // A stranger mints a throwaway identity, derives the unlock. + let (stranger_id, stranger_seed) = make_persona(141); + let unlock = derive_open_slot_unlock(&post, &stranger_id) + .expect("stranger derives the open slot unlock"); + assert_eq!(unlock.cek, built.cek, "stranger recovers the (public) CEK"); + assert_eq!(unlock.slot_index, decl.slot_index); + + // The derived signing seed matches the published pub_x. + let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes(); + assert_eq!(built.gating.pub_post_set[decl.slot_index as usize], derived_pub); + + // Stranger authors a comment through the normal FoF path — must + // pass the CDN group_sig gate unmodified. + let comment = build_fof_comment( + &crate::content::compute_post_id(&post), &unlock, + &built.slot_binder_nonce, &stranger_id, &stranger_seed, + "hello, stranger here", None, 4000, 4_000_000_000_000, + ).unwrap(); + assert!(verify_fof_group_sig(&comment, &built.gating)); + } + + /// Open-slot declaration bounds are enforced on receive. + #[test] + fn open_slot_validation_bounds() { + use crate::types::{OpenSlotDecl, OpenSlotKind}; + + // slot_index out of bounds. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 8, kind: OpenSlotKind::Greeting, body_bucket: 1024 }); + let p = dummy_post(Some(g)); + let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); + assert!(err.contains("out of bounds"), "got: {}", err); + + // body_bucket too big. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Registry, body_bucket: 4097 }); + let p = dummy_post(Some(g)); + let err = validate_fof_gating_on_receive(&p).unwrap_err().to_string(); + assert!(err.contains("out of range"), "got: {}", err); + + // body_bucket zero. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 0, kind: OpenSlotKind::Greeting, body_bucket: 0 }); + let p = dummy_post(Some(g)); + assert!(validate_fof_gating_on_receive(&p).is_err()); + + // Well-formed decl passes. + let mut g = dummy_gating(8); + g.open_slot = Some(OpenSlotDecl { slot_index: 3, kind: OpenSlotKind::Greeting, body_bucket: 1024 }); + let p = dummy_post(Some(g)); + validate_fof_gating_on_receive(&p).unwrap(); + } + + /// No open slot declared → derive_open_slot_unlock returns None even + /// though the gating exists. + #[test] + fn open_slot_unlock_requires_declaration() { + let g = dummy_gating(8); + let p = dummy_post(Some(g)); + let (stranger, _) = make_persona(150); + assert!(derive_open_slot_unlock(&p, &stranger).is_none()); + } } diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index 0a753c2..d835757 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -1,15 +1,9 @@ -//! Group-key distribution as an encrypted post. -//! -//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin → -//! member, uni-stream) with a standard public post that carries the group -//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the +//! Group-key distribution as an encrypted post: the group seed travels +//! inside `PostVisibility::Encrypted`. Each member is a recipient; the //! post's CEK is wrapped per member using the admin's posting key. Members //! receive the post via normal CDN / pull paths, decrypt with their posting -//! secret, and recover the seed + metadata. -//! -//! Removing the direct push eliminates the wire-level signal that a given -//! network endpoint is coordinating group membership with another specific -//! endpoint. +//! secret, and recover the seed + metadata. (No direct wire push — that +//! would signal which endpoints coordinate group membership.) //! //! Note: Members are identified by their **posting** NodeIds (the //! author/recipient namespace since the v0.6.1 identity split), not network @@ -20,7 +14,7 @@ use crate::content::compute_post_id; use crate::crypto; use crate::storage::Storage; use crate::types::{ - GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId, + GroupKeyDistributionContent, GroupKeyRecord, NodeId, Post, PostId, PostVisibility, PostingIdentity, VisibilityIntent, }; @@ -297,4 +291,40 @@ mod tests { assert!(!applied2); assert!(s2.get_group_key(&group_id).unwrap().is_none()); } + + /// A1 (multi-persona): a distribution post addressed to our SECOND + /// persona must still be applied — the trial-unwrap iterates ALL held + /// posting identities, not just the default one. + #[test] + fn second_persona_member_applies_distribution() { + let s = temp_storage(); + let (admin_sec, admin_id) = make_keypair(1); + let (default_sec, default_id) = make_keypair(2); // default persona (not a member) + let (second_sec, second_id) = make_keypair(3); // second persona (the member) + + let group_id = [43u8; 32]; + let record = GroupKeyRecord { + group_id, + circle_name: "second".to_string(), + epoch: 1, + group_public_key: [8u8; 32], + admin: admin_id, + created_at: 100, + canonical_root_post_id: None, + }; + let group_seed = [11u8; 32]; + + // Addressed ONLY to the second persona. + let (_pid, post, visibility) = build_distribution_post( + &admin_id, &admin_sec, &record, &group_seed, &[second_id], + ).unwrap(); + + let personas = vec![ + mk_persona(default_sec, default_id), + mk_persona(second_sec, second_id), + ]; + let applied = try_apply_distribution_post(&s, &post, &visibility, &personas).unwrap(); + assert!(applied, "second persona must unwrap the group seed"); + assert_eq!(s.get_group_seed(&group_id, 1).unwrap().unwrap(), group_seed); + } } diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index d79fcb1..3e43d3a 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -8,7 +8,6 @@ use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::sync::Mutex; use tracing::{debug, info}; use crate::blob::BlobStore; @@ -106,7 +105,6 @@ pub async fn run_http_server( port: u16, storage: Arc, blob_store: Arc, - downstream_addrs: Arc>>>, ) -> anyhow::Result<()> { let addr: SocketAddr = ([0, 0, 0, 0], port).into(); // Use SO_REUSEADDR + SO_REUSEPORT so TCP punch sockets can share the port @@ -156,10 +154,9 @@ pub async fn run_http_server( let storage = Arc::clone(&storage); let blob_store = Arc::clone(&blob_store); let budget = Arc::clone(&budget); - let downstream_addrs = Arc::clone(&downstream_addrs); tokio::spawn(async move { - handle_connection(stream, ip, slot, &storage, &blob_store, &downstream_addrs).await; + handle_connection(stream, ip, slot, &storage, &blob_store).await; let mut b = budget.lock().unwrap(); match slot { SlotKind::Content => b.release_content(ip), @@ -182,7 +179,6 @@ async fn handle_connection( slot: SlotKind, storage: &Arc, blob_store: &Arc, - downstream_addrs: &Arc>>>, ) { // Keep-alive loop: handle sequential requests on the same connection loop { @@ -222,7 +218,7 @@ async fn handle_connection( } } SlotKind::Redirect => { - if !try_redirect(&mut stream, &post_id, storage, downstream_addrs).await { + if !try_redirect(&mut stream, &post_id, storage).await { return; } } @@ -368,7 +364,6 @@ async fn try_redirect( stream: &mut TcpStream, post_id: &[u8; 32], storage: &Arc, - _downstream_addrs: &Arc>>>, ) -> bool { // Get downstream peers for this post let downstream_peers = { @@ -583,114 +578,6 @@ pub fn html_escape(s: &str) -> String { out } -// --- Share link generation --- - -/// Encode a list of socket addresses as compact binary, then base64url. -/// Per IPv4: [0x04][4 bytes IP][2 bytes port] = 7 bytes -/// Per IPv6: [0x06][16 bytes IP][2 bytes port] = 19 bytes -pub fn encode_hostlist(hosts: &[SocketAddr]) -> String { - let mut buf = Vec::with_capacity(hosts.len() * 19); - for host in hosts.iter().take(5) { - match host { - SocketAddr::V4(v4) => { - buf.push(0x04); - buf.extend_from_slice(&v4.ip().octets()); - buf.extend_from_slice(&v4.port().to_be_bytes()); - } - SocketAddr::V6(v6) => { - buf.push(0x06); - buf.extend_from_slice(&v6.ip().octets()); - buf.extend_from_slice(&v6.port().to_be_bytes()); - } - } - } - base64url_encode(&buf) -} - -/// Decode a base64url-encoded hostlist back to socket addresses. -pub fn decode_hostlist(encoded: &str) -> Vec { - let buf = match base64url_decode(encoded) { - Some(b) => b, - None => return Vec::new(), - }; - let mut addrs = Vec::new(); - let mut i = 0; - while i < buf.len() { - match buf[i] { - 0x04 if i + 7 <= buf.len() => { - let ip = std::net::Ipv4Addr::new(buf[i + 1], buf[i + 2], buf[i + 3], buf[i + 4]); - let port = u16::from_be_bytes([buf[i + 5], buf[i + 6]]); - addrs.push(SocketAddr::new(ip.into(), port)); - i += 7; - } - 0x06 if i + 19 <= buf.len() => { - let mut octets = [0u8; 16]; - octets.copy_from_slice(&buf[i + 1..i + 17]); - let ip = std::net::Ipv6Addr::from(octets); - let port = u16::from_be_bytes([buf[i + 17], buf[i + 18]]); - addrs.push(SocketAddr::new(ip.into(), port)); - i += 19; - } - _ => break, // malformed - } - } - addrs -} - -// --- Minimal base64url implementation (no external dependency) --- - -const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -fn base64url_encode(data: &[u8]) -> String { - let mut out = String::with_capacity((data.len() * 4 + 2) / 3); - let mut i = 0; - while i + 2 < data.len() { - let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32; - out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char); - out.push(B64_CHARS[(n & 0x3F) as usize] as char); - i += 3; - } - let remaining = data.len() - i; - if remaining == 2 { - let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8); - out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char); - } else if remaining == 1 { - let n = (data[i] as u32) << 16; - out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); - } - out // no padding -} - -fn base64url_decode(s: &str) -> Option> { - let mut buf = Vec::with_capacity(s.len() * 3 / 4); - let mut accum: u32 = 0; - let mut bits: u32 = 0; - for c in s.bytes() { - let val = match c { - b'A'..=b'Z' => c - b'A', - b'a'..=b'z' => c - b'a' + 26, - b'0'..=b'9' => c - b'0' + 52, - b'-' => 62, - b'_' => 63, - b'=' => continue, // skip padding - _ => return None, - }; - accum = (accum << 6) | val as u32; - bits += 6; - if bits >= 8 { - bits -= 8; - buf.push((accum >> bits) as u8); - accum &= (1 << bits) - 1; - } - } - Some(buf) -} - #[cfg(test)] mod tests { use super::*; @@ -713,26 +600,6 @@ mod tests { assert_eq!(html_escape("a&b"), "a&b"); } - #[test] - fn test_base64url_roundtrip() { - let data = b"hello world"; - let encoded = base64url_encode(data); - let decoded = base64url_decode(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn test_hostlist_roundtrip() { - use std::net::{Ipv4Addr, Ipv6Addr}; - let hosts = vec![ - SocketAddr::new(Ipv4Addr::new(192, 168, 1, 1).into(), 4433), - SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 8080), - ]; - let encoded = encode_hostlist(&hosts); - let decoded = decode_hostlist(&encoded); - assert_eq!(decoded, hosts); - } - #[test] fn test_parse_request_line() { let req = b"GET /p/abc123 HTTP/1.1\r\nHost: example.com\r\n\r\n"; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a4e6d7f..290d93f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,6 @@ pub mod activity; +#[cfg(target_os = "android")] +pub mod android_wifi; pub mod blob; pub mod connection; pub mod content; @@ -15,6 +17,7 @@ pub mod network; pub mod node; pub mod profile; pub mod protocol; +pub mod registry; pub mod storage; pub mod stun; pub mod types; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 282a576..16274b9 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -10,16 +10,15 @@ use tracing::{debug, error, info, warn}; use crate::activity::{ActivityCategory, ActivityLevel, ActivityLog}; use crate::blob::BlobStore; use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult}; -use crate::content::verify_post_id; use crate::protocol::{ read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload, MessageType, ProfileUpdatePayload, - PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, - ALPN_V2, + RefuseRedirectPayload, + ALPN, }; use crate::storage::StoragePool; use crate::types::{ - DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId, + DeviceProfile, DeviceRole, NodeId, MeshSlot, Post, PostId, PostVisibility, PublicProfile, SessionReachMethod, WormResult, }; @@ -35,10 +34,12 @@ pub struct Network { /// Growth loop signal sender (set by start_growth_loop) growth_tx: tokio::sync::Mutex>>, activity_log: Arc>, - /// UPnP mapping result (None if no mapping or on mobile) - upnp_mapping: Option, - /// Whether UPnP TCP mapping succeeded (for HTTP serving) - has_upnp_tcp: bool, + /// UDP port mapping (UPnP/NAT-PMP/PCP) for the QUIC socket. + /// Holding this keeps the auto-renewing portmapper service alive. + upnp_mapping: Option, + /// TCP port mapping for HTTP serving, if available. Holding it keeps + /// the lease renewed by the portmapper background service. + tcp_mapping: Option, /// Whether this node has a public IPv6 address has_public_v6: bool, /// Stable bind address (from --bind flag), passed to ConnectionManager for anchor advertised address @@ -93,7 +94,6 @@ impl Network { secret_key: iroh::SecretKey, storage: Arc, bind_addr: Option, - secret_seed: [u8; 32], blob_store: Arc, profile: DeviceProfile, activity_log: Arc>, @@ -101,7 +101,7 @@ impl Network { let mut builder = iroh::Endpoint::builder() .secret_key(secret_key) .relay_mode(iroh::RelayMode::Disabled) - .alpns(vec![ALPN_V2.to_vec()]) + .alpns(vec![ALPN.to_vec()]) .clear_address_lookup(); // Remove default pkarr + DNS (no dns.iroh.link publishing) // mDNS LAN discovery only: enables automatic peer discovery on local network @@ -120,12 +120,18 @@ impl Network { let our_node_id = *endpoint.id().as_bytes(); - // Best-effort UPnP port mapping (desktop only, skip if --bind was used) + // Best-effort UDP port mapping (UPnP/NAT-PMP/PCP via portmapper). + // Skip if --bind was explicit (operator chose a fixed external port). + // The portmapper service auto-renews internally; we hold the + // PortMapping handle for the lifetime of the network. On Android the + // upnp module gates on active-network-is-WiFi and acquires a + // MulticastLock around UPnP/SSDP discovery for the lifetime of the + // mapping. iOS works for PCP and NAT-PMP without entitlement. let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); - let upnp_mapping = if !is_mobile && bind_addr.is_none() { + let upnp_mapping = if bind_addr.is_none() { let bound_port = endpoint.bound_sockets().first() .map(|s| s.port()).unwrap_or(0); - crate::upnp::try_upnp_mapping(bound_port).await + crate::upnp::PortMapping::try_udp(bound_port).await } else { None }; @@ -215,12 +221,19 @@ impl Network { } let upnp_external_addr = upnp_mapping.as_ref().map(|m| m.external_addr); + let session_relay_enabled = { + let s = storage.get().await; + s.get_setting("relay.session_relay_enabled") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false) + }; let conn_mgr = ConnectionManager::new( endpoint.clone(), Arc::clone(&storage), our_node_id, Arc::clone(&is_anchor), - secret_seed, blob_store, profile, Arc::clone(&activity_log), @@ -228,18 +241,26 @@ impl Network { bind_addr, nat_type, nat_mapping, + session_relay_enabled, ); let conn_mgr = Arc::new(Mutex::new(conn_mgr)); // Spawn actor wrapping the same Arc> (Phase 1: additive) let conn_handle = ConnectionActor::spawn_with_arc(Arc::clone(&conn_mgr)).await; - // TCP UPnP mapping for HTTP post delivery (only if UDP UPnP succeeded) - let has_upnp_tcp = if let Some(ref mapping) = upnp_mapping { - crate::upnp::try_upnp_tcp_mapping(mapping.local_port, mapping.external_addr.port()).await + // TCP port mapping for HTTP post delivery. Run on every platform — + // mobile devices with permissive NAT (UPnP/PCP-TCP working) can serve + // HTTP for direct browser fetches. We attempt independently of the + // UDP mapping result because the protocols are separate calls in + // portmapper. + let tcp_mapping = if bind_addr.is_none() { + let bound_port = endpoint.bound_sockets().first() + .map(|s| s.port()).unwrap_or(0); + crate::upnp::PortMapping::try_tcp(bound_port).await } else { - false + None }; + let has_upnp_tcp = tcp_mapping.is_some(); info!( node_id = %endpoint.id(), @@ -259,6 +280,79 @@ impl Network { info!(role = %device_role, "CDN replication role determined"); conn_handle.set_device_role(device_role); + // Anchor reachability watcher: tracks the UDP port mapping over time + // and adjusts anchor mode without restart. + // - Mapping lost (None) for >5 min → clear anchor mode (don't keep + // advertising an unreachable address). Replaces the old + // "3 consecutive UPnP renewal failures" logic. + // - Mapping restored (None → Some) → re-evaluate auto-anchor. If + // this node qualifies (desktop with the new mapping in hand), set + // anchor back on. Mobile never auto-anchors regardless. + // The watcher exits when the watch channel closes (i.e., when the + // PortMapping is dropped at network shutdown). + if let Some(ref mapping) = upnp_mapping { + let mut rx = mapping.watch_external(); + let is_anchor_w = Arc::clone(&is_anchor); + let alog_w = Arc::clone(&activity_log); + tokio::spawn(async move { + let mut down_since: Option = None; + let threshold = std::time::Duration::from_secs(300); + loop { + if rx.changed().await.is_err() { + // Channel closed — mapping was dropped, watcher done. + return; + } + let current = *rx.borrow_and_update(); + match current { + Some(addr) => { + // Mapping restored. If we'd lost anchor due to a + // previous drop, restore it now. Skip on mobile + // (cellular IPs look public but aren't anchorable). + let recovered = down_since.is_some(); + down_since = None; + if recovered && !is_mobile && !is_anchor_w.load(Ordering::Relaxed) { + is_anchor_w.store(true, Ordering::Relaxed); + if let Ok(mut log) = alog_w.try_lock() { + log.log( + ActivityLevel::Info, + ActivityCategory::Connection, + format!( + "Port mapping restored ({}), anchor mode re-enabled", + addr + ), + None, + ); + } + info!(external = %addr, "Port mapping restored — anchor mode re-enabled"); + } + } + None => { + let now = std::time::Instant::now(); + let t = *down_since.get_or_insert(now); + if now.duration_since(t) > threshold + && is_anchor_w.load(Ordering::Relaxed) + { + is_anchor_w.store(false, Ordering::Relaxed); + if let Ok(mut log) = alog_w.try_lock() { + log.log( + ActivityLevel::Warn, + ActivityCategory::Connection, + "Port mapping lost >5min, anchor mode cleared" + .into(), + None, + ); + } + warn!( + "Port mapping lost for >5min — anchor mode cleared" + ); + // Don't return — keep watching for recovery so we can re-anchor. + } + } + } + } + }); + } + Ok(Self { endpoint, storage, @@ -269,7 +363,7 @@ impl Network { growth_tx: tokio::sync::Mutex::new(None), activity_log, upnp_mapping, - has_upnp_tcp, + tcp_mapping, has_public_v6, bind_addr, device_role, @@ -333,8 +427,8 @@ impl Network { addrs } - /// Get the UPnP mapping, if one was successfully acquired. - pub fn upnp_mapping(&self) -> Option<&crate::upnp::UpnpMapping> { + /// Get the active UDP port mapping (UPnP/NAT-PMP/PCP), if any. + pub fn upnp_mapping(&self) -> Option<&crate::upnp::PortMapping> { self.upnp_mapping.as_ref() } @@ -359,7 +453,7 @@ impl Network { /// Whether this node can serve HTTP (has TCP reachability). pub fn is_http_capable(&self) -> bool { - self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some() + self.tcp_mapping.is_some() || self.has_public_v6 || self.bind_addr.is_some() } /// Get the port to bind the HTTP TCP listener on (same as QUIC). @@ -373,9 +467,9 @@ impl Network { } } - /// Whether UPnP TCP mapping is active. + /// Whether the TCP port mapping for HTTP serving is active. pub fn has_upnp_tcp(&self) -> bool { - self.has_upnp_tcp + self.tcp_mapping.is_some() } /// Whether this node has a public IPv6 address. @@ -383,15 +477,19 @@ impl Network { self.has_public_v6 } - /// Whether this node has UPnP mapping (UDP or TCP). + /// Whether this node has a port mapping (UDP or TCP) from any of + /// UPnP-IGD / NAT-PMP / PCP. pub fn has_upnp(&self) -> bool { - self.upnp_mapping.is_some() || self.has_upnp_tcp + self.upnp_mapping.is_some() || self.tcp_mapping.is_some() } /// Get external HTTP address string for InitialExchange advertisement. pub fn http_addr(&self) -> Option { - if let Some(ref mapping) = self.upnp_mapping { + // Prefer an explicit TCP mapping (UPnP/PCP/NAT-PMP) — that's the address + // a remote browser can reach for HTTP serving. The UDP mapping is for + // QUIC, not HTTP. + if let Some(ref mapping) = self.tcp_mapping { return Some(mapping.external_addr.to_string()); } if let Some(bind) = self.bind_addr { @@ -635,13 +733,16 @@ impl Network { recv: iroh::endpoint::RecvStream, is_mesh: &AtomicBool, ) -> bool { - // Try to allocate a slot - let accepted = conn_handle.accept_connection( + // Try to allocate a slot. `None` = both the mesh pool and the temp + // referral band are full. + let slot = conn_handle.accept_connection( conn.clone(), remote_node_id, Some(remote_sock), ).await; - info!(peer = hex::encode(remote_node_id), accepted, "try_mesh_upgrade: accept_connection result"); - if !accepted { + info!(peer = hex::encode(remote_node_id), accepted = slot.is_some(), "try_mesh_upgrade: accept_connection result"); + let slot = match slot { + Some(s) => s, + None => { let redirect = conn_handle.pick_random_redirect_peer(&remote_node_id).await; let payload = RefuseRedirectPayload { reason: "slots full".to_string(), @@ -653,13 +754,19 @@ impl Network { debug!(peer = hex::encode(remote_node_id), "Refused mesh connection (slots full)"); conn_handle.add_session(remote_node_id, conn.clone(), crate::types::SessionReachMethod::Direct, Some(remote_sock)).await; conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Refused {} mesh (slots full), added session", &hex::encode(remote_node_id)[..8]), Some(remote_node_id)); - return false; - } + return false; + } + }; { let s = storage.get().await; + // bugs-fixed #2: address always persisted, temp slots included. let _ = s.upsert_peer(&remote_node_id, &[remote_sock], None); - let _ = s.add_mesh_peer(&remote_node_id, PeerSlotKind::Local, 0); + // But mesh_peers ONLY for a real mesh slot — that omission is what + // keeps temp referral peers out of our uniques announce. + if slot.is_mesh() { + let _ = s.add_mesh_peer(&remote_node_id); + } if s.has_social_route(&remote_node_id).unwrap_or(false) { let _ = s.touch_social_route_connect( &remote_node_id, @@ -675,9 +782,10 @@ impl Network { let our_nat_type = conn_handle.nat_type().await; let our_http_capable = conn_handle.is_http_capable(); let our_http_addr = conn_handle.http_addr(); - match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false).await { + let our_depth = conn_handle.max_bounce(); + match initial_exchange_accept(storage, &our_node_id, send, recv, remote_node_id, anchor_addr, Some(remote_sock), our_nat_type, our_http_capable, our_http_addr, conn_handle.device_role(), None, false, our_depth, slot.is_mesh()).await { Ok(()) => { - info!(peer = hex::encode(remote_node_id), "Initial exchange complete (upgraded to mesh)"); + info!(peer = hex::encode(remote_node_id), slot = %slot, "Initial exchange complete (upgraded to mesh)"); conn_handle.log_activity(ActivityLevel::Info, ActivityCategory::Connection, format!("Upgraded {} to mesh", &hex::encode(remote_node_id)[..8]), Some(remote_node_id)); } Err(e) => { @@ -720,12 +828,15 @@ impl Network { let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?; // Register the established connection - self.conn_handle.register_connection(peer_id, conn.clone(), addrs, PeerSlotKind::Local).await; + let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), addrs).await { + Some(s) => s, + None => anyhow::bail!("no slot available (mesh + temp full)"), + }; let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await; let our_nat_type = self.conn_handle.nat_type().await; // Initial exchange WITHOUT holding conn_mgr lock - match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await? { + match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await? { ExchangeResult::Accepted { duplicate_active } => { if duplicate_active { self.duplicate_detected.store(true, std::sync::atomic::Ordering::Relaxed); @@ -771,29 +882,38 @@ impl Network { } } - /// Pull from all connected peers. - pub async fn pull_from_all(&self) -> anyhow::Result { + /// TRANSITIONAL content sync from every connected peer, plus the + /// engagement fetch that Iteration A's registry and greetings ride on. + /// + /// Routes through the actor path (`content_sync_unlocked`), NOT a second + /// local implementation: the old `Network::pull_from_peer` stored posts + /// with `store_post_with_visibility` instead of `control::receive_post`, so + /// it silently dropped `intent` (control posts — deletes, revocations, key + /// distributions — were never processed) and never called + /// `touch_file_holder`, which is why posts received on the startup pull + /// registered no CDN holder. + pub async fn content_sync_all(&self) -> anyhow::Result { let peers = self.conn_handle.connected_peers().await; let mut total_posts = 0; - let mut total_vis = 0; let mut success = 0; for peer_id in peers { - // Uses Network::pull_from_peer which doesn't hold conn_mgr lock during I/O - let result = self.pull_from_peer(&peer_id).await; + let result = self.conn_handle.content_sync_from_peer(&peer_id).await; match result { Ok(stats) => { total_posts += stats.posts_received; - total_vis += stats.visibility_updates; success += 1; - // Also fetch engagement data + // Engagement fetch is what carries Iteration A's registry + // entries and greeting comments (BlobHeaderRequest/Response + // + BlobHeaderDiff). It MUST stay driven on a timer — this + // is the pull side of that safety net. let _ = self.conn_handle.fetch_engagement_from_peer(&peer_id).await; if stats.posts_received > 0 { info!( peer = hex::encode(peer_id), posts = stats.posts_received, - "Pulled posts" + "Content sync: received posts" ); } } @@ -801,7 +921,7 @@ impl Network { debug!( peer = hex::encode(peer_id), error = %e, - "Pull failed" + "Content sync failed" ); } } @@ -810,96 +930,97 @@ impl Network { Ok(PullStats { peers_pulled: success, posts_received: total_posts, - visibility_updates: total_vis, }) } - /// Broadcast routing diff to all connected peers. - /// Uses ConnHandle to get diff data, then sends outside the lock. - pub async fn broadcast_diff(&self) -> anyhow::Result { - use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType}; - - let snapshot = self.conn_handle.get_diff_data().await; - - if snapshot.n1_added.is_empty() && snapshot.n1_removed.is_empty() - && snapshot.n2_added.is_empty() && snapshot.n2_removed.is_empty() - { - return Ok(0); + /// Run the uniques-index exchange (0x40/0x41) against every MESH peer. + /// + /// This is what a "pull" means in v0.8: an exchange of the IDs each side + /// has a live path to. Symmetric, so one round trip refreshes both indexes. + /// Temp referral slots are skipped — they carry no knowledge. + pub async fn uniques_pull_all(&self) -> usize { + let conns = self.conn_handle.get_connection_map().await; + let mut exchanged = 0; + for (peer_id, conn, slot, _) in conns { + if !slot.is_mesh() { + continue; + } + let fut = crate::connection::ConnectionManager::uniques_pull_unlocked( + &self.conn_mgr, + conn, + &peer_id, + ); + match tokio::time::timeout(std::time::Duration::from_secs(20), fut).await { + Ok(Ok(n)) => { + exchanged += 1; + if n > 0 { + debug!(peer = hex::encode(peer_id), rows = n, "Uniques pull: index updated"); + } + } + Ok(Err(e)) => debug!(peer = hex::encode(peer_id), error = %e, "Uniques pull failed"), + Err(_) => debug!(peer = hex::encode(peer_id), "Uniques pull timed out"), + } } + exchanged + } - let payload = NodeListUpdatePayload { - seq: snapshot.diff_seq, - n1_added: snapshot.n1_added, - n1_removed: snapshot.n1_removed, - n2_added: snapshot.n2_added, - n2_removed: snapshot.n2_removed, + /// Broadcast our uniques announce to every MESH peer. + /// + /// Temporary referral slots are excluded — they carry no knowledge + /// exchange. Peers that advertised `depth < 4` get the terminal pool + /// stripped before send (the biggest saving on a cellular link). + /// + /// The announce is always a full snapshot; `snapshot.unchanged` is what + /// stands in for incremental diffing, so an idle mesh sends nothing. + pub async fn broadcast_uniques(&self) -> anyhow::Result { + use crate::protocol::{write_typed_message, MessageType, UniquesSlice}; + + let snapshot = self.conn_handle.get_announce_data().await; + let payload = match snapshot.payload { + Some(p) if !snapshot.unchanged => p, + _ => return Ok(0), }; let mut sent = 0; - for (peer_id, conn) in &snapshot.connections { + for (peer_id, conn, peer_depth) in &snapshot.connections { + let per_peer = crate::protocol::UniquesAnnouncePayload { + seq: payload.seq, + full: payload.full, + fwd: payload.fwd.clone(), + term: if *peer_depth >= 4 { payload.term.clone() } else { UniquesSlice::default() }, + depth: payload.depth, + }; let result = async { let mut send = conn.open_uni().await?; - write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?; + write_typed_message(&mut send, MessageType::UniquesAnnounce, &per_peer).await?; send.finish()?; anyhow::Ok(()) }.await; if result.is_ok() { sent += 1; } else { - debug!(peer = hex::encode(peer_id), "Failed to send routing diff"); + debug!(peer = hex::encode(peer_id), "Failed to send uniques announce"); } } Ok(sent) } - /// Broadcast full N1/N2 state to all mesh peers (periodic catch-up for missed diffs). + /// Periodic catch-up: force the next announce even if the pools are + /// unchanged, so a peer that missed one resynchronises. + /// (The old `broadcast_full_state` is subsumed — every announce is already + /// a full snapshot.) pub async fn broadcast_full_state(&self) -> anyhow::Result { - use crate::protocol::{NodeListUpdatePayload, write_typed_message, MessageType}; - - let snapshot = self.conn_handle.get_diff_data().await; - - // Build full state: all current N1 and N2 as "added", nothing removed - let all_n1 = self.conn_handle.connected_peers().await; - let all_n2 = { - let storage = self.storage.get().await; - storage.build_n2_share().unwrap_or_default() - }; - - if all_n1.is_empty() && all_n2.is_empty() { - return Ok(0); - } - - let payload = NodeListUpdatePayload { - seq: snapshot.diff_seq, - n1_added: all_n1, - n1_removed: vec![], - n2_added: all_n2, - n2_removed: vec![], - }; - - let mut sent = 0; - for (_peer_id, conn) in &snapshot.connections { - let result = async { - let mut send = conn.open_uni().await?; - write_typed_message(&mut send, MessageType::NodeListUpdate, &payload).await?; - send.finish()?; - anyhow::Ok(()) - }.await; - if result.is_ok() { - sent += 1; - } - } - Ok(sent) + self.conn_handle.force_announce().await; + self.broadcast_uniques().await } /// Push a profile update to all audience members (ephemeral-capable). pub async fn push_profile(&self, profile: &PublicProfile) -> usize { - // v0.6.1: profiles broadcast on the wire are keyed by the network - // NodeId. They carry ONLY routing metadata (anchors, recent_peers, - // preferred_peers) — no display name / bio / avatar. Attaching a - // human-readable name to the network id would correlate the QUIC - // endpoint to a specific person. Persona-level display data will - // travel via signed posts from v0.6.2 onward. + // Profiles broadcast on the wire are keyed by the network NodeId. + // They carry ONLY routing metadata (anchors, recent_peers) — no + // display name / bio / avatar. Attaching a human-readable name to + // the network id would correlate the QUIC endpoint to a specific + // person. Persona-level display data travels via signed posts. let push_profile = profile.sanitized_for_network_broadcast(); let payload = ProfileUpdatePayload { profiles: vec![push_profile], @@ -949,32 +1070,6 @@ impl Network { sent } -/// Push a visibility update to all connected peers. - /// Gets connections snapshot, sends I/O outside the lock. - pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize { - use crate::protocol::{VisibilityUpdatePayload, write_typed_message, MessageType}; - - let conns = self.conn_handle.get_connection_map().await; - let payload = VisibilityUpdatePayload { - updates: vec![update.clone()], - }; - let mut sent = 0; - for (peer_id, conn, _, _) in &conns { - let result = async { - let mut send = conn.open_uni().await?; - write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?; - send.finish()?; - anyhow::Ok(()) - }.await; - if result.is_ok() { - sent += 1; - } else { - debug!(peer = hex::encode(peer_id), "Failed to push visibility update"); - } - } - sent - } - /// Push an updated manifest to all known holders of the file (flat set, /// up to 5 most-recent). Replaces the legacy downstream-tree push. pub async fn push_manifest_to_downstream( @@ -1122,7 +1217,7 @@ impl Network { } /// Get connection info for display. - pub async fn connection_info(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { + pub async fn connection_info(&self) -> Vec<(NodeId, MeshSlot, u64)> { self.conn_handle.connection_info().await } @@ -1157,11 +1252,15 @@ impl Network { } match self.connect_to_peer(peer_id, addr.clone()).await { - Ok(()) => Ok(()), + Ok(()) => { + self.promote_proven_anchor(peer_id, &addr).await; + Ok(()) + } Err(e) if e.to_string().contains("mesh refused") => { // Anchor refused mesh — reconnect as session for registration - let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr).await?; + let conn = ConnectionManager::connect_to_unlocked(&self.endpoint, addr.clone()).await?; self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::Direct, None).await; + self.promote_proven_anchor(peer_id, &addr).await; self.conn_handle.log_activity( ActivityLevel::Info, ActivityCategory::Anchor, @@ -1174,6 +1273,28 @@ impl Network { } } + /// An anchor we have COMPLETED a QUIC handshake to. iroh has proven the + /// node ID owns this path, so — and only so — the entry may enter the + /// authoritative address state: `peers.is_anchor` and the `known_anchors` + /// bootstrap cache. + /// + /// This is the counterpart to removing the announce-path mirror. Hearsay + /// anchor claims live in `reachable` only (mineable via `list_pool_anchors`, + /// which is what "the pools ARE the anchor directory" means); proven ones + /// live here, which is also what makes `known_anchors.last_seen_ms` + /// ordering meaningful again — and keeps the DNS-refreshed bootstrap anchor + /// from being evicted by a flood of gossip-sourced entries. + async fn promote_proven_anchor(&self, peer_id: NodeId, addr: &iroh::EndpointAddr) { + let socks: Vec = addr.ip_addrs().copied().collect(); + if socks.is_empty() { + return; + } + let storage = self.storage.get().await; + let _ = storage.upsert_peer(&peer_id, &socks, None); + let _ = storage.set_peer_anchor(&peer_id, true); + let _ = storage.upsert_known_anchor(&peer_id, &socks); + } + /// Register an already-established QUIC connection as a mesh peer. /// Sends InitialExchange first — if the remote accepts (responds with /// InitialExchange), registers as mesh and spawns the stream loop. @@ -1190,13 +1311,15 @@ impl Network { let anchor_addr = self.conn_handle.build_anchor_advertised_addr().await; let our_nat_type = self.conn_handle.nat_type().await; - match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await { + // Decide the slot BEFORE the exchange: the slot class is what says + // whether this handshake carries a uniques announce. + // bugs-fixed #3: a connection is registered before anything can drop it. + let slot = match self.conn_handle.register_connection(peer_id, conn.clone(), vec![]).await { + Some(s) => s, + None => anyhow::bail!("no slot available (mesh + temp full)"), + }; + match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, peer_id, anchor_addr, our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), slot.is_mesh()).await { Ok(ExchangeResult::Accepted { .. }) => { - self.conn_handle.register_connection(peer_id, conn.clone(), vec![], PeerSlotKind::Local).await; - { - let s = self.storage.get().await; - let _ = s.add_mesh_peer(&peer_id, PeerSlotKind::Local, 0); - } // Spawn the per-connection stream loop let conn_data = self.conn_handle.get_connection_map().await; @@ -1211,10 +1334,16 @@ impl Network { Ok(ExchangeResult::Refused { redirect }) => { let redir_info = redirect.as_ref().map(|r| r.n.clone()); info!(peer = hex::encode(peer_id), redirect = ?redir_info, "Mesh refused after hole punch, keeping as session"); + self.conn_handle.disconnect_peer(&peer_id).await; self.conn_handle.add_session(peer_id, conn, crate::types::SessionReachMethod::HolePunch, None).await; anyhow::bail!("mesh refused: slots full"); } - Err(e) => Err(e), + Err(e) => { + // Roll back the slot we reserved — otherwise a failed handshake + // holds a mesh slot until the zombie sweep. + self.conn_handle.disconnect_peer(&peer_id).await; + Err(e) + } } } @@ -1275,7 +1404,9 @@ impl Network { None, ); - if remaining == 0 { + // Recovery threshold is mesh < 2 (design.html §lifecycle), not zero: + // a single remaining peer is a partition waiting to happen. + if remaining < 2 { self.conn_handle.notify_recovery(); } else { self.conn_handle.notify_growth(); @@ -1295,7 +1426,7 @@ impl Network { for peer_id in &newly_connected { let conn = self.conn_handle.get_connection(peer_id).await; if let Some(conn) = conn { - match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None).await { + match initial_exchange_connect(&self.storage, &self.our_node_id, &conn, *peer_id, anchor_addr.clone(), our_nat_type, self.is_http_capable(), self.http_addr(), Some(self.device_role), None, self.conn_handle.max_bounce(), true).await { Ok(ExchangeResult::Accepted { .. }) => {} Ok(ExchangeResult::Refused { redirect }) => { debug!(peer = hex::encode(peer_id), "Auto-connect refused, disconnecting"); @@ -1375,10 +1506,10 @@ impl Network { loop { // Check slots + pick candidate via ConnHandle (no lock contention) - let available = self.conn_handle.available_local_slots().await; + let available = self.conn_handle.available_mesh_slots().await; if available == 0 { - debug!("Growth loop: local slots full"); - self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Local slots full".into(), None); + debug!("Growth loop: mesh slots full"); + self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, "Mesh slots full".into(), None); break; } let candidate = { @@ -1407,25 +1538,39 @@ impl Network { self.log_activity(ActivityLevel::Info, ActivityCategory::Growth, format!("Trying candidate {} (score {:.1})", &hex::encode(candidate_id)[..8], score), Some(candidate_id)); // Resolve address via ConnHandle (no lock during I/O) + let asked_a_reporter; let addr_str = { let local_addr = self.conn_handle.resolve_peer_addr_local(&candidate_id).await; if let Some(endpoint_addr) = local_addr { + asked_a_reporter = true; endpoint_addr.ip_addrs().next().map(|a| a.to_string()) } else { - // Network resolution: get reporter connections, resolve outside lock + // Network resolution: get reporter connections, resolve + // outside lock. The horizon here must match the horizon + // the CANDIDATE SCORER uses (2..=4, same as + // `ConnectionManager::resolve_address`) — when it did + // not, the growth loop scored N4 candidates it then had + // no reporter to ask about, and burned a + // `consecutive_failures` slot plus an UNREACHABLE_EXPIRY + // suppression on every one of them. let reporters_and_conns = { let storage = self.storage.get().await; - let n2 = storage.find_in_n2(&candidate_id).unwrap_or_default(); - let n3 = storage.find_in_n3(&candidate_id).unwrap_or_default(); + let mut reporter_set: std::collections::HashSet = + std::collections::HashSet::new(); + for bounce in 2..=4u8 { + for r in storage.find_reporters_at(&candidate_id, bounce).unwrap_or_default() { + reporter_set.insert(r); + } + } drop(storage); let conn_map = self.conn_handle.get_connection_map().await; - let reporter_set: std::collections::HashSet = n2.into_iter().chain(n3).collect(); conn_map.into_iter() .filter(|(nid, _, _, _)| reporter_set.contains(nid)) .map(|(_, conn, _, _)| conn) .collect::>() }; + asked_a_reporter = !reporters_and_conns.is_empty(); let mut resolved = None; for conn in reporters_and_conns { let result: anyhow::Result> = async { @@ -1452,10 +1597,19 @@ impl Network { None => { debug!( peer = hex::encode(candidate_id), - "Growth loop: no address, marking unreachable" + asked_a_reporter, + "Growth loop: no address" ); self.log_activity(ActivityLevel::Warn, ActivityCategory::Growth, format!("No address for {}", &hex::encode(candidate_id)[..8]), Some(candidate_id)); - self.conn_handle.mark_unreachable(&candidate_id); + // Only a candidate we could actually ask about, and + // that came back with nothing, has earned an + // UNREACHABLE_EXPIRY suppression. Failing for want of a + // connected reporter says nothing about the candidate — + // suppressing it there would hide it even after a + // shallower reporter shows up. + if asked_a_reporter { + self.conn_handle.mark_unreachable(&candidate_id); + } consecutive_failures += 1; if consecutive_failures >= 3 { debug!("Growth loop: 3 consecutive failures, backing off"); @@ -1491,7 +1645,7 @@ impl Network { consecutive_failures = 0; // Broadcast diff so peers learn about our new connection - let _ = self.broadcast_diff().await; + let _ = self.broadcast_uniques().await; // Brief pause to let InitialExchange update N2/N3 before next pick tokio::time::sleep(std::time::Duration::from_millis(500)).await; @@ -1549,7 +1703,7 @@ impl Network { if introduced { consecutive_failures = 0; - let _ = self.broadcast_diff().await; + let _ = self.broadcast_uniques().await; tokio::time::sleep(std::time::Duration::from_millis(500)).await; } else { self.conn_handle.mark_unreachable(&candidate_id); @@ -1566,76 +1720,6 @@ impl Network { } } -/// Pull posts from a peer (persistent if available, ephemeral otherwise). - pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { - let conn = self.get_connection(peer_id).await?; - let (our_follows, follows_sync, our_personas) = { - let storage = self.storage.get().await; - ( - storage.list_follows()?, - storage.get_follows_with_last_sync().unwrap_or_default(), - storage.list_posting_identities().unwrap_or_default(), - ) - }; - // Merged pull: include every posting identity we hold so DMs addressed - // to any of our personas match on recipient. Our network NodeId is - // never an author nor a wrapped_key recipient — including it would - // never match and would leak the network↔posting boundary. - let mut query_list = our_follows; - for pi in &our_personas { - if !query_list.contains(&pi.node_id) { - query_list.push(pi.node_id); - } - } - let (mut send, mut recv) = conn.open_bi().await?; - write_typed_message( - &mut send, - MessageType::PullSyncRequest, - &PullSyncRequestPayload { - follows: query_list, - have_post_ids: vec![], // v4: empty, using since_ms instead - since_ms: follows_sync, - }, - ) - .await?; - send.finish()?; - let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::PullSyncResponse { - anyhow::bail!("expected PullSyncResponse, got {:?}", msg_type); - } - let response: PullSyncResponsePayload = read_payload(&mut recv, 64 * 1024 * 1024).await?; - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - let storage = self.storage.get().await; - let mut posts_received = 0; - let mut vis_updates = 0; - for sp in &response.posts { - if !storage.is_deleted(&sp.id)? && verify_post_id(&sp.id, &sp.post) { - if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { - posts_received += 1; - } - // Protocol v4: update last_sync_ms for the author - let _ = storage.update_follow_last_sync(&sp.post.author, now_ms); - } - } - for vu in response.visibility_updates { - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - if storage.update_post_visibility(&vu.post_id, &vu.visibility)? { - vis_updates += 1; - } - } - } - } - Ok(PullStats { - peers_pulled: 1, - posts_received, - visibility_updates: vis_updates, - }) - } - /// Send a uni-stream message. Uses persistent connection if available, ephemeral otherwise. pub async fn send_to_peer_uni( &self, @@ -1763,7 +1847,7 @@ impl Network { if let Some(addr) = addr { match tokio::time::timeout( std::time::Duration::from_secs(5), - self.endpoint.connect(addr, ALPN_V2), + self.endpoint.connect(addr, ALPN), ).await { Ok(Ok(conn)) => { self.conn_handle.mark_reachable(peer_id); @@ -1883,41 +1967,146 @@ impl Network { Some(addr) } - // ---- Anchor referral delegation ---- + // ---- Anchor convection delegation (design.html §anchors) ---- - /// Request peer referrals from an anchor peer (mesh or session). - /// Does NOT hold conn_mgr lock during network I/O. - pub async fn request_anchor_referrals( + /// Ask an anchor for peers. The request itself enrols us in that anchor's + /// rolling window — there is no registration message; 0xC0 is retired. + /// + /// Does NOT hold the conn_mgr lock during network I/O. A refusal comes back + /// as a single small message, so the `refused` case costs one round trip, + /// not a timeout. + pub async fn request_convection( &self, anchor: &NodeId, - ) -> anyhow::Result> { + class: crate::protocol::ConvectionClass, + ) -> anyhow::Result { let conn = self.conn_handle.get_any_connection(anchor).await .ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?; + // Self-reported addresses, best first: the stable advertised address + // (if we are an anchor ourselves), then the UPnP external mapping, then + // whatever the endpoint knows. The anchor prefers what it OBSERVES over + // all of these; these only help when it cannot observe us. let endpoint = self.conn_handle.endpoint().await; - let our_addrs: Vec = endpoint.addr().ip_addrs() + let mut our_addrs: Vec = endpoint.addr().ip_addrs() + .filter(|s| is_publicly_routable(s)) .map(|s| s.to_string()) .collect(); + if let Some(ext) = self.conn_handle.upnp_external_addr().await { + let ext_str = ext.to_string(); + if !our_addrs.contains(&ext_str) { + our_addrs.insert(0, ext_str); + } + } + if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await { + if !our_addrs.contains(&anchor_addr) { + our_addrs.insert(0, anchor_addr); + } + } - let request = crate::protocol::AnchorReferralRequestPayload { + let request = crate::protocol::ConvectionRequestPayload { requester: self.our_node_id, requester_addresses: our_addrs, + class, }; let (mut send, mut recv) = conn.open_bi().await?; - crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorReferralRequest, &request).await?; + crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::ConvectionRequest, &request).await?; send.finish()?; let msg_type = crate::protocol::read_message_type(&mut recv).await?; - if msg_type != crate::protocol::MessageType::AnchorReferralResponse { - anyhow::bail!("expected AnchorReferralResponse, got {:?}", msg_type); + if msg_type != crate::protocol::MessageType::ConvectionResponse { + anyhow::bail!("expected ConvectionResponse, got {:?}", msg_type); } - let response: crate::protocol::AnchorReferralResponsePayload = crate::protocol::read_payload(&mut recv, 4096).await?; + let response: crate::protocol::ConvectionResponsePayload = + crate::protocol::read_payload(&mut recv, 4096).await?; // Touch session to prevent idle reaping self.conn_handle.touch_session_if_exists(anchor); - Ok(response.referrals) + // Adaptive feedback (round 5): a refusal shifts weight toward mesh + // introductions and other anchors; a served request drifts it back. + if response.refused { + debug!(anchor = hex::encode(anchor), "Convection: refused (top-up, no capacity)"); + self.conn_handle.record_convection_refusal(anchor); + } else { + self.conn_handle.record_convection_success(anchor); + } + + // The NAT filter probe used to ride the register message. It has to + // ride something, and this is now the only message we send an anchor. + if self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown { + if let Err(e) = self.request_nat_filter_probe(anchor).await { + debug!(error = %e, "NAT filter probe request failed"); + } + } + + Ok(response) + } + + /// Act on one convection response: connect to each referral, landing it in + /// whatever slot class is free (mesh first, then the temp referral band + /// above the cap). Returns how many connections were established. + /// + /// `introduced` referrals skip the extra introduction round trip — the + /// anchor already pushed a RelayIntroduce toward them naming us, so they + /// are punching outward while we dial. + pub async fn act_on_convection( + &self, + anchor: &NodeId, + response: &crate::protocol::ConvectionResponsePayload, + ) -> usize { + let mut connected = 0; + for referral in &response.referrals { + if referral.node_id == self.our_node_id { + continue; + } + if self.conn_handle.is_connected(&referral.node_id).await { + continue; + } + let mut ok = false; + // bugs-fixed #8 (receive side): a referral address is a third + // party's claim. Dialling `127.0.0.1:22` or `192.168.1.1:443` + // because an anchor said so is attacker-directed internal probing + // from our own host, and every such connect() enters iroh's + // per-endpoint path store permanently (v0.7.3 EDM lesson). + let usable: Option<&String> = referral.addresses.iter().find(|a| { + a.parse::() + .map(|sa| crate::connection::convection_addr_ok(&sa)) + .unwrap_or(false) + }); + if let Some(addr_str) = usable { + let connect_str = format!("{}@{}", hex::encode(referral.node_id), addr_str); + if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { + let fut = self.connect_to_peer(rid, raddr); + match tokio::time::timeout(std::time::Duration::from_secs(15), fut).await { + Ok(Ok(())) => { + info!(peer = hex::encode(rid), "Convection: connected to referred peer"); + ok = true; + } + Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Convection: direct connect failed"), + Err(_) => debug!(peer = hex::encode(rid), "Convection: direct connect timed out"), + } + } + } + if !ok && !referral.introduced { + // No coordinated introduction was possible — ask for one. + match self.connect_via_introduction_as_mesh(referral.node_id, *anchor).await { + Ok(()) => { + info!(peer = hex::encode(referral.node_id), "Convection: connected via introduction"); + ok = true; + } + Err(e) => debug!(error = %e, peer = hex::encode(referral.node_id), "Convection: introduction failed"), + } + } + if ok { + connected += 1; + } + } + if connected > 0 { + self.notify_growth().await; + } + connected } /// Request NAT filter probe from an anchor (determines address-restricted vs port-restricted). @@ -1971,55 +2160,6 @@ impl Network { Ok(()) } - /// Register our address with an anchor peer (mesh or session). - /// Also runs NAT filter probe if filtering is still Unknown. - /// Does NOT hold conn_mgr lock during network I/O. - pub async fn send_anchor_register(&self, anchor: &NodeId) -> anyhow::Result<()> { - let conn = self.conn_handle.get_any_connection(anchor).await - .ok_or_else(|| anyhow::anyhow!("anchor peer not connected (mesh or session)"))?; - - let endpoint = self.conn_handle.endpoint().await; - let mut our_addrs: Vec = endpoint.addr().ip_addrs() - .map(|s| s.to_string()) - .collect(); - // Prepend UPnP external address (most useful for remote peers) - if let Some(ext) = self.conn_handle.upnp_external_addr().await { - let ext_str = ext.to_string(); - if !our_addrs.contains(&ext_str) { - our_addrs.insert(0, ext_str); - } - } - // Prepend stable anchor advertised address - if let Some(anchor_addr) = self.conn_handle.build_anchor_advertised_addr().await { - if !our_addrs.contains(&anchor_addr) { - our_addrs.insert(0, anchor_addr); - } - } - - let payload = crate::protocol::AnchorRegisterPayload { - node_id: self.our_node_id, - addresses: our_addrs, - }; - - let mut send = conn.open_uni().await?; - crate::protocol::write_typed_message(&mut send, crate::protocol::MessageType::AnchorRegister, &payload).await?; - send.finish()?; - - // Touch session to prevent idle reaping - self.conn_handle.touch_session_if_exists(anchor); - - debug!(anchor = hex::encode(anchor), "Registered with anchor"); - - // Also run NAT filter probe if filtering is still Unknown - let needs_probe = self.conn_handle.nat_filtering().await == crate::types::NatFiltering::Unknown; - if needs_probe { - if let Err(e) = self.request_nat_filter_probe(anchor).await { - debug!(error = %e, "NAT filter probe request failed"); - } - } - Ok(()) - } - /// Check if a peer is a known anchor. pub async fn is_anchor_peer(&self, node_id: &NodeId) -> bool { let storage = self.storage.get().await; @@ -2152,18 +2292,20 @@ impl Network { pub async fn shutdown(self) -> anyhow::Result<()> { // Remove UPnP port mapping before closing endpoint - if let Some(ref mapping) = self.upnp_mapping { - crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; - } + // Dropping the PortMapping triggers the portmapper Client's + // AbortOnDropHandle and stops the renewal task. We don't explicitly + // release here because `self` doesn't move; the field will drop + // naturally when the Network goes out of scope. self.endpoint.close().await; Ok(()) } /// Shutdown via Arc reference — closes the endpoint, causing all background tasks to exit. pub async fn shutdown_ref(&self) { - if let Some(ref mapping) = self.upnp_mapping { - crate::upnp::remove_upnp_mapping(mapping.external_addr.port()).await; - } + // Dropping the PortMapping triggers the portmapper Client's + // AbortOnDropHandle and stops the renewal task. We don't explicitly + // release here because `self` doesn't move; the field will drop + // naturally when the Network goes out of scope. self.endpoint.close().await; } @@ -2188,6 +2330,22 @@ impl Network { sent += 1; } } + // v0.8 (A3): no-known-holders fallback — seed the diff through + // up to 3 current mesh connections. Without this, a first + // engagement on a post with no recorded file_holders (e.g. a + // fresh registration on the self-materialized registry post, or + // a greeting on a bio just pulled) goes nowhere until someone + // pulls the header on the slow cadence. + if sent == 0 { + for peer in self.connected_peers().await.into_iter().take(3) { + if &peer == exclude_peer { + continue; + } + if self.send_to_peer_uni(&peer, MessageType::BlobHeaderDiff, payload).await.is_ok() { + sent += 1; + } + } + } sent } } @@ -2195,7 +2353,6 @@ impl Network { pub struct PullStats { pub peers_pulled: usize, pub posts_received: usize, - pub visibility_updates: usize, } /// Decide whether a post should be sent to a requesting peer. diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 7c90407..e9e9c4c 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -13,7 +13,7 @@ use crate::network::Network; use crate::storage::StoragePool; use crate::types::{ Attachment, Circle, - DeviceProfile, DeviceRole, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId, + DeviceProfile, DeviceRole, NodeId, PeerRecord, MeshSlot, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, ReachMethod, RevocationMode, SessionReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, VisibilityIntent, WormResult, }; @@ -31,6 +31,11 @@ use crate::types::{ /// with "UnknownIssuer" because they pin the wrong cert identity. const DEFAULT_ANCHOR: &str = "ab2b7258ef0b75b2c6ee8bf6595232055f6199d584d3c0fc10b15a1ed549aa13@itsgoin.net:4433"; +/// Cooldown between relay-introduction attempts toward the same target (5 min) +const RELAY_COOLDOWN_MS: i64 = 300_000; +/// Timeout for a single relay-introduction round trip +const RELAY_INTRO_TIMEOUT_SECS: u64 = 15; + /// A distsoc node: ties together identity, storage, and networking pub struct Node { pub data_dir: PathBuf, @@ -50,11 +55,12 @@ pub struct Node { bootstrap_anchors: tokio::sync::Mutex>, /// True if an anchor reported another instance of this identity is already active pub duplicate_detected: Arc, - #[allow(dead_code)] profile: DeviceProfile, pub activity_log: Arc>, pub last_rebalance_ms: Arc, - pub last_anchor_register_ms: Arc, + /// Last time the convection loop acted (replaces the retired anchor + /// register cycle's timer). + pub last_convection_ms: Arc, /// CDN replication budget: bytes remaining we're willing to pull and cache this hour replication_budget_remaining: Arc, /// CDN delivery budget: bytes remaining we're willing to serve this hour @@ -81,6 +87,64 @@ fn generate_and_store_initial_v_me( Ok(()) } +/// v0.8 (A3): the CommentPolicy stored for every FoF-gated post at +/// creation time. Fixes the historic dead gate — nothing ever set +/// `CommentPermission::FriendsOfFriends`, so receivers' policy-based +/// arm was unreachable. (The receive gate itself keys on +/// `post.fof_gating.is_some()`; this is belt-and-suspenders.) +fn fof_comment_policy() -> crate::types::CommentPolicy { + crate::types::CommentPolicy { + allow_comments: crate::types::CommentPermission::FriendsOfFriends, + ..Default::default() + } +} + +/// v0.8 (A3): plaintext bucket for sealed greeting bodies on bio posts. +pub const GREETING_BODY_BUCKET: u16 = 1024; + +/// v0.8 (A3): FoFRevocation reason code used when a persona withdraws +/// greeting consent — the open-slot pub_x of every prior bio is revoked +/// so holders stop accepting (and purge) greetings on superseded bios. +pub const GREETING_CONSENT_REVOKE_REASON: u8 = 2; + +/// v0.8 (A3): per-persona greeting consent. PRE-CHECKED default (round +/// 8): unset = ON. The UI presents the checkbox as an active choice at +/// first profile publish; unchecking sets the key to "0" and republishes +/// the bio without a greeting slot. +pub fn greetings_open_setting_key(posting_id: &NodeId) -> String { + format!("greetings_open.{}", hex::encode(posting_id)) +} + +fn greetings_open_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> bool { + storage + .get_setting(&greetings_open_setting_key(posting_id)) + .ok() + .flatten() + .map(|v| v != "0") + .unwrap_or(true) +} + +/// v0.8 (A3): persist the author-side state of a freshly-published +/// gated post: slot provenance (cascade revocation), cached CEK +/// (author-direct decrypt), and the FriendsOfFriends comment policy. +fn persist_gated_post_author_state( + storage: &crate::storage::Storage, + author_persona_id: &NodeId, + post_id: &PostId, + built: &crate::fof::FoFCommentGatingBuilt, +) { + for entry in &built.real_slot_provenance { + let _ = storage.record_post_slot_provenance( + author_persona_id, post_id, entry.slot_index, + &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, + ); + } + let _ = storage.cache_own_fof_post_cek( + author_persona_id, post_id, &built.cek, &built.slot_binder_nonce, + ); + let _ = storage.set_comment_policy(post_id, &fof_comment_policy()); +} + /// Async wrapper used by `Node::create_posting_identity`. Acquires the /// storage handle and delegates to the sync helper. async fn ensure_initial_v_me( @@ -92,6 +156,296 @@ async fn ensure_initial_v_me( generate_and_store_initial_v_me(&s, persona_id, now_ms) } +/// Probe a list of anchors with batched parallelism, returning the first +/// successful NodeId. Remaining probes continue in background tasks after +/// first success and naturally register additional mesh connections. +/// +/// **Parameters fixed in v0.7.3:** +/// - 3 anchors in flight at a time +/// - 2-second stagger between batch dispatches +/// - 10s per-anchor connect timeout +/// - Failed probes to anchors with `last_seen_ms` older than 3 days +/// auto-delete from `known_anchors` (self-healing pruning) +/// +/// Returns `None` only when every probe completed without success. +async fn probe_anchors_batched( + anchors: Vec<(NodeId, Vec)>, + network: Arc, + storage: Arc, + self_node_id: NodeId, + label: &'static str, +) -> Option { + use std::sync::atomic::{AtomicUsize, Ordering}; + + const BATCH_SIZE: usize = 3; + const BATCH_STAGGER_SECS: u64 = 2; + const PER_ANCHOR_TIMEOUT_SECS: u64 = 10; + const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000; + + let total = anchors.len(); + if total == 0 { + return None; + } + + let (success_tx, success_rx) = tokio::sync::oneshot::channel::(); + let success_tx = Arc::new(tokio::sync::Mutex::new(Some(success_tx))); + let completed = Arc::new(AtomicUsize::new(0)); + let all_done = Arc::new(tokio::sync::Notify::new()); + + // Dispatcher: spawns per-anchor tasks in batches of BATCH_SIZE, + // sleeping BATCH_STAGGER_SECS between batches. The per-anchor tasks + // continue running after the dispatcher exits. + let dispatcher = { + let network = Arc::clone(&network); + let storage = Arc::clone(&storage); + let success_tx = Arc::clone(&success_tx); + let completed = Arc::clone(&completed); + let all_done = Arc::clone(&all_done); + tokio::spawn(async move { + let mut iter = anchors.into_iter(); + loop { + let batch: Vec<_> = (&mut iter).take(BATCH_SIZE).collect(); + if batch.is_empty() { + break; + } + let more = iter.size_hint().0 > 0; + for (nid, addrs) in batch { + let network = Arc::clone(&network); + let storage = Arc::clone(&storage); + let success_tx = Arc::clone(&success_tx); + let completed = Arc::clone(&completed); + let all_done = Arc::clone(&all_done); + tokio::spawn(async move { + let result = probe_one_anchor(&network, &storage, nid, addrs, self_node_id, label).await; + if let Some(nid) = result { + let mut guard = success_tx.lock().await; + if let Some(sender) = guard.take() { + let _ = sender.send(nid); + } + } + let prev = completed.fetch_add(1, Ordering::SeqCst); + if prev + 1 == total { + all_done.notify_one(); + } + }); + } + if more { + tokio::time::sleep(std::time::Duration::from_secs(BATCH_STAGGER_SECS)).await; + } + } + }) + }; + + // Race: first success vs all probes complete unsuccessfully. + let result = tokio::select! { + Ok(nid) = success_rx => Some(nid), + _ = all_done.notified() => None, + }; + + // Detach the dispatcher; in-flight per-anchor tasks continue. + drop(dispatcher); + + let _ = BATCH_STAGGER_SECS; // silence unused-const if compiler is picky + let _ = PER_ANCHOR_TIMEOUT_SECS; + let _ = STALE_THRESHOLD_MS; + result +} + +/// Gather anchor candidates, POOL FIRST (round-4 ruling). +/// +/// Phase 0: anchor-flagged entries mined out of the uniques pools. Anchor +/// entries are the only address-bearing rows in the index, so the +/// pools double as the anchor directory — including the retained +/// pools of peers that have since disconnected (slot knowledge is +/// overwritten memory, wiped when a new handshake takes the slot, +/// not when a peer leaves). +/// Phase 1: `known_anchors` — DEMOTED to a bootstrap cache. Its `success_count` +/// ordering was a v0.7.x scarcity artifact and no longer means +/// anything now that pools supply anchors in bulk. +/// Phase 2: peers flagged `is_anchor`. +/// +/// Anchors currently in the refusal penalty box are pushed to the back rather +/// than dropped: a refusal is a load signal, not a blacklist. +async fn gather_anchor_candidates( + storage: &Arc, + network: &crate::network::Network, + self_node_id: NodeId, + limit: usize, +) -> Vec<(NodeId, Vec)> { + let mut out: Vec<(NodeId, Vec)> = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + seen.insert(self_node_id); + + { + let s = storage.get().await; + // Phase 0 — pool-mined. + for (nid, addrs) in s.list_pool_anchors(limit).unwrap_or_default() { + if !seen.insert(nid) { + continue; + } + let socks: Vec = + addrs.iter().filter_map(|a| a.parse().ok()).collect(); + if !socks.is_empty() { + out.push((nid, socks)); + } + } + // Phase 1 — bootstrap cache. + for (nid, addrs) in s.list_known_anchors().unwrap_or_default() { + if seen.insert(nid) && !addrs.is_empty() { + out.push((nid, addrs)); + } + } + // Phase 2 — anchor-flagged peers. + for r in s.list_anchor_peers().unwrap_or_default() { + if seen.insert(r.node_id) && !r.addresses.is_empty() { + out.push((r.node_id, r.addresses)); + } + } + } + + // De-prioritise (do not drop) anchors that recently refused us. + let mut penalized = Vec::new(); + let mut fresh = Vec::new(); + for entry in out { + if network.conn_handle().is_anchor_penalized(&entry.0).await { + penalized.push(entry); + } else { + fresh.push(entry); + } + } + fresh.extend(penalized); + fresh.truncate(limit); + fresh +} + +/// One convection exchange against one anchor: connect if needed, ask, act. +/// +/// Replaces the four hand-rolled `request_anchor_referrals` → `connect_to_peer` +/// → `connect_via_introduction` blocks (bootstrap x2, recovery, register cycle) +/// that had drifted apart. Returns how many peer connections it produced. +async fn run_convection( + network: &Arc, + anchor_nid: NodeId, + anchor_addrs: &[std::net::SocketAddr], + class: crate::protocol::ConvectionClass, + self_node_id: NodeId, +) -> usize { + if anchor_nid == self_node_id { + return 0; + } + if !network.is_peer_connected_or_session(&anchor_nid).await { + let endpoint_id = match iroh::EndpointId::from_bytes(&anchor_nid) { + Ok(eid) => eid, + Err(_) => return 0, + }; + let mut addr = iroh::EndpointAddr::from(endpoint_id); + for sa in anchor_addrs { + addr = addr.with_ip_addr(*sa); + } + if let Err(e) = network.connect_to_anchor(anchor_nid, addr).await { + debug!(error = %e, anchor = hex::encode(anchor_nid), "Convection: anchor connect failed"); + return 0; + } + } + + // A refusal is one small message — the 10s ceiling is for the connect leg, + // never for the refusal itself. + let response = match tokio::time::timeout( + std::time::Duration::from_secs(10), + network.request_convection(&anchor_nid, class), + ).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + debug!(error = %e, anchor = hex::encode(anchor_nid), "Convection request failed"); + return 0; + } + Err(_) => { + debug!(anchor = hex::encode(anchor_nid), "Convection request timed out"); + return 0; + } + }; + + if response.refused { + // Feedback already recorded inside request_convection. + return 0; + } + network.act_on_convection(&anchor_nid, &response).await +} + +async fn probe_one_anchor( + network: &crate::network::Network, + storage: &Arc, + nid: NodeId, + addrs: Vec, + self_node_id: NodeId, + label: &'static str, +) -> Option { + const PER_ANCHOR_TIMEOUT_SECS: u64 = 10; + const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000; + + if nid == self_node_id || network.is_peer_connected_or_session(&nid).await { + return None; + } + let endpoint_id = match iroh::EndpointId::from_bytes(&nid) { + Ok(eid) => eid, + Err(_) => return None, + }; + let mut addr = iroh::EndpointAddr::from(endpoint_id); + for sa in &addrs { + addr = addr.with_ip_addr(*sa); + } + info!(peer = hex::encode(&nid), label, "Trying anchor"); + let result = tokio::time::timeout( + std::time::Duration::from_secs(PER_ANCHOR_TIMEOUT_SECS), + network.connect_to_anchor(nid, addr), + ).await; + + match result { + Ok(Ok(())) => { + info!(peer = hex::encode(&nid), label, "Connected to anchor"); + Some(nid) + } + Ok(Err(e)) => { + debug!(error = %e, peer = hex::encode(&nid), label, "Anchor connect failed"); + maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await; + None + } + Err(_) => { + debug!(peer = hex::encode(&nid), label, "Anchor connect timed out"); + maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await; + None + } + } +} + +/// If the anchor's last successful contact was more than `threshold_ms` +/// ago, delete it from `known_anchors`. Future startups won't waste a +/// probe slot on it. Anchors that were recently successful are preserved +/// even when they fail a single probe (likely transient). +async fn maybe_prune_stale_anchor( + storage: &Arc, + nid: &NodeId, + threshold_ms: u64, +) { + let s = storage.get().await; + let last_seen_ms = match s.get_known_anchor_last_seen(nid) { + Ok(Some(ms)) => ms, + _ => return, + }; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + if now_ms > last_seen_ms && now_ms - last_seen_ms > threshold_ms { + let _ = s.delete_known_anchor(nid); + debug!( + peer = hex::encode(nid), + age_ms = now_ms - last_seen_ms, + "Pruned stale anchor (>3 days since last success + failed probe)" + ); + } +} + impl Node { /// Create or open a node in the given data directory (Desktop profile) pub async fn open(data_dir: impl AsRef) -> anyhow::Result { @@ -115,7 +469,7 @@ impl Node { // Load or generate identity key (network secret — QUIC endpoint only, // never used as content author under the v0.6.1+ clean model). let key_path = data_dir.join("identity.key"); - let (mut secret_key, mut secret_seed) = if key_path.exists() { + let (mut secret_key, secret_seed) = if key_path.exists() { let key_bytes = std::fs::read(&key_path)?; let bytes: [u8; 32] = key_bytes .try_into() @@ -136,7 +490,7 @@ impl Node { // Startup sweep: clear stale N2/N3 and mesh_peers from prior session { let s = storage.get().await; - let n_cleared = s.clear_all_n2_n3().unwrap_or(0); + let n_cleared = s.clear_all_reach().unwrap_or(0); let m_cleared = s.clear_all_mesh_peers().unwrap_or(0); if n_cleared > 0 || m_cleared > 0 { info!(n2_n3 = n_cleared, mesh_peers = m_cleared, "Startup sweep: cleared stale entries"); @@ -188,7 +542,6 @@ impl Node { std::fs::write(&key_path, new_seed)?; info!("v0.6.1 migration: rotated network key to decouple from default posting key"); secret_key = new_key; - secret_seed = new_seed; } } } @@ -197,14 +550,12 @@ impl Node { // Open blob store let blob_store = Arc::new(BlobStore::open(&data_dir)?); - // Activity log + timer atomics + // Activity log let activity_log = Arc::new(std::sync::Mutex::new(ActivityLog::new())); - let last_rebalance_ms = Arc::new(AtomicU64::new(0)); - let last_anchor_register_ms = Arc::new(AtomicU64::new(0)); - // Start network (v2: single ALPN, connection manager) + // Start network (single ALPN, connection manager) let network = Arc::new( - Network::new(secret_key, Arc::clone(&storage), bind_addr, secret_seed, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, + Network::new(secret_key, Arc::clone(&storage), bind_addr, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, ); let node_id = network.node_id_bytes(); @@ -228,7 +579,7 @@ impl Node { // Build the node (fast path — no network I/O beyond endpoint creation) let activity_log_ref = Arc::clone(&activity_log); let last_rebalance_ms = Arc::new(AtomicU64::new(0)); - let last_anchor_register_ms = Arc::new(AtomicU64::new(0)); + let last_convection_ms = Arc::new(AtomicU64::new(0)); let role = network.device_role(); let (replication_budget, delivery_budget) = (role.replication_limit(), role.delivery_limit()); @@ -240,7 +591,7 @@ impl Node { )); blob_store.set_delivery_budget(delivery_budget); - let mut node = Self { + let node = Self { data_dir: data_dir.clone(), storage: Arc::clone(&storage), network: Arc::clone(&network), @@ -253,12 +604,81 @@ impl Node { profile, activity_log: activity_log_ref, last_rebalance_ms, - last_anchor_register_ms, + last_convection_ms, replication_budget_remaining, delivery_budget_remaining, budget_last_reset_ms, }; + // v0.8 one-time migrations (best-effort; never block init): + // + // (a) Legacy (pre-posting/network split) profile rows keyed by the + // NETWORK id may still carry persona fields from the unified-id + // era. Strip them in place so no code path can ever re-broadcast + // persona data bound to the device network id. Topology fields + // (anchors/recent_peers) are preserved. + // + // (b) The manifest signature digest dropped author_addresses, so + // rows signed by pre-v0.8 builds no longer verify. Re-sign our + // own manifests with the matching persona key; purge cached + // foreign manifests that can never verify again (they'd sit + // silently un-propagatable otherwise). Guarded by a settings + // flag so it runs once per data dir. + { + let s = storage.get().await; + + // (a) strip persona fields from the network-id row + if let Ok(Some(p)) = s.get_profile(&node.node_id) { + if !p.display_name.is_empty() || !p.bio.is_empty() || p.avatar_cid.is_some() { + let mut stripped = p; + stripped.display_name = String::new(); + stripped.bio = String::new(); + stripped.avatar_cid = None; + if s.store_profile(&stripped).is_ok() { + info!("v0.8 migration: stripped persona fields from network-id profile row"); + } + } + } + + // (b) re-sign own / purge stale-foreign CDN manifests + if s.get_setting("v08_manifest_resign_done").ok().flatten().is_none() { + let personas: std::collections::HashMap = + s.list_posting_identities() + .unwrap_or_default() + .into_iter() + .map(|pi| (pi.node_id, pi.secret_seed)) + .collect(); + let mut resigned = 0usize; + let mut purged = 0usize; + for (cid, json) in s.list_all_cdn_manifests().unwrap_or_default() { + let Ok(mut m) = serde_json::from_str::(&json) else { + let _ = s.delete_cdn_manifest(&cid); + purged += 1; + continue; + }; + if crypto::verify_manifest_signature(&m) { + continue; // already valid under the v0.8 digest + } + if let Some(seed) = personas.get(&m.author) { + m.signature = crypto::sign_manifest(seed, &m); + if let Ok(updated_json) = serde_json::to_string(&m) { + let _ = s.store_cdn_manifest(&cid, &updated_json, &m.author, m.updated_at); + resigned += 1; + } + } else { + // Foreign manifest signed under the pre-v0.8 digest — + // can never verify again; drop it so it isn't re-served. + let _ = s.delete_cdn_manifest(&cid); + purged += 1; + } + } + let _ = s.set_setting("v08_manifest_resign_done", "1"); + if resigned > 0 || purged > 0 { + info!(resigned, purged, "v0.8 migration: manifest re-sign/purge complete"); + } + } + } + // Startup backfill: any named persona without a profile post gets // one synthesized at its own `created_at`. Makes legacy / imported // named personas Discover-able without requiring a manual rename. @@ -267,11 +687,32 @@ impl Node { warn!(error = %e, "Profile-post backfill failed; continuing init"); } + // v0.8 (A3): self-materialize the registry post (store-if-absent) + // so every node can hold/serve the registration chain — no fetch + // needed, and the existing engagement-check cadence keeps its + // comment chain refreshing. + { + let s = node.storage.get().await; + match crate::registry::materialize_registry_post(&s) { + Ok(true) => info!( + post_id = hex::encode(crate::registry::REGISTRY_POST_ID), + "Registry post self-materialized" + ), + Ok(false) => {} + Err(e) => warn!(error = %e, "Registry post materialization failed"), + } + } + Ok(node) } /// Bootstrap: connect to anchors, pull initial data, NAT probe, referrals. /// Can be called during open_with_bind (blocking startup) or deferred to background. + /// + /// v0.7.3: anchor probing is batched (3 in flight, 2s stagger between batches, + /// 10s per-anchor timeout, first success unblocks downstream, remaining probes + /// continue in background and naturally fill peer connections). Failed probes + /// to anchors >3 days stale auto-prune from `known_anchors`. pub async fn run_bootstrap(&self, data_dir: &Path) -> anyhow::Result<()> { let storage = &self.storage; let network = &self.network; @@ -322,7 +763,7 @@ impl Node { Ok(()) => { info!(peer = hex::encode(nid), "Bootstrap: connected"); // Pull posts from the bootstrap peer - match network.pull_from_all().await { + match network.content_sync_all().await { Ok(stats) => { info!( "Bootstrap pull: {} posts from {} peers", @@ -345,52 +786,25 @@ impl Node { } } - // Request referrals from anchor (10s timeout) - match tokio::time::timeout(std::time::Duration::from_secs(10), network.request_anchor_referrals(&nid)).await { - Ok(Ok(referrals)) if !referrals.is_empty() => { - info!(count = referrals.len(), "Bootstrap: got anchor referrals"); - // Spawn referral connections in background — don't block startup - let net = Arc::clone(&network); - let my_id = node_id; - let anchor = nid; - tokio::spawn(async move { - for referral in referrals { - if referral.node_id == my_id { - continue; - } - if let Some(addr_str) = referral.addresses.first() { - let connect_str = format!( - "{}@{}", - hex::encode(referral.node_id), - addr_str, - ); - if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { - let connect_fut = async { - match net.connect_to_peer(rid, raddr).await { - Ok(()) => { info!(peer = hex::encode(rid), "Connected to referred peer"); Ok(()) }, - Err(e) => { - debug!(error = %e, peer = hex::encode(rid), "One-sided connect failed, requesting introduction from anchor"); - match net.connect_via_introduction(rid, anchor).await { - Ok(()) => { info!(peer = hex::encode(rid), "Connected to referred peer via hole punch"); Ok(()) }, - Err(e2) => Err(e2), - } - } - } - }; - match tokio::time::timeout(std::time::Duration::from_secs(15), connect_fut).await { - Ok(Ok(())) => {}, - Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Bootstrap referral connect failed"), - Err(_) => debug!(peer = hex::encode(rid), "Bootstrap referral connect timed out"), - } - } - } - } - net.notify_growth().await; - }); - } - Ok(Ok(_)) => debug!("Bootstrap: no referrals from anchor (first to register)"), - Ok(Err(e)) => debug!(error = %e, "Bootstrap: referral request failed"), - Err(_) => debug!("Bootstrap: referral request timed out"), + // Convection, ENTRY class — we have no mesh + // yet, so this is always served. Spawned so + // startup isn't blocked on peer connects. + { + let net = Arc::clone(&network); + let my_id = node_id; + let anchor = nid; + let anchor_addrs = ip_addrs.clone(); + tokio::spawn(async move { + let n = run_convection( + &net, + anchor, + &anchor_addrs, + crate::protocol::ConvectionClass::Entry, + my_id, + ).await; + info!(connected = n, "Bootstrap: convection complete"); + net.notify_growth().await; + }); } break; } @@ -471,65 +885,35 @@ impl Node { { let conn_count = network.connection_count().await; if conn_count < 5 { - let known = { - let s = storage.get().await; - s.list_known_anchors().unwrap_or_default() - }; + // Pool-mined anchors FIRST (round-4), then the known_anchors + // bootstrap cache, then anchor-flagged peers. + let known = gather_anchor_candidates(storage, network, node_id, 32).await; // Split into discovered anchors (priority) and bootstrap anchors (fallback) let (discovered, bootstrap_known): (Vec<_>, Vec<_>) = known.into_iter() .partition(|(nid, _)| !bootstrap_anchor_ids.contains(nid)); - // Phase 1: Try discovered (non-bootstrap) anchors first - let mut connected_anchor = None; - for (anchor_nid, anchor_addrs) in &discovered { - if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await { - continue; - } - let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) { - Ok(eid) => eid, - Err(_) => continue, - }; - let mut addr = iroh::EndpointAddr::from(endpoint_id); - for sa in anchor_addrs { - addr = addr.with_ip_addr(*sa); - } - info!(peer = hex::encode(anchor_nid), "Trying discovered anchor"); - match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await { - Ok(Ok(())) => { - info!(peer = hex::encode(anchor_nid), "Connected to discovered anchor"); - connected_anchor = Some(*anchor_nid); - break; - } - Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Discovered anchor: connect failed"), - Err(_) => debug!(peer = hex::encode(anchor_nid), "Discovered anchor: connect timed out"), - } - } + // Phase 1: probe discovered (non-bootstrap) anchors in batches. + // First success returns immediately; remaining probes continue in + // background. Failed probes to anchors >3 days stale auto-prune. + let mut connected_anchor = probe_anchors_batched( + discovered.clone(), + network.clone(), + Arc::clone(storage), + node_id, + "discovered", + ).await; - // Phase 2: Fall back to bootstrap anchors only if no discovered anchor worked + // Phase 2: bootstrap anchors as fallback — only fires if every + // Phase 1 entry failed. Preserves the load-distribution intent + // (don't smash the central anchor when discovered anchors work). if connected_anchor.is_none() { - for (anchor_nid, anchor_addrs) in &bootstrap_known { - if *anchor_nid == node_id || network.is_peer_connected_or_session(anchor_nid).await { - continue; - } - let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) { - Ok(eid) => eid, - Err(_) => continue, - }; - let mut addr = iroh::EndpointAddr::from(endpoint_id); - for sa in anchor_addrs { - addr = addr.with_ip_addr(*sa); - } - info!(peer = hex::encode(anchor_nid), "Trying bootstrap anchor (fallback)"); - match tokio::time::timeout(std::time::Duration::from_secs(10), network.connect_to_anchor(*anchor_nid, addr)).await { - Ok(Ok(())) => { - info!(peer = hex::encode(anchor_nid), "Connected to bootstrap anchor"); - connected_anchor = Some(*anchor_nid); - break; - } - Ok(Err(e)) => debug!(error = %e, peer = hex::encode(anchor_nid), "Bootstrap anchor: connect failed"), - Err(_) => debug!(peer = hex::encode(anchor_nid), "Bootstrap anchor: connect timed out"), - } - } + connected_anchor = probe_anchors_batched( + bootstrap_known.clone(), + network.clone(), + Arc::clone(storage), + node_id, + "bootstrap", + ).await; } // Phase 3: NAT probe + referrals from whichever anchor we connected to @@ -542,49 +926,23 @@ impl Node { Ok(Err(e)) => warn!(error = %e, "NAT filter probe failed during bootstrap"), Err(_) => warn!("NAT filter probe timed out during bootstrap"), } - match tokio::time::timeout(std::time::Duration::from_secs(10), network.request_anchor_referrals(&anchor_nid)).await { - Ok(Ok(referrals)) if !referrals.is_empty() => { - info!(count = referrals.len(), "Got anchor referrals"); - let net = Arc::clone(&network); - let my_id = node_id; - let anchor = anchor_nid; - tokio::spawn(async move { - for referral in referrals { - if referral.node_id == my_id { - continue; - } - if let Some(addr_str) = referral.addresses.first() { - let connect_str = format!( - "{}@{}", - hex::encode(referral.node_id), - addr_str, - ); - if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { - let connect_fut = async { - match net.connect_to_peer(rid, raddr).await { - Ok(()) => { info!(peer = hex::encode(rid), "Connected to referred peer"); Ok(()) }, - Err(_) => { - match net.connect_via_introduction(rid, anchor).await { - Ok(()) => { info!(peer = hex::encode(rid), "Connected via hole punch"); Ok(()) }, - Err(e) => Err(e), - } - } - } - }; - match tokio::time::timeout(std::time::Duration::from_secs(15), connect_fut).await { - Ok(Ok(())) => {}, - Ok(Err(e)) => debug!(error = %e, peer = hex::encode(rid), "Referral connect failed"), - Err(_) => debug!(peer = hex::encode(rid), "Referral connect timed out"), - } - } - } - } - net.notify_growth().await; - }); - } - Ok(Ok(_)) => debug!("No referrals from anchor"), - Ok(Err(e)) => debug!(error = %e, "Referral request failed"), - Err(_) => debug!("Referral request timed out"), + // Convection, ENTRY class — same single code path as + // bootstrap and recovery. + { + let net = Arc::clone(&network); + let my_id = node_id; + let anchor = anchor_nid; + tokio::spawn(async move { + let n = run_convection( + &net, + anchor, + &[], + crate::protocol::ConvectionClass::Entry, + my_id, + ).await; + info!(connected = n, "Startup: convection complete"); + net.notify_growth().await; + }); } } } @@ -601,11 +959,11 @@ impl Node { self.activity_log.lock().unwrap().recent(limit) } - /// Get timer state: (last_rebalance_ms, last_anchor_register_ms). + /// Get timer state: (last_rebalance_ms, last_convection_ms). pub fn timer_state(&self) -> (u64, u64) { ( self.last_rebalance_ms.load(AtomicOrdering::Relaxed), - self.last_anchor_register_ms.load(AtomicOrdering::Relaxed), + self.last_convection_ms.load(AtomicOrdering::Relaxed), ) } @@ -680,9 +1038,16 @@ impl Node { /// Create a new posting identity with a fresh ed25519 key. Auto-follows /// the new identity so its own posts show in the merged feed. + /// + /// `greetings_open` is the persona's greeting-consent choice (round 8: + /// an ACTIVE pre-checked choice, never silently defaulted on the + /// wire). It is persisted BEFORE the initial bio publish so an + /// opted-out persona never ships a greeting slot at all. `None` + /// keeps the pre-checked default (ON). pub async fn create_posting_identity( &self, display_name: String, + greetings_open: Option, ) -> anyhow::Result { let key = iroh::SecretKey::generate(&mut rand::rng()); let seed: [u8; 32] = key.to_bytes(); @@ -701,6 +1066,14 @@ impl Node { s.upsert_posting_identity(&identity)?; // Auto-follow this persona so its own posts reach its own feed. s.add_follow(&node_id)?; + // Record greeting consent BEFORE the initial bio publish below + // reads it — an opt-out persona must never ship a slot. + if let Some(open) = greetings_open { + s.set_setting( + &greetings_open_setting_key(&node_id), + if open { "1" } else { "0" }, + )?; + } } // If the user supplied a non-empty display name at creation time, @@ -737,11 +1110,22 @@ impl Node { ) -> anyhow::Result<()> { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump the bio_epoch. - let (vouch_grants, bio_epoch) = { + // v0.8 (A3): when the persona's `greetings_open` consent is on, + // the bio carries FoF gating with a Greeting open slot. + let (vouch_grants, bio_epoch, gating_built) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; let epoch = storage.next_bio_epoch_for(posting_id)?; - (batch, epoch) + let gating = if greetings_open_setting(&storage, posting_id) { + crate::fof::build_fof_comment_gating( + &*storage, + posting_id, + Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)), + )? + } else { + None + }; + (batch, epoch, gating) }; let profile_post = crate::profile::build_profile_post( posting_id, @@ -751,6 +1135,7 @@ impl Node { avatar_cid, vouch_grants, bio_epoch, + gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -768,6 +1153,9 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; + if let Some(built) = &gating_built { + persist_gated_post_author_state(&storage, posting_id, &profile_post_id, built); + } } self.update_neighbor_manifests_as( posting_id, @@ -977,19 +1365,13 @@ impl Node { /// Prefers social peers, then wide. async fn current_recent_peers(&self) -> Vec { let conns = self.network.connection_info().await; - let mut social: Vec = Vec::new(); - let mut wide: Vec = Vec::new(); - for (nid, kind, _) in conns { - if nid == self.node_id { - continue; - } - match kind { - PeerSlotKind::Preferred | PeerSlotKind::Local => social.push(nid), - PeerSlotKind::Wide => wide.push(nid), - } - } - let mut result = social; - result.extend(wide); + // v0.8: one mesh pool. Temp referral slots are excluded — a peer we + // hold only provisionally should not be advertised as our neighborhood. + let mut result: Vec = conns + .into_iter() + .filter(|(nid, slot, _)| *nid != self.node_id && slot.is_mesh()) + .map(|(nid, _, _)| nid) + .collect(); result.truncate(10); result } @@ -1060,7 +1442,7 @@ impl Node { // Build the gating block from the default persona's keyring. let built = { let storage = self.storage.get().await; - crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? @@ -1097,6 +1479,10 @@ impl Node { &cek, &gating.slot_binder_nonce, ); } + // v0.8 (A3): FIX THE DEAD GATE — store the FriendsOfFriends + // policy at creation for every gated post (belt-and- + // suspenders; the receive gate keys on fof_gating presence). + let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } Ok((post_id, post, visibility, cek)) @@ -1159,7 +1545,7 @@ impl Node { ) -> anyhow::Result<(PostId, Post, [u8; 32])> { let built = { let storage = self.storage.get().await; - crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id)? + crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? @@ -1209,6 +1595,8 @@ impl Node { let _ = storage.cache_own_fof_post_cek( &self.default_posting_id, &post_id, &cek, &slot_binder_nonce, ); + // v0.8 (A3): store FriendsOfFriends policy at creation. + let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } self.update_neighbor_manifests_as( @@ -1417,7 +1805,6 @@ impl Node { let manifest = crate::types::AuthorManifest { post_id, author: *posting_id, - author_addresses: self.network.our_addresses(), created_at: now, updated_at: now, previous_posts: previous, @@ -1444,16 +1831,13 @@ impl Node { let storage = self.storage.get().await; storage.get_manifests_for_author_blobs(posting_id).unwrap_or_default() }; - let our_addrs = self.network.our_addresses(); for (push_cid, push_json) in &manifests_to_push { if let Ok(author_manifest) = serde_json::from_str::(push_json) { + // v0.8: no device addresses ride the manifest — receivers + // learn the holder from the QUIC-authenticated connection. let cdn_manifest = crate::types::CdnManifest { - author_manifest: author_manifest, + author_manifest, host: self.node_id, - host_addresses: our_addrs.clone(), - source: self.node_id, - source_addresses: our_addrs.clone(), - downstream_count: 0, }; self.network.push_manifest_to_downstream(push_cid, &cdn_manifest).await; } @@ -1662,7 +2046,6 @@ impl Node { let peer_addresses = storage.build_peer_addresses_for(node_id)?; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_millis() as u64; - let preferred_tree = storage.build_preferred_tree_for(node_id).unwrap_or_default(); storage.upsert_social_route(&SocialRouteEntry { node_id: *node_id, addresses, @@ -1672,7 +2055,6 @@ impl Node { last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, - preferred_tree, })?; Ok(()) @@ -1752,11 +2134,21 @@ impl Node { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump bio_epoch. - let (vouch_grants, bio_epoch) = { + // v0.8 (A3): attach the Greeting open slot when consent is on. + let (vouch_grants, bio_epoch, gating_built) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; let epoch = storage.next_bio_epoch_for(&posting_id)?; - (batch, epoch) + let gating = if greetings_open_setting(&storage, &posting_id) { + crate::fof::build_fof_comment_gating( + &*storage, + &posting_id, + Some((crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET)), + )? + } else { + None + }; + (batch, epoch, gating) }; let profile_post = crate::profile::build_profile_post( &posting_id, @@ -1766,6 +2158,7 @@ impl Node { avatar_cid, vouch_grants, bio_epoch, + gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; @@ -1786,6 +2179,9 @@ impl Node { &profile_post, Some(&VisibilityIntent::Profile), )?; + if let Some(built) = &gating_built { + persist_gated_post_author_state(&storage, &posting_id, &profile_post_id, built); + } if !display_name.is_empty() { if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") { if marker == hex::encode(posting_id) { @@ -1793,6 +2189,20 @@ impl Node { } } } + // Keep posting_identities.display_name in sync with the + // profile post so the Personas list and any UI reading + // PostingIdentity sees the current name (not the original + // empty/auto-gen one). The upsert preserves the persona's + // secret_seed / created_at; only display_name changes. + if let Ok(Some(existing)) = storage.get_posting_identity(&posting_id) { + let updated = crate::types::PostingIdentity { + node_id: existing.node_id, + secret_seed: existing.secret_seed, + display_name: display_name.clone(), + created_at: existing.created_at, + }; + let _ = storage.upsert_posting_identity(&updated); + } } // Propagate via neighbor-manifest header diffs like any other post. @@ -1813,7 +2223,6 @@ impl Node { updated_at: timestamp_ms, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid, }) @@ -1835,23 +2244,22 @@ impl Node { let recent_peers = self.current_recent_peers().await; let profile = { let storage = self.storage.get().await; - let existing = storage.get_profile(&self.node_id)?; - let (display_name, bio, public_visible, avatar_cid) = match existing { - Some(p) => (p.display_name, p.bio, p.public_visible, p.avatar_cid), - None => (String::new(), String::new(), true, None), - }; - let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); + // v0.8: the network-id-keyed profile row is TOPOLOGY ONLY + // (anchors, recent_peers). Persona fields + // (display_name, bio, avatar_cid, public_visible) live + // exclusively on posting-id-keyed rows written by profile + // posts — never copy them onto the network row, or a legacy + // unified-id row would keep re-linking persona to device. let profile = PublicProfile { node_id: self.node_id, - display_name, - bio, + display_name: String::new(), + bio: String::new(), updated_at: now, anchors, recent_peers, - preferred_peers, - public_visible, - avatar_cid, + public_visible: true, + avatar_cid: None, }; storage.store_profile(&profile)?; @@ -2128,16 +2536,23 @@ impl Node { post: &Post, visibility: &PostVisibility, group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>, + personas: &[crate::types::PostingIdentity], ) -> anyhow::Result>> { match visibility { PostVisibility::Public => Ok(Some(data)), PostVisibility::Encrypted { recipients } => { - let cek = crypto::unwrap_cek_for_recipient( - &self.default_posting_secret, - &self.node_id, - &post.author, - recipients, - )?; + // Recipients are POSTING ids — try every persona's (id, seed) + // pair, same as decrypt_posts does for post bodies. + let cek = personas.iter().find_map(|pi| { + crypto::unwrap_cek_for_recipient( + &pi.secret_seed, + &pi.node_id, + &post.author, + recipients, + ) + .ok() + .flatten() + }); match cek { Some(cek) => { let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; @@ -2181,7 +2596,7 @@ impl Node { }; // Single lock acquisition for all DB reads - let (post, visibility, group_seeds) = { + let (post, visibility, group_seeds, personas) = { let storage = self.storage.get().await; let _ = storage.touch_blob_access(cid); match storage.get_post_with_visibility(post_id)? { @@ -2191,7 +2606,12 @@ impl Node { } else { std::collections::HashMap::new() }; - (post, vis, seeds) + let personas = if matches!(vis, PostVisibility::Encrypted { .. }) { + storage.list_posting_identities().unwrap_or_default() + } else { + Vec::new() + }; + (post, vis, seeds, personas) } None => return Ok(Some(raw_data)), // No post context — return raw } @@ -2199,7 +2619,7 @@ impl Node { // Lock released — decrypt without lock match &visibility { PostVisibility::Public => Ok(Some(raw_data)), - _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds), + _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds, &personas), } } @@ -2307,14 +2727,13 @@ impl Node { } } - // Record upstream source - let source_addrs: Vec = response.manifest.as_ref() - .map(|m| m.host_addresses.clone()) - .unwrap_or_default(); + // Record upstream source. v0.8: manifests carry no addresses; + // the holder's address is already known from the live connection + // (peers table) — record the holder id with no manifest addrs. let _ = storage.touch_file_holder( cid, from_peer, - &source_addrs, + &[], crate::storage::HolderDirection::Received, ); } @@ -2461,13 +2880,15 @@ impl Node { let post_to_propagate: Option<(PostId, u64, NodeId, [u8; 32])> = { let storage = self.storage.get().await; if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { - if gk.admin == self.default_posting_id { + // "Am I the admin?" = admin ∈ ALL my posting identities; use + // the MATCHED persona's (id, seed) pair for all crypto below. + if let Ok(Some(admin_persona)) = storage.get_posting_identity(&gk.admin) { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { // Record our own wrapped member key locally (so we // still track membership in group_member_keys for // rotation math). if let Ok(wrapped_new) = crypto::wrap_group_key_for_member( - &self.default_posting_secret, &node_id, &seed, + &admin_persona.secret_seed, &node_id, &seed, ) { let _ = storage.store_group_member_key( &gk.group_id, @@ -2480,8 +2901,8 @@ impl Node { } match crate::group_key_distribution::build_distribution_post( - &self.default_posting_id, - &self.default_posting_secret, + &admin_persona.node_id, + &admin_persona.secret_seed, &gk, &seed, &[node_id], @@ -2493,7 +2914,7 @@ impl Node { &visibility, &VisibilityIntent::GroupKeyDistribute, )?; - Some((post_id, post.timestamp_ms, self.default_posting_id, self.default_posting_secret)) + Some((post_id, post.timestamp_ms, admin_persona.node_id, admin_persona.secret_seed)) } Err(e) => { warn!(error = %e, "failed to build key-distribution post"); @@ -2556,8 +2977,15 @@ impl Node { } self.create_group_key_inner(&circle_name, Some(root_post_id)).await?; + // initial_members are posting ids — skip ALL of our own personas, + // not the network NodeId (which never appears in member lists). + let own_posting_ids: Vec = { + let storage = self.storage.get().await; + storage.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect() + }; for member in initial_members { - if member == self.node_id { + if own_posting_ids.contains(&member) { continue; } if let Err(e) = self.add_to_circle(circle_name.clone(), member).await { @@ -2727,12 +3155,17 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + // The admin of a group/circle is a POSTING identity (persona), never + // the network NodeId: the wire format ships admin == post.author + // (a posting id) and receivers verify exactly that + // (group_key_distribution.rs). Creation currently always acts as the + // default persona. let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.to_string(), epoch: 1, group_public_key: pubkey, - admin: self.node_id, + admin: self.default_posting_id, created_at: now, canonical_root_post_id, }; @@ -2741,10 +3174,11 @@ impl Node { storage.create_group_key(&record, Some(&seed))?; storage.store_group_seed(&group_id, 1, &seed)?; - // Wrap for ourselves - let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.node_id, &seed)?; + // Wrap for ourselves (as the admin persona — member rows are keyed + // by posting ids so the wrapped key is actually unwrappable). + let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.default_posting_id, &seed)?; let self_mk = crate::types::GroupMemberKey { - member: self.node_id, + member: self.default_posting_id, epoch: 1, wrapped_group_key: self_wrapped, }; @@ -2754,10 +3188,12 @@ impl Node { // via a single encrypted key-distribution post. v0.6.2 replaces the // per-member uni-stream GroupKeyDistribute push with this // CDN-propagated post (one post per epoch, recipients = all non-self - // members). + // members). Circle members are posting ids — strip ALL our personas. + let own_posting_ids: Vec = storage.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect(); let other_members: Vec = storage.get_circle_members(circle_name)? .into_iter() - .filter(|m| *m != self.node_id) + .filter(|m| !own_posting_ids.contains(m)) .collect(); for member in &other_members { @@ -2811,21 +3247,29 @@ impl Node { let rotate_result = { let storage = self.storage.get().await; let gk = match storage.get_group_key_by_circle(circle_name) { - Ok(Some(gk)) if gk.admin == self.node_id => gk, + Ok(Some(gk)) => gk, + _ => return, + }; + // "Am I the admin?" = admin ∈ my posting identities. Use the + // matched persona's (id, seed) for wrapping + signing. + let admin_persona = match storage.get_posting_identity(&gk.admin) { + Ok(Some(p)) => p, _ => return, }; let remaining_members = match storage.get_circle_members(circle_name) { Ok(m) => m, Err(_) => return, }; - // Always include ourselves + // Always include ourselves — as the admin PERSONA (member sets + // hold posting ids; the network NodeId must never leak into a + // CDN-propagated key-distribution post). let mut all_members = remaining_members; - if !all_members.contains(&self.node_id) { - all_members.push(self.node_id); + if !all_members.contains(&admin_persona.node_id) { + all_members.push(admin_persona.node_id); } - match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) { + match crypto::rotate_group_key(&admin_persona.secret_seed, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { - Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id)) + Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id, admin_persona)) } Err(e) => { warn!(error = %e, "Failed to rotate group key"); @@ -2834,23 +3278,28 @@ impl Node { } }; - if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root)) = rotate_result { + if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root, admin_persona)) = rotate_result { // Update storage - { + let own_posting_ids: Vec = { let storage = self.storage.get().await; let _ = storage.update_group_epoch(&group_id, new_epoch, &new_pubkey, Some(&new_seed)); let _ = storage.store_group_seed(&group_id, new_epoch, &new_seed); for mk in &member_keys { let _ = storage.store_group_member_key(&group_id, mk); } - } + storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect() + }; // v0.6.2: distribute the new seed via an encrypted // key-distribution post instead of per-member unicast pushes. + // Strip ALL our personas (never just the default one) so no + // self-addressed wrapped CEK rides a propagated post. let recipients: Vec = member_keys .iter() .map(|mk| mk.member) - .filter(|m| *m != self.default_posting_id) + .filter(|m| !own_posting_ids.contains(m)) .collect(); if !recipients.is_empty() { @@ -2859,7 +3308,7 @@ impl Node { circle_name: circle_name.clone(), epoch: new_epoch, group_public_key: new_pubkey, - admin: self.default_posting_id, + admin: admin_persona.node_id, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) @@ -2867,8 +3316,8 @@ impl Node { canonical_root_post_id: canonical_root, }; match crate::group_key_distribution::build_distribution_post( - &self.default_posting_id, - &self.default_posting_secret, + &admin_persona.node_id, + &admin_persona.secret_seed, &record, &new_seed, &recipients, @@ -2882,7 +3331,7 @@ impl Node { ); } self.update_neighbor_manifests_as( - &self.default_posting_id, &self.default_posting_secret, &post_id, ts, + &admin_persona.node_id, &admin_persona.secret_seed, &post_id, ts, ).await; } Err(e) => { @@ -2914,17 +3363,8 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let cp = crate::types::CircleProfile { - author: self.default_posting_id, - circle_name: circle_name.clone(), - display_name, - bio, - avatar_cid, - updated_at: now, - }; - // Get group key for this circle - let (encrypted_payload, wrapped_cek, group_id, epoch) = { + let (cp, encrypted_payload, wrapped_cek, group_id, epoch) = { let storage = self.storage.get().await; // Verify circle exists let circles = storage.list_circles()?; @@ -2935,9 +3375,20 @@ impl Node { let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; - if gk.admin != self.node_id { - anyhow::bail!("not admin of circle '{}'", circle_name); - } + // Admin is a posting identity — check membership across ALL our + // personas and author the profile as the MATCHED persona so the + // local row key matches what receivers store (cp.author). + let admin_persona = storage.get_posting_identity(&gk.admin)? + .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; + + let cp = crate::types::CircleProfile { + author: admin_persona.node_id, + circle_name: circle_name.clone(), + display_name, + bio, + avatar_cid, + updated_at: now, + }; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found for circle '{}'", circle_name))?; @@ -2946,10 +3397,11 @@ impl Node { let json = serde_json::to_string(&cp)?; let (encrypted, wrapped) = crypto::encrypt_post_for_group(&json, &seed, &gk.group_public_key)?; - // Store plaintext + encrypted form + // Store plaintext + encrypted form, both keyed by the authoring + // persona id (same key class as the pushed payload/remote rows). storage.set_circle_profile(&cp)?; storage.store_remote_circle_profile( - &self.node_id, + &admin_persona.node_id, &circle_name, &cp, &encrypted, @@ -2958,12 +3410,12 @@ impl Node { gk.epoch, )?; - (encrypted, wrapped, gk.group_id, gk.epoch) + (cp, encrypted, wrapped, gk.group_id, gk.epoch) }; // Push to all connected mesh peers let payload = crate::protocol::CircleProfileUpdatePayload { - author: self.default_posting_id, + author: cp.author, circle_name, group_id, epoch, @@ -2989,16 +3441,20 @@ impl Node { let storage = self.storage.get().await; let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; + // Admin ∈ our posting identities; the local row is keyed by that + // persona id (matches set_circle_profile), so delete that row. + let admin_persona = storage.get_posting_identity(&gk.admin)? + .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found"))?; // Encrypt empty string as tombstone let (encrypted, wrapped) = crypto::encrypt_post_for_group("", &seed, &gk.group_public_key)?; - storage.delete_circle_profile(&self.node_id, &circle_name)?; + storage.delete_circle_profile(&admin_persona.node_id, &circle_name)?; crate::protocol::CircleProfileUpdatePayload { - author: self.default_posting_id, + author: admin_persona.node_id, circle_name, group_id: gk.group_id, epoch: gk.epoch, @@ -3012,40 +3468,40 @@ impl Node { Ok(()) } - /// Set public_visible flag and push profile update. + /// Set public_visible flag on our own persona profile. + /// + /// v0.8: public_visible is a persona-class flag (it gates persona display + /// fields), so it lives on the posting-id-keyed profile row — matching + /// publish_profile / my_profile — NOT the network-id row. No wire push: + /// the ProfileUpdate receive path blind-REPLACEs rows, so pushing a + /// sanitized (persona-free) posting-id profile would wipe receivers' + /// stored display fields for this persona. The flag propagates locally; + /// carrying it in ProfilePostContent is tracked future work. pub async fn set_public_visible(&self, visible: bool) -> anyhow::Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let recent_peers = self.current_recent_peers().await; - let profile = { - let storage = self.storage.get().await; - let existing = storage.get_profile(&self.node_id)?; - let (display_name, bio, avatar_cid) = match existing { - Some(p) => (p.display_name, p.bio, p.avatar_cid), - None => (String::new(), String::new(), None), - }; - let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); - let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); - - let profile = PublicProfile { - node_id: self.node_id, - display_name, - bio, + let storage = self.storage.get().await; + let pid = self.default_posting_id; + let profile = match storage.get_profile(&pid)? { + Some(mut p) => { + p.public_visible = visible; + p.updated_at = now; + p + } + None => PublicProfile { + node_id: pid, + display_name: String::new(), + bio: String::new(), updated_at: now, - anchors: existing_anchors, - recent_peers, - preferred_peers, + anchors: vec![], + recent_peers: vec![], public_visible: visible, - avatar_cid, - }; - - storage.store_profile(&profile)?; - profile + avatar_cid: None, + }, }; - - self.network.push_profile(&profile).await; + storage.store_profile(&profile)?; Ok(()) } @@ -3055,23 +3511,42 @@ impl Node { author: &NodeId, ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { let storage = self.storage.get().await; - storage.resolve_display_for_peer(author, &self.node_id) + // Viewer identity for circle-profile resolution = ALL our posting + // identities (circle membership is posting-class, never network id). + let viewers: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + storage.resolve_display_for_peer(author, &viewers) } - /// Get our own circle profile for a given circle. + /// Get our own circle profile for a given circle. Own circle-profile rows + /// are keyed by the authoring persona (the circle's admin posting id). pub async fn get_circle_profile( &self, circle_name: &str, ) -> anyhow::Result> { let storage = self.storage.get().await; - storage.get_circle_profile(&self.node_id, circle_name) + let gk = match storage.get_group_key_by_circle(circle_name)? { + Some(gk) => gk, + None => return Ok(None), + }; + // Own circles only: the admin must be one of OUR posting identities. + // Group-key records for circles we merely belong to (received via + // key-distribution posts) store the REMOTE admin's id; returning that + // row here would surface a foreign circle profile as "our own" in the + // edit dialog. Mirrors the set/delete_circle_profile gate. + if storage.get_posting_identity(&gk.admin)?.is_none() { + return Ok(None); + } + storage.get_circle_profile(&gk.admin, circle_name) } - /// Get the public_visible setting for our own profile. + /// Get the public_visible setting for our own persona profile. + /// v0.8: keyed by the default posting id (see set_public_visible). pub async fn get_public_visible(&self) -> anyhow::Result { let storage = self.storage.get().await; Ok(storage - .get_profile(&self.node_id)? + .get_profile(&self.default_posting_id)? .map(|p| p.public_visible) .unwrap_or(true)) } @@ -3114,20 +3589,24 @@ impl Node { let staleness_ms = 3600 * 1000; - let (candidates, follows) = { + let (candidates, follows, own_ids) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - (candidates, follows) + let own_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + (candidates, follows, own_ids) }; if candidates.is_empty() { return Ok(255); // Empty cache = max willingness to accept } - // Filter to non-elevated blobs (not pinned, not own content, not followed author) + // Filter to non-elevated blobs (not pinned, not own content, not followed + // author). Own content = authored by ANY of our posting identities. let non_elevated: Vec<_> = candidates.iter().filter(|c| { - !c.pinned && c.author != self.node_id && !follows.contains(&c.author) + !c.pinned && !own_ids.contains(&c.author) && !follows.contains(&c.author) }).collect(); if non_elevated.is_empty() { @@ -3138,7 +3617,7 @@ impl Node { let mut min_priority = f64::MAX; let mut min_created_at = u64::MAX; for c in &non_elevated { - let priority = self.compute_blob_priority(c, &follows, now); + let priority = self.compute_blob_priority(c, &own_ids, &follows, now); if priority < min_priority { min_priority = priority; min_created_at = c.created_at; @@ -3209,8 +3688,7 @@ impl Node { let now = control_post.timestamp_ms; // Clean up blob storage local-side. Blobs in remote holders become - // orphans and get evicted naturally via LRU — BlobDeleteNotice is - // gone in v0.6.2. + // orphans and get evicted naturally via LRU. let blob_cids = { let storage = self.storage.get().await; let cids = storage.delete_blobs_for_post(post_id)?; @@ -3277,9 +3755,17 @@ impl Node { .ok_or_else(|| anyhow::anyhow!("post not found"))? }; - if post.author != self.node_id { - anyhow::bail!("cannot revoke: you are not the author"); - } + // Posts are authored by POSTING identities (personas), never the + // network NodeId. "Is this mine?" = author ∈ all my posting identities; + // remember the matched persona so crypto below uses its (id, seed) pair. + let author_persona = { + let storage = self.storage.get().await; + storage.get_posting_identity(&post.author)? + }; + let author_persona = match author_persona { + Some(p) => p, + None => anyhow::bail!("cannot revoke: you are not the author"), + }; let existing_recipients = match &visibility { PostVisibility::Public => anyhow::bail!("cannot revoke access on a public post"), @@ -3305,8 +3791,8 @@ impl Node { match mode { RevocationMode::SyncAccessList => { let new_wrapped = crypto::rewrap_visibility( - &self.default_posting_secret, - &self.node_id, + &author_persona.secret_seed, + &author_persona.node_id, existing_recipients, &new_recipient_ids, )?; @@ -3320,12 +3806,7 @@ impl Node { // Propagate via a signed control-visibility post rather than a // direct push. Only the target's author can make such a post. - let author_secret = { - let s = self.storage.get().await; - s.get_posting_identity(&post.author)? - .map(|pi| pi.secret_seed) - .ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))? - }; + let author_secret = author_persona.secret_seed; let control_post = crate::control::build_visibility_control_post( &post.author, &author_secret, @@ -3355,8 +3836,8 @@ impl Node { RevocationMode::ReEncrypt => { let (new_content, new_wrapped) = crypto::re_encrypt_post( &post.content, - &self.default_posting_secret, - &self.node_id, + &author_persona.secret_seed, + &author_persona.node_id, existing_recipients, &new_recipient_ids, )?; @@ -3365,7 +3846,9 @@ impl Node { }; let new_post = Post { - author: self.default_posting_id, + // Keep the ORIGINAL persona as author — the replacement + // must not migrate content to the default persona. + author: post.author, content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, @@ -3379,7 +3862,7 @@ impl Node { storage.store_post_with_visibility(&new_post_id, &new_post, &new_vis)?; } - // delete_post already pushes the DeleteRecord. + // delete_post propagates the deletion as a signed control post. // Replacement post propagates via the CDN to remaining recipients. self.delete_post(post_id).await?; @@ -3399,9 +3882,15 @@ impl Node { revoked: &NodeId, mode: RevocationMode, ) -> anyhow::Result { + // Posts are authored by posting identities — query every persona, + // not the network NodeId (which never authors posts). let posts = { let storage = self.storage.get().await; - storage.find_posts_by_circle_intent(circle_name, &self.node_id)? + let mut all = Vec::new(); + for persona in storage.list_posting_identities()? { + all.extend(storage.find_posts_by_circle_intent(circle_name, &persona.node_id)?); + } + all }; let mut count = 0; @@ -3428,7 +3917,12 @@ impl Node { pub async fn get_redundancy_summary(&self) -> anyhow::Result<(usize, usize, usize, usize)> { let storage = self.storage.get().await; - storage.get_redundancy_summary(&self.node_id, 3_600_000) + // Posts are authored by posting identities (personas), not the + // network NodeId. Use every persona on this device so the + // summary counts all of my posts across personas. + let author_ids: Vec = storage.list_posting_identities()? + .into_iter().map(|p| p.node_id).collect(); + storage.get_redundancy_summary(&author_ids, 3_600_000) } // ---- Networking ---- @@ -3584,7 +4078,7 @@ impl Node { { let on_cooldown = { let storage = self.storage.get().await; - storage.is_relay_cooldown(&peer_id, 300_000).unwrap_or(false) + storage.is_relay_cooldown(&peer_id, RELAY_COOLDOWN_MS).unwrap_or(false) }; if !on_cooldown { @@ -3604,7 +4098,7 @@ impl Node { ); let intro_result = tokio::time::timeout( - std::time::Duration::from_secs(15), + std::time::Duration::from_secs(RELAY_INTRO_TIMEOUT_SECS), self.network.send_relay_introduce_standalone(relay_peer, &peer_id, *ttl), ).await; @@ -3673,8 +4167,14 @@ impl Node { } } - // Step 7: Session relay fallback — if intro was accepted but hole punch failed - if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { + // Step 7: Session relay fallback — only if BOTH the introducer + // signaled relay availability AND this node has opted in to + // using session relay (`relay.session_relay_enabled`). Default + // is opt-out: hole-punch failure does NOT silently fall back + // to byte-relaying through a third party. + if !self.network.conn_handle().is_session_relay_enabled().await { + debug!(target = hex::encode(peer_id), "Session relay opt-out — skipping relay fallback"); + } else if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { if last_relay_available { info!( target = hex::encode(peer_id), @@ -3749,7 +4249,7 @@ impl Node { let storage = self.storage.get().await; let _ = storage.update_follow_last_sync(&peer_id, 0); } - let stats = self.network.conn_handle().pull_from_peer(&peer_id).await?; + let stats = self.network.conn_handle().content_sync_from_peer(&peer_id).await?; // Also fetch engagement data (reactions, comments) for posts we hold let engagement = self.network.conn_handle().fetch_engagement_from_peer(&peer_id).await.unwrap_or(0); info!( @@ -3769,7 +4269,7 @@ impl Node { pub async fn sync_with_addr(&self, addr: iroh::EndpointAddr) -> anyhow::Result<()> { let peer_id = *addr.id.as_bytes(); self.network.connect_to_peer(peer_id, addr).await?; - let stats = self.network.conn_handle().pull_from_peer(&peer_id).await?; + let stats = self.network.conn_handle().content_sync_from_peer(&peer_id).await?; info!( peer = hex::encode(peer_id), posts = stats.posts_received, @@ -3780,7 +4280,7 @@ impl Node { /// Pull from all connected peers pub async fn sync_all(&self) -> anyhow::Result<()> { - let stats = self.network.pull_from_all().await?; + let stats = self.network.content_sync_all().await?; info!( "Pull complete: {} posts from {} peers", stats.posts_received, stats.peers_pulled @@ -3813,8 +4313,13 @@ impl Node { self.bootstrap_anchors.lock().await.clone() } - /// Get connection info for display: (node_id, slot_kind, connected_at) - pub async fn list_connections(&self) -> Vec<(NodeId, PeerSlotKind, u64)> { + /// This device's slot/depth budget. + pub fn device_profile(&self) -> DeviceProfile { + self.profile + } + + /// Get connection info for display: (node_id, slot, connected_at) + pub async fn list_connections(&self) -> Vec<(NodeId, MeshSlot, u64)> { self.network.connection_info().await } @@ -3833,53 +4338,73 @@ impl Node { tokio::spawn(async move { network.run_accept_loop().await }) } - /// Start pull cycle: Protocol v4 tiered pull — 60s ticks, full pull on first tick, - /// then only pull for stale authors (last_sync_ms > 4 hours old). - pub fn start_pull_cycle(self: &Arc, _interval_secs: u64) -> tokio::task::JoinHandle<()> { + /// Start the sync cycle — two cadences on one 60s tick. + /// + /// (1) THE PULL, i.e. the uniques-index exchange (0x40/0x41). design.html + /// §sync: a pull is not a post transfer, it is "if you want these IDs, + /// talk to me and I'll help you find them". Runs on the slow tick + /// because the pools are large and mostly static; the push-side + /// announce (0x01) already covers fast changes. + /// + /// (2) TRANSITIONAL content sync (0x46/0x47) for stale authors. Folds into + /// the update-cadence scheduler + CDN replication in Iteration D. + /// Until then it is the only carrier for non-public visibilities. + pub fn start_sync_cycle(self: &Arc) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut is_first_tick = true; + let mut tick: u64 = 0; loop { interval.tick().await; + tick += 1; - if is_first_tick { - // Full pull on startup - let _ = node.network.pull_from_all().await; - is_first_tick = false; - // Prefetch after initial sync + if tick == 1 { + // Startup: full content sync + engagement fetch, then + // prefetch blobs for what arrived. + let _ = node.network.content_sync_all().await; let peers = node.network.conn_handle().connected_peers().await; for peer_id in peers { node.prefetch_blobs_from_peer(&peer_id).await; } + let n = node.network.uniques_pull_all().await; + tracing::debug!(peers = n, "Startup uniques-index exchange"); continue; } - // Tiered: only pull for stale authors (4-hour default) + // (1) Uniques-index exchange every 5 minutes. + if tick % 5 == 0 { + let n = node.network.uniques_pull_all().await; + if n > 0 { + tracing::debug!(peers = n, "Uniques-index exchange"); + } + } + + // (2) Tiered content sync: only when some author is stale. let stale_authors = { let storage = node.storage.get().await; storage.get_stale_follows(4 * 3600 * 1000).unwrap_or_default() }; - if stale_authors.is_empty() { continue; // Most ticks skip — no stale authors } - // Find a connected peer and pull + // Every connected peer, not just `peers.first()`: one arbitrary + // peer is very unlikely to hold a given stale author's posts, + // so the tiered tick silently did almost nothing. let peers = node.network.conn_handle().connected_peers().await; - if let Some(peer_id) = peers.first() { - match node.network.conn_handle().pull_from_peer(peer_id).await { - Ok(stats) => { - if stats.posts_received > 0 { - tracing::debug!( - posts = stats.posts_received, - "Tiered pull complete" - ); - node.prefetch_blobs_from_peer(peer_id).await; - } + for peer_id in &peers { + match node.network.conn_handle().content_sync_from_peer(peer_id).await { + Ok(stats) if stats.posts_received > 0 => { + tracing::debug!( + peer = hex::encode(peer_id), + posts = stats.posts_received, + "Tiered content sync complete" + ); + node.prefetch_blobs_from_peer(peer_id).await; } - Err(e) => tracing::debug!(error = %e, "Tiered pull failed"), + Ok(_) => {} + Err(e) => tracing::debug!(error = %e, "Tiered content sync failed"), } } } @@ -3911,7 +4436,7 @@ impl Node { } } } else { - match network.broadcast_diff().await { + match network.broadcast_uniques().await { Ok(count) => { if count > 0 { tracing::debug!(count, "Broadcast routing diff"); @@ -3961,8 +4486,12 @@ impl Node { }) } - /// Start recovery loop: triggered when mesh drops below 2 connections. - /// Immediately reconnects to anchors and requests referrals. + /// Start recovery loop: triggered when the mesh drops below 2 peers. + /// + /// Recovery is deliberately NOT stochastic (round-4 ruling). A node with + /// fewer than 2 mesh peers cannot function, so it always acts immediately; + /// only *growth* rolls dice. Anchors are gathered pool-first, then the + /// bootstrap cache. pub fn start_recovery_loop(&self) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let storage = Arc::clone(&self.storage); @@ -3976,86 +4505,31 @@ impl Node { network.set_recovery_tx(tx).await; while rx.recv().await.is_some() { tracing::info!("Recovery triggered: reconnecting to anchors"); - log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, "Recovery triggered: mesh empty".into(), None); + log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, "Recovery triggered: mesh below 2".into(), None); // Debounce: wait briefly for more disconnects to settle tokio::time::sleep(std::time::Duration::from_secs(2)).await; // Drain any queued signals while rx.try_recv().is_ok() {} - // Gather anchors: known_anchors table, then anchor peers fallback - let anchors: Vec<(crate::types::NodeId, Vec)> = { - let s = storage.get().await; - let known = s.list_known_anchors().unwrap_or_default(); - if !known.is_empty() { - known - } else { - s.list_anchor_peers().unwrap_or_default() - .into_iter() - .map(|r| (r.node_id, r.addresses)) - .collect() - } - }; - + let anchors = gather_anchor_candidates(&storage, &network, node_id, 8).await; + let mut connected = 0usize; for (anchor_nid, anchor_addrs) in &anchors { - if *anchor_nid == node_id { continue; } - // Connect to anchor (mesh or session fallback) - if !network.is_peer_connected_or_session(anchor_nid).await { - let endpoint_id = match iroh::EndpointId::from_bytes(anchor_nid) { - Ok(eid) => eid, - Err(_) => continue, - }; - let mut addr = iroh::EndpointAddr::from(endpoint_id); - for sa in anchor_addrs { - addr = addr.with_ip_addr(*sa); - } - match network.connect_to_anchor(*anchor_nid, addr).await { - Ok(()) => { - log_evt(ActivityLevel::Info, ActivityCategory::Recovery, "Connected to anchor".into(), Some(*anchor_nid)); - } - Err(e) => { - tracing::debug!(error = %e, "Recovery: anchor connect failed"); - log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, format!("Anchor connect failed: {}", e), Some(*anchor_nid)); - continue; - } - } - } - // Register with anchor - let _ = network.send_anchor_register(anchor_nid).await; - // Request referrals - match network.request_anchor_referrals(anchor_nid).await { - Ok(referrals) => { - for referral in referrals { - if referral.node_id == node_id { continue; } - if let Some(addr_str) = referral.addresses.first() { - let connect_str = format!( - "{}@{}", hex::encode(referral.node_id), addr_str, - ); - if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { - match network.connect_to_peer(rid, raddr).await { - Ok(()) => { - tracing::info!(peer = hex::encode(rid), "Recovery: connected to referred peer"); - log_evt(ActivityLevel::Info, ActivityCategory::Recovery, "Connected to referred peer".into(), Some(rid)); - } - Err(_) => { - match network.connect_via_introduction(rid, *anchor_nid).await { - Ok(()) => { - tracing::info!(peer = hex::encode(rid), "Recovery: connected via hole punch"); - log_evt(ActivityLevel::Info, ActivityCategory::Recovery, "Connected via hole punch".into(), Some(rid)); - } - Err(e) => { - tracing::debug!(error = %e, peer = hex::encode(rid), "Recovery: hole punch failed"); - log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, format!("Hole punch failed: {}", e), Some(rid)); - } - } - } - } - } - } - } - } - Err(e) => tracing::debug!(error = %e, "Recovery: referral request failed"), + // ENTRY class: always served, by definition of the ruling. + // There is no separate registration — the request enrols us. + connected += run_convection( + &network, + *anchor_nid, + anchor_addrs, + crate::protocol::ConvectionClass::Entry, + node_id, + ).await; + if network.conn_handle().mesh_count().await >= 2 { + break; } } + if connected > 0 { + log_evt(ActivityLevel::Info, ActivityCategory::Recovery, format!("Convection produced {} connections", connected), None); + } let conn_count = network.connection_count().await; tracing::info!(connections = conn_count, "Recovery complete"); log_evt(ActivityLevel::Info, ActivityCategory::Recovery, format!("Recovery complete, {} connections", conn_count), None); @@ -4063,6 +4537,117 @@ impl Node { }) } + /// Run one convection exchange against a specific anchor, on demand. + /// + /// Diagnostics + integration testing: the automatic paths pick the anchor + /// themselves (recovery pool-first, the stochastic arm at random), which is + /// correct but untestable. Returns `(peers_connected, refused, elapsed_ms)` + /// — the elapsed time is the point of the cheap-refusal contract. + pub async fn convection_request(&self, anchor: NodeId) -> anyhow::Result<(usize, bool, u128)> { + let started = std::time::Instant::now(); + let mesh = self.network.conn_handle().mesh_count().await; + let class = crate::protocol::ConvectionClass::for_mesh_count(mesh); + let addrs: Vec = { + let s = self.storage.get().await; + s.get_peer_record(&anchor).ok().flatten().map(|r| r.addresses).unwrap_or_default() + }; + if !self.network.is_peer_connected_or_session(&anchor).await { + let eid = iroh::EndpointId::from_bytes(&anchor)?; + let mut ea = iroh::EndpointAddr::from(eid); + for sa in &addrs { + ea = ea.with_ip_addr(*sa); + } + self.network.connect_to_anchor(anchor, ea).await?; + } + let response = self.network.request_convection(&anchor, class).await?; + let refused = response.refused; + let connected = if refused { + 0 + } else { + self.network.act_on_convection(&anchor, &response).await + }; + Ok((connected, refused, started.elapsed().as_millis())) + } + + /// Run the uniques-index exchange (the v0.8 "pull") against every mesh peer. + pub async fn uniques_pull(&self) -> usize { + self.network.uniques_pull_all().await + } + + /// Start the convection loop: the "ask a random known anchor" arm of the + /// per-disconnect stochastic action (round-4/5). + /// + /// The dice are rolled inside `disconnect_peer` under the conn_mgr lock + /// (pure state read + RNG); this loop is where the resulting network I/O + /// happens, so nothing blocks a teardown. It also carries the anchor + /// self-verification probe, which lost its home when the register cycle + /// retired. + pub fn start_convection_loop(&self) -> tokio::task::JoinHandle<()> { + let network = Arc::clone(&self.network); + let storage = Arc::clone(&self.storage); + let node_id = self.node_id; + let alog = Arc::clone(&self.activity_log); + let timer = Arc::clone(&self.last_convection_ms); + let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1); + tokio::spawn(async move { + let log_evt = |level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option| { + if let Ok(mut log) = alog.try_lock() { log.log(level, cat, msg, peer); } + }; + network.conn_handle().set_convection_tx(tx).await; + // Slow maintenance tick: the anchor self-verification probe used to + // hang off the register cycle. + let mut probe_tick = tokio::time::interval(std::time::Duration::from_secs(600)); + probe_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + signal = rx.recv() => { + if signal.is_none() { break; } + // Coalesce a burst of disconnects into one action. + while rx.try_recv().is_ok() {} + + let mesh = network.conn_handle().mesh_count().await; + let class = crate::protocol::ConvectionClass::for_mesh_count(mesh); + let mut anchors = gather_anchor_candidates(&storage, &network, node_id, 12).await; + if anchors.is_empty() { + // No anchor to ask — fall through to the mesh arm + // rather than doing nothing. + network.notify_growth().await; + continue; + } + // "a RANDOM known anchor" — not the best-ranked one. + // Ranking anchors was a scarcity artifact; spreading + // load is what keeps convection windows fresh. + use rand::seq::SliceRandom; + anchors.shuffle(&mut rand::rng()); + let (anchor_nid, anchor_addrs) = anchors.remove(0); + timer.store( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + AtomicOrdering::Relaxed, + ); + let n = run_convection(&network, anchor_nid, &anchor_addrs, class, node_id).await; + if n > 0 { + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, format!("Convection: {} new peers", n), Some(anchor_nid)); + } else { + // Nothing came back — let the mesh arm try. + network.notify_growth().await; + } + } + _ = probe_tick.tick() => { + if network.conn_handle().probe_due().await { + log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Initiating anchor self-verification probe".into(), None); + if let Err(e) = network.conn_handle().initiate_anchor_probe().await { + tracing::debug!(error = %e, "Anchor probe error"); + } + } + } + } + } + }) + } + /// Start social checkin cycle: every interval_secs, refresh stale social routes. /// Uses ephemeral connections if not persistently connected. pub fn start_social_checkin_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { @@ -4108,165 +4693,6 @@ impl Node { }) } - /// Register with all connected anchor peers. Returns count registered. - pub async fn register_with_anchors(&self) -> usize { - let conns = self.network.connection_info().await; - let mut count = 0; - for (nid, _, _) in &conns { - if self.network.is_anchor_peer(nid).await { - match self.network.send_anchor_register(nid).await { - Ok(()) => { - count += 1; - info!(anchor = hex::encode(nid), "Registered with anchor"); - } - Err(e) => debug!(error = %e, anchor = hex::encode(nid), "Anchor register failed"), - } - } - } - count - } - - /// Start anchor register cycle: periodically re-register with anchors and request referrals - /// when connection count is low. - pub fn start_anchor_register_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { - let network = Arc::clone(&self.network); - let storage = Arc::clone(&self.storage); - let node_id = self.node_id; - let alog = Arc::clone(&self.activity_log); - let timer = Arc::clone(&self.last_anchor_register_ms); - tokio::spawn(async move { - let log_evt = |level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option| { - if let Ok(mut log) = alog.try_lock() { log.log(level, cat, msg, peer); } - }; - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - loop { - interval.tick().await; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - timer.store(now, AtomicOrdering::Relaxed); - - // Re-register with connected anchors (mesh + session) - let conns = network.connection_info().await; - let session_peers = network.session_peer_ids().await; - let mut registered_anchors = std::collections::HashSet::new(); - // Mesh-connected anchors - for (nid, _, _) in &conns { - if network.is_anchor_peer(nid).await { - match network.send_anchor_register(nid).await { - Ok(()) => { - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Re-registered with anchor".into(), Some(*nid)); - registered_anchors.insert(*nid); - } - Err(e) => { - tracing::debug!(error = %e, "Anchor re-register failed"); - log_evt(ActivityLevel::Warn, ActivityCategory::Anchor, format!("Re-register failed: {}", e), Some(*nid)); - } - } - } - } - // Session-connected anchors (e.g. anchor with full mesh) - for nid in &session_peers { - if registered_anchors.contains(nid) { continue; } - if network.is_anchor_peer(nid).await { - match network.send_anchor_register(nid).await { - Ok(()) => { - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Re-registered with anchor (session)".into(), Some(*nid)); - } - Err(e) => { - tracing::debug!(error = %e, "Anchor session re-register failed"); - } - } - } - } - - // If few connections, try requesting referrals from known anchors - let conn_count = network.connection_count().await; - if conn_count < 10 { - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, format!("Low connections ({}), requesting referrals", conn_count), None); - let known = { - let s = storage.get().await; - s.list_known_anchors().unwrap_or_default() - }; - for (anchor_nid, anchor_addrs) in known { - if anchor_nid == node_id { - continue; - } - // Connect if not already connected (mesh or session) - if !network.is_peer_connected_or_session(&anchor_nid).await { - let endpoint_id = match iroh::EndpointId::from_bytes(&anchor_nid) { - Ok(eid) => eid, - Err(_) => continue, - }; - let mut addr = iroh::EndpointAddr::from(endpoint_id); - for sa in &anchor_addrs { - addr = addr.with_ip_addr(*sa); - } - if let Err(e) = network.connect_to_anchor(anchor_nid, addr).await { - tracing::debug!(error = %e, "Anchor cycle: connect failed"); - continue; - } - } - match network.request_anchor_referrals(&anchor_nid).await { - Ok(referrals) => { - for referral in referrals { - if referral.node_id == node_id { - continue; - } - if let Some(addr_str) = referral.addresses.first() { - let connect_str = format!( - "{}@{}", - hex::encode(referral.node_id), - addr_str, - ); - if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { - match network.connect_to_peer(rid, raddr).await { - Ok(()) => { - tracing::info!(peer = hex::encode(rid), "Anchor cycle: connected to referred peer"); - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Connected to referred peer".into(), Some(rid)); - } - Err(_) => { - match network.connect_via_introduction(rid, anchor_nid).await { - Ok(()) => { - tracing::info!(peer = hex::encode(rid), "Anchor cycle: connected via hole punch"); - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Connected via hole punch".into(), Some(rid)); - } - Err(e) => { - tracing::debug!(error = %e, peer = hex::encode(rid), "Anchor cycle: hole punch failed"); - log_evt(ActivityLevel::Warn, ActivityCategory::Anchor, format!("Hole punch failed: {}", e), Some(rid)); - } - } - } - } - } - } - } - } - Err(e) => tracing::debug!(error = %e, "Anchor cycle: referral request failed"), - } - } - } - - // Anchor self-verification probe - { - let probe_due = network.conn_handle().probe_due().await; - if probe_due { - log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Initiating anchor self-verification probe".into(), None); - match network.conn_handle().initiate_anchor_probe().await { - Ok(true) => {}, // success already logged inside - Ok(false) => {}, // failure already logged inside - Err(e) => { - tracing::debug!(error = %e, "Anchor probe error"); - } - } - } - } - } - }) - } - /// Start bootstrap connectivity check: 24 hours after startup, verify the bootstrap /// anchor is within our network knowledge (N1/N2/N3). If not, we may be in an isolated /// segment — reconnect to bootstrap and request referrals to bridge back. @@ -4291,20 +4717,18 @@ impl Node { continue; } - // Check if bootstrap is in N1 (mesh), N2, or N3 + // Is the bootstrap anywhere in our N1-N4 horizon? N4 counts: + // it is used for search and resolution, it is only never + // re-announced. let is_reachable = { let connected = node.network.is_connected(&bootstrap_nid).await; if connected { true } else { let storage = node.storage.get().await; - let in_n2 = storage.find_in_n2(&bootstrap_nid).unwrap_or_default(); - if !in_n2.is_empty() { - true - } else { - let in_n3 = storage.find_in_n3(&bootstrap_nid).unwrap_or_default(); - !in_n3.is_empty() - } + storage.find_any_reachable(std::slice::from_ref(&bootstrap_nid)) + .map(|r| !r.is_empty()) + .unwrap_or(false) } }; @@ -4322,26 +4746,16 @@ impl Node { continue; } - // Report bootstrap in our N1 for 24 hours so peers learn about it - node.network.conn_handle().add_sticky_n1(&bootstrap_nid, 24 * 60 * 60 * 1000); - - match node.network.request_anchor_referrals(&bootstrap_nid).await { - Ok(referrals) => { - tracing::info!(count = referrals.len(), "Bootstrap connectivity: got referrals"); - for referral in referrals { - if referral.node_id == node.node_id { continue; } - if let Some(addr_str) = referral.addresses.first() { - let connect_str = format!("{}@{}", hex::encode(referral.node_id), addr_str); - if let Ok((rid, raddr)) = crate::parse_connect_string(&connect_str) { - let _ = node.network.connect_to_peer(rid, raddr).await; - } - } - } - } - Err(e) => { - tracing::warn!(error = %e, "Bootstrap connectivity: referral request failed"); - } - } + // ENTRY class: an isolated segment is exactly the case the + // always-served class exists for. + let n = run_convection( + &node.network, + bootstrap_nid, + &[], + crate::protocol::ConvectionClass::Entry, + node.node_id, + ).await; + tracing::info!(connected = n, "Bootstrap connectivity: convection complete"); } }) } @@ -4427,8 +4841,9 @@ impl Node { if nid == self.node_id { continue; } - // Prefer social peers - if kind != PeerSlotKind::Local && result.len() >= 10 { + // Temp referral slots are never advertised as part of our + // neighborhood. + if !kind.is_mesh() { continue; } let addrs: Vec = storage.get_peer_record(&nid) @@ -4456,13 +4871,16 @@ impl Node { // ---- Blob Eviction ---- /// Compute priority score for a blob. Higher score = keep longer. + /// `own_author_ids` = ALL of this node's posting identities (blob authors + /// are posting ids, never the network NodeId). pub fn compute_blob_priority( &self, candidate: &crate::storage::EvictionCandidate, + own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { - compute_blob_priority_standalone(candidate, &self.node_id, follows, now_ms) + compute_blob_priority_standalone(candidate, own_author_ids, follows, now_ms) } /// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote @@ -4496,17 +4914,20 @@ impl Node { // 1-hour staleness for replica counts let staleness_ms = 3600 * 1000; - let (candidates, follows) = { + let (candidates, follows, own_ids) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - (candidates, follows) + let own_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + (candidates, follows, own_ids) }; // Score and sort ascending (lowest priority first) let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates .iter() - .map(|c| (self.compute_blob_priority(c, &follows, now), c)) + .map(|c| (self.compute_blob_priority(c, &own_ids, &follows, now), c)) .collect(); scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); @@ -4554,48 +4975,30 @@ impl Node { Ok(n) => info!(evicted = n, "Eviction cycle complete"), Err(e) => warn!(error = %e, "Eviction cycle failed"), } - } - }) - } - /// Start UPnP lease renewal cycle. Renews every lease_secs/2. - /// On 3 consecutive failures: clears is_anchor and logs a warning. - pub fn start_upnp_renewal_cycle(&self) -> Option> { - let mapping = self.network.upnp_mapping()?; - let local_port = mapping.local_port; - let external_port = mapping.external_addr.port(); - let interval_secs = (mapping.lease_secs / 2) as u64; - let network = Arc::clone(&self.network); - let alog = Arc::clone(&self.activity_log); - - Some(tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - let mut consecutive_failures: u32 = 0; - loop { - interval.tick().await; - if crate::upnp::renew_upnp_mapping(local_port, external_port).await { - consecutive_failures = 0; - debug!("UPnP: lease renewed (port {})", external_port); - } else { - consecutive_failures += 1; - warn!("UPnP: renewal failed ({}/3)", consecutive_failures); - if consecutive_failures >= 3 { - network.clear_anchor(); - if let Ok(mut log) = alog.try_lock() { - log.log( - ActivityLevel::Warn, - ActivityCategory::Connection, - "UPnP lease lost after 3 renewal failures, auto-anchor disabled".into(), - None, - ); - } - warn!("UPnP: 3 consecutive renewal failures, auto-anchor disabled"); - return; // stop the cycle + // v0.8 (A3): comment-TTL sweep piggybacks the storage- + // hygiene loop. Hard delete — expiry is the forgetting + // mechanism; 300s ticks are far inside 30–365d TTLs. + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let s = node.storage.get().await; + match s.expire_comments(now) { + Ok(0) | Err(_) => {} + Ok(n) => info!(expired = n, "Expired comments swept"), } } + + // v0.8 (A3): registry auto-renew — while "Listed" is + // checked, re-sign a fresh 30d entry when the current one + // expires within 5 days (~every 25 days). + if let Err(e) = node.renew_registry_entries_if_due().await { + debug!(error = %e, "Registry auto-renew pass failed"); + } } - })) + }) } // --- HTTP Post Delivery --- @@ -4613,9 +5016,6 @@ impl Node { } let storage = Arc::clone(&self.storage); let blob_store = Arc::clone(&self.blob_store); - let downstream_addrs = Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::<[u8; 32], Vec>::new(), - )); // Advertise HTTP capability to peers let http_addr = self.network.http_addr(); @@ -4633,7 +5033,7 @@ impl Node { info!("Starting HTTP server on TCP port {}", port); Some(tokio::spawn(async move { - if let Err(e) = crate::http::run_http_server(port, storage, blob_store, downstream_addrs).await { + if let Err(e) = crate::http::run_http_server(port, storage, blob_store).await { warn!("HTTP server stopped: {}", e); } })) @@ -4650,34 +5050,21 @@ impl Node { }) } - /// Start UPnP TCP lease renewal cycle alongside the UDP renewal. + /// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally. pub fn start_upnp_tcp_renewal_cycle(&self) -> Option> { - if !self.network.has_upnp_tcp() { - return None; - } - let mapping = self.network.upnp_mapping()?; - let local_port = mapping.local_port; - let external_port = mapping.external_addr.port(); - let interval_secs = (mapping.lease_secs / 2) as u64; - - Some(tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(interval_secs)); - loop { - interval.tick().await; - if !crate::upnp::renew_upnp_tcp_mapping(local_port, external_port).await { - warn!("UPnP: TCP lease renewal failed"); - // Don't stop the cycle — TCP is best-effort - } - } - })) + None } /// Generate a share link URL for a public post. /// Returns None if post is not public or not found. + /// + /// URL Phase 1 (v0.7.2): the link contains only the post ID — no author + /// hex, no node addresses. The receiving anchor (itsgoin.net) does the + /// holder lookup itself and serves via redirect or QUIC-proxy fallback. + /// Older URLs with `/{post_hex}/{author_hex}` continue to work — the + /// web handler parses the author hex as optional. pub async fn generate_share_link(&self, post_id: &PostId) -> anyhow::Result> { - // Look up the post to verify it's public and get the author - let (post, visibility) = { + let (_post, visibility) = { let store = self.storage.get().await; match store.get_post_with_visibility(post_id)? { Some(pv) => pv, @@ -4690,8 +5077,7 @@ impl Node { } let post_hex = hex::encode(post_id); - let author_hex = hex::encode(post.author); - Ok(Some(format!("https://itsgoin.net/p/{}/{}", post_hex, author_hex))) + Ok(Some(format!("https://itsgoin.net/p/{}", post_hex))) } // --- Engagement API --- @@ -4752,7 +5138,13 @@ impl Node { }; // propagate_engagement_diff targets all file_holders (flat set, max 5) // which already subsumes what used to be upstream + downstream. - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(reaction) @@ -4781,7 +5173,13 @@ impl Node { }], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) @@ -4814,11 +5212,14 @@ impl Node { Ok(reactions) } - /// Get reaction counts grouped by emoji for a post. + /// Get reaction counts grouped by emoji for a post. "Mine" = a reaction + /// from ANY of our posting identities. pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result> { - let our_node_id = self.default_posting_id; let storage = self.storage.get().await; - let counts = storage.get_reaction_counts(&post_id, &our_node_id)?; + let our_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + let counts = storage.get_reaction_counts(&post_id, &our_ids)?; Ok(counts) } @@ -4874,6 +5275,8 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + // v0.8 (A3): TTL drawn BEFORE signing — it's inside the digest. + let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); let signature = crate::crypto::sign_comment( &seed, &our_node_id, @@ -4881,6 +5284,7 @@ impl Node { &content, now, ref_post_id.as_ref(), + expires_at_ms, ); let comment = crate::types::InlineComment { @@ -4894,10 +5298,16 @@ impl Node { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms, }; let storage = self.storage.get().await; - storage.store_comment(&comment)?; + // `store_own_comment`, not `store_comment`: WE authored this, so its + // author (our persona) must stay out of our own uniques announce. + storage.store_own_comment(&comment)?; + // v0.8 (A3): refresh the aggregated header so pulls serve this + // comment without waiting for a diff roundtrip. + let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff to the target post's known holders. @@ -4909,7 +5319,13 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(comment) @@ -4929,6 +5345,7 @@ impl Node { let storage = self.storage.get().await; storage.edit_comment(&our_node_id, &post_id, timestamp_ms, &new_content)?; + let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff @@ -4945,7 +5362,13 @@ impl Node { }], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) } @@ -4963,8 +5386,18 @@ impl Node { let storage = self.storage.get().await; storage.delete_comment(&our_node_id, &post_id, timestamp_ms)?; + let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); + // v0.8 (A3): self-certifying delete signature — holders that + // never met this persona can verify it from the op alone. + let delete_sig = crate::crypto::sign_comment_delete( + &self.default_posting_secret, + &our_node_id, + &post_id, + timestamp_ms, + ); + // Propagate via BlobHeaderDiff { let network = &self.network; @@ -4975,10 +5408,17 @@ impl Node { author: our_node_id, post_id, timestamp_ms, + signature: delete_sig, }], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) } @@ -5013,7 +5453,13 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::SetPolicy(policy)], timestamp_ms: now, }; - network.propagate_engagement_diff(&post_id, &diff, &our_node_id).await; + network.propagate_engagement_diff( + &post_id, + &diff, + // exclude_peer is NETWORK-class (file_holders hold device + // ids) — pass the network NodeId, not a posting id. + &self.node_id, + ).await; } Ok(()) @@ -5078,7 +5524,9 @@ impl Node { }], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } @@ -5113,15 +5561,19 @@ impl Node { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; + // v0.8 (A3): expiry rides the outer plaintext fields so + // non-member holders can expire the comment too. + let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); let comment = crate::fof::build_fof_comment( &post_id, &unlock, &slot_binder_nonce, - &commenter_id, &commenter_secret, &body, None, now, + &commenter_id, &commenter_secret, &body, None, now, expires_at_ms, )?; // Store locally. { let storage = self.storage.get().await; - storage.store_comment(&comment)?; + storage.store_own_comment(&comment)?; + let _ = storage.rebuild_blob_header_from_db(&post_id, &post_author, now); } // Propagate via engagement-diff path. @@ -5131,7 +5583,9 @@ impl Node { ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(comment) } @@ -5212,7 +5666,9 @@ impl Node { }], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } @@ -5295,7 +5751,9 @@ impl Node { }], timestamp_ms: now, }; - self.network.propagate_engagement_diff(&post_id, &diff, &post_author).await; + // exclude_peer is NETWORK-class — pass our device NodeId, not the + // posting-class author id. + self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } @@ -5334,34 +5792,44 @@ impl Node { // --- Encrypted receipt/comment slot methods --- - /// Unwrap the CEK for a post we are a participant of, returning (cek, sorted_participants). + /// Unwrap the CEK for a post we are a participant of, returning + /// (cek, sorted_participants, our_participant_id) where + /// `our_participant_id` is the POSTING identity of ours that actually + /// matched the participant set (participants are posting ids — the + /// network NodeId never appears in them). /// Returns None if this is a public post or we cannot decrypt. async fn get_post_cek_and_participants( &self, post_id: &PostId, - ) -> anyhow::Result)>> { + ) -> anyhow::Result, NodeId)>> { let storage = self.storage.get().await; let (post, visibility) = match storage.get_post_with_visibility(post_id)? { Some(pv) => pv, None => return Ok(None), }; + let personas = storage.list_posting_identities().unwrap_or_default(); drop(storage); match &visibility { - PostVisibility::Public => Ok(None), PostVisibility::Encrypted { recipients } => { - let cek = crypto::unwrap_cek_for_recipient( - &self.default_posting_secret, - &self.node_id, - &post.author, - recipients, - )?; - match cek { - Some(cek) => { + // Try every persona; remember WHICH one unwrapped the CEK. + let matched = personas.iter().find_map(|pi| { + crypto::unwrap_cek_for_recipient( + &pi.secret_seed, + &pi.node_id, + &post.author, + recipients, + ) + .ok() + .flatten() + .map(|cek| (cek, pi.node_id)) + }); + match matched { + Some((cek, our_id)) => { let mut participants: Vec = recipients.iter().map(|wk| wk.recipient).collect(); participants.sort(); participants.dedup(); - Ok(Some((cek, participants))) + Ok(Some((cek, participants, our_id))) } None => Ok(None), } @@ -5386,11 +5854,19 @@ impl Node { } participants.sort(); participants.dedup(); - Ok(Some((cek, participants))) + // Our participant identity = whichever of our personas is + // in the set (admin/member), falling back to the default + // persona if none is listed. + let our_id = personas.iter() + .map(|p| p.node_id) + .find(|id| participants.contains(id)) + .unwrap_or(self.default_posting_id); + Ok(Some((cek, participants, our_id))) } else { Ok(None) } } + PostVisibility::Public => Ok(None), // FoF Layer 3: FoFClosed posts don't use the legacy // receipt/comment slot mechanism — they use the FoF gating's // CEK_comments. This helper isn't used for FoF posts; @@ -5407,13 +5883,14 @@ impl Node { state: crate::types::ReceiptState, emoji: Option, ) -> anyhow::Result<()> { - let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); - // Find our slot index (sorted participant position) - let our_slot = participants.iter().position(|nid| nid == &self.node_id) - .ok_or_else(|| anyhow::anyhow!("our node_id not found in participants"))?; + // Find our slot index (sorted participant position) — participants + // are posting ids, so search for the persona that matched the CEK. + let our_slot = participants.iter().position(|nid| nid == &our_participant_id) + .ok_or_else(|| anyhow::anyhow!("our posting id not found in participants"))?; // Build plaintext: [1 byte state][8 bytes timestamp_ms][23 bytes emoji+padding] let now = std::time::SystemTime::now() @@ -5495,7 +5972,7 @@ impl Node { post_id: PostId, content: String, ) -> anyhow::Result<()> { - let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, _participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5503,9 +5980,12 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - // Build plaintext: [32 bytes author_node_id][8 bytes timestamp_ms][216 bytes content+padding] + // Build plaintext: [32 bytes author_posting_id][8 bytes timestamp_ms][216 bytes content+padding] + // Slot authorship is the matched POSTING identity — never the network + // NodeId (mis-attribution + a persona↔device linkage leak to all + // participants). let mut plaintext = [0u8; 256]; - plaintext[..32].copy_from_slice(&self.node_id); + plaintext[..32].copy_from_slice(&our_participant_id); plaintext[32..40].copy_from_slice(&now.to_le_bytes()); let content_bytes = content.as_bytes(); let copy_len = content_bytes.len().min(216); @@ -5608,7 +6088,7 @@ impl Node { &self, post_id: PostId, ) -> anyhow::Result> { - let (cek, participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5670,7 +6150,7 @@ impl Node { &self, post_id: PostId, ) -> anyhow::Result> { - let (cek, _participants) = self.get_post_cek_and_participants(&post_id).await? + let (cek, _participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); @@ -5722,6 +6202,678 @@ impl Node { } } +/// v0.8 (A3): a received greeting (or reply), unsealed for the inbox. +/// The `(comment_author, post_id, timestamp_ms)` triple is the comment +/// key used by `reply_to_greeting` / `dismiss_greeting`. +#[derive(Debug, Clone)] +pub struct GreetingRecord { + /// Throwaway outer comment identity (comment key part 1). + pub comment_author: NodeId, + /// The bio/return-path post the comment sits on (comment key part 2). + pub post_id: PostId, + /// Comment timestamp (comment key part 3). + pub timestamp_ms: u64, + /// The sender's REAL persona id (recovered from inside the seal). + pub sender_persona: NodeId, + pub sender_name: String, + pub text: String, + /// Post whose Greeting open slot a reply goes into (inside the seal). + pub return_path: PostId, + /// Fresh per-greeting x25519 pubkey replies must be sealed to. + pub reply_pubkey: [u8; 32], +} + +// --- v0.8 (A3): registry + greetings API --- +impl Node { + /// Per-persona greeting consent toggle. Republishes the persona's bio + /// so the greeting slot appears/disappears on the wire. + /// + /// Turning consent OFF also revokes the Greeting open-slot pub_x of + /// every previously published bio: old bios never expire, and each + /// carries its own valid open slot — without revocation, holders keep + /// accepting greetings on superseded bios indefinitely (each with its + /// own 64-greeting cap). `revoke_fof_commenter` is the designated + /// global off-switch (spec §1.7): the RevocationEntry propagates on + /// the standard rails and holders cascade-purge stored greetings. + pub async fn set_greetings_open(&self, posting_id: &NodeId, open: bool) -> anyhow::Result<()> { + let (secret, display_name, bio, avatar, greeting_slots) = { + let s = self.storage.get().await; + s.set_setting( + &greetings_open_setting_key(posting_id), + if open { "1" } else { "0" }, + )?; + let identity = s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; + let profile = s.get_profile(posting_id)?; + // When closing: collect every prior post by this persona that + // declares a Greeting open slot, so its pub_x can be revoked. + let mut slots: Vec<(PostId, u32)> = Vec::new(); + if !open { + for (post_id, post) in s.list_gated_posts_by_author(posting_id)? { + if let Some(decl) = post + .fof_gating + .as_ref() + .and_then(|g| g.open_slot.as_ref()) + { + if decl.kind == crate::types::OpenSlotKind::Greeting { + slots.push((post_id, decl.slot_index)); + } + } + } + } + ( + identity.secret_seed, + profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(), + profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(), + profile.as_ref().and_then(|p| p.avatar_cid), + slots, + ) + }; + // Republish the bio with the new consent state. + self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?; + // Off-switch: revoke the greeting slot on every prior bio so + // holders stop accepting (and purge) greetings on them. + for (post_id, slot_index) in greeting_slots { + if let Err(e) = self + .revoke_fof_commenter(post_id, slot_index, GREETING_CONSENT_REVOKE_REASON) + .await + { + warn!( + post = hex::encode(post_id), + slot = slot_index, + error = %e, + "Failed to revoke greeting slot on prior bio" + ); + } + } + Ok(()) + } + + /// Current greeting-consent state (unset = ON, the pre-checked default). + pub async fn get_greetings_open(&self, posting_id: &NodeId) -> anyhow::Result { + let s = self.storage.get().await; + Ok(greetings_open_setting(&s, posting_id)) + } + + /// Best-effort network fetch of a post we don't hold: content-search + /// worm by post id, then PostFetch from the reported holders, stored + /// through the standard receive path. + async fn fetch_post_best_effort(&self, post_id: &PostId) -> anyhow::Result> { + let search = self + .network + .content_search(&[0u8; 32], Some(*post_id), None) + .await + .ok() + .flatten(); + if let Some(result) = search { + let holders: Vec = [result.post_holder, Some(result.node_id)] + .into_iter() + .flatten() + .collect(); + for holder in holders { + let _ = self.connect_by_node_id(holder).await; + if let Ok(Some(sp)) = self.network.post_fetch(&holder, post_id).await { + let s = self.storage.get().await; + let _ = crate::control::receive_post( + &s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref(), + ); + return Ok(s.get_post(post_id)?); + } + } + } + Ok(None) + } + + /// Shared greeting/reply sender: seal `text` to `recipient_x25519_pub` + /// and drop it into `target_post_id`'s Greeting open slot under a + /// freshly-minted throwaway outer identity. The sealed body carries + /// the ACTING persona's identity + next-hop return path (that + /// persona's bio post) + a fresh reply key. `acting_persona` must be + /// a local posting identity — passing the wrong persona here would + /// disclose an unrelated persona's real posting key inside the seal, + /// silently linking personas the architecture keeps unlinkable. + async fn send_sealed_via_open_slot( + &self, + acting_persona: &NodeId, + target_post_id: PostId, + recipient_x25519_pub: [u8; 32], + text: &str, + ) -> anyhow::Result<()> { + if text.chars().count() > 600 { + anyhow::bail!("greeting text over 600 chars"); + } + + // Load the target post (fetch if absent), require a Greeting slot. + let target_post = { + let s = self.storage.get().await; + s.get_post(&target_post_id)? + }; + let target_post = match target_post { + Some(p) => p, + None => self + .fetch_post_best_effort(&target_post_id) + .await? + .ok_or_else(|| anyhow::anyhow!("target post not held and not fetchable"))?, + }; + let decl = target_post + .fof_gating + .as_ref() + .and_then(|g| g.open_slot.as_ref()) + .ok_or_else(|| anyhow::anyhow!("post declares no open slot"))? + .clone(); + if decl.kind != crate::types::OpenSlotKind::Greeting { + anyhow::bail!("post's open slot is not a Greeting slot"); + } + let slot_binder_nonce = target_post.fof_gating.as_ref().unwrap().slot_binder_nonce; + + // The acting persona's return path + display name + fresh reply + // keypair. Everything inside the seal is per-persona: identity, + // name, and return-path bio must all belong to `acting_persona`. + let (return_path, sender_name) = { + let s = self.storage.get().await; + // Refuse to seal anything if the persona isn't local — a + // wrong id here would misattribute the message. + s.get_posting_identity(acting_persona)? + .ok_or_else(|| anyhow::anyhow!("acting persona not on this device"))?; + let rp = s + .get_latest_profile_post_id_by_author(acting_persona)? + .ok_or_else(|| anyhow::anyhow!( + "no bio post to use as return path — set a profile first (`name `)" + ))?; + let name = s + .get_profile(acting_persona)? + .map(|p| p.display_name) + .unwrap_or_default(); + (rp, name) + }; + let (reply_priv, reply_pub) = crypto::generate_x25519_keypair(); + { + let s = self.storage.get().await; + s.store_greeting_reply_key(&reply_pub, &reply_priv, &return_path)?; + } + + // Sealed body: real (acting) persona + return path + fresh reply key. + let body = crate::types::GreetingBody { + v: 1, + sender_persona: hex::encode(acting_persona), + sender_name: sender_name.chars().take(64).collect(), + text: text.to_string(), + return_path: hex::encode(return_path), + reply_pubkey: hex::encode(reply_pub), + }; + let plaintext = serde_json::to_vec(&body)?; + let sealed = crypto::seal_greeting_body( + &recipient_x25519_pub, + &target_post_id, + &plaintext, + decl.body_bucket as usize, + )?; + let sealed_b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(&sealed) + }; + + // Throwaway outer identity — minted per greeting, never reused. + // It exists in the network only through this comment and vanishes + // when the comment expires (§20 identity hygiene). + let throwaway_key = iroh::SecretKey::generate(&mut rand::rng()); + let throwaway_seed: [u8; 32] = throwaway_key.to_bytes(); + let throwaway_id: NodeId = *throwaway_key.public().as_bytes(); + + let unlock = crate::fof::derive_open_slot_unlock(&target_post, &throwaway_id) + .ok_or_else(|| anyhow::anyhow!("open slot did not unlock (revoked or malformed)"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let expires_at_ms = crate::registry::draw_ordinary_comment_expiry(now); + let comment = crate::fof::build_fof_comment( + &target_post_id, + &unlock, + &slot_binder_nonce, + &throwaway_id, + &throwaway_seed, + &sealed_b64, + None, + now, + expires_at_ms, + )?; + + { + let s = self.storage.get().await; + // Greeting: the author is a per-greeting THROWAWAY id. We are the + // first node in the network that could announce it, at bounce 1, + // the moment it is created — first-announcer identifies the + // greeter. `store_own_comment` keeps it out of our announce. + s.store_own_comment(&comment)?; + let _ = s.rebuild_blob_header_from_db(&target_post_id, &target_post.author, now); + } + + // Propagate on the existing engagement rail. + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id: target_post_id, + author: target_post.author, + ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], + timestamp_ms: now, + }; + self.network.propagate_engagement_diff(&target_post_id, &diff, &self.node_id).await; + Ok(()) + } + + /// Send a sealed first-contact greeting to a bio post's author. + /// Messaging-first: no vouch is involved anywhere in this flow. + pub async fn send_greeting(&self, bio_post_id: PostId, text: String) -> anyhow::Result<()> { + // Recipient key: the bio author's posting key, converted to x25519. + let author = { + let s = self.storage.get().await; + s.get_post(&bio_post_id)?.map(|p| p.author) + }; + let author = match author { + Some(a) => a, + None => self + .fetch_post_best_effort(&bio_post_id) + .await? + .map(|p| p.author) + .ok_or_else(|| anyhow::anyhow!("bio post not held and not fetchable"))?, + }; + let recipient = crypto::ed25519_pubkey_to_x25519_public(&author)?; + // Outbound first-contact greetings act as the default persona. + let acting = self.default_posting_id; + self.send_sealed_via_open_slot(&acting, bio_post_id, recipient, &text).await + } + + /// Reply to a received greeting: sealed to the greeting's fresh + /// `reply_pubkey` (never a long-term key), dropped into the sender's + /// declared `return_path` post through its Greeting open slot, under + /// a fresh throwaway outer identity. Each reply carries OUR next-hop + /// return path + fresh reply key. + pub async fn reply_to_greeting( + &self, + comment_author: NodeId, + post_id: PostId, + timestamp_ms: u64, + text: String, + ) -> anyhow::Result<()> { + let greeting = self + .list_greetings() + .await? + .into_iter() + .find(|g| { + g.comment_author == comment_author + && g.post_id == post_id + && g.timestamp_ms == timestamp_ms + }) + .ok_or_else(|| anyhow::anyhow!("greeting not found (expired or dismissed?)"))?; + // Act as the persona the greeting was ADDRESSED TO — the author + // of the bio post it arrived on. Hardcoding the default persona + // here would leak the default persona's real posting key + bio + // into the seal, silently linking two personas (and answering as + // someone the counterparty never greeted). + let acting = { + let s = self.storage.get().await; + let bio_author = s + .get_post(&greeting.post_id)? + .map(|p| p.author) + .ok_or_else(|| anyhow::anyhow!("greeting's bio post no longer held"))?; + s.get_posting_identity(&bio_author)? + .ok_or_else(|| anyhow::anyhow!( + "persona the greeting was addressed to is not on this device" + ))? + .node_id + }; + self.send_sealed_via_open_slot(&acting, greeting.return_path, greeting.reply_pubkey, &text) + .await + } + + /// Unseal + list greetings (and replies) on all of our personas' bio + /// posts. Original greetings open with the persona's long-term key; + /// replies open with the stored per-greeting reply private keys. + /// Dismissed rows are skipped. + pub async fn list_greetings(&self) -> anyhow::Result> { + use base64::Engine; + let s = self.storage.get().await; + let personas = s.list_posting_identities()?; + let reply_keys = s.list_greeting_reply_keys()?; + let mut out: Vec = Vec::new(); + + for persona in &personas { + let persona_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); + for (post_id, post) in s.list_gated_posts_by_author(&persona.node_id)? { + let Some(gating) = post.fof_gating.as_ref() else { continue }; + let Some(decl) = gating.open_slot.as_ref() else { continue }; + if decl.kind != crate::types::OpenSlotKind::Greeting { + continue; + } + for c in s.get_comments(&post_id)? { + if c.pub_x_index != Some(decl.slot_index) { + continue; + } + if s.is_greeting_dismissed(&c.author, &post_id, c.timestamp_ms)? { + continue; + } + // Outer layer: the CEK is public (derivable open slot). + let Some(unlock) = crate::fof::derive_open_slot_unlock(&post, &c.author) + else { continue }; + let Ok(payload) = crate::fof::decrypt_fof_comment_payload( + &c, &unlock.cek, &gating.slot_binder_nonce, + ) else { continue }; + let Ok(sealed) = + base64::engine::general_purpose::STANDARD.decode(payload.body.as_bytes()) + else { continue }; + // Inner seal: long-term persona key (original + // greetings) or a stored fresh reply key (replies). + let plain = crypto::open_greeting_body(&persona_priv, &post_id, &sealed) + .or_else(|| { + reply_keys.iter().find_map(|(privkey, _rp)| { + crypto::open_greeting_body(privkey, &post_id, &sealed) + }) + }); + let Some(plain) = plain else { continue }; + let Ok(body) = serde_json::from_slice::(&plain) + else { continue }; + let Ok(sender_persona) = crate::parse_node_id_hex(&body.sender_persona) + else { continue }; + let Ok(return_path) = crate::parse_node_id_hex(&body.return_path) + else { continue }; + let Ok(reply_pubkey) = crate::parse_node_id_hex(&body.reply_pubkey) + else { continue }; + out.push(GreetingRecord { + comment_author: c.author, + post_id, + timestamp_ms: c.timestamp_ms, + sender_persona, + sender_name: body.sender_name, + text: body.text, + return_path, + reply_pubkey, + }); + } + } + } + out.sort_by_key(|g| std::cmp::Reverse(g.timestamp_ms)); + Ok(out) + } + + /// Dismiss a greeting (local only — nothing propagates). + pub async fn dismiss_greeting( + &self, + comment_author: NodeId, + post_id: PostId, + timestamp_ms: u64, + ) -> anyhow::Result<()> { + let s = self.storage.get().await; + s.add_greeting_dismissal(&comment_author, &post_id, timestamp_ms) + } + + /// Register a persona in the network registry: a plaintext, + /// self-certifying entry {name, keywords} signed by the persona's + /// REAL posting key, fixed 30-day TTL, newest-wins per persona. + /// Re-running renews. Sets the "Listed" flag for auto-renew. + pub async fn register_persona( + &self, + posting_id: &NodeId, + name: &str, + keywords: &[String], + ) -> anyhow::Result<()> { + let entry = crate::registry::RegistrationEntry { + v: 1, + name: name.to_string(), + keywords: keywords.to_vec(), + }; + let entry_json = serde_json::to_string(&entry)?; + // Enforce shape limits locally before signing anything. + crate::registry::parse_registration(&entry_json)?; + + let (secret_seed, registry_post) = { + let s = self.storage.get().await; + let identity = s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; + let _ = crate::registry::materialize_registry_post(&s); + let post = s.get_post(&crate::registry::REGISTRY_POST_ID)? + .ok_or_else(|| anyhow::anyhow!("registry post missing after materialization"))?; + (identity.secret_seed, post) + }; + + let unlock = crate::fof::derive_open_slot_unlock(®istry_post, posting_id) + .ok_or_else(|| anyhow::anyhow!("registry open slot did not unlock"))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let expires_at_ms = now + crate::registry::REGISTRATION_TTL_MS; + + // group_sig over (content_bytes || post_id || pub_x_index_le) — + // the plaintext-open-slot form of the standard scheme. + let group_sig = { + use ed25519_dalek::{Signer, SigningKey}; + let signer = SigningKey::from_bytes(&unlock.priv_x_seed); + let mut to_sign = Vec::with_capacity(entry_json.len() + 32 + 4); + to_sign.extend_from_slice(entry_json.as_bytes()); + to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); + to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); + signer.sign(&to_sign).to_bytes().to_vec() + }; + + // The standard comment signature IS the self-certification: the + // entry names its persona in `author`, verified against that key. + let signature = crypto::sign_comment( + &secret_seed, + posting_id, + &crate::registry::REGISTRY_POST_ID, + &entry_json, + now, + None, + expires_at_ms, + ); + let comment = crate::types::InlineComment { + author: *posting_id, + post_id: crate::registry::REGISTRY_POST_ID, + content: entry_json, + timestamp_ms: now, + signature, + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(unlock.slot_index), + group_sig: Some(group_sig), + encrypted_payload: None, + expires_at_ms, + }; + + { + let s = self.storage.get().await; + // Newest-wins locally (deletes our older entries). + let _ = s.upsert_registry_entry_newest_wins( + &crate::registry::REGISTRY_POST_ID, posting_id, now, + ); + s.store_own_comment(&comment)?; + let _ = s.rebuild_blob_header_from_db( + &crate::registry::REGISTRY_POST_ID, ®istry_post.author, now, + ); + let id_hex = hex::encode(posting_id); + s.set_setting(&format!("registry_listed.{}", id_hex), "1")?; + s.set_setting(&format!("registry_name.{}", id_hex), name)?; + s.set_setting(&format!("registry_keywords.{}", id_hex), &keywords.join(","))?; + } + + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id: crate::registry::REGISTRY_POST_ID, + author: registry_post.author, + ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], + timestamp_ms: now, + }; + self.network + .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) + .await; + Ok(()) + } + + /// Remove a persona's registry entry via a self-certifying signed + /// DeleteComment (honored by holders that never met the persona). + /// Clears the "Listed" flag. + pub async fn unregister_persona(&self, posting_id: &NodeId) -> anyhow::Result<()> { + let (secret_seed, newest) = { + let s = self.storage.get().await; + let identity = s.get_posting_identity(posting_id)? + .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; + let newest = s.get_newest_registry_entry( + &crate::registry::REGISTRY_POST_ID, posting_id, + )?; + s.set_setting(&format!("registry_listed.{}", hex::encode(posting_id)), "0")?; + (identity.secret_seed, newest) + }; + let Some((entry_ts, _exp)) = newest else { + return Ok(()); // nothing listed — flag cleared, done + }; + + let delete_sig = crypto::sign_comment_delete( + &secret_seed, + posting_id, + &crate::registry::REGISTRY_POST_ID, + entry_ts, + ); + { + let s = self.storage.get().await; + let _ = s.delete_comment(posting_id, &crate::registry::REGISTRY_POST_ID, entry_ts); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let _ = s.rebuild_blob_header_from_db( + &crate::registry::REGISTRY_POST_ID, &crate::DEFAULT_ANCHOR_POSTING_ID, now_ms, + ); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + let diff = crate::protocol::BlobHeaderDiffPayload { + post_id: crate::registry::REGISTRY_POST_ID, + author: crate::DEFAULT_ANCHOR_POSTING_ID, + ops: vec![crate::types::BlobHeaderDiffOp::DeleteComment { + author: *posting_id, + post_id: crate::registry::REGISTRY_POST_ID, + timestamp_ms: entry_ts, + signature: delete_sig, + }], + timestamp_ms: now, + }; + self.network + .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) + .await; + Ok(()) + } + + /// Search the registry: refresh the chain from up to 3 connected + /// peers (BlobHeaderRequest via the existing engagement-fetch rail), + /// then query locally. Search cost lands on the searcher (design §27). + pub async fn search_registry( + &self, + query: &str, + ) -> anyhow::Result> { + // Mark the registry post due so the engagement fetch includes it. + { + let s = self.storage.get().await; + let _ = crate::registry::materialize_registry_post(&s); + let _ = s.update_post_last_check(&crate::registry::REGISTRY_POST_ID, 0); + } + let peers = self.list_connections().await; + for (peer, _slot, _ts) in peers.into_iter().take(3) { + let _ = self.network.conn_handle().fetch_engagement_from_peer(&peer).await; + } + let s = self.storage.get().await; + crate::registry::search_entries(&s, query) + } + + /// Auto-renew (round 8, DECIDED): while the "Listed" flag is set, + /// re-sign a fresh 30d entry when the current one expires within 5 + /// days (~every 25 days). Piggybacked on the eviction cycle. + pub async fn renew_registry_entries_if_due(&self) -> anyhow::Result { + const RENEW_WINDOW_MS: u64 = 5 * 24 * 3600 * 1000; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_millis() as u64; + + let due: Vec<(NodeId, String, Vec)> = { + let s = self.storage.get().await; + let mut due = Vec::new(); + for persona in s.list_posting_identities()? { + let id_hex = hex::encode(persona.node_id); + let listed = s + .get_setting(&format!("registry_listed.{}", id_hex))? + .map(|v| v == "1") + .unwrap_or(false); + if !listed { + continue; + } + let newest = s.get_newest_registry_entry( + &crate::registry::REGISTRY_POST_ID, + &persona.node_id, + )?; + let needs_renew = match newest { + Some((_ts, exp)) => exp <= now + RENEW_WINDOW_MS, + None => true, + }; + if !needs_renew { + continue; + } + let name = s + .get_setting(&format!("registry_name.{}", id_hex))? + .unwrap_or_else(|| persona.display_name.clone()); + let keywords: Vec = s + .get_setting(&format!("registry_keywords.{}", id_hex))? + .unwrap_or_default() + .split(',') + .filter(|k| !k.is_empty()) + .map(|k| k.to_string()) + .collect(); + due.push((persona.node_id, name, keywords)); + } + due + }; + + let mut renewed = 0usize; + for (persona_id, name, keywords) in due { + if self.register_persona(&persona_id, &name, &keywords).await.is_ok() { + renewed += 1; + } + } + Ok(renewed) + } + + /// One-shot genesis publish of the registry post (`--publish-registry`). + /// Refuses unless the default posting identity is the bootstrap + /// anchor's (mirrors `publish_announcement`). Debug builds may bypass + /// via `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1` for multi-node tests. + pub async fn publish_registry_genesis(&self) -> anyhow::Result { + #[allow(unused_mut)] + let mut allowed = self.default_posting_id == crate::DEFAULT_ANCHOR_POSTING_ID; + #[cfg(debug_assertions)] + { + if std::env::var("ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS").as_deref() == Ok("1") { + allowed = true; + } + } + if !allowed { + anyhow::bail!( + "refusing to publish registry genesis: default posting identity is not the bootstrap anchor" + ); + } + { + let s = self.storage.get().await; + let _ = crate::registry::materialize_registry_post(&s)?; + } + self.update_neighbor_manifests_as( + &self.default_posting_id, + &self.default_posting_secret, + &crate::registry::REGISTRY_POST_ID, + crate::registry::REGISTRY_GENESIS_TIMESTAMP_MS, + ).await; + info!( + post_id = hex::encode(crate::registry::REGISTRY_POST_ID), + "Registry genesis published" + ); + Ok(crate::registry::REGISTRY_POST_ID) + } +} + pub struct NodeStats { pub post_count: usize, pub peer_count: usize, @@ -5732,7 +6884,7 @@ pub struct NodeStats { /// score = pin_boost + (relationship × heart_recency × freshness / (peer_copies + 1)) pub fn compute_blob_priority_standalone( candidate: &crate::storage::EvictionCandidate, - our_node_id: &NodeId, + own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { @@ -5749,7 +6901,8 @@ pub fn compute_blob_priority_standalone( }; // v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other. - let relationship = if candidate.author == *our_node_id { + // Authors are posting identities — check against ALL of our personas. + let relationship = if own_author_ids.contains(&candidate.author) { 5.0 } else if follows.contains(&candidate.author) { 2.0 @@ -5773,7 +6926,8 @@ pub fn compute_blob_priority_standalone( impl Node { /// Start the active replication cycle: periodically ask peers to hold our - /// under-replicated recent content. Only Available/Persistent devices initiate. + /// under-replicated recent content. All devices initiate — phones need + /// their content replicated before they go to sleep. pub fn start_replication_cycle(self: &Arc, interval_secs: u64) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { @@ -5819,13 +6973,19 @@ impl Node { // Single lock: get under-replicated posts AND peer roles/pressure let (under_replicated, suitable_peers) = { let storage = self.storage.get().await; - let recent_ids = match storage.get_own_recent_post_ids(&self.node_id, since_ms) { - Ok(ids) => ids, - Err(e) => { - debug!(error = %e, "Replication: failed to get own recent posts"); - return; + // Own posts are authored by posting identities (personas), never + // the network NodeId — union recent posts across all personas. + let personas = storage.list_posting_identities().unwrap_or_default(); + let mut recent_ids: Vec = Vec::new(); + for persona in &personas { + match storage.get_own_recent_post_ids(&persona.node_id, since_ms) { + Ok(ids) => recent_ids.extend(ids), + Err(e) => { + debug!(error = %e, "Replication: failed to get own recent posts"); + return; + } } - }; + } // Filter to under-replicated (< 2 holders) let mut needs_replication = Vec::new(); @@ -5944,7 +7104,7 @@ mod tests { let candidate = make_candidate(our_id, true, now - 86400_000, now, 0); let score = compute_blob_priority_standalone( - &candidate, &our_id, &[], now, + &candidate, &[our_id], &[], now, ); assert!(score > 1000.0, "own pinned should score >1000, got {}", score); } @@ -5958,7 +7118,7 @@ mod tests { let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0); let follow_score = compute_blob_priority_standalone( - &follow_candidate, &our_id, &[follow_id], now, + &follow_candidate, &[our_id], &[follow_id], now, ); let stranger_candidate = make_candidate( @@ -5968,7 +7128,7 @@ mod tests { 5, ); let stranger_score = compute_blob_priority_standalone( - &stranger_candidate, &our_id, &[], now, + &stranger_candidate, &[our_id], &[], now, ); assert!(follow_score > stranger_score, @@ -5989,7 +7149,7 @@ mod tests { 10, ); let score = compute_blob_priority_standalone( - &candidate, &our_id, &[], now, + &candidate, &[our_id], &[], now, ); assert!(score < 0.01, "stranger stale should score near 0, got {}", score); @@ -6006,11 +7166,34 @@ mod tests { let follow = make_candidate(follow_id, false, now - 86400_000, now, 0); let stranger = make_candidate(stranger_id, false, now - 30 * 86400_000, now - 30 * 86400_000, 10); - let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], now); - let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], now); - let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], now); + let own_score = compute_blob_priority_standalone(&own, &[our_id], &[follow_id], now); + let follow_score = compute_blob_priority_standalone(&follow, &[our_id], &[follow_id], now); + let stranger_score = compute_blob_priority_standalone(&stranger, &[our_id], &[follow_id], now); assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score); assert!(follow_score > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score); } + + /// A1 (bug 4): blobs authored by ANY of our posting identities get the + /// own-content 5.0 tier — including non-default personas — and the + /// network NodeId never matches (posting authors only). + #[test] + fn second_persona_blob_scores_as_own() { + let persona1 = make_node_id(1); + let persona2 = make_node_id(2); + let network_id = make_node_id(9); + let now = 10_000_000_000u64; + + let by_second = make_candidate(persona2, false, now - 86400_000, now, 0); + let by_network = make_candidate(network_id, false, now - 86400_000, now, 0); + + let own_ids = [persona1, persona2]; + let second_score = compute_blob_priority_standalone(&by_second, &own_ids, &[], now); + let network_score = compute_blob_priority_standalone(&by_network, &own_ids, &[], now); + // Identical candidates → the only difference is the relationship + // tier: 5.0 (own persona) vs 0.1 (stranger). Ratio must reflect it. + assert!(second_score > network_score * 10.0, + "persona2 blob ({}) must be own-tier vs network-authored ({})", + second_score, network_score); + } } diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index 7d584f8..2617e25 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -68,7 +68,6 @@ pub fn apply_profile_post_if_applicable( updated_at: content.timestamp_ms, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid: content.avatar_cid, }; @@ -175,6 +174,9 @@ pub fn scan_vouch_grants_for_all_personas( /// batch distributing the persona's current `V_me` to vouched personas. /// `bio_epoch` is a monotonic per-persona counter that lets receivers /// short-circuit re-scanning unchanged bios. +/// v0.8 (A3): `fof_gating` carries the bio's comment gating — including +/// the Greeting open slot when the persona's `greetings_open` consent is +/// on. `None` publishes a bio that accepts no greetings. pub fn build_profile_post( author: &NodeId, author_secret: &[u8; 32], @@ -183,6 +185,7 @@ pub fn build_profile_post( avatar_cid: Option<[u8; 32]>, vouch_grants: Option, bio_epoch: u32, + fof_gating: Option, ) -> Post { let timestamp_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -203,8 +206,8 @@ pub fn build_profile_post( content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms, - fof_gating: None, - supersedes_post_id: None, + fof_gating, + supersedes_post_id: None, } } @@ -345,7 +348,7 @@ mod tests { let s = temp_storage(); let (sec, pub_id) = make_keypair(11); - let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0); + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0, None); apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); @@ -360,7 +363,7 @@ mod tests { let (sec_b, _pub_b) = make_keypair(2); // Build a post claiming `pub_a` but signing with `sec_b`. - let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0); + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0, None); let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); assert!(res.is_err()); assert!(s.get_profile(&pub_a).unwrap().is_none()); @@ -372,7 +375,7 @@ mod tests { let (sec, pub_id) = make_keypair(3); // Seed with a newer profile. - let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0); + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0, None); // Hack the timestamp to make it clearly newer. let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); content.timestamp_ms = 10_000; @@ -382,7 +385,7 @@ mod tests { apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); // Apply an older profile — should be ignored. - let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0); + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0, None); let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); content_o.timestamp_ms = 5_000; content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 0221c3c..1e354b8 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -1,12 +1,15 @@ use serde::{Deserialize, Serialize}; use crate::types::{ - BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId, - PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId, + BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId, + PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, WormId, }; -/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture) -pub const ALPN_V2: &[u8] = b"itsgoin/3"; +/// Wire-protocol ALPN. One protocol version per build, no negotiation — +/// mismatched versions are refused at the QUIC handshake. v0.8 = itsgoin/4: +/// clean break from <=v0.7.x (zero-users ruling, design.html Appendix D +/// item 3). +pub const ALPN: &[u8] = b"itsgoin/4"; /// A post bundled with its visibility metadata for sync #[derive(Debug, Serialize, Deserialize)] @@ -15,9 +18,8 @@ pub struct SyncPost { pub post: Post, pub visibility: PostVisibility, /// Optional originator's intent, so receivers can filter control posts - /// out of the feed and process their ControlOp payload. Absent on - /// pre-v0.6.2 senders; receivers treat as "unknown"/regular post. - #[serde(default)] + /// out of the feed and process their ControlOp payload. None = regular + /// post. pub intent: Option, } @@ -25,23 +27,29 @@ pub struct SyncPost { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] pub enum MessageType { - NodeListUpdate = 0x01, + UniquesAnnounce = 0x01, InitialExchange = 0x02, AddressRequest = 0x03, AddressResponse = 0x04, RefuseRedirect = 0x05, - PullSyncRequest = 0x40, - PullSyncResponse = 0x41, - // 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), - // 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes - // are gone. Public posts propagate via the CDN; encrypted posts via pull. + /// The uniques-index exchange (design.html §sync). A pull is no longer a + /// post transfer: it is "if you want these IDs, talk to me and I'll help + /// you find them". Symmetric — request carries our pools, response carries + /// theirs, one round trip updates both sides. + UniquesPullRequest = 0x40, + UniquesPullResponse = 0x41, + /// TRANSITIONAL (Iteration D folds this into the update-cadence scheduler + + /// CDN replication). Content still has to move somewhere while + /// PostFetchRequest (0xD4) serves Public posts only: DMs, group posts, + /// Friends and FoFClosed posts have no other carrier. Splitting it off + /// 0x40/0x41 keeps §sync honest — a *pull* is not a post transfer, because + /// post transfer is not a pull. + ContentSyncRequest = 0x46, + ContentSyncResponse = 0x47, ProfileUpdate = 0x50, - DeleteRecord = 0x51, - VisibilityUpdate = 0x52, WormQuery = 0x60, WormResponse = 0x61, SocialAddressUpdate = 0x70, - SocialDisconnectNotice = 0x71, SocialCheckin = 0x72, // 0x80-0x81 reserved BlobRequest = 0x90, @@ -49,19 +57,15 @@ pub enum MessageType { ManifestRefreshRequest = 0x92, ManifestRefreshResponse = 0x93, ManifestPush = 0x94, - // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. - // 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel - // as encrypted posts via the CDN. See `group_key_distribution` module. - GroupKeyRequest = 0xA1, - GroupKeyResponse = 0xA2, RelayIntroduce = 0xB0, RelayIntroduceResult = 0xB1, SessionRelay = 0xB2, - MeshPrefer = 0xB3, CircleProfileUpdate = 0xB4, - AnchorRegister = 0xC0, - AnchorReferralRequest = 0xC1, - AnchorReferralResponse = 0xC2, + // 0xC0 retired in v0.8 — anchors keep a rolling window of recent callers + // fed by the request itself; there is no registration message and no + // registration database. + ConvectionRequest = 0xC1, + ConvectionResponse = 0xC2, AnchorProbeRequest = 0xC3, AnchorProbeResult = 0xC4, PortScanHeartbeat = 0xC5, @@ -83,36 +87,31 @@ pub enum MessageType { impl MessageType { pub fn from_byte(b: u8) -> Option { match b { - 0x01 => Some(Self::NodeListUpdate), + 0x01 => Some(Self::UniquesAnnounce), 0x02 => Some(Self::InitialExchange), 0x03 => Some(Self::AddressRequest), 0x04 => Some(Self::AddressResponse), 0x05 => Some(Self::RefuseRedirect), - 0x40 => Some(Self::PullSyncRequest), - 0x41 => Some(Self::PullSyncResponse), + 0x40 => Some(Self::UniquesPullRequest), + 0x41 => Some(Self::UniquesPullResponse), + 0x46 => Some(Self::ContentSyncRequest), + 0x47 => Some(Self::ContentSyncResponse), 0x50 => Some(Self::ProfileUpdate), - 0x51 => Some(Self::DeleteRecord), - 0x52 => Some(Self::VisibilityUpdate), 0x60 => Some(Self::WormQuery), 0x61 => Some(Self::WormResponse), 0x70 => Some(Self::SocialAddressUpdate), - 0x71 => Some(Self::SocialDisconnectNotice), 0x72 => Some(Self::SocialCheckin), 0x90 => Some(Self::BlobRequest), 0x91 => Some(Self::BlobResponse), 0x92 => Some(Self::ManifestRefreshRequest), 0x93 => Some(Self::ManifestRefreshResponse), 0x94 => Some(Self::ManifestPush), - 0xA1 => Some(Self::GroupKeyRequest), - 0xA2 => Some(Self::GroupKeyResponse), 0xB0 => Some(Self::RelayIntroduce), 0xB1 => Some(Self::RelayIntroduceResult), 0xB2 => Some(Self::SessionRelay), - 0xB3 => Some(Self::MeshPrefer), 0xB4 => Some(Self::CircleProfileUpdate), - 0xC0 => Some(Self::AnchorRegister), - 0xC1 => Some(Self::AnchorReferralRequest), - 0xC2 => Some(Self::AnchorReferralResponse), + 0xC1 => Some(Self::ConvectionRequest), + 0xC2 => Some(Self::ConvectionResponse), 0xC3 => Some(Self::AnchorProbeRequest), 0xC4 => Some(Self::AnchorProbeResult), 0xC5 => Some(Self::PortScanHeartbeat), @@ -140,13 +139,152 @@ impl MessageType { // --- Payload structs --- -/// Initial exchange: N1/N2 node lists + profile + deletes + post_ids + peer addresses +/// A packed set of 32-byte IDs: base64 of the raw concatenated bytes. +/// +/// `NodeId = [u8; 32]` serialised through serde_json becomes a JSON array of 32 +/// decimal numbers (~100-130 bytes/ID — a 3-4x blowup). The uniques pools can +/// carry thousands of IDs, so they ride packed instead. +pub type PackedIds = String; + +/// Encode a set of IDs into a packed base64 blob. +pub fn pack_ids(ids: &[NodeId]) -> PackedIds { + use base64::Engine; + let mut raw = Vec::with_capacity(ids.len() * 32); + for id in ids { + raw.extend_from_slice(id); + } + base64::engine::general_purpose::STANDARD.encode(raw) +} + +/// Decode a packed base64 blob back into IDs. Trailing partial IDs are dropped. +pub fn unpack_ids(packed: &str) -> Vec { + use base64::Engine; + let raw = match base64::engine::general_purpose::STANDARD.decode(packed) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; + raw.chunks_exact(32) + .map(|c| { + let mut id = [0u8; 32]; + id.copy_from_slice(c); + id + }) + .collect() +} + +/// How many 32-byte IDs a packed base64 blob holds, WITHOUT decoding it. +/// +/// Standard base64 emits 4 chars per 3 input bytes (padded), so decoded length +/// is `chars / 4 * 3` minus padding; integer division by 32 absorbs the padding +/// slack for any non-empty blob. Exact for every blob `pack_ids` produces. +pub fn packed_id_count(packed: &str) -> usize { + (packed.len() / 4) * 3 / 32 +} + +/// The ONLY entry type in a uniques pool that carries an address. +/// +/// design.html §layers: anchor entries carry addresses so the pools double as +/// the anchor directory; every other entry is a bare ID (there is no point +/// sharing an address that needs an introduction anyway). Addresses MUST be +/// filtered through `is_publicly_routable()` before they go on the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnchorEntry { + /// hex node id (anchors are always node-class) + pub i: String, + /// e.g. ["1.2.3.4:4433"] + pub a: Vec, +} + +/// One slice of a uniques pool. Node-class and author-class IDs live in +/// separate arrays so a receiver structurally cannot mistake a persona ID for +/// a connectable node ID. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UniquesSlice { + /// Connectable network IDs, bare. + #[serde(default)] + pub nodes: PackedIds, + /// Posting/persona/throwaway IDs, bare. CDN-resolvable only — never a + /// connect target. + #[serde(default)] + pub authors: PackedIds, + /// Address-bearing anchor entries (node-class by definition). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub anchors: Vec, +} + +impl UniquesSlice { + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() && self.authors.is_empty() && self.anchors.is_empty() + } + /// Number of IDs carried (for logging / budget checks). + /// + /// `pack_ids` concatenates ALL IDs and base64-encodes the blob ONCE, so the + /// encoded length is `4 * ceil(32n/3)` characters — about 42.67 per ID, not + /// 44 (44 is the encoding of exactly ONE 32-byte ID). Counting `len()/44` + /// undercounts by ~3% at scale and by 33% for a 3-ID blob. Since this backs + /// budget checks, it is computed from the DECODED byte length instead. + pub fn len(&self) -> usize { + packed_id_count(&self.nodes) + packed_id_count(&self.authors) + self.anchors.len() + } +} + +/// 0x01 — the two-pool uniques announce (design.html §layers). +/// +/// POOL 1 (`fwd`) is FORWARDABLE and indexed by OUR bounce: +/// fwd[0] = N0 — ourselves (and our own anchor address, if we are an anchor) +/// fwd[1] = N1 — our own uniques: mesh peers, social directs, CDN file +/// authors, CDN file-holder peers, merged so the receiver +/// cannot tell which source an entry came from +/// fwd[2] = N2 — what our N1 reported to us +/// The receiver stores `fwd[i]` at bounce `i + 1` (their N1..N3), tagged to us +/// as reporter, and MAY re-announce it shifted one deeper. +/// +/// POOL 2 (`term`) is TERMINAL: our N3, deduplicated against every fwd slice. +/// The receiver stores it as their N4 and USES it for search / address +/// resolution, but NEVER re-announces it. The structural separation — a +/// distinct field, not a flag in a merged list — is what makes "never +/// re-announce" un-forgettable at the announce builder: the builder simply +/// never reads bounce-4 rows. There is no N5 anywhere. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UniquesAnnouncePayload { + pub seq: u64, + /// Always true in v0.8 — the announce is a full snapshot that replaces this + /// reporter's rows. (Incremental diffing over four layers with anchor + /// metadata was not worth the divergence risk; the sender skips the send + /// entirely when nothing changed, which recovers most of the saving.) + pub full: bool, + /// Pool 1, forwardable. Always length 3 (bounces 0, 1, 2). + pub fwd: Vec, + /// Pool 2, terminal. Empty when the recipient advertised `depth < 4`. + #[serde(default)] + pub term: UniquesSlice, + /// Deepest bounce the SENDER retains (4 desktop, 3 mobile). Lets the peer + /// skip building/sending the terminal pool for us — the single biggest + /// bandwidth saving on a cellular link. + pub depth: u8, +} + +impl UniquesAnnouncePayload { + pub fn empty(seq: u64, depth: u8) -> Self { + Self { + seq, + full: true, + fwd: vec![UniquesSlice::default(), UniquesSlice::default(), UniquesSlice::default()], + term: UniquesSlice::default(), + depth, + } + } +} + +/// Initial exchange: uniques announce + profile + deletes + post_ids + peer addresses #[derive(Debug, Serialize, Deserialize)] pub struct InitialExchangePayload { - /// Our connections + social contacts NodeIds (no addresses) - pub n1_node_ids: Vec, - /// Our deduplicated N2 NodeIds (no addresses) - pub n2_node_ids: Vec, + /// Our uniques pools (full snapshot). `None` means "this connection carries + /// no knowledge" — i.e. the sender placed us in a temporary referral slot. + /// Explicit on the wire rather than a pair of empty vectors so the receiver + /// can tell "nothing to share" from "not sharing". + #[serde(default)] + pub uniques: Option, /// Our profile pub profile: Option, /// Our delete records @@ -154,68 +292,57 @@ pub struct InitialExchangePayload { /// Our post IDs (for replica tracking) pub post_ids: Vec, /// Our N+10:Addresses (connected peers with addresses) for social routing - #[serde(default)] pub peer_addresses: Vec, /// If sender is an anchor, their stable advertised address (e.g. "174.127.120.52:4433") - #[serde(default)] pub anchor_addr: Option, /// What the sender sees as the receiver's address (STUN-like observed addr) - #[serde(default)] pub your_observed_addr: Option, /// Sender's NAT type ("public", "easy", "hard", "unknown") - #[serde(default)] pub nat_type: Option, /// Sender's NAT mapping behavior ("eim", "edm", "unknown") - #[serde(default)] pub nat_mapping: Option, /// Sender's NAT filtering behavior ("open", "port_restricted", "unknown") - #[serde(default)] pub nat_filtering: Option, /// Whether the sender is running an HTTP server for direct browser access - #[serde(default)] pub http_capable: bool, /// External HTTP address if known (e.g. "1.2.3.4:4433") - #[serde(default)] pub http_addr: Option, /// CDN replication device role: "intermittent", "available", "persistent" - #[serde(default)] pub device_role: Option, /// CDN cache pressure: 0-255 availability score (255 = lots of capacity) - #[serde(default)] pub cache_pressure: Option, /// Set by anchor when it detects this NodeId is already connected from a different address #[serde(default, skip_serializing_if = "Option::is_none")] pub duplicate_active: Option, } -/// Incremental N1/N2 changes +/// 0x40/0x41 — the uniques-index exchange, both directions. +/// +/// The requester sends its pools; the responder answers with its own. One +/// round trip refreshes both indexes, which is what makes an index *exchange* +/// rather than a fetch. Content does not ride this path. #[derive(Debug, Serialize, Deserialize)] -pub struct NodeListUpdatePayload { - pub seq: u64, - pub n1_added: Vec, - pub n1_removed: Vec, - pub n2_added: Vec, - pub n2_removed: Vec, +pub struct UniquesPullPayload { + /// The sender's two pools. `None` = "I carry no knowledge on this link" + /// (temp referral slot), mirroring `InitialExchangePayload.uniques`. + #[serde(default)] + pub uniques: Option, } -/// Pull-based post sync request +/// 0x46 — TRANSITIONAL content sync request. Identical in shape to the v0.7 +/// pull request; only the opcode moved. See `MessageType::ContentSyncRequest`. #[derive(Debug, Serialize, Deserialize)] -pub struct PullSyncRequestPayload { +pub struct ContentSyncRequestPayload { /// Our follows (for the responder to filter) pub follows: Vec, - /// Post IDs we already have (backward compat — empty for v4 senders) - #[serde(default)] - pub have_post_ids: Vec, - /// Protocol v4: per-author timestamps (Vec of tuples for serde compat) - #[serde(default)] + /// Per-author last-sync timestamps (Vec of tuples for serde compat) pub since_ms: Vec<(NodeId, u64)>, } -/// Pull-based post sync response +/// 0x47 — TRANSITIONAL content sync response. #[derive(Debug, Serialize, Deserialize)] -pub struct PullSyncResponsePayload { +pub struct ContentSyncResponsePayload { pub posts: Vec, - pub visibility_updates: Vec, } /// Profile update (pushed via uni-stream) @@ -224,18 +351,6 @@ pub struct ProfileUpdatePayload { pub profiles: Vec, } -/// Delete record (pushed via uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct DeleteRecordPayload { - pub records: Vec, -} - -/// Visibility update (pushed via uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct VisibilityUpdatePayload { - pub updates: Vec, -} - /// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address) #[derive(Debug, Serialize, Deserialize)] pub struct AddressRequestPayload { @@ -248,10 +363,8 @@ pub struct AddressResponsePayload { pub target: NodeId, pub address: Option, /// Set when the target is known-disconnected (requester registered as watcher) - #[serde(default)] pub disconnected_at: Option, /// Target's N+10:Addresses if known - #[serde(default)] pub peer_addresses: Vec, } @@ -268,15 +381,12 @@ pub struct WormQueryPayload { pub worm_id: WormId, pub target: NodeId, /// Additional IDs to search for (up to 10 recent_peers of target) - #[serde(default)] pub needle_peers: Vec, pub ttl: u8, pub visited: Vec, /// Optional: also search for a specific post by ID - #[serde(default)] pub post_id: Option, /// Optional: also search for a specific blob by CID - #[serde(default)] pub blob_id: Option<[u8; 32]>, } @@ -286,19 +396,15 @@ pub struct WormResponsePayload { pub worm_id: WormId, pub found: bool, /// Which needle was actually found (target or one of its recent_peers) - #[serde(default)] pub found_id: Option, pub addresses: Vec, pub reporter: Option, pub hop: Option, /// One random wide-peer referral: (node_id, address) for bloom round - #[serde(default)] pub wide_referral: Option<(NodeId, String)>, /// Node that holds the requested post (may differ from found_id) - #[serde(default)] pub post_holder: Option, /// Node that holds the requested blob - #[serde(default)] pub blob_holder: Option, } @@ -312,12 +418,6 @@ pub struct SocialAddressUpdatePayload { pub peer_addresses: Vec, } -/// Disconnect notice: "peer X disconnected" -#[derive(Debug, Serialize, Deserialize)] -pub struct SocialDisconnectNoticePayload { - pub node_id: NodeId, -} - /// Lightweight keepalive checkin (bidirectional) #[derive(Debug, Serialize, Deserialize)] pub struct SocialCheckinPayload { @@ -333,7 +433,6 @@ pub struct SocialCheckinPayload { pub struct BlobRequestPayload { pub cid: [u8; 32], /// Requester's addresses so the host can record downstream - #[serde(default)] pub requester_addresses: Vec, } @@ -343,16 +442,12 @@ pub struct BlobResponsePayload { pub cid: [u8; 32], pub found: bool, /// Base64-encoded blob bytes (empty if not found) - #[serde(default)] pub data_b64: String, /// Author manifest + host info (if available) - #[serde(default)] pub manifest: Option, /// Whether host accepted requester as downstream - #[serde(default)] pub cdn_registered: bool, /// If not registered (host full), try these peers - #[serde(default)] pub cdn_redirect_peers: Vec, } @@ -390,23 +485,6 @@ pub struct ManifestPushEntry { // encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See // `crate::group_key_distribution` and `types::GroupKeyDistributionContent`. -/// Member requests current group key (bi-stream request) -#[derive(Debug, Serialize, Deserialize)] -pub struct GroupKeyRequestPayload { - pub group_id: GroupId, - pub known_epoch: GroupEpoch, -} - -/// Admin responds with wrapped key (bi-stream response) -#[derive(Debug, Serialize, Deserialize)] -pub struct GroupKeyResponsePayload { - pub group_id: GroupId, - pub epoch: GroupEpoch, - pub group_public_key: [u8; 32], - pub admin: NodeId, - pub member_key: Option, -} - // --- Relay introduction payloads --- /// Relay introduction identifier for deduplication @@ -453,18 +531,6 @@ pub struct SessionRelayPayload { pub target: NodeId, } -/// Mesh preference negotiation (bi-stream: request + response) -#[derive(Debug, Serialize, Deserialize)] -pub struct MeshPreferPayload { - /// true = "I want us to be preferred peers" (request) - pub requesting: bool, - /// true = "I agree to be preferred peers" (response only) - pub accepted: bool, - /// Reason for rejection (response only, when accepted=false) - #[serde(default)] - pub reject_reason: Option, -} - /// Circle profile update: encrypted profile variant for a circle (uni-stream push) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CircleProfileUpdatePayload { @@ -479,33 +545,63 @@ pub struct CircleProfileUpdatePayload { pub updated_at: u64, } -// --- Anchor referral payloads --- +// --- Anchor convection payloads (design.html §anchors) --- -/// Node registers its address with an anchor (uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct AnchorRegisterPayload { - pub node_id: NodeId, - pub addresses: Vec, +/// The one request-class bit (round-5 ruling). +/// +/// ENTRY = bootstrap or recovery, i.e. the caller has fewer than 2 mesh +/// connections. Always served — a node that cannot get in cannot come back. +/// TOP_UP = growth with a working mesh. Served capacity-permitting and refused +/// CHEAPLY (a single response message, no timeout burned). Top-ups are the +/// convection medium — they are what keeps referral windows fresh — so they +/// are not throttled aggressively; the refusal doubles as the adaptive load +/// signal the caller feeds back into its action weights. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ConvectionClass { + Entry, + TopUp, } -/// Node requests peer referrals from an anchor (bi-stream request) +impl ConvectionClass { + /// Entry class is decided purely by "can this node function right now". + pub fn for_mesh_count(mesh_count: usize) -> Self { + if mesh_count < 2 { Self::Entry } else { Self::TopUp } + } + pub fn is_entry(self) -> bool { + matches!(self, Self::Entry) + } +} + +/// 0xC1 — "I'm joining / I need peers". The request itself is what enrols the +/// caller in the anchor's rolling window; there is no separate registration. #[derive(Debug, Serialize, Deserialize)] -pub struct AnchorReferralRequestPayload { +pub struct ConvectionRequestPayload { pub requester: NodeId, pub requester_addresses: Vec, + pub class: ConvectionClass, } -/// Anchor responds with peer referrals (bi-stream response) +/// 0xC2 — up to 2 recent prior callers, or a cheap refusal. #[derive(Debug, Serialize, Deserialize)] -pub struct AnchorReferralResponsePayload { - pub referrals: Vec, +pub struct ConvectionResponsePayload { + pub referrals: Vec, + /// Cheap refusal of a TOP_UP request: one message, immediately, so the + /// caller never burns a timeout. Never set for ENTRY-class requests. + #[serde(default)] + pub refused: bool, } -/// A single peer referral from an anchor +/// A single referral out of the anchor's rolling caller window. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnchorReferral { +pub struct ConvectionReferral { pub node_id: NodeId, pub addresses: Vec, + /// The anchor had BOTH ends live and has pushed a RelayIntroduce toward + /// this peer naming the caller as requester — so the peer is already + /// punching outward. Dial straight through instead of paying for a second + /// introduction round trip. + #[serde(default)] + pub introduced: bool, } // --- Anchor probe payloads --- @@ -533,7 +629,9 @@ pub struct AnchorProbeResultPayload { pub observed_addr: Option, } -/// Port scan heartbeat during scanning hole punch (informational) +/// Port scan heartbeat during scanning hole punch (informational). +/// Reserved wire surface for the EDM scanner (`edm_port_scan_disabled_v0_7_3` +/// in connection.rs) — no sender/handler until the raw-UDP scan refactor. #[derive(Debug, Serialize, Deserialize)] pub struct PortScanHeartbeatPayload { pub peer: NodeId, @@ -584,7 +682,6 @@ pub struct BlobHeaderResponsePayload { /// True if the sender has a newer header than requested pub updated: bool, /// JSON-serialized BlobHeader (if updated) - #[serde(default)] pub header_json: Option, } @@ -725,36 +822,31 @@ mod tests { #[test] fn message_type_roundtrip() { let types = [ - MessageType::NodeListUpdate, + MessageType::UniquesAnnounce, MessageType::InitialExchange, MessageType::AddressRequest, MessageType::AddressResponse, MessageType::RefuseRedirect, - MessageType::PullSyncRequest, - MessageType::PullSyncResponse, + MessageType::UniquesPullRequest, + MessageType::UniquesPullResponse, + MessageType::ContentSyncRequest, + MessageType::ContentSyncResponse, MessageType::ProfileUpdate, - MessageType::DeleteRecord, - MessageType::VisibilityUpdate, MessageType::WormQuery, MessageType::WormResponse, MessageType::SocialAddressUpdate, - MessageType::SocialDisconnectNotice, MessageType::SocialCheckin, MessageType::BlobRequest, MessageType::BlobResponse, MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, - MessageType::GroupKeyRequest, - MessageType::GroupKeyResponse, MessageType::RelayIntroduce, MessageType::RelayIntroduceResult, MessageType::SessionRelay, - MessageType::MeshPrefer, MessageType::CircleProfileUpdate, - MessageType::AnchorRegister, - MessageType::AnchorReferralRequest, - MessageType::AnchorReferralResponse, + MessageType::ConvectionRequest, + MessageType::ConvectionResponse, MessageType::AnchorProbeRequest, MessageType::AnchorProbeResult, MessageType::PortScanHeartbeat, @@ -841,43 +933,6 @@ mod tests { assert_eq!(decoded2.reject_reason.unwrap(), "target not reachable"); } - #[test] - fn mesh_prefer_payload_roundtrip() { - // Request - let request = MeshPreferPayload { - requesting: true, - accepted: false, - reject_reason: None, - }; - let json = serde_json::to_string(&request).unwrap(); - let decoded: MeshPreferPayload = serde_json::from_str(&json).unwrap(); - assert!(decoded.requesting); - assert!(!decoded.accepted); - assert!(decoded.reject_reason.is_none()); - - // Accepted response - let accept = MeshPreferPayload { - requesting: false, - accepted: true, - reject_reason: None, - }; - let json2 = serde_json::to_string(&accept).unwrap(); - let decoded2: MeshPreferPayload = serde_json::from_str(&json2).unwrap(); - assert!(!decoded2.requesting); - assert!(decoded2.accepted); - - // Rejected response - let reject = MeshPreferPayload { - requesting: false, - accepted: false, - reject_reason: Some("slots full".to_string()), - }; - let json3 = serde_json::to_string(&reject).unwrap(); - let decoded3: MeshPreferPayload = serde_json::from_str(&json3).unwrap(); - assert!(!decoded3.accepted); - assert_eq!(decoded3.reject_reason.unwrap(), "slots full"); - } - #[test] fn session_relay_payload_roundtrip() { let payload = SessionRelayPayload { @@ -913,56 +968,105 @@ mod tests { } #[test] - fn anchor_register_payload_roundtrip() { - let payload = AnchorRegisterPayload { - node_id: [1u8; 32], - addresses: vec!["192.168.1.5:4433".to_string(), "10.0.0.1:4433".to_string()], - }; - let json = serde_json::to_string(&payload).unwrap(); - let decoded: AnchorRegisterPayload = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.node_id, [1u8; 32]); - assert_eq!(decoded.addresses.len(), 2); - assert_eq!(decoded.addresses[0], "192.168.1.5:4433"); + fn convection_request_carries_the_class_bit() { + for class in [ConvectionClass::Entry, ConvectionClass::TopUp] { + let payload = ConvectionRequestPayload { + requester: [2u8; 32], + requester_addresses: vec!["10.0.0.2:4433".to_string()], + class, + }; + let json = serde_json::to_string(&payload).unwrap(); + let decoded: ConvectionRequestPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.requester, [2u8; 32]); + assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]); + assert_eq!(decoded.class, class); + } + // The class is derived purely from "can this node function right now". + assert!(ConvectionClass::for_mesh_count(0).is_entry()); + assert!(ConvectionClass::for_mesh_count(1).is_entry()); + assert!(!ConvectionClass::for_mesh_count(2).is_entry()); + assert!(!ConvectionClass::for_mesh_count(20).is_entry()); } #[test] - fn anchor_referral_request_payload_roundtrip() { - let payload = AnchorReferralRequestPayload { - requester: [2u8; 32], - requester_addresses: vec!["10.0.0.2:4433".to_string()], - }; - let json = serde_json::to_string(&payload).unwrap(); - let decoded: AnchorReferralRequestPayload = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.requester, [2u8; 32]); - assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]); - } - - #[test] - fn anchor_referral_response_payload_roundtrip() { - let payload = AnchorReferralResponsePayload { + fn convection_response_roundtrip_including_cheap_refusal() { + let payload = ConvectionResponsePayload { referrals: vec![ - AnchorReferral { + ConvectionReferral { node_id: [3u8; 32], addresses: vec!["10.0.0.3:4433".to_string()], + introduced: false, }, - AnchorReferral { + ConvectionReferral { node_id: [4u8; 32], - addresses: vec!["10.0.0.4:4433".to_string(), "192.168.1.4:4433".to_string()], + addresses: vec!["10.0.0.4:4433".to_string()], + introduced: true, }, ], + refused: false, }; let json = serde_json::to_string(&payload).unwrap(); - let decoded: AnchorReferralResponsePayload = serde_json::from_str(&json).unwrap(); + let decoded: ConvectionResponsePayload = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.referrals.len(), 2); - assert_eq!(decoded.referrals[0].node_id, [3u8; 32]); - assert_eq!(decoded.referrals[0].addresses, vec!["10.0.0.3:4433"]); - assert_eq!(decoded.referrals[1].node_id, [4u8; 32]); - assert_eq!(decoded.referrals[1].addresses.len(), 2); + assert!(!decoded.referrals[0].introduced); + assert!(decoded.referrals[1].introduced); + assert!(!decoded.refused); - // Empty referrals - let empty = AnchorReferralResponsePayload { referrals: vec![] }; - let json2 = serde_json::to_string(&empty).unwrap(); - let decoded2: AnchorReferralResponsePayload = serde_json::from_str(&json2).unwrap(); + // A refusal is one small message with no referrals — the caller reads + // it immediately instead of burning a timeout. + let refusal = ConvectionResponsePayload { referrals: vec![], refused: true }; + let json2 = serde_json::to_string(&refusal).unwrap(); + assert!(json2.len() < 64, "refusal must stay tiny: {}", json2); + let decoded2: ConvectionResponsePayload = serde_json::from_str(&json2).unwrap(); + assert!(decoded2.refused); assert!(decoded2.referrals.is_empty()); } + + #[test] + fn uniques_pull_is_an_index_exchange_not_a_post_transfer() { + let payload = UniquesPullPayload { + uniques: Some(UniquesAnnouncePayload::empty(7, 4)), + }; + let json = serde_json::to_string(&payload).unwrap(); + let decoded: UniquesPullPayload = serde_json::from_str(&json).unwrap(); + let u = decoded.uniques.unwrap(); + assert_eq!(u.seq, 7); + assert_eq!(u.depth, 4); + assert_eq!(u.fwd.len(), 3); + // No post field exists on this payload at all — content rides 0x46/0x47. + assert!(!json.contains("posts")); + + // A temp referral slot carries no knowledge in either direction. + let none = UniquesPullPayload { uniques: None }; + let json2 = serde_json::to_string(&none).unwrap(); + let decoded2: UniquesPullPayload = serde_json::from_str(&json2).unwrap(); + assert!(decoded2.uniques.is_none()); + } + + /// `len()` backs budget checks, so it must be EXACT — not the old + /// `bytes / 44` approximation, which assumed one base64 encoding per ID + /// when `pack_ids` encodes the whole concatenated blob once. + #[test] + fn uniques_slice_len_counts_packed_ids_exactly() { + for n in [0usize, 1, 2, 3, 7, 100, 1000] { + let ids: Vec = (0..n) + .map(|i| { + let mut id = [0u8; 32]; + id[0] = (i % 251) as u8; + id[1] = (i / 251) as u8; + id + }) + .collect(); + let packed = pack_ids(&ids); + assert_eq!(packed_id_count(&packed), n, "packed_id_count for n={}", n); + assert_eq!(unpack_ids(&packed).len(), n); + + let slice = UniquesSlice { + nodes: packed.clone(), + authors: packed.clone(), + anchors: vec![AnchorEntry { i: "aa".into(), a: vec!["1.2.3.4:1".into()] }], + }; + assert_eq!(slice.len(), n * 2 + 1, "UniquesSlice::len for n={}", n); + } + } } diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs new file mode 100644 index 0000000..d5557f9 --- /dev/null +++ b/crates/core/src/registry.rs @@ -0,0 +1,474 @@ +//! v0.8 (A3): the network registry post — a well-known "registrations +//! here" post whose comment chain is the opt-in, searchable linkage of a +//! public persona name/keywords to a posting-identity author ID +//! (design.html §27 `id="discovery"`). +//! +//! - The post's canonical bytes are FROZEN in [`REGISTRY_POST_JSON`]; +//! its id is [`REGISTRY_POST_ID`] = BLAKE3(bytes). The constant is the +//! authority — never re-serialize the `Post` struct at runtime to +//! derive the id (serde field order = declaration order; any future +//! field would silently change the bytes). +//! - Every v0.8 node self-materializes the post at startup +//! ([`materialize_registry_post`]); only the comment-chain pull is +//! network work. +//! - Registration = the SAME open-slot comment implementation as +//! greetings (round-6 ruling), aimed at this post: plaintext JSON +//! entry, self-certified by the standard comment signature under the +//! persona's REAL posting key, fixed 30-day TTL, one active entry per +//! persona (newest wins). +//! - Sharding: `shard(seed, k)` derivation exists from day one via the +//! content string embedding the seed + k. k=1 forever in A3; adding +//! shard k+1 is a new content string = threshold change, not a +//! migration. + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::storage::Storage; +use crate::types::{NodeId, Post, PostId}; + +/// Shard seed + index (design ruling: derivable shard identity). +pub const REGISTRY_SHARD_SEED: &str = "itsgoin-registry-v1"; +pub const REGISTRY_SHARD_K: u32 = 1; + +/// Fixed genesis timestamp baked into the canonical bytes +/// (2026-07-30T00:00:00Z). +pub const REGISTRY_GENESIS_TIMESTAMP_MS: u64 = 1_785_369_600_000; + +/// Single padded bucket for registration entries (plaintext JSON). +pub const REGISTRY_BODY_BUCKET: u16 = 512; + +/// Registrations live exactly 30 days; re-sign to stay listed. +pub const REGISTRATION_TTL_MS: u64 = 30 * 24 * 3600 * 1000; +/// Holder-side slack when checking the fixed TTL (clock skew). +pub const REGISTRATION_TTL_SLACK_MS: u64 = 5 * 60 * 1000; + +/// Entry shape limits (holder-enforced). +pub const MAX_REGISTRY_NAME_CHARS: usize = 64; +pub const MAX_REGISTRY_KEYWORDS: usize = 8; +pub const MAX_REGISTRY_KEYWORD_CHARS: usize = 32; + +/// Ordinary comment TTL window: rand(30..=365 days), drawn BEFORE +/// signing (the expiry is inside the signed digest). +pub const ORDINARY_TTL_MIN_MS: u64 = 30 * 24 * 3600 * 1000; +pub const ORDINARY_TTL_MAX_MS: u64 = 365 * 24 * 3600 * 1000; + +/// Draw the expiry for an ordinary comment: `now + rand(30..=365 days)`. +/// The randomness serves identity hygiene — throwaway commenter IDs +/// vanish at unpredictable times. +/// +/// Debug/test override: `ITSGOIN_TEST_TTL_SECS=` makes every ordinary +/// comment expire in n seconds (integration-test hook; debug builds only). +pub fn draw_ordinary_comment_expiry(now_ms: u64) -> u64 { + #[cfg(debug_assertions)] + { + if let Ok(secs) = std::env::var("ITSGOIN_TEST_TTL_SECS") { + if let Ok(secs) = secs.parse::() { + return now_ms + secs * 1000; + } + } + } + use rand::Rng; + let ttl = rand::rng().random_range(ORDINARY_TTL_MIN_MS..=ORDINARY_TTL_MAX_MS); + now_ms + ttl +} + +/// The frozen canonical serialized bytes of the registry post. Generated +/// once (see `build_canonical_registry_post` + the `frozen_bytes_*` +/// tests) and pasted here as a literal. DO NOT regenerate at runtime. +pub const REGISTRY_POST_JSON: &str = include_str!("registry_post_v1.json"); + +/// `BLAKE3(REGISTRY_POST_JSON)` — the registry post's id. Asserted +/// against the frozen bytes in CI (`frozen_bytes_triple_assertion`). +pub const REGISTRY_POST_ID: PostId = [ + 0x95, 0x28, 0x55, 0x78, 0x0d, 0xaa, 0x7c, 0xcc, + 0x1c, 0xe1, 0x2d, 0x18, 0x10, 0x3f, 0x77, 0x1d, + 0x6b, 0xe0, 0xd8, 0x81, 0x33, 0x18, 0x7b, 0xbf, + 0xec, 0x64, 0x60, 0xda, 0x24, 0x21, 0x90, 0xb4, +]; + +/// Deterministic builder for the canonical registry post. All seal +/// inputs are blake3-derived from the shard seed + k, so the output +/// bytes are reproducible bit-for-bit — this is what generated (and what +/// CI re-checks against) [`REGISTRY_POST_JSON`]. +pub fn build_canonical_registry_post() -> Result { + use crate::types::{FoFCommentGating, OpenSlotDecl, OpenSlotKind, WrapSlot}; + use ed25519_dalek::SigningKey; + + let shard_input = format!("{}/k={}", REGISTRY_SHARD_SEED, REGISTRY_SHARD_K); + let derive = |ctx: &str| -> [u8; 32] { blake3::derive_key(ctx, shard_input.as_bytes()) }; + + let slot_binder_nonce = derive("itsgoin/registry/v1/slot-binder"); + let cek = derive("itsgoin/registry/v1/cek"); + let priv_x_seed = derive("itsgoin/registry/v1/priv-x-seed"); + let pub_x = SigningKey::from_bytes(&priv_x_seed).verifying_key().to_bytes(); + + let v_open = crate::crypto::derive_open_slot_vx( + &crate::DEFAULT_ANCHOR_POSTING_ID, + &slot_binder_nonce, + ); + let sealed = crate::crypto::seal_wrap_slot(&v_open, &slot_binder_nonce, &cek, &priv_x_seed)?; + + let gating = FoFCommentGating { + slot_binder_nonce, + pub_post_set: vec![pub_x], + wrap_slots: vec![WrapSlot { + prefilter_tag: sealed.prefilter_tag, + read_ciphertext: sealed.read_ciphertext, + sign_ciphertext: sealed.sign_ciphertext, + }], + revocation_list: vec![], + open_slot: Some(OpenSlotDecl { + slot_index: 0, + kind: OpenSlotKind::Registry, + body_bucket: REGISTRY_BODY_BUCKET, + }), + }; + + Ok(Post { + author: crate::DEFAULT_ANCHOR_POSTING_ID, + content: format!( + "ItsGoin Network Registry — shard({}, k={}). Register here with a signed \ + public comment {{name, keywords}} authored by your persona's posting key. \ + Entries are self-certifying, expire after 30 days, and are newest-wins per \ + persona. Delete = signed DeleteComment.", + REGISTRY_SHARD_SEED, REGISTRY_SHARD_K, + ), + attachments: vec![], + timestamp_ms: REGISTRY_GENESIS_TIMESTAMP_MS, + fof_gating: Some(gating), + supersedes_post_id: None, + }) +} + +/// Parse the frozen constant into a `Post`. `verify_post_id` passes by +/// construction everywhere. +pub fn registry_post() -> Post { + serde_json::from_str(REGISTRY_POST_JSON) + .expect("frozen REGISTRY_POST_JSON must deserialize — build is broken otherwise") +} + +/// Self-materialize the registry post (store-if-absent, Public). Every +/// v0.8 node can therefore hold/serve the chain; no fetch needed. +/// Returns `true` if the post was newly stored. +pub fn materialize_registry_post(storage: &Storage) -> Result { + let post = registry_post(); + debug_assert!(crate::content::verify_post_id(®ISTRY_POST_ID, &post)); + storage.store_post_with_intent( + ®ISTRY_POST_ID, + &post, + &crate::types::PostVisibility::Public, + &crate::types::VisibilityIntent::Public, + ) +} + +/// A registration entry — plaintext JSON in the comment `content`. +/// Public by intent: no encryption, no anonymity. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistrationEntry { + pub v: u32, + pub name: String, + #[serde(default)] + pub keywords: Vec, +} + +/// Parse + shape-validate a registration entry from comment content. +pub fn parse_registration(content: &str) -> Result { + if content.len() > REGISTRY_BODY_BUCKET as usize { + bail!("registration entry exceeds body bucket ({} bytes)", content.len()); + } + let entry: RegistrationEntry = serde_json::from_str(content) + .map_err(|e| anyhow::anyhow!("registration entry is not valid JSON: {}", e))?; + if entry.v != 1 { + bail!("unsupported registration version {}", entry.v); + } + if entry.name.is_empty() || entry.name.chars().count() > MAX_REGISTRY_NAME_CHARS { + bail!("registration name empty or over {} chars", MAX_REGISTRY_NAME_CHARS); + } + if entry.keywords.len() > MAX_REGISTRY_KEYWORDS { + bail!("more than {} keywords", MAX_REGISTRY_KEYWORDS); + } + for kw in &entry.keywords { + if kw.is_empty() || kw.chars().count() > MAX_REGISTRY_KEYWORD_CHARS { + bail!("keyword empty or over {} chars", MAX_REGISTRY_KEYWORD_CHARS); + } + } + Ok(entry) +} + +/// Fixed-30d TTL check for registrations: `expires_at_ms − timestamp_ms` +/// must equal 30 days within ±5 minutes of slack. +pub fn registration_ttl_ok(timestamp_ms: u64, expires_at_ms: u64) -> bool { + let Some(ttl) = expires_at_ms.checked_sub(timestamp_ms) else { return false; }; + ttl.abs_diff(REGISTRATION_TTL_MS) <= REGISTRATION_TTL_SLACK_MS +} + +/// One search hit from the local registry chain. +#[derive(Debug, Clone)] +pub struct RegistryMatch { + pub author: NodeId, + pub name: String, + pub keywords: Vec, + pub timestamp_ms: u64, + pub expires_at_ms: u64, +} + +/// Case-insensitive substring search over name + keywords of the +/// locally-held, unexpired, newest-wins registry entries. Search cost +/// lands on the searcher (design §27) — LOCAL query only; chain refresh +/// is the caller's job (`Node::search_registry`). +/// +/// An empty query returns every live entry (browse mode). +pub fn search_entries(storage: &Storage, query: &str) -> Result> { + let comments = storage.get_comments(®ISTRY_POST_ID)?; + let needle = query.trim().to_lowercase(); + + // Newest-wins per author (storage enforces at ingest; dedupe + // defensively for locally-authored duplicates). + let mut newest: std::collections::HashMap = + std::collections::HashMap::new(); + for c in &comments { + let Ok(entry) = parse_registration(&c.content) else { continue; }; + let m = RegistryMatch { + author: c.author, + name: entry.name, + keywords: entry.keywords, + timestamp_ms: c.timestamp_ms, + expires_at_ms: c.expires_at_ms, + }; + match newest.get(&c.author) { + Some(existing) if existing.timestamp_ms >= m.timestamp_ms => {} + _ => { + newest.insert(c.author, m); + } + } + } + + let mut hits: Vec = newest + .into_values() + .filter(|m| { + if needle.is_empty() { + return true; + } + m.name.to_lowercase().contains(&needle) + || m.keywords.iter().any(|k| k.to_lowercase().contains(&needle)) + }) + .collect(); + hits.sort_by(|a, b| b.timestamp_ms.cmp(&a.timestamp_ms)); + Ok(hits) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::InlineComment; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + /// Generator (dev-time): rebuild the canonical bytes and print them. + /// Run with `cargo test -p itsgoin-core registry_generator -- --ignored --nocapture` + /// when (and only when) the canonical definition intentionally changes, + /// then paste the output into `registry_post_v1.json` + REGISTRY_POST_ID. + #[test] + #[ignore] + fn registry_generator() { + let post = build_canonical_registry_post().unwrap(); + let json = serde_json::to_string(&post).unwrap(); + let id = blake3::hash(json.as_bytes()); + println!("JSON:\n{}", json); + println!("ID: {:?}", id.as_bytes()); + println!("ID hex: {}", hex::encode(id.as_bytes())); + } + + /// CI: the frozen literal, its BLAKE3, the shipped constant, and the + /// deterministic builder must all agree — forever. + #[test] + fn frozen_bytes_triple_assertion() { + // 1. blake3(frozen bytes) == REGISTRY_POST_ID. + let hash = blake3::hash(REGISTRY_POST_JSON.as_bytes()); + assert_eq!( + hash.as_bytes(), + ®ISTRY_POST_ID, + "REGISTRY_POST_ID must equal blake3 of the frozen bytes", + ); + + // 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID — + // i.e., re-serialization is currently byte-identical, so + // verify_post_id passes everywhere. + let post = registry_post(); + assert_eq!( + crate::content::compute_post_id(&post), + REGISTRY_POST_ID, + "round-trip serialization must reproduce the frozen bytes", + ); + + // 3. The deterministic builder still reproduces the constant + // (all seal inputs derived — no drift in crypto derivations). + let rebuilt = build_canonical_registry_post().unwrap(); + assert_eq!( + serde_json::to_string(&rebuilt).unwrap(), + REGISTRY_POST_JSON, + "deterministic builder must reproduce the frozen bytes", + ); + + // Sanity: canonical fields. + assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID); + assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS); + let gating = post.fof_gating.as_ref().expect("gating"); + let decl = gating.open_slot.as_ref().expect("open slot"); + assert_eq!(decl.slot_index, 0); + assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry); + assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET); + assert_eq!(gating.wrap_slots.len(), 1); + } + + /// The registry open slot must actually open under the derivable key + /// and yield the signing seed matching pub_post_set[0]. + #[test] + fn registry_open_slot_derivable() { + use ed25519_dalek::SigningKey; + let post = registry_post(); + let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32]) + .expect("registry open slot must open under the derived key"); + assert_eq!(unlock.slot_index, 0); + let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes(); + assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub); + } + + #[test] + fn materialize_is_idempotent() { + let s = temp_storage(); + assert!(materialize_registry_post(&s).unwrap(), "first store inserts"); + assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops"); + let (post, vis) = s.get_post_with_visibility(®ISTRY_POST_ID).unwrap().unwrap(); + assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID); + assert!(matches!(vis, crate::types::PostVisibility::Public)); + } + + #[test] + fn registration_shape_limits() { + // Good. + parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap(); + // No keywords is fine. + parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap(); + // Wrong version. + assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err()); + // Empty name. + assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err()); + // Name too long. + let long_name = "x".repeat(65); + assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err()); + // Too many keywords. + let kws: Vec = (0..9).map(|i| format!("k{}", i)).collect(); + let json = serde_json::json!({"v":1,"name":"A","keywords":kws}).to_string(); + assert!(parse_registration(&json).is_err()); + // Keyword too long. + let json = serde_json::json!({"v":1,"name":"A","keywords":["x".repeat(33)]}).to_string(); + assert!(parse_registration(&json).is_err()); + // Not JSON. + assert!(parse_registration("hello").is_err()); + } + + #[test] + fn registration_ttl_fixed_30d() { + let ts = 1_000_000u64; + // Exact 30d. + assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS)); + // Inside slack. + assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS)); + assert!(registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS)); + // Outside slack. + assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS + REGISTRATION_TTL_SLACK_MS + 1)); + assert!(!registration_ttl_ok(ts, ts + REGISTRATION_TTL_MS - REGISTRATION_TTL_SLACK_MS - 1)); + // Degenerate. + assert!(!registration_ttl_ok(ts, ts)); + assert!(!registration_ttl_ok(ts, 0)); + } + + fn reg_comment(author: NodeId, ts: u64, name: &str, kws: &[&str]) -> InlineComment { + InlineComment { + author, + post_id: REGISTRY_POST_ID, + content: serde_json::json!({"v":1,"name":name,"keywords":kws}).to_string(), + timestamp_ms: ts, + signature: vec![0u8; 64], + deleted_at: None, + ref_post_id: None, + pub_x_index: Some(0), + group_sig: None, + encrypted_payload: None, + expires_at_ms: ts + REGISTRATION_TTL_MS, + } + } + + fn test_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + #[test] + fn newest_wins_upsert() { + let s = temp_storage(); + let author = [7u8; 32]; + let t0 = test_now(); + + // First entry at t0. + assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0).unwrap()); + s.store_comment(®_comment(author, t0, "Old", &["a"])).unwrap(); + + // Newer entry at t0+1000: accepted, older row hard-deleted. + assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap()); + s.store_comment(®_comment(author, t0 + 1000, "New", &["b"])).unwrap(); + let comments = s.get_comments(®ISTRY_POST_ID).unwrap(); + assert_eq!(comments.len(), 1, "older row hard-deleted"); + assert_eq!(comments[0].timestamp_ms, t0 + 1000); + + // Older-than-stored entry: rejected, nothing changes. + assert!(!s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 500).unwrap()); + assert_eq!(s.get_comments(®ISTRY_POST_ID).unwrap().len(), 1); + + // Same-timestamp re-ingest: idempotent accept. + assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap()); + } + + #[test] + fn search_matches_name_and_keywords() { + let s = temp_storage(); + let alice = [1u8; 32]; + let bob = [2u8; 32]; + let t0 = test_now(); + s.store_comment(®_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap(); + s.store_comment(®_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap(); + + // Keyword hit (case-insensitive). + let hits = search_entries(&s, "RUST").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].author, alice); + assert_eq!(hits[0].name, "Alice"); + + // Name substring hit. + let hits = search_entries(&s, "bo").unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].author, bob); + + // Empty query = browse all. + assert_eq!(search_entries(&s, "").unwrap().len(), 2); + + // No hit. + assert!(search_entries(&s, "zzz").unwrap().is_empty()); + } + + #[test] + fn search_skips_expired_entries() { + let s = temp_storage(); + let alice = [1u8; 32]; + let mut c = reg_comment(alice, 1000, "Alice", &["rust"]); + c.expires_at_ms = 2000; // long past + s.store_comment(&c).unwrap(); + assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered"); + } +} diff --git a/crates/core/src/registry_post_v1.json b/crates/core/src/registry_post_v1.json new file mode 100644 index 0000000..f0b1e96 --- /dev/null +++ b/crates/core/src/registry_post_v1.json @@ -0,0 +1 @@ +{"author":[23,175,20,25,86,174,11,80,220,28,185,36,140,173,245,252,163,113,234,45,133,49,172,154,221,60,3,202,255,198,20,65],"content":"ItsGoin Network Registry — shard(itsgoin-registry-v1, k=1). Register here with a signed public comment {name, keywords} authored by your persona's posting key. Entries are self-certifying, expire after 30 days, and are newest-wins per persona. Delete = signed DeleteComment.","attachments":[],"timestamp_ms":1785369600000,"fof_gating":{"slot_binder_nonce":[194,138,144,176,11,137,185,140,117,108,61,163,60,83,166,44,35,228,74,53,76,175,67,116,175,175,49,54,140,206,82,147],"pub_post_set":[[247,219,138,10,17,157,246,133,230,232,228,97,55,195,255,125,108,212,226,71,102,202,26,31,78,233,76,47,236,66,71,144]],"wrap_slots":[{"prefilter_tag":[95,128],"read_ciphertext":[216,233,169,245,192,77,2,116,19,155,45,0,222,219,59,237,100,86,247,42,2,101,141,2,131,40,39,236,29,158,172,66,8,45,135,137,219,4,159,255,148,101,61,120,3,253,1,220],"sign_ciphertext":[42,203,235,211,8,208,10,246,77,37,158,207,8,214,152,49,15,110,220,5,190,177,105,164,146,117,190,211,12,13,32,36,109,105,156,33,42,133,118,213,248,33,74,182,178,235,176,204]}],"revocation_list":[],"open_slot":{"slot_index":0,"kind":"Registry","body_bucket":512}}} \ No newline at end of file diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index cee97e8..ffa7e61 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -7,9 +7,9 @@ use crate::types::{ Attachment, Circle, CircleProfile, CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId, GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord, - PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, - PublicProfile, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, - ThreadMeta, VisibilityIntent, + IdClass, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, + PublicProfile, ReachEntry, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, + SocialStatus, ThreadMeta, UniquesPools, VisibilityIntent, }; /// Direction for file_holders entries: whether we sent the file to this peer, @@ -92,11 +92,39 @@ impl StoragePool { /// Current schema version. Bump this when making schema or data changes /// that require migration. Old databases with a lower version will be migrated. /// If the gap is too large (major version mismatch), the DB is reset instead. -const SCHEMA_VERSION: u32 = 2; +const SCHEMA_VERSION: u32 = 3; /// Minimum schema version we can migrate from. Anything older gets a full reset. const MIN_MIGRATABLE_VERSION: u32 = 1; +/// How recently we must have been in contact with an anchor-flagged peer for +/// its address to ride our own bounce-1 slice. `peers.is_anchor` is a one-way +/// latch; this is what bounds it in time. +const ANCHOR_ENRICH_MAX_AGE_MS: i64 = 24 * 60 * 60 * 1000; // 24h + +// ---- v0.8 §layers size budget for the uniques pools ---- +// +// design.html §layers publishes N2 <= 400 and N3 <= 8,000 as the design +// ceiling. These are the ENFORCED numbers, set generously above the design +// budget so a large-but-honest peer degrades gracefully (truncation) rather +// than being refused, while a hostile peer cannot make us write six figures of +// rows per announce. + +/// Max entries we will BUILD into one shallow announce slice (fwd[1], fwd[2]). +pub const UNIQUES_BUILD_CAP_SHALLOW: usize = 4_000; +/// Max entries we will BUILD into the terminal slice (our N3, budget 8,000). +pub const UNIQUES_BUILD_CAP_TERM: usize = 12_000; + +/// Max entries we will ACCEPT at each bounce from one reporter — 2x our own +/// build caps, so an honest peer is never truncated and a hostile one is. +pub fn uniques_accept_cap(bounce: u8) -> usize { + match bounce { + 0 | 1 | 2 => 8_000, + 3 => 8_000, + _ => 24_000, + } +} + impl Storage { pub fn open(path: impl AsRef) -> anyhow::Result { let conn = Connection::open(path.as_ref())?; @@ -218,24 +246,25 @@ impl Storage { target_id BLOB PRIMARY KEY, failed_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS reachable_n2 ( - reporter_node_id BLOB NOT NULL, - reachable_node_id BLOB NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (reporter_node_id, reachable_node_id) + -- v0.8 (Iteration C): reachable_n2 + reachable_n3 collapse into one + -- bounce-parametrised table so N4 does not need a third near-identical + -- copy of 12 methods. bounce ∈ {2,3,4}; bounce 1 (our own uniques) is + -- computed from mesh_peers/social_routes/CDN, never stored here. + -- addresses is non-empty ONLY when is_anchor = 1 (design.html §layers). + CREATE TABLE IF NOT EXISTS reachable ( + reporter_node_id BLOB NOT NULL, + reachable_id BLOB NOT NULL, + bounce INTEGER NOT NULL, + id_class INTEGER NOT NULL DEFAULT 0, + is_anchor INTEGER NOT NULL DEFAULT 0, + addresses TEXT NOT NULL DEFAULT '[]', + updated_at INTEGER NOT NULL, + PRIMARY KEY (reporter_node_id, reachable_id) ); - CREATE INDEX IF NOT EXISTS idx_n2_reachable ON reachable_n2(reachable_node_id); - CREATE TABLE IF NOT EXISTS reachable_n3 ( - reporter_node_id BLOB NOT NULL, - reachable_node_id BLOB NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (reporter_node_id, reachable_node_id) - ); - CREATE INDEX IF NOT EXISTS idx_n3_reachable ON reachable_n3(reachable_node_id); + CREATE INDEX IF NOT EXISTS idx_reachable_id ON reachable(reachable_id, bounce); + CREATE INDEX IF NOT EXISTS idx_reachable_anchor ON reachable(is_anchor, bounce); CREATE TABLE IF NOT EXISTS mesh_peers ( node_id BLOB NOT NULL PRIMARY KEY, - slot_kind TEXT NOT NULL, - priority INTEGER NOT NULL DEFAULT 0, connected_at INTEGER NOT NULL, last_diff_seq INTEGER NOT NULL DEFAULT 0 ); @@ -308,10 +337,6 @@ impl Storage { target_id BLOB PRIMARY KEY, failed_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS preferred_peers ( - node_id BLOB PRIMARY KEY, - agreed_at INTEGER NOT NULL - ); CREATE TABLE IF NOT EXISTS circle_profiles ( author BLOB NOT NULL, circle_name TEXT NOT NULL, @@ -366,9 +391,34 @@ impl Storage { group_sig BLOB, encrypted_payload BLOB, deleted_at INTEGER, + -- v0.8 (A3): absolute expiry (ms). NULL/0 = legacy no-TTL. + expires_at INTEGER, + -- v0.8 (C): 1 = WE authored this comment locally. Locally + -- authored comment authors (our personas, our per-greeting + -- throwaway IDs) must never enter our own uniques announce — + -- announcing them at bounce 1 next to our node ID is a + -- persona-to-device linkage (design.html §21). + origin_local INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); + -- v0.8 (A3): local-only greeting inbox dismissals. + CREATE TABLE IF NOT EXISTS greeting_dismissals ( + comment_author BLOB NOT NULL, + post_id BLOB NOT NULL, + timestamp_ms INTEGER NOT NULL, + dismissed_at INTEGER NOT NULL, + PRIMARY KEY (comment_author, post_id, timestamp_ms) + ); + -- v0.8 (A3): private halves of per-greeting reply x25519 keys, + -- keyed by the reply pubkey we told the recipient to seal to. + -- return_post_id = our return-path post (own bio) at mint time. + CREATE TABLE IF NOT EXISTS greeting_reply_keys ( + reply_pubkey BLOB PRIMARY KEY, + reply_privkey BLOB NOT NULL, + return_post_id BLOB NOT NULL, + created_at INTEGER NOT NULL + ); CREATE TABLE IF NOT EXISTS comment_policies ( post_id BLOB PRIMARY KEY, policy_json TEXT NOT NULL @@ -656,6 +706,34 @@ impl Storage { "ALTER TABLE routing_peers RENAME TO mesh_peers;" ); + // v0.8 Iteration C: mesh_peers loses slot_kind (Local/Wide collapsed into + // one pool) and priority (the preferred-tier rank, dead since Iteration B). + // init_tables() uses CREATE TABLE IF NOT EXISTS, so an existing DB keeps + // the old 5-column table and never sees the new DDL — the drop+create has + // to happen HERE (migrate runs after init_tables, before migrate_data). + // Safe to drop outright: node.rs clears the whole table on every open. + let has_slot_kind = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('mesh_peers') WHERE name='slot_kind'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_slot_kind > 0 { + self.conn.execute_batch( + "DROP TABLE IF EXISTS mesh_peers; + CREATE TABLE mesh_peers ( + node_id BLOB NOT NULL PRIMARY KEY, + connected_at INTEGER NOT NULL, + last_diff_seq INTEGER NOT NULL DEFAULT 0 + );" + )?; + } + + // v0.8 Iteration C: reachable_n2/reachable_n3 → one `reachable` table + // parametrised by bounce (2/3/4). The N-layer index is rebuilt from + // scratch on every handshake, so there is nothing worth carrying over. + let _ = self.conn.execute_batch( + "DROP TABLE IF EXISTS reachable_n2; + DROP TABLE IF EXISTS reachable_n3;" + ); + // Add post_id/author/created_at/last_accessed_at columns to blobs if missing // (blobs are recoverable from filesystem + posts, so drop-and-recreate is safe) let has_blob_post_id = self.conn.prepare( @@ -688,25 +766,10 @@ impl Storage { )?; } - // Add preferred_peers column to profiles if missing (Preferred Mesh Peers migration) - let has_preferred_peers = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('profiles') WHERE name='preferred_peers'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_preferred_peers == 0 { - self.conn.execute_batch( - "ALTER TABLE profiles ADD COLUMN preferred_peers TEXT NOT NULL DEFAULT '[]';" - )?; - } - - // Add preferred_tree column to social_routes if missing (Preferred Tree migration) - let has_pref_tree = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('social_routes') WHERE name='preferred_tree'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_pref_tree == 0 { - self.conn.execute_batch( - "ALTER TABLE social_routes ADD COLUMN preferred_tree TEXT NOT NULL DEFAULT '[]';" - )?; - } + // v0.8: preferred-peer tier removed — drop the agreement table. + // (The unused profiles.preferred_peers / social_routes.preferred_tree + // columns are left in place on old dirs; no code reads them.) + self.conn.execute_batch("DROP TABLE IF EXISTS preferred_peers;")?; // Add public_visible column to profiles if missing (Phase D-4 migration) let has_public_visible = self.conn.prepare( @@ -800,6 +863,29 @@ impl Storage { )?; } + // v0.8 (A3): add expires_at for comment TTL (rand 30–365d ordinary, + // fixed 30d registry entries). NULL/0 = legacy comment without TTL. + let has_expires_at = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='expires_at'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_expires_at == 0 { + self.conn.execute_batch( + "ALTER TABLE comments ADD COLUMN expires_at INTEGER DEFAULT NULL;" + )?; + } + + // v0.8 (C): mark locally-authored comments so `build_own_uniques` can + // exclude their authors. Our own personas and per-greeting throwaway + // IDs must never ride our N1 slice — see §21 persona/device split. + let has_origin_local = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='origin_local'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_origin_local == 0 { + self.conn.execute_batch( + "ALTER TABLE comments ADD COLUMN origin_local INTEGER NOT NULL DEFAULT 0;" + )?; + } + // v0.6.2: add canonical_root_post_id to group_keys. When set, the // record is a group (many-way, anchored at a public root post); // when NULL it's a traditional circle (one-way, admin-only). @@ -821,65 +907,6 @@ impl Storage { "CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);" )?; - // v0.6.2 import bugfix: the import pipeline up through f b0e293 - // filed EVERY encrypted post as VisibilityIntent::Friends, including - // DMs. The Messages tab in the UI only shows posts whose intent is - // `Direct` (or `unknown` with the right visibility shape), so DMs - // imported from an "everything" bundle silently disappeared. - // - // This one-time migration reclassifies short-recipient encrypted - // posts filed as Friends back to Direct, using the same small-list - // heuristic as the import code (recipients <= 3 → Direct). A guard - // flag in the settings kv ensures this sweep runs once per DB. - let imported_dm_fixup_done = self.get_setting("mig_import_dm_fixup_v1")?.is_some(); - if !imported_dm_fixup_done { - let mut fixed = 0i64; - let mut stmt = self.conn.prepare( - "SELECT id, visibility FROM posts - WHERE visibility_intent = '\"Friends\"' - AND visibility LIKE '%Encrypted%'" - )?; - let rows: Vec<(Vec, String)> = stmt - .query_map([], |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)))? - .filter_map(|r| r.ok()) - .collect(); - drop(stmt); - for (id_bytes, vis_json) in rows { - // Parse out the recipients array from the Encrypted - // visibility. Short recipient list (≤ 3) = likely DM. - let vis: Result = serde_json::from_str(&vis_json); - let should_fix = match vis { - Ok(PostVisibility::Encrypted { recipients }) => recipients.len() <= 3, - _ => false, - }; - if !should_fix { continue; } - // Build a Direct-intent value with the recipient list recovered - // from the visibility — semantically equivalent to what the - // user's original send-side intent would have been. - let vis_parsed: PostVisibility = match serde_json::from_str(&vis_json) { - Ok(v) => v, - Err(_) => continue, - }; - let recipients: Vec = match vis_parsed { - PostVisibility::Encrypted { recipients } => { - recipients.iter().map(|wk| wk.recipient).collect() - } - _ => continue, - }; - let intent = crate::types::VisibilityIntent::Direct(recipients); - let intent_json = serde_json::to_string(&intent).unwrap_or_default(); - let n = self.conn.execute( - "UPDATE posts SET visibility_intent = ?1 WHERE id = ?2", - params![intent_json, id_bytes], - )?; - fixed += n as i64; - } - self.set_setting("mig_import_dm_fixup_v1", &fixed.to_string())?; - if fixed > 0 { - tracing::info!(count = fixed, "Migrated imported DMs from Friends-intent to Direct-intent"); - } - } - // Add device_role column to peers if missing (Active CDN replication) let has_device_role = self.conn.prepare( "SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'" @@ -912,22 +939,6 @@ impl Storage { )?; } - // 0.6.1-beta: seed file_holders from legacy upstream/downstream tables - // before they're dropped. Idempotent — only fires on an empty - // file_holders table. - self.seed_file_holders_from_legacy()?; - - // 0.6.1-beta: drop legacy directional tables — replaced by file_holders. - self.conn.execute_batch( - "DROP TABLE IF EXISTS blob_upstream; - DROP TABLE IF EXISTS blob_downstream; - DROP TABLE IF EXISTS post_upstream; - DROP TABLE IF EXISTS post_downstream;", - )?; - - // 0.6.2-beta: seed post_recipients index from existing encrypted posts. - self.seed_post_recipients_from_posts()?; - // FoF Layer 2: add comment columns for pub_x_index / group_sig / // encrypted_payload. Old DBs have NULL → deserializes to None. let has_comment_pub_x = self.conn.prepare( @@ -1249,20 +1260,29 @@ impl Storage { } /// Batch: reaction counts for multiple posts at once - pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_node_id: &NodeId) -> anyhow::Result>> { + /// `our_ids` = ALL of the local node's posting identities (reactors are + /// posting ids — the network NodeId never reacts). + pub fn get_reaction_counts_batch(&self, post_ids: &[PostId], our_ids: &[NodeId]) -> anyhow::Result>> { use std::collections::HashMap; let mut result: HashMap> = HashMap::new(); if post_ids.is_empty() { return Ok(result); } let placeholders: String = (0..post_ids.len()).map(|i| format!("?{}", i + 1)).collect::>().join(","); + let mine_placeholders: String = if our_ids.is_empty() { + "NULL".to_string() + } else { + (0..our_ids.len()).map(|i| format!("?{}", post_ids.len() + 1 + i)).collect::>().join(",") + }; let sql = format!( - "SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor = ?{} THEN 1 ELSE 0 END) as my_count + "SELECT post_id, emoji, COUNT(*) as cnt, SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count FROM reactions WHERE post_id IN ({}) AND deleted_at IS NULL GROUP BY post_id, emoji ORDER BY cnt DESC", - post_ids.len() + 1, placeholders + mine_placeholders, placeholders ); let mut stmt = self.conn.prepare(&sql)?; let mut params: Vec> = post_ids.iter().map(|id| Box::new(id.to_vec()) as Box).collect(); - params.push(Box::new(our_node_id.to_vec())); + for oid in our_ids { + params.push(Box::new(oid.to_vec())); + } let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let rows = stmt.query_map(param_refs.as_slice(), |row| { let pid: Vec = row.get(0)?; @@ -1545,7 +1565,7 @@ impl Storage { pub fn list_discoverable_profiles(&self, self_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT p.node_id, p.display_name, p.bio, p.updated_at, - p.anchors, p.recent_peers, p.preferred_peers, + p.anchors, p.recent_peers, p.public_visible, p.avatar_cid FROM profiles p WHERE p.display_name != '' @@ -1564,9 +1584,8 @@ impl Storage { let updated_at = row.get::<_, i64>(3)? as u64; let anchors = parse_anchors_json(&row.get::<_, String>(4).unwrap_or_default()); let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_default()); - let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_default()); - let public_visible: i64 = row.get(7).unwrap_or(1); - let avatar_cid: Option> = row.get(8).ok(); + let public_visible: i64 = row.get(6).unwrap_or(1); + let avatar_cid: Option> = row.get(7).ok(); let avatar_cid = avatar_cid.and_then(|v| if v.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&v); Some(arr) } else { None }); @@ -1577,7 +1596,6 @@ impl Storage { updated_at, anchors, recent_peers, - preferred_peers, public_visible: public_visible != 0, avatar_cid, }); @@ -1879,8 +1897,10 @@ impl Storage { /// Get a random N2 stranger (node in reachable_n2 but not in our connections) /// Returns (witness_node_id, reporter_node_id) for anchor probe pub fn random_n2_stranger(&self, our_connections: &std::collections::HashSet) -> anyhow::Result> { + // id_class = 0: an author/persona ID cannot act as a probe witness. let mut stmt = self.conn.prepare( - "SELECT reachable_node_id, reporter_node_id FROM reachable_n2 ORDER BY RANDOM() LIMIT 10" + "SELECT reachable_id, reporter_node_id FROM reachable + WHERE bounce = 2 AND id_class = 0 ORDER BY RANDOM() LIMIT 10" )?; let rows = stmt.query_map([], |row| { let rn: Vec = row.get(0)?; @@ -1970,12 +1990,9 @@ impl Storage { let recent_peers_json = serde_json::to_string( &profile.recent_peers.iter().map(hex::encode).collect::>() )?; - let preferred_peers_json = serde_json::to_string( - &profile.preferred_peers.iter().map(hex::encode).collect::>() - )?; let avatar_cid_slice = profile.avatar_cid.as_ref().map(|c| c.as_slice()); self.conn.execute( - "INSERT OR REPLACE INTO profiles (node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT OR REPLACE INTO profiles (node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ profile.node_id.as_slice(), profile.display_name, @@ -1983,7 +2000,6 @@ impl Storage { profile.updated_at as i64, anchors_json, recent_peers_json, - preferred_peers_json, profile.public_visible as i64, avatar_cid_slice, ], @@ -2122,15 +2138,14 @@ impl Storage { /// Get a profile by node ID pub fn get_profile(&self, node_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid FROM profiles WHERE node_id = ?1", + "SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid FROM profiles WHERE node_id = ?1", )?; let mut rows = stmt.query(params![node_id.as_slice()])?; if let Some(row) = rows.next()? { let anchors = parse_anchors_json(&row.get::<_, String>(4)?); let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string())); - let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_else(|_| "[]".to_string())); - let public_visible = row.get::<_, i64>(7).unwrap_or(1) != 0; - let avatar_cid = row.get::<_, Option>>(8).unwrap_or(None) + let public_visible = row.get::<_, i64>(6).unwrap_or(1) != 0; + let avatar_cid = row.get::<_, Option>>(7).unwrap_or(None) .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); Ok(Some(PublicProfile { node_id: blob_to_nodeid(row.get(0)?)?, @@ -2139,7 +2154,6 @@ impl Storage { updated_at: row.get::<_, i64>(3)? as u64, anchors, recent_peers, - preferred_peers, public_visible, avatar_cid, })) @@ -2152,7 +2166,7 @@ impl Storage { pub fn list_profiles(&self) -> anyhow::Result> { let mut stmt = self .conn - .prepare("SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid FROM profiles")?; + .prepare("SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid FROM profiles")?; let rows = stmt.query_map([], |row| { let node_id_bytes: Vec = row.get(0)?; let display_name: String = row.get(1)?; @@ -2160,14 +2174,13 @@ impl Storage { let updated_at: i64 = row.get(3)?; let anchors_json: String = row.get(4)?; let recent_peers_json: String = row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string()); - let preferred_peers_json: String = row.get::<_, String>(6).unwrap_or_else(|_| "[]".to_string()); - let public_visible: i64 = row.get::<_, i64>(7).unwrap_or(1); - let avatar_cid_bytes: Option> = row.get::<_, Option>>(8).unwrap_or(None); - Ok((node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, preferred_peers_json, public_visible, avatar_cid_bytes)) + let public_visible: i64 = row.get::<_, i64>(6).unwrap_or(1); + let avatar_cid_bytes: Option> = row.get::<_, Option>>(7).unwrap_or(None); + Ok((node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, public_visible, avatar_cid_bytes)) })?; let mut profiles = Vec::new(); for row in rows { - let (node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, preferred_peers_json, public_visible, avatar_cid_bytes) = row?; + let (node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, public_visible, avatar_cid_bytes) = row?; let avatar_cid = avatar_cid_bytes.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); profiles.push(PublicProfile { node_id: blob_to_nodeid(node_id_bytes)?, @@ -2176,7 +2189,6 @@ impl Storage { updated_at: updated_at as u64, anchors: parse_anchors_json(&anchors_json), recent_peers: parse_anchors_json(&recent_peers_json), - preferred_peers: parse_anchors_json(&preferred_peers_json), public_visible: public_visible != 0, avatar_cid, }); @@ -2208,9 +2220,84 @@ impl Storage { Ok(records) } - // ---- Known anchors (persistent anchor cache for NAT traversal) ---- + /// Anchor-flagged peers we have actually been in contact with recently. + /// + /// `build_own_uniques` uses this (never the unbounded `list_anchor_peers`) + /// so a stale one-way `is_anchor` latch cannot keep re-advertising an + /// address we have not touched in months. + pub fn list_anchor_peers_seen_since(&self, cutoff_ms: i64) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT node_id, addresses, last_seen, introduced_by, is_anchor, first_seen + FROM peers WHERE is_anchor = 1 AND last_seen >= ?1 ORDER BY last_seen DESC", + )?; + let mut records = Vec::new(); + let mut rows = stmt.query(params![cutoff_ms])?; + while let Some(row) = rows.next()? { + records.push(row_to_peer_record(row)?); + } + Ok(records) + } - /// Upsert a known anchor. Increments success_count on conflict. Auto-prunes to 5. + /// Merge addresses into a peer row WITHOUT dropping what is already there. + /// + /// `upsert_peer` REPLACES the address list, which is right when we have + /// just connected on those addresses and wrong for anything a peer merely + /// claims: a claimed address that replaced a working one is bugs-fixed #5 + /// (stale/wrong addresses block reconnection), remotely triggerable. Union + /// semantics keep every address we have ever had for the node, newest + /// first, capped so the row cannot be inflated without bound. + pub fn add_peer_addresses( + &self, + node_id: &NodeId, + addresses: &[SocketAddr], + ) -> anyhow::Result<()> { + const MAX_PEER_ADDRESSES: usize = 8; + if addresses.is_empty() { + return Ok(()); + } + // EXISTING FIRST, claims appended. `upsert_peer` writes the addresses we + // actually connected on, so position 0 stays the proven one — callers + // that take `.first()` keep working, and no volume of claims can push + // a working address out under the cap (bugs-fixed #5). + let mut merged: Vec = self + .get_peer_record(node_id)? + .map(|r| r.addresses.iter().map(|a| a.to_string()).collect()) + .unwrap_or_default(); + for a in addresses { + let s = a.to_string(); + if !merged.contains(&s) { + merged.push(s); + } + } + merged.truncate(MAX_PEER_ADDRESSES); + let now = now_ms(); + let addrs_json = serde_json::to_string(&merged)?; + self.conn.execute( + "INSERT INTO peers (node_id, addresses, last_seen, introduced_by, first_seen) + VALUES (?1, ?2, ?3, NULL, ?3) + ON CONFLICT(node_id) DO UPDATE SET addresses = ?2, last_seen = ?3", + params![node_id.as_slice(), addrs_json, now], + )?; + Ok(()) + } + + // ---- Known anchors — the BOOTSTRAP CACHE ---- + // + // v0.8 (round-4 ruling): this table is demoted. The uniques pools are the + // anchor directory (anchor entries are the only address-bearing rows, so + // `list_pool_anchors` supplies anchors in bulk and for free); `known_anchors` + // exists only to survive a cold start with an empty index. `success_count` + // ranking was a v0.7.x scarcity artifact — under pool-mined abundance, + // "the anchor that answered most often" is not a better anchor, it is just + // the one we hammered. Ordering is now by recency, which at least means + // "most likely to still be at that address". + + /// Cap on the bootstrap cache. Raised from 5 because `apply_uniques_- + /// to_storage` now mirrors every received anchor entry here — at 5 the + /// table would thrash on every announce. + pub const KNOWN_ANCHOR_CACHE_CAP: usize = 32; + + /// Upsert a known anchor. Auto-prunes to `KNOWN_ANCHOR_CACHE_CAP`. pub fn upsert_known_anchor(&self, node_id: &NodeId, addresses: &[SocketAddr]) -> anyhow::Result<()> { let addr_json = serde_json::to_string( &addresses.iter().map(|a| a.to_string()).collect::>(), @@ -2223,18 +2310,19 @@ impl Storage { addresses = ?2, last_seen_ms = ?3, success_count = success_count + 1", params![node_id.as_slice(), addr_json, now], )?; - self.prune_known_anchors(5)?; + self.prune_known_anchors(Self::KNOWN_ANCHOR_CACHE_CAP)?; Ok(()) } - /// List known anchors, ordered by success_count descending. + /// List the bootstrap cache, freshest first. (Not success_count — see the + /// section comment above.) pub fn list_known_anchors(&self) -> anyhow::Result)>> { let mut stmt = self.conn.prepare( "SELECT node_id, addresses FROM known_anchors - ORDER BY success_count DESC LIMIT 5", + ORDER BY last_seen_ms DESC LIMIT ?1", )?; let mut result = Vec::new(); - let mut rows = stmt.query([])?; + let mut rows = stmt.query(params![Self::KNOWN_ANCHOR_CACHE_CAP as i64])?; while let Some(row) = rows.next()? { let node_id = blob_to_nodeid(row.get(0)?)?; let addr_json: String = row.get(1)?; @@ -2248,7 +2336,36 @@ impl Storage { Ok(result) } - /// Prune known anchors to keep at most `max` entries (by highest success_count). + /// Get the last successful contact time (ms since epoch) for a known anchor. + /// Returns None if the anchor isn't in the table. + pub fn get_known_anchor_last_seen(&self, node_id: &NodeId) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT last_seen_ms FROM known_anchors WHERE node_id = ?1", + )?; + let mut rows = stmt.query(params![node_id.as_slice()])?; + if let Some(row) = rows.next()? { + let ms: i64 = row.get(0)?; + Ok(Some(ms as u64)) + } else { + Ok(None) + } + } + + /// Remove a known anchor entry. Used by the bootstrap connect path + /// when a stale anchor (>3 days since last successful contact) fails + /// to connect — self-healing pruning so future startups don't re-try + /// long-dead entries. + pub fn delete_known_anchor(&self, node_id: &NodeId) -> anyhow::Result<()> { + self.conn.execute( + "DELETE FROM known_anchors WHERE node_id = ?1", + params![node_id.as_slice()], + )?; + Ok(()) + } + + /// Prune the bootstrap cache to at most `max` entries, dropping the + /// stalest first. (The 3-day stale-anchor self-prune on failed probes — + /// `delete_known_anchor` via `maybe_prune_stale_anchor` — is unchanged.) pub fn prune_known_anchors(&self, max: usize) -> anyhow::Result { let count: i64 = self.conn.query_row( "SELECT COUNT(*) FROM known_anchors", @@ -2262,7 +2379,7 @@ impl Storage { self.conn.execute( "DELETE FROM known_anchors WHERE node_id IN ( SELECT node_id FROM known_anchors - ORDER BY success_count ASC, last_seen_ms ASC + ORDER BY last_seen_ms ASC LIMIT ?1 )", params![excess as i64], @@ -2530,15 +2647,18 @@ impl Storage { } } - /// Resolve display info for a peer: check circle profiles the viewer belongs to, - /// then fall back to public profile. + /// Resolve display info for a peer: check circle profiles any of the + /// viewer's posting identities belongs to, then fall back to public + /// profile. `viewers` = ALL of the local node's posting identities + /// (circle_members holds posting ids, never the network NodeId). /// Returns (display_name, bio, avatar_cid). pub fn resolve_display_for_peer( &self, author: &NodeId, - viewer: &NodeId, + viewers: &[NodeId], ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { - // Find circles where viewer is a member and author has a circle profile + // Find circles where a viewer persona is a member and author has a + // circle profile; take the freshest match across all personas. let mut stmt = self.conn.prepare( "SELECT cp.display_name, cp.bio, cp.avatar_cid, cp.updated_at FROM circle_profiles cp @@ -2547,17 +2667,26 @@ impl Storage { ORDER BY cp.updated_at DESC LIMIT 1", )?; - let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?; - if let Some(row) = rows.next()? { - let dn: String = row.get(0)?; - // Only use circle profile if it has actual content - if !dn.is_empty() { - let bio: String = row.get(1)?; - let avatar_cid = row.get::<_, Option>>(2).unwrap_or(None) - .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); - return Ok((dn, bio, avatar_cid)); + let mut best: Option<(String, String, Option<[u8; 32]>, u64)> = None; + for viewer in viewers { + let mut rows = stmt.query(params![author.as_slice(), viewer.as_slice()])?; + if let Some(row) = rows.next()? { + let dn: String = row.get(0)?; + // Only use circle profile if it has actual content + if !dn.is_empty() { + let bio: String = row.get(1)?; + let avatar_cid = row.get::<_, Option>>(2).unwrap_or(None) + .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); + let updated_at = row.get::<_, i64>(3).unwrap_or(0) as u64; + if best.as_ref().map(|b| updated_at > b.3).unwrap_or(true) { + best = Some((dn, bio, avatar_cid, updated_at)); + } + } } } + if let Some((dn, bio, avatar_cid, _)) = best { + return Ok((dn, bio, avatar_cid)); + } // Fall back to public profile if let Some(profile) = self.get_profile(author)? { @@ -3129,17 +3258,30 @@ impl Storage { /// Get a summary of redundancy across all our authored posts. /// Returns (total, zero_replicas, one_replica, two_plus_replicas). + /// Redundancy summary across every post authored by ANY of the + /// device's posting identities (personas). Pre-v0.6.0 this matched + /// on the device's network NodeId, but the network/posting-ID + /// split moved authorship to the posting identity — so the old + /// query returned 0 of my posts and the UI showed 0 redundancy + /// for new posts. pub fn get_redundancy_summary( &self, - our_node_id: &NodeId, + author_ids: &[NodeId], staleness_ms: u64, ) -> anyhow::Result<(usize, usize, usize, usize)> { + if author_ids.is_empty() { + return Ok((0, 0, 0, 0)); + } let cutoff = now_ms() - staleness_ms as i64; - let mut stmt = self.conn.prepare( - "SELECT p.id FROM posts p WHERE p.author = ?1", - )?; + let placeholders: Vec<&str> = (0..author_ids.len()).map(|_| "?").collect(); + let sql = format!( + "SELECT p.id FROM posts p WHERE p.author IN ({})", + placeholders.join(","), + ); + let mut stmt = self.conn.prepare(&sql)?; + let params_iter = rusqlite::params_from_iter(author_ids.iter().map(|n| n.to_vec())); let post_ids: Vec = { - let mut rows = stmt.query(params![our_node_id.as_slice()])?; + let mut rows = stmt.query(params_iter)?; let mut ids = Vec::new(); while let Some(row) = rows.next()? { ids.push(blob_to_postid(row.get(0)?)?); @@ -3378,276 +3520,562 @@ impl Storage { Ok(count > 0) } - // ---- Reach: N2/N3 ---- + // ---- Reach: the bounce-parametrised uniques index ---- - /// Replace a peer's entire N1 set in reachable_n2 (their N1 share → our N2). - pub fn set_peer_n1(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { - let now = now_ms(); + /// Replace a reporter's ENTIRE contribution at one bounce. + /// + /// Dedup is free at the store: the composite PK `(reporter, reachable_id)` + /// gives per-reporter set-merge, and the conflict clause keeps the + /// SHALLOWEST bounce we have ever heard from this reporter — so an ID can + /// never occupy two depths from one reporter (the ambiguity the old + /// n2/n3-table pair papered over by sorting). + /// + /// DELETE + inserts run in ONE transaction. Thousands of individually + /// committed inserts (an announce is sized in thousands of rows, once per + /// peer per cycle across ~20 peers) is the dominant cost of the whole + /// uniques exchange; one commit makes it a rounding error. + pub fn set_reach( + &self, + reporter: &NodeId, + bounce: u8, + entries: &[ReachEntry], + ) -> anyhow::Result<()> { + let tx = self.conn.unchecked_transaction()?; self.conn.execute( - "DELETE FROM reachable_n2 WHERE reporter_node_id = ?1", - params![reporter.as_slice()], + "DELETE FROM reachable WHERE reporter_node_id = ?1 AND bounce = ?2", + params![reporter.as_slice(), bounce as i64], )?; - let mut stmt = self.conn.prepare( - "INSERT OR REPLACE INTO reachable_n2 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", - )?; - for nid in node_ids { - stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; - } + self.add_reach_inner(reporter, bounce, entries)?; + tx.commit()?; Ok(()) } - /// Add NodeIds to a peer's N1 set in reachable_n2. - pub fn add_peer_n1(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { + /// Merge entries into a reporter's contribution at one bounce. + pub fn add_reach( + &self, + reporter: &NodeId, + bounce: u8, + entries: &[ReachEntry], + ) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } + let tx = self.conn.unchecked_transaction()?; + self.add_reach_inner(reporter, bounce, entries)?; + tx.commit()?; + Ok(()) + } + + fn add_reach_inner( + &self, + reporter: &NodeId, + bounce: u8, + entries: &[ReachEntry], + ) -> anyhow::Result<()> { + if entries.is_empty() { + return Ok(()); + } let now = now_ms(); let mut stmt = self.conn.prepare( - "INSERT OR REPLACE INTO reachable_n2 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", + "INSERT INTO reachable + (reporter_node_id, reachable_id, bounce, id_class, is_anchor, addresses, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(reporter_node_id, reachable_id) DO UPDATE SET + bounce = MIN(bounce, excluded.bounce), + id_class = excluded.id_class, + is_anchor = MAX(is_anchor, excluded.is_anchor), + addresses = CASE WHEN excluded.is_anchor = 1 THEN excluded.addresses ELSE addresses END, + updated_at = excluded.updated_at", )?; - for nid in node_ids { - stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; + for e in entries { + let addrs_json = if e.is_anchor && !e.addresses.is_empty() { + serde_json::to_string(&e.addresses).unwrap_or_else(|_| "[]".into()) + } else { + "[]".to_string() + }; + stmt.execute(params![ + reporter.as_slice(), + e.id.as_slice(), + bounce as i64, + e.id_class.as_i64(), + if e.is_anchor { 1i64 } else { 0i64 }, + addrs_json, + now, + ])?; } Ok(()) } - /// Remove NodeIds from a peer's N1 set in reachable_n2. - pub fn remove_peer_n1(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { - let mut stmt = self.conn.prepare( - "DELETE FROM reachable_n2 WHERE reporter_node_id = ?1 AND reachable_node_id = ?2", - )?; - for nid in node_ids { - stmt.execute(params![reporter.as_slice(), nid.as_slice()])?; - } - Ok(()) - } - - /// Remove all N2 entries from a specific reporter (on disconnect). - pub fn clear_peer_n2(&self, reporter: &NodeId) -> anyhow::Result { + /// Remove all entries a reporter contributed (on disconnect). + pub fn clear_peer_reach(&self, reporter: &NodeId) -> anyhow::Result { let deleted = self.conn.execute( - "DELETE FROM reachable_n2 WHERE reporter_node_id = ?1", + "DELETE FROM reachable WHERE reporter_node_id = ?1", params![reporter.as_slice()], )?; Ok(deleted) } - /// Replace a peer's N2-reported entries in reachable_n3 (their N2 share → our N3). - pub fn set_peer_n2(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { - let now = now_ms(); - self.conn.execute( - "DELETE FROM reachable_n3 WHERE reporter_node_id = ?1", - params![reporter.as_slice()], - )?; + /// Which reporters place this ID at exactly this bounce? + pub fn find_reporters_at(&self, id: &NodeId, bounce: u8) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "INSERT OR REPLACE INTO reachable_n3 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", + "SELECT reporter_node_id FROM reachable WHERE reachable_id = ?1 AND bounce = ?2", )?; - for nid in node_ids { - stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; + let mut result = Vec::new(); + let mut rows = stmt.query(params![id.as_slice(), bounce as i64])?; + while let Some(row) = rows.next()? { + result.push(blob_to_nodeid(row.get(0)?)?); } - Ok(()) + Ok(result) } - /// Add to N3 from a peer's N2 changes. - pub fn add_peer_n2(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { - let now = now_ms(); - let mut stmt = self.conn.prepare( - "INSERT OR REPLACE INTO reachable_n3 (reporter_node_id, reachable_node_id, updated_at) VALUES (?1, ?2, ?3)", - )?; - for nid in node_ids { - stmt.execute(params![reporter.as_slice(), nid.as_slice(), now])?; - } - Ok(()) - } - - /// Remove from N3. - pub fn remove_peer_n2(&self, reporter: &NodeId, node_ids: &[NodeId]) -> anyhow::Result<()> { - let mut stmt = self.conn.prepare( - "DELETE FROM reachable_n3 WHERE reporter_node_id = ?1 AND reachable_node_id = ?2", - )?; - for nid in node_ids { - stmt.execute(params![reporter.as_slice(), nid.as_slice()])?; - } - Ok(()) - } - - /// Remove all N3 entries from a specific reporter. - pub fn clear_peer_n3(&self, reporter: &NodeId) -> anyhow::Result { - let deleted = self.conn.execute( - "DELETE FROM reachable_n3 WHERE reporter_node_id = ?1", - params![reporter.as_slice()], - )?; - Ok(deleted) - } - - /// Which reporters have this node in N2? + /// Which reporters have this ID in our N2 (bounce 2)? pub fn find_in_n2(&self, node_id: &NodeId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT reporter_node_id FROM reachable_n2 WHERE reachable_node_id = ?1", - )?; - let mut result = Vec::new(); - let mut rows = stmt.query(params![node_id.as_slice()])?; - while let Some(row) = rows.next()? { - result.push(blob_to_nodeid(row.get(0)?)?); - } - Ok(result) + self.find_reporters_at(node_id, 2) } - /// Which reporters have this node in N3? + /// Which reporters have this ID in our N3 (bounce 3)? pub fn find_in_n3(&self, node_id: &NodeId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT reporter_node_id FROM reachable_n3 WHERE reachable_node_id = ?1", - )?; - let mut result = Vec::new(); - let mut rows = stmt.query(params![node_id.as_slice()])?; - while let Some(row) = rows.next()? { - result.push(blob_to_nodeid(row.get(0)?)?); - } - Ok(result) + self.find_reporters_at(node_id, 3) } - /// Batch lookup: find any of the given node IDs in N2 or N3. - /// Returns Vec<(target, reporter, level)> where level is 2 or 3, sorted by level ASC. - pub fn find_any_in_n2_n3(&self, ids: &[NodeId]) -> anyhow::Result> { + /// Which reporters have this ID in our N4 (bounce 4)? + /// + /// N4 is USED (search, address resolution) but NEVER re-announced — + /// `build_uniques_pools` simply does not read bounce-4 rows. + pub fn find_in_n4(&self, node_id: &NodeId) -> anyhow::Result> { + self.find_reporters_at(node_id, 4) + } + + /// Batch lookup across the whole stored horizon (bounces 2..4). + /// Returns Vec<(target, reporter, bounce)> sorted shallowest-first. + /// Node-class only — an author ID is not a routing target. + pub fn find_any_reachable(&self, ids: &[NodeId]) -> anyhow::Result> { if ids.is_empty() { return Ok(vec![]); } + let mut stmt = self.conn.prepare( + "SELECT reporter_node_id, bounce FROM reachable + WHERE reachable_id = ?1 AND id_class = 0 ORDER BY bounce ASC", + )?; let mut results = Vec::new(); - // Check N2 first (closer) for id in ids { - let reporters = self.find_in_n2(id)?; - for r in reporters { - results.push((*id, r, 2u8)); + let mut rows = stmt.query(params![id.as_slice()])?; + while let Some(row) = rows.next()? { + let reporter = blob_to_nodeid(row.get(0)?)?; + let bounce: i64 = row.get(1)?; + results.push((*id, reporter, bounce as u8)); } } - // Then N3 - for id in ids { - let reporters = self.find_in_n3(id)?; - for r in reporters { - results.push((*id, r, 3u8)); - } - } - results.sort_by_key(|&(_, _, level)| level); + results.sort_by_key(|&(_, _, bounce)| bounce); Ok(results) } - /// All NodeIds this peer can reach (from N2 table). - pub fn list_n2_for_reporter(&self, reporter: &NodeId) -> anyhow::Result> { + /// Everything a reporter contributed at one bounce. + pub fn list_reach_for_reporter( + &self, + reporter: &NodeId, + bounce: u8, + ) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT reachable_node_id FROM reachable_n2 WHERE reporter_node_id = ?1", + "SELECT reachable_id FROM reachable WHERE reporter_node_id = ?1 AND bounce = ?2", )?; let mut result = Vec::new(); - let mut rows = stmt.query(params![reporter.as_slice()])?; + let mut rows = stmt.query(params![reporter.as_slice(), bounce as i64])?; while let Some(row) = rows.next()? { result.push(blob_to_nodeid(row.get(0)?)?); } Ok(result) } - /// Build N1 share: merge mesh peers (connections) + social contacts NodeIds (deduplicated). - pub fn build_n1_share(&self) -> anyhow::Result> { - let mut ids = std::collections::HashSet::new(); - // Add mesh peers (connections) - let mesh_peers = self.list_mesh_peers()?; - for (nid, _, _) in mesh_peers { - ids.insert(nid); + /// Distinct IDs stored at a bounce, with their class/anchor metadata. + /// + /// One row per ID is selected EXPLICITLY (anchor rows first, then freshest) + /// rather than relying on SQLite's bare-column-with-MAX() quirk to keep + /// `id_class`, `is_anchor` and `addresses` from the same physical row. A + /// future query rewrite or a second aggregate would silently break that + /// pairing, and the thing it pairs is "is this ID address-bearing" — the + /// invariant the whole announce format rests on. + /// + /// `limit` bounds the result deterministically so the announce builder + /// cannot emit an unbounded slice; ordering is stable across cycles + /// (anchors, then freshest) so the announce digest does not thrash. + pub fn list_reach_entries_at(&self, bounce: u8) -> anyhow::Result> { + self.list_reach_entries_at_limited(bounce, usize::MAX) + } + + pub fn list_reach_entries_at_limited( + &self, + bounce: u8, + limit: usize, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT reachable_id, id_class, is_anchor, addresses, updated_at + FROM reachable WHERE bounce = ?1 + ORDER BY reachable_id ASC, is_anchor DESC, updated_at DESC", + )?; + let mut result: Vec = Vec::new(); + let mut last: Option = None; + let mut rows = stmt.query(params![bounce as i64])?; + while let Some(row) = rows.next()? { + let id = blob_to_nodeid(row.get(0)?)?; + if last == Some(id) { + continue; // keep only the winning row per ID + } + last = Some(id); + let id_class = IdClass::from_i64(row.get::<_, i64>(1)?); + let is_anchor = row.get::<_, i64>(2)? != 0; + let addrs_json: String = row.get(3)?; + let addresses: Vec = serde_json::from_str(&addrs_json).unwrap_or_default(); + result.push(ReachEntry { id, id_class, is_anchor, addresses }); } - // Add only ONLINE social routes (not disconnected) - let routes = self.list_social_routes()?; - for route in routes { - if route.status == crate::types::SocialStatus::Online { - ids.insert(route.node_id); + if result.len() > limit { + // Anchors first (they are the anchor directory), then the rest — + // deterministic, so trimming does not thrash the digest. + result.sort_by(|a, b| b.is_anchor.cmp(&a.is_anchor).then(a.id.cmp(&b.id))); + result.truncate(limit); + } + Ok(result) + } + + /// Distinct IDs stored at a bounce (bare). + pub fn list_reach_ids_at(&self, bounce: u8) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT DISTINCT reachable_id FROM reachable WHERE bounce = ?1", + )?; + let mut result = Vec::new(); + let mut rows = stmt.query(params![bounce as i64])?; + while let Some(row) = rows.next()? { + result.push(blob_to_nodeid(row.get(0)?)?); + } + Ok(result) + } + + /// Deepest bounce present in the index (0 when empty). Test/diagnostic + /// helper: nothing may ever be stored past bounce 4. + pub fn max_stored_bounce(&self) -> anyhow::Result { + Ok(self.conn.query_row( + "SELECT ifnull(MAX(bounce), 0) FROM reachable", + [], + |row| row.get(0), + )?) + } + + /// Count of distinct IDs stored at a bounce. + pub fn count_distinct_at(&self, bounce: u8) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(DISTINCT reachable_id) FROM reachable WHERE bounce = ?1", + params![bounce as i64], + |row| row.get(0), + )?; + Ok(count as usize) + } + + /// Anchor entries mined from the uniques pools, shallowest and freshest first. + /// + /// Round-4 ruling: because anchor entries are the only address-bearing rows, + /// the pools double as the anchor directory. `known_anchors` demotes to a + /// bootstrap cache. SEAM FOR WRITER 2 (convection): this is the query the + /// stochastic growth trigger should draw a "random known anchor" from. + pub fn list_pool_anchors(&self, limit: usize) -> anyhow::Result)>> { + let mut stmt = self.conn.prepare( + "SELECT reachable_id, addresses, MIN(bounce) FROM reachable + WHERE is_anchor = 1 AND id_class = 0 + GROUP BY reachable_id + ORDER BY MIN(bounce) ASC, MAX(updated_at) DESC + LIMIT ?1", + )?; + let mut result = Vec::new(); + let mut rows = stmt.query(params![limit as i64])?; + while let Some(row) = rows.next()? { + let id = blob_to_nodeid(row.get(0)?)?; + let addrs_json: String = row.get(1)?; + let addrs: Vec = serde_json::from_str(&addrs_json).unwrap_or_default(); + if !addrs.is_empty() { + result.push((id, addrs)); } } - Ok(ids.into_iter().collect()) - } - - /// Build N2 share (reach): deduplicated unique NodeIds from all N2 entries. - pub fn build_n2_share(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT DISTINCT reachable_node_id FROM reachable_n2", - )?; - let mut result = Vec::new(); - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - result.push(blob_to_nodeid(row.get(0)?)?); - } Ok(result) } - /// Count distinct reachable NodeIds in the N2 table. + /// Anchor-flagged share of the distinct node-class IDs we know about — a + /// free local statistic (no census) because anchor entries self-identify by + /// carrying an address. SEAM FOR WRITER 2: round-5 adaptive action-weight + /// prior for the stochastic convection trigger. + pub fn anchor_density(&self) -> anyhow::Result { + let (anchors, total): (i64, i64) = self.conn.query_row( + "SELECT COUNT(DISTINCT CASE WHEN is_anchor = 1 THEN reachable_id END), + COUNT(DISTINCT reachable_id) + FROM reachable WHERE id_class = 0", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if total == 0 { + return Ok(0.0); + } + Ok(anchors as f64 / total as f64) + } + + /// Our own bounce-1 uniques: every ID we have a live path to, merged from + /// all four sources BEFORE announcing so a receiver cannot tell which source + /// an entry came from. + /// + /// * mesh peers (node class) — temp referral slots are excluded + /// structurally: they are never + /// written to `mesh_peers` + /// * social directs (node class) — ALL of them, not just Online: + /// the ruling is "everyone's + /// directs"; a held route is a + /// findable ID either way + /// * CDN file-holder peers (node class) + /// * CDN file authors (AUTHOR class) — posting identities. No address, + /// no device linkage. Kept in a + /// separate class so they never + /// enter the connect/hole-punch + /// paths. + /// * comment authors (AUTHOR class) — OTHER people's throwaway/ + /// greeting IDs stay in + /// deliberately (anonymity noise, + /// round-1 ruling); ours are + /// excluded, see below. + /// + /// NEVER included: + /// * our own posting identities (all personas, not just the default) and + /// any author ID on a locally-authored comment (per-greeting throwaway + /// IDs). Our node ID and — when we are an anchor — our address ride + /// `fwd[0]` of the very same announce, so emitting our personas at + /// bounce 1 would hand every mesh peer a signed-by-transport + /// persona→device mapping. Worse, our own personas are the invariant + /// across every announce we ever send while everything else churns, so + /// the set is trivially separable by diffing two announces. design.html + /// §21: "Posting identities never map to device addresses on the wire." + /// They remain discoverable through everyone ELSE who holds our + /// content — we simply must not be the reporter. + /// * anchors we have merely HEARD about. A third-party anchor report is + /// an index row at its true bounce, not a bounce-1 unique of ours; + /// re-emitting it at depth 1 would reset its horizon on every hop and + /// let a terminal (N4) entry propagate forever. + pub fn build_own_uniques(&self) -> anyhow::Result> { + let mut nodes: std::collections::HashSet = std::collections::HashSet::new(); + let mut authors: std::collections::HashSet = std::collections::HashSet::new(); + + for nid in self.list_mesh_peers()? { + nodes.insert(nid); + } + for route in self.list_social_routes()? { + nodes.insert(route.node_id); + } + { + let mut stmt = self.conn.prepare("SELECT DISTINCT peer_id FROM file_holders")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + if let Ok(nid) = blob_to_nodeid(row.get(0)?) { + nodes.insert(nid); + } + } + } + { + let mut stmt = self.conn.prepare("SELECT DISTINCT node_id FROM post_replicas")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + if let Ok(nid) = blob_to_nodeid(row.get(0)?) { + nodes.insert(nid); + } + } + } + { + let mut stmt = self.conn.prepare("SELECT DISTINCT author FROM cdn_manifests")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + if let Ok(nid) = blob_to_nodeid(row.get(0)?) { + authors.insert(nid); + } + } + } + { + // origin_local = 0: comments that reached us from the network. + // Ours are filtered at the store, not here, so nobody has to + // remember the rule at each of the four local authoring sites. + let mut stmt = self.conn + .prepare("SELECT DISTINCT author FROM comments WHERE origin_local = 0")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + if let Ok(nid) = blob_to_nodeid(row.get(0)?) { + authors.insert(nid); + } + } + } + + // Our own personas — ALL of them, not just the default posting ID — + // never ride our own announce. See the doc comment above. + for pi in self.list_posting_identities()? { + authors.remove(&pi.node_id); + nodes.remove(&pi.node_id); + } + + // A node-class ID wins over an author-class one: it is the strictly + // more useful classification and cannot mislead a connect attempt. + for n in &nodes { + authors.remove(n); + } + + // Anchor enrichment, NOT anchor import. An ID already in `nodes` (we + // have a live path to it) that we have PROVEN is an anchor by + // connecting to it carries its address; nothing new is added to the + // set. `is_anchor` is only ever set from a completed handshake or a + // connected peer's own self-declaration — never from a third party's + // announce — so a bounce-4 hearsay anchor can never surface here at + // bounce 1. + let anchor_ids: std::collections::HashSet = self + .list_anchor_peers_seen_since(now_ms() - ANCHOR_ENRICH_MAX_AGE_MS)? + .into_iter() + .map(|r| r.node_id) + .filter(|id| nodes.contains(id)) + .collect(); + + let mut out: Vec = Vec::with_capacity(nodes.len() + authors.len()); + for id in nodes { + let addresses = if anchor_ids.contains(&id) { + self.get_peer_record(&id) + .ok() + .flatten() + .map(|r| { + r.addresses + .iter() + .filter(|a| crate::network::is_publicly_routable(a)) + .map(|a| a.to_string()) + .collect::>() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + let is_anchor = !addresses.is_empty(); + out.push(ReachEntry { id, id_class: IdClass::Node, is_anchor, addresses }); + } + for id in authors { + out.push(ReachEntry { + id, + id_class: IdClass::Author, + is_anchor: false, + addresses: Vec::new(), + }); + } + Ok(out) + } + + /// Build the two announce pools (design.html §layers). + /// + /// Slices are built shallow-to-deep against one running set of already- + /// emitted IDs, so an ID announced at a shallower layer is never repeated + /// deeper — dedup at the ANNOUNCE side, complementing the store-side + /// `MIN(bounce)` upsert. Duplication counts are never transmitted (that + /// would leak topology); only membership is used and the set is discarded. + /// + /// The terminal pool reads bounce 3. Bounce 4 (our N4) is NEVER read here — + /// that omission is the whole "terminal, never re-announced" rule, and it + /// is enforced structurally rather than by a filter someone can forget. + pub fn build_uniques_pools( + &self, + our_node_id: &NodeId, + our_anchor_addr: Option<&str>, + ) -> anyhow::Result { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + // fwd[0] = N0: ourselves. The only place our own anchor address rides. + let self_entry = ReachEntry { + id: *our_node_id, + id_class: IdClass::Node, + is_anchor: our_anchor_addr.is_some(), + addresses: our_anchor_addr.map(|a| vec![a.to_string()]).unwrap_or_default(), + }; + seen.insert(*our_node_id); + let fwd0 = vec![self_entry]; + + let take = |entries: Vec, + seen: &mut std::collections::HashSet| + -> Vec { + let mut out = Vec::new(); + for e in entries { + if seen.insert(e.id) { + out.push(e); + } + } + out + }; + + // Each slice is capped (§layers size budget). The trim is deterministic + // — anchors first, then by ID — so a stable index produces a stable + // digest and the announce is skipped rather than resent every cycle. + let mut fwd1 = take(self.build_own_uniques()?, &mut seen); + if fwd1.len() > UNIQUES_BUILD_CAP_SHALLOW { + fwd1.sort_by(|a, b| b.is_anchor.cmp(&a.is_anchor).then(a.id.cmp(&b.id))); + fwd1.truncate(UNIQUES_BUILD_CAP_SHALLOW); + } + let fwd2 = take( + self.list_reach_entries_at_limited(2, UNIQUES_BUILD_CAP_SHALLOW)?, + &mut seen, + ); + let term = take( + self.list_reach_entries_at_limited(3, UNIQUES_BUILD_CAP_TERM)?, + &mut seen, + ); + + Ok(UniquesPools { fwd: vec![fwd0, fwd1, fwd2], term }) + } + + /// Count distinct reachable IDs at bounce 2 (our N2). pub fn count_distinct_n2(&self) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(DISTINCT reachable_node_id) FROM reachable_n2", - [], - |row| row.get(0), - )?; - Ok(count as usize) + self.count_distinct_at(2) } - /// Count distinct reachable NodeIds in the N3 table. + /// Count distinct reachable IDs at bounce 3 (our N3). pub fn count_distinct_n3(&self) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(DISTINCT reachable_node_id) FROM reachable_n3", - [], - |row| row.get(0), - )?; - Ok(count as usize) + self.count_distinct_at(3) } - /// List distinct reachable NodeIds in the N3 table. + /// Count distinct reachable IDs at bounce 4 (our N4 — the terminal layer). + pub fn count_distinct_n4(&self) -> anyhow::Result { + self.count_distinct_at(4) + } + + /// List distinct reachable IDs at bounce 3. pub fn list_distinct_n3(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT DISTINCT reachable_node_id FROM reachable_n3", - )?; - let mut result = Vec::new(); - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - result.push(blob_to_nodeid(row.get(0)?)?); - } - Ok(result) + self.list_reach_ids_at(3) } - /// Diversity score: how many unique NodeIds does this reporter contribute + /// List distinct reachable IDs at bounce 4. + pub fn list_distinct_n4(&self) -> anyhow::Result> { + self.list_reach_ids_at(4) + } + + /// Diversity score: how many IDs does this reporter contribute at bounce 2 /// that no other reporter provides? pub fn count_unique_n2_for_reporter( &self, reporter: &NodeId, - exclude_reporters: &[NodeId], + _exclude_reporters: &[NodeId], ) -> anyhow::Result { - // Get this reporter's N2 set let reporter_set: std::collections::HashSet = - self.list_n2_for_reporter(reporter)?.into_iter().collect(); - + self.list_reach_for_reporter(reporter, 2)?.into_iter().collect(); if reporter_set.is_empty() { return Ok(0); } - - // Get all other reporters' N2 sets (excluding specified reporters) - let exclude_set: std::collections::HashSet = - exclude_reporters.iter().copied().collect(); let mut other_nodes = std::collections::HashSet::new(); - let mut stmt = self.conn.prepare( - "SELECT reachable_node_id FROM reachable_n2 WHERE reporter_node_id != ?1", + "SELECT reachable_id FROM reachable WHERE reporter_node_id != ?1 AND bounce = 2", )?; let mut rows = stmt.query(params![reporter.as_slice()])?; while let Some(row) = rows.next()? { - let rn: Vec = row.get(0)?; - // Check if the reporter of this entry is excluded - // (simplified: we just exclude the reporter itself) - if let Ok(nid) = blob_to_nodeid(rn) { + if let Ok(nid) = blob_to_nodeid(row.get(0)?) { other_nodes.insert(nid); } } - - let unique = reporter_set.difference(&other_nodes).count(); - let _ = exclude_set; // used for future filtering - Ok(unique) + Ok(reporter_set.difference(&other_nodes).count()) } - /// Remove stale N2/N3 entries. - /// Clear ALL N2/N3 entries (startup sweep after unclean shutdown). - pub fn clear_all_n2_n3(&self) -> anyhow::Result { - let d1 = self.conn.execute("DELETE FROM reachable_n2", [])?; - let d2 = self.conn.execute("DELETE FROM reachable_n3", [])?; - Ok(d1 + d2) + /// Clear the whole uniques index (startup sweep after unclean shutdown). + pub fn clear_all_reach(&self) -> anyhow::Result { + Ok(self.conn.execute("DELETE FROM reachable", [])?) } /// Clear ALL mesh_peers entries (no connections exist at startup). @@ -3656,39 +4084,56 @@ impl Storage { Ok(deleted) } - pub fn prune_n2_n3(&self, max_age_ms: u64) -> anyhow::Result { + /// Age out stale uniques rows — same discipline at every bounce, N4 included. + pub fn prune_reach(&self, max_age_ms: u64) -> anyhow::Result { let cutoff = now_ms() - max_age_ms as i64; - let d1 = self.conn.execute( - "DELETE FROM reachable_n2 WHERE updated_at < ?1", + Ok(self.conn.execute( + "DELETE FROM reachable WHERE updated_at < ?1", params![cutoff], - )?; - let d2 = self.conn.execute( - "DELETE FROM reachable_n3 WHERE updated_at < ?1", - params![cutoff], - )?; - Ok(d1 + d2) + )?) } - /// Score all N2 candidates for growth loop diversity selection. - /// Returns (node_id, reporter_count, in_n3) for each unique N2 candidate. - /// Lower reporter_count = more unique neighborhood = higher diversity value. - pub fn score_n2_candidates_batch(&self) -> anyhow::Result> { + /// How many candidates the SQL shortlist returns. Large enough that the + /// Rust-side exclusions (self, already-connected, likely-unreachable) can + /// never empty it in practice, small enough that the growth loop — which + /// uses exactly ONE candidate per pass and is woken on every announce that + /// stores rows — is not a full scan + in-memory sort of six figures of + /// rows. `idx_reachable_id(reachable_id, bounce)` supports the grouping. + pub const GROWTH_SHORTLIST: usize = 64; + + /// Score growth candidates for the growth loop's diversity selection. + /// + /// Returns (node_id, reporter_count, shallowest_bounce) over the whole + /// stored horizon, already ORDERED and LIMITED in SQL: shallowest first + /// (cheapest to resolve and to reach), then fewest reporters (most unique + /// neighborhood = highest diversity value). + /// + /// `shallowest_bounce` is returned GRADED rather than as a boolean: a + /// bounce-4 ID seen by one reporter must not outrank a genuine bounce-2 ID + /// seen by three, which is exactly what `1.0/reporter_count + 0.3` did when + /// the horizon widened past N2. + /// + /// Author-class IDs are EXCLUDED: a persona ID has no address and can never + /// answer a connect, so scoring it would burn hole-punch timeouts and mark + /// phantom peers unreachable. + pub fn score_n2_candidates_batch(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT n2.reachable_node_id, - COUNT(DISTINCT n2.reporter_node_id) as reporter_count, - CASE WHEN n3.reachable_node_id IS NOT NULL THEN 1 ELSE 0 END as in_n3 - FROM reachable_n2 n2 - LEFT JOIN (SELECT DISTINCT reachable_node_id FROM reachable_n3) n3 - ON n2.reachable_node_id = n3.reachable_node_id - GROUP BY n2.reachable_node_id", + "SELECT reachable_id, + COUNT(DISTINCT reporter_node_id) AS reporter_count, + MIN(bounce) AS shallowest + FROM reachable + WHERE id_class = 0 + GROUP BY reachable_id + ORDER BY shallowest ASC, reporter_count ASC + LIMIT ?1", )?; let mut results = Vec::new(); - let mut rows = stmt.query([])?; + let mut rows = stmt.query(params![Self::GROWTH_SHORTLIST as i64])?; while let Some(row) = rows.next()? { let nid = blob_to_nodeid(row.get(0)?)?; let reporter_count: usize = row.get::<_, i64>(1)? as usize; - let in_n3: bool = row.get::<_, i64>(2)? != 0; - results.push((nid, reporter_count, in_n3)); + let shallowest: i64 = row.get(2)?; + results.push((nid, reporter_count, shallowest.clamp(0, 255) as u8)); } Ok(results) } @@ -3705,24 +4150,20 @@ impl Storage { // ---- Mesh Peers ---- - /// Add a mesh peer connection record. - pub fn add_mesh_peer( - &self, - node_id: &NodeId, - slot_kind: PeerSlotKind, - priority: i32, - ) -> anyhow::Result<()> { + /// Record a MESH-slot peer. + /// + /// Temporary referral slots must NEVER be written here. `build_own_uniques` + /// reads this table to construct our announce, so simply not writing the row + /// is what keeps temp peers out of the uniques exchange — at the store + /// layer, with no filtering anyone can forget. (`get_lateral_blob_sources` + /// also treats membership here as "reachable", a second reason.) + pub fn add_mesh_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { let now = now_ms(); self.conn.execute( - "INSERT INTO mesh_peers (node_id, slot_kind, priority, connected_at, last_diff_seq) - VALUES (?1, ?2, ?3, ?4, 0) - ON CONFLICT(node_id) DO UPDATE SET slot_kind = ?2, priority = ?3, connected_at = ?4", - params![ - node_id.as_slice(), - slot_kind.to_string(), - priority, - now, - ], + "INSERT INTO mesh_peers (node_id, connected_at, last_diff_seq) + VALUES (?1, ?2, 0) + ON CONFLICT(node_id) DO UPDATE SET connected_at = ?2", + params![node_id.as_slice(), now], )?; Ok(()) } @@ -3736,66 +4177,10 @@ impl Storage { Ok(()) } - /// List all mesh peers: (node_id, slot_kind_str, priority). - pub fn list_mesh_peers(&self) -> anyhow::Result> { + /// List all mesh-slot peers, most recently connected first. + pub fn list_mesh_peers(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, slot_kind, priority FROM mesh_peers ORDER BY connected_at DESC", - )?; - let mut result = Vec::new(); - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - let node_id = blob_to_nodeid(row.get(0)?)?; - let slot_kind: String = row.get(1)?; - let priority: i32 = row.get(2)?; - result.push((node_id, slot_kind, priority)); - } - Ok(result) - } - - /// Count mesh peers of a given slot kind. - pub fn count_mesh_peers_by_kind(&self, slot_kind: PeerSlotKind) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM mesh_peers WHERE slot_kind = ?1", - params![slot_kind.to_string()], - |row| row.get(0), - )?; - Ok(count as usize) - } - - /// Update last_diff_seq for a mesh peer. - pub fn update_mesh_peer_seq(&self, node_id: &NodeId, seq: u64) -> anyhow::Result<()> { - self.conn.execute( - "UPDATE mesh_peers SET last_diff_seq = ?1 WHERE node_id = ?2", - params![seq as i64, node_id.as_slice()], - )?; - Ok(()) - } - - // ---- Preferred Peers ---- - - /// Add a bilateral preferred peer agreement. - pub fn add_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { - let now = now_ms(); - self.conn.execute( - "INSERT OR REPLACE INTO preferred_peers (node_id, agreed_at) VALUES (?1, ?2)", - params![node_id.as_slice(), now], - )?; - Ok(()) - } - - /// Remove a preferred peer agreement. - pub fn remove_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { - self.conn.execute( - "DELETE FROM preferred_peers WHERE node_id = ?1", - params![node_id.as_slice()], - )?; - Ok(()) - } - - /// List all preferred peers. - pub fn list_preferred_peers(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT node_id FROM preferred_peers ORDER BY agreed_at DESC", + "SELECT node_id FROM mesh_peers ORDER BY connected_at DESC", )?; let mut result = Vec::new(); let mut rows = stmt.query([])?; @@ -3805,67 +4190,11 @@ impl Storage { Ok(result) } - /// Check if a peer is a preferred peer. - pub fn is_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM preferred_peers WHERE node_id = ?1", - params![node_id.as_slice()], - |row| row.get(0), - )?; - Ok(count > 0) - } - - /// Count preferred peers. - pub fn count_preferred_peers(&self) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM preferred_peers", - [], - |row| row.get(0), - )?; - Ok(count as usize) - } - - // ---- Preferred Tree ---- - - /// Build 2-layer preferred peer tree from stored profiles. - /// Layer 0: target. Layer 1: target's preferred_peers. Layer 2: each L1 peer's preferred_peers. - /// Returns ~100 unique NodeIds. - pub fn build_preferred_tree_for(&self, target: &NodeId) -> anyhow::Result> { - let mut tree = std::collections::HashSet::new(); - - // Layer 0: target itself - tree.insert(*target); - - // Layer 1: target's preferred peers from their profile - let l1_peers = match self.get_profile(target)? { - Some(profile) => profile.preferred_peers, - None => return Ok(tree.into_iter().collect()), - }; - - for pp in &l1_peers { - tree.insert(*pp); - } - - // Layer 2: each L1 peer's preferred peers - for pp in &l1_peers { - if let Some(profile) = self.get_profile(pp)? { - for pp2 in &profile.preferred_peers { - tree.insert(*pp2); - } - } - } - - Ok(tree.into_iter().collect()) - } - - /// Update the preferred_tree JSON for a social route. - pub fn update_social_route_preferred_tree(&self, node_id: &NodeId, tree: &[NodeId]) -> anyhow::Result<()> { - let json = serde_json::to_string( - &tree.iter().map(hex::encode).collect::>() - )?; + /// Update last_diff_seq for a mesh peer. + pub fn update_mesh_peer_seq(&self, node_id: &NodeId, seq: u64) -> anyhow::Result<()> { self.conn.execute( - "UPDATE social_routes SET preferred_tree = ?1 WHERE node_id = ?2", - params![json, node_id.as_slice()], + "UPDATE mesh_peers SET last_diff_seq = ?1 WHERE node_id = ?2", + params![seq as i64, node_id.as_slice()], )?; Ok(()) } @@ -3878,17 +4207,14 @@ impl Storage { &entry.addresses.iter().map(|a| a.to_string()).collect::>() )?; let peer_addrs_json = serde_json::to_string(&entry.peer_addresses)?; - let pref_tree_json = serde_json::to_string( - &entry.preferred_tree.iter().map(hex::encode).collect::>() - )?; self.conn.execute( - "INSERT INTO social_routes (node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "INSERT INTO social_routes (node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(node_id) DO UPDATE SET addresses = ?2, peer_addresses = ?3, relation = ?4, status = ?5, last_connected_ms = MAX(social_routes.last_connected_ms, ?6), last_seen_ms = MAX(social_routes.last_seen_ms, ?7), - reach_method = ?8, preferred_tree = ?9", + reach_method = ?8", params![ entry.node_id.as_slice(), addrs_json, @@ -3898,7 +4224,6 @@ impl Storage { entry.last_connected_ms as i64, entry.last_seen_ms as i64, entry.reach_method.to_string(), - pref_tree_json, ], )?; Ok(()) @@ -3907,7 +4232,7 @@ impl Storage { /// Get a single social route entry. pub fn get_social_route(&self, node_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method FROM social_routes WHERE node_id = ?1", )?; let mut rows = stmt.query(params![node_id.as_slice()])?; @@ -3988,7 +4313,7 @@ impl Storage { /// List all social routes, sorted by last_seen DESC. pub fn list_social_routes(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method FROM social_routes ORDER BY last_seen_ms DESC", )?; let mut entries = Vec::new(); @@ -4003,7 +4328,7 @@ impl Storage { pub fn list_stale_social_routes(&self, max_age_ms: u64) -> anyhow::Result> { let cutoff = now_ms() - max_age_ms as i64; let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method FROM social_routes WHERE last_seen_ms < ?1 ORDER BY last_seen_ms ASC", )?; let mut entries = Vec::new(); @@ -4046,9 +4371,6 @@ impl Storage { // Build peer_addresses from the contact's profile recent_peers let peer_addresses = self.build_peer_addresses_for(&nid)?; - // Build preferred peer tree - let preferred_tree = self.build_preferred_tree_for(&nid).unwrap_or_default(); - // Only insert if not already present (don't overwrite runtime state) if !self.has_social_route(&nid)? { self.upsert_social_route(&SocialRouteEntry { @@ -4060,12 +4382,8 @@ impl Storage { last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, - preferred_tree, })?; count += 1; - } else { - // Update the preferred tree for existing routes - self.update_social_route_preferred_tree(&nid, &preferred_tree)?; } } @@ -4073,14 +4391,31 @@ impl Storage { } /// Build peer_addresses for a contact from their profile's recent_peers. + /// + /// PUBLICLY ROUTABLE ONLY. design.html §21 sanctions this payload class on + /// the reasoning that these are "network endpoints an observer at that + /// vantage could already see us talking to" — that holds for public + /// addresses and not for a peer's LAN address (192.168.x, 10.x picked up + /// from an earlier exchange), which discloses local topology to someone who + /// could not otherwise observe it. A peer left with no routable address is + /// dropped rather than sent as a bare, useless entry. pub fn build_peer_addresses_for(&self, node_id: &NodeId) -> anyhow::Result> { let recent_peers = self.get_recent_peers(node_id)?; let mut result = Vec::new(); for rp in recent_peers.iter().take(10) { let addrs: Vec = self .get_peer_record(rp)? - .map(|r| r.addresses.iter().map(|a| a.to_string()).collect()) + .map(|r| { + r.addresses + .iter() + .filter(|a| crate::connection::convection_addr_ok(a)) + .map(|a| a.to_string()) + .collect() + }) .unwrap_or_default(); + if addrs.is_empty() { + continue; + } result.push(PeerWithAddress { n: hex::encode(rp), a: addrs, @@ -4468,6 +4803,36 @@ impl Storage { Ok(result) } + /// List ALL stored CDN manifests: (cid, manifest_json). Used by the v0.8 + /// startup migration to re-sign own manifests / purge stale foreign ones. + pub fn list_all_cdn_manifests(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT cid, manifest_json FROM cdn_manifests" + )?; + let rows = stmt.query_map([], |row| { + let cid_bytes: Vec = row.get(0)?; + let json: String = row.get(1)?; + Ok((cid_bytes, json)) + })?; + let mut result = Vec::new(); + for row in rows { + let (cid_bytes, json) = row?; + let cid: [u8; 32] = cid_bytes.try_into() + .map_err(|_| anyhow::anyhow!("invalid cid in cdn_manifests"))?; + result.push((cid, json)); + } + Ok(result) + } + + /// Delete a single CDN manifest row by CID (file_holders untouched). + pub fn delete_cdn_manifest(&self, cid: &[u8; 32]) -> anyhow::Result<()> { + self.conn.execute( + "DELETE FROM cdn_manifests WHERE cid = ?1", + params![cid.as_slice()], + )?; + Ok(()) + } + /// Get CIDs of manifests older than a cutoff. Callers look up holders /// via file_holders to pick a refresh source. pub fn get_stale_manifest_cids(&self, older_than_ms: u64) -> anyhow::Result> { @@ -4659,36 +5024,6 @@ impl Storage { Ok(out) } - /// Seed the post_recipients index from existing encrypted posts. - /// One-time idempotent migration for users upgrading from pre-0.6.2. - pub fn seed_post_recipients_from_posts(&self) -> anyhow::Result<()> { - let existing: i64 = self.conn.prepare("SELECT COUNT(*) FROM post_recipients")? - .query_row([], |row| row.get(0))?; - if existing > 0 { - return Ok(()); - } - // Scan all posts, parse visibility, index recipients. - let mut stmt = self.conn.prepare("SELECT id, visibility FROM posts")?; - let rows = stmt.query_map([], |row| { - let id_bytes: Vec = row.get(0)?; - let vis_json: String = row.get(1)?; - Ok((id_bytes, vis_json)) - })?; - let entries: Vec<([u8; 32], PostVisibility)> = rows - .filter_map(|r| r.ok()) - .filter_map(|(id_bytes, vis_json)| { - let pid = <[u8; 32]>::try_from(id_bytes.as_slice()).ok()?; - let vis: PostVisibility = serde_json::from_str(&vis_json).ok()?; - Some((pid, vis)) - }) - .collect(); - drop(stmt); - for (pid, vis) in entries { - self.index_post_recipients(&pid, &vis)?; - } - Ok(()) - } - // --- Posting identities (multi-persona plumbing) --- pub fn upsert_posting_identity(&self, id: &PostingIdentity) -> anyhow::Result<()> { @@ -5719,55 +6054,6 @@ impl Storage { Ok(()) } - /// One-time migration: seed file_holders from the legacy upstream/downstream - /// tables so a user upgrading from pre-0.6.1 doesn't start with empty holder - /// sets. Idempotent — inserts use ON CONFLICT DO NOTHING semantics via the - /// PRIMARY KEY. Skips tables that don't exist on fresh installs. - pub fn seed_file_holders_from_legacy(&self) -> anyhow::Result<()> { - // Skip if file_holders already populated (idempotent re-run protection). - let existing: i64 = self.conn.prepare("SELECT COUNT(*) FROM file_holders")? - .query_row([], |row| row.get(0))?; - if existing > 0 { - return Ok(()); - } - let now = now_ms() as i64; - let table_exists = |name: &str| -> anyhow::Result { - let count: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - )?.query_row(params![name], |row| row.get(0))?; - Ok(count > 0) - }; - if table_exists("post_upstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT post_id, peer_node_id, '[]', ?1, 'received' FROM post_upstream", - params![now], - )?; - } - if table_exists("post_downstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT post_id, peer_node_id, '[]', ?1, 'sent' FROM post_downstream", - params![now], - )?; - } - if table_exists("blob_upstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT cid, source_node_id, source_addresses, ?1, 'received' FROM blob_upstream", - params![now], - )?; - } - if table_exists("blob_downstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT cid, peer_node_id, peer_addresses, ?1, 'sent' FROM blob_downstream", - params![now], - )?; - } - Ok(()) - } - // --- Engagement: reactions --- /// Store a reaction (upsert by reactor+post_id+emoji). @@ -5868,13 +6154,27 @@ impl Storage { } /// Get reaction counts grouped by emoji for a post (excludes tombstoned reactions). - pub fn get_reaction_counts(&self, post_id: &PostId, my_node_id: &NodeId) -> anyhow::Result> { - let mut stmt = self.conn.prepare( + /// `my_ids` = ALL of the local node's posting identities (reactors are + /// posting ids — the network NodeId never reacts). + pub fn get_reaction_counts(&self, post_id: &PostId, my_ids: &[NodeId]) -> anyhow::Result> { + let mine_placeholders: String = if my_ids.is_empty() { + "NULL".to_string() + } else { + (0..my_ids.len()).map(|i| format!("?{}", i + 2)).collect::>().join(",") + }; + let sql = format!( "SELECT emoji, COUNT(*) as cnt, - SUM(CASE WHEN reactor = ?2 THEN 1 ELSE 0 END) as my_count - FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC" - )?; - let rows = stmt.query_map(params![post_id.as_slice(), my_node_id.as_slice()], |row| { + SUM(CASE WHEN reactor IN ({}) THEN 1 ELSE 0 END) as my_count + FROM reactions WHERE post_id = ?1 AND deleted_at IS NULL GROUP BY emoji ORDER BY cnt DESC", + mine_placeholders + ); + let mut stmt = self.conn.prepare(&sql)?; + let mut params: Vec> = vec![Box::new(post_id.to_vec())]; + for id in my_ids { + params.push(Box::new(id.to_vec())); + } + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let rows = stmt.query_map(param_refs.as_slice(), |row| { let emoji: String = row.get(0)?; let count: i64 = row.get(1)?; let my_count: i64 = row.get(2)?; @@ -5895,15 +6195,18 @@ impl Storage { self.conn.execute( "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, - ref_post_id, pub_x_index, group_sig, encrypted_payload) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ref_post_id, pub_x_index, group_sig, encrypted_payload, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, + signature = CASE WHEN excluded.deleted_at IS NOT NULL THEN signature ELSE excluded.signature END, deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id), pub_x_index = COALESCE(excluded.pub_x_index, pub_x_index), group_sig = COALESCE(excluded.group_sig, group_sig), - encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload)", + encrypted_payload = COALESCE(excluded.encrypted_payload, encrypted_payload), + expires_at = CASE WHEN excluded.expires_at IS NOT NULL AND excluded.expires_at > 0 + THEN excluded.expires_at ELSE expires_at END", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -5915,6 +6218,26 @@ impl Storage { comment.pub_x_index.map(|i| i as i64), comment.group_sig.as_ref().map(|b| b.as_slice()), comment.encrypted_payload.as_ref().map(|b| b.as_slice()), + if comment.expires_at_ms > 0 { Some(comment.expires_at_ms as i64) } else { None }, + ], + )?; + Ok(()) + } + + /// Store a comment WE authored. Identical to `store_comment` except it + /// marks the row `origin_local = 1`, which keeps its author ID (our + /// persona, or a per-greeting throwaway) out of `build_own_uniques` — see + /// design.html §21: we must never be the node that announces our own + /// posting identities at bounce 1 beside our node ID and address. + pub fn store_own_comment(&self, comment: &InlineComment) -> anyhow::Result<()> { + self.store_comment(comment)?; + self.conn.execute( + "UPDATE comments SET origin_local = 1 + WHERE author = ?1 AND post_id = ?2 AND timestamp_ms = ?3", + params![ + comment.author.as_slice(), + comment.post_id.as_slice(), + comment.timestamp_ms as i64, ], )?; Ok(()) @@ -5938,14 +6261,18 @@ impl Storage { Ok(updated > 0) } - /// Get live (non-tombstoned) comments for a post. Used for UI display. + /// Get live (non-tombstoned, unexpired) comments for a post. Used for + /// UI display. The expiry filter is belt-and-suspenders between + /// `expire_comments` sweeps. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id, - pub_x_index, group_sig, encrypted_payload - FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" + pub_x_index, group_sig, encrypted_payload, expires_at + FROM comments WHERE post_id = ?1 AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2) + ORDER BY timestamp_ms ASC" )?; - let rows = stmt.query_map(params![post_id.as_slice()], |row| { + let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| { let author: Vec = row.get(0)?; let pid: Vec = row.get(1)?; let content: String = row.get(2)?; @@ -5955,11 +6282,12 @@ impl Storage { let pxi: Option = row.get(6)?; let gsig: Option> = row.get(7)?; let epl: Option> = row.get(8)?; - Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl)) + let exp: Option = row.get(9)?; + Ok((author, pid, content, ts, sig, ref_post, pxi, gsig, epl, exp)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post, pxi, gsig, epl, exp) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -5977,19 +6305,30 @@ impl Storage { pub_x_index: pxi.map(|v| v as u32), group_sig: gsig, encrypted_payload: epl, + expires_at_ms: exp.unwrap_or(0) as u64, }); } Ok(result) } - /// Get ALL comments for a post, including tombstoned ones. Used for header rebuild - /// so tombstones propagate through pull-based sync. + /// Get ALL comments for a post, including tombstoned ones (but never + /// expired ones — expiry is the forgetting mechanism and expired + /// comments must not be re-served). Used for header rebuild so + /// tombstones propagate through pull-based sync. + /// + /// v0.8 (A3): also carries the FoF fields (pub_x_index / group_sig / + /// encrypted_payload) + expires_at — without them, FoF/greeting + /// comments served via full-header sync would lose their group_sig + /// and be dropped by the receive gate. pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id - FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC" + "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id, + pub_x_index, group_sig, encrypted_payload, expires_at + FROM comments WHERE post_id = ?1 + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2) + ORDER BY timestamp_ms ASC" )?; - let rows = stmt.query_map(params![post_id.as_slice()], |row| { + let rows = stmt.query_map(params![post_id.as_slice(), now_ms()], |row| { let author: Vec = row.get(0)?; let pid: Vec = row.get(1)?; let content: String = row.get(2)?; @@ -5997,11 +6336,15 @@ impl Storage { let sig: Vec = row.get(4)?; let del: Option = row.get(5)?; let ref_post: Option> = row.get(6)?; - Ok((author, pid, content, ts, sig, del, ref_post)) + let pxi: Option = row.get(7)?; + let gsig: Option> = row.get(8)?; + let epl: Option> = row.get(9)?; + let exp: Option = row.get(10)?; + Ok((author, pid, content, ts, sig, del, ref_post, pxi, gsig, epl, exp)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?; + let (author_bytes, pid_bytes, content, ts, sig, del, ref_post, pxi, gsig, epl, exp) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; let ref_post_id = match ref_post { @@ -6016,22 +6359,319 @@ impl Storage { signature: sig, deleted_at: del.map(|v| v as u64), ref_post_id, - pub_x_index: None, - group_sig: None, - encrypted_payload: None, + pub_x_index: pxi.map(|v| v as u32), + group_sig: gsig, + encrypted_payload: epl, + expires_at_ms: exp.unwrap_or(0) as u64, }); } Ok(result) } - /// Get comment count for a post (excludes tombstoned comments). + /// Get comment count for a post (excludes tombstoned + expired comments). pub fn get_comment_count(&self, post_id: &PostId) -> anyhow::Result { let count: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL" - )?.query_row(params![post_id.as_slice()], |row| row.get(0))?; + "SELECT COUNT(*) FROM comments WHERE post_id = ?1 AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?2)" + )?.query_row(params![post_id.as_slice(), now_ms()], |row| row.get(0))?; Ok(count as u64) } + /// v0.8 (A3): rebuild the post's aggregated blob header from vetted + /// DB state (reactions + comments + policy), preserving slot fields + /// from any previously-stored header JSON. Used after local comment + /// creation (so pulls serve our own engagement) and after pull-path + /// ingestion (so we never re-serve peer-supplied comments our accept + /// gate rejected). + pub fn rebuild_blob_header_from_db( + &self, + post_id: &PostId, + fallback_author: &NodeId, + now: u64, + ) -> anyhow::Result<()> { + let reactions = self.get_reactions_with_tombstones(post_id).unwrap_or_default(); + let comments = self.get_comments_with_tombstones(post_id).unwrap_or_default(); + let policy = self.get_comment_policy(post_id).ok().flatten().unwrap_or_default(); + let (existing_json, _) = self.get_blob_header(post_id).ok().flatten() + .unwrap_or((String::new(), 0)); + let mut header: crate::types::BlobHeader = serde_json::from_str(&existing_json) + .unwrap_or_else(|_| crate::types::BlobHeader { + post_id: *post_id, + author: *fallback_author, + reactions: vec![], + comments: vec![], + policy: CommentPolicy::default(), + updated_at: 0, + thread_splits: vec![], + receipt_slots: vec![], + comment_slots: vec![], + prior_author: None, + }); + header.post_id = *post_id; + header.reactions = reactions; + header.comments = comments; + header.policy = policy; + header.updated_at = now; + // Trust the locally-stored post for authorship when available. + let author = self.get_post(post_id).ok().flatten() + .map(|p| p.author) + .unwrap_or(*fallback_author); + header.author = author; + let json = serde_json::to_string(&header)?; + self.store_blob_header(post_id, &author, &json, now) + } + + // --- v0.8 (A3): comment TTL sweep + open-slot helpers --- + + /// Hard-DELETE (not tombstone) all comments whose expiry has passed. + /// Expiry is the forgetting mechanism — tombstones would keep + /// throwaway IDs alive. Returns the number of rows deleted. + pub fn expire_comments(&self, now_ms_val: u64) -> anyhow::Result { + let deleted = self.conn.execute( + "DELETE FROM comments + WHERE expires_at IS NOT NULL AND expires_at > 0 AND expires_at <= ?1", + params![now_ms_val as i64], + )?; + Ok(deleted) + } + + /// Newest-wins registry rule: one active entry per persona (`author`) + /// on the registry post. Returns `true` if the incoming row (at + /// `incoming_ts`) is the newest for this author — in which case any + /// strictly-older rows are HARD-deleted — and `false` when a newer + /// entry is already stored (caller drops the incoming one). + pub fn upsert_registry_entry_newest_wins( + &self, + post_id: &PostId, + author: &NodeId, + incoming_ts: u64, + ) -> anyhow::Result { + if !self.registry_entry_is_newest(post_id, author, incoming_ts)? { + return Ok(false); + } + self.prune_older_registry_entries(post_id, author, incoming_ts)?; + Ok(true) + } + + /// READ-ONLY newest-wins check: is `incoming_ts` at least as new as + /// every stored entry for this persona? Used by the accept gate, + /// which must stay side-effect free — the destructive prune happens + /// in the storing callers AFTER a successful store_comment. + pub fn registry_entry_is_newest( + &self, + post_id: &PostId, + author: &NodeId, + incoming_ts: u64, + ) -> anyhow::Result { + let newest: Option = self.conn.query_row( + "SELECT MAX(timestamp_ms) FROM comments WHERE post_id = ?1 AND author = ?2", + params![post_id.as_slice(), author.as_slice()], + |row| row.get(0), + )?; + if let Some(newest) = newest { + if (newest as u64) > incoming_ts { + return Ok(false); + } + } + Ok(true) + } + + /// Hard-delete this persona's registry entries older than `newest_ts`. + /// Call only after the newest entry has been successfully stored, so + /// a store failure never leaves the persona entry-less. + pub fn prune_older_registry_entries( + &self, + post_id: &PostId, + author: &NodeId, + newest_ts: u64, + ) -> anyhow::Result { + let n = self.conn.execute( + "DELETE FROM comments WHERE post_id = ?1 AND author = ?2 AND timestamp_ms < ?3", + params![post_id.as_slice(), author.as_slice(), newest_ts as i64], + )?; + Ok(n) + } + + /// Count live (unexpired, non-tombstoned) comments on a post's open + /// slot — the per-bio greeting flood cap input. + pub fn count_unexpired_open_slot_comments( + &self, + post_id: &PostId, + pub_x_index: u32, + ) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM comments + WHERE post_id = ?1 AND pub_x_index = ?2 AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at = 0 OR expires_at > ?3)", + params![post_id.as_slice(), pub_x_index as i64, now_ms()], + |row| row.get(0), + )?; + Ok(count as u64) + } + + /// Local-only: mark a greeting as dismissed in the inbox. + pub fn add_greeting_dismissal( + &self, + comment_author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO greeting_dismissals + (comment_author, post_id, timestamp_ms, dismissed_at) + VALUES (?1, ?2, ?3, ?4)", + params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64, now_ms()], + )?; + Ok(()) + } + + /// Local-only: was this greeting dismissed? + pub fn is_greeting_dismissed( + &self, + comment_author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + ) -> anyhow::Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM greeting_dismissals + WHERE comment_author = ?1 AND post_id = ?2 AND timestamp_ms = ?3", + params![comment_author.as_slice(), post_id.as_slice(), timestamp_ms as i64], + |row| row.get(0), + )?; + Ok(count > 0) + } + + /// Persist the private half of a per-greeting reply x25519 keypair. + pub fn store_greeting_reply_key( + &self, + reply_pubkey: &[u8; 32], + reply_privkey: &[u8; 32], + return_post_id: &PostId, + ) -> anyhow::Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO greeting_reply_keys + (reply_pubkey, reply_privkey, return_post_id, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + reply_pubkey.as_slice(), + reply_privkey.as_slice(), + return_post_id.as_slice(), + now_ms(), + ], + )?; + Ok(()) + } + + /// List all stored reply private keys (privkey, return_post_id). + /// Used to unseal replies arriving on our return-path posts. + pub fn list_greeting_reply_keys(&self) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT reply_privkey, return_post_id FROM greeting_reply_keys ORDER BY created_at DESC" + )?; + let rows = stmt.query_map([], |row| { + let privkey: Vec = row.get(0)?; + let post: Vec = row.get(1)?; + Ok((privkey, post)) + })?; + let mut result = Vec::new(); + for row in rows { + let (privkey, post) = row?; + let pk: [u8; 32] = privkey.as_slice().try_into() + .map_err(|_| anyhow::anyhow!("invalid reply privkey length"))?; + result.push((pk, blob_to_postid(post)?)); + } + Ok(result) + } + + /// All FoF-gated posts authored by `author` (fof_gating_json set), + /// reconstructed with their gating. Used by the greeting inbox to + /// scan own bio posts (current + previous revisions) for open-slot + /// comments. + pub fn list_gated_posts_by_author( + &self, + author: &NodeId, + ) -> anyhow::Result> { + let mut stmt = self.conn.prepare( + "SELECT id, author, content, attachments, timestamp_ms, fof_gating_json + FROM posts WHERE author = ?1 AND fof_gating_json IS NOT NULL + ORDER BY timestamp_ms DESC", + )?; + let rows = stmt.query_map(params![author.as_slice()], |row| { + let id: Vec = row.get(0)?; + let author: Vec = row.get(1)?; + let content: String = row.get(2)?; + let attachments: String = row.get(3)?; + let ts: i64 = row.get(4)?; + let fof_json: Option = row.get(5)?; + Ok((id, author, content, attachments, ts, fof_json)) + })?; + let mut result = Vec::new(); + for row in rows { + let (id, author_bytes, content, attachments_json, ts, fof_json) = row?; + let attachments: Vec = + serde_json::from_str(&attachments_json).unwrap_or_default(); + let fof_gating = fof_json + .and_then(|s| serde_json::from_str::(&s).ok()); + result.push(( + blob_to_postid(id)?, + Post { + author: blob_to_nodeid(author_bytes)?, + content, + attachments, + timestamp_ms: ts as u64, + fof_gating, + supersedes_post_id: None, + }, + )); + } + Ok(result) + } + + /// Newest live registry entry for `author`: + /// `(timestamp_ms, expires_at_ms)`. Used by unregister + auto-renew. + pub fn get_newest_registry_entry( + &self, + registry_post_id: &PostId, + author: &NodeId, + ) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT timestamp_ms, expires_at FROM comments + WHERE post_id = ?1 AND author = ?2 AND deleted_at IS NULL + ORDER BY timestamp_ms DESC LIMIT 1", + params![registry_post_id.as_slice(), author.as_slice()], + |row| { + let ts: i64 = row.get(0)?; + let exp: Option = row.get(1)?; + Ok((ts as u64, exp.unwrap_or(0) as u64)) + }, + ); + match result { + Ok(pair) => Ok(Some(pair)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Latest `VisibilityIntent::Profile` post authored by `author` — + /// the persona's current bio post (the v1 greeting return path). + pub fn get_latest_profile_post_id_by_author( + &self, + author: &NodeId, + ) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT id FROM posts + WHERE author = ?1 AND visibility_intent = '\"Profile\"' + ORDER BY timestamp_ms DESC LIMIT 1", + params![author.as_slice()], + |row| row.get::<_, Vec>(0), + ); + match result { + Ok(bytes) => Ok(Some(blob_to_postid(bytes)?)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + // --- Engagement: comment policies --- /// Store or update a comment policy for a post. @@ -6203,8 +6843,6 @@ fn row_to_social_route(row: &rusqlite::Row) -> anyhow::Result let last_seen_ms = row.get::<_, i64>(6)? as u64; let method_str: String = row.get(7)?; let reach_method: ReachMethod = method_str.parse().unwrap_or(ReachMethod::Direct); - let pref_tree_json: String = row.get::<_, String>(8).unwrap_or_else(|_| "[]".to_string()); - let preferred_tree = parse_anchors_json(&pref_tree_json); Ok(SocialRouteEntry { node_id, @@ -6215,7 +6853,6 @@ fn row_to_social_route(row: &rusqlite::Row) -> anyhow::Result last_connected_ms, last_seen_ms, reach_method, - preferred_tree, }) } @@ -6384,8 +7021,12 @@ mod tests { assert!(s.is_relay_cooldown(&target, 1).unwrap()); } + fn nodes(ids: &[NodeId]) -> Vec { + ids.iter().map(|id| ReachEntry::node(*id)).collect() + } + #[test] - fn n2_n3_crud() { + fn reach_crud() { let s = temp_storage(); let reporter_a = make_node_id(1); let reporter_b = make_node_id(2); @@ -6393,69 +7034,285 @@ mod tests { let node_y = make_node_id(11); let node_z = make_node_id(12); - // Set reporter_a's N1 (their connections) → our N2 - s.set_peer_n1(&reporter_a, &[node_x, node_y]).unwrap(); - let found = s.find_in_n2(&node_x).unwrap(); - assert_eq!(found, vec![reporter_a]); + s.set_reach(&reporter_a, 2, &nodes(&[node_x, node_y])).unwrap(); + assert_eq!(s.find_in_n2(&node_x).unwrap(), vec![reporter_a]); - // Set reporter_b's N1 → our N2 - s.set_peer_n1(&reporter_b, &[node_y, node_z]).unwrap(); - let found = s.find_in_n2(&node_y).unwrap(); - assert_eq!(found.len(), 2); // Both reporters have node_y + s.set_reach(&reporter_b, 2, &nodes(&[node_y, node_z])).unwrap(); + assert_eq!(s.find_in_n2(&node_y).unwrap().len(), 2); - // Build N2 share (deduplicated) - let n2_share = s.build_n2_share().unwrap(); - assert_eq!(n2_share.len(), 3); // node_x, node_y, node_z + assert_eq!(s.list_reach_ids_at(2).unwrap().len(), 3); - // Clear reporter_a's N2 contributions - let cleared = s.clear_peer_n2(&reporter_a).unwrap(); - assert_eq!(cleared, 2); - let found = s.find_in_n2(&node_x).unwrap(); - assert!(found.is_empty()); + // Disconnect cleanup wipes every bounce for that reporter. + s.set_reach(&reporter_a, 4, &nodes(&[node_z])).unwrap(); + let cleared = s.clear_peer_reach(&reporter_a).unwrap(); + assert_eq!(cleared, 3); + assert!(s.find_in_n2(&node_x).unwrap().is_empty()); + assert!(s.find_in_n4(&node_z).unwrap().is_empty()); - // N3 operations - s.set_peer_n2(&reporter_a, &[node_z]).unwrap(); - let found = s.find_in_n3(&node_z).unwrap(); - assert_eq!(found, vec![reporter_a]); - - s.clear_peer_n3(&reporter_a).unwrap(); - let found = s.find_in_n3(&node_z).unwrap(); - assert!(found.is_empty()); + // N3 + N4 land at their own bounces, reporter-tagged. + s.set_reach(&reporter_a, 3, &nodes(&[node_z])).unwrap(); + assert_eq!(s.find_in_n3(&node_z).unwrap(), vec![reporter_a]); + s.set_reach(&reporter_b, 4, &nodes(&[node_x])).unwrap(); + assert_eq!(s.find_in_n4(&node_x).unwrap(), vec![reporter_b]); + assert_eq!(s.count_distinct_n4().unwrap(), 1); } + /// Store-side dedup: one reporter cannot place one ID at two depths — the + /// shallowest sighting wins. #[test] - fn n1_share_build() { + fn reach_store_dedup_keeps_shallowest_bounce() { let s = temp_storage(); - let peer_a = make_node_id(1); - let follow_b = make_node_id(2); - let addr: std::net::SocketAddr = "10.0.0.1:4433".parse().unwrap(); + let reporter = make_node_id(1); + let target = make_node_id(10); - // Add a mesh peer - s.add_mesh_peer(&peer_a, PeerSlotKind::Local, 0).unwrap(); - // Add a follow with social route - s.add_follow(&follow_b).unwrap(); + s.set_reach(&reporter, 4, &nodes(&[target])).unwrap(); + assert_eq!(s.find_in_n4(&target).unwrap(), vec![reporter]); + + // Same reporter now reports it shallower. + s.add_reach(&reporter, 2, &nodes(&[target])).unwrap(); + assert_eq!(s.find_in_n2(&target).unwrap(), vec![reporter]); + assert!(s.find_in_n4(&target).unwrap().is_empty(), "must not occupy two depths"); + + // A deeper re-report does not push it back down. + s.add_reach(&reporter, 3, &nodes(&[target])).unwrap(); + assert_eq!(s.find_in_n2(&target).unwrap(), vec![reporter]); + } + + /// §layers privacy invariant: addresses exist ONLY on anchor entries. + #[test] + fn reach_addresses_only_on_anchors() { + let s = temp_storage(); + let reporter = make_node_id(1); + let plain = make_node_id(10); + let anchor = make_node_id(11); + + // A non-anchor entry that arrives carrying addresses must be blanked. + let sneaky = ReachEntry { + id: plain, + id_class: IdClass::Node, + is_anchor: false, + addresses: vec!["1.2.3.4:4433".to_string()], + }; + s.set_reach( + &reporter, + 2, + &[sneaky, ReachEntry::anchor(anchor, vec!["5.6.7.8:4433".to_string()])], + ) + .unwrap(); + + let leaked: i64 = s + .conn + .query_row( + "SELECT COUNT(*) FROM reachable WHERE is_anchor = 0 AND addresses != '[]'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(leaked, 0, "non-anchor entries must never carry an address"); + + let mined = s.list_pool_anchors(10).unwrap(); + assert_eq!(mined.len(), 1); + assert_eq!(mined[0].0, anchor); + assert_eq!(mined[0].1, vec!["5.6.7.8:4433".to_string()]); + + // Anchor density is a free local statistic (2 distinct node IDs, 1 anchor). + assert!((s.anchor_density().unwrap() - 0.5).abs() < 1e-9); + } + + /// Author-class IDs must never be offered as connect targets. + #[test] + fn author_class_excluded_from_growth_candidates() { + let s = temp_storage(); + let reporter = make_node_id(1); + let node = make_node_id(10); + let persona = make_node_id(11); + + s.set_reach(&reporter, 2, &[ReachEntry::node(node), ReachEntry::author(persona)]) + .unwrap(); + + let scored = s.score_n2_candidates_batch().unwrap(); + let ids: Vec = scored.iter().map(|(id, _, _)| *id).collect(); + assert!(ids.contains(&node)); + assert!(!ids.contains(&persona), "persona IDs are not connectable"); + + // ...but they ARE findable, which is the whole point of carrying them. + assert!(s.list_reach_ids_at(2).unwrap().contains(&persona)); + } + + /// The announce builder must never read bounce-4 rows: N4 is terminal. + #[test] + fn n4_never_appears_in_a_built_announce() { + let s = temp_storage(); + let us = make_node_id(0); + let reporter = make_node_id(1); + let n2_id = make_node_id(10); + let n3_id = make_node_id(11); + let n4_id = make_node_id(12); + + s.set_reach(&reporter, 2, &nodes(&[n2_id])).unwrap(); + s.set_reach(&reporter, 3, &nodes(&[n3_id])).unwrap(); + s.set_reach(&reporter, 4, &nodes(&[n4_id])).unwrap(); + + let pools = s.build_uniques_pools(&us, None).unwrap(); + let all: Vec = pools + .fwd + .iter() + .flatten() + .chain(pools.term.iter()) + .map(|e| e.id) + .collect(); + + assert!(all.contains(&n2_id), "our N2 rides pool 1"); + assert!(all.contains(&n3_id), "our N3 rides pool 2 (terminal)"); + assert!(!all.contains(&n4_id), "N4 must NEVER be re-announced"); + + // And it is in the right pool. + assert!(pools.fwd[2].iter().any(|e| e.id == n2_id)); + assert!(pools.term.iter().any(|e| e.id == n3_id)); + } + + /// Announce-side dedup: an ID announced shallow is not repeated deeper. + #[test] + fn announce_dedup_across_pool_slices() { + let s = temp_storage(); + let us = make_node_id(0); + let reporter = make_node_id(1); + let dup = make_node_id(10); + + // Same ID known at bounce 2 AND bounce 3 (via different reporters). + s.set_reach(&reporter, 2, &nodes(&[dup])).unwrap(); + s.set_reach(&make_node_id(2), 3, &nodes(&[dup])).unwrap(); + + let pools = s.build_uniques_pools(&us, None).unwrap(); + let count = pools + .fwd + .iter() + .flatten() + .chain(pools.term.iter()) + .filter(|e| e.id == dup) + .count(); + assert_eq!(count, 1, "an ID must appear in exactly one pool slice"); + assert!(pools.fwd[2].iter().any(|e| e.id == dup), "shallowest slice wins"); + + // Our own node id is never repeated deeper either. + let self_count = pools + .fwd + .iter() + .flatten() + .chain(pools.term.iter()) + .filter(|e| e.id == us) + .count(); + assert_eq!(self_count, 1); + } + + /// Our own uniques merge every source, and anchor addresses only attach to + /// peers actually flagged as anchors. + #[test] + fn own_uniques_merge_sources_and_flag_anchors() { + let s = temp_storage(); + let mesh = make_node_id(1); + let direct = make_node_id(2); + let anchor = make_node_id(3); + let addr: std::net::SocketAddr = "203.0.113.7:4433".parse().unwrap(); + + s.add_mesh_peer(&mesh).unwrap(); s.upsert_social_route(&SocialRouteEntry { - node_id: follow_b, - addresses: vec![addr], + node_id: direct, + addresses: vec![], peer_addresses: vec![], relation: SocialRelation::Follow, + // Deliberately Disconnected: the ruling is "everyone's directs". status: SocialStatus::Disconnected, last_connected_ms: 0, last_seen_ms: 1000, reach_method: ReachMethod::Direct, - preferred_tree: vec![], - }).unwrap(); + }) + .unwrap(); + // An anchor we have a LIVE PATH to (here: a social direct) carries its + // address. Anchor flagging ENRICHES a unique; it never imports one. + s.upsert_peer(&direct, &[addr], None).unwrap(); + s.set_peer_anchor(&direct, true).unwrap(); - // Disconnected routes should NOT be in N1 share - let n1 = s.build_n1_share().unwrap(); - assert!(n1.contains(&peer_a)); - assert!(!n1.contains(&follow_b), "Disconnected social route should not be in N1"); + // An anchor-flagged peers row with no live path — e.g. one we merely + // heard about — must NOT become a bounce-1 unique of ours. + s.upsert_peer(&anchor, &[addr], None).unwrap(); + s.set_peer_anchor(&anchor, true).unwrap(); - // Set to Online — now it should be included - s.set_social_route_status(&follow_b, SocialStatus::Online).unwrap(); - let n1 = s.build_n1_share().unwrap(); - assert!(n1.contains(&peer_a)); - assert!(n1.contains(&follow_b), "Online social route should be in N1"); + let own = s.build_own_uniques().unwrap(); + let ids: Vec = own.iter().map(|e| e.id).collect(); + assert!(ids.contains(&mesh)); + assert!(ids.contains(&direct), "held routes count even when offline"); + assert!( + !ids.contains(&anchor), + "an anchor we merely have a row for is not a live path of ours" + ); + + let anchor_entry = own.iter().find(|e| e.id == direct).unwrap(); + assert!(anchor_entry.is_anchor); + assert_eq!(anchor_entry.addresses, vec![addr.to_string()]); + + let mesh_entry = own.iter().find(|e| e.id == mesh).unwrap(); + assert!(!mesh_entry.is_anchor); + assert!(mesh_entry.addresses.is_empty(), "non-anchors are bare IDs"); + } + + /// design.html §21: "Posting identities never map to device addresses on + /// the wire." Our node ID (and our anchor address) ride `fwd[0]` of the + /// same announce, so a persona of ours in `fwd[1]` IS that mapping. + #[test] + fn own_personas_and_local_comment_authors_never_ride_our_announce() { + let s = temp_storage(); + let us = make_node_id(0); + // Plural personas per the A1 rule — not just the default posting id. + let persona = add_persona(&s, 66); + let persona2 = add_persona(&s, 67); + let throwaway = make_node_id(77); + let foreign_author = make_node_id(88); + + // CDN manifests we authored (node.rs stores these with OUR posting id). + s.store_cdn_manifest(&make_post_id(1), "{}", &persona, 1).unwrap(); + s.store_cdn_manifest(&make_post_id(2), "{}", &persona2, 1).unwrap(); + + let mk = |author: NodeId, ts: u64| crate::types::InlineComment { + author, + post_id: [9u8; 32], + content: "hi".into(), + timestamp_ms: ts, + signature: vec![1, 2, 3], + ref_post_id: None, + deleted_at: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, + expires_at_ms: 0, + }; + // Our greeting: a per-greeting throwaway ID we minted. We would be the + // FIRST node in the network to announce it, at bounce 1, the instant it + // was created — first-announcer identifies the greeter. + s.store_own_comment(&mk(throwaway, 100)).unwrap(); + // Someone else's comment reached us from the network — that author is + // legitimate uniques material (anonymity noise, round-1 ruling). + s.store_comment(&mk(foreign_author, 200)).unwrap(); + + let own = s.build_own_uniques().unwrap(); + let ids: Vec = own.iter().map(|e| e.id).collect(); + assert!(!ids.contains(&persona), "our own persona must never be in our N1"); + assert!(!ids.contains(&persona2), "every persona, not just the default"); + assert!(!ids.contains(&throwaway), "our greeting throwaway must never be in our N1"); + assert!(ids.contains(&foreign_author), "other people's authors still count"); + + // ...and it holds through the pool builder, which is what goes on the wire. + let pools = s.build_uniques_pools(&us, None).unwrap(); + let all: Vec = pools + .fwd + .iter() + .flatten() + .chain(pools.term.iter()) + .map(|e| e.id) + .collect(); + assert!(!all.contains(&persona)); + assert!(!all.contains(&persona2)); + assert!(!all.contains(&throwaway)); } #[test] @@ -6467,9 +7324,9 @@ mod tests { let shared_node = make_node_id(11); // reporter_a has unique_node + shared_node - s.set_peer_n1(&reporter_a, &[unique_node, shared_node]).unwrap(); + s.set_reach(&reporter_a, 2, &nodes(&[unique_node, shared_node])).unwrap(); // reporter_b only has shared_node - s.set_peer_n1(&reporter_b, &[shared_node]).unwrap(); + s.set_reach(&reporter_b, 2, &nodes(&[shared_node])).unwrap(); // reporter_a contributes 1 unique node (unique_node) let unique = s.count_unique_n2_for_reporter(&reporter_a, &[]).unwrap(); @@ -6480,41 +7337,40 @@ mod tests { assert_eq!(unique, 0); } + /// N4 is USED for search even though it is never re-announced. #[test] - fn find_any_in_n2_n3() { + fn find_any_reachable_spans_the_whole_horizon() { let s = temp_storage(); let reporter = make_node_id(1); let node_n2 = make_node_id(10); let node_n3 = make_node_id(11); - let node_nowhere = make_node_id(12); + let node_n4 = make_node_id(12); + let node_nowhere = make_node_id(13); - s.set_peer_n1(&reporter, &[node_n2]).unwrap(); - s.set_peer_n2(&reporter, &[node_n3]).unwrap(); + s.set_reach(&reporter, 2, &nodes(&[node_n2])).unwrap(); + s.set_reach(&make_node_id(2), 3, &nodes(&[node_n3])).unwrap(); + s.set_reach(&make_node_id(3), 4, &nodes(&[node_n4])).unwrap(); - let results = s.find_any_in_n2_n3(&[node_n2, node_n3, node_nowhere]).unwrap(); - assert_eq!(results.len(), 2); - assert_eq!(results[0].2, 2); // N2 first - assert_eq!(results[1].2, 3); // N3 second + let results = s + .find_any_reachable(&[node_n2, node_n3, node_n4, node_nowhere]) + .unwrap(); + assert_eq!(results.len(), 3); + assert_eq!(results[0].2, 2); + assert_eq!(results[1].2, 3); + assert_eq!(results[2].2, 4); } #[test] fn mesh_peers_crud() { - use crate::types::PeerSlotKind; let s = temp_storage(); let nid = make_node_id(1); - s.add_mesh_peer(&nid, PeerSlotKind::Local, 4).unwrap(); + s.add_mesh_peer(&nid).unwrap(); let peers = s.list_mesh_peers().unwrap(); - assert_eq!(peers.len(), 1); - assert_eq!(peers[0].0, nid); - assert_eq!(peers[0].1, "local"); - assert_eq!(peers[0].2, 4); - - assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 1); - assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Wide).unwrap(), 0); + assert_eq!(peers, vec![nid]); s.remove_mesh_peer(&nid).unwrap(); - assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 0); + assert!(s.list_mesh_peers().unwrap().is_empty()); } // ---- Social routes tests ---- @@ -6538,7 +7394,6 @@ mod tests { last_connected_ms: 1000, last_seen_ms: 2000, reach_method: ReachMethod::Direct, - preferred_tree: vec![], }; s.upsert_social_route(&entry).unwrap(); @@ -6598,7 +7453,6 @@ mod tests { last_connected_ms: 0, last_seen_ms: 0, reach_method: ReachMethod::Direct, - preferred_tree: vec![], }; s.upsert_social_route(&entry).unwrap(); @@ -6940,235 +7794,21 @@ mod tests { assert_eq!(got_pubkey, expected_pubkey); } - // ---- Preferred peers tests ---- - #[test] - fn preferred_peers_crud() { - let s = temp_storage(); - let peer_a = make_node_id(1); - let peer_b = make_node_id(2); + fn slot_counts() { + // v0.8: ONE mesh pool. Local/Wide collapsed. + assert_eq!(DeviceProfile::Desktop.mesh_slots(), 20); + assert_eq!(DeviceProfile::Mobile.mesh_slots(), 15); - assert_eq!(s.count_preferred_peers().unwrap(), 0); - assert!(!s.is_preferred_peer(&peer_a).unwrap()); + // Temp referral slots sit ABOVE the mesh cap, +4..+10 (round-3 ruling). + for p in [DeviceProfile::Desktop, DeviceProfile::Mobile] { + let t = p.temp_referral_slots(); + assert!((4..=10).contains(&t), "temp referral band out of range: {t}"); + } - s.add_preferred_peer(&peer_a).unwrap(); - s.add_preferred_peer(&peer_b).unwrap(); - - assert_eq!(s.count_preferred_peers().unwrap(), 2); - assert!(s.is_preferred_peer(&peer_a).unwrap()); - assert!(s.is_preferred_peer(&peer_b).unwrap()); - - let list = s.list_preferred_peers().unwrap(); - assert_eq!(list.len(), 2); - assert!(list.contains(&peer_a)); - assert!(list.contains(&peer_b)); - - s.remove_preferred_peer(&peer_a).unwrap(); - assert!(!s.is_preferred_peer(&peer_a).unwrap()); - assert_eq!(s.count_preferred_peers().unwrap(), 1); - } - - #[test] - fn preferred_peers_idempotent() { - let s = temp_storage(); - let peer = make_node_id(1); - - s.add_preferred_peer(&peer).unwrap(); - s.add_preferred_peer(&peer).unwrap(); // no error on duplicate - assert_eq!(s.count_preferred_peers().unwrap(), 1); - } - - #[test] - fn profile_stores_preferred_peers() { - let s = temp_storage(); - let nid = make_node_id(1); - let pref_a = make_node_id(10); - let pref_b = make_node_id(11); - - let profile = PublicProfile { - node_id: nid, - display_name: "test".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![pref_a, pref_b], - public_visible: true, - avatar_cid: None, - }; - s.store_profile(&profile).unwrap(); - - let got = s.get_profile(&nid).unwrap().unwrap(); - assert_eq!(got.preferred_peers.len(), 2); - assert!(got.preferred_peers.contains(&pref_a)); - assert!(got.preferred_peers.contains(&pref_b)); - assert!(got.public_visible); - assert!(got.avatar_cid.is_none()); - } - - #[test] - fn preferred_slot_counts() { - assert_eq!(DeviceProfile::Desktop.preferred_slots(), 10); - assert_eq!(DeviceProfile::Desktop.local_slots(), 71); - assert_eq!(DeviceProfile::Mobile.preferred_slots(), 3); - assert_eq!(DeviceProfile::Mobile.local_slots(), 7); - // Total unchanged - assert_eq!( - DeviceProfile::Desktop.preferred_slots() + DeviceProfile::Desktop.local_slots() + DeviceProfile::Desktop.wide_slots(), - 101 - ); - assert_eq!( - DeviceProfile::Mobile.preferred_slots() + DeviceProfile::Mobile.local_slots() + DeviceProfile::Mobile.wide_slots(), - 15 - ); - } - - #[test] - fn peer_slot_kind_preferred_roundtrip() { - let kind: PeerSlotKind = "preferred".parse().unwrap(); - assert_eq!(kind, PeerSlotKind::Preferred); - assert_eq!(kind.to_string(), "preferred"); - } - - // ---- Preferred tree tests ---- - - #[test] - fn build_preferred_tree_empty() { - let s = temp_storage(); - let target = make_node_id(1); - // No profile stored — tree should just contain the target - let tree = s.build_preferred_tree_for(&target).unwrap(); - assert_eq!(tree.len(), 1); - assert!(tree.contains(&target)); - } - - #[test] - fn build_preferred_tree_two_layers() { - let s = temp_storage(); - let target = make_node_id(1); - let l1_a = make_node_id(10); - let l1_b = make_node_id(11); - let l2_a1 = make_node_id(20); - let l2_a2 = make_node_id(21); - let l2_b1 = make_node_id(30); - - // Target's profile with 2 preferred peers - s.store_profile(&PublicProfile { - node_id: target, - display_name: "target".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l1_a, l1_b], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - // L1 peer A's profile with 2 preferred peers - s.store_profile(&PublicProfile { - node_id: l1_a, - display_name: "l1a".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l2_a1, l2_a2], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - // L1 peer B's profile with 1 preferred peer - s.store_profile(&PublicProfile { - node_id: l1_b, - display_name: "l1b".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l2_b1], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - let tree = s.build_preferred_tree_for(&target).unwrap(); - // Should contain: target, l1_a, l1_b, l2_a1, l2_a2, l2_b1 = 6 unique nodes - assert_eq!(tree.len(), 6); - assert!(tree.contains(&target)); - assert!(tree.contains(&l1_a)); - assert!(tree.contains(&l1_b)); - assert!(tree.contains(&l2_a1)); - assert!(tree.contains(&l2_a2)); - assert!(tree.contains(&l2_b1)); - } - - #[test] - fn build_preferred_tree_deduplicates() { - let s = temp_storage(); - let target = make_node_id(1); - let shared = make_node_id(10); - let l1_a = make_node_id(11); - - // Target's preferred peers include shared - s.store_profile(&PublicProfile { - node_id: target, - display_name: "target".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l1_a, shared], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - // L1 peer's preferred peers also include shared - s.store_profile(&PublicProfile { - node_id: l1_a, - display_name: "l1a".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![shared, target], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - let tree = s.build_preferred_tree_for(&target).unwrap(); - // Should contain: target, l1_a, shared = 3 unique nodes (no duplicates) - assert_eq!(tree.len(), 3); - } - - #[test] - fn social_route_preferred_tree_roundtrip() { - use crate::types::{ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus}; - let s = temp_storage(); - let nid = make_node_id(1); - let tree_node = make_node_id(10); - - let entry = SocialRouteEntry { - node_id: nid, - addresses: vec![], - peer_addresses: vec![], - relation: SocialRelation::Follow, - status: SocialStatus::Online, - last_connected_ms: 0, - last_seen_ms: 1000, - reach_method: ReachMethod::Direct, - preferred_tree: vec![tree_node], - }; - s.upsert_social_route(&entry).unwrap(); - - let got = s.get_social_route(&nid).unwrap().unwrap(); - assert_eq!(got.preferred_tree.len(), 1); - assert!(got.preferred_tree.contains(&tree_node)); - - // Update preferred tree - let new_tree = vec![make_node_id(20), make_node_id(21)]; - s.update_social_route_preferred_tree(&nid, &new_tree).unwrap(); - let got2 = s.get_social_route(&nid).unwrap().unwrap(); - assert_eq!(got2.preferred_tree.len(), 2); + // Device-tiered depth: mobile caps the stored horizon at 3 bounces. + assert_eq!(DeviceProfile::Desktop.max_bounce(), 4); + assert_eq!(DeviceProfile::Mobile.max_bounce(), 3); } // ---- Circle Profile tests ---- @@ -7232,7 +7872,6 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid: None, }).unwrap(); @@ -7253,13 +7892,13 @@ mod tests { s.set_circle_profile(&cp).unwrap(); // Viewer in circle sees circle profile - let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &viewer).unwrap(); + let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[viewer]).unwrap(); assert_eq!(dn, "Alice (CF)"); assert_eq!(bio, "Circle bio"); assert_eq!(avatar, Some([99u8; 32])); // Stranger sees public profile - let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &stranger).unwrap(); + let (dn2, bio2, avatar2) = s.resolve_display_for_peer(&author, &[stranger]).unwrap(); assert_eq!(dn2, "Alice Public"); assert_eq!(bio2, "Public bio"); assert!(avatar2.is_none()); @@ -7279,13 +7918,12 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: false, avatar_cid: None, }).unwrap(); // Stranger sees nothing - let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &stranger).unwrap(); + let (dn, bio, avatar) = s.resolve_display_for_peer(&author, &[stranger]).unwrap(); assert!(dn.is_empty()); assert!(bio.is_empty()); assert!(avatar.is_none()); @@ -7304,7 +7942,6 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid: None, }).unwrap(); @@ -7327,29 +7964,33 @@ mod tests { s.upsert_known_anchor(&a2, &[addr]).unwrap(); s.upsert_known_anchor(&a3, &[addr]).unwrap(); - // a1 gets extra success bumps - s.upsert_known_anchor(&a1, &[addr]).unwrap(); + // Re-contacting a1 refreshes it. Ordering is by RECENCY, not by + // success_count: under pool-mined anchor abundance "the anchor that + // answered most often" is not a better anchor, just the one we + // hammered (round-4 ruling). + std::thread::sleep(std::time::Duration::from_millis(2)); s.upsert_known_anchor(&a1, &[addr]).unwrap(); let anchors = s.list_known_anchors().unwrap(); assert_eq!(anchors.len(), 3); - // a1 should be first (highest success_count = 3) - assert_eq!(anchors[0].0, a1); + assert_eq!(anchors[0].0, a1, "freshest anchor first"); } #[test] - fn known_anchors_prune() { + fn known_anchors_prune_to_the_bootstrap_cache_cap() { let s = temp_storage(); let addr: SocketAddr = "10.0.0.1:4433".parse().unwrap(); - for i in 0..10u8 { + // The old cap was 5. `apply_uniques_to_storage` now mirrors every + // received anchor entry into this table, so at 5 it would thrash on + // every announce. + for i in 0..(Storage::KNOWN_ANCHOR_CACHE_CAP as u8 + 8) { let nid = make_node_id(i + 1); s.upsert_known_anchor(&nid, &[addr]).unwrap(); } - // Auto-prune should keep only 5 let anchors = s.list_known_anchors().unwrap(); - assert_eq!(anchors.len(), 5); + assert_eq!(anchors.len(), Storage::KNOWN_ANCHOR_CACHE_CAP); } #[test] @@ -7448,7 +8089,7 @@ mod tests { let reactions = s.get_reactions(&post_id).unwrap(); assert_eq!(reactions.len(), 3); - let counts = s.get_reaction_counts(&post_id, &me).unwrap(); + let counts = s.get_reaction_counts(&post_id, &[me]).unwrap(); assert_eq!(counts.len(), 2); // 👍 and ❤️ // 👍 has 2 reactions, one from me let thumbs = counts.iter().find(|(e, _, _)| e == "👍").unwrap(); @@ -7480,6 +8121,7 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms: 0, }).unwrap(); s.store_comment(&InlineComment { @@ -7493,6 +8135,7 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms: 0, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -7521,6 +8164,7 @@ mod tests { pub_x_index: None, group_sig: None, encrypted_payload: None, + expires_at_ms: 0, }).unwrap(); let live = s.get_comments(&post_id).unwrap(); @@ -7609,4 +8253,287 @@ mod tests { assert!(s.get_thread_parent(&parent).unwrap().is_none()); } + + // --- A1 ID-class fixes: multi-persona coverage --- + + fn make_post(author: NodeId, ts: u64) -> Post { + Post { + author, + content: format!("post at {ts}"), + attachments: vec![], + timestamp_ms: ts, + fof_gating: None, + supersedes_post_id: None, + } + } + + fn add_persona(s: &Storage, byte: u8) -> NodeId { + let id = make_node_id(byte); + s.upsert_posting_identity(&PostingIdentity { + node_id: id, + secret_seed: [byte; 32], + display_name: String::new(), + created_at: byte as u64, + }).unwrap(); + id + } + + /// Replication (bug A1-3): own posts are found by unioning over ALL + /// posting identities; a network-NodeId query finds nothing. + #[test] + fn own_recent_posts_found_across_personas_not_network_id() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = add_persona(&s, 1); + let persona2 = add_persona(&s, 2); + + s.store_post(&make_post_id(11), &make_post(persona1, 1000)).unwrap(); + s.store_post(&make_post_id(12), &make_post(persona2, 2000)).unwrap(); + + // The old bug: querying by the network id → always empty. + assert!(s.get_own_recent_post_ids(&network_id, 0).unwrap().is_empty()); + + // The fix pattern: union across all posting identities. + let mut found = Vec::new(); + for pi in s.list_posting_identities().unwrap() { + found.extend(s.get_own_recent_post_ids(&pi.node_id, 0).unwrap()); + } + assert_eq!(found.len(), 2, "posts from BOTH personas must be found"); + } + + /// Reaction "mine" flag (caller-side bug): reactors are posting ids; + /// the flag must light up for a reaction from ANY persona and never for + /// the network id. + #[test] + fn reaction_mine_flag_multi_persona() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = make_node_id(1); + let persona2 = make_node_id(2); + let post_id = make_post_id(21); + + s.store_reaction(&Reaction { + reactor: persona2, + emoji: "👍".to_string(), + post_id, + timestamp_ms: 1000, + encrypted_payload: None, + deleted_at: None, + signature: vec![], + }).unwrap(); + + // Old bug shape: network id as "me" → never mine. + let counts = s.get_reaction_counts(&post_id, &[network_id]).unwrap(); + assert!(!counts[0].2, "network id must never own a reaction"); + + // Fix: all personas → reaction by the SECOND persona is mine. + let counts = s.get_reaction_counts(&post_id, &[persona1, persona2]).unwrap(); + assert!(counts[0].2, "reaction by any persona must count as mine"); + + // Batch variant behaves identically. + let batch = s.get_reaction_counts_batch(&[post_id], &[persona1, persona2]).unwrap(); + assert!(batch.get(&post_id).unwrap()[0].2); + let batch = s.get_reaction_counts_batch(&[post_id], &[network_id]).unwrap(); + assert!(!batch.get(&post_id).unwrap()[0].2); + } + + /// Circle display resolution (bug A1-11): the viewer join is against + /// posting-class circle members — a second persona's membership must + /// resolve, the network id must not. + #[test] + fn resolve_display_for_second_persona_viewer() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = make_node_id(1); + let persona2 = make_node_id(2); + let author = make_node_id(5); + + s.create_circle("friends").unwrap(); + // Only our SECOND persona is a member of the author's circle mirror. + s.add_circle_member("friends", &persona2).unwrap(); + s.set_circle_profile(&CircleProfile { + author, + circle_name: "friends".to_string(), + display_name: "Circle Name".to_string(), + bio: "circle bio".to_string(), + avatar_cid: None, + updated_at: 1000, + }).unwrap(); + + // Old bug shape: network id as viewer → no circle match. + let (dn, _, _) = s.resolve_display_for_peer(&author, &[network_id]).unwrap(); + assert_eq!(dn, "", "network-id viewer must not resolve circle profiles"); + + // Fix: all personas as viewers → second persona matches. + let (dn, bio, _) = s.resolve_display_for_peer(&author, &[persona1, persona2]).unwrap(); + assert_eq!(dn, "Circle Name"); + assert_eq!(bio, "circle bio"); + } + + /// Circle revocation (bug A1-9): posts stored under per-persona authors + /// are found by per-persona circle-intent queries, not by the network id. + #[test] + fn circle_intent_posts_found_per_persona() { + let s = temp_storage(); + let network_id = make_node_id(9); + let persona1 = add_persona(&s, 1); + let persona2 = add_persona(&s, 2); + + let vis = PostVisibility::Encrypted { recipients: vec![] }; + s.store_post_with_intent( + &make_post_id(31), + &make_post(persona1, 1000), + &vis, + &VisibilityIntent::Circle("crew".to_string()), + ).unwrap(); + s.store_post_with_intent( + &make_post_id(32), + &make_post(persona2, 2000), + &vis, + &VisibilityIntent::Circle("crew".to_string()), + ).unwrap(); + + // Old bug shape: query by network id → nothing. + assert!(s.find_posts_by_circle_intent("crew", &network_id).unwrap().is_empty()); + + // Fix pattern: union across personas finds both posts. + let mut found = 0; + for pi in s.list_posting_identities().unwrap() { + found += s.find_posts_by_circle_intent("crew", &pi.node_id).unwrap().len(); + } + assert_eq!(found, 2); + } + + // --- v0.8 (A3): comment TTL + greeting storage --- + + fn ttl_comment(author: NodeId, post_id: PostId, ts: u64, expires: u64) -> InlineComment { + InlineComment { + author, + post_id, + content: "hello".to_string(), + timestamp_ms: ts, + signature: vec![0u8; 64], + deleted_at: None, + ref_post_id: None, + pub_x_index: None, + group_sig: None, + encrypted_payload: None, + expires_at_ms: expires, + } + } + + fn test_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } + + /// The expires_at migration must be idempotent across re-opens of a + /// file-backed DB (pragma-guarded ALTER). + #[test] + fn expires_at_migration_idempotent() { + let dir = std::env::temp_dir().join(format!("itsgoin-a3-mig-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let db_path = dir.join("test.db"); + let db_str = db_path.to_str().unwrap().to_string(); + { + let s = Storage::open(&db_str).unwrap(); + let now = test_now_ms(); + s.store_comment(&ttl_comment(make_node_id(1), make_post_id(1), now, now + 60_000)).unwrap(); + } + // Re-open: migration runs again, must not fail or lose data. + { + let s = Storage::open(&db_str).unwrap(); + let comments = s.get_comments(&make_post_id(1)).unwrap(); + assert_eq!(comments.len(), 1); + assert!(comments[0].expires_at_ms > 0, "expiry survived re-open"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// expire_comments hard-deletes only due rows; legacy (NULL/0) rows + /// are never touched. + #[test] + fn expire_comments_deletes_only_due_rows() { + let s = temp_storage(); + let post_id = make_post_id(2); + let now = test_now_ms(); + + s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // due + s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); // live + s.store_comment(&ttl_comment(make_node_id(3), post_id, 1002, 0)).unwrap(); // legacy no-TTL + + assert_eq!(s.expire_comments(now).unwrap(), 1, "only the due row deleted"); + // Hard delete: gone even from the with-tombstones view. + let all = s.get_comments_with_tombstones(&post_id).unwrap(); + assert_eq!(all.len(), 2); + assert!(all.iter().all(|c| c.author != make_node_id(1))); + // Idempotent. + assert_eq!(s.expire_comments(now).unwrap(), 0); + } + + /// Read filters exclude expired-but-unswept rows (belt-and-suspenders + /// between sweeps). + #[test] + fn read_filters_exclude_expired_unswept() { + let s = temp_storage(); + let post_id = make_post_id(3); + let now = test_now_ms(); + + s.store_comment(&ttl_comment(make_node_id(1), post_id, 1000, now - 1)).unwrap(); // expired, unswept + s.store_comment(&ttl_comment(make_node_id(2), post_id, 1001, now + 60_000)).unwrap(); + + assert_eq!(s.get_comments(&post_id).unwrap().len(), 1); + assert_eq!(s.get_comments_with_tombstones(&post_id).unwrap().len(), 1); + assert_eq!(s.get_comment_count(&post_id).unwrap(), 1); + } + + #[test] + fn open_slot_comment_count_helper() { + let s = temp_storage(); + let post_id = make_post_id(4); + let now = test_now_ms(); + + for i in 0..3u8 { + let mut c = ttl_comment(make_node_id(10 + i), post_id, 1000 + i as u64, now + 60_000); + c.pub_x_index = Some(5); + s.store_comment(&c).unwrap(); + } + // One on a different slot; one expired on slot 5. + let mut other = ttl_comment(make_node_id(20), post_id, 2000, now + 60_000); + other.pub_x_index = Some(6); + s.store_comment(&other).unwrap(); + let mut expired = ttl_comment(make_node_id(21), post_id, 2001, now - 1); + expired.pub_x_index = Some(5); + s.store_comment(&expired).unwrap(); + + assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 5).unwrap(), 3); + assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 6).unwrap(), 1); + assert_eq!(s.count_unexpired_open_slot_comments(&post_id, 7).unwrap(), 0); + } + + #[test] + fn greeting_dismissals_and_reply_keys() { + let s = temp_storage(); + let author = make_node_id(1); + let post_id = make_post_id(5); + + assert!(!s.is_greeting_dismissed(&author, &post_id, 1000).unwrap()); + s.add_greeting_dismissal(&author, &post_id, 1000).unwrap(); + assert!(s.is_greeting_dismissed(&author, &post_id, 1000).unwrap()); + // Different timestamp = different greeting, not dismissed. + assert!(!s.is_greeting_dismissed(&author, &post_id, 1001).unwrap()); + // Idempotent re-dismiss. + s.add_greeting_dismissal(&author, &post_id, 1000).unwrap(); + + // Reply keys roundtrip. + let privkey = [7u8; 32]; + let pubkey = [8u8; 32]; + s.store_greeting_reply_key(&pubkey, &privkey, &post_id).unwrap(); + let keys = s.list_greeting_reply_keys().unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].0, privkey); + assert_eq!(keys[0].1, post_id); + } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index aca1ea1..faba589 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -68,7 +68,22 @@ pub struct Attachment { pub size_bytes: u64, } -/// Public profile — plaintext, synced to all peers +/// Public profile — plaintext, synced to all peers. +/// +/// v0.8 keying (dual-purpose struct — the `node_id` decides which class): +/// - NETWORK-id rows carry TOPOLOGY ONLY (anchors, recent_peers); +/// persona fields stay empty. They travel via +/// ProfileUpdate / InitialExchange, always through +/// `sanitized_for_network_broadcast()`. +/// - POSTING-id rows carry PERSONA fields (display_name, bio, avatar_cid, +/// public_visible); topology fields stay empty. They travel ONLY as +/// signed profile posts (`ProfilePostContent`), which have no topology +/// fields at all — so posting identities never advertise device +/// neighborhoods on the wire. +/// The topology NodeIds on network-id rows are the designed 11-needle +/// findability mechanism (device-to-device, no posting-id linkage) — an +/// accepted tradeoff, not a leak; see design.html v0.8 location-anonymity +/// ruling before reflagging in audits. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PublicProfile { pub node_id: NodeId, @@ -84,9 +99,6 @@ pub struct PublicProfile { /// Up to 10 currently-connected peer NodeIds (for 11-needle worm search) #[serde(default)] pub recent_peers: Vec, - /// Bilateral preferred peer NodeIds (stable relay hubs) - #[serde(default)] - pub preferred_peers: Vec, /// Whether display_name/bio are visible to non-circle peers #[serde(default = "default_true")] pub public_visible: bool, @@ -98,7 +110,7 @@ pub struct PublicProfile { impl PublicProfile { /// Return a copy with persona-level display data (display_name, bio, /// avatar_cid) stripped, leaving only the routing metadata (anchors, - /// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under + /// recent_peers). v0.6.1 broadcasts the profile under /// the network NodeId; attaching a human-readable name to that key would /// correlate the network endpoint to a specific person. Persona display /// data will travel via signed posts from v0.6.2 onward. @@ -450,14 +462,6 @@ pub struct DeleteRecord { pub signature: Vec, } -/// An update to a post's visibility (new wrapped keys after revocation) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VisibilityUpdate { - pub post_id: PostId, - pub author: NodeId, - pub visibility: PostVisibility, -} - /// How to handle revoking a recipient's access to past encrypted posts #[derive(Debug, Clone, Copy)] pub enum RevocationMode { @@ -645,33 +649,67 @@ impl NatProfile { } /// Device profile — determines connection slot budget +/// +/// v0.8 (Iteration C): the Local/Wide split is gone. There is ONE mesh pool +/// (`mesh_slots`) plus a strictly-additional band of temporary referral slots +/// (`temp_referral_slots`) that live ABOVE the mesh cap and carry no knowledge +/// exchange. See design.html §connections. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeviceProfile { - /// Desktop: 81 local + 20 wide = 101 mesh connections + /// Desktop: 20 mesh + up to 10 temp referral Desktop, - /// Mobile: 10 local + 5 wide = 15 mesh connections + /// Mobile: 15 mesh + up to 4 temp referral Mobile, } impl DeviceProfile { - pub fn preferred_slots(&self) -> usize { - match self { - DeviceProfile::Desktop => 10, - DeviceProfile::Mobile => 3, + /// The single mesh pool size (v0.8 narrow mesh, deep knowledge). + /// + /// `ITSGOIN_TEST_MESH_SLOTS` overrides it so integration tests can reach + /// the temp-referral boundary with a handful of nodes instead of 21. Test + /// gate only — same pattern as `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS`. + /// + /// Read ONCE into a `OnceLock`: this is called from `ConnectionManager` + /// construction and from every path that wants a fresh cap, and an env + /// lookup per call is a syscall-shaped cost on a hot path (and lets the + /// mesh cap change under a running node, which nothing expects). + pub fn mesh_slots(&self) -> usize { + static OVERRIDE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let over = *OVERRIDE.get_or_init(|| { + std::env::var("ITSGOIN_TEST_MESH_SLOTS") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|n| *n > 0) + }); + if let Some(n) = over { + return n; } - } - - pub fn local_slots(&self) -> usize { - match self { - DeviceProfile::Desktop => 71, - DeviceProfile::Mobile => 7, - } - } - - pub fn wide_slots(&self) -> usize { match self { DeviceProfile::Desktop => 20, - DeviceProfile::Mobile => 5, + DeviceProfile::Mobile => 15, + } + } + + /// Temporary referral slots, allocated ABOVE `mesh_slots`. These exist only + /// to facilitate introductions: they carry no uniques exchange, are never + /// persisted to `mesh_peers`, and either graduate into a freed mesh slot or + /// expire. Ruling: +4..+10 above the cap. + pub fn temp_referral_slots(&self) -> usize { + match self { + DeviceProfile::Desktop => 10, + DeviceProfile::Mobile => 4, + } + } + + /// Deepest bounce this device stores in the `reachable` index. + /// Desktop keeps the full 4-bounce horizon; mobile caps at 3 (it drops the + /// terminal pool on receipt and advertises `depth = 3` so senders skip + /// building it). Alternative considered and NOT implemented: Bloom-compress + /// N4 on mobile (see `apply_uniques_announce` doc comment). + pub fn max_bounce(&self) -> u8 { + match self { + DeviceProfile::Desktop => 4, + DeviceProfile::Mobile => 3, } } @@ -711,37 +749,101 @@ impl std::fmt::Display for SessionReachMethod { } } -/// Slot kind for mesh connections -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PeerSlotKind { - /// Bilateral preferred connections (Desktop: 10, Mobile: 3) - Preferred, - /// Diverse local connections (Desktop: 71, Mobile: 7) - Local, - /// Bloom-sourced random distant connections (20 slots) - Wide, +/// Which class of slot a live connection occupies. +/// +/// v0.8: `Mesh` is the real pool (capped at `DeviceProfile::mesh_slots`) and is +/// the ONLY class that participates in the uniques announce. `TempReferral` +/// slots sit above the cap, exist purely to broker introductions, and are never +/// written to `mesh_peers` — which is what structurally keeps them out of our +/// own announcements. They graduate into a freed mesh slot or expire. +/// +/// NOT on the wire — purely local accounting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MeshSlot { + Mesh, + TempReferral { expires_at_ms: u64 }, } -impl std::fmt::Display for PeerSlotKind { +impl MeshSlot { + pub fn is_mesh(&self) -> bool { + matches!(self, MeshSlot::Mesh) + } + pub fn is_temp(&self) -> bool { + !self.is_mesh() + } +} + +impl std::fmt::Display for MeshSlot { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PeerSlotKind::Preferred => write!(f, "preferred"), - PeerSlotKind::Local => write!(f, "local"), - PeerSlotKind::Wide => write!(f, "wide"), + MeshSlot::Mesh => write!(f, "mesh"), + MeshSlot::TempReferral { .. } => write!(f, "temp"), } } } -impl std::str::FromStr for PeerSlotKind { - type Err = anyhow::Error; - fn from_str(s: &str) -> Result { - match s { - "preferred" => Ok(PeerSlotKind::Preferred), - "local" | "social" => Ok(PeerSlotKind::Local), - "wide" => Ok(PeerSlotKind::Wide), - _ => Err(anyhow::anyhow!("unknown slot kind: {}", s)), - } +/// Class of an ID carried in a uniques pool. +/// +/// A `Node` ID is a network identity we may connect to / hole-punch / relay to. +/// An `Author` ID is a posting identity (persona, throwaway, greeting ID). It +/// has NO address and NO device linkage (Iteration A stripped +/// `AuthorManifest.author_addresses`), so it must NEVER enter the address +/// resolution cascade or the growth-candidate scorer — it resolves only through +/// CDN holder search. The pools keep the two classes in separate arrays so a +/// receiver structurally cannot confuse them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdClass { + Node = 0, + Author = 1, +} + +impl IdClass { + pub fn as_i64(self) -> i64 { + self as i64 } + pub fn from_i64(v: i64) -> Self { + if v == 1 { IdClass::Author } else { IdClass::Node } + } +} + +/// One entry in the uniques index / announce pools. +/// +/// `addresses` is non-empty ONLY when `is_anchor` — that is the §layers privacy +/// invariant, enforced at the store (`add_reach` blanks the column otherwise) +/// and at the wire builder (only `AnchorEntry` has an address field at all). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReachEntry { + pub id: NodeId, + pub id_class: IdClass, + pub is_anchor: bool, + pub addresses: Vec, +} + +impl ReachEntry { + /// A bare node-class entry (no address). + pub fn node(id: NodeId) -> Self { + Self { id, id_class: IdClass::Node, is_anchor: false, addresses: Vec::new() } + } + /// A bare author-class entry (no address, never a connect target). + pub fn author(id: NodeId) -> Self { + Self { id, id_class: IdClass::Author, is_anchor: false, addresses: Vec::new() } + } + /// An anchor entry — the only kind that carries an address. + pub fn anchor(id: NodeId, addresses: Vec) -> Self { + Self { id, id_class: IdClass::Node, is_anchor: true, addresses } + } +} + +/// The two announce pools, pre-wire. +/// +/// `fwd` is always length 3 (our N0, N1, N2) and is FORWARDABLE — the receiver +/// stores `fwd[i]` at bounce `i + 1` and may re-announce it shifted. +/// `term` is our N3 and is TERMINAL — the receiver stores it at bounce 4, uses +/// it, and never re-announces it. +#[derive(Debug, Clone, Default)] +pub struct UniquesPools { + pub fwd: Vec>, + pub term: Vec, } // --- Social Routing Cache --- @@ -856,8 +958,6 @@ pub struct AuthorManifest { /// The post this manifest is anchored to pub post_id: PostId, pub author: NodeId, - /// Author's N+10:A (ip:port strings) - pub author_addresses: Vec, /// Original post creation time (ms) pub created_at: u64, /// When manifest was last updated (ms) @@ -870,20 +970,16 @@ pub struct AuthorManifest { pub signature: Vec, } -/// CDN manifest traveling with blobs (author-signed part + host metadata) +/// CDN manifest traveling with blobs (author-signed part + serving host). +/// v0.8: ALL device-address fields removed (host_addresses, source, +/// source_addresses, downstream_count) — posting-identity manifests must not +/// carry device addresses (location anonymity). Holder resolution goes via +/// file_holders / worm lookup / redirect peers instead. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CdnManifest { pub author_manifest: AuthorManifest, - /// Serving host's NodeId + /// Serving host's NodeId (the QUIC-authenticated device serving the blob) pub host: NodeId, - /// Serving host's N+10:A - pub host_addresses: Vec, - /// Who the host got it from - pub source: NodeId, - /// Source's N+10:A - pub source_addresses: Vec, - /// How many downstream this host has - pub downstream_count: u32, } /// Cached routing info for a social contact @@ -897,8 +993,6 @@ pub struct SocialRouteEntry { pub last_connected_ms: u64, pub last_seen_ms: u64, pub reach_method: ReachMethod, - /// 2-layer preferred peer tree (~100 nodes) for fast relay candidate search - pub preferred_tree: Vec, } // --- Engagement System --- @@ -978,6 +1072,38 @@ pub struct InlineComment { /// Non-FoF observers see only ciphertext + sigs — body is opaque. #[serde(default, skip_serializing_if = "Option::is_none")] pub encrypted_payload: Option>, + /// v0.8 (A3): absolute expiry, ms since epoch. Fixed at creation and + /// covered by the comment signature (digest v2) — no silent extension + /// is possible. Ordinary comments: created_at + rand(30..=365 days). + /// Registry entries: created_at + exactly 30 days. `0` means "no TTL" + /// (pre-v0.8 comment); v0.8 receivers REJECT `0` at ingest. + #[serde(default)] + pub expires_at_ms: u64, +} + +/// v0.8 (A3): plaintext JSON inside a sealed greeting blob (see +/// `crypto::seal_greeting_body`). Only the recipient (bio author for an +/// original greeting; holder of the per-greeting `reply_pubkey` private +/// half for a reply) can read this. +/// +/// Messaging-first (rounds 8–9): `return_path` names the post whose +/// Greeting open slot the recipient replies into (v1: the sender's own +/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key — +/// replies are sealed to it, never to a long-term key. No vouch is +/// involved anywhere in this flow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GreetingBody { + pub v: u32, + /// hex32 — the sender's REAL posting pubkey (inside the seal only). + pub sender_persona: String, + /// <= 64 chars. + pub sender_name: String, + /// <= 600 chars. + pub text: String, + /// hex32 post id — where the sender watches for a reply. + pub return_path: String, + /// hex32 fresh x25519 pubkey — seal replies to THIS. + pub reply_pubkey: String, } /// Permission level for comments on a post @@ -1076,6 +1202,34 @@ pub struct RevocationEntry { pub author_sig: Vec, } +/// v0.8 (A3): what an open slot on a post is FOR. One mechanism — a wrap +/// slot whose V_x is derivable by anyone — two features aimed at +/// different parent posts (round-6 ruling). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum OpenSlotKind { + /// Sealed first-contact greetings on a bio post. + Greeting, + /// Plaintext self-certifying registration entries on the registry post. + Registry, +} + +/// v0.8 (A3): author-signed declaration that one of the post's wrap +/// slots is OPEN — its V_x is derivable from the post alone via +/// `crypto::derive_open_slot_vx(author, slot_binder_nonce)`. Covered by +/// the PostId hash (lives inside `FoFCommentGating`), so it is immutable +/// per publish; a bio republish (new slot_binder_nonce) is the only way +/// to change it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpenSlotDecl { + /// Index into `wrap_slots`; its pub_x is a normal member of + /// `pub_post_set`. Observable-by-design: greetings are identifiable + /// as a class, never by sender. + pub slot_index: u32, + pub kind: OpenSlotKind, + /// Padded plaintext bucket size in bytes (single bucket, design §27). + pub body_bucket: u16, +} + /// FoF Layer 2: the author-published gating block embedded in a /// FoF-comment-policy post. Carries the wrap slots + the matching /// pub_post_set + the slot_binder_nonce. The `revocation_list` is @@ -1097,6 +1251,10 @@ pub struct FoFCommentGating { /// arrive; the on-wire t=0 snapshot is empty. #[serde(default)] pub revocation_list: Vec, + /// v0.8 (A3): optional open-slot declaration (greeting / registry). + /// `None` on ordinary FoF-gated posts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_slot: Option, } /// Author-controlled engagement policy for a post @@ -1128,7 +1286,19 @@ pub enum BlobHeaderDiffOp { RemoveReaction { reactor: NodeId, emoji: String, post_id: PostId }, AddComment(InlineComment), EditComment { author: NodeId, post_id: PostId, timestamp_ms: u64, new_content: String }, - DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 }, + /// v0.8 (A3): self-certifying delete. `signature` is + /// `crypto::sign_comment_delete` by the comment author's posting key + /// over (author || post_id || timestamp_ms). Receivers accept when the + /// signature verifies OR when the diff sender is the parent post's + /// author (moderation override). Empty signature = legacy shape, + /// only the sender-override path can accept it. + DeleteComment { + author: NodeId, + post_id: PostId, + timestamp_ms: u64, + #[serde(default)] + signature: Vec, + }, SetPolicy(CommentPolicy), ThreadSplit { new_post_id: PostId }, /// Write an encrypted receipt slot (64 bytes encrypted data) diff --git a/crates/core/src/upnp.rs b/crates/core/src/upnp.rs index df4f445..379647b 100644 --- a/crates/core/src/upnp.rs +++ b/crates/core/src/upnp.rs @@ -1,243 +1,181 @@ -//! Best-effort UPnP port mapping for NAT traversal. -//! Skipped entirely on mobile platforms where UPnP is unsupported. +//! NAT port mapping via UPnP-IGD, NAT-PMP, and PCP. +//! +//! Wraps the `portmapper` crate (also used internally by iroh) which runs +//! all three protocols in parallel and auto-renews the lease in a background +//! task. This module is named `upnp` for historical reasons — by v0.7.2 it +//! covers more than UPnP. +//! +//! ## Protocols +//! - **UPnP-IGD** — long-standing consumer-router default. Discovery uses +//! SSDP multicast on 239.255.255.250:1900. Behavior on Hold harbor routers +//! varies; many ship with UPnP disabled by default. +//! - **NAT-PMP** (RFC 6886) — Apple lineage; widespread on routers that +//! ever shipped Bonjour. Unicast to the gateway on UDP/5351. +//! - **PCP** (RFC 6887) — modern IETF-track successor to NAT-PMP. Unicast +//! on UDP/5351. Supports both IPv4 NAT mapping and IPv6 firewall pinholes +//! (the latter via `AddPinhole`-shaped requests). Increasingly common in +//! modern routers. +//! +//! All three are attempted in parallel by portmapper; the first one to +//! respond wins. PCP responses arrive sub-second when present; SSDP wakes +//! up in 1–3s. +//! +//! ## Per-platform contract +//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP | +//! |---|---|---|---|---| +//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ | +//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ | +//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ | +//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ | +//! +//! ## Android specifics +//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`. +//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular +//! networks almost never expose any of these protocols, and a discovery +//! timeout would waste ~3s every startup. +//! +//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the +//! lifetime of the resulting `PortMapping`. Without the lock Android filters +//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD +//! would never complete. The lock is released on Drop. +//! +//! ## iOS specifics +//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail +//! (no SSDP responses delivered to the socket). The unicast protocols PCP +//! and NAT-PMP succeed without any entitlement and cover most modern home +//! routers, so iOS gets reasonable coverage today. +//! +//! ## Renewal model +//! Auto-renewal happens inside `portmapper::Client`'s internal task — there +//! is **no** external renewal cycle to schedule. The `PortMapping` value +//! owns the client; while it's held, the mapping stays alive. Dropping it +//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the +//! mapping alive at the gateway. `release(&self)` triggers an explicit +//! deactivation message before drop for cleaner shutdown. +//! +//! ## Anchor reachability watcher (bidirectional) +//! `Network::start` spawns a task that observes the UDP mapping's +//! `watch_external()` channel and adjusts anchor mode at runtime: +//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops +//! advertising itself as an anchor at the now-stale external address. +//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On +//! non-mobile devices the anchor flag is set back on so the node +//! re-joins the anchor set without a restart. +//! +//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one) +//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs +//! look public but sit behind CGNAT. use std::net::SocketAddr; -#[cfg(not(any(target_os = "android", target_os = "ios")))] -use tracing::{info, debug}; +use std::num::NonZeroU16; +use std::time::Duration; +use tracing::{debug, info}; -/// Result of a successful UPnP port mapping. -pub struct UpnpMapping { +/// An active port mapping. While this value is held, the underlying +/// `portmapper::Client` keeps the lease alive in a background task. +/// Dropping it releases the mapping. +pub struct PortMapping { pub external_addr: SocketAddr, - pub lease_secs: u32, pub local_port: u16, + #[allow(dead_code)] // service kept alive by holding the client + client: portmapper::Client, + #[cfg(target_os = "android")] + #[allow(dead_code)] // lock kept alive for the lifetime of the mapping + mcast_lock: Option, } -/// Best-effort UPnP port mapping. -/// 3s gateway discovery timeout, 1800s (30 min) lease, UDP protocol. -/// Returns None on any failure (no router, unsupported, timeout, port conflict). -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn try_upnp_mapping(local_port: u16) -> Option { - use igd_next::SearchOptions; +impl PortMapping { + /// Best-effort UDP port mapping for the given local QUIC port. + /// On Android, requires an active WiFi/Ethernet connection — returns + /// `None` immediately on cellular. Waits up to 3 seconds for any of the + /// three protocols (PCP / NAT-PMP / UPnP-IGD) to return a mapping. + pub async fn try_udp(local_port: u16) -> Option { + Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await + } - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; + /// Best-effort TCP port mapping for HTTP serving on the given local port. + /// Same platform gating as UDP. Used to make this node HTTP-reachable + /// for browser-shaped fetches of public posts. + pub async fn try_tcp(local_port: u16) -> Option { + Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await + } - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(e) => { - debug!("UPnP gateway discovery failed (expected behind non-UPnP router): {}", e); - return None; + async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option { + let port = NonZeroU16::new(local_port)?; + + #[cfg(target_os = "android")] + { + if !crate::android_wifi::is_on_wifi() { + debug!("Port mapping: skipping (not on WiFi/Ethernet)"); + return None; + } } - }; - let external_ip = match gateway.get_external_ip().await { - Ok(ip) => ip, - Err(e) => { - debug!("UPnP: could not get external IP: {}", e); - return None; - } - }; + #[cfg(target_os = "android")] + let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp"); - // Local address for the mapping — bind to all interfaces - let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); - let lease_secs: u32 = 1800; // 30 minutes + let config = portmapper::Config { + enable_upnp: true, + enable_pcp: true, + enable_nat_pmp: true, + protocol, + }; + let client = portmapper::Client::new(config); + client.update_local_port(port); - // Try mapping the same external port first - let result = gateway.add_port( - igd_next::PortMappingProtocol::UDP, - local_port, - local_addr, - lease_secs, - "itsgoin", - ).await; - - let external_port = match result { - Ok(()) => local_port, - Err(_) => { - // Port taken — try any available port - match gateway.add_any_port( - igd_next::PortMappingProtocol::UDP, - local_addr, - lease_secs, - "itsgoin", - ).await { - Ok(port) => port, - Err(e) => { - debug!("UPnP: port mapping failed: {}", e); + // Wait up to 3 seconds for any protocol to produce a mapping. + let mut watch = client.watch_external_address(); + let external = match tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(addr) = *watch.borrow_and_update() { + return Some(addr); + } + if watch.changed().await.is_err() { return None; } } - } - }; - - let external_addr = SocketAddr::new(external_ip, external_port); - info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port); - - Some(UpnpMapping { - external_addr, - lease_secs, - local_port, - }) -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn try_upnp_mapping(_local_port: u16) -> Option { - None -} - -/// Renew an existing UPnP lease. Returns true on success. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port); - gateway.add_port( - igd_next::PortMappingProtocol::UDP, - external_port, - local_addr, - 1800, - "itsgoin", - ).await.is_ok() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn remove_upnp_mapping(external_port: u16) { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { - let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await; - info!("UPnP: removed port mapping for external port {}", external_port); - } -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn remove_upnp_mapping(_external_port: u16) {} - -// --- TCP port mapping (for HTTP post delivery) --- - -/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP. -/// Returns true on success. Reuses the already-discovered gateway. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new( - std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - local_port, - ); - - match gateway - .add_port( - igd_next::PortMappingProtocol::TCP, - external_port, - local_addr, - 1800, - "itsgoin-http", - ) + }) .await - { - Ok(()) => { - info!("UPnP: TCP port {} mapped for HTTP serving", external_port); - true - } - Err(e) => { - debug!("UPnP: TCP port mapping failed (non-fatal): {}", e); - false - } + { + Ok(Some(addr)) => addr, + _ => { + debug!( + protocol = ?protocol, + local_port, + "Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)" + ); + return None; + } + }; + + info!( + external = %external, + local_port, + protocol = ?protocol, + "Port mapping established" + ); + + Some(PortMapping { + external_addr: SocketAddr::V4(external), + local_port, + client, + #[cfg(target_os = "android")] + mcast_lock, + }) + } + + /// Watch for changes to the external address. Useful when the underlying + /// network changes (e.g., mobile WiFi roam) — the mapping may move to a + /// new public IP/port. + pub fn watch_external(&self) -> tokio::sync::watch::Receiver> { + self.client.watch_external_address() + } + + /// Explicitly request the portmapper service to release the gateway + /// mapping. The actual cleanup completes when the `PortMapping` is + /// dropped (the internal service handle is abort-on-drop). Allows + /// callers to signal "release now" before the value goes out of scope. + pub fn release(&self) { + self.client.deactivate(); } } - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Renew an existing UPnP TCP lease. Returns true on success. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await { - Ok(gw) => gw, - Err(_) => return false, - }; - - let local_addr = SocketAddr::new( - std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), - local_port, - ); - gateway - .add_port( - igd_next::PortMappingProtocol::TCP, - external_port, - local_addr, - 1800, - "itsgoin-http", - ) - .await - .is_ok() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool { - false -} - -/// Remove UPnP TCP mapping on shutdown. -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub async fn remove_upnp_tcp_mapping(external_port: u16) { - use igd_next::SearchOptions; - - let search_opts = SearchOptions { - timeout: Some(std::time::Duration::from_secs(3)), - ..Default::default() - }; - - if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await { - let _ = gateway - .remove_port(igd_next::PortMappingProtocol::TCP, external_port) - .await; - info!("UPnP: removed TCP port mapping for port {}", external_port); - } -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn remove_upnp_tcp_mapping(_external_port: u16) {} diff --git a/crates/core/src/web.rs b/crates/core/src/web.rs index c946abb..e1efe0e 100644 --- a/crates/core/src/web.rs +++ b/crates/core/src/web.rs @@ -3,8 +3,9 @@ //! renders HTML, serves blobs. No permanent storage of fetched content. //! //! Routes (behind Apache reverse proxy): -//! GET /p// → render post HTML (fetched on-demand) -//! GET /b/ → serve blob (images/videos) +//! GET /p/ → render post HTML (fetched on-demand; anchor +//! resolves holders itself) +//! GET /b/ → serve blob (images/videos) use std::net::SocketAddr; use std::sync::Arc; @@ -89,7 +90,7 @@ fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> { None } -/// Handle GET /p// +/// Handle GET /p/ /// /// Three-tier serving: /// 1. Redirect to a CDN holder with a public/punchable HTTP server @@ -112,26 +113,11 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc, browse _ => return, }; - // Parse optional author_id (after the slash) - let author_id: Option = if rest.len() > 65 { - let author_hex = &rest[65..]; - if author_hex.len() == 64 && author_hex.chars().all(|c| c.is_ascii_hexdigit()) { - hex::decode(author_hex).ok().and_then(|b| b.try_into().ok()) - } else { - None - } - } else { - None - }; - // Single lock: gather holders, local post, AND author name if local let (holders, local_post, local_author_name) = { let store = node.storage.get().await; let mut holders = Vec::new(); - if let Some(author) = author_id { - holders.push(author); - } if let Ok(file_holders) = store.get_file_holders(&post_id) { for (peer, _addrs) in file_holders { if !holders.contains(&peer) { @@ -177,8 +163,9 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc, browse } } - // Fetch via content search + PostFetch - let author = author_id.unwrap_or([0u8; 32]); + // Fetch via content search + PostFetch (no author hint — holders resolved + // from file_holders / worm search) + let author: NodeId = [0u8; 32]; info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex); let search_result = tokio::time::timeout( diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index d7bc90c..d2de386 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.7.0" +version = "0.8.0-alpha" edition = "2021" [lib] diff --git a/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt b/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt index 4a5aaf0..0112936 100644 --- a/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt +++ b/crates/tauri-app/gen/android/app/src/main/java/com/itsgoin/app/NodeService.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build @@ -16,6 +17,17 @@ class NodeService : Service() { companion object { const val CHANNEL_ID = "itsgoin_node" const val NOTIFICATION_ID = 1 + + // Called via JNI from Rust when the user taps the in-app close + // button. Foreground services survive Activity exit by design + // (keeps connections alive when backgrounded). When the user + // explicitly wants to stop networking, we need to stop the + // service in addition to ending the Activity. + @JvmStatic + fun stopFromNative(context: Context) { + val intent = Intent(context, NodeService::class.java) + context.stopService(intent) + } } private var wakeLock: PowerManager.WakeLock? = null diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 6246241..a6440b5 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -6,7 +6,7 @@ use tracing::info; use itsgoin_core::identity::IdentityManager; use itsgoin_core::node::Node; -use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent}; +use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent}; /// The active Node, swappable on identity switch. type AppNode = Arc>>; @@ -145,6 +145,8 @@ struct BadgeCountsDto { unread_messages: usize, new_reacts: usize, new_comments: usize, + /// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this). + greetings: usize, } #[derive(Serialize)] @@ -279,7 +281,11 @@ async fn post_to_dto( // Engagement data let reaction_counts = { let storage = node.storage.get().await; - storage.get_reaction_counts(id, &node.node_id).unwrap_or_default() + // Reactors are posting ids — "reacted_by_me" = any of our personas. + let our_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + storage.get_reaction_counts(id, &our_ids).unwrap_or_default() .into_iter() .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me }) .collect() @@ -555,9 +561,13 @@ async fn list_posting_identities( async fn create_posting_identity( state: State<'_, AppNode>, display_name: String, + greetings_open: Option, ) -> Result { let node = get_node(&state).await; - let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?; + let id = node + .create_posting_identity(display_name, greetings_open) + .await + .map_err(|e| e.to_string())?; Ok(PostingIdentityDto { node_id: hex::encode(id.node_id), display_name: id.display_name, @@ -863,10 +873,12 @@ async fn post_to_dto_batch( // Batch queries — 3 queries total instead of 4 × N let (reaction_map, comment_map, intent_map, posting_identities) = { let storage = node.storage.get().await; - let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default(); + let identities = storage.list_posting_identities().unwrap_or_default(); + // Reactors are posting ids — "reacted_by_me" = any of our personas. + let our_ids: Vec = identities.iter().map(|p| p.node_id).collect(); + let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default(); let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default(); let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default(); - let identities = storage.list_posting_identities().unwrap_or_default(); (reactions, comments, intents, identities) }; // Map posting-id -> display-name so we can tag author=persona posts. @@ -1142,6 +1154,18 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result) -> Result, String> { let node = get_node(&state).await; @@ -1339,7 +1363,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { .into_iter() .map(|(nid, _, _)| nid) .collect(); - let (social_ids, n2_ids, n3_ids) = { + let (social_ids, n2_ids, n3_ids, n4_ids) = { let storage = node.storage.get().await; let social: std::collections::HashSet<_> = storage .list_social_routes() @@ -1348,7 +1372,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { .map(|r| r.node_id) .collect(); let n2: std::collections::HashSet<_> = storage - .build_n2_share() + .list_reach_ids_at(2) .unwrap_or_default() .into_iter() .collect(); @@ -1357,7 +1381,12 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { .unwrap_or_default() .into_iter() .collect(); - (social, n2, n3) + let n4: std::collections::HashSet<_> = storage + .list_distinct_n4() + .unwrap_or_default() + .into_iter() + .collect(); + (social, n2, n3, n4) }; let mut dtos = Vec::with_capacity(records.len()); @@ -1382,6 +1411,8 @@ async fn list_peers(state: State<'_, AppNode>) -> Result, String> { "n2" } else if n3_ids.contains(&rec.node_id) { "n3" + } else if n4_ids.contains(&rec.node_id) { + "n4" } else { "known" }; @@ -1698,6 +1729,283 @@ async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Resul storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string()) } +#[tauri::command] +async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result { + let node = get_node(&state).await; + Ok(node.network.conn_handle().is_session_relay_enabled().await) +} + +#[tauri::command] +async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> { + let node = get_node(&state).await; + { + let storage = node.storage.get().await; + storage + .set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" }) + .map_err(|e| e.to_string())?; + } + node.network.conn_handle().set_session_relay_enabled(enabled).await; + Ok(()) +} + +// --- v0.8 (A3): registry search + greetings --- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegistryEntryDto { + author_id: String, + display_name: String, + keywords: Vec, + updated_at_ms: u64, + expires_at_ms: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RegistryStatusDto { + listed: bool, + keywords: Vec, + expires_at_ms: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GreetingDto { + /// Comment key part 1 (throwaway outer identity) — opaque to the UI, + /// passed back verbatim for reply/dismiss. + comment_author: String, + /// Comment key part 2. + post_id: String, + /// Comment key part 3. + timestamp_ms: u64, + /// The sender's REAL persona (recovered from inside the seal). + sender_persona_id: String, + sender_name: String, + content: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GreetingSlotDto { + bio_post_id: String, + accepts_greetings: bool, +} + +/// Register the persona in the network registry (30d entry, auto-renews +/// while listed — the core eviction cycle re-signs ~every 25 days). +#[tauri::command] +async fn register_persona( + state: State<'_, AppNode>, + posting_id_hex: String, + name: String, + keywords: Vec, +) -> Result<(), String> { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.register_persona(&posting_id, &name, &keywords) + .await + .map_err(|e| e.to_string()) +} + +/// Remove the persona's registry entry (self-certifying signed delete). +#[tauri::command] +async fn unregister_persona( + state: State<'_, AppNode>, + posting_id_hex: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.unregister_persona(&posting_id).await.map_err(|e| e.to_string()) +} + +/// Current "Listed" state for the consent panel: listed flag + the +/// keywords used at last registration + entry expiry (0 if none held). +#[tauri::command] +async fn get_registry_status( + state: State<'_, AppNode>, + posting_id_hex: String, +) -> Result { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + let storage = node.storage.get().await; + let listed = storage + .get_setting(&format!("registry_listed.{}", posting_id_hex)) + .ok() + .flatten() + .map(|v| v == "1") + .unwrap_or(false); + let keywords = storage + .get_setting(&format!("registry_keywords.{}", posting_id_hex)) + .ok() + .flatten() + .map(|v| { + v.split(',') + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) + .collect() + }) + .unwrap_or_default(); + let expires_at_ms = storage + .get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id) + .ok() + .flatten() + .map(|(_ts, exp)| exp) + .unwrap_or(0); + Ok(RegistryStatusDto { listed, keywords, expires_at_ms }) +} + +/// Search the registry: refreshes the comment chain from peers, then +/// queries locally (search cost lands on the searcher, design §27). +#[tauri::command] +async fn search_registry( + state: State<'_, AppNode>, + query: String, +) -> Result, String> { + let node = get_node(&state).await; + let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?; + Ok(matches + .into_iter() + .map(|m| RegistryEntryDto { + author_id: hex::encode(m.author), + display_name: m.name, + keywords: m.keywords, + updated_at_ms: m.timestamp_ms, + expires_at_ms: m.expires_at_ms, + }) + .collect()) +} + +/// Locally-held greeting-slot info for an author's latest bio post. +async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option { + let storage = node.storage.get().await; + let bio_post_id = storage + .get_latest_profile_post_id_by_author(author) + .ok() + .flatten()?; + let post = storage.get_post(&bio_post_id).ok().flatten()?; + let accepts = post + .fof_gating + .as_ref() + .and_then(|g| g.open_slot.as_ref()) + .map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting) + .unwrap_or(false); + Some(GreetingSlotDto { + bio_post_id: hex::encode(bio_post_id), + accepts_greetings: accepts, + }) +} + +/// Does this author's bio declare a Greeting open slot? Drives the +/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio +/// isn't local yet (e.g. a registry search result), worm-locate the +/// author and sync their posts once before answering. +#[tauri::command] +async fn get_greeting_slot( + state: State<'_, AppNode>, + node_id_hex: String, +) -> Result, String> { + let node = get_node(&state).await; + let nid = parse_node_id(&node_id_hex)?; + if let Some(dto) = greeting_slot_local(&node, &nid).await { + return Ok(Some(dto)); + } + // Fetch-on-demand: existing worm lookup + one-shot sync, then retry. + if let Ok(Some(wr)) = node.worm_lookup(&nid).await { + let _ = node.sync_with(wr.node_id).await; + return Ok(greeting_slot_local(&node, &nid).await); + } + Ok(None) +} + +/// Send a sealed first-contact greeting to a bio post's author. +/// Messaging-first: no vouch is involved anywhere in this flow. +#[tauri::command] +async fn send_greeting( + state: State<'_, AppNode>, + bio_post_id_hex: String, + content: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let bio_post_id = hex_to_postid(&bio_post_id_hex)?; + node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string()) +} + +/// Reply to a received greeting — sealed to the greeting's fresh reply +/// key and dropped into its declared return path (never a long-term key). +#[tauri::command] +async fn reply_to_greeting( + state: State<'_, AppNode>, + comment_author_hex: String, + post_id_hex: String, + timestamp_ms: u64, + content: String, +) -> Result<(), String> { + let node = get_node(&state).await; + let comment_author = parse_node_id(&comment_author_hex)?; + let post_id = hex_to_postid(&post_id_hex)?; + node.reply_to_greeting(comment_author, post_id, timestamp_ms, content) + .await + .map_err(|e| e.to_string()) +} + +/// Unsealed greetings (and replies) across all personas' bio posts, +/// newest first. Return-path/reply-key internals stay inside core. +#[tauri::command] +async fn list_greetings(state: State<'_, AppNode>) -> Result, String> { + let node = get_node(&state).await; + let records = node.list_greetings().await.map_err(|e| e.to_string())?; + Ok(records + .into_iter() + .map(|g| GreetingDto { + comment_author: hex::encode(g.comment_author), + post_id: hex::encode(g.post_id), + timestamp_ms: g.timestamp_ms, + sender_persona_id: hex::encode(g.sender_persona), + sender_name: g.sender_name, + content: g.text, + }) + .collect()) +} + +/// Dismiss a greeting (local only — nothing propagates). +#[tauri::command] +async fn dismiss_greeting( + state: State<'_, AppNode>, + comment_author_hex: String, + post_id_hex: String, + timestamp_ms: u64, +) -> Result<(), String> { + let node = get_node(&state).await; + let comment_author = parse_node_id(&comment_author_hex)?; + let post_id = hex_to_postid(&post_id_hex)?; + node.dismiss_greeting(comment_author, post_id, timestamp_ms) + .await + .map_err(|e| e.to_string()) +} + +/// Per-persona greeting consent (republishes the bio on change). +#[tauri::command] +async fn set_greetings_open( + state: State<'_, AppNode>, + posting_id_hex: String, + open: bool, +) -> Result<(), String> { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string()) +} + +/// Current greeting-consent state (unset = ON, the pre-checked default). +#[tauri::command] +async fn get_greetings_open( + state: State<'_, AppNode>, + posting_id_hex: String, +) -> Result { + let node = get_node(&state).await; + let posting_id = parse_node_id(&posting_id_hex)?; + node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string()) +} + /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. @@ -1765,12 +2073,12 @@ async fn list_connections(state: State<'_, AppNode>) -> Result u64 { + let mut count = storage.get_comment_count(post_id).unwrap_or(0); + if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) { + if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) { + let greetings = storage + .count_unexpired_open_slot_comments(post_id, decl.slot_index) + .unwrap_or(0); + count = count.saturating_sub(greetings); + } + } + count +} + #[tauri::command] async fn get_badge_counts( state: State<'_, AppNode>, @@ -2040,11 +2370,17 @@ async fn get_badge_counts( let node = get_node(&state).await; let storage = node.storage.get().await; + // "Own post" checks below are posting-class: posts are authored by our + // posting identities (personas), never the network NodeId. + let own_ids: Vec = storage.list_posting_identities() + .unwrap_or_default() + .into_iter().map(|p| p.node_id).collect(); + // Feed badge: count non-DM posts from others newer than last_feed_view_ms let feed_posts = storage.get_feed().map_err(|e| e.to_string())?; let new_feed = feed_posts.iter() .filter(|(id, p, _vis)| { - p.author != node.node_id + !own_ids.contains(&p.author) && p.timestamp_ms > last_feed_view_ms && !matches!( storage.get_post_intent(id).ok().flatten(), @@ -2057,18 +2393,20 @@ async fn get_badge_counts( let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?; let mut new_engagement = 0usize; for (id, post, _vis) in &all_posts { - if post.author != node.node_id { continue; } + if !own_ids.contains(&post.author) { continue; } // Skip DMs if matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) ) { continue; } - let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) + let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids) .unwrap_or_default() .iter() .map(|(_, count, _)| *count) .sum(); - let total_comments = storage.get_comment_count(id).unwrap_or(0); + // Greeting open-slot comments are excluded — they have their own + // dedicated Greetings badge (see non_greeting_comment_count). + let total_comments = non_greeting_comment_count(&storage, id, post); if total_reacts > 0 || total_comments > 0 { let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 || total_comments > seen_c as u64 { @@ -2083,14 +2421,14 @@ async fn get_badge_counts( matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) - ) || (p.author != node.node_id && matches!( + ) || (!own_ids.contains(&p.author) && matches!( storage.get_post_with_visibility(id).ok().flatten(), Some((_, PostVisibility::Encrypted { .. })) )) }); let mut seen_partners = std::collections::HashSet::new(); for (_id, post, _vis) in dm_posts { - let partner = if post.author == node.node_id { + let partner = if own_ids.contains(&post.author) { // sent DM — skip for unread count continue; } else { @@ -2108,16 +2446,22 @@ async fn get_badge_counts( let mut new_reacts = 0usize; let mut new_comments = 0usize; for (id, post, _vis) in &all_posts { - if post.author != node.node_id { continue; } - let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) + if !own_ids.contains(&post.author) { continue; } + let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids) .unwrap_or_default().iter().map(|(_, c, _)| *c).sum(); - let total_comments = storage.get_comment_count(id).unwrap_or(0); + // Greeting open-slot comments excluded here too (dedicated badge). + let total_comments = non_greeting_comment_count(&storage, id, post); let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; } if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; } } - Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments }) + // v0.8 (A3): greetings inbox size. Release the storage slot first — + // list_greetings takes its own guard internally. + drop(storage); + let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0); + + Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings }) } #[tauri::command] @@ -2156,12 +2500,13 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct NetworkSummaryDto { - preferred_count: usize, - local_count: usize, - wide_count: usize, + mesh_count: usize, + temp_count: usize, + mesh_cap: usize, total_connections: usize, n2_distinct: usize, n3_distinct: usize, + n4_distinct: usize, has_public_v6: bool, has_public_v4: bool, has_upnp: bool, @@ -2171,29 +2516,24 @@ struct NetworkSummaryDto { async fn get_network_summary(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let conns = node.list_connections().await; - let mut preferred = 0usize; - let mut local = 0usize; - let mut wide = 0usize; - for (_nid, slot_kind, _at) in &conns { - match slot_kind { - PeerSlotKind::Preferred => preferred += 1, - PeerSlotKind::Local => local += 1, - PeerSlotKind::Wide => wide += 1, - } - } - let (n2, n3) = { + let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count(); + let temp = conns.len() - mesh; + let (n2, n3, n4) = { let storage = node.storage.get().await; - let n2 = storage.count_distinct_n2().unwrap_or(0); - let n3 = storage.count_distinct_n3().unwrap_or(0); - (n2, n3) + ( + storage.count_distinct_n2().unwrap_or(0), + storage.count_distinct_n3().unwrap_or(0), + storage.count_distinct_n4().unwrap_or(0), + ) }; Ok(NetworkSummaryDto { - preferred_count: preferred, - local_count: local, - wide_count: wide, + mesh_count: mesh, + temp_count: temp, + mesh_cap: node.device_profile().mesh_slots(), total_connections: conns.len(), n2_distinct: n2, n3_distinct: n3, + n4_distinct: n4, has_public_v6: node.network.has_public_v6(), has_public_v4: node.network.is_anchor(), has_upnp: node.network.has_upnp(), @@ -2415,15 +2755,14 @@ struct ActivityLogDto { events: Vec, rebalance_last_ms: u64, rebalance_interval_secs: u64, - anchor_register_last_ms: u64, - anchor_register_interval_secs: u64, + convection_last_ms: u64, } #[tauri::command] async fn get_activity_log(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let events = node.get_activity_log(200); - let (rebalance_last, anchor_last) = node.timer_state(); + let (rebalance_last, convection_last) = node.timer_state(); let dto_events: Vec = events.into_iter().map(|e| { ActivityEventDto { timestamp_ms: e.timestamp_ms, @@ -2437,8 +2776,7 @@ async fn get_activity_log(state: State<'_, AppNode>) -> Result) -> Result async fn request_referrals(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let node_id = node.node_id; - // Try known_anchors table first (populated by anchor register cycle), - // fall back to anchor peers from the peers table (is_anchor = true) + let mesh = node.network.conn_handle().mesh_count().await; + // Manual trigger uses the same class rule as the automatic path: below 2 + // mesh peers this is an ENTRY request and is always served. + let class = itsgoin_core::protocol::ConvectionClass::for_mesh_count(mesh); + + // Pool-mined anchors first (the uniques pools ARE the anchor directory), + // then the known_anchors bootstrap cache, then anchor-flagged peers. let anchors: Vec<(NodeId, Vec)> = { let storage = node.storage.get().await; - let known = storage.list_known_anchors().unwrap_or_default(); - if !known.is_empty() { - known - } else { - storage.list_anchor_peers().unwrap_or_default() - .into_iter() - .map(|r| (r.node_id, r.addresses)) - .collect() + let mut out: Vec<(NodeId, Vec)> = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() { + let socks: Vec = addrs.iter().filter_map(|a| a.parse().ok()).collect(); + if !socks.is_empty() && seen.insert(nid) { + out.push((nid, socks)); + } } + for (nid, addrs) in storage.list_known_anchors().unwrap_or_default() { + if seen.insert(nid) { + out.push((nid, addrs)); + } + } + for r in storage.list_anchor_peers().unwrap_or_default() { + if seen.insert(r.node_id) { + out.push((r.node_id, r.addresses)); + } + } + out }; if anchors.is_empty() { return Ok("No known anchors".to_string()); } - let mut total = 0usize; - let mut reachable = 0usize; + + let mut connected = 0usize; + let mut asked = 0usize; + let mut refused = 0usize; for (anchor_nid, anchor_addrs) in &anchors { if *anchor_nid == node_id { continue; } - // Connect to anchor if not already connected - if !node.network.is_peer_connected(anchor_nid).await { + if !node.network.is_peer_connected_or_session(anchor_nid).await { let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) { Ok(eid) => eid, Err(_) => continue, @@ -2487,40 +2841,26 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result for sa in anchor_addrs { addr = addr.with_ip_addr(*sa); } - if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await { + if node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() { continue; } } - match node.network.request_anchor_referrals(anchor_nid).await { - Ok(referrals) => { - reachable += 1; - for referral in &referrals { - if referral.node_id == node_id { - continue; - } - if let Some(addr_str) = referral.addresses.first() { - let connect_str = format!( - "{}@{}", - hex::encode(referral.node_id), - addr_str, - ); - if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) { - match node.network.connect_to_peer(rid, raddr).await { - Ok(()) => {} - Err(_) => { - // Direct connect failed (NAT) — try hole punch via anchor - let _ = node.network.connect_via_introduction(rid, *anchor_nid).await; - } - } - total += 1; - } - } + match node.network.request_convection(anchor_nid, class).await { + Ok(response) => { + asked += 1; + if response.refused { + refused += 1; + } else { + connected += node.network.act_on_convection(anchor_nid, &response).await; } } Err(_) => {} } } - Ok(format!("Got {} referrals from {} anchors", total, reachable)) + Ok(format!( + "Convection: {} new peers from {} anchors ({} refused)", + connected, asked, refused + )) } #[tauri::command] @@ -2864,14 +3204,13 @@ async fn switch_identity( // Start background tasks on the new node new_node.start_accept_loop(); - new_node.start_pull_cycle(300); + new_node.start_sync_cycle(); new_node.start_diff_cycle(120); new_node.start_rebalance_cycle(600); new_node.start_growth_loop(); new_node.start_recovery_loop(); new_node.start_social_checkin_cycle(3600); - new_node.start_anchor_register_cycle(600); - new_node.start_upnp_renewal_cycle(); + new_node.start_convection_loop(); new_node.start_upnp_tcp_renewal_cycle(); new_node.start_http_server(); new_node.start_bootstrap_connectivity_check(); @@ -2963,6 +3302,8 @@ async fn export_data( } } }; + // node_id here is only used for the export file name; the post filter + // inside export_data now iterates all posting identities itself. let result = itsgoin_core::export::export_data( &node.data_dir, &node.storage, @@ -2987,14 +3328,13 @@ async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> { node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed); // Start the sync tasks that were skipped during bootstrap node.start_accept_loop(); - node.start_pull_cycle(300); + node.start_sync_cycle(); node.start_diff_cycle(120); node.start_rebalance_cycle(600); node.start_growth_loop(); node.start_recovery_loop(); node.start_social_checkin_cycle(3600); - node.start_anchor_register_cycle(600); - node.start_upnp_renewal_cycle(); + node.start_convection_loop(); node.start_upnp_tcp_renewal_cycle(); node.start_http_server(); node.start_bootstrap_connectivity_check(); @@ -3052,11 +3392,13 @@ async fn import_public_posts( zip_path: String, ) -> Result { let node = get_node(&state).await; + // Imported posts are re-authored as US — that must be a POSTING identity + // (the network NodeId never authors posts). let result = itsgoin_core::import::import_public_posts( std::path::Path::new(&zip_path), &node.storage, &node.blob_store, - &node.node_id, + &node.default_posting_id, ).await.map_err(|e| e.to_string())?; Ok(result.message) } @@ -3110,12 +3452,14 @@ async fn import_merge_with_key( key_hex: String, ) -> Result { let node = get_node(&state).await; + // Merged content is authored under a POSTING identity, never the + // network NodeId. let result = itsgoin_core::import::merge_with_key( std::path::Path::new(&zip_path), &key_hex, &node.storage, &node.blob_store, - &node.node_id, + &node.default_posting_id, &node.secret_seed(), ).await.map_err(|e| e.to_string())?; Ok(result.message) @@ -3232,14 +3576,13 @@ pub fn run() { // Start all background networking tasks boot_node.start_accept_loop(); - boot_node.start_pull_cycle(300); + boot_node.start_sync_cycle(); boot_node.start_diff_cycle(120); boot_node.start_rebalance_cycle(600); boot_node.start_growth_loop(); boot_node.start_recovery_loop(); boot_node.start_social_checkin_cycle(3600); - boot_node.start_anchor_register_cycle(600); - boot_node.start_upnp_renewal_cycle(); + boot_node.start_convection_loop(); boot_node.start_upnp_tcp_renewal_cycle(); boot_node.start_http_server(); boot_node.start_bootstrap_connectivity_check(); @@ -3380,6 +3723,20 @@ pub fn run() { import_as_new_identity, import_as_personas_cmd, import_merge_with_key, + exit_app, + get_session_relay_enabled, + set_session_relay_enabled, + register_persona, + unregister_persona, + get_registry_status, + search_registry, + get_greeting_slot, + send_greeting, + reply_to_greeting, + list_greetings, + dismiss_greeting, + set_greetings_open, + get_greetings_open, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index f3aaa93..932a95d 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.7.0", + "version": "0.8.0-alpha", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/deploy.sh b/deploy.sh index c780395..17f632d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -18,26 +18,34 @@ SSH_OPTS="-o StrictHostKeyChecking=no" KEYSTORE="itsgoin.keystore" KS_ALIAS="itsgoin" -VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/') -echo "=== Deploying v${VERSION} ===" +# Captures the FULL version string including any pre-release suffix +# (e.g. 0.8.0-alpha). The old digits-and-dots-only pattern silently +# truncated at the dash, so every ${VERSION} filename below missed the +# artifacts Tauri had actually produced. +VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') +# Pre-release versions announce on their own channel so stable clients +# are not told an alpha is "the" current release. +case "$VERSION" in + *-*) ANN_CHANNEL="${VERSION#*-}" ;; + *) ANN_CHANNEL="stable" ;; +esac -# Build CLI -echo "=== Building CLI ===" -cargo build -p itsgoin-cli --release & -CLI_PID=$! +echo "=== Deploying v${VERSION} (announce channel: ${ANN_CHANNEL}) ===" -# Build APK -echo "=== Building APK ===" -cargo tauri android build --apk & -APK_PID=$! +# Builds run SERIALLY — parallel cargo invocations write to the same +# target/ directory, which causes intermittent failures (linuxdeploy +# blowing up mid-AppImage was the v0.7.0 release symptom). The extra +# wall time vs. the parallel version is small because cargo's +# incremental cache deduplicates the shared core crate compilation. -# Build AppImage (includes GStreamer patch) -echo "=== Building AppImage ===" +echo "=== Building AppImage (includes GStreamer patch) ===" ./build-appimage.sh -wait $CLI_PID -echo "=== CLI build complete ===" -wait $APK_PID -echo "=== APK build complete ===" + +echo "=== Building APK ===" +cargo tauri android build --apk + +echo "=== Building CLI ===" +cargo build -p itsgoin-cli --release # Sign APK echo "=== Signing APK ===" @@ -79,10 +87,11 @@ sshpass -p "$SSH_PASS" ssh $SSH_OPTS "$SSH_HOST" " kill \$(cat ~/itsgoin-anchor.pid 2>/dev/null) 2>/dev/null sleep 1 mv ~/bin/itsgoin.new ~/bin/itsgoin && chmod +x ~/bin/itsgoin + ~/bin/itsgoin ~/itsgoin-anchor-data --publish-registry 2>&1 | tail -3 ~/bin/itsgoin ~/itsgoin-anchor-data \ --announce \ --ann-category release \ - --ann-channel stable \ + --ann-channel ${ANN_CHANNEL} \ --ann-version ${VERSION} \ --ann-url https://itsgoin.com/download.html \ --ann-title 'ItsGoin v${VERSION} available' \ diff --git a/frontend/app.js b/frontend/app.js index 1840b8e..88a74ca 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -449,6 +449,12 @@ $('#popover-overlay').addEventListener('click', (e) => { if (e.target === $('#popover-overlay')) closePopover(); }); +$('#close-app-btn').addEventListener('click', async () => { + if (confirm('Close ItsGoin?\n\nStops all network connections to save battery. Reopen the app any time to resume.')) { + try { await invoke('exit_app'); } catch (_) {} + } +}); + function relativeTime(timestampMs) { const now = Date.now(); const diff = now - timestampMs; @@ -1084,7 +1090,117 @@ function setupMyPostsMediaObserver() { mutObs.observe(myPostsList, { childList: true }); } +// v0.8 (A3): greetings inbox. Rows offer [Reply] (primary) and [Dismiss] +// ONLY — vouching is People-tab relationship management and must never be +// reachable from a Messages surface (round 9). The sender's name opens +// the ordinary bio modal, where the existing Friend flow lives. +let _greetingsCount = 0; +let _lastDmUnread = 0; +async function loadGreetings() { + const container = document.getElementById('greetings-list'); + if (!container) return; + try { + const greetings = await invoke('list_greetings'); + _greetingsCount = greetings.length; + + // Notify once per greeting (localStorage-backed seen set, tag + // prefix `greet-` mirroring `msg-`). + try { + let notified = []; + try { notified = JSON.parse(localStorage.getItem('greetNotified') || '[]'); } catch (_) {} + const notifSet = new Set(notified); + const keys = []; + for (const g of greetings) { + const key = `greet-${g.commentAuthor}-${g.timestampMs}`; + keys.push(key); + if (_notifReady && !notifSet.has(key)) { + const who = g.senderName || g.senderPersonaId.slice(0, 8); + maybeNotify(`Greeting from ${who}`, (g.content || '').slice(0, 100), key); + } + } + // Keep only keys still in the inbox (dismissed/expired drop out). + localStorage.setItem('greetNotified', JSON.stringify(keys)); + } catch (_) {} + + if (greetings.length === 0) { + container.innerHTML = `

No greetings yet

`; + } else { + container.innerHTML = greetings.map(g => { + const icon = generateIdenticon(g.senderPersonaId, 18); + const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12)); + return `
+
${icon} ${label} ${formatTimeAgo(g.timestampMs)}
+
${escapeHtml(g.content)}
+
+ + +
+ +
`; + }).join(''); + + container.querySelectorAll('.greet-bio-link').forEach(el => { + el.addEventListener('click', (e) => { + e.preventDefault(); + openBioModal(el.dataset.nodeId, el.dataset.name); + }); + }); + container.querySelectorAll('.greet-reply-btn').forEach(btn => { + btn.addEventListener('click', () => { + const box = btn.closest('.greeting-item').querySelector('.greet-reply-box'); + box.classList.toggle('hidden'); + if (!box.classList.contains('hidden')) box.querySelector('textarea').focus(); + }); + }); + container.querySelectorAll('.greet-reply-send').forEach(btn => { + btn.addEventListener('click', async () => { + const item = btn.closest('.greeting-item'); + const input = item.querySelector('.greet-reply-box textarea'); + const content = input.value.trim(); + if (!content) return; + btn.disabled = true; + try { + await invoke('reply_to_greeting', { + commentAuthorHex: item.dataset.commentAuthor, + postIdHex: item.dataset.postId, + timestampMs: Number(item.dataset.ts), + content, + }); + toast('Reply sent (sealed)'); + input.value = ''; + item.querySelector('.greet-reply-box').classList.add('hidden'); + } catch (e) { toast('Error: ' + e); } + finally { btn.disabled = false; } + }); + }); + container.querySelectorAll('.greet-dismiss-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const item = btn.closest('.greeting-item'); + btn.disabled = true; + try { + await invoke('dismiss_greeting', { + commentAuthorHex: item.dataset.commentAuthor, + postIdHex: item.dataset.postId, + timestampMs: Number(item.dataset.ts), + }); + loadGreetings(); + } catch (e) { toast('Error: ' + e); btn.disabled = false; } + }); + }); + } + updateTabBadge('messages', _lastDmUnread + _greetingsCount); + } catch (e) { + container.innerHTML = `

Error: ${e}

`; + } +} + async function loadMessages(force) { + // v0.8 (A3): refresh the greetings inbox alongside DMs (own IPC call, + // fire-and-forget — it updates the shared Messages badge itself). + loadGreetings().catch(() => {}); try { const [posts, follows] = await Promise.all([ invoke('get_all_posts'), @@ -1330,7 +1446,9 @@ async function loadMessages(force) { const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs); if (hasUnread) unreadCount++; } - updateTabBadge('messages', unreadCount); + // v0.8 (A3): the Messages badge = unread DMs + undismissed greetings. + _lastDmUnread = unreadCount; + updateTabBadge('messages', unreadCount + _greetingsCount); } catch (e) { conversationsList.innerHTML = `

Error: ${e}

`; } @@ -1432,6 +1550,7 @@ async function loadPeers() { else if (p.reach === 'n1') reachBadge = 'N1'; else if (p.reach === 'n2') reachBadge = 'N2'; else if (p.reach === 'n3') reachBadge = 'N3'; + else if (p.reach === 'n4') reachBadge = 'N4'; let actions = ''; if (p.nodeId === myNodeId) { @@ -1663,7 +1782,7 @@ async function openBioModal(nodeId, preloadedName) { overlay.classList.remove('hidden'); try { - // `resolve_display` returns {name, bio, avatarCid} for any NodeId. + // `resolve_display` returns {displayName, bio, avatarCid} for any NodeId. const resolved = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); const follows = await invoke('list_follows').catch(() => []); const ignored = await invoke('list_ignored_peers').catch(() => []); @@ -1671,7 +1790,7 @@ async function openBioModal(nodeId, preloadedName) { const following = follows.some(f => f.nodeId === nodeId); const isIgnored = ignored.some(i => i.nodeId === nodeId); const isVouched = vouches.some(v => v.nodeId === nodeId); - const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12); + const name = (resolved && resolved.displayName) || preloadedName || nodeId.slice(0, 12); const bio = (resolved && resolved.bio) || ''; const icon = generateIdenticon(nodeId, 48); @@ -1680,19 +1799,20 @@ async function openBioModal(nodeId, preloadedName) {
${icon}
-
${escapeHtml(name)}
+
${escapeHtml(name)}
${nodeId}
- ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} -
+ ${bio ? `

${escapeHtml(bio)}

` : '

No bio.

'} +
- ${following - ? `` - : ``} - ${isVouched - ? `` - : ``} + ${(following && isVouched) + ? `` + : (following + ? ` + ` + : ` + `)} ${isIgnored ? `` @@ -1749,22 +1869,119 @@ async function openBioModal(nodeId, preloadedName) { } catch (e) { toast('Error: ' + e); } finally { vouch.disabled = false; } }; - const revokeVouch = document.getElementById('bio-revoke-vouch'); - if (revokeVouch) revokeVouch.onclick = async () => { - if (!confirm(`Revoke vouch for ${name}? This rotates your vouch key — they keep access to existing posts but not future ones.`)) return; - revokeVouch.disabled = true; + // Friend = follow + vouch in one click. Default action per v0.7.x UX. + const friend = document.getElementById('bio-friend'); + if (friend) friend.onclick = async () => { + friend.disabled = true; + try { + await invoke('follow_node', { nodeIdHex: nodeId }); + await invoke('vouch_for_peer', { nodeIdHex: nodeId }); + toast(`Friended ${name}`); + close(); + loadFollows(); + loadFeed(true); + } catch (e) { toast('Error: ' + e); } + finally { friend.disabled = false; } + }; + // Unfriend = revoke vouch + unfollow. Rotation cost is real; confirm. + const unfriend = document.getElementById('bio-unfriend'); + if (unfriend) unfriend.onclick = async () => { + if (!confirm(`Unfriend ${name}? This revokes your vouch (rotates your vouch key — they keep access to existing posts but not future ones) AND unfollows them.`)) return; + unfriend.disabled = true; try { await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId }); - toast('Revoked and rotated'); + await invoke('unfollow_node', { nodeIdHex: nodeId }); + toast(`Unfriended ${name}`); close(); + loadFollows(); + loadFeed(true); } catch (e) { toast('Error: ' + e); } - finally { revokeVouch.disabled = false; } + finally { unfriend.disabled = false; } }; + + // v0.8 (A3): greeting affordance for strangers only (no existing + // relationship). The slot check may fetch the bio on-demand, so + // the button arrives asynchronously. Established peers keep the + // ordinary Message button. + if (!following && !isVouched) { + invoke('get_greeting_slot', { nodeIdHex: nodeId }).then(async slot => { + const row = document.getElementById('bio-actions'); + if (!row || !bodyEl.contains(row)) return; // modal replaced/closed + // The slot check may have pulled the author's posts on-demand + // (registry search results start non-local) — if the first + // resolve came back empty, patch name/bio in place now. + if (!resolved || (!resolved.displayName && !resolved.bio)) { + const again = await invoke('resolve_display', { nodeIdHex: nodeId }).catch(() => null); + if (again && bodyEl.contains(row)) { + if (again.displayName) { + titleEl.textContent = again.displayName; + const nm = document.getElementById('bio-modal-name'); + if (nm) nm.textContent = again.displayName; + } + if (again.bio) { + const bp = document.getElementById('bio-modal-bio'); + if (bp) { + bp.textContent = again.bio; + bp.className = ''; + bp.style.cssText = 'font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem'; + } + } + } + } + if (slot && slot.acceptsGreetings) { + const hi = document.createElement('button'); + hi.id = 'bio-say-hi'; + hi.className = 'btn btn-primary btn-sm'; + hi.textContent = 'Say hi'; + row.prepend(hi); + hi.onclick = () => openGreetingCompose(slot.bioPostId, titleEl.textContent || name); + } else { + const na = document.createElement('button'); + na.className = 'btn btn-ghost btn-sm'; + na.disabled = true; + na.textContent = 'Not accepting greetings'; + row.appendChild(na); + } + }).catch(() => {}); + } } catch (e) { bodyEl.innerHTML = `

Error: ${e}

`; } } +// v0.8 (A3): greeting compose — swaps the bio modal body for a sealed +// one-shot compose aimed at the bio post's Greeting open slot. +function openGreetingCompose(bioPostIdHex, name) { + const titleEl = document.getElementById('popover-title'); + const bodyEl = document.getElementById('popover-body'); + if (!titleEl || !bodyEl) return; + titleEl.textContent = `Say hi to ${name}`; + bodyEl.innerHTML = ` + +
+ Sealed — only ${escapeHtml(name)} can read this + +
`; + const input = document.getElementById('greeting-text'); + setTimeout(() => input.focus(), 100); + const sendBtn = document.getElementById('greeting-send-btn'); + const send = async () => { + const content = input.value.trim(); + if (!content) return; + sendBtn.disabled = true; + try { + await invoke('send_greeting', { bioPostIdHex, content }); + toast('Greeting sent'); + closePopover(); + } catch (e) { + toast('Error: ' + e); + sendBtn.disabled = false; + } + }; + sendBtn.addEventListener('click', send); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.ctrlKey) send(); }); +} + function openAuthorFeed(nodeId, name) { authorFilterNodeId = nodeId; authorFilterName = name || nodeId.slice(0, 12); @@ -1862,11 +2079,11 @@ async function loadNetworkSummary() { const s = await invoke('get_network_summary'); networkSummaryEl.innerHTML = `
${s.totalConnections}Connections
-
${s.preferredCount}Preferred
-
${s.localCount}Mesh
-
${s.wideCount}Non-mesh N1
+
${s.meshCount}/${s.meshCap}Mesh
+
${s.tempCount}Temp referral
${s.n2Distinct}N2 Reach
${s.n3Distinct}N3 Reach
+
${s.n4Distinct}N4 Reach
`; } catch (e) { networkSummaryEl.innerHTML = `

Could not load network summary

`; @@ -1883,10 +2100,9 @@ async function loadConnections() { connectionsList.innerHTML = conns.map(c => { const label = escapeHtml(peerLabel(c.nodeId, c.displayName)); const icon = generateIdenticon(c.nodeId, 18); - const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred' - : c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local'; - const slotLabel = c.slotKind === 'Local' ? 'Mesh' - : c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind; + const slotClass = c.slotKind === 'temp' ? 'slot-temp' : 'slot-mesh'; + const slotLabel = c.slotKind === 'mesh' ? 'Mesh' + : c.slotKind === 'temp' ? 'Temp referral' : c.slotKind; const duration = c.connectedAt ? relativeTime(c.connectedAt) : ''; return `
${icon} ${label} ${slotLabel}
@@ -1978,7 +2194,13 @@ function renderTimer(label, lastMs, intervalSecs, now) { } function escapeHtml(str) { - return str.replace(/&/g, '&').replace(//g, '>'); + // Quotes MUST be escaped too: escaped output is interpolated into + // double-quoted HTML attributes (data-name="..."), and registry + // entries / greeting sender names are attacker-controlled — a name + // like `x" onclick="..."` would otherwise break out of the attribute + // and inject an inline handler (stored XSS). + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); } function formatTimeAgo(timestampMs) { @@ -2643,7 +2865,7 @@ async function doPost() { try { const vis = visibilitySelect.value; const params = { content: content || '' }; - if (vis !== 'public') { + if (vis !== 'public' && vis !== 'fof_closed') { params.visibility = vis; } if (vis === 'circle') { @@ -2667,28 +2889,11 @@ async function doPost() { const reactPerm = document.getElementById('react-perm-select').value; let result; - if (commentPerm === 'friends_of_friends') { - // FoF Layer 2: body is still public (Mode 2) but the post - // carries a fof_gating block built from the author's - // keyring. Routed through a dedicated command because the - // gating block is signed at publish time (can't be added - // via SetPolicy after the fact). + if (vis === 'fof_closed') { + // Visibility = Extended Friends (FoF). Body + comments are + // encrypted under the FoF gating CEK. Mode 1. if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoF posts with attachments or non-default persona not yet supported.'); - postBtn.disabled = false; - return; - } - const created = await invoke('create_post_with_fof_comments', { - content: params.content, - }); - result = { id: created.postId }; - } else if (commentPerm === 'fof_closed') { - // FoF Layer 3 / Mode 1: body itself encrypted under the - // gating CEK. Non-FoF observers see only ciphertext; - // FoF readers unlock + decrypt on render via - // read_fof_closed_body. - if (selectedFiles.length > 0 || params.postingIdHex) { - toast('FoFClosed posts with attachments or non-default persona not yet supported.'); + toast('FoF (Extended Friends) posts with attachments or non-default persona not yet supported.'); postBtn.disabled = false; return; } @@ -2696,6 +2901,17 @@ async function doPost() { content: params.content, }); result = { id: created.postId }; + } else if (vis === 'public' && commentPerm === 'friends_of_friends') { + // Public body, FoF-gated comments. Mode 2. + if (selectedFiles.length > 0 || params.postingIdHex) { + toast('FoF-comment posts with attachments or non-default persona not yet supported.'); + postBtn.disabled = false; + return; + } + const created = await invoke('create_post_with_fof_comments', { + content: params.content, + }); + result = { id: created.postId }; } else if (selectedFiles.length > 0) { // Convert ArrayBuffers to base64 strings const files = selectedFiles.map(f => { @@ -2728,7 +2944,7 @@ async function doPost() { selectedFiles = []; renderAttachmentPreview(); updateCharCount(); - visibilitySelect.value = 'public'; + visibilitySelect.value = 'fof_closed'; updateVisibilityUI(); toast('Posted!'); loadFeed(true); @@ -2808,11 +3024,41 @@ async function doSyncAll() { } } +// v0.8 (A3): default persona id (hex) — the persona the Profiles lightbox +// and first-run consent act on. Falls back to the first-run auto persona. +async function getDefaultPersonaId() { + try { + const ids = await invoke('list_posting_identities'); + const def = ids.find(p => p.isDefault) || ids[0]; + if (def) return def.nodeId; + } catch (_) {} + try { + const auto = await invoke('get_first_run_auto_persona_id'); + if (auto) return auto; + } catch (_) {} + return null; +} + async function doSetupName() { // Name is optional — users who want to stay anonymous can proceed with a blank field. const name = setupName.value.trim(); setupBtn.disabled = true; try { + // v0.8 (A3): active choice at first publish — if the pre-checked + // "Accept greetings" box was unticked, record the opt-out BEFORE + // the first bio publish so no greeting slot ever ships (the + // republish inside set_display_name reads this setting). + const greetCheck = document.getElementById('setup-greetings-check'); + if (greetCheck && !greetCheck.checked) { + // The opt-out write is LOAD-BEARING (round 8: an explicit + // opt-out must never silently fail and ship the slot anyway). + // Any failure here aborts the publish; the overlay stays up. + const personaId = await getDefaultPersonaId(); + if (!personaId) { + throw new Error('could not resolve your persona to record the greeting opt-out — please try again'); + } + await invoke('set_setting', { key: 'greetings_open.' + personaId, value: '0' }); + } await invoke('set_display_name', { name }); setupOverlay.classList.add('hidden'); toast(name ? 'Welcome, ' + name + '!' : 'Welcome!'); @@ -2982,6 +3228,11 @@ async function loadCircleProfiles() { function updateVisibilityUI() { const vis = visibilitySelect.value; circleSelect.classList.toggle('hidden', vis !== 'circle'); + // Hide the comment-permission picker for FoF (Extended Friends) — the + // visibility already implies comments-restricted-to-FoF. Show it + // again when audience is public / friends / circle. + const commentPerm = document.getElementById('comment-perm-select'); + if (commentPerm) commentPerm.classList.toggle('hidden', vis === 'fof_closed'); } async function loadCircleOptions() { @@ -2999,6 +3250,9 @@ visibilitySelect.addEventListener('change', () => { updateVisibilityUI(); if (visibilitySelect.value === 'circle') loadCircleOptions(); }); +// Run once on load so the comment-perm picker is hidden for the +// default FoF visibility (matches the dropdown's `selected` option). +updateVisibilityUI(); // --- Circles management --- async function loadCircles() { @@ -3153,6 +3407,7 @@ document.querySelectorAll('.tab').forEach(tab => { if (!conversationsList.children.length) conversationsList.innerHTML = renderLoading(); loadMessages(true); loadDmRecipientOptions(); clearNotifications('msg-'); + clearNotifications('greet-'); } if (target === 'settings') { loadIdentities(); loadPersonas(); loadRedundancy(); loadPublicVisible(); @@ -3189,6 +3444,16 @@ $('#profile-lightbox-btn').addEventListener('click', () => { Show my profile to non-circle peers + + +
Not listed
+
@@ -3201,14 +3466,72 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
`; document.body.appendChild(overlay); - overlay.querySelector('#lb-profile-save').addEventListener('click', async () => { + // v0.8 (A3): prefill the consent panel (registry listing + greeting + // consent) for the default persona. Status line mirrors auto-renew. + // The Save button stays DISABLED until the prefill settles: a listed + // user clicking Save against the unfilled (unchecked) checkbox would + // otherwise be treated as never-listed — their unlist intent dropped + // and auto-renew silently continuing. + let _lbWasListed = false; + const _lbSaveBtn = overlay.querySelector('#lb-profile-save'); + _lbSaveBtn.disabled = true; + (async () => { + try { + const personaId = await getDefaultPersonaId(); + if (!personaId || !document.body.contains(overlay)) return; + try { + const status = await invoke('get_registry_status', { postingIdHex: personaId }); + _lbWasListed = !!status.listed; + const listedCheck = overlay.querySelector('#lb-registry-listed'); + const kwInput = overlay.querySelector('#lb-registry-keywords'); + const statusEl = overlay.querySelector('#lb-registry-status'); + if (listedCheck) listedCheck.checked = _lbWasListed; + if (kwInput && status.keywords.length) kwInput.value = status.keywords.join(', '); + if (statusEl) statusEl.textContent = _lbWasListed + ? 'Listed — auto-renews every 30 days while checked' + : 'Not listed'; + } catch (_) {} + try { + const open = await invoke('get_greetings_open', { postingIdHex: personaId }); + const greetCheck = overlay.querySelector('#lb-greetings-open'); + if (greetCheck) greetCheck.checked = !!open; + } catch (_) {} + } finally { + _lbSaveBtn.disabled = false; + } + })(); + _lbSaveBtn.addEventListener('click', async () => { const name = overlay.querySelector('#lb-profile-name').value.trim(); const bio = overlay.querySelector('#lb-profile-bio').value.trim(); if (!name) { toast('Display name is required'); return; } try { + const personaId = await getDefaultPersonaId(); + // v0.8 (A3): write greeting consent BEFORE set_profile so the + // single bio republish below carries the right slot state + // (avoids a second republish via set_greetings_open). The + // write is LOAD-BEARING (round 8): if it fails, abort the + // publish rather than republishing with the wrong slot state. + const greetOpen = overlay.querySelector('#lb-greetings-open').checked; + if (!personaId) { + throw new Error('could not resolve your persona — profile not saved, please try again'); + } + await invoke('set_setting', { key: 'greetings_open.' + personaId, value: greetOpen ? '1' : '0' }); await invoke('set_profile', { name, bio }); const visible = overlay.querySelector('#lb-public-visible').checked; await invoke('set_public_visible', { visible }); + // v0.8 (A3): registry listing. Checked = (re-)register — the + // core renews automatically every ~25 days while listed. + // Unchecking sends the self-certifying signed delete. + if (personaId) { + const listed = overlay.querySelector('#lb-registry-listed').checked; + const keywords = overlay.querySelector('#lb-registry-keywords').value + .split(',').map(k => k.trim()).filter(k => k).slice(0, 8); + if (listed) { + await invoke('register_persona', { postingIdHex: personaId, name, keywords }); + } else if (_lbWasListed) { + await invoke('unregister_persona', { postingIdHex: personaId }); + } + } // Sync back to settings fields profileNameInput.value = name; profileBioInput.value = bio; @@ -3270,6 +3593,58 @@ $('#discover-toggle').addEventListener('click', () => { if (!body.classList.contains('hidden')) loadDiscoverPeople(); }); +// v0.8 (A3): registry search — pulls the registry comment chain from +// peers, then queries locally. Results show name/keywords only; the bio +// is fetched on-demand when a result is clicked (openBioModal). +async function runRegistrySearch() { + const input = $('#registry-search-input'); + const results = $('#registry-results'); + const btn = $('#registry-search-btn'); + const query = input.value.trim(); + if (!query) { results.innerHTML = ''; return; } + btn.disabled = true; + results.innerHTML = renderLoading(); + try { + const entries = await invoke('search_registry', { query }); + if (!entries || entries.length === 0) { + results.innerHTML = renderEmptyState( + 'No registry matches', + 'Entries expire after 30 days — try different keywords.' + ); + return; + } + results.innerHTML = `

Registry results

` + entries.map(e => { + const icon = generateIdenticon(e.authorId, 18); + const label = escapeHtml(e.displayName); + const kwLine = e.keywords && e.keywords.length + ? `
${e.keywords.map(k => escapeHtml(k)).join(' · ')}
` + : ''; + const ageLine = e.updatedAtMs + ? `
Listed ${formatTimeAgo(e.updatedAtMs)}
` + : ''; + return `
+
${icon} ${label}
+ ${kwLine} + ${ageLine} +
`; + }).join(''); + results.querySelectorAll('.bio-link').forEach(el => { + el.addEventListener('click', (ev) => { + ev.preventDefault(); + openBioModal(el.dataset.nodeId, el.dataset.name); + }); + }); + } catch (e) { + results.innerHTML = `

Error: ${e}

`; + } finally { + btn.disabled = false; + } +} +$('#registry-search-btn').addEventListener('click', runRegistrySearch); +$('#registry-search-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') runRegistrySearch(); +}); + // "See new activity" button in Following: applies staged data and // re-renders so the user picks when to rearrange the list. { @@ -3305,7 +3680,7 @@ function openDiagnostics() {
- +
@@ -3441,11 +3816,11 @@ function openDiagnostics() { try { const r = await invoke('request_referrals'); btn.textContent = r; - setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000); + setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000); loadAllDiagnostics(); } catch (e) { btn.textContent = 'Failed'; - setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000); + setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000); } }); loadAllDiagnostics(); @@ -3522,11 +3897,11 @@ if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => { const key = await invoke('export_identity'); try { await navigator.clipboard.writeText(key); - toast('Identity key copied to clipboard. KEEP IT SECRET!'); + toast('Device address key copied to clipboard. KEEP IT SECRET!'); } catch (clipErr) { // Clipboard API may fail in some webview contexts — show the key instead console.error('Clipboard write failed:', clipErr); - prompt('Copy your identity key (KEEP IT SECRET!):', key); + prompt('Copy your device address key (KEEP IT SECRET!):', key); } } catch (e) { console.error('export_identity failed:', e); @@ -3565,14 +3940,16 @@ document.querySelectorAll('.text-size-opt').forEach(btn => { }); }); -// --- Identity management --- +// --- Device address management (formerly "Identity") --- +// The underlying Tauri commands keep their `identity` names for now — +// only the user-facing labels rename. Backend rename can follow. async function loadIdentities() { const list = $('#identities-list'); if (!list) return; try { const identities = await invoke('list_identities'); if (identities.length === 0) { - list.innerHTML = '

No identities

'; + list.innerHTML = '

No device addresses

'; return; } list.innerHTML = identities.map(id => { @@ -3595,7 +3972,7 @@ async function loadIdentities() { btn.textContent = 'Switching...'; try { await invoke('switch_identity', { nodeIdHex: btn.dataset.id }); - toast('Identity switched — reloading...'); + toast('Device address switched — reloading...'); setTimeout(() => location.reload(), 1000); } catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; } }); @@ -3604,10 +3981,10 @@ async function loadIdentities() { // Wire delete buttons list.querySelectorAll('.delete-id-btn').forEach(btn => { btn.addEventListener('click', async () => { - if (!confirm('Delete this identity? This cannot be undone.')) return; + if (!confirm('Delete this device address? This cannot be undone.')) return; try { await invoke('delete_identity', { nodeIdHex: btn.dataset.id }); - toast('Identity deleted'); + toast('Device address deleted'); loadIdentities(); } catch (e) { toast('Error: ' + e); } }); @@ -3623,8 +4000,9 @@ $('#create-identity-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

New Identity

- +

New Device Address

+

A new QUIC network endpoint for this device. Your personas are unaffected.

+
@@ -3633,10 +4011,10 @@ $('#create-identity-btn').addEventListener('click', () => { document.body.appendChild(overlay); overlay.querySelector('#new-id-create').addEventListener('click', async () => { const name = overlay.querySelector('#new-id-name').value.trim(); - if (!name) { toast('Name is required'); return; } + if (!name) { toast('Label is required'); return; } try { const nodeId = await invoke('create_identity', { name }); - toast(`Identity created: ${nodeId.substring(0, 12)}`); + toast(`Device address created: ${nodeId.substring(0, 12)}`); overlay.remove(); loadIdentities(); } catch (e) { toast('Error: ' + e); } @@ -3651,10 +4029,10 @@ $('#import-identity-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

Import Identity

-

Paste the 64-character hex key from an identity export.

- - +

Import Device Address Key

+

Paste a 64-character hex key from a previous device-address export. This is NOT how you move your personas — use Export/Import personas above for that.

+ +
@@ -3667,7 +4045,7 @@ $('#import-identity-btn').addEventListener('click', () => { if (keyHex.length !== 64) { toast('Key must be 64 hex characters'); return; } try { const nodeId = await invoke('import_identity_key', { keyHex, name }); - toast(`Identity imported: ${nodeId.substring(0, 12)}`); + toast(`Device address imported: ${nodeId.substring(0, 12)}`); overlay.remove(); loadIdentities(); } catch (e) { toast('Error: ' + e); } @@ -3947,15 +4325,46 @@ function renderPersonasList() { const deleteBtn = p.isDefault ? '' : ``; + // Round 8: greeting consent is per-persona REVOCABLE. This toggle + // is the revocation path for non-default personas (the Profiles + // lightbox only manages the default persona). Label filled async. + const greetBtn = ``; return `
${escapeHtml(label)}${defaultTag}
${p.nodeId.substring(0, 16)}...
-
${setDefaultBtn}${deleteBtn}
+
${greetBtn}${setDefaultBtn}${deleteBtn}
`; }).join(''); + list.querySelectorAll('.persona-greetings-btn').forEach(btn => { + // Fill the current consent state, then enable the toggle. + (async () => { + try { + const open = await invoke('get_greetings_open', { postingIdHex: btn.dataset.id }); + btn.dataset.open = open ? '1' : '0'; + btn.textContent = open ? 'Greetings: on' : 'Greetings: off'; + btn.disabled = false; + } catch (_) { + btn.textContent = 'Greetings: ?'; + } + })(); + btn.addEventListener('click', async () => { + const next = btn.dataset.open !== '1'; + btn.disabled = true; + try { + await invoke('set_greetings_open', { postingIdHex: btn.dataset.id, open: next }); + btn.dataset.open = next ? '1' : '0'; + btn.textContent = next ? 'Greetings: on' : 'Greetings: off'; + toast(next + ? 'Greetings enabled — bio republished with a greeting slot' + : 'Greetings disabled — slot revoked on published bios'); + } catch (e) { toast('Error: ' + e); } + finally { btn.disabled = false; } + }); + }); + list.querySelectorAll('.set-default-persona-btn').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; @@ -4010,6 +4419,10 @@ $('#create-persona-btn').addEventListener('click', () => {

New Persona

Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.

+
@@ -4022,7 +4435,12 @@ $('#create-persona-btn').addEventListener('click', () => { const name = nameInput.value.trim(); if (!name) { toast('Enter a name'); return; } try { - await invoke('create_posting_identity', { displayName: name }); + // Round 8: greeting consent is an ACTIVE pre-checked choice at + // first publish. The choice rides the create call itself so the + // consent setting is written BEFORE the initial bio publish — + // an opted-out persona never ships a greeting slot at all. + const greetingsOpen = overlay.querySelector('#new-persona-greetings').checked; + await invoke('create_posting_identity', { displayName: name, greetingsOpen }); toast(`Persona created: ${name}`); overlay.remove(); loadPersonas(); @@ -4041,14 +4459,14 @@ $('#export-btn').addEventListener('click', () => { overlay.className = 'image-lightbox'; overlay.style.cursor = 'default'; overlay.innerHTML = ` -
-

Export Data

-

Choose what to include in the export ZIP.

+
+

Export your personas

+

Save your personas + (optionally) your posts to a ZIP file so you can import them on another device.

- - - - + + + +
@@ -4118,8 +4536,8 @@ $('#import-btn').addEventListener('click', () => { overlay.style.cursor = 'default'; overlay.innerHTML = `
-

Import Data

-

Select an ItsGoin export ZIP file.

+

Import from another device

+

Select an ItsGoin export ZIP. Default action restores the exported personas onto this device so you can post as them.

@@ -4221,6 +4639,20 @@ $('#import-btn').addEventListener('click', () => { overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); }); +(async () => { + const toggle = $('#session-relay-toggle'); + if (!toggle) return; + try { toggle.checked = await invoke('get_session_relay_enabled'); } catch (_) {} + toggle.addEventListener('change', async () => { + try { + await invoke('set_session_relay_enabled', { enabled: toggle.checked }); + } catch (e) { + toggle.checked = !toggle.checked; + alert('Failed to update session relay setting: ' + e); + } + }); +})(); + $('#notifications-btn').addEventListener('click', async () => { // Load current settings const msgVal = await invoke('get_setting', { key: 'notif_messages' }).catch(() => null) || 'on'; @@ -4302,7 +4734,7 @@ async function init() { // Update tab badges from welcome screen updateTabBadge('feed', b.newFeed || 0); updateTabBadge('myposts', b.newEngagement || 0); - updateTabBadge('messages', b.unreadMessages || 0); + updateTabBadge('messages', (b.unreadMessages || 0) + (b.greetings || 0)); // Ticker + notifications only after user leaves welcome screen // (welcome page already shows these counts directly) }).catch(() => {}); @@ -4385,7 +4817,7 @@ async function init() {

How would you like to get started?

- +
`; document.body.appendChild(chooser); @@ -4456,6 +4888,7 @@ async function init() { const badges = await invoke('get_badge_counts', { lastFeedViewMs: _lastFeedViewMs }); if (currentTab !== 'feed') updateTabBadge('feed', badges.newFeed); if (currentTab !== 'myposts') updateTabBadge('myposts', badges.newEngagement); + if (currentTab !== 'messages') updateTabBadge('messages', (badges.unreadMessages || 0) + (badges.greetings || 0)); } catch (_) {} }, 30000); diff --git a/frontend/index.html b/frontend/index.html index e415fd8..3fcfc55 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,12 @@

Welcome to ItsGoin

Pick a display name if you want one — or leave blank to stay anonymous.

+ +
@@ -25,6 +31,7 @@
+
@@ -180,6 +193,15 @@

Message Requests

+ + +
+

Greetings

+

Sealed hellos from people outside your circles. Replies go back sealed — only the sender can read them.

+
+
@@ -231,13 +253,13 @@
-
-

Identities

-
-
- - -
+
+

Your data on this device

+

+ Personas are who you are to peers — the keys you post and message with. Most people only need one. To move your account to a new device, you export your personas from this device and import them on the new one. +

+ Device Address below is this device's own network endpoint — usually not what you want to move. Leave it alone unless you know why you're touching it. +

@@ -248,9 +270,25 @@
+

Move to another device

+

+ Export creates a ZIP containing your personas (and optionally posts/follows). Import on the other device's Settings > Move to another device. +

- - + + +
+
+ +
+

Device Address (advanced)

+

+ This device's network endpoint — the QUIC address peers use to reach you. Changing this rotates the device's network identifier but does NOT change your posting identity (personas). Rarely useful. +

+
+
+ +
@@ -258,6 +296,17 @@
+
+

Session Relay (off by default)

+

+ When two peers can't connect directly through their networks, a third peer can pipe their traffic through itself. This burns the relay peer's bandwidth on someone else's connection. Off by default. Enable only if you're OK both using other peers as relays and serving as a relay for others. +

+ +
+