Compare commits
No commits in common. "master" and "docs/fof-spec-layer1-bio-grants" have entirely different histories.
master
...
docs/fof-s
36 changed files with 5918 additions and 13428 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-cli"
|
||||
version = "0.8.0-alpha"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(×tamp_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(×tamp_ms.to_le_bytes());
|
||||
hasher.finalize()
|
||||
}
|
||||
|
||||
/// Sign a comment delete: ed25519 by the comment author's posting key
|
||||
/// over BLAKE3(context="itsgoin/comment-delete/v1", author || post_id ||
|
||||
/// timestamp_ms). Verifiable from the delete op alone — works on holders
|
||||
/// that never met the persona (registry unregister path).
|
||||
pub fn sign_comment_delete(
|
||||
seed: &[u8; 32],
|
||||
author: &NodeId,
|
||||
post_id: &PostId,
|
||||
timestamp_ms: u64,
|
||||
) -> Vec<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![],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,39 +117,24 @@ 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 } => {
|
||||
real_slot_provenance.push(RealSlotProvenance {
|
||||
slot_index: idx as u32,
|
||||
v_x_owner,
|
||||
v_x_epoch,
|
||||
pub_x,
|
||||
});
|
||||
}
|
||||
EntryKind::Open => open_slot_index = Some(idx as u32),
|
||||
EntryKind::Dummy => {}
|
||||
if let EntryKind::Real { v_x_owner, v_x_epoch } = kind {
|
||||
real_slot_provenance.push(RealSlotProvenance {
|
||||
slot_index: idx as u32,
|
||||
v_x_owner,
|
||||
v_x_epoch,
|
||||
pub_x,
|
||||
});
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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&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";
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,8 +203,8 @@ pub fn build_profile_post(
|
|||
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||
attachments: vec![],
|
||||
timestamp_ms,
|
||||
fof_gating,
|
||||
supersedes_post_id: None,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
requester: [2u8; 32],
|
||||
requester_addresses: vec!["10.0.0.2:4433".to_string()],
|
||||
class,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let decoded: ConvectionRequestPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.requester, [2u8; 32]);
|
||||
assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]);
|
||||
assert_eq!(decoded.class, class);
|
||||
}
|
||||
// The class is derived purely from "can this node function right now".
|
||||
assert!(ConvectionClass::for_mesh_count(0).is_entry());
|
||||
assert!(ConvectionClass::for_mesh_count(1).is_entry());
|
||||
assert!(!ConvectionClass::for_mesh_count(2).is_entry());
|
||||
assert!(!ConvectionClass::for_mesh_count(20).is_entry());
|
||||
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 convection_response_roundtrip_including_cheap_refusal() {
|
||||
let payload = ConvectionResponsePayload {
|
||||
fn anchor_referral_request_payload_roundtrip() {
|
||||
let payload = AnchorReferralRequestPayload {
|
||||
requester: [2u8; 32],
|
||||
requester_addresses: vec!["10.0.0.2:4433".to_string()],
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let decoded: AnchorReferralRequestPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.requester, [2u8; 32]);
|
||||
assert_eq!(decoded.requester_addresses, vec!["10.0.0.2:4433"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_referral_response_payload_roundtrip() {
|
||||
let payload = AnchorReferralResponsePayload {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®ISTRY_POST_ID, &post));
|
||||
storage.store_post_with_intent(
|
||||
®ISTRY_POST_ID,
|
||||
&post,
|
||||
&crate::types::PostVisibility::Public,
|
||||
&crate::types::VisibilityIntent::Public,
|
||||
)
|
||||
}
|
||||
|
||||
/// A registration entry — plaintext JSON in the comment `content`.
|
||||
/// Public by intent: no encryption, no anonymity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RegistrationEntry {
|
||||
pub v: u32,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<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(®ISTRY_POST_ID)?;
|
||||
let needle = query.trim().to_lowercase();
|
||||
|
||||
// Newest-wins per author (storage enforces at ingest; dedupe
|
||||
// defensively for locally-authored duplicates).
|
||||
let mut newest: std::collections::HashMap<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(),
|
||||
®ISTRY_POST_ID,
|
||||
"REGISTRY_POST_ID must equal blake3 of the frozen bytes",
|
||||
);
|
||||
|
||||
// 2. compute_post_id(parse(frozen bytes)) == REGISTRY_POST_ID —
|
||||
// i.e., re-serialization is currently byte-identical, so
|
||||
// verify_post_id passes everywhere.
|
||||
let post = registry_post();
|
||||
assert_eq!(
|
||||
crate::content::compute_post_id(&post),
|
||||
REGISTRY_POST_ID,
|
||||
"round-trip serialization must reproduce the frozen bytes",
|
||||
);
|
||||
|
||||
// 3. The deterministic builder still reproduces the constant
|
||||
// (all seal inputs derived — no drift in crypto derivations).
|
||||
let rebuilt = build_canonical_registry_post().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&rebuilt).unwrap(),
|
||||
REGISTRY_POST_JSON,
|
||||
"deterministic builder must reproduce the frozen bytes",
|
||||
);
|
||||
|
||||
// Sanity: canonical fields.
|
||||
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
|
||||
assert_eq!(post.timestamp_ms, REGISTRY_GENESIS_TIMESTAMP_MS);
|
||||
let gating = post.fof_gating.as_ref().expect("gating");
|
||||
let decl = gating.open_slot.as_ref().expect("open slot");
|
||||
assert_eq!(decl.slot_index, 0);
|
||||
assert_eq!(decl.kind, crate::types::OpenSlotKind::Registry);
|
||||
assert_eq!(decl.body_bucket, REGISTRY_BODY_BUCKET);
|
||||
assert_eq!(gating.wrap_slots.len(), 1);
|
||||
}
|
||||
|
||||
/// The registry open slot must actually open under the derivable key
|
||||
/// and yield the signing seed matching pub_post_set[0].
|
||||
#[test]
|
||||
fn registry_open_slot_derivable() {
|
||||
use ed25519_dalek::SigningKey;
|
||||
let post = registry_post();
|
||||
let unlock = crate::fof::derive_open_slot_unlock(&post, &[0u8; 32])
|
||||
.expect("registry open slot must open under the derived key");
|
||||
assert_eq!(unlock.slot_index, 0);
|
||||
let derived_pub = SigningKey::from_bytes(&unlock.priv_x_seed).verifying_key().to_bytes();
|
||||
assert_eq!(post.fof_gating.unwrap().pub_post_set[0], derived_pub);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_is_idempotent() {
|
||||
let s = temp_storage();
|
||||
assert!(materialize_registry_post(&s).unwrap(), "first store inserts");
|
||||
assert!(!materialize_registry_post(&s).unwrap(), "second store no-ops");
|
||||
let (post, vis) = s.get_post_with_visibility(®ISTRY_POST_ID).unwrap().unwrap();
|
||||
assert_eq!(post.author, crate::DEFAULT_ANCHOR_POSTING_ID);
|
||||
assert!(matches!(vis, crate::types::PostVisibility::Public));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_shape_limits() {
|
||||
// Good.
|
||||
parse_registration(r#"{"v":1,"name":"Alice","keywords":["rust","p2p"]}"#).unwrap();
|
||||
// No keywords is fine.
|
||||
parse_registration(r#"{"v":1,"name":"Bob"}"#).unwrap();
|
||||
// Wrong version.
|
||||
assert!(parse_registration(r#"{"v":2,"name":"X"}"#).is_err());
|
||||
// Empty name.
|
||||
assert!(parse_registration(r#"{"v":1,"name":""}"#).is_err());
|
||||
// Name too long.
|
||||
let long_name = "x".repeat(65);
|
||||
assert!(parse_registration(&format!(r#"{{"v":1,"name":"{}"}}"#, long_name)).is_err());
|
||||
// Too many keywords.
|
||||
let kws: Vec<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(®ISTRY_POST_ID, &author, t0).unwrap());
|
||||
s.store_comment(®_comment(author, t0, "Old", &["a"])).unwrap();
|
||||
|
||||
// Newer entry at t0+1000: accepted, older row hard-deleted.
|
||||
assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap());
|
||||
s.store_comment(®_comment(author, t0 + 1000, "New", &["b"])).unwrap();
|
||||
let comments = s.get_comments(®ISTRY_POST_ID).unwrap();
|
||||
assert_eq!(comments.len(), 1, "older row hard-deleted");
|
||||
assert_eq!(comments[0].timestamp_ms, t0 + 1000);
|
||||
|
||||
// Older-than-stored entry: rejected, nothing changes.
|
||||
assert!(!s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 500).unwrap());
|
||||
assert_eq!(s.get_comments(®ISTRY_POST_ID).unwrap().len(), 1);
|
||||
|
||||
// Same-timestamp re-ingest: idempotent accept.
|
||||
assert!(s.upsert_registry_entry_newest_wins(®ISTRY_POST_ID, &author, t0 + 1000).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_name_and_keywords() {
|
||||
let s = temp_storage();
|
||||
let alice = [1u8; 32];
|
||||
let bob = [2u8; 32];
|
||||
let t0 = test_now();
|
||||
s.store_comment(®_comment(alice, t0, "Alice", &["rust", "p2p"])).unwrap();
|
||||
s.store_comment(®_comment(bob, t0 + 1, "Bob", &["gardening"])).unwrap();
|
||||
|
||||
// Keyword hit (case-insensitive).
|
||||
let hits = search_entries(&s, "RUST").unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].author, alice);
|
||||
assert_eq!(hits[0].name, "Alice");
|
||||
|
||||
// Name substring hit.
|
||||
let hits = search_entries(&s, "bo").unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].author, bob);
|
||||
|
||||
// Empty query = browse all.
|
||||
assert_eq!(search_entries(&s, "").unwrap().len(), 2);
|
||||
|
||||
// No hit.
|
||||
assert!(search_entries(&s, "zzz").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_skips_expired_entries() {
|
||||
let s = temp_storage();
|
||||
let alice = [1u8; 32];
|
||||
let mut c = reg_comment(alice, 1000, "Alice", &["rust"]);
|
||||
c.expires_at_ms = 2000; // long past
|
||||
s.store_comment(&c).unwrap();
|
||||
assert!(search_entries(&s, "rust").unwrap().is_empty(), "expired entries filtered");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
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)),
|
||||
}
|
||||
}
|
||||
pub fn from_i64(v: i64) -> Self {
|
||||
if v == 1 { IdClass::Author } else { IdClass::Node }
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the uniques index / announce pools.
|
||||
///
|
||||
/// `addresses` is non-empty ONLY when `is_anchor` — that is the §layers privacy
|
||||
/// invariant, enforced at the store (`add_reach` blanks the column otherwise)
|
||||
/// and at the wire builder (only `AnchorEntry` has an address field at all).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReachEntry {
|
||||
pub id: NodeId,
|
||||
pub id_class: IdClass,
|
||||
pub is_anchor: bool,
|
||||
pub addresses: Vec<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 8–9): `return_path` names the post whose
|
||||
/// Greeting open slot the recipient replies into (v1: the sender's own
|
||||
/// bio post), and `reply_pubkey` is a FRESH per-greeting x25519 key —
|
||||
/// replies are sealed to it, never to a long-term key. No vouch is
|
||||
/// involved anywhere in this flow.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GreetingBody {
|
||||
pub v: u32,
|
||||
/// hex32 — the sender's REAL posting pubkey (inside the seal only).
|
||||
pub sender_persona: String,
|
||||
/// <= 64 chars.
|
||||
pub sender_name: String,
|
||||
/// <= 600 chars.
|
||||
pub text: String,
|
||||
/// hex32 post id — where the sender watches for a reply.
|
||||
pub return_path: String,
|
||||
/// hex32 fresh x25519 pubkey — seal replies to THIS.
|
||||
pub reply_pubkey: String,
|
||||
}
|
||||
|
||||
/// Permission level for comments on a post
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 1–3s.
|
||||
//!
|
||||
//! ## Per-platform contract
|
||||
//! | Platform | UPnP-IGD | NAT-PMP | PCP | TCP for HTTP |
|
||||
//! |---|---|---|---|---|
|
||||
//! | Linux/macOS/Windows | ✓ | ✓ | ✓ | ✓ |
|
||||
//! | Android (WiFi/Ethernet) | ✓ (with MulticastLock) | ✓ | ✓ | ✓ |
|
||||
//! | Android (cellular) | ✗ (skipped early) | ✗ | ✗ | ✗ |
|
||||
//! | iOS | ✗ (without `com.apple.developer.networking.multicast` entitlement) | ✓ | ✓ | ✓ |
|
||||
//!
|
||||
//! ## Android specifics
|
||||
//! `try_udp` / `try_tcp` first check `crate::android_wifi::is_on_wifi()`.
|
||||
//! If not on WiFi/Ethernet (cellular), returns `None` immediately — cellular
|
||||
//! networks almost never expose any of these protocols, and a discovery
|
||||
//! timeout would waste ~3s every startup.
|
||||
//!
|
||||
//! When on WiFi, a `WifiManager.MulticastLock` is acquired and held for the
|
||||
//! lifetime of the resulting `PortMapping`. Without the lock Android filters
|
||||
//! the SSDP multicast responses; PCP/NAT-PMP work without it but UPnP-IGD
|
||||
//! would never complete. The lock is released on Drop.
|
||||
//!
|
||||
//! ## iOS specifics
|
||||
//! Until Apple grants the multicast entitlement, UPnP-IGD will silently fail
|
||||
//! (no SSDP responses delivered to the socket). The unicast protocols PCP
|
||||
//! and NAT-PMP succeed without any entitlement and cover most modern home
|
||||
//! routers, so iOS gets reasonable coverage today.
|
||||
//!
|
||||
//! ## Renewal model
|
||||
//! Auto-renewal happens inside `portmapper::Client`'s internal task — there
|
||||
//! is **no** external renewal cycle to schedule. The `PortMapping` value
|
||||
//! owns the client; while it's held, the mapping stays alive. Dropping it
|
||||
//! aborts the renewal task (via `AbortOnDropHandle`) and stops keeping the
|
||||
//! mapping alive at the gateway. `release(&self)` triggers an explicit
|
||||
//! deactivation message before drop for cleaner shutdown.
|
||||
//!
|
||||
//! ## Anchor reachability watcher (bidirectional)
|
||||
//! `Network::start` spawns a task that observes the UDP mapping's
|
||||
//! `watch_external()` channel and adjusts anchor mode at runtime:
|
||||
//! - **Mapping lost** for >5 min → clear `is_anchor`. The node stops
|
||||
//! advertising itself as an anchor at the now-stale external address.
|
||||
//! - **Mapping restored** (None → Some) → re-evaluate auto-anchor. On
|
||||
//! non-mobile devices the anchor flag is set back on so the node
|
||||
//! re-joins the anchor set without a restart.
|
||||
//!
|
||||
//! Network roams (e.g., leaving a UPnP-capable WiFi and joining a new one)
|
||||
//! self-heal. Mobile devices never auto-anchor regardless — cellular IPs
|
||||
//! look public but sit behind CGNAT.
|
||||
//! 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
|
||||
}
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
let mcast_lock = crate::android_wifi::MulticastLockGuard::acquire("itsgoin-ssdp");
|
||||
let external_ip = match gateway.get_external_ip().await {
|
||||
Ok(ip) => ip,
|
||||
Err(e) => {
|
||||
debug!("UPnP: could not get external IP: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let config = portmapper::Config {
|
||||
enable_upnp: true,
|
||||
enable_pcp: true,
|
||||
enable_nat_pmp: true,
|
||||
protocol,
|
||||
};
|
||||
let client = portmapper::Client::new(config);
|
||||
client.update_local_port(port);
|
||||
// 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
|
||||
|
||||
// Wait up to 3 seconds for any protocol to produce a mapping.
|
||||
let mut watch = client.watch_external_address();
|
||||
let external = match tokio::time::timeout(Duration::from_secs(3), async {
|
||||
loop {
|
||||
if let Some(addr) = *watch.borrow_and_update() {
|
||||
return Some(addr);
|
||||
}
|
||||
if watch.changed().await.is_err() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
})
|
||||
.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;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
external = %external,
|
||||
local_port,
|
||||
protocol = ?protocol,
|
||||
"Port mapping established"
|
||||
);
|
||||
let external_addr = SocketAddr::new(external_ip, external_port);
|
||||
info!("UPnP: mapped {}:{} → :{}", external_ip, external_port, local_port);
|
||||
|
||||
Some(PortMapping {
|
||||
external_addr: SocketAddr::V4(external),
|
||||
local_port,
|
||||
client,
|
||||
#[cfg(target_os = "android")]
|
||||
mcast_lock,
|
||||
})
|
||||
}
|
||||
Some(UpnpMapping {
|
||||
external_addr,
|
||||
lease_secs,
|
||||
local_port,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 try_upnp_mapping(_local_port: u16) -> Option<UpnpMapping> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 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();
|
||||
/// Renew an existing UPnP lease. Returns true on success.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn renew_upnp_mapping(local_port: u16, external_port: u16) -> bool {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let local_addr = SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), local_port);
|
||||
gateway.add_port(
|
||||
igd_next::PortMappingProtocol::UDP,
|
||||
external_port,
|
||||
local_addr,
|
||||
1800,
|
||||
"itsgoin",
|
||||
).await.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn renew_upnp_mapping(_local_port: u16, _external_port: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove UPnP mapping on shutdown. Best-effort, errors are silently ignored.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn remove_upnp_mapping(external_port: u16) {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
let _ = gateway.remove_port(igd_next::PortMappingProtocol::UDP, external_port).await;
|
||||
info!("UPnP: removed port mapping for external port {}", external_port);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn remove_upnp_mapping(_external_port: u16) {}
|
||||
|
||||
// --- TCP port mapping (for HTTP post delivery) ---
|
||||
|
||||
/// Best-effort UPnP TCP port mapping on the same port as QUIC UDP.
|
||||
/// Returns true on success. Reuses the already-discovered gateway.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn try_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
local_port,
|
||||
);
|
||||
|
||||
match gateway
|
||||
.add_port(
|
||||
igd_next::PortMappingProtocol::TCP,
|
||||
external_port,
|
||||
local_addr,
|
||||
1800,
|
||||
"itsgoin-http",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("UPnP: TCP port {} mapped for HTTP serving", external_port);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("UPnP: TCP port mapping failed (non-fatal): {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn try_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Renew an existing UPnP TCP lease. Returns true on success.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn renew_upnp_tcp_mapping(local_port: u16, external_port: u16) -> bool {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let gateway = match igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
Ok(gw) => gw,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let local_addr = SocketAddr::new(
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
local_port,
|
||||
);
|
||||
gateway
|
||||
.add_port(
|
||||
igd_next::PortMappingProtocol::TCP,
|
||||
external_port,
|
||||
local_addr,
|
||||
1800,
|
||||
"itsgoin-http",
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn renew_upnp_tcp_mapping(_local_port: u16, _external_port: u16) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Remove UPnP TCP mapping on shutdown.
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub async fn remove_upnp_tcp_mapping(external_port: u16) {
|
||||
use igd_next::SearchOptions;
|
||||
|
||||
let search_opts = SearchOptions {
|
||||
timeout: Some(std::time::Duration::from_secs(3)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Ok(gateway) = igd_next::aio::tokio::search_gateway(search_opts).await {
|
||||
let _ = gateway
|
||||
.remove_port(igd_next::PortMappingProtocol::TCP, external_port)
|
||||
.await;
|
||||
info!("UPnP: removed TCP port mapping for port {}", external_port);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
pub async fn remove_upnp_tcp_mapping(_external_port: u16) {}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
//! 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 /b/<blobid_hex> → serve blob (images/videos)
|
||||
//! GET /p/<postid_hex>/<author_hex> → render post HTML (fetched on-demand)
|
||||
//! GET /b/<blobid_hex> → serve blob (images/videos)
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-desktop"
|
||||
version = "0.8.0-alpha"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"productName": "itsgoin",
|
||||
"version": "0.8.0-alpha",
|
||||
"version": "0.7.0",
|
||||
"identifier": "com.itsgoin.app",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend",
|
||||
|
|
|
|||
47
deploy.sh
47
deploy.sh
|
|
@ -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' \
|
||||
|
|
|
|||
597
frontend/app.js
597
frontend/app.js
|
|
@ -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 — 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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(' · ')}</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 — 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 — 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 — 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 “move to new device”)</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);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,6 @@
|
|||
<h2>Welcome to ItsGoin</h2>
|
||||
<p>Pick a display name if you want one — 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 — 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">📰</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 — 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 — 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 — 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 > 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 — 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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 ]
|
||||
|
|
@ -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 ]
|
||||
68
sessions.md
68
sessions.md
|
|
@ -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 1–5 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 1–5 + 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
|
||||
|
|
|
|||
2485
website/design.html
2485
website/design.html
File diff suppressed because it is too large
Load diff
|
|
@ -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 — 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> — 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 — 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 → Export</strong>, tick <em>“Posts + media + identity key”</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>“Import an Identity”</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 — 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 — 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 — 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 — 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 — 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–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 — 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 & 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 — 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 → 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 — 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 — 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 → 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 — 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 ↔ 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 — 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 — 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 — 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>&#x23FB;</code> — 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 — May 15, 2026</h2>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Network & 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 — 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/<post></code> directly for browser fetches. No mode switch — 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 >5min → anchor mode clears (don't keep advertising an unreachable address). Mapping restored → 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 — <code>itsgoin.net/p/<post></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 — 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 & UX improvements are local.</p>
|
||||
|
||||
<h2 style="margin-top: 2rem;">v0.7.1 — 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 → 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 — 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 — 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 — no DMs, no recipient IDs on the wire.</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue