Compare commits

..

No commits in common. "master" and "docs/fof-spec-layer1-bio-grants" have entirely different histories.

36 changed files with 5918 additions and 13428 deletions

10
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "itsgoin-cli"
version = "0.8.0-alpha"
version = "0.7.0"
dependencies = [
"anyhow",
"hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]]
name = "itsgoin-core"
version = "0.8.0-alpha"
version = "0.7.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@ -2753,10 +2753,8 @@ dependencies = [
"curve25519-dalek",
"ed25519-dalek",
"hex",
"igd-next",
"iroh",
"jni",
"ndk-context",
"portmapper",
"rand 0.9.2",
"rusqlite",
"serde",
@ -2769,7 +2767,7 @@ dependencies = [
[[package]]
name = "itsgoin-desktop"
version = "0.8.0-alpha"
version = "0.7.0"
dependencies = [
"anyhow",
"base64 0.22.1",

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-cli"
version = "0.8.0-alpha"
version = "0.7.0"
edition = "2021"
[[bin]]

View file

@ -27,7 +27,6 @@ async fn main() -> anyhow::Result<()> {
let mut web_port: Option<u16> = None;
let mut print_identity = false;
let mut announce = false;
let mut publish_registry = false;
let mut ann_category: Option<String> = None;
let mut ann_title: Option<String> = None;
let mut ann_body: Option<String> = None;
@ -77,7 +76,6 @@ 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; }
@ -154,29 +152,6 @@ 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?);
@ -187,9 +162,8 @@ async fn main() -> anyhow::Result<()> {
let addr = node.endpoint_addr();
let sockets: Vec<_> = addr.ip_addrs().collect();
// 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);
// Show our display name if set
let my_name = node.get_display_name(&node.node_id).await.unwrap_or(None);
let name_display = my_name.as_deref().unwrap_or("(not set)");
println!();
@ -230,21 +204,8 @@ async fn main() -> anyhow::Result<()> {
println!(" redundancy Show replica counts for your posts");
println!(" worm <node_id> 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 <anchor_id> Ask an anchor for peers (convection)");
println!(" uniques-pull Exchange uniques indexes with mesh peers");
println!(" social-routes Show social routing cache");
println!(" name <display_name> Set your display name");
println!(" register <name> [keywords...] Register default persona in the network registry (30d, re-run to renew)");
println!(" unregister Remove your registry entry (signed delete)");
println!(" search <keywords> Search the registry (pulls chain, queries locally)");
println!(" greet <bio_post_id_hex> <text> Send a sealed greeting to a bio post's author");
println!(" greetings List unsealed greetings on your bio (with reply/dismiss index)");
println!(" reply <greeting_index> <text> Sealed reply to a greeting's return path (fresh reply key)");
println!(" dismiss <greeting_index> Dismiss a greeting (local only)");
println!(" greetings-open <on|off> 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");
@ -253,22 +214,14 @@ async fn main() -> anyhow::Result<()> {
// Start background tasks (v2: mesh connections)
let _accept_handle = node.start_accept_loop();
let _sync_handle = node.start_sync_cycle(); // 60s: uniques-index exchange + content sync
let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle
let _diff_handle = node.start_diff_cycle(120); // 2 min routing diff
let _rebalance_handle = node.start_rebalance_cycle(600); // 10 min rebalance
// 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 _growth_handle = node.start_growth_loop(); // reactive mesh growth
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 _convection_handle = node.start_convection_loop(); // stochastic anchor convection + anchor self-probe
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 _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
@ -301,10 +254,6 @@ async fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
let reader = stdin.lock();
// A3: cached greeting list so `reply <n>` / `dismiss <n>` indices
// refer to the last `greetings` output.
let mut last_greetings: Vec<itsgoin_core::node::GreetingRecord> = Vec::new();
print!("> ");
io::stdout().flush()?;
@ -393,13 +342,9 @@ async fn main() -> anyhow::Result<()> {
if follows.is_empty() {
println!("(not following anyone)");
}
let own_ids: Vec<itsgoin_core::types::NodeId> = 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);
// Follows are posting ids — "you" = any of our personas.
let label = if own_ids.contains(&nid) { " (you)" } else { "" };
let label = if nid == node.node_id { " (you)" } else { "" };
if let Some(name) = name {
println!(" {} ({}){}", name, &hex::encode(nid)[..12], label);
} else {
@ -752,7 +697,7 @@ async fn main() -> anyhow::Result<()> {
"create-persona" => {
let name = arg.unwrap_or("").to_string();
match node.create_posting_identity(name, None).await {
match node.create_posting_identity(name).await {
Ok(id) => {
println!("Created posting identity: {}", hex::encode(id.node_id));
}
@ -872,7 +817,7 @@ async fn main() -> anyhow::Result<()> {
println!("(no mesh connections)");
} else {
println!("Mesh connections ({}):", conns.len());
for (nid, slot, connected_at) in conns {
for (nid, slot_kind, 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))
@ -884,95 +829,7 @@ async fn main() -> anyhow::Result<()> {
.as_millis() as u64;
(now.saturating_sub(connected_at)) / 1000
};
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 <anchor_node_id_hex>");
} 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(","));
println!(" {} [{:?}] connected {}s ago", label, slot_kind, duration_secs);
}
}
}
@ -1004,166 +861,6 @@ 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<String> = words.map(|w| w.to_string()).collect();
if name.is_empty() {
println!("Usage: register <name> [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 <name> [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 <bio_post_id_hex> <text>");
} 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 <bio_post_id_hex> <text>");
}
}
"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::<usize>().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 <greeting_index> <text>"),
}
} else {
println!("Usage: reply <greeting_index> <text>");
}
}
"dismiss" => {
match arg.and_then(|a| a.trim().parse::<usize>().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 <greeting_index>"),
}
}
"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 <on|off>",
if open { "open" } else { "closed" }
),
Err(e) => println!("Error: {}", e),
}
}
}
}
"quit" | "exit" | "q" => {
println!("Shutting down...");
break;
@ -1190,9 +887,7 @@ async fn print_post(
) {
let author_hex = hex::encode(post.author);
let author_short = &author_hex[..12];
// 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 is_me = &post.author == &node.node_id;
let author_name = node.get_display_name(&post.author).await.unwrap_or(None);
let author_label = match (author_name, is_me) {

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-core"
version = "0.8.0-alpha"
version = "0.7.0"
edition = "2021"
[dependencies]
@ -19,11 +19,7 @@ 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"] }
portmapper = "0.14"
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
ndk-context = "0.1"
igd-next = { version = "0.16", features = ["tokio"] }
[dev-dependencies]
tempfile = "3"

View file

@ -1,256 +0,0 @@
//! 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<bool, String> {
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<Self> {
match Self::acquire_inner(tag) {
Ok(g) => Some(g),
Err(e) => {
debug!("MulticastLock acquire failed: {}", e);
None
}
}
}
fn acquire_inner(tag: &str) -> Result<Self, String> {
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(())
}

File diff suppressed because it is too large Load diff

View file

@ -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 ‖ 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.
/// Digest = BLAKE3(post_id ‖ author ‖ created_at_le ‖ updated_at_le ‖ author_addresses_json ‖ previous_posts_json ‖ following_posts_json)
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,20 +1010,8 @@ pub fn random_slot_noise(size: usize) -> Vec<u8> {
// --- Engagement crypto ---
const REACTION_WRAP_CONTEXT: &str = "itsgoin/private-reaction/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 COMMENT_SIGN_CONTEXT: &str = "itsgoin/comment-sig/v1";
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.
@ -1079,31 +1067,25 @@ 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 [|| 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.
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms).
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(&timestamp_ms.to_le_bytes());
// Domain-separated appends (same pattern as the v0.6.2 `ref:` field).
// 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.
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()
}
@ -1114,14 +1096,13 @@ pub fn sign_comment(
content: &str,
timestamp_ms: u64,
ref_post_id: Option<&PostId>,
expires_at_ms: u64,
) -> Vec<u8> {
let signing_key = SigningKey::from_bytes(seed);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id, expires_at_ms);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
}
/// Verify a comment's ed25519 signature (digest v2, expiry included).
/// Verify a comment's ed25519 signature.
pub fn verify_comment_signature(
author: &NodeId,
post_id: &PostId,
@ -1129,7 +1110,6 @@ 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;
@ -1137,177 +1117,10 @@ 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, expires_at_ms);
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
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(&timestamp_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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
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],
@ -1546,133 +1359,20 @@ 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), exp);
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post));
// Verifies only when the ref is supplied.
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post), exp));
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post)));
// Same signature must NOT verify when the ref is dropped (binding).
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None, exp));
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None));
// 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), exp));
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref)));
// 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());
// 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));
}
#[test]
@ -1683,6 +1383,7 @@ 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 {
@ -1699,97 +1400,6 @@ 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;
@ -1799,6 +1409,7 @@ 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![],

View file

@ -91,14 +91,7 @@ pub async fn export_data(
let (posts, blob_cids) = if scope == ExportScope::IdentityOnly {
(vec![], vec![])
} else {
// Own posts are authored by POSTING identities (personas) — the
// network NodeId never authors posts. Export across all personas.
let author_ids: Vec<NodeId> = {
let s = storage.get().await;
s.list_posting_identities()?
.into_iter().map(|p| p.node_id).collect()
};
gather_posts(storage, &author_ids).await?
gather_posts(storage, node_id).await?
};
let (follows, profiles, settings) = if scope == ExportScope::Everything {
@ -252,10 +245,10 @@ pub async fn export_data(
})
}
/// Gather own posts (authored by any of our posting identities) and their blob CIDs.
/// Gather own posts and their blob CIDs.
async fn gather_posts(
storage: &StoragePool,
author_ids: &[NodeId],
node_id: &NodeId,
) -> anyhow::Result<(Vec<ExportedPost>, Vec<[u8; 32]>)> {
let s = storage.get().await;
let posts_with_vis = s.list_posts_with_visibility()?;
@ -264,7 +257,7 @@ async fn gather_posts(
for (id, post, vis) in &posts_with_vis {
// Only export our own posts
if !author_ids.contains(&post.author) {
if post.author != *node_id {
continue;
}

View file

@ -20,11 +20,7 @@ use rand::RngCore;
use crate::crypto::{seal_wrap_slot, SealedWrapSlot};
use crate::storage::Storage;
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;
use crate::types::{FoFCommentGating, NodeId, WrapSlot};
/// Build the `FoFCommentGating` block for a post about to be published
/// under `CommentPermission::FriendsOfFriends`. The author's keyring
@ -37,16 +33,9 @@ pub const MAX_OPEN_SLOT_BODY_BUCKET: u16 = 4096;
///
/// 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<Option<FoFCommentGatingBuilt>> {
// Gather the author's keyring with provenance: (V_x, owner, epoch).
// The author's own V_me appears with owner=author_persona_id.
@ -81,9 +70,6 @@ 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());
@ -102,25 +88,6 @@ 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();
@ -150,10 +117,8 @@ pub fn build_fof_comment_gating(
let mut pub_post_set: Vec<[u8; 32]> = Vec::with_capacity(entries.len());
let mut wrap_slots: Vec<WrapSlot> = Vec::with_capacity(entries.len());
let mut real_slot_provenance: Vec<RealSlotProvenance> = Vec::new();
let mut open_slot_index: Option<u32> = None;
for (idx, (kind, pub_x, slot)) in entries.into_iter().enumerate() {
match kind {
EntryKind::Real { v_x_owner, v_x_epoch } => {
if let EntryKind::Real { v_x_owner, v_x_epoch } = kind {
real_slot_provenance.push(RealSlotProvenance {
slot_index: idx as u32,
v_x_owner,
@ -161,28 +126,15 @@ pub fn build_fof_comment_gating(
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 {
@ -190,7 +142,6 @@ pub fn build_fof_comment_gating(
cek,
slot_binder_nonce,
real_slot_provenance,
open_slot_index,
}))
}
@ -213,9 +164,6 @@ 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<RealSlotProvenance>,
/// A3: index of the derivable open slot, when one was requested.
/// Mirrors `gating.open_slot.slot_index`.
pub open_slot_index: Option<u32>,
}
/// One entry per real (non-dummy) slot in a published FoF post.
@ -366,37 +314,6 @@ 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<PostUnlock> {
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,
@ -459,7 +376,6 @@ pub fn build_fof_comment(
body: &str,
parent_comment_id: Option<[u8; 32]>,
now_ms: u64,
expires_at_ms: u64,
) -> Result<crate::types::InlineComment> {
use ed25519_dalek::{Signer, SigningKey};
@ -495,7 +411,6 @@ pub fn build_fof_comment(
"",
now_ms,
None,
expires_at_ms,
);
Ok(crate::types::InlineComment {
@ -509,7 +424,6 @@ pub fn build_fof_comment(
pub_x_index: Some(unlock.slot_index),
group_sig: Some(group_sig),
encrypted_payload: Some(encrypted),
expires_at_ms,
})
}
@ -525,14 +439,7 @@ 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; };
// 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 Some(encrypted_payload) = comment.encrypted_payload.as_ref() else { return false; };
let idx = pub_x_index as usize;
if idx >= gating.pub_post_set.len() { return false; }
if group_sig.len() != 64 { return false; }
@ -572,10 +479,9 @@ 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 (`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.
/// 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.
const MAX_FOF_WRAP_SLOTS: usize = 8192;
/// Maximum allowed revocation_list entries in a t=0 published gating
@ -646,24 +552,6 @@ 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(())
}
@ -1067,7 +955,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, None).unwrap().expect("gating built");
let built = build_fof_comment_gating(&s, &alice_id).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);
@ -1121,7 +1009,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, None).unwrap();
let built = build_fof_comment_gating(&s, &alice_id).unwrap();
assert!(built.is_none(), "no V_me → no gating block");
}
@ -1143,7 +1031,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).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:
@ -1200,7 +1088,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id).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).
@ -1240,7 +1128,6 @@ 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());
@ -1296,7 +1183,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let post_id = [0xDE; 32];
// Persist the post so apply_fof_revocation_locally can resolve
@ -1341,7 +1228,7 @@ mod tests {
};
let comment = build_fof_comment(
&post_id, &bob_unlock, &built.slot_binder_nonce,
&bob_id, &bob_seed, "hello", None, 4000, 4_000_000_000_000,
&bob_id, &bob_seed, "hello", None, 4000,
).unwrap();
s.store_comment(&comment).unwrap();
assert_eq!(s.get_comments(&post_id).unwrap().len(), 1, "Bob's comment stored");
@ -1390,7 +1277,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let post_id = [0xBC; 32];
let post = crate::types::Post {
author: alice_id, content: "alice".into(), attachments: vec![],
@ -1492,7 +1379,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let post_id = [0xAB; 32];
let post = crate::types::Post {
author: alice_id, content: "x".into(), attachments: vec![],
@ -1582,7 +1469,6 @@ 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,
}
}
@ -1688,7 +1574,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).unwrap().expect("built");
let post_id = [0xEE; 32];
let post = crate::types::Post {
author: alice_id, content: String::new(), attachments: vec![],
@ -1820,7 +1706,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id).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()),
@ -1864,7 +1750,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&alice_storage, &alice_id).unwrap().expect("built");
// Bob's storage: holds his own V_me only (no Carol-V_x). The post
// shouldn't unlock for him yet.
@ -1940,7 +1826,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).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();
@ -2019,7 +1905,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, None).unwrap().expect("built");
let built = build_fof_comment_gating(&s, &alice_id).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);
@ -2076,105 +1962,4 @@ 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());
}
}

View file

@ -1,9 +1,15 @@
//! Group-key distribution as an encrypted post: the group seed travels
//! inside `PostVisibility::Encrypted`. Each member is a recipient; the
//! 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
//! 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. (No direct wire push — that
//! would signal which endpoints coordinate group membership.)
//! 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.
//!
//! Note: Members are identified by their **posting** NodeIds (the
//! author/recipient namespace since the v0.6.1 identity split), not network
@ -14,7 +20,7 @@ use crate::content::compute_post_id;
use crate::crypto;
use crate::storage::Storage;
use crate::types::{
GroupKeyDistributionContent, GroupKeyRecord, NodeId, Post, PostId,
GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId,
PostVisibility, PostingIdentity, VisibilityIntent,
};
@ -291,40 +297,4 @@ 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);
}
}

View file

@ -8,6 +8,7 @@ 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;
@ -105,6 +106,7 @@ pub async fn run_http_server(
port: u16,
storage: Arc<StoragePool>,
blob_store: Arc<BlobStore>,
downstream_addrs: Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> anyhow::Result<()> {
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
// Use SO_REUSEADDR + SO_REUSEPORT so TCP punch sockets can share the port
@ -154,9 +156,10 @@ 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).await;
handle_connection(stream, ip, slot, &storage, &blob_store, &downstream_addrs).await;
let mut b = budget.lock().unwrap();
match slot {
SlotKind::Content => b.release_content(ip),
@ -179,6 +182,7 @@ async fn handle_connection(
slot: SlotKind,
storage: &Arc<StoragePool>,
blob_store: &Arc<BlobStore>,
downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) {
// Keep-alive loop: handle sequential requests on the same connection
loop {
@ -218,7 +222,7 @@ async fn handle_connection(
}
}
SlotKind::Redirect => {
if !try_redirect(&mut stream, &post_id, storage).await {
if !try_redirect(&mut stream, &post_id, storage, downstream_addrs).await {
return;
}
}
@ -364,6 +368,7 @@ async fn try_redirect(
stream: &mut TcpStream,
post_id: &[u8; 32],
storage: &Arc<StoragePool>,
_downstream_addrs: &Arc<Mutex<HashMap<[u8; 32], Vec<SocketAddr>>>>,
) -> bool {
// Get downstream peers for this post
let downstream_peers = {
@ -578,6 +583,114 @@ 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<SocketAddr> {
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<Vec<u8>> {
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::*;
@ -600,6 +713,26 @@ mod tests {
assert_eq!(html_escape("a&b"), "a&amp;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";

View file

@ -1,6 +1,4 @@
pub mod activity;
#[cfg(target_os = "android")]
pub mod android_wifi;
pub mod blob;
pub mod connection;
pub mod content;
@ -17,7 +15,6 @@ pub mod network;
pub mod node;
pub mod profile;
pub mod protocol;
pub mod registry;
pub mod storage;
pub mod stun;
pub mod types;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,7 @@ 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,
};
@ -174,9 +175,6 @@ 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],
@ -185,7 +183,6 @@ pub fn build_profile_post(
avatar_cid: Option<[u8; 32]>,
vouch_grants: Option<crate::types::VouchGrantBatch>,
bio_epoch: u32,
fof_gating: Option<crate::types::FoFCommentGating>,
) -> Post {
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -206,7 +203,7 @@ pub fn build_profile_post(
content: serde_json::to_string(&content).unwrap_or_default(),
attachments: vec![],
timestamp_ms,
fof_gating,
fof_gating: None,
supersedes_post_id: None,
}
}
@ -348,7 +345,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, None);
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None, None, 0);
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
@ -363,7 +360,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, None);
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None, None, 0);
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());
@ -375,7 +372,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, None);
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None, None, 0);
// Hack the timestamp to make it clearly newer.
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
content.timestamp_ms = 10_000;
@ -385,7 +382,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, None);
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None, None, 0);
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);

View file

@ -1,15 +1,12 @@
use serde::{Deserialize, Serialize};
use crate::types::{
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, WormId,
BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, GroupMemberKey, NodeId,
PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId,
};
/// 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";
/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture)
pub const ALPN_V2: &[u8] = b"itsgoin/3";
/// A post bundled with its visibility metadata for sync
#[derive(Debug, Serialize, Deserialize)]
@ -18,8 +15,9 @@ 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. None = regular
/// post.
/// out of the feed and process their ControlOp payload. Absent on
/// pre-v0.6.2 senders; receivers treat as "unknown"/regular post.
#[serde(default)]
pub intent: Option<crate::types::VisibilityIntent>,
}
@ -27,29 +25,23 @@ pub struct SyncPost {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum MessageType {
UniquesAnnounce = 0x01,
NodeListUpdate = 0x01,
InitialExchange = 0x02,
AddressRequest = 0x03,
AddressResponse = 0x04,
RefuseRedirect = 0x05,
/// 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,
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.
ProfileUpdate = 0x50,
DeleteRecord = 0x51,
VisibilityUpdate = 0x52,
WormQuery = 0x60,
WormResponse = 0x61,
SocialAddressUpdate = 0x70,
SocialDisconnectNotice = 0x71,
SocialCheckin = 0x72,
// 0x80-0x81 reserved
BlobRequest = 0x90,
@ -57,15 +49,19 @@ 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,
// 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,
AnchorRegister = 0xC0,
AnchorReferralRequest = 0xC1,
AnchorReferralResponse = 0xC2,
AnchorProbeRequest = 0xC3,
AnchorProbeResult = 0xC4,
PortScanHeartbeat = 0xC5,
@ -87,31 +83,36 @@ pub enum MessageType {
impl MessageType {
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(Self::UniquesAnnounce),
0x01 => Some(Self::NodeListUpdate),
0x02 => Some(Self::InitialExchange),
0x03 => Some(Self::AddressRequest),
0x04 => Some(Self::AddressResponse),
0x05 => Some(Self::RefuseRedirect),
0x40 => Some(Self::UniquesPullRequest),
0x41 => Some(Self::UniquesPullResponse),
0x46 => Some(Self::ContentSyncRequest),
0x47 => Some(Self::ContentSyncResponse),
0x40 => Some(Self::PullSyncRequest),
0x41 => Some(Self::PullSyncResponse),
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),
0xC1 => Some(Self::ConvectionRequest),
0xC2 => Some(Self::ConvectionResponse),
0xC0 => Some(Self::AnchorRegister),
0xC1 => Some(Self::AnchorReferralRequest),
0xC2 => Some(Self::AnchorReferralResponse),
0xC3 => Some(Self::AnchorProbeRequest),
0xC4 => Some(Self::AnchorProbeResult),
0xC5 => Some(Self::PortScanHeartbeat),
@ -139,152 +140,13 @@ impl MessageType {
// --- Payload structs ---
/// 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<NodeId> {
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<String>,
}
/// 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<AnchorEntry>,
}
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<UniquesSlice>,
/// 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
/// Initial exchange: N1/N2 node lists + profile + deletes + post_ids + peer addresses
#[derive(Debug, Serialize, Deserialize)]
pub struct InitialExchangePayload {
/// 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<UniquesAnnouncePayload>,
/// Our connections + social contacts NodeIds (no addresses)
pub n1_node_ids: Vec<NodeId>,
/// Our deduplicated N2 NodeIds (no addresses)
pub n2_node_ids: Vec<NodeId>,
/// Our profile
pub profile: Option<PublicProfile>,
/// Our delete records
@ -292,57 +154,68 @@ pub struct InitialExchangePayload {
/// Our post IDs (for replica tracking)
pub post_ids: Vec<PostId>,
/// Our N+10:Addresses (connected peers with addresses) for social routing
#[serde(default)]
pub peer_addresses: Vec<PeerWithAddress>,
/// If sender is an anchor, their stable advertised address (e.g. "174.127.120.52:4433")
#[serde(default)]
pub anchor_addr: Option<String>,
/// What the sender sees as the receiver's address (STUN-like observed addr)
#[serde(default)]
pub your_observed_addr: Option<String>,
/// Sender's NAT type ("public", "easy", "hard", "unknown")
#[serde(default)]
pub nat_type: Option<String>,
/// Sender's NAT mapping behavior ("eim", "edm", "unknown")
#[serde(default)]
pub nat_mapping: Option<String>,
/// Sender's NAT filtering behavior ("open", "port_restricted", "unknown")
#[serde(default)]
pub nat_filtering: Option<String>,
/// 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<String>,
/// CDN replication device role: "intermittent", "available", "persistent"
#[serde(default)]
pub device_role: Option<String>,
/// CDN cache pressure: 0-255 availability score (255 = lots of capacity)
#[serde(default)]
pub cache_pressure: Option<u8>,
/// 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<bool>,
}
/// 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.
/// Incremental N1/N2 changes
#[derive(Debug, Serialize, Deserialize)]
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<UniquesAnnouncePayload>,
pub struct NodeListUpdatePayload {
pub seq: u64,
pub n1_added: Vec<NodeId>,
pub n1_removed: Vec<NodeId>,
pub n2_added: Vec<NodeId>,
pub n2_removed: Vec<NodeId>,
}
/// 0x46 — TRANSITIONAL content sync request. Identical in shape to the v0.7
/// pull request; only the opcode moved. See `MessageType::ContentSyncRequest`.
/// Pull-based post sync request
#[derive(Debug, Serialize, Deserialize)]
pub struct ContentSyncRequestPayload {
pub struct PullSyncRequestPayload {
/// Our follows (for the responder to filter)
pub follows: Vec<NodeId>,
/// Per-author last-sync timestamps (Vec of tuples for serde compat)
/// Post IDs we already have (backward compat — empty for v4 senders)
#[serde(default)]
pub have_post_ids: Vec<PostId>,
/// Protocol v4: per-author timestamps (Vec of tuples for serde compat)
#[serde(default)]
pub since_ms: Vec<(NodeId, u64)>,
}
/// 0x47 — TRANSITIONAL content sync response.
/// Pull-based post sync response
#[derive(Debug, Serialize, Deserialize)]
pub struct ContentSyncResponsePayload {
pub struct PullSyncResponsePayload {
pub posts: Vec<SyncPost>,
pub visibility_updates: Vec<VisibilityUpdate>,
}
/// Profile update (pushed via uni-stream)
@ -351,6 +224,18 @@ pub struct ProfileUpdatePayload {
pub profiles: Vec<PublicProfile>,
}
/// Delete record (pushed via uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteRecordPayload {
pub records: Vec<DeleteRecord>,
}
/// Visibility update (pushed via uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct VisibilityUpdatePayload {
pub updates: Vec<VisibilityUpdate>,
}
/// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address)
#[derive(Debug, Serialize, Deserialize)]
pub struct AddressRequestPayload {
@ -363,8 +248,10 @@ pub struct AddressResponsePayload {
pub target: NodeId,
pub address: Option<String>,
/// Set when the target is known-disconnected (requester registered as watcher)
#[serde(default)]
pub disconnected_at: Option<u64>,
/// Target's N+10:Addresses if known
#[serde(default)]
pub peer_addresses: Vec<PeerWithAddress>,
}
@ -381,12 +268,15 @@ 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<NodeId>,
pub ttl: u8,
pub visited: Vec<NodeId>,
/// Optional: also search for a specific post by ID
#[serde(default)]
pub post_id: Option<PostId>,
/// Optional: also search for a specific blob by CID
#[serde(default)]
pub blob_id: Option<[u8; 32]>,
}
@ -396,15 +286,19 @@ 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<NodeId>,
pub addresses: Vec<String>,
pub reporter: Option<NodeId>,
pub hop: Option<u8>,
/// 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<NodeId>,
/// Node that holds the requested blob
#[serde(default)]
pub blob_holder: Option<NodeId>,
}
@ -418,6 +312,12 @@ pub struct SocialAddressUpdatePayload {
pub peer_addresses: Vec<PeerWithAddress>,
}
/// 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 {
@ -433,6 +333,7 @@ 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<String>,
}
@ -442,12 +343,16 @@ 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<CdnManifest>,
/// 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<PeerWithAddress>,
}
@ -485,6 +390,23 @@ 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<GroupMemberKey>,
}
// --- Relay introduction payloads ---
/// Relay introduction identifier for deduplication
@ -531,6 +453,18 @@ 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<String>,
}
/// Circle profile update: encrypted profile variant for a circle (uni-stream push)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircleProfileUpdatePayload {
@ -545,63 +479,33 @@ pub struct CircleProfileUpdatePayload {
pub updated_at: u64,
}
// --- Anchor convection payloads (design.html §anchors) ---
// --- Anchor referral payloads ---
/// 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,
}
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.
/// Node registers its address with an anchor (uni-stream)
#[derive(Debug, Serialize, Deserialize)]
pub struct ConvectionRequestPayload {
pub requester: NodeId,
pub requester_addresses: Vec<String>,
pub class: ConvectionClass,
}
/// 0xC2 — up to 2 recent prior callers, or a cheap refusal.
#[derive(Debug, Serialize, Deserialize)]
pub struct ConvectionResponsePayload {
pub referrals: Vec<ConvectionReferral>,
/// 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 referral out of the anchor's rolling caller window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvectionReferral {
pub struct AnchorRegisterPayload {
pub node_id: NodeId,
pub addresses: Vec<String>,
}
/// Node requests peer referrals from an anchor (bi-stream request)
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralRequestPayload {
pub requester: NodeId,
pub requester_addresses: Vec<String>,
}
/// Anchor responds with peer referrals (bi-stream response)
#[derive(Debug, Serialize, Deserialize)]
pub struct AnchorReferralResponsePayload {
pub referrals: Vec<AnchorReferral>,
}
/// A single peer referral from an anchor
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorReferral {
pub node_id: NodeId,
pub addresses: Vec<String>,
/// 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 ---
@ -629,9 +533,7 @@ pub struct AnchorProbeResultPayload {
pub observed_addr: Option<String>,
}
/// 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.
/// Port scan heartbeat during scanning hole punch (informational)
#[derive(Debug, Serialize, Deserialize)]
pub struct PortScanHeartbeatPayload {
pub peer: NodeId,
@ -682,6 +584,7 @@ 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<String>,
}
@ -822,31 +725,36 @@ mod tests {
#[test]
fn message_type_roundtrip() {
let types = [
MessageType::UniquesAnnounce,
MessageType::NodeListUpdate,
MessageType::InitialExchange,
MessageType::AddressRequest,
MessageType::AddressResponse,
MessageType::RefuseRedirect,
MessageType::UniquesPullRequest,
MessageType::UniquesPullResponse,
MessageType::ContentSyncRequest,
MessageType::ContentSyncResponse,
MessageType::PullSyncRequest,
MessageType::PullSyncResponse,
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::ConvectionRequest,
MessageType::ConvectionResponse,
MessageType::AnchorRegister,
MessageType::AnchorReferralRequest,
MessageType::AnchorReferralResponse,
MessageType::AnchorProbeRequest,
MessageType::AnchorProbeResult,
MessageType::PortScanHeartbeat,
@ -933,6 +841,43 @@ 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 {
@ -968,105 +913,56 @@ mod tests {
}
#[test]
fn convection_request_carries_the_class_bit() {
for class in [ConvectionClass::Entry, ConvectionClass::TopUp] {
let payload = ConvectionRequestPayload {
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");
}
#[test]
fn anchor_referral_request_payload_roundtrip() {
let payload = AnchorReferralRequestPayload {
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();
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"]);
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 convection_response_roundtrip_including_cheap_refusal() {
let payload = ConvectionResponsePayload {
fn anchor_referral_response_payload_roundtrip() {
let payload = AnchorReferralResponsePayload {
referrals: vec![
ConvectionReferral {
AnchorReferral {
node_id: [3u8; 32],
addresses: vec!["10.0.0.3:4433".to_string()],
introduced: false,
},
ConvectionReferral {
AnchorReferral {
node_id: [4u8; 32],
addresses: vec!["10.0.0.4:4433".to_string()],
introduced: true,
addresses: vec!["10.0.0.4:4433".to_string(), "192.168.1.4:4433".to_string()],
},
],
refused: false,
};
let json = serde_json::to_string(&payload).unwrap();
let decoded: ConvectionResponsePayload = serde_json::from_str(&json).unwrap();
let decoded: AnchorReferralResponsePayload = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.referrals.len(), 2);
assert!(!decoded.referrals[0].introduced);
assert!(decoded.referrals[1].introduced);
assert!(!decoded.refused);
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);
// 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);
// Empty referrals
let empty = AnchorReferralResponsePayload { referrals: vec![] };
let json2 = serde_json::to_string(&empty).unwrap();
let decoded2: AnchorReferralResponsePayload = serde_json::from_str(&json2).unwrap();
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<crate::types::NodeId> = (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);
}
}
}

View file

@ -1,474 +0,0 @@
//! 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=<n>` 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::<u64>() {
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<Post> {
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<bool> {
let post = registry_post();
debug_assert!(crate::content::verify_post_id(&REGISTRY_POST_ID, &post));
storage.store_post_with_intent(
&REGISTRY_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<String>,
}
/// Parse + shape-validate a registration entry from comment content.
pub fn parse_registration(content: &str) -> Result<RegistrationEntry> {
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<String>,
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<Vec<RegistryMatch>> {
let comments = storage.get_comments(&REGISTRY_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<NodeId, RegistryMatch> =
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<RegistryMatch> = 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(),
&REGISTRY_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(&REGISTRY_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<String> = (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(&REGISTRY_POST_ID, &author, t0).unwrap());
s.store_comment(&reg_comment(author, t0, "Old", &["a"])).unwrap();
// Newer entry at t0+1000: accepted, older row hard-deleted.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_POST_ID, &author, t0 + 1000).unwrap());
s.store_comment(&reg_comment(author, t0 + 1000, "New", &["b"])).unwrap();
let comments = s.get_comments(&REGISTRY_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(&REGISTRY_POST_ID, &author, t0 + 500).unwrap());
assert_eq!(s.get_comments(&REGISTRY_POST_ID).unwrap().len(), 1);
// Same-timestamp re-ingest: idempotent accept.
assert!(s.upsert_registry_entry_newest_wins(&REGISTRY_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(&reg_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap();
s.store_comment(&reg_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");
}
}

View file

@ -1 +0,0 @@
{"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}}}

File diff suppressed because it is too large Load diff

View file

@ -68,22 +68,7 @@ pub struct Attachment {
pub size_bytes: u64,
}
/// 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.
/// Public profile — plaintext, synced to all peers
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PublicProfile {
pub node_id: NodeId,
@ -99,6 +84,9 @@ pub struct PublicProfile {
/// Up to 10 currently-connected peer NodeIds (for 11-needle worm search)
#[serde(default)]
pub recent_peers: Vec<NodeId>,
/// Bilateral preferred peer NodeIds (stable relay hubs)
#[serde(default)]
pub preferred_peers: Vec<NodeId>,
/// Whether display_name/bio are visible to non-circle peers
#[serde(default = "default_true")]
pub public_visible: bool,
@ -110,7 +98,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). v0.6.1 broadcasts the profile under
/// recent_peers, preferred_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.
@ -462,6 +450,14 @@ pub struct DeleteRecord {
pub signature: Vec<u8>,
}
/// 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 {
@ -649,67 +645,33 @@ 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: 20 mesh + up to 10 temp referral
/// Desktop: 81 local + 20 wide = 101 mesh connections
Desktop,
/// Mobile: 15 mesh + up to 4 temp referral
/// Mobile: 10 local + 5 wide = 15 mesh connections
Mobile,
}
impl DeviceProfile {
/// 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<Option<usize>> = std::sync::OnceLock::new();
let over = *OVERRIDE.get_or_init(|| {
std::env::var("ITSGOIN_TEST_MESH_SLOTS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|n| *n > 0)
});
if let Some(n) = over {
return n;
}
match self {
DeviceProfile::Desktop => 20,
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 {
pub fn preferred_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 10,
DeviceProfile::Mobile => 4,
DeviceProfile::Mobile => 3,
}
}
/// 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 {
pub fn local_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 4,
DeviceProfile::Mobile => 3,
DeviceProfile::Desktop => 71,
DeviceProfile::Mobile => 7,
}
}
pub fn wide_slots(&self) -> usize {
match self {
DeviceProfile::Desktop => 20,
DeviceProfile::Mobile => 5,
}
}
@ -749,101 +711,37 @@ impl std::fmt::Display for SessionReachMethod {
}
}
/// 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 },
/// 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,
}
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 {
impl std::fmt::Display for PeerSlotKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MeshSlot::Mesh => write!(f, "mesh"),
MeshSlot::TempReferral { .. } => write!(f, "temp"),
PeerSlotKind::Preferred => write!(f, "preferred"),
PeerSlotKind::Local => write!(f, "local"),
PeerSlotKind::Wide => write!(f, "wide"),
}
}
}
/// 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 }
impl std::str::FromStr for PeerSlotKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"preferred" => Ok(PeerSlotKind::Preferred),
"local" | "social" => Ok(PeerSlotKind::Local),
"wide" => Ok(PeerSlotKind::Wide),
_ => Err(anyhow::anyhow!("unknown slot kind: {}", s)),
}
}
/// 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<String>,
}
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<String>) -> 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<Vec<ReachEntry>>,
pub term: Vec<ReachEntry>,
}
// --- Social Routing Cache ---
@ -958,6 +856,8 @@ 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<String>,
/// Original post creation time (ms)
pub created_at: u64,
/// When manifest was last updated (ms)
@ -970,16 +870,20 @@ pub struct AuthorManifest {
pub signature: Vec<u8>,
}
/// 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.
/// CDN manifest traveling with blobs (author-signed part + host metadata)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdnManifest {
pub author_manifest: AuthorManifest,
/// Serving host's NodeId (the QUIC-authenticated device serving the blob)
/// Serving host's NodeId
pub host: NodeId,
/// Serving host's N+10:A
pub host_addresses: Vec<String>,
/// Who the host got it from
pub source: NodeId,
/// Source's N+10:A
pub source_addresses: Vec<String>,
/// How many downstream this host has
pub downstream_count: u32,
}
/// Cached routing info for a social contact
@ -993,6 +897,8 @@ 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<NodeId>,
}
// --- Engagement System ---
@ -1072,38 +978,6 @@ 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<Vec<u8>>,
/// 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 89): `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
@ -1202,34 +1076,6 @@ pub struct RevocationEntry {
pub author_sig: Vec<u8>,
}
/// 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
@ -1251,10 +1097,6 @@ pub struct FoFCommentGating {
/// arrive; the on-wire t=0 snapshot is empty.
#[serde(default)]
pub revocation_list: Vec<RevocationEntry>,
/// 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<OpenSlotDecl>,
}
/// Author-controlled engagement policy for a post
@ -1286,19 +1128,7 @@ 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 },
/// 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<u8>,
},
DeleteComment { author: NodeId, post_id: PostId, timestamp_ms: u64 },
SetPolicy(CommentPolicy),
ThreadSplit { new_post_id: PostId },
/// Write an encrypted receipt slot (64 bytes encrypted data)

View file

@ -1,181 +1,243 @@
//! 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 13s.
//!
//! ## 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.
//! Best-effort UPnP port mapping for NAT traversal.
//! Skipped entirely on mobile platforms where UPnP is unsupported.
use std::net::SocketAddr;
use std::num::NonZeroU16;
use std::time::Duration;
use tracing::{debug, info};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tracing::{info, debug};
/// 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 {
/// Result of a successful UPnP port mapping.
pub struct UpnpMapping {
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<crate::android_wifi::MulticastLockGuard>,
}
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> {
Self::try_for_protocol(local_port, portmapper::Protocol::Udp).await
}
/// 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<UpnpMapping> {
use igd_next::SearchOptions;
/// 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> {
Self::try_for_protocol(local_port, portmapper::Protocol::Tcp).await
}
async fn try_for_protocol(local_port: u16, protocol: portmapper::Protocol) -> Option<Self> {
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;
}
}
#[cfg(target_os = "android")]
let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");
let config = portmapper::Config {
enable_upnp: true,
enable_pcp: true,
enable_nat_pmp: true,
protocol,
let search_opts = SearchOptions {
timeout: Some(std::time::Duration::from_secs(3)),
..Default::default()
};
let client = portmapper::Client::new(config);
client.update_local_port(port);
// 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);
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;
}
if watch.changed().await.is_err() {
};
let external_ip = match gateway.get_external_ip().await {
Ok(ip) => ip,
Err(e) => {
debug!("UPnP: could not get external IP: {}", e);
return None;
}
};
// 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
// 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);
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<UpnpMapping> {
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(Some(addr)) => addr,
_ => {
debug!(
protocol = ?protocol,
local_port,
"Port mapping: no protocol responded within 3s (no UPnP/PCP/NAT-PMP gateway?)"
);
return None;
Ok(()) => {
info!("UPnP: TCP port {} mapped for HTTP serving", external_port);
true
}
Err(e) => {
debug!("UPnP: TCP port mapping failed (non-fatal): {}", e);
false
}
}
}
#[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()
};
info!(
external = %external,
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,
protocol = ?protocol,
"Port mapping established"
);
Some(PortMapping {
external_addr: SocketAddr::V4(external),
local_port,
client,
#[cfg(target_os = "android")]
mcast_lock,
})
gateway
.add_port(
igd_next::PortMappingProtocol::TCP,
external_port,
local_addr,
1800,
"itsgoin-http",
)
.await
.is_ok()
}
/// 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<Option<std::net::SocketAddrV4>> {
self.client.watch_external_address()
#[cfg(any(target_os = "android", target_os = "ios"))]
pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
false
}
/// 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();
/// 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) {}

View file

@ -3,8 +3,7 @@
//! renders HTML, serves blobs. No permanent storage of fetched content.
//!
//! Routes (behind Apache reverse proxy):
//! GET /p/<postid_hex> → render post HTML (fetched on-demand; anchor
//! resolves holders itself)
//! GET /p/<postid_hex>/<author_hex> → render post HTML (fetched on-demand)
//! GET /b/<blobid_hex> → serve blob (images/videos)
use std::net::SocketAddr;
@ -90,7 +89,7 @@ fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> {
None
}
/// Handle GET /p/<postid_hex>
/// Handle GET /p/<postid_hex>/<author_hex>
///
/// Three-tier serving:
/// 1. Redirect to a CDN holder with a public/punchable HTTP server
@ -113,11 +112,26 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
_ => return,
};
// Parse optional author_id (after the slash)
let author_id: Option<NodeId> = 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) {
@ -163,9 +177,8 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc<Node>, browse
}
}
// Fetch via content search + PostFetch (no author hint — holders resolved
// from file_holders / worm search)
let author: NodeId = [0u8; 32];
// Fetch via content search + PostFetch
let author = author_id.unwrap_or([0u8; 32]);
info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex);
let search_result = tokio::time::timeout(

View file

@ -1,6 +1,6 @@
[package]
name = "itsgoin-desktop"
version = "0.8.0-alpha"
version = "0.7.0"
edition = "2021"
[lib]

View file

@ -5,7 +5,6 @@ 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
@ -17,17 +16,6 @@ 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

View file

@ -6,7 +6,7 @@ use tracing::info;
use itsgoin_core::identity::IdentityManager;
use itsgoin_core::node::Node;
use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent};
use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent};
/// The active Node, swappable on identity switch.
type AppNode = Arc<tokio::sync::RwLock<Arc<Node>>>;
@ -145,8 +145,6 @@ 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)]
@ -281,11 +279,7 @@ async fn post_to_dto(
// Engagement data
let reaction_counts = {
let storage = node.storage.get().await;
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = 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()
storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
.into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect()
@ -561,13 +555,9 @@ async fn list_posting_identities(
async fn create_posting_identity(
state: State<'_, AppNode>,
display_name: String,
greetings_open: Option<bool>,
) -> Result<PostingIdentityDto, String> {
let node = get_node(&state).await;
let id = node
.create_posting_identity(display_name, greetings_open)
.await
.map_err(|e| e.to_string())?;
let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?;
Ok(PostingIdentityDto {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
@ -873,12 +863,10 @@ 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 identities = storage.list_posting_identities().unwrap_or_default();
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = identities.iter().map(|p| p.node_id).collect();
let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default();
let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).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.
@ -1154,18 +1142,6 @@ async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenD
}).collect())
}
#[tauri::command]
async fn exit_app(app: tauri::AppHandle) {
// On Android, the foreground NodeService survives Activity exit by
// design (keeps network alive when backgrounded). When the user
// explicitly hits the in-app close button, also stop the service
// so we actually free the device's network/wakelock.
#[cfg(target_os = "android")]
itsgoin_core::android_wifi::stop_node_service();
app.exit(0);
}
#[tauri::command]
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
let node = get_node(&state).await;
@ -1363,7 +1339,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.into_iter()
.map(|(nid, _, _)| nid)
.collect();
let (social_ids, n2_ids, n3_ids, n4_ids) = {
let (social_ids, n2_ids, n3_ids) = {
let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage
.list_social_routes()
@ -1372,7 +1348,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.map(|r| r.node_id)
.collect();
let n2: std::collections::HashSet<_> = storage
.list_reach_ids_at(2)
.build_n2_share()
.unwrap_or_default()
.into_iter()
.collect();
@ -1381,12 +1357,7 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
.unwrap_or_default()
.into_iter()
.collect();
let n4: std::collections::HashSet<_> = storage
.list_distinct_n4()
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3, n4)
(social, n2, n3)
};
let mut dtos = Vec::with_capacity(records.len());
@ -1411,8 +1382,6 @@ async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
"n2"
} else if n3_ids.contains(&rec.node_id) {
"n3"
} else if n4_ids.contains(&rec.node_id) {
"n4"
} else {
"known"
};
@ -1729,283 +1698,6 @@ 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<bool, String> {
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<String>,
updated_at_ms: u64,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryStatusDto {
listed: bool,
keywords: Vec<String>,
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<String>,
) -> 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<RegistryStatusDto, String> {
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<Vec<RegistryEntryDto>, 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<GreetingSlotDto> {
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<Option<GreetingSlotDto>, 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<Vec<GreetingDto>, 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<bool, String> {
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.
@ -2073,12 +1765,12 @@ async fn list_connections(state: State<'_, AppNode>) -> Result<Vec<ConnectionDto
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut dtos = Vec::with_capacity(conns.len());
for (nid, slot, connected_at) in conns {
for (nid, slot_kind, connected_at) in conns {
let display_name = node.get_display_name(&nid).await.unwrap_or(None);
dtos.push(ConnectionDto {
node_id: hex::encode(nid),
display_name,
slot_kind: slot.to_string(),
slot_kind: format!("{:?}", slot_kind),
connected_at,
});
}
@ -2340,28 +2032,6 @@ async fn get_seen_engagement(
}))
}
/// Comment count for the engagement badges, EXCLUDING greeting open-slot
/// comments: greetings have their own dedicated badge/inbox, and counting
/// them here would double-count every greeting into "My Posts" while
/// pointing the user at a bio post where the comment renders as sealed
/// ciphertext instead of at the Greetings inbox.
fn non_greeting_comment_count(
storage: &itsgoin_core::storage::Storage,
post_id: &itsgoin_core::types::PostId,
post: &itsgoin_core::types::Post,
) -> 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>,
@ -2370,17 +2040,11 @@ 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<itsgoin_core::types::NodeId> = 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)| {
!own_ids.contains(&p.author)
p.author != node.node_id
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
@ -2393,20 +2057,18 @@ 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 !own_ids.contains(&post.author) { continue; }
if post.author != node.node_id { 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, &own_ids)
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
.unwrap_or_default()
.iter()
.map(|(_, count, _)| *count)
.sum();
// 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);
let total_comments = storage.get_comment_count(id).unwrap_or(0);
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 {
@ -2421,14 +2083,14 @@ async fn get_badge_counts(
matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) || (!own_ids.contains(&p.author) && matches!(
) || (p.author != node.node_id && 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 own_ids.contains(&post.author) {
let partner = if post.author == node.node_id {
// sent DM — skip for unread count
continue;
} else {
@ -2446,22 +2108,16 @@ async fn get_badge_counts(
let mut new_reacts = 0usize;
let mut new_comments = 0usize;
for (id, post, _vis) in &all_posts {
if !own_ids.contains(&post.author) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
if post.author != node.node_id { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
// Greeting open-slot comments excluded here too (dedicated badge).
let total_comments = non_greeting_comment_count(&storage, id, post);
let total_comments = storage.get_comment_count(id).unwrap_or(0);
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; }
}
// 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 })
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
}
#[tauri::command]
@ -2500,13 +2156,12 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkSummaryDto {
mesh_count: usize,
temp_count: usize,
mesh_cap: usize,
preferred_count: usize,
local_count: usize,
wide_count: usize,
total_connections: usize,
n2_distinct: usize,
n3_distinct: usize,
n4_distinct: usize,
has_public_v6: bool,
has_public_v4: bool,
has_upnp: bool,
@ -2516,24 +2171,29 @@ struct NetworkSummaryDto {
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count();
let temp = conns.len() - mesh;
let (n2, n3, n4) = {
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 storage = node.storage.get().await;
(
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
)
let n2 = storage.count_distinct_n2().unwrap_or(0);
let n3 = storage.count_distinct_n3().unwrap_or(0);
(n2, n3)
};
Ok(NetworkSummaryDto {
mesh_count: mesh,
temp_count: temp,
mesh_cap: node.device_profile().mesh_slots(),
preferred_count: preferred,
local_count: local,
wide_count: wide,
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(),
@ -2755,14 +2415,15 @@ struct ActivityLogDto {
events: Vec<ActivityEventDto>,
rebalance_last_ms: u64,
rebalance_interval_secs: u64,
convection_last_ms: u64,
anchor_register_last_ms: u64,
anchor_register_interval_secs: u64,
}
#[tauri::command]
async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, String> {
let node = get_node(&state).await;
let events = node.get_activity_log(200);
let (rebalance_last, convection_last) = node.timer_state();
let (rebalance_last, anchor_last) = node.timer_state();
let dto_events: Vec<ActivityEventDto> = events.into_iter().map(|e| {
ActivityEventDto {
timestamp_ms: e.timestamp_ms,
@ -2776,7 +2437,8 @@ async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, S
events: dto_events,
rebalance_last_ms: rebalance_last,
rebalance_interval_secs: 600,
convection_last_ms: convection_last,
anchor_register_last_ms: anchor_last,
anchor_register_interval_secs: 600,
})
}
@ -2792,47 +2454,31 @@ async fn trigger_rebalance(state: State<'_, AppNode>) -> Result<String, String>
async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let node_id = node.node_id;
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.
// Try known_anchors table first (populated by anchor register cycle),
// fall back to anchor peers from the peers table (is_anchor = true)
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.get().await;
let mut out: Vec<(NodeId, Vec<std::net::SocketAddr>)> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() {
let socks: Vec<std::net::SocketAddr> = addrs.iter().filter_map(|a| a.parse().ok()).collect();
if !socks.is_empty() && seen.insert(nid) {
out.push((nid, socks));
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()
}
}
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 connected = 0usize;
let mut asked = 0usize;
let mut refused = 0usize;
let mut total = 0usize;
let mut reachable = 0usize;
for (anchor_nid, anchor_addrs) in &anchors {
if *anchor_nid == node_id {
continue;
}
if !node.network.is_peer_connected_or_session(anchor_nid).await {
// Connect to anchor if not already connected
if !node.network.is_peer_connected(anchor_nid).await {
let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
@ -2841,26 +2487,40 @@ async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String>
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
if node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() {
if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await {
continue;
}
}
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;
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;
}
}
}
}
Err(_) => {}
}
}
Ok(format!(
"Convection: {} new peers from {} anchors ({} refused)",
connected, asked, refused
))
Ok(format!("Got {} referrals from {} anchors", total, reachable))
}
#[tauri::command]
@ -3204,13 +2864,14 @@ async fn switch_identity(
// Start background tasks on the new node
new_node.start_accept_loop();
new_node.start_sync_cycle();
new_node.start_pull_cycle(300);
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_convection_loop();
new_node.start_anchor_register_cycle(600);
new_node.start_upnp_renewal_cycle();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
@ -3302,8 +2963,6 @@ 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,
@ -3328,13 +2987,14 @@ 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_sync_cycle();
node.start_pull_cycle(300);
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_convection_loop();
node.start_anchor_register_cycle(600);
node.start_upnp_renewal_cycle();
node.start_upnp_tcp_renewal_cycle();
node.start_http_server();
node.start_bootstrap_connectivity_check();
@ -3392,13 +3052,11 @@ async fn import_public_posts(
zip_path: String,
) -> Result<String, String> {
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.default_posting_id,
&node.node_id,
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
@ -3452,14 +3110,12 @@ async fn import_merge_with_key(
key_hex: String,
) -> Result<String, String> {
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.default_posting_id,
&node.node_id,
&node.secret_seed(),
).await.map_err(|e| e.to_string())?;
Ok(result.message)
@ -3576,13 +3232,14 @@ pub fn run() {
// Start all background networking tasks
boot_node.start_accept_loop();
boot_node.start_sync_cycle();
boot_node.start_pull_cycle(300);
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_convection_loop();
boot_node.start_anchor_register_cycle(600);
boot_node.start_upnp_renewal_cycle();
boot_node.start_upnp_tcp_renewal_cycle();
boot_node.start_http_server();
boot_node.start_bootstrap_connectivity_check();
@ -3723,20 +3380,6 @@ 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")

View file

@ -1,6 +1,6 @@
{
"productName": "itsgoin",
"version": "0.8.0-alpha",
"version": "0.7.0",
"identifier": "com.itsgoin.app",
"build": {
"frontendDist": "../../frontend",

View file

@ -18,34 +18,26 @@ SSH_OPTS="-o StrictHostKeyChecking=no"
KEYSTORE="itsgoin.keystore"
KS_ALIAS="itsgoin"
# 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
echo "=== Deploying v${VERSION} (announce channel: ${ANN_CHANNEL}) ==="
# 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.
echo "=== Building AppImage (includes GStreamer patch) ==="
./build-appimage.sh
echo "=== Building APK ==="
cargo tauri android build --apk
VERSION=$(grep '"version"' crates/tauri-app/tauri.conf.json | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/')
echo "=== Deploying v${VERSION} ==="
# Build CLI
echo "=== Building CLI ==="
cargo build -p itsgoin-cli --release
cargo build -p itsgoin-cli --release &
CLI_PID=$!
# Build APK
echo "=== Building APK ==="
cargo tauri android build --apk &
APK_PID=$!
# Build AppImage (includes GStreamer patch)
echo "=== Building AppImage ==="
./build-appimage.sh
wait $CLI_PID
echo "=== CLI build complete ==="
wait $APK_PID
echo "=== APK build complete ==="
# Sign APK
echo "=== Signing APK ==="
@ -87,11 +79,10 @@ 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 ${ANN_CHANNEL} \
--ann-channel stable \
--ann-version ${VERSION} \
--ann-url https://itsgoin.com/download.html \
--ann-title 'ItsGoin v${VERSION} available' \

View file

@ -449,12 +449,6 @@ $('#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;
@ -1090,117 +1084,7 @@ 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 = `<p class="empty-hint">No greetings yet</p>`;
} else {
container.innerHTML = greetings.map(g => {
const icon = generateIdenticon(g.senderPersonaId, 18);
const label = escapeHtml(g.senderName || g.senderPersonaId.slice(0, 12));
return `<div class="peer-card greeting-item" data-comment-author="${g.commentAuthor}" data-post-id="${g.postId}" data-ts="${g.timestampMs}">
<div class="peer-card-row">${icon} <a class="peer-name-link greet-bio-link" data-node-id="${g.senderPersonaId}" data-name="${label}">${label}</a> <span class="last-seen">${formatTimeAgo(g.timestampMs)}</span></div>
<div style="font-size:0.85rem;line-height:1.4;margin-top:0.25rem;white-space:pre-wrap;word-break:break-word">${escapeHtml(g.content)}</div>
<div class="peer-card-actions">
<button class="btn btn-primary btn-sm greet-reply-btn">Reply</button>
<button class="btn btn-danger btn-sm greet-dismiss-btn">Dismiss</button>
</div>
<div class="greet-reply-box hidden" style="margin-top:0.4rem;display:flex;gap:0.4rem;align-items:flex-end">
<textarea class="conv-reply-input" rows="2" maxlength="600" placeholder="Sealed reply..." style="flex:1"></textarea>
<button class="btn btn-primary btn-sm greet-reply-send">Send</button>
</div>
</div>`;
}).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 = `<p class="status-err">Error: ${e}</p>`;
}
}
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'),
@ -1446,9 +1330,7 @@ async function loadMessages(force) {
const hasUnread = thread.posts.some(p => !p.isMe && p.timestampMs > lastReadMs);
if (hasUnread) unreadCount++;
}
// v0.8 (A3): the Messages badge = unread DMs + undismissed greetings.
_lastDmUnread = unreadCount;
updateTabBadge('messages', unreadCount + _greetingsCount);
updateTabBadge('messages', unreadCount);
} catch (e) {
conversationsList.innerHTML = `<div class="section-card"><p class="status-err">Error: ${e}</p></div>`;
}
@ -1550,7 +1432,6 @@ async function loadPeers() {
else if (p.reach === 'n1') reachBadge = '<span class="reach-badge reach-n1">N1</span>';
else if (p.reach === 'n2') reachBadge = '<span class="reach-badge reach-n2">N2</span>';
else if (p.reach === 'n3') reachBadge = '<span class="reach-badge reach-n3">N3</span>';
else if (p.reach === 'n4') reachBadge = '<span class="reach-badge reach-n4">N4</span>';
let actions = '';
if (p.nodeId === myNodeId) {
@ -1782,7 +1663,7 @@ async function openBioModal(nodeId, preloadedName) {
overlay.classList.remove('hidden');
try {
// `resolve_display` returns {displayName, bio, avatarCid} for any NodeId.
// `resolve_display` returns {name, 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(() => []);
@ -1790,7 +1671,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.displayName) || preloadedName || nodeId.slice(0, 12);
const name = (resolved && resolved.name) || preloadedName || nodeId.slice(0, 12);
const bio = (resolved && resolved.bio) || '';
const icon = generateIdenticon(nodeId, 48);
@ -1799,20 +1680,19 @@ async function openBioModal(nodeId, preloadedName) {
<div style="display:flex;gap:0.75rem;align-items:flex-start;margin-bottom:0.75rem">
${icon}
<div style="flex:1;min-width:0">
<div id="bio-modal-name" style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div style="font-size:1.05rem;font-weight:600">${escapeHtml(name)}</div>
<div style="font-size:0.75rem;color:#888;word-break:break-all">${nodeId}</div>
</div>
</div>
${bio ? `<p id="bio-modal-bio" style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p id="bio-modal-bio" class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div id="bio-actions" style="display:flex;gap:0.4rem;flex-wrap:wrap">
${bio ? `<p style="font-size:0.9rem;line-height:1.45;margin:0 0 0.75rem">${escapeHtml(bio)}</p>` : '<p class="empty-hint" style="margin:0 0 0.75rem">No bio.</p>'}
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
<button id="bio-view-posts" class="btn btn-primary btn-sm">View Posts</button>
${(following && isVouched)
? `<button id="bio-unfriend" class="btn btn-ghost btn-sm">Unfriend</button>`
: (following
? `<button id="bio-vouch" class="btn btn-primary btn-sm">Add Vouch</button>
<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
: `<button id="bio-friend" class="btn btn-primary btn-sm">Friend</button>
<button id="bio-follow" class="btn btn-ghost btn-sm">Follow only</button>`)}
${following
? `<button id="bio-unfollow" class="btn btn-ghost btn-sm">Unfollow</button>`
: `<button id="bio-follow" class="btn btn-primary btn-sm">Follow</button>`}
${isVouched
? `<button id="bio-revoke-vouch" class="btn btn-ghost btn-sm">Revoke Vouch</button>`
: `<button id="bio-vouch" class="btn btn-ghost btn-sm">Vouch</button>`}
<button id="bio-message" class="btn btn-ghost btn-sm">Message</button>
${isIgnored
? `<button id="bio-unignore" class="btn btn-ghost btn-sm">Unignore</button>`
@ -1869,119 +1749,22 @@ async function openBioModal(nodeId, preloadedName) {
} catch (e) { toast('Error: ' + e); }
finally { vouch.disabled = false; }
};
// 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;
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;
try {
await invoke('revoke_vouch_for_peer', { nodeIdHex: nodeId });
await invoke('unfollow_node', { nodeIdHex: nodeId });
toast(`Unfriended ${name}`);
toast('Revoked and rotated');
close();
loadFollows();
loadFeed(true);
} catch (e) { toast('Error: ' + e); }
finally { unfriend.disabled = false; }
finally { revokeVouch.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 = `<p class="status-err">Error: ${e}</p>`;
}
}
// 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 = `
<textarea id="greeting-text" rows="4" maxlength="600" placeholder="Introduce yourself..." style="width:100%;padding:0.4rem;background:#1a1a2e;color:#e0e0e0;border:1px solid #333;border-radius:4px;resize:vertical;font-family:inherit;font-size:0.9rem"></textarea>
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;margin-top:0.5rem">
<span class="hint">Sealed &mdash; only ${escapeHtml(name)} can read this</span>
<button id="greeting-send-btn" class="btn btn-primary btn-sm">Send</button>
</div>`;
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);
@ -2079,11 +1862,11 @@ async function loadNetworkSummary() {
const s = await invoke('get_network_summary');
networkSummaryEl.innerHTML = `<div class="diag-grid">
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
<div class="diag-item"><span class="diag-value">${s.meshCount}/${s.meshCap}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.tempCount}</span><span class="diag-label">Temp referral</span></div>
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
<div class="diag-item"><span class="diag-value">${s.n4Distinct}</span><span class="diag-label">N4 Reach</span></div>
</div>`;
} catch (e) {
networkSummaryEl.innerHTML = `<p class="empty-hint">Could not load network summary</p>`;
@ -2100,9 +1883,10 @@ 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 === 'temp' ? 'slot-temp' : 'slot-mesh';
const slotLabel = c.slotKind === 'mesh' ? 'Mesh'
: c.slotKind === 'temp' ? 'Temp referral' : c.slotKind;
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 duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
return `<div class="peer-card">
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
@ -2194,13 +1978,7 @@ function renderTimer(label, lastMs, intervalSecs, now) {
}
function escapeHtml(str) {
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function formatTimeAgo(timestampMs) {
@ -2865,7 +2643,7 @@ async function doPost() {
try {
const vis = visibilitySelect.value;
const params = { content: content || '' };
if (vis !== 'public' && vis !== 'fof_closed') {
if (vis !== 'public') {
params.visibility = vis;
}
if (vis === 'circle') {
@ -2889,22 +2667,14 @@ async function doPost() {
const reactPerm = document.getElementById('react-perm-select').value;
let result;
if (vis === 'fof_closed') {
// Visibility = Extended Friends (FoF). Body + comments are
// encrypted under the FoF gating CEK. Mode 1.
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 (selectedFiles.length > 0 || params.postingIdHex) {
toast('FoF (Extended Friends) posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
const created = await invoke('create_post_fof_closed', {
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.');
toast('FoF posts with attachments or non-default persona not yet supported.');
postBtn.disabled = false;
return;
}
@ -2912,6 +2682,20 @@ async function doPost() {
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.');
postBtn.disabled = false;
return;
}
const created = await invoke('create_post_fof_closed', {
content: params.content,
});
result = { id: created.postId };
} else if (selectedFiles.length > 0) {
// Convert ArrayBuffers to base64 strings
const files = selectedFiles.map(f => {
@ -2944,7 +2728,7 @@ async function doPost() {
selectedFiles = [];
renderAttachmentPreview();
updateCharCount();
visibilitySelect.value = 'fof_closed';
visibilitySelect.value = 'public';
updateVisibilityUI();
toast('Posted!');
loadFeed(true);
@ -3024,41 +2808,11 @@ 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!');
@ -3228,11 +2982,6 @@ 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() {
@ -3250,9 +2999,6 @@ 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() {
@ -3407,7 +3153,6 @@ 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();
@ -3444,16 +3189,6 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
<input type="checkbox" id="lb-public-visible" ${currentVisible ? 'checked' : ''} />
Show my profile to non-circle peers
</label>
<label class="checkbox-label" style="margin:0.5rem 0 0.25rem;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-registry-listed" />
List me in the network registry (searchable by anyone)
</label>
<input id="lb-registry-keywords" type="text" placeholder="Keywords, comma-separated (up to 8)" maxlength="280" style="width:100%;margin-bottom:0.25rem" />
<div id="lb-registry-status" class="empty-hint" style="font-size:0.72rem;margin-bottom:0.5rem">Not listed</div>
<label class="checkbox-label" style="margin:0.5rem 0;display:block;font-size:0.8rem">
<input type="checkbox" id="lb-greetings-open" checked />
Accept greetings (sealed hellos from people outside your circles)
</label>
<div style="display:flex;gap:0.5rem;justify-content:center;margin-top:0.75rem">
<button class="btn btn-primary btn-sm" id="lb-profile-save">Save</button>
</div>
@ -3466,72 +3201,14 @@ $('#profile-lightbox-btn').addEventListener('click', () => {
</div>
</div>`;
document.body.appendChild(overlay);
// 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 () => {
overlay.querySelector('#lb-profile-save').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;
@ -3593,58 +3270,6 @@ $('#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 = `<h4 class="subsection-title" style="margin:0 0 0.4rem">Registry results</h4>` + entries.map(e => {
const icon = generateIdenticon(e.authorId, 18);
const label = escapeHtml(e.displayName);
const kwLine = e.keywords && e.keywords.length
? `<div class="peer-card-meta">${e.keywords.map(k => escapeHtml(k)).join(' &middot; ')}</div>`
: '';
const ageLine = e.updatedAtMs
? `<div class="peer-card-lastseen"><span class="last-seen">Listed ${formatTimeAgo(e.updatedAtMs)}</span></div>`
: '';
return `<div class="peer-card" data-node-id="${e.authorId}">
<div class="peer-card-row">${icon} <a class="peer-name-link bio-link" data-node-id="${e.authorId}" data-name="${label}">${label}</a></div>
${kwLine}
${ageLine}
</div>`;
}).join('');
results.querySelectorAll('.bio-link').forEach(el => {
el.addEventListener('click', (ev) => {
ev.preventDefault();
openBioModal(el.dataset.nodeId, el.dataset.name);
});
});
} catch (e) {
results.innerHTML = `<p class="status-err">Error: ${e}</p>`;
} 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.
{
@ -3680,7 +3305,7 @@ function openDiagnostics() {
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="rebalance-btn" class="btn btn-ghost btn-sm">Rebalance Now</button>
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Find Peers (Convection)</button>
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="show-ourinfo-btn" class="btn btn-ghost btn-sm">Our Info</button>
@ -3816,11 +3441,11 @@ function openDiagnostics() {
try {
const r = await invoke('request_referrals');
btn.textContent = r;
setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000);
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
loadAllDiagnostics();
} catch (e) {
btn.textContent = 'Failed';
setTimeout(() => { btn.textContent = 'Find Peers (Convection)'; btn.disabled = false; }, 3000);
setTimeout(() => { btn.textContent = 'Request Referrals'; btn.disabled = false; }, 3000);
}
});
loadAllDiagnostics();
@ -3897,11 +3522,11 @@ if (exportKeyBtn) exportKeyBtn.addEventListener('click', async () => {
const key = await invoke('export_identity');
try {
await navigator.clipboard.writeText(key);
toast('Device address key copied to clipboard. KEEP IT SECRET!');
toast('Identity 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 device address key (KEEP IT SECRET!):', key);
prompt('Copy your identity key (KEEP IT SECRET!):', key);
}
} catch (e) {
console.error('export_identity failed:', e);
@ -3940,16 +3565,14 @@ document.querySelectorAll('.text-size-opt').forEach(btn => {
});
});
// --- 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.
// --- Identity management ---
async function loadIdentities() {
const list = $('#identities-list');
if (!list) return;
try {
const identities = await invoke('list_identities');
if (identities.length === 0) {
list.innerHTML = '<p class="empty-hint">No device addresses</p>';
list.innerHTML = '<p class="empty-hint">No identities</p>';
return;
}
list.innerHTML = identities.map(id => {
@ -3972,7 +3595,7 @@ async function loadIdentities() {
btn.textContent = 'Switching...';
try {
await invoke('switch_identity', { nodeIdHex: btn.dataset.id });
toast('Device address switched — reloading...');
toast('Identity switched — reloading...');
setTimeout(() => location.reload(), 1000);
} catch (e) { toast('Error: ' + e); btn.disabled = false; btn.textContent = 'Switch'; }
});
@ -3981,10 +3604,10 @@ async function loadIdentities() {
// Wire delete buttons
list.querySelectorAll('.delete-id-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Delete this device address? This cannot be undone.')) return;
if (!confirm('Delete this identity? This cannot be undone.')) return;
try {
await invoke('delete_identity', { nodeIdHex: btn.dataset.id });
toast('Device address deleted');
toast('Identity deleted');
loadIdentities();
} catch (e) { toast('Error: ' + e); }
});
@ -4000,9 +3623,8 @@ $('#create-identity-btn').addEventListener('click', () => {
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:350px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.5rem">New Device Address</h3>
<p style="font-size:0.72rem;color:#888;margin-bottom:0.75rem">A new QUIC network endpoint for this device. Your personas are unaffected.</p>
<input id="new-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Identity</h3>
<input id="new-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-id-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-id-cancel">Cancel</button>
@ -4011,10 +3633,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('Label is required'); return; }
if (!name) { toast('Name is required'); return; }
try {
const nodeId = await invoke('create_identity', { name });
toast(`Device address created: ${nodeId.substring(0, 12)}`);
toast(`Identity created: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
@ -4029,10 +3651,10 @@ $('#import-identity-btn').addEventListener('click', () => {
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Import Device Address Key</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste a 64-character hex key from a previous device-address export. This is NOT how you move your personas &mdash; use Export/Import personas above for that.</p>
<input id="import-id-key" type="text" placeholder="Device address key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Label (your reference, not shared)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Identity</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.5rem">Paste the 64-character hex key from an identity export.</p>
<input id="import-id-key" type="text" placeholder="Identity key (64 hex chars)" maxlength="64" style="width:100%;margin-bottom:0.5rem;font-family:monospace;font-size:0.7rem" />
<input id="import-id-name" type="text" placeholder="Display name" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="import-id-go">Import</button>
<button class="btn btn-ghost btn-sm" id="import-id-cancel">Cancel</button>
@ -4045,7 +3667,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(`Device address imported: ${nodeId.substring(0, 12)}`);
toast(`Identity imported: ${nodeId.substring(0, 12)}`);
overlay.remove();
loadIdentities();
} catch (e) { toast('Error: ' + e); }
@ -4325,46 +3947,15 @@ function renderPersonasList() {
const deleteBtn = p.isDefault
? ''
: `<button class="btn btn-ghost btn-sm delete-persona-btn" data-id="${p.nodeId}" style="font-size:0.65rem;color:#e74c3c">Delete</button>`;
// 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 = `<button class="btn btn-ghost btn-sm persona-greetings-btn" data-id="${p.nodeId}" style="font-size:0.65rem" disabled>Greetings: ...</button>`;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:0.3rem 0;border-bottom:1px solid #222;gap:0.5rem">
<div style="min-width:0;flex:1">
<div style="font-weight:600">${escapeHtml(label)}${defaultTag}</div>
<div style="font-size:0.6rem;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.nodeId.substring(0, 16)}...</div>
</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${greetBtn}${setDefaultBtn}${deleteBtn}</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;justify-content:flex-end">${setDefaultBtn}${deleteBtn}</div>
</div>`;
}).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;
@ -4419,10 +4010,6 @@ $('#create-persona-btn').addEventListener('click', () => {
<h3 style="color:#7fdbca;margin:0 0 0.75rem">New Persona</h3>
<p style="font-size:0.7rem;color:#888;margin-bottom:0.75rem">Peers will see this persona as a distinct author. No one can tell which personas belong to the same device.</p>
<input id="new-persona-name" type="text" placeholder="Display name (e.g. Work, Garden Club)" maxlength="50" style="width:100%;margin-bottom:0.75rem" />
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.75rem;margin-bottom:0.75rem">
<input type="checkbox" id="new-persona-greetings" checked />
Accept greetings (sealed hellos from people outside your circles)
</label>
<div style="display:flex;gap:0.5rem;justify-content:center">
<button class="btn btn-primary btn-sm" id="new-persona-create">Create</button>
<button class="btn btn-ghost btn-sm" id="new-persona-cancel">Cancel</button>
@ -4435,12 +4022,7 @@ $('#create-persona-btn').addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) { toast('Enter a name'); return; }
try {
// 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 });
await invoke('create_posting_identity', { displayName: name });
toast(`Persona created: ${name}`);
overlay.remove();
loadPersonas();
@ -4459,14 +4041,14 @@ $('#export-btn').addEventListener('click', () => {
overlay.className = 'image-lightbox';
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Export your personas</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Save your personas + (optionally) your posts to a ZIP file so you can import them on another device.</p>
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:400px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Export Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Choose what to include in the export ZIP.</p>
<div style="display:flex;flex-direction:column;gap:0.5rem;text-align:left;margin-bottom:1rem">
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Persona keys only (tiny backup &mdash; restores your identity but not your posts)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media only (no keys &mdash; safe to share publicly)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + persona keys (typical &ldquo;move to new device&rdquo;)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, keys, follows, settings)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="identity_only" /> Identity key only (tiny backup)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_only" /> Posts + media (no key safe to share)</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="posts_with_identity" checked /> Posts + media + identity key</label>
<label class="checkbox-label"><input type="radio" name="export-scope" value="everything" /> Everything (posts, key, follows, settings)</label>
</div>
<div style="margin-bottom:0.75rem">
<label style="font-size:0.75rem;color:#888">Save to folder:</label>
@ -4536,8 +4118,8 @@ $('#import-btn').addEventListener('click', () => {
overlay.style.cursor = 'default';
overlay.innerHTML = `
<div style="background:#1a1a2e;border:1px solid #333;border-radius:12px;padding:1.5rem;max-width:420px;width:90%;text-align:center">
<h3 style="color:#7fdbca;margin:0 0 0.5rem">Import from another device</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP. Default action restores the exported personas onto this device so you can post as them.</p>
<h3 style="color:#7fdbca;margin:0 0 0.75rem">Import Data</h3>
<p style="font-size:0.75rem;color:#888;margin-bottom:0.75rem">Select an ItsGoin export ZIP file.</p>
<div style="display:flex;gap:0.25rem;margin-bottom:0.75rem">
<input id="import-zip-path" type="text" placeholder="/path/to/itsgoin-export.zip" style="flex:1;font-size:0.8rem" />
<button class="btn btn-ghost btn-sm" id="import-browse">Browse</button>
@ -4639,20 +4221,6 @@ $('#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';
@ -4734,7 +4302,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) + (b.greetings || 0));
updateTabBadge('messages', b.unreadMessages || 0);
// Ticker + notifications only after user leaves welcome screen
// (welcome page already shows these counts directly)
}).catch(() => {});
@ -4817,7 +4385,7 @@ async function init() {
<p style="color:#888;font-size:0.85rem;margin-bottom:1.5rem">How would you like to get started?</p>
<div style="display:flex;flex-direction:column;gap:0.75rem">
<button id="first-run-new" class="btn btn-primary" style="padding:0.75rem">Start Fresh</button>
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import from another device</button>
<button id="first-run-import" class="btn btn-ghost" style="padding:0.75rem">Import an Identity</button>
</div>
</div>`;
document.body.appendChild(chooser);
@ -4888,7 +4456,6 @@ 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);

View file

@ -13,12 +13,6 @@
<h2>Welcome to ItsGoin</h2>
<p>Pick a display name if you want one &mdash; or leave blank to stay anonymous.</p>
<input id="setup-name" type="text" placeholder="Display name (optional)" maxlength="50" autofocus />
<!-- v0.8 (A3): active choice at first profile publish — pre-checked,
visible, opt-out BEFORE anything ships (never silently defaulted). -->
<label class="checkbox-label" style="display:block;text-align:left;font-size:0.8rem;margin:0.5rem 0 0.75rem;cursor:pointer">
<input type="checkbox" id="setup-greetings-check" checked />
Accept greetings &mdash; people who find you can send a sealed hello. Only you can read them. Turn off any time in Profiles.
</label>
<button id="setup-btn" class="btn btn-primary">Continue</button>
</div>
</div>
@ -31,7 +25,6 @@
<span id="net-dot"></span>
<span id="net-labels"></span>
</div>
<button id="close-app-btn" title="Close app (stops connections to save battery)" aria-label="Close app"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg></button>
</div>
<nav id="tabs">
<button class="tab" data-tab="feed"><span class="tab-icon">&#x1f4f0;</span><span class="tab-label">Feed</span></button>
@ -103,9 +96,8 @@
<div id="visibility-row">
<select id="persona-select" title="Post as" class="hidden"></select>
<select id="visibility-select">
<option value="fof_closed" selected>Extended Friends (FoF)</option>
<option value="friends">Friends</option>
<option value="public">Public</option>
<option value="friends">Friends</option>
<option value="circle">Circle</option>
</select>
<select id="circle-select" class="hidden"></select>
@ -113,6 +105,7 @@
<option value="public">Comments: All</option>
<option value="followers_only">Comments: Followers</option>
<option value="friends_of_friends">Comments: Friends of Friends</option>
<option value="fof_closed">Body+Comments: FoF only (Mode 1)</option>
<option value="none">Comments: Off</option>
</select>
<select id="react-perm-select" title="React permission">
@ -145,14 +138,8 @@
</div>
<div class="section-card">
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Hide Discover</button>
<div id="discover-body">
<!-- v0.8 (A3): registry search — on-demand chain pull, local query -->
<div class="input-row" style="margin-bottom:0.5rem">
<input id="registry-search-input" placeholder="Search the registry: name or keywords" />
<button id="registry-search-btn" class="btn btn-primary">Search</button>
</div>
<div id="registry-results"></div>
<button id="discover-toggle" class="btn btn-ghost btn-sm section-toggle">Discover People</button>
<div id="discover-body" class="hidden">
<p class="empty-hint" style="margin-bottom:0.5rem">Named profiles on the network you haven't followed or ignored.</p>
<div id="discover-list"></div>
</div>
@ -193,15 +180,6 @@
<h3>Message Requests</h3>
<div id="message-requests-list"></div>
</div>
<!-- v0.8 (A3): greetings inbox — [Reply] and [Dismiss] ONLY.
Vouching is People-tab relationship management, never a
Messages action (round 9). -->
<div class="section-card" id="greetings-section">
<h3>Greetings</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.75rem">Sealed hellos from people outside your circles. Replies go back sealed &mdash; only the sender can read them.</p>
<div id="greetings-list"></div>
</div>
</section>
<!-- Settings tab -->
@ -253,13 +231,13 @@
<button id="check-updates-btn" class="btn btn-ghost btn-sm" style="margin-top:0.5rem">Check now</button>
</div>
<div class="section-card" style="text-align:left">
<h3 style="margin-bottom:0.4rem;text-align:center">Your data on this device</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem;line-height:1.5">
<strong style="color:#7fdbca">Personas</strong> are who you are to peers &mdash; the keys you post and message with. Most people only need one. To move your account to a new device, you <em>export your personas</em> from this device and <em>import them</em> on the new one.
<br><br>
<strong style="color:#888">Device Address</strong> below is this device's own network endpoint &mdash; usually not what you want to move. Leave it alone unless you know why you're touching it.
</p>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.5rem">Identities</h3>
<div id="identities-list" style="margin-bottom:0.5rem"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Identity</button>
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Key</button>
</div>
</div>
<div class="section-card" style="text-align:center">
@ -270,25 +248,9 @@
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.4rem">Move to another device</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.78rem">
Export creates a ZIP containing your personas (and optionally posts/follows). Import on the other device's Settings &gt; Move to another device.
</p>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="export-btn" class="btn btn-primary btn-sm">Export personas</button>
<button id="import-btn" class="btn btn-ghost btn-sm">Import from another device</button>
</div>
</div>
<div class="section-card" style="text-align:center">
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Device Address (advanced)</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
This device's network endpoint &mdash; 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.
</p>
<div id="identities-list" style="margin-bottom:0.5rem"></div>
<div style="display:flex;gap:0.5rem;justify-content:center;flex-wrap:wrap">
<button id="create-identity-btn" class="btn btn-ghost btn-sm">New Device Address</button>
<button id="import-identity-btn" class="btn btn-ghost btn-sm">Import Address Key</button>
<button id="export-btn" class="btn btn-ghost btn-sm">Export</button>
<button id="import-btn" class="btn btn-ghost btn-sm">Import</button>
</div>
</div>
@ -296,17 +258,6 @@
<button id="notifications-btn" class="btn btn-ghost btn-full">Notifications</button>
</div>
<div class="section-card">
<h3 style="margin-bottom:0.4rem;font-size:0.85rem;color:#888">Session Relay (off by default)</h3>
<p class="empty-hint" style="margin-bottom:0.5rem;font-size:0.72rem">
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 <em>using</em> other peers as relays and <em>serving</em> as a relay for others.
</p>
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer">
<input type="checkbox" id="session-relay-toggle">
<span>Enable session relay</span>
</label>
</div>
<!-- Hidden: node-info, anchors, diagnostics btn moved elsewhere -->
<div class="hidden">
<button id="anchors-toggle"></button>
@ -349,7 +300,7 @@
<div class="section-card">
<h3>Danger Zone</h3>
<p class="empty-hint">Delete all local data. Device address key preserved.</p>
<p class="empty-hint">Delete all local data. Identity key preserved.</p>
<button id="reset-data-btn" class="btn btn-danger btn-full">Reset All Data</button>
</div>
</section>

View file

@ -18,9 +18,6 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
#net-dot.net-green { background: #22c55e; }
#net-labels { font-size: 0.65rem; color: #888; display: flex; gap: 0.3rem; }
.net-label { background: #2a2a40; padding: 0.1rem 0.35rem; border-radius: 3px; color: #aab; }
#close-app-btn { margin-left: 0.6rem; padding: 0.3rem 0.5rem; background: transparent; border: 1px solid #444; border-radius: 4px; color: #999; font-size: 1rem; line-height: 1; cursor: pointer; }
#close-app-btn:hover { color: #e74c3c; border-color: #e74c3c; }
#close-app-btn:active { background: #2a1a1a; }
/* Setup overlay */
.overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 10, 20, 0.92); display: flex; align-items: center; justify-content: center; z-index: 200; }
@ -227,8 +224,7 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
.identicon { display: inline-block; vertical-align: middle; flex-shrink: 0; border-radius: 2px; }
/* Visibility selector */
#visibility-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.35rem; align-items: center; max-width: 100%; }
#visibility-row select { max-width: 100%; }
#visibility-row { display: flex; gap: 0.4rem; margin-top: 0.35rem; align-items: center; }
#visibility-select, #circle-select { background: #1a1a2e; border: 1px solid #444; border-radius: 3px; padding: 0.2rem 0.4rem; font-size: 0.75rem; font-family: inherit; -webkit-appearance: none; appearance: none; }
#visibility-select:focus, #circle-select:focus { outline: none; border-color: #7fdbca; }
#visibility-select option, #circle-select option { background: #fff; color: #000; }
@ -359,8 +355,9 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
/* Slot kind badges */
.slot-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; }
.slot-mesh { background: #1e2040; color: #aab; }
.slot-temp { background: #2a2a1e; color: #e2b93d; }
.slot-preferred { background: #1a3a2e; color: #7fdbca; }
.slot-local { background: #1e2040; color: #aab; }
.slot-wide { background: #2a2a1e; color: #e2b93d; }
/* Reach level badges */
.reach-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-family: system-ui, sans-serif; }
@ -368,7 +365,6 @@ header h1 { font-size: clamp(1.4rem, 2.5vw, 2rem); color: #7fdbca; margin: 0; }
.reach-n1 { background: #1a2e3a; color: #7fc4db; }
.reach-n2 { background: #1e2040; color: #aab; }
.reach-n3 { background: #1e2040; color: #667; }
.reach-n4 { background: #1a1a2e; color: #556; }
/* Diagnostics summary grid */
.diag-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; margin-bottom: 0.75rem; }

View file

@ -1,142 +0,0 @@
#!/usr/bin/env bash
# v0.8 A3 integration test — registry + greetings across 3 local nodes.
#
# Spec: A3-SPEC §6 multi-node scenario. Run from the repo root after
# `cargo build -p itsgoin-cli`. Uses FIFOs to drive the interactive REPL.
#
# 1. All nodes self-materialize the same registry post ID; node1 also
# runs the --publish-registry genesis one-shot (debug gate override).
# 2. node2 registers "Alice rust p2p" → node3 `search rust` finds her.
# node2 `unregister` → node3 re-search finds nothing.
# 3. node2's bio has a greeting slot; node3 greets it; node2 sees the
# unsealed text + real sender persona; node2 replies; node3 sees the
# reply. node1 (holder) stores only ciphertext.
# 5. Newest-wins: node2 re-registers; node3 sees exactly one entry.
# 6. (Optional, ITSGOIN_TEST_TTL_SECS) comment expiry sweep.
#
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-cli{1,2,3}.log
set -u
cd "$(dirname "$0")/.."
BIN=target/debug/itsgoin
[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; }
PASS=0; FAIL=0
check() { # check <desc> <cmd...>
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc";
else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi
}
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; }
# Same noise filter for the tail-window checks below. The netlink/mDNS watcher
# logs in unpredictable bursts, and a burst landing between a REPL command and
# its assertion pushes the answer out of a raw `tail -N` window — a false
# failure with nothing wrong in the node.
quiet_tail() { grep -av "netlink\|buffer_tool\|iroh_quinn\|swarm_discovery" "$1" | tail -"$2"; }
export -f quiet_tail
sq() { sqlite3 "/tmp/itsgoin-test$1/itsgoin.db" "$2"; }
cleanup() {
{ exec 13>&- 14>&- 15>&-; } 2>/dev/null || true
kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null
rm -f /tmp/itsgoin-cmd{1,2,3}
}
trap cleanup EXIT
echo "== setup =="
kill $(pgrep -f 'itsgoin.*itsgoin-test') 2>/dev/null; sleep 1
for i in 1 2 3; do
rm -rf /tmp/itsgoin-test$i /tmp/itsgoin-cmd$i /tmp/itsgoin-cli$i.log
mkdir -p /tmp/itsgoin-test$i
mkfifo /tmp/itsgoin-cmd$i
done
export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1
# Genesis one-shot on node1's data dir (debug-gate override).
"$BIN" /tmp/itsgoin-test1 --bind 127.0.0.1:18411 --publish-registry > /tmp/itsgoin-genesis.log 2>&1
check "genesis prints registry post id matching shipped constant" \
bash -c 'g=$(grep registry_post_id /tmp/itsgoin-genesis.log | awk "{print \$2}");
c=$(grep shipped_constant /tmp/itsgoin-genesis.log | awk "{print \$2}");
[ -n "$g" ] && [ "$g" = "$c" ]'
# Start the three nodes with FIFO-driven stdin.
for i in 1 2 3; do
( exec "$BIN" /tmp/itsgoin-test$i --bind 127.0.0.1:1841$i \
< /tmp/itsgoin-cmd$i > /tmp/itsgoin-cli$i.log 2>&1 ) &
done
# Keep FIFO write ends open for the whole run.
exec 13>/tmp/itsgoin-cmd1 14>/tmp/itsgoin-cmd2 15>/tmp/itsgoin-cmd3
sleep 6
n1_id=$(strip_ansi /tmp/itsgoin-cli1.log | grep -m1 "Node ID:" | awk '{print $3}')
n2_id=$(strip_ansi /tmp/itsgoin-cli2.log | grep -m1 "Node ID:" | awk '{print $3}')
echo "node1=$n1_id node2=$n2_id"
check "all nodes self-materialized the registry post" \
bash -c 'for i in 1 2 3; do sqlite3 /tmp/itsgoin-test$i/itsgoin.db \
"SELECT count(*) FROM posts WHERE hex(id) = upper(\"'"$(grep shipped_constant /tmp/itsgoin-genesis.log | awk '{print $2}')"'\")" \
| grep -q 1 || exit 1; done'
echo "== mesh: 2,3 -> 1; 3 -> 2 =="
echo "connect $n1_id@127.0.0.1:18411" >&14
echo "connect $n1_id@127.0.0.1:18411" >&15
sleep 4
echo "connect $n2_id@127.0.0.1:18412" >&15
sleep 4
echo "== step 2: register / search / unregister =="
echo "name Alice" >&14
sleep 3
echo "register Alice rust p2p" >&14
sleep 3
echo "search rust" >&15
sleep 8
check "node3 search finds Alice" grep -q "Alice" /tmp/itsgoin-cli3.log
echo "unregister" >&14
sleep 3
echo "search rust" >&15
sleep 8
check "node3 re-search finds nothing after signed delete" \
bash -c 'quiet_tail /tmp/itsgoin-cli3.log 20 | grep -q "no registry matches"'
echo "== step 3: greeting roundtrip =="
# node2's bio post id (latest Profile post) from its DB.
bio2=$(sq 2 'SELECT lower(hex(id)) FROM posts WHERE visibility_intent = '"'"'"Profile"'"'"' ORDER BY timestamp_ms DESC LIMIT 1')
check "node2 has a bio post with a greeting slot" \
bash -c '[ -n "'"$bio2"'" ] && sqlite3 /tmp/itsgoin-test2/itsgoin.db \
"SELECT fof_gating_json FROM posts WHERE lower(hex(id))=\"'"$bio2"'\"" | grep -q Greeting'
echo "name Bob" >&15
sleep 3
echo "greet $bio2 hello alice from bob" >&15
sleep 6
echo "greetings" >&14
sleep 4
check "node2 unseals the greeting text" grep -q "hello alice from bob" /tmp/itsgoin-cli2.log
echo "reply 0 hello back bob" >&14
sleep 6
echo "greetings" >&15
sleep 4
check "node3 unseals the reply via its stored fresh reply key" \
grep -q "hello back bob" /tmp/itsgoin-cli3.log
# Holder-side opacity: node1 never sees plaintext greeting bodies.
check "node1 stores only ciphertext (no greeting plaintext in db)" \
bash -c '! sqlite3 /tmp/itsgoin-test1/itsgoin.db \
"SELECT content FROM comments" | grep -q "hello alice"'
echo "== step 5: newest-wins =="
echo "register Alice rust p2p" >&14
sleep 2
echo "register AliceV2 rust mesh" >&14
sleep 4
echo "search rust" >&15
sleep 8
check "node3 sees exactly one (newest) entry for node2's persona" \
bash -c 'quiet_tail /tmp/itsgoin-cli3.log 6 | grep -c "rust" | grep -q "^1$" &&
quiet_tail /tmp/itsgoin-cli3.log 6 | grep -q AliceV2'
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

View file

@ -1,278 +0,0 @@
#!/usr/bin/env bash
# v0.8 Iteration C integration test — TOPOLOGY.
#
# Run from the repo root after `cargo build -p itsgoin-cli`, AFTER
# scripts/a3_integration_test.sh (C does not replace A). Drives interactive
# REPLs over FIFOs, asserts by grepping logs and by sqlite3 against node DBs.
#
# SCENARIO 1 Two-pool uniques exchange over a chain: pool 1 shifts one bounce
# deeper per hop, pool 2 lands at N4, and N4 is USED but NEVER
# re-announced (no N5 anywhere). Plus the §layers privacy
# invariant: only anchor entries carry an address.
# SCENARIO 2 Anchor convection: the rolling window hands each caller the 2
# most recent prior callers, produces a REAL connection, rotates
# entries out after 2 hand-outs, coordinates a RelayIntroduce when
# both ends are live, and refuses a top-up CHEAPLY.
# SCENARIO 3 Temp referral slots: allocated ABOVE the mesh cap, carrying no
# knowledge, never evicting an established mesh peer, graduating
# into a freed mesh slot.
# STANDING 0xC0 AnchorRegister is gone; no automatic session relay anywhere.
#
# Exit code 0 = all checks passed. Logs: /tmp/itsgoin-ctop{1..7}.log
set -u
cd "$(dirname "$0")/.."
BIN=target/debug/itsgoin
[ -x "$BIN" ] || { echo "build first: cargo build -p itsgoin-cli"; exit 2; }
PASS=0; FAIL=0
check() { # check <desc> <cmd...>
local desc="$1"; shift
if "$@" >/dev/null 2>&1; then PASS=$((PASS+1)); echo "PASS: $desc";
else FAIL=$((FAIL+1)); echo "FAIL: $desc"; fi
}
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g' "$1" | grep -v "WARN.*netlink\|WARN.*buffer_tool"; }
sq() { sqlite3 "/tmp/itsgoin-ctop$1/itsgoin.db" "$2"; }
# Is `id` (hex) present in node N's uniques index at `bounce`?
at_bounce() { # at_bounce <node> <id_hex> <bounce>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2' AND bounce=$3")" != "0" ]
}
anywhere() { # anywhere <node> <id_hex>
[ "$(sq "$1" "SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))='$2'")" != "0" ]
}
# `check` runs its command via `bash -c`, which does not inherit shell functions.
export -f strip_ansi sq at_bounce anywhere
NODES="1 2 3 4 5 6 7"
FDS=""
cleanup() {
for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null
rm -f /tmp/itsgoin-ctopcmd*
}
trap cleanup EXIT
# Test gates:
# - loopback referrals: the convection address filter is publicly-routable-only
# (bugs-fixed #8), which would make every 127.0.0.1 referral unusable.
# - mesh cap 2: reaches the temp-referral boundary with 4 nodes, not 21.
# - no growth loop: on loopback the growth loop collapses any chain into a
# full mesh in seconds, and a full mesh has no N2 at all (every peer is a
# direct). Topology here is script-controlled on purpose.
export ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1
export ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1
export ITSGOIN_TEST_NO_GROWTH=1
export RUST_LOG="${RUST_LOG:-itsgoin_core=debug,info,iroh=warn,swarm_discovery=warn}"
echo "== setup =="
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 1
for i in $NODES; do
rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log
mkdir -p /tmp/itsgoin-ctop$i
mkfifo /tmp/itsgoin-ctopcmd$i
done
start_node() { # start_node <n> <port> [extra env assignments...]
local i="$1" port="$2"; shift 2
( exec env "$@" "$BIN" /tmp/itsgoin-ctop$i --bind 127.0.0.1:$port \
< /tmp/itsgoin-ctopcmd$i > /tmp/itsgoin-ctop$i.log 2>&1 ) &
}
# Nodes 1-6: chain + convection. Node 7 joins late (fresh index).
for i in 1 2 3 4 5 6 7; do start_node $i $((18430 + i)) ITSGOIN_TEST_NO_GROWTH=1; done
exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \
24>/tmp/itsgoin-ctopcmd4 25>/tmp/itsgoin-ctopcmd5 26>/tmp/itsgoin-ctopcmd6 \
27>/tmp/itsgoin-ctopcmd7
sleep 7
for i in $NODES; do
eval "n${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')"
done
echo "n1=$n1 n2=$n2 n3=$n3 n4=$n4 n5=$n5"
[ -n "$n5" ] || { echo "FAIL: nodes did not start"; exit 1; }
##############################################################################
echo
echo "== scenario 1: two-pool uniques exchange, N4 terminal =="
##############################################################################
# Chain 1 - 2 - 3 - 4 - 5. Built inward so each hop's initial exchange already
# carries the deeper knowledge where possible.
echo "connect $n1@127.0.0.1:18431" >&22; sleep 3
echo "connect $n2@127.0.0.1:18432" >&23; sleep 3
echo "connect $n3@127.0.0.1:18433" >&24; sleep 3
echo "connect $n4@127.0.0.1:18434" >&25; sleep 4
# Drive propagation explicitly: each uniques-pull round moves knowledge one
# bounce along the chain. This IS the v0.8 pull — an index exchange, no posts.
for round in 1 2 3 4 5; do
for fd in 21 22 23 24 25; do echo "uniques-pull" >&$fd; done
sleep 3
done
check "uniques pull is an index exchange (peers exchanged, no post transfer)" \
bash -c 'grep -q "uniques-pull: exchanged with [1-9]" /tmp/itsgoin-ctop3.log'
# Pool 1 shifts one bounce deeper per hop, tagged to the reporter.
check "node1 holds node3 at N2 (pool 1 shifted one bounce)" at_bounce 1 "$n3" 2
check "node1 holds node4 at N3" at_bounce 1 "$n4" 3
check "node1 holds node5 at N4 (terminal pool)" at_bounce 1 "$n5" 4
check "node5 holds node1 at N4 from the other end of the chain" at_bounce 5 "$n1" 4
# N4 is USED — address resolution and search read it.
check "node1 can look up its N4 entry (used, not merely stored)" \
bash -c 'grep -q "N4 via" <(echo "uniques '"$n5"'" >&21; sleep 3; tail -40 /tmp/itsgoin-ctop1.log)'
# §layers privacy invariant: ONLY anchor entries carry an address.
check "no non-anchor index row carries an address (on any node)" \
bash -c 'for i in 1 2 3 4 5; do
c=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \
"SELECT count(*) FROM reachable WHERE is_anchor=0 AND addresses NOT IN ('"''"',\"[]\")");
[ "$c" = "0" ] || exit 1; done'
# Per-reporter dedup: one row per (reporter, id) — an ID can never sit at two
# depths from the same reporter.
check "one index row per (reporter, id) — no duplicate depths" \
bash -c 'for i in 1 2 3 4 5; do
d=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db \
"SELECT count(*) FROM (SELECT reporter_node_id, reachable_id FROM reachable
GROUP BY reporter_node_id, reachable_id HAVING count(*) > 1)");
[ "$d" = "0" ] || exit 1; done'
# Nothing is stored past bounce 4. There is no N5 table and no N5 row.
check "stored horizon never exceeds 4 bounces" \
bash -c 'for i in 1 2 3 4 5; do
m=$(sqlite3 /tmp/itsgoin-ctop$i/itsgoin.db "SELECT ifnull(max(bounce),0) FROM reachable");
[ "$m" -le 4 ] || exit 1; done'
# THE KEY ASSERTION: a fresh node joining node1 must NOT learn node5. node1
# knows node5 only at N4, and the terminal pool is never re-announced.
echo "connect $n1@127.0.0.1:18431" >&27; sleep 4
for round in 1 2 3; do echo "uniques-pull" >&27; echo "uniques-pull" >&21; sleep 3; done
check "N4 is never re-announced: node7 never learns node5" \
bash -c '! /usr/bin/env bash -c "[ \"\$(sqlite3 /tmp/itsgoin-ctop7/itsgoin.db \
\"SELECT count(*) FROM reachable WHERE lower(hex(reachable_id))=\\\"'"$n5"'\\\"\")\" != \"0\" ]"'
check "node7 DID learn node2 (the exchange itself works)" anywhere 7 "$n2"
##############################################################################
echo
echo "== scenario 2: anchor convection =="
##############################################################################
# Node 6 plays the anchor: directly dialable, and it is the one every caller
# asks. Callers are node1 and node5 — the two ENDS of the chain, so they have
# no path to each other and a referral that lands is unambiguous.
echo "connect $n6@127.0.0.1:18436" >&21; sleep 3
echo "convect $n6" >&21; sleep 5 # node1 admitted; window was empty
echo "connect $n6@127.0.0.1:18436" >&25; sleep 3
echo "convect $n6" >&25; sleep 10 # node5 is referred to node1
check "anchor serves referrals out of the rolling window" \
bash -c 'grep -q "Convection: serving referrals" /tmp/itsgoin-ctop6.log'
check "the anchor admitted callers to the window without any registration msg" \
bash -c 'grep -q "Convection: admitted caller to rolling window" /tmp/itsgoin-ctop6.log'
check "the anchor referred the PRIOR caller to the next one" \
bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -q "'"$n1"'"'
check "a convection referral produced a real peer connection" \
bash -c 'grep -q "Convection: connected to referred peer\|Convection: connected via introduction" \
/tmp/itsgoin-ctop5.log'
check "node5 is now actually mesh-connected to node1 (chain ends joined)" \
bash -c 'echo "connections" >&25; sleep 3; strip_ansi /tmp/itsgoin-ctop5.log | tail -20 | grep -q "'"${n1:0:12}"'"'
# Both ends live on the anchor => coordinate an introduction, not a cold address.
check "anchor coordinates RelayIntroduce when both ends are live" \
bash -c 'grep -q "Convection: coordinated introduction" /tmp/itsgoin-ctop6.log'
# Rotation: an entry is handed to exactly 2 callers, then leaves the window.
echo "convect $n6" >&24; sleep 6
echo "convect $n6" >&23; sleep 6
echo "convect $n6" >&27; sleep 6
check "each window entry is handed out at most twice, then rotates out" \
bash -c 'n=$(grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | grep -c "referred=\"\?'"$n1"'"); \
[ "$n" -le 2 ]'
check "later callers are served the more RECENT callers" \
bash -c 'grep "Convection: referring peer" /tmp/itsgoin-ctop6.log | tail -3 | grep -q "'"$n5"'\|'"$n4"'"'
# CHEAP REFUSAL: node3 is an interior chain node (2 mesh peers) so its request
# is TOP-UP class. It asks node5, whose own convection window is empty — there
# is nothing fresh to circulate, so the refusal must be one message, not a
# burned timeout.
echo "connect $n5@127.0.0.1:18435" >&23; sleep 4
echo "convect $n5" >&23; sleep 6
check "top-up against an empty window is refused" \
bash -c 'grep -q "convection: refused" /tmp/itsgoin-ctop3.log'
check "the refusal is CHEAP (single message, well under a timeout)" \
bash -c 'ms=$(grep -o "convection: refused in [0-9]*ms" /tmp/itsgoin-ctop3.log | tail -1 |
grep -o "[0-9]*" | head -1); [ -n "$ms" ] && [ "$ms" -lt 3000 ]'
check "entry class is served even where top-up was refused" \
bash -c 'grep "Convection: serving referrals" /tmp/itsgoin-ctop6.log | grep -q entry'
check "the refusing anchor lands in the caller's penalty box (refusal feedback)" \
bash -c 'echo "convection" >&23; sleep 3; strip_ansi /tmp/itsgoin-ctop3.log | tail -20 |
grep -oE "anchor_bias [0-9.]+" | tail -1 | awk "{ exit !(\$2 < 1.0) }"'
##############################################################################
echo
echo "== scenario 3: temp referral slots (mesh cap 2) =="
##############################################################################
# Restart nodes 1-4 with a mesh cap of 2 so the temp-referral band is reachable.
for fd in 21 22 23 24 25 26 27; do echo "quit" >&$fd 2>/dev/null; done
sleep 3
kill $(pgrep -f 'itsgoin.*itsgoin-ctop') 2>/dev/null; sleep 2
for fd in 21 22 23 24 25 26 27; do eval "exec $fd>&-" 2>/dev/null || true; done
for i in 1 2 3 4; do
rm -rf /tmp/itsgoin-ctop$i /tmp/itsgoin-ctopcmd$i /tmp/itsgoin-ctop$i.log
mkdir -p /tmp/itsgoin-ctop$i; mkfifo /tmp/itsgoin-ctopcmd$i
start_node $i $((18440 + i)) ITSGOIN_TEST_MESH_SLOTS=2 \
ITSGOIN_TEST_ALLOW_LOOPBACK_REFERRALS=1 ITSGOIN_TEST_NO_GROWTH=1
done
exec 21>/tmp/itsgoin-ctopcmd1 22>/tmp/itsgoin-ctopcmd2 23>/tmp/itsgoin-ctopcmd3 \
24>/tmp/itsgoin-ctopcmd4
sleep 7
for i in 1 2 3 4; do
eval "m${i}=\$(strip_ansi /tmp/itsgoin-ctop$i.log | grep -m1 'Node ID:' | awk '{print \$3}')"
done
# Fill node1's 2 mesh slots, then send a third peer at it.
echo "connect $m1@127.0.0.1:18441" >&22; sleep 4
echo "connect $m1@127.0.0.1:18441" >&23; sleep 4
echo "slots" >&21; sleep 3
check "mesh pool fills to the cap" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | grep -q "mesh 2/2"'
echo "connect $m1@127.0.0.1:18441" >&24; sleep 5
echo "slots" >&21; sleep 3
check "the 4th peer lands in a temp referral slot ABOVE the cap" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2 temp-referral 1/"'
check "an established mesh peer was never evicted to make room" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -30 | grep -q "mesh 2/2"'
check "the temp slot carries NO knowledge (never a reporter in our index)" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM reachable WHERE lower(hex(reporter_node_id))=\"'"$m4"'\"")" = "0" ]'
check "and a temp peer is never written to mesh_peers" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" = "0" ]'
# Free a mesh slot: the temp peer graduates into it. The wait covers the mesh
# keepalive interval (30s) — an abruptly-gone QUIC peer is noticed when the
# next keepalive write fails, not instantly.
echo "quit" >&22; sleep 45
echo "slots" >&21; sleep 5
check "the temp referral slot GRADUATES into the freed mesh slot" \
bash -c 'strip_ansi /tmp/itsgoin-ctop1.log | tail -20 | grep -q "temp-referral 0/"'
check "the graduated peer now exchanges knowledge (written to mesh_peers)" \
bash -c '[ "$(sqlite3 /tmp/itsgoin-ctop1/itsgoin.db \
"SELECT count(*) FROM mesh_peers WHERE lower(hex(node_id))=\"'"$m4"'\"")" != "0" ]'
##############################################################################
echo
echo "== standing assertions =="
##############################################################################
check "0xC0 AnchorRegister is gone from the tree" \
bash -c '! grep -rn "AnchorRegister" crates/ --include=*.rs'
check "no automatic session relay anywhere in the convection path" \
bash -c '! grep -riE "session relay" /tmp/itsgoin-ctop*.log'
check "no registration cycle writes known_anchors (bootstrap cache only)" \
bash -c '! grep -rn "start_anchor_register_cycle" crates/ --include=*.rs'
echo
echo "== results: $PASS passed, $FAIL failed =="
[ "$FAIL" -eq 0 ]

View file

@ -6,74 +6,6 @@ See `CONTRIBUTING.md` for the protocol. See `AGENTS.md` for the Claude-specific
---
## 2026-05-13 to 2026-05-15 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants` → master
**Started**: May 13 UTC. Released v0.7.0 stable on May 15 UTC.
**Instance**: Scott's primary Claude (Lead)
**Issue**: implement FoF spec Layers 15 end-to-end
**Branch**: `docs/fof-spec-layer1-bio-grants` (continued from prior spec-drafting session; merged to master at d46fcb4 on May 15)
**Scope**: Friend-of-Friend post gating: per-persona vouch keys, anonymous bio-post wrapper distribution, FoF-gated comments with CDN verification, FoF-closed encrypted bodies, V_me lifecycle (rotation/cascade/key-burn), unlock cache + retry sweep. Pre-deploy hardening pass. Version bump to 0.7.0 stable.
**Commits landed on master** (34 total, `1fdf9a9..d46fcb4`):
Layer 1 (vouch primitive):
- `8a53d83` schema + storage API + HPKE-sealed vouch-grant crypto + 3 tests
- `bc008c5` wire types (VouchGrantBatch) + V_me auto-gen on persona create
- `3ee5c30` publish path: bucketed-padding VouchGrantBatch in bio posts
- `d1afcec` receive-path scan + follow-gating + scan cache (2 e2e tests)
- `34c5b60` Tauri commands + Settings UI for vouching
Layer 2 (Mode 2 + CDN verify + revocation + access-grant):
- `74fec3b` wrap-slot dual-derivation seal/open primitives + 4 tests
- `0f5147a` wire types: WrapSlot, FoFCommentGating, CommentPermission::FriendsOfFriends, RevocationEntry
- `bdcd214` fof.rs: build_fof_comment_gating with bucketed padding
- `673f9e2` wired FoF gating into post-create path
- `00522f4` reader unlock + commenter authoring + sig verify (1 roundtrip test)
- `63ff5ad` CDN four-check verification on AddComment receive
- `583033e` persist FoF fields + fof_revocations table
- `6a76ade` FoFRevocation diff + sign/verify/apply + retroactive cascade-delete (2 tests)
- `96118d7` FoFAccessGrant diff + retroactive read widening (1 test)
- `10de3f6` Tauri commands + frontend compose-picker for Mode 2
Layer 3 (Mode 1 FoFClosed):
- `856f386` PostVisibility::FoFClosed variant + body encrypt/decrypt + body-size bucket padding (3 tests)
- `66b7804` create_post_fof_closed + read_fof_closed_body + frontend hooks for locked/unlocked posts (1 e2e test)
Layer 4 (V_me lifecycle + cascade + key-burn):
- `c0de21d` own_post_slot_provenance + Node::rotate_v_me + cascade_revoke_v_me_epoch (1 test)
- `c2f2203` FoFKeyBurn primitive (1 test)
- `fdbf97f` supersedes_post_id field for re-issue path
- `ce710a6` Tauri commands + Settings "Rotate my vouch key" UI
Layer 5 (perf):
- `12a3058` unlock cache + unreadable-posts queue + author-direct fast path + sweep on V_x arrival (2 tests)
Pre-deploy hardening (audit pass):
- `aa190db` wire-shape validation on incoming FoF posts; unreadable-queue per-persona cap of 4096 (7 tests)
- `4ec3a80` key-burn replay rejection (monotonic timestamps); MAX_SWEEP_PER_CALL=256 (1 test)
Release prep:
- `d46fcb4` version bump 0.6.2 → 0.7.0; download page updated with FoF release notes
**Test count**: 158 passing on master (added ~24 new fof:: integration tests across Layers 15 + hardening).
**Build state**: full-pipeline deploy initiated on May 15 (`./deploy.sh` from this Linux host: CLI + AppImage + APK in parallel, sign APK, sequential SCP uploads, anchor swap with signed release announcement). Windows installer separate (uploaded by Windows host team).
**Key design decisions worth knowing**:
- `slot_binder_nonce` (32B random per post) replaces the spec's "post_id in HKDF info" — PostId = BLAKE3(post) was circular here. Same anti-replay property.
- Per-V_x signing keypair (`pub_x`/`priv_x`) generated per-post (not per-V_x-genesis). Comment signing is asymmetric Ed25519; PQ-migration deferred. Body + comment-payload encryption is symmetric ChaCha20-Poly1305 (PQ-safe).
- `vouch_keys_received` keyed by `(holder, owner, epoch)` — multi-epoch retention is the receiver-chain mechanism. New V_me from a voucher appends; old key isn't deleted.
- Revocation is per-post per-pub_x with retroactive cascade-delete. V_me rotation is grandfather-by-default; cascade is opt-in via `cascade_revoke_v_me_epoch`. Key-burn swaps slots in-place for leaked-key scenarios.
**Pending after deploy succeeds**:
- Live shakedown on real devices (Scott has been looking forward to this).
- Per-post Revoke / Grant Access UI surfaces (Tauri commands exist; only Rotate has a Settings button so far).
- Update `MEMORY.md` "Current Status" to v0.7.0 once anchor swap confirms healthy.
**Stopping point**: deploy script running in background; master at `d46fcb4`. Awaiting deploy completion notification.
---
## 2026-04-24 — primary Claude (Lead) — `docs/fof-spec-layer1-bio-grants`
**Started**: April 24 UTC

File diff suppressed because it is too large Load diff

View file

@ -26,148 +26,26 @@
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
<p>Available for Android, Linux, and Windows. Free and open source.</p>
<h2 style="margin-top: 2rem;">v0.8.0-alpha &mdash; July 30, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Clean protocol break.</strong> ALPN moves to <code>itsgoin/4</code> &mdash; v0.8 nodes and v0.7.x nodes refuse each other at the QUIC handshake rather than half-interoperating. Alpha: testers only. See <a href="design.html">design.html</a> for the full v0.8 architecture.</p>
<div class="downloads">
<a href="itsgoin-0.8.0-alpha.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin_0.8.0-alpha_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin-cli-0.8.0-alpha-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.8.0-alpha</span>
</a>
<a href="itsgoin-0.8.0-alpha-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.8.0-alpha</span>
</a>
<div class="note" style="margin-top: 1rem; border-left: 3px solid var(--accent); padding-left: 0.9rem;">
<strong>Upgrading from v0.5.1 or newer?</strong> v0.6 is a hard network fork &mdash; older versions can no longer reach the network. Bring your account over in three steps:
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
<li>On your current v0.5 install, go to <strong>Settings &rarr; Export</strong>, tick <em>&ldquo;Posts + media + identity key&rdquo;</em>, and save the .zip somewhere safe.</li>
<li>Download v0.6 below (APK, AppImage, or Windows installer).</li>
<li>On first launch, choose <strong>&ldquo;Import an Identity&rdquo;</strong> and point it at the .zip.</li>
</ol>
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">Your posts, media, follows, and identity key all come across. Encrypted posts stay decryptable under the same key.</p>
</div>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Finding people &mdash; the headline feature</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Registry &mdash; opt-in discoverability.</strong> A well-known "registrations here" post whose comment chain <em>is</em> the directory. Registering publishes a signed comment carrying your display name, keywords, and public author ID &mdash; self-certifying, so any holder can verify it against the key it names, with no authority involved. Entries carry a fixed 30-day expiry and auto-renew while you stay listed, so the chain self-cleans and is never more than a month stale. Unlisting sends a signed delete every holder honors. Your author ID stays location-anonymous: it maps to content through ordinary CDN holder search, never to an address.</li>
<li><strong>Greeting comments &mdash; talking to strangers.</strong> Messaging with no prior key relationship. A greeting is an opaque comment on a bio post, signed with a published open-slot key so it passes the CDN's comment-verification gate, with its body sealed to the recipient's posting key. Inside: who you really are and a return path. Outside: ciphertext authored by a throwaway ID. Replies carry a fresh key and the next rendezvous, so a conversation hops temporary identities and no two messages share an outer identity. Since encrypted comment blobs are already commonplace on public posts, greetings blend into ordinary traffic &mdash; if everyone's anonymous, anonymity doesn't stand out.</li>
<li><strong>Vouching is deliberately separate.</strong> No vouch control exists on any messaging surface. Talking to someone never grants them anything; vouching is a considered relationship act that lives in the People tab.</li>
<li><strong>Comment expiry.</strong> Ordinary comments now carry a randomized 30&ndash;365 day lifetime fixed at creation and covered by the signature, so every holder expires them at the same moment. Throwaway conversation identities eventually vanish from the network entirely.</li>
<li><strong>No proof-of-work.</strong> Considered and rejected: its cost lands inverted &mdash; a phone registrant pays seconds of battery while a botnet rig pays effectively nothing. Flood limits are holder-side size and count caps; the durable answer is vouch-gated registration once the graph supports it.</li>
</ul>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Privacy &amp; correctness fixes</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Manifests no longer carry device addresses.</strong> <code>AuthorManifest</code> shipped your device's network addresses under a posting-identity author &mdash; a direct link between a persona and the machine hosting it. The field is gone from the struct <em>and</em> the signature digest, with startup migrations that re-sign your own stored manifests and purge unverifiable foreign copies. This is what makes a registered author ID safe to publish.</li>
<li><strong>Persona-split bug family fixed.</strong> The v0.6.1 network-key/posting-key split left roughly fourteen places comparing a network NodeId where posting identities now live. Consequences included: <em>visibility revocation silently failed on every post</em>, group-key distribution to later-added circle members never worked, encrypted attachments wouldn't decrypt, the replication cycle never found under-replicated content, and your own blobs missed their eviction protection tier. Every "is this mine?" check now tests membership across all of your posting personas.</li>
<li><strong>Comment ingest is verified everywhere.</strong> Two pull paths stored incoming comments with no signature verification at all. All three ingest sites now share one acceptance gate. Also fixed during review: forged tombstones could delete comments through the ingest path, a delete path trusted the sender rather than a signature, a forged policy message could poison a post's comment intake remotely, and the frontend's HTML escaper didn't escape quotes (an attribute-breakout XSS reachable through registry names, which also affected seven pre-existing sites).</li>
</ul>
<h3 style="margin-top: 1.25rem; font-size: 1rem;">Protocol slimming</h3>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 0.5rem;">
<li><strong>Message types 46 &rarr; 40, about 1,400 lines of Rust removed.</strong> Deleted: <code>DeleteRecord</code> (0x51) and <code>VisibilityUpdate</code> (0x52), both superseded by signed control posts; <code>SocialDisconnectNotice</code> (0x71) and <code>MeshPrefer</code> (0xB3), which nothing had sent in generations; <code>GroupKeyRequest</code>/<code>Response</code> (0xA1/0xA2), an orphaned pair that could never have granted a key after the persona split; and the legacy half of the dual pull-matching path.</li>
<li><strong>Preferred peers eliminated.</strong> The preferred tier existed to serve direct N+10 push/pull that the CDN's holder model replaced. Gone: the slot tier, the <code>preferred_peers</code> table, preferred-tree routing semantics, the rebalance priority pass, the relay candidate tiers, and the 7-day and 30-day watchers.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;"><strong>Upgrade note:</strong> v0.8.0-alpha cannot talk to v0.7.x at all &mdash; that is intentional, and the anchor moves with this release. Existing data directories migrate in place on first run (manifest re-signing, legacy profile cleanup, registry materialization).</p>
<h2 style="margin-top: 2rem;">v0.7.3 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with v0.7.0/v0.7.1/v0.7.2.</p>
<div class="downloads">
<a href="itsgoin-0.7.3.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.3</span>
</a>
<a href="itsgoin_0.7.3_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.3</span>
</a>
<a href="itsgoin-cli-0.7.3-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.3</span>
</a>
<a href="itsgoin-0.7.3-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.3</span>
</a>
<div class="note" style="margin-top: 0.75rem; border-left: 3px solid var(--text-muted); padding-left: 0.9rem;">
<strong>Upgrading from v0.5.0 or older?</strong> The v0.5 export format matured in v0.5.1, so a direct export from 0.5.0 won't import cleanly into v0.6. Do a two-hop upgrade:
<ol style="margin: 0.5rem 0 0.25rem 1.25rem; padding: 0;">
<li>Install <a href="itsgoin_0.5.3_amd64.AppImage">v0.5.3 Linux AppImage</a> / <a href="itsgoin-0.5.3.apk">Android APK</a> / <a href="itsgoin-0.5.3-windows-x64-setup.exe">Windows installer</a> and open it. It reads your existing data in place.</li>
<li>In v0.5.3, go to <strong>Settings &rarr; Export</strong> and save the bundle.</li>
<li>Install v0.6.0 below and import that bundle on first launch.</li>
</ol>
<p style="margin: 0.5rem 0 0 0; font-size: 0.8rem; color: var(--text-muted);">v0.5.3 is kept online only as an upgrade bridge &mdash; it no longer connects to the live network.</p>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>EDM port scanner disabled.</strong> The "advanced NAT traversal" port-scanner (Hard NAT &harr; Hard NAT) used <code>endpoint.connect()</code> as its probe primitive; iroh accumulates every connect target into its per-endpoint path store and probes them all in the background under QUIC NAT-traversal. A 5-min scan inserted ~30k paths; iroh then probed all of them &mdash; observed at 22MB/s outbound from a single client. DoS-grade at any scale. Disabled until we replace per-probe <code>connect()</code> with raw UDP sends. The scanner source is preserved as <code>edm_port_scan_disabled_v0_7_3</code> to refactor against.</li>
<li><strong>Bootstrap anchor probing batched.</strong> Discovered anchors are now probed 3 at a time with a 2s stagger between batches and a 10s per-anchor timeout. First success unblocks the bootstrap flow immediately; remaining probes continue in background and naturally fill peer connections. Phase 2 (bootstrap fallback) still only fires when every discovered anchor has failed &mdash; preserves the load-distribution intent for when the network scales.</li>
<li><strong>Stale-anchor self-pruning.</strong> When a probe fails AND the anchor's <code>last_seen_ms</code> is more than 3 days old, the entry is deleted from <code>known_anchors</code> immediately. Recoverable anchors (failed once, succeeded recently) are preserved. Users with old data dirs whose discovered anchors point to keypairs that rotated months ago no longer carry stale baggage forward.</li>
<li><strong>Close button kills the Android NodeService.</strong> The in-app close button now calls <code>NodeService.stopFromNative()</code> via JNI before exiting the Activity, so the foreground service actually stops &mdash; previously the button ended the UI but networking kept running.</li>
<li><strong>Power-icon SVG.</strong> The close-button glyph is now an inline SVG instead of <code>&amp;#x23FB;</code> &mdash; Android webview fonts that lack U+23FB previously rendered the button as a missing-image tofu box.</li>
</ul>
<h2 style="margin-top: 2rem;">v0.7.2 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Network &amp; reachability improvements, plus a relay-privacy fix.</p>
<div class="downloads">
<a href="itsgoin-0.7.2.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.2</span>
</a>
<a href="itsgoin_0.7.2_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.2</span>
</a>
<a href="itsgoin-cli-0.7.2-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.2</span>
</a>
<a href="itsgoin-0.7.2-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.2</span>
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>NAT traversal: UPnP + NAT-PMP + PCP.</strong> Replaced UPnP-only port mapping with the <code>portmapper</code> crate (also used by iroh internally). All three protocols run in parallel; the first router-response wins. PCP adds IPv6 firewall pinholes and works on iOS without a multicast entitlement. Auto-renewal runs in a background task &mdash; no more lease-expiry surprises.</li>
<li><strong>Android NAT mapping on WiFi.</strong> Mobile devices on WiFi now attempt UPnP/PCP/NAT-PMP just like desktops, with the required <code>WifiManager.MulticastLock</code> acquired for the lifetime of the mapping. Cellular is gated off (no UPnP/PCP gateway on carrier nets, so we skip the discovery cost). Reachability for phones-on-home-WiFi should improve noticeably.</li>
<li><strong>Mobile HTTP serving when reachable.</strong> Phones whose router cooperates with TCP port mapping can now serve <code>/p/&lt;post&gt;</code> directly for browser fetches. No mode switch &mdash; this just works when the network allows it.</li>
<li><strong>Anchor-mode self-heals across network changes.</strong> A new watcher observes the live port mapping. Mapping lost &gt;5min &rarr; anchor mode clears (don't keep advertising an unreachable address). Mapping restored &rarr; anchor mode comes back on, no restart needed. Roaming between UPnP-capable WiFi networks no longer requires a reboot.</li>
<li><strong>Shorter share URLs.</strong> Share links now contain only the post ID &mdash; <code>itsgoin.net/p/&lt;post&gt;</code>. The anchor handles holder lookup itself, so URLs stay stable as the holder set changes. Older URLs with the author hex appended continue to work.</li>
<li><strong>Session relay is opt-in, off by default.</strong> Fixed a regression where hole-punch failures could silently pipe a peer-to-peer session through an intermediary's bandwidth. The previous behavior burned random users' bandwidth without consent. A new Settings toggle (<em>Enable session relay</em>) opts in to both serving as a relay and using one. Anchors default-off too &mdash; server bandwidth shouldn't be silently consumed either. Hole-punch coordination (small signaling) is unaffected.</li>
<li><strong>Quick app close from the header.</strong> Power icon next to the network indicator fully terminates the app (with confirm). Useful on mobile to stop network activity between active sessions.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.2 is wire-compatible with v0.7.0 and v0.7.1. No protocol changes; all network &amp; UX improvements are local.</p>
<h2 style="margin-top: 2rem;">v0.7.1 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">UI polish + bug-fix pass on top of v0.7.0's FoF gating. Default post visibility is now Extended Friends (FoF). New <em>Friend</em> button combines follow + vouch in one click. Network Identity renamed to Device Address (you almost never need to touch it). Settings clearly separates personas from device address with an export/import "Move to another device" flow. Plus three fixes: profile display name now updates everywhere when changed; redundancy panel reads from the correct author set so it no longer shows 0 for all posts; My Posts tab no longer horizontally overflows and breaks the sticky header/tabs.</p>
<div class="downloads">
<a href="itsgoin-0.7.1.apk" class="download-btn btn-android">
Android APK
<span class="sub">v0.7.1</span>
</a>
<a href="itsgoin_0.7.1_amd64.AppImage" class="download-btn btn-linux">
Linux AppImage
<span class="sub">v0.7.1</span>
</a>
<a href="itsgoin-cli-0.7.1-linux-amd64" class="download-btn btn-linux">
Linux CLI / Anchor
<span class="sub">v0.7.1</span>
</a>
<a href="itsgoin-0.7.1-windows-x64-setup.exe" class="download-btn btn-windows">
Windows Installer
<span class="sub">v0.7.1</span>
</a>
</div>
<ul style="color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin-top: 1rem;">
<li><strong>Default visibility is Extended Friends (FoF).</strong> Public is one click away when you want it; encrypted-by-default is the new posture.</li>
<li><strong>Friend button</strong> on the bio modal: follow + vouch in one tap. "Follow only" stays available for the watch-but-don't-trust case. "Unfriend" reverses both.</li>
<li><strong>Network Identity &rarr; Device Address.</strong> UI rename for clarity; the device's QUIC endpoint is distinct from your posting personas. Backend command names are unchanged.</li>
<li><strong>Settings &mdash; Move to another device.</strong> Export-personas / import-from-another-device buttons get their own labeled section with a plain-English explainer.</li>
<li><strong>Bug fix: profile display name update.</strong> Renaming the auto-generated first persona now propagates everywhere (the posting-identities table was previously stuck on the original empty name).</li>
<li><strong>Bug fix: redundancy panel.</strong> Was querying posts authored by the device's network NodeId; since v0.6.0 posts are authored by personas, so the query returned 0 of your posts. Now queries every persona on the device.</li>
<li><strong>Bug fix: My Posts horizontal-scroll regression.</strong> The new "FoF only (Mode 1)" compose-policy option pushed the visibility row past viewport width on mobile, triggering browser-chrome auto-hide behavior. Row now wraps; selects cap at container width.</li>
</ul>
<p style="color: var(--text-muted); font-size: 0.85rem;">v0.7.1 is wire-compatible with v0.7.0. UI/UX only.</p>
<h2 style="margin-top: 2rem;">v0.7.0 &mdash; May 15, 2026</h2>
<p style="color: var(--text-muted); font-size: 0.85rem;">Friend-of-Friend gating is live. Posts can be public to readers but FoF-gated for comments (Mode 2), or fully FoF-gated for body + comments (Mode 1, <code>FoFClosed</code>). The CDN verifies comment signatures before propagating, killing the bandwidth-DoS attack a single admitted FoF member could otherwise mount. Vouches distribute via HPKE-sealed wrappers in your bio post &mdash; no DMs, no recipient IDs on the wire.</p>

View file

@ -124,8 +124,8 @@ wrapped_key[i] = X25519_DH(author_ed25519, recipient_ed25519[i]) XOR CEK</code><
<!-- Sync -->
<section>
<h2>Sync protocol (v4)</h2>
<p>The protocol uses a single ALPN (<code>itsgoin/4</code>) with 40 message types multiplexed over QUIC bi-streams and uni-streams.</p>
<h2>Sync protocol (v3)</h2>
<p>The protocol uses a single ALPN (<code>itsgoin/3</code>) with 37+ message types multiplexed over QUIC bi-streams and uni-streams.</p>
<div class="card">
<h3>Pull-based sync</h3>