From 36e3871c4be6be03a3ce3e68024144f13d59c0ce Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Thu, 30 Jul 2026 09:26:16 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20v0.8=20Iteration=20B=20=E2=80=94=20?= =?UTF-8?q?cruft=20purge=20+=20ALPN=20bump=20to=20itsgoin/4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-users ruling removes all wire-compat obligations. Net -1,443 LOC of Rust. Protocol break: - ALPN_V2 b"itsgoin/3" -> ALPN b"itsgoin/4" (renamed: the versioned name was what rotted). Old nodes are now refused at the QUIC handshake instead of half-interoperating with the v0.8 manifest/digest formats. Deploy gate from Iteration A is discharged. Dead wire surface removed (MessageType 46 -> 40): - DeleteRecord 0x51 (deletes ride ControlOp::DeletePost + InitialExchange) - VisibilityUpdate 0x52 + its two dead senders (ControlOp::UpdateVisibility) - SocialDisconnectNotice 0x71 (zero senders; checkin timeouts carry the signal) - MeshPrefer 0xB3 + request_prefer + handler (preferred peers are gone) - Legacy dual pull-matching half (have_post_ids) — behavior-identical - PullSyncResponse.visibility_updates + counters (control posts cover it) - serde(default) stripped from wire-only payloads (kept where skip_serializing_if makes it load-bearing; persisted-row defaults untouched) Preferred-peer subsystem eliminated (design ruling: CDN file_holders replaced the N+10 direct push/pull it served): slot tier, preferred_peers table (dropped), preferred_tree semantics, rebalance Priority 0, find_relays_for preferred tiers, 7-day prune + 30-day watcher, dead FromStr impl. Slots are now Local/Wide only (91 desktop / 12 mobile) pending Iteration C's single ~20-slot pool. Other dead code: hostlist encoder + base64url helper, always-empty downstream_addrs parameter chain, start_upnp_renewal_cycle no-op + 4 callers, RELAY_TARGET_RATE_LIMIT, GetSecretSeed, stale comments swept. Preserved deliberately: EDM scanner corpse (awaiting raw-UDP refactor), PortScanHeartbeat, Iteration A startup migrations, session-relay opt-in gating. Docs: design.html + tech.html synced (message counts, ALPN, purge status, Rework asides flipped past-tense). 190 core tests pass; CLI + desktop build; A3 integration 9/9 (independently re-run post-purge). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5 --- crates/cli/src/main.rs | 3 +- crates/core/src/connection.rs | 496 ++----------------- crates/core/src/fof.rs | 7 +- crates/core/src/group_key_distribution.rs | 14 +- crates/core/src/http.rs | 137 +----- crates/core/src/network.rs | 61 +-- crates/core/src/node.rs | 57 +-- crates/core/src/profile.rs | 1 - crates/core/src/protocol.rs | 145 +----- crates/core/src/storage.rs | 573 +--------------------- crates/core/src/types.rs | 47 +- crates/core/src/web.rs | 27 +- crates/tauri-app/src/lib.rs | 13 +- website/design.html | 43 +- website/tech.html | 4 +- 15 files changed, 152 insertions(+), 1476 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 21c3f73..1f286fc 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -248,14 +248,13 @@ async fn main() -> anyhow::Result<()> { // Start background tasks (v2: mesh connections) let _accept_handle = node.start_accept_loop(); - let _pull_handle = node.start_pull_cycle(300); // 5 min pull cycle + let _pull_handle = node.start_pull_cycle(); // 60s tiered 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 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 _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 diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index ef1019d..ea7ed6b 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -15,13 +15,13 @@ use crate::protocol::{ AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, BlobHeaderDiffPayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, - CircleProfileUpdatePayload, InitialExchangePayload, MeshPreferPayload, + CircleProfileUpdatePayload, InitialExchangePayload, MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, - SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload, - SyncPost, VisibilityUpdatePayload, WormQueryPayload, WormResponsePayload, - ReplicationRequestPayload, ReplicationResponsePayload, ALPN_V2, + SocialAddressUpdatePayload, SocialCheckinPayload, + SyncPost, WormQueryPayload, WormResponsePayload, + ReplicationRequestPayload, ReplicationResponsePayload, ALPN, }; use crate::storage::StoragePool; use crate::types::{ @@ -37,24 +37,15 @@ const WORM_TOTAL_TIMEOUT_MS: u64 = 3000; const WORM_COOLDOWN_MS: i64 = 300_000; // 5 min const WORM_DEDUP_EXPIRY_MS: u64 = 10_000; // 10 sec const SESSION_IDLE_TIMEOUT_MS: u64 = 300_000; // 5 min -#[allow(dead_code)] -const RELAY_COOLDOWN_MS: i64 = 300_000; // 5 min const RELAY_INTRO_DEDUP_EXPIRY_MS: u64 = 30_000; // 30 sec -#[allow(dead_code)] -const RELAY_INTRO_TIMEOUT_MS: u64 = 15_000; // 15 sec const HOLE_PUNCH_TIMEOUT_MS: u64 = 30_000; // 30 sec overall window const HOLE_PUNCH_ATTEMPT_MS: u64 = 2_000; // 2 sec per attempt before retry /// Max bytes relayed per pipe before closing const RELAY_MAX_BYTES: u64 = 50 * 1024 * 1024; // 50 MB /// Relay pipe idle timeout const RELAY_PIPE_IDLE_MS: u64 = 120_000; // 2 min -/// How long a preferred peer can be unreachable before being pruned (7 days) -const PREFERRED_UNREACHABLE_PRUNE_MS: u64 = 7 * 24 * 60 * 60 * 1000; /// How long reconnect watchers live before expiry (30 days) const WATCHER_EXPIRY_MS: i64 = 30 * 24 * 60 * 60 * 1000; -/// Max pending introductions per target in 5 minutes -#[allow(dead_code)] -const RELAY_TARGET_RATE_LIMIT: usize = 5; /// Grace period before removing disconnected peers from referral list const REFERRAL_DISCONNECT_GRACE_MS: u64 = 120_000; // 2 min @@ -84,7 +75,7 @@ pub(crate) async fn hole_punch_parallel( target: &NodeId, addresses: &[String], ) -> Option { - use crate::protocol::ALPN_V2; + use crate::protocol::ALPN; // Filter to address families this endpoint can actually reach let reachable = filter_reachable_families(endpoint, addresses); @@ -116,7 +107,7 @@ pub(crate) async fn hole_punch_parallel( handles.push(tokio::spawn(async move { tokio::time::timeout( std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS), - ep.connect(a, ALPN_V2), + ep.connect(a, ALPN), ).await })); } @@ -348,7 +339,7 @@ async fn edm_port_scan_disabled_v0_7_3( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN_V2), + ep.connect(addr, ALPN), ).await { let _ = tx.send(conn).await; } @@ -378,7 +369,7 @@ async fn edm_port_scan_disabled_v0_7_3( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN_V2), + ep.connect(addr, ALPN), ).await { let _ = tx.send(conn).await; } @@ -419,7 +410,7 @@ async fn edm_port_scan_disabled_v0_7_3( join_set.spawn(async move { if let Ok(Ok(conn)) = tokio::time::timeout( std::time::Duration::from_millis(SCAN_CONNECT_TIMEOUT_MS), - ep.connect(addr, ALPN_V2), + ep.connect(addr, ALPN), ).await { let _ = tx.send(conn).await; } @@ -525,7 +516,7 @@ async fn hole_punch_single( match tokio::time::timeout( std::time::Duration::from_millis(HOLE_PUNCH_ATTEMPT_MS), - endpoint.connect(endpoint_addr, ALPN_V2), + endpoint.connect(endpoint_addr, ALPN), ).await { Ok(Ok(conn)) => { tracing::info!(peer = hex::encode(target), "Quick single punch succeeded"); @@ -597,7 +588,6 @@ pub struct SessionConnection { pub struct PullSyncStats { pub posts_received: usize, - pub visibility_updates: usize, } /// Entry in the anchor's referral list — connection-backed, self-pruning. @@ -666,8 +656,6 @@ pub struct ConnectionManager { #[allow(dead_code)] is_anchor: Arc, diff_seq: AtomicU64, - #[allow(dead_code)] - secret_seed: [u8; 32], blob_store: Arc, /// Dedup map for worm queries: worm_id → timestamp_ms seen_worms: HashMap, @@ -675,8 +663,6 @@ pub struct ConnectionManager { last_n1_set: HashSet, /// Last broadcast N2 set (for computing diffs) last_n2_set: HashSet, - /// Max preferred (bilateral) mesh slots - preferred_slots: usize, /// Max local (diverse) mesh slots local_slots: usize, /// Max wide (bloom-sourced) mesh slots @@ -769,7 +755,6 @@ impl ConnectionManager { storage: Arc, our_node_id: NodeId, is_anchor: Arc, - secret_seed: [u8; 32], blob_store: Arc, profile: DeviceProfile, activity_log: Arc>, @@ -791,12 +776,10 @@ impl ConnectionManager { our_node_id, is_anchor, diff_seq: AtomicU64::new(0), - secret_seed, blob_store, seen_worms: HashMap::new(), last_n1_set: HashSet::new(), last_n2_set: HashSet::new(), - preferred_slots: profile.preferred_slots(), local_slots: profile.local_slots(), wide_slots: profile.wide_slots(), sessions: HashMap::new(), @@ -1188,7 +1171,7 @@ impl ConnectionManager { let addr = iroh::EndpointAddr::from(eid).with_ip_addr(sock_addr); match tokio::time::timeout( std::time::Duration::from_secs(15), - self.endpoint.connect(addr, ALPN_V2), + self.endpoint.connect(addr, ALPN), ).await { Ok(Ok(_conn)) => true, _ => false, @@ -1369,7 +1352,7 @@ impl ConnectionManager { debug!(peer = hex::encode(remote_node_id), "Replacing existing connection"); } - let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; + let total_slots = self.local_slots + self.wide_slots; let total = self.connections.len(); if total >= total_slots && !self.connections.contains_key(&remote_node_id) { debug!(peer = hex::encode(remote_node_id), "Slots full, rejecting"); @@ -1486,7 +1469,7 @@ impl ConnectionManager { ) -> anyhow::Result { let conn = tokio::time::timeout( std::time::Duration::from_secs(15), - endpoint.connect(addr, ALPN_V2), + endpoint.connect(addr, ALPN), ).await .map_err(|_| anyhow::anyhow!("connect timed out (15s)"))? .map_err(|e| anyhow::anyhow!("connect failed: {e}"))?; @@ -1522,7 +1505,6 @@ impl ConnectionManager { let request = PullSyncRequestPayload { follows: query_list, - have_post_ids: vec![], since_ms: follows_sync, }; @@ -1537,7 +1519,6 @@ impl ConnectionManager { let response: PullSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let mut posts_received = 0; - let mut vis_updates = 0; let mut new_post_ids: Vec = Vec::new(); let now_ms = crate::connection::now_ms(); let mut synced_authors: HashSet = HashSet::new(); @@ -1565,7 +1546,7 @@ impl ConnectionManager { } } - // Brief storage lock: upstream + last_sync + visibility updates + // Brief storage lock: upstream + last_sync { let s = storage.get().await; for pid in &new_post_ids { @@ -1579,15 +1560,6 @@ impl ConnectionManager { for author in &synced_authors { let _ = s.update_follow_last_sync(author, now_ms); } - for vu in response.visibility_updates { - if let Some(post) = s.get_post(&vu.post_id)? { - if post.author == vu.author { - if s.update_post_visibility(&vu.post_id, &vu.visibility)? { - vis_updates += 1; - } - } - } - } } // Register as downstream (spawned, no lock needed) @@ -1604,7 +1576,7 @@ impl ConnectionManager { }); } - Ok(PullSyncStats { posts_received, visibility_updates: vis_updates }) + Ok(PullSyncStats { posts_received }) } /// Fetch engagement headers from a peer — standalone version that doesn't require conn_mgr lock. @@ -2021,36 +1993,6 @@ impl ConnectionManager { Ok(count) } - /// Broadcast a visibility update to all connected peers. - pub async fn broadcast_visibility_update( - &self, - update: &crate::types::VisibilityUpdate, - ) -> usize { - let payload = VisibilityUpdatePayload { - updates: vec![update.clone()], - }; - - let mut sent = 0; - for (peer_id, pc) in &self.connections { - let result = async { - let mut send = pc.connection.open_uni().await?; - write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?; - send.finish()?; - anyhow::Ok(()) - } - .await; - - match result { - Ok(()) => sent += 1, - Err(e) => { - debug!(peer = hex::encode(peer_id), error = %e, "Failed to push visibility update"); - } - } - } - - sent - } - /// Pull posts from a connected peer. pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { let pc = self @@ -2077,7 +2019,6 @@ impl ConnectionManager { let request = PullSyncRequestPayload { follows: query_list, - have_post_ids: vec![], // v4: empty, using since_ms instead since_ms: follows_sync, }; @@ -2092,7 +2033,6 @@ impl ConnectionManager { let response: PullSyncResponsePayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let mut posts_received = 0; - let mut vis_updates = 0; let mut new_post_ids: Vec = Vec::new(); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -2126,7 +2066,7 @@ impl ConnectionManager { } // Lock RELEASED - // Brief lock 2: upstream + last_sync + visibility updates + // Brief lock 2: upstream + last_sync { let storage = self.storage.get().await; for pid in &new_post_ids { @@ -2140,19 +2080,6 @@ impl ConnectionManager { for author in &synced_authors { let _ = storage.update_follow_last_sync(author, now_ms); } - for vu in response.visibility_updates { - if vu.author != *peer_id { - // Only accept visibility updates authored by the responding peer - // (or their forwarded data — but for now, only from the peer) - } - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - if storage.update_post_visibility(&vu.post_id, &vu.visibility)? { - vis_updates += 1; - } - } - } - } } // Register as downstream with the sender for new posts (cap at 50 to avoid flooding) @@ -2171,7 +2098,6 @@ impl ConnectionManager { Ok(PullSyncStats { posts_received, - visibility_updates: vis_updates, }) } @@ -2296,26 +2222,20 @@ impl ConnectionManager { let request: PullSyncRequestPayload = read_payload(&mut recv, MAX_PAYLOAD).await?; let their_follows: HashSet = request.follows.into_iter().collect(); - let their_post_ids: HashSet<[u8; 32]> = request.have_post_ids.into_iter().collect(); - // Protocol v4: build per-author since_ms lookup + // Per-author since_ms lookup let since_ms_map: HashMap = request.since_ms.into_iter().collect(); - let use_since_ms = !since_ms_map.is_empty(); // Phase 1: Brief lock — load data - let (all_posts, group_members, own_posting_ids) = { + let (all_posts, group_members) = { let s = storage.get().await; let posts = s.list_posts_with_visibility()?; let members = s.get_all_group_members().unwrap_or_default(); - let own_ids: Vec = s.list_posting_identities() - .unwrap_or_default() - .into_iter().map(|p| p.node_id).collect(); - (posts, members, own_ids) + (posts, members) }; // Phase 2: Filter without lock (pure CPU) let mut candidates_to_send = Vec::new(); - let mut vis_updates_to_send = Vec::new(); for (id, post, visibility) in all_posts { let should_send = @@ -2325,35 +2245,19 @@ impl ConnectionManager { continue; } - let peer_has_post = if use_since_ms { - if let Some(&since) = since_ms_map.get(&post.author) { - post.timestamp_ms <= since + 60_000 - } else { - false - } + let peer_has_post = if let Some(&since) = since_ms_map.get(&post.author) { + post.timestamp_ms <= since + 60_000 } else { - their_post_ids.contains(&id) + false }; if !peer_has_post { candidates_to_send.push((id, post, visibility)); - } else { - // "Own post" = authored by ANY of our posting identities; - // our_node_id is the NETWORK id and never authors posts. - // (Redundant with control-post propagation — deletion - // candidate in the v0.8 cruft purge.) - if own_posting_ids.contains(&post.author) { - vis_updates_to_send.push(crate::types::VisibilityUpdate { - post_id: id, - author: post.author, - visibility, - }); - } } } // Phase 3: Brief re-lock for is_deleted checks + intent fetch on filtered posts - let (posts, vis_updates) = { + let posts = { let s = storage.get().await; let posts_to_send: Vec = candidates_to_send.into_iter() .filter(|(id, _, _)| !s.is_deleted(id).unwrap_or(false)) @@ -2362,12 +2266,11 @@ impl ConnectionManager { SyncPost { id, post, visibility, intent } }) .collect(); - (posts_to_send, vis_updates_to_send) + posts_to_send }; let response = PullSyncResponsePayload { posts, - visibility_updates: vis_updates, }; write_typed_message(&mut send, MessageType::PullSyncResponse, &response).await?; @@ -2815,7 +2718,7 @@ impl ConnectionManager { addr = addr.with_ip_addr(sock); } - let conn = endpoint.connect(addr, ALPN_V2).await.ok()?; + let conn = endpoint.connect(addr, ALPN).await.ok()?; let resp = Self::send_worm_query_raw(&conn, &payload).await.ok()?; let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some(); @@ -3160,7 +3063,7 @@ impl ConnectionManager { } // Signal growth loop to fill the empty slot (don't wait 10min for rebalance) - let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; + let total_slots = self.local_slots + self.wide_slots; if remaining < total_slots { self.notify_growth(); } @@ -3280,72 +3183,6 @@ impl ConnectionManager { let newly_connected: Vec = Vec::new(); let mut pending_connects: Vec<(NodeId, iroh::EndpointAddr, String, PeerSlotKind)> = Vec::new(); - // Priority 0 (NEW): Reconnect preferred peers - { - let preferred_peers = { - let storage = self.storage.get().await; - storage.list_preferred_peers().unwrap_or_default() - }; - - let now = now_ms(); - for peer_id in &preferred_peers { - if self.connections.contains_key(peer_id) || *peer_id == self.our_node_id { - continue; - } - // Prune preferred peers unreachable for 7+ days - if let Some(&failed_at) = self.unreachable_peers.get(peer_id) { - if now.saturating_sub(failed_at) > PREFERRED_UNREACHABLE_PRUNE_MS { - info!(peer = hex::encode(peer_id), "Removing preferred peer unreachable for 7 days+"); - let storage = self.storage.get().await; - let _ = storage.remove_preferred_peer(peer_id); - continue; - } - } - - // Evict lowest-diversity non-preferred peer if at capacity - let total_slots = self.preferred_slots + self.local_slots + self.wide_slots; - if self.connections.len() >= total_slots { - let evict_candidate = self.find_non_preferred_eviction_candidate().await; - if let Some(evict_id) = evict_candidate { - info!( - evicting = hex::encode(evict_id), - for_preferred = hex::encode(peer_id), - "Evicting non-preferred peer for preferred reconnection" - ); - self.log_activity(ActivityLevel::Info, ActivityCategory::Rebalance, format!("Evicting {} for preferred {}", &hex::encode(evict_id)[..8], &hex::encode(peer_id)[..8]), Some(evict_id)); - self.disconnect_peer(&evict_id).await; - } else { - debug!(peer = hex::encode(peer_id), "No non-preferred peer to evict"); - continue; - } - } - - // Collect for connection outside the lock - let addr_str = if !self.is_likely_unreachable(peer_id) { - let storage = self.storage.get().await; - let addr = storage.get_peer_record(peer_id).ok().flatten() - .and_then(|r| r.addresses.first().map(|a| a.to_string())) - .or_else(|| { - storage.get_social_route(peer_id).ok().flatten() - .and_then(|r| r.addresses.first().map(|a| a.to_string())) - }); - drop(storage); - addr - } else { - None - }; - if let Some(addr_s) = addr_str { - if let Ok(eid) = iroh::EndpointId::from_bytes(peer_id) { - let mut addr = iroh::EndpointAddr::from(eid); - if let Ok(sock) = addr_s.parse::() { - addr = addr.with_ip_addr(sock); - } - pending_connects.push((*peer_id, addr, addr_s, PeerSlotKind::Preferred)); - } - } - } - } - // Priority 1+2: Fill empty local slots with diverse candidates let local_count = self.count_kind(PeerSlotKind::Local); if local_count < self.local_slots { @@ -3353,15 +3190,11 @@ impl ConnectionManager { let storage = self.storage.get().await; let mut cands = Vec::new(); - // Priority 1: reconnect recently-dead non-preferred peers + // Priority 1: reconnect recently-dead peers for peer_id in &dead { if *peer_id == self.our_node_id { continue; } - // Skip preferred peers — handled above - if storage.is_preferred_peer(peer_id).unwrap_or(false) { - continue; - } if let Ok(Some(rec)) = storage.get_peer_record(peer_id) { let addr = rec.addresses.first().map(|a| a.to_string()); cands.push((*peer_id, addr)); @@ -3431,24 +3264,6 @@ impl ConnectionManager { Ok((newly_connected, pending_connects)) } - /// Find the lowest-diversity non-preferred peer to evict. - async fn find_non_preferred_eviction_candidate(&self) -> Option { - let storage = self.storage.get().await; - let mut worst: Option<(NodeId, usize)> = None; - for (peer_id, mc) in &self.connections { - if mc.slot_kind == PeerSlotKind::Preferred { - continue; // Never evict preferred - } - let unique = storage.count_unique_n2_for_reporter(peer_id, &[]).unwrap_or(0); - match &worst { - None => worst = Some((*peer_id, unique)), - Some((_, worst_unique)) if unique < *worst_unique => worst = Some((*peer_id, unique)), - _ => {} - } - } - worst.map(|(nid, _)| nid) - } - pub fn is_connected(&self, peer_id: &NodeId) -> bool { self.connections.contains_key(peer_id) } @@ -3478,120 +3293,6 @@ impl ConnectionManager { self.connections.values().filter(|pc| pc.slot_kind == kind).count() } - // ---- Preferred peer negotiation ---- - - /// Request bilateral preferred peer status with a connected mesh peer. - /// On success, both sides persist the agreement and upgrade the slot. - pub async fn request_prefer(&mut self, peer_id: &NodeId) -> anyhow::Result { - let pc = self.connections.get(peer_id) - .ok_or_else(|| anyhow::anyhow!("peer not connected"))?; - - // Check if we have room - let preferred_count = self.count_kind(PeerSlotKind::Preferred); - if preferred_count >= self.preferred_slots { - anyhow::bail!("preferred slots full ({}/{})", preferred_count, self.preferred_slots); - } - - let request = MeshPreferPayload { - requesting: true, - accepted: false, - reject_reason: None, - }; - - let (mut send, mut recv) = pc.connection.open_bi().await?; - write_typed_message(&mut send, MessageType::MeshPrefer, &request).await?; - send.finish()?; - - let msg_type = read_message_type(&mut recv).await?; - if msg_type != MessageType::MeshPrefer { - anyhow::bail!("expected MeshPrefer response, got {:?}", msg_type); - } - let response: MeshPreferPayload = read_payload(&mut recv, 4096).await?; - - if response.accepted { - // Persist agreement - let storage = self.storage.get().await; - storage.add_preferred_peer(peer_id)?; - storage.add_mesh_peer(peer_id, PeerSlotKind::Preferred, 100)?; - drop(storage); - - // Upgrade slot in-memory - if let Some(mc) = self.connections.get_mut(peer_id) { - mc.slot_kind = PeerSlotKind::Preferred; - } - info!(peer = hex::encode(peer_id), "Preferred peer agreement established"); - Ok(true) - } else { - debug!( - peer = hex::encode(peer_id), - reason = ?response.reject_reason, - "Preferred peer request rejected" - ); - Ok(false) - } - } - - /// Handle an incoming MeshPrefer request from a connected peer. - pub async fn handle_mesh_prefer( - &mut self, - from_peer: NodeId, - mut send: iroh::endpoint::SendStream, - mut recv: iroh::endpoint::RecvStream, - ) -> anyhow::Result<()> { - let request: MeshPreferPayload = read_payload(&mut recv, 4096).await?; - - if !request.requesting { - // Not a request — ignore - return Ok(()); - } - - // Check if we have room for a preferred peer - let preferred_count = self.count_kind(PeerSlotKind::Preferred); - let can_accept = preferred_count < self.preferred_slots - && self.connections.contains_key(&from_peer); - - let response = if can_accept { - // Persist agreement - let storage = self.storage.get().await; - storage.add_preferred_peer(&from_peer)?; - storage.add_mesh_peer(&from_peer, PeerSlotKind::Preferred, 100)?; - drop(storage); - - // Upgrade slot in-memory - if let Some(mc) = self.connections.get_mut(&from_peer) { - mc.slot_kind = PeerSlotKind::Preferred; - } - - info!(peer = hex::encode(from_peer), "Accepted preferred peer request"); - - MeshPreferPayload { - requesting: false, - accepted: true, - reject_reason: None, - } - } else { - let reason = if !self.connections.contains_key(&from_peer) { - "not connected".to_string() - } else { - format!("preferred slots full ({}/{})", preferred_count, self.preferred_slots) - }; - debug!(peer = hex::encode(from_peer), reason = %reason, "Rejecting preferred peer request"); - MeshPreferPayload { - requesting: false, - accepted: false, - reject_reason: Some(reason), - } - }; - - write_typed_message(&mut send, MessageType::MeshPrefer, &response).await?; - send.finish()?; - Ok(()) - } - - // reconnect_preferred removed — direct connect now happens outside the lock - // via pending_connects in the actor dispatch. Relay fallback for preferred peers - // is handled by the growth loop's normal relay introduction path. - // ---- Session connection management ---- /// Add a session connection. Evicts oldest idle session if at capacity. @@ -3820,38 +3521,7 @@ impl ConnectionManager { let mut candidates = Vec::new(); let storage = self.storage.get().await; - // Step 1 (NEW): Check target's preferred_tree from social_routes (~100 NodeIds) - // Intersect with our connections → TTL=0 candidates (they know target or are stably nearby) - if let Ok(Some(route)) = storage.get_social_route(target) { - if !route.preferred_tree.is_empty() { - // Prefer our own preferred peers first within the tree - for tree_node in &route.preferred_tree { - if candidates.len() >= 3 { - break; - } - if let Some(mc) = self.connections.get(tree_node) { - if mc.slot_kind == PeerSlotKind::Preferred - && !candidates.iter().any(|(nid, _)| nid == tree_node) - { - candidates.push((*tree_node, 0)); - } - } - } - // Then any connected tree node - for tree_node in &route.preferred_tree { - if candidates.len() >= 3 { - break; - } - if self.connections.contains_key(tree_node) - && !candidates.iter().any(|(nid, _)| nid == tree_node) - { - candidates.push((*tree_node, 0)); - } - } - } - } - - // Step 2: Our mesh peers that have target in their N1 (our N2 entry tagged to them) + // Step 1: Our mesh peers that have target in their N1 (our N2 entry tagged to them) if candidates.len() < 3 { if let Ok(reporters) = storage.find_in_n2(target) { for reporter in reporters { @@ -3865,28 +3535,7 @@ impl ConnectionManager { } } - // Step 3: Intersect preferred_tree with N3 → TTL=1 candidates - if candidates.len() < 3 { - if let Ok(Some(route)) = storage.get_social_route(target) { - for tree_node in &route.preferred_tree { - if candidates.len() >= 3 { - break; - } - if let Ok(reporters) = storage.find_in_n3(tree_node) { - for reporter in reporters { - if self.connections.contains_key(&reporter) - && !candidates.iter().any(|(nid, _)| *nid == reporter) - && candidates.len() < 3 - { - candidates.push((reporter, 1)); - } - } - } - } - } - } - - // Step 4: Fallback — full N3 scan for target + // Step 2: Fallback — full N3 scan for target if candidates.len() < 3 { if let Ok(reporters) = storage.find_in_n3(target) { for reporter in reporters { @@ -4600,7 +4249,7 @@ impl ConnectionManager { } }; - // Attempt reconnect for unexpected disconnects (not intentional SocialDisconnectNotice) + // Attempt reconnect for unexpected disconnects if is_current { if let Some(addr) = peer_addr { let cm_arc = Arc::clone(&conn_mgr); @@ -4953,7 +4602,7 @@ impl ConnectionManager { // Build a temporary endpoint with a random port let secret_key = iroh::SecretKey::generate(&mut rand::rng()); let temp_ep = iroh::Endpoint::builder() - .alpns(vec![ALPN_V2.to_vec()]) + .alpns(vec![ALPN.to_vec()]) .secret_key(secret_key) .bind() .await?; @@ -4964,7 +4613,7 @@ impl ConnectionManager { // Try connecting with a short timeout (2s) let result = tokio::time::timeout( std::time::Duration::from_secs(2), - temp_ep.connect(addr, ALPN_V2), + temp_ep.connect(addr, ALPN), ).await; // Clean up the temporary endpoint @@ -5005,54 +4654,6 @@ impl ConnectionManager { let _ = storage.store_profile(&profile); } } - MessageType::DeleteRecord => { - let payload: crate::protocol::DeleteRecordPayload = - read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - - // Collect blob CIDs + CDN peers before async work - let mut blob_cleanup: Vec<([u8; 32], Vec<(NodeId, Vec)>)> = Vec::new(); - { - let storage = cm.storage.get().await; - for dr in &payload.records { - if crypto::verify_delete_signature(&dr.author, &dr.post_id, &dr.signature) { - // Collect blobs for CDN cleanup before deleting - let blob_cids = storage.get_blobs_for_post(&dr.post_id).unwrap_or_default(); - for cid in blob_cids { - let holders = storage.get_file_holders(&cid).unwrap_or_default(); - blob_cleanup.push((cid, holders)); - } - let _ = storage.store_delete(dr); - let _ = storage.apply_delete(dr); - // Delete blob metadata + CDN metadata - let deleted_cids = storage.delete_blobs_for_post(&dr.post_id).unwrap_or_default(); - for cid in &deleted_cids { - let _ = storage.cleanup_cdn_for_blob(cid); - let _ = cm.blob_store.delete(cid); - } - } - } - } - - drop(cm); - // BlobDeleteNotice removed in v0.6.2: orphaned blobs on remote - // holders are evicted naturally via LRU rather than by a - // persona-signed push. - let _ = blob_cleanup; - } - MessageType::VisibilityUpdate => { - let payload: crate::protocol::VisibilityUpdatePayload = - read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - for vu in payload.updates { - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility); - } - } - } - } MessageType::SocialAddressUpdate => { let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5308,19 +4909,6 @@ impl ConnectionManager { debug!(peer = hex::encode(remote_node_id), stored, relayed = relay_conns.len(), "Received manifest push"); } - MessageType::SocialDisconnectNotice => { - let payload: SocialDisconnectNoticePayload = read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - if storage.has_social_route(&payload.node_id).unwrap_or(false) { - let _ = storage.set_social_route_status(&payload.node_id, SocialStatus::Disconnected); - } - debug!( - peer = hex::encode(remote_node_id), - target = hex::encode(payload.node_id), - "Received social disconnect notice" - ); - } MessageType::CircleProfileUpdate => { let payload: CircleProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5942,10 +5530,6 @@ impl ConnectionManager { let cm = Arc::clone(conn_mgr); Self::handle_session_relay(cm, recv, send, remote_node_id).await?; } - MessageType::MeshPrefer => { - let mut cm = conn_mgr.lock().await; - cm.handle_mesh_prefer(remote_node_id, send, recv).await?; - } MessageType::AnchorReferralRequest => { let payload: AnchorReferralRequestPayload = read_payload(&mut recv, 4096).await?; let mut cm = conn_mgr.lock().await; @@ -6502,7 +6086,6 @@ pub enum ConnResponse { OptString(Option), OptEndpointAddr(Option), Endpoint(iroh::Endpoint), - SecretSeed([u8; 32]), Storage(Arc), BlobStore(Arc), ActiveRelayPipes(Arc), @@ -6595,9 +6178,6 @@ pub enum ConnCommand { GetEndpoint { reply: oneshot::Sender, }, - GetSecretSeed { - reply: oneshot::Sender<[u8; 32]>, - }, GetStorage { reply: oneshot::Sender>, }, @@ -6984,12 +6564,6 @@ impl ConnHandle { rx.await.expect("actor dropped") } - pub async fn secret_seed(&self) -> [u8; 32] { - let (tx, rx) = oneshot::channel(); - let _ = self.tx.send(ConnCommand::GetSecretSeed { reply: tx }).await; - rx.await.unwrap_or([0u8; 32]) - } - pub async fn storage(&self) -> Arc { let (tx, rx) = oneshot::channel(); let _ = self.tx.send(ConnCommand::GetStorage { reply: tx }).await; @@ -7700,7 +7274,7 @@ impl ConnectionActor { let endpoint_id = iroh::EndpointId::from_bytes(&ref_id).ok()?; let mut addr = iroh::EndpointAddr::from(endpoint_id); if let Ok(sock) = ref_addr.parse::() { addr = addr.with_ip_addr(sock); } - let conn = endpoint.connect(addr, ALPN_V2).await.ok()?; + let conn = endpoint.connect(addr, ALPN).await.ok()?; let resp = ConnectionManager::send_worm_query_raw(&conn, &payload).await.ok()?; let is_hit = resp.found || resp.post_holder.is_some() || resp.blob_holder.is_some(); if is_hit { @@ -7930,10 +7504,6 @@ impl ConnectionActor { let cm = self.cm.lock().await; let _ = reply.send(cm.endpoint().clone()); } - ConnCommand::GetSecretSeed { reply } => { - let cm = self.cm.lock().await; - let _ = reply.send(cm.secret_seed); - } ConnCommand::GetStorage { reply } => { let cm = self.cm.lock().await; let _ = reply.send(cm.storage_ref()); diff --git a/crates/core/src/fof.rs b/crates/core/src/fof.rs index 1e48a7b..d50d849 100644 --- a/crates/core/src/fof.rs +++ b/crates/core/src/fof.rs @@ -572,9 +572,10 @@ pub fn decrypt_fof_comment_payload( // re-propagated via neighbor-manifest diffs. /// Maximum allowed wrap_slots / pub_post_set entries on an incoming -/// FoF post. The bucket rule caps at `real + rand(0..=128)` above 256; -/// at a 4096-vouchee max realistic graph that's ~4224. Round up for -/// headroom; anything larger is presumed attacker-shaped. +/// FoF post. The bucket rule (`next_vouch_batch_bucket` in profile.rs) is +/// deterministic: ≤8 → 8; ≤256 → next power of two; >256 → next multiple +/// of 128. A 4096-vouchee realistic max buckets to 4096; 8192 gives 2x +/// headroom, anything larger is presumed attacker-shaped. const MAX_FOF_WRAP_SLOTS: usize = 8192; /// Maximum allowed revocation_list entries in a t=0 published gating diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs index dd018ed..115afb4 100644 --- a/crates/core/src/group_key_distribution.rs +++ b/crates/core/src/group_key_distribution.rs @@ -1,15 +1,9 @@ -//! Group-key distribution as an encrypted post. -//! -//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin → -//! member, uni-stream) with a standard public post that carries the group -//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the +//! Group-key distribution as an encrypted post: the group seed travels +//! inside `PostVisibility::Encrypted`. Each member is a recipient; the //! post's CEK is wrapped per member using the admin's posting key. Members //! receive the post via normal CDN / pull paths, decrypt with their posting -//! secret, and recover the seed + metadata. -//! -//! Removing the direct push eliminates the wire-level signal that a given -//! network endpoint is coordinating group membership with another specific -//! endpoint. +//! secret, and recover the seed + metadata. (No direct wire push — that +//! would signal which endpoints coordinate group membership.) //! //! Note: Members are identified by their **posting** NodeIds (the //! author/recipient namespace since the v0.6.1 identity split), not network diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs index d79fcb1..3e43d3a 100644 --- a/crates/core/src/http.rs +++ b/crates/core/src/http.rs @@ -8,7 +8,6 @@ use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tokio::sync::Mutex; use tracing::{debug, info}; use crate::blob::BlobStore; @@ -106,7 +105,6 @@ pub async fn run_http_server( port: u16, storage: Arc, blob_store: Arc, - downstream_addrs: Arc>>>, ) -> anyhow::Result<()> { let addr: SocketAddr = ([0, 0, 0, 0], port).into(); // Use SO_REUSEADDR + SO_REUSEPORT so TCP punch sockets can share the port @@ -156,10 +154,9 @@ pub async fn run_http_server( let storage = Arc::clone(&storage); let blob_store = Arc::clone(&blob_store); let budget = Arc::clone(&budget); - let downstream_addrs = Arc::clone(&downstream_addrs); tokio::spawn(async move { - handle_connection(stream, ip, slot, &storage, &blob_store, &downstream_addrs).await; + handle_connection(stream, ip, slot, &storage, &blob_store).await; let mut b = budget.lock().unwrap(); match slot { SlotKind::Content => b.release_content(ip), @@ -182,7 +179,6 @@ async fn handle_connection( slot: SlotKind, storage: &Arc, blob_store: &Arc, - downstream_addrs: &Arc>>>, ) { // Keep-alive loop: handle sequential requests on the same connection loop { @@ -222,7 +218,7 @@ async fn handle_connection( } } SlotKind::Redirect => { - if !try_redirect(&mut stream, &post_id, storage, downstream_addrs).await { + if !try_redirect(&mut stream, &post_id, storage).await { return; } } @@ -368,7 +364,6 @@ async fn try_redirect( stream: &mut TcpStream, post_id: &[u8; 32], storage: &Arc, - _downstream_addrs: &Arc>>>, ) -> bool { // Get downstream peers for this post let downstream_peers = { @@ -583,114 +578,6 @@ pub fn html_escape(s: &str) -> String { out } -// --- Share link generation --- - -/// Encode a list of socket addresses as compact binary, then base64url. -/// Per IPv4: [0x04][4 bytes IP][2 bytes port] = 7 bytes -/// Per IPv6: [0x06][16 bytes IP][2 bytes port] = 19 bytes -pub fn encode_hostlist(hosts: &[SocketAddr]) -> String { - let mut buf = Vec::with_capacity(hosts.len() * 19); - for host in hosts.iter().take(5) { - match host { - SocketAddr::V4(v4) => { - buf.push(0x04); - buf.extend_from_slice(&v4.ip().octets()); - buf.extend_from_slice(&v4.port().to_be_bytes()); - } - SocketAddr::V6(v6) => { - buf.push(0x06); - buf.extend_from_slice(&v6.ip().octets()); - buf.extend_from_slice(&v6.port().to_be_bytes()); - } - } - } - base64url_encode(&buf) -} - -/// Decode a base64url-encoded hostlist back to socket addresses. -pub fn decode_hostlist(encoded: &str) -> Vec { - let buf = match base64url_decode(encoded) { - Some(b) => b, - None => return Vec::new(), - }; - let mut addrs = Vec::new(); - let mut i = 0; - while i < buf.len() { - match buf[i] { - 0x04 if i + 7 <= buf.len() => { - let ip = std::net::Ipv4Addr::new(buf[i + 1], buf[i + 2], buf[i + 3], buf[i + 4]); - let port = u16::from_be_bytes([buf[i + 5], buf[i + 6]]); - addrs.push(SocketAddr::new(ip.into(), port)); - i += 7; - } - 0x06 if i + 19 <= buf.len() => { - let mut octets = [0u8; 16]; - octets.copy_from_slice(&buf[i + 1..i + 17]); - let ip = std::net::Ipv6Addr::from(octets); - let port = u16::from_be_bytes([buf[i + 17], buf[i + 18]]); - addrs.push(SocketAddr::new(ip.into(), port)); - i += 19; - } - _ => break, // malformed - } - } - addrs -} - -// --- Minimal base64url implementation (no external dependency) --- - -const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - -fn base64url_encode(data: &[u8]) -> String { - let mut out = String::with_capacity((data.len() * 4 + 2) / 3); - let mut i = 0; - while i + 2 < data.len() { - let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8) | data[i + 2] as u32; - out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char); - out.push(B64_CHARS[(n & 0x3F) as usize] as char); - i += 3; - } - let remaining = data.len() - i; - if remaining == 2 { - let n = ((data[i] as u32) << 16) | ((data[i + 1] as u32) << 8); - out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 6) & 0x3F) as usize] as char); - } else if remaining == 1 { - let n = (data[i] as u32) << 16; - out.push(B64_CHARS[((n >> 18) & 0x3F) as usize] as char); - out.push(B64_CHARS[((n >> 12) & 0x3F) as usize] as char); - } - out // no padding -} - -fn base64url_decode(s: &str) -> Option> { - let mut buf = Vec::with_capacity(s.len() * 3 / 4); - let mut accum: u32 = 0; - let mut bits: u32 = 0; - for c in s.bytes() { - let val = match c { - b'A'..=b'Z' => c - b'A', - b'a'..=b'z' => c - b'a' + 26, - b'0'..=b'9' => c - b'0' + 52, - b'-' => 62, - b'_' => 63, - b'=' => continue, // skip padding - _ => return None, - }; - accum = (accum << 6) | val as u32; - bits += 6; - if bits >= 8 { - bits -= 8; - buf.push((accum >> bits) as u8); - accum &= (1 << bits) - 1; - } - } - Some(buf) -} - #[cfg(test)] mod tests { use super::*; @@ -713,26 +600,6 @@ mod tests { assert_eq!(html_escape("a&b"), "a&b"); } - #[test] - fn test_base64url_roundtrip() { - let data = b"hello world"; - let encoded = base64url_encode(data); - let decoded = base64url_decode(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn test_hostlist_roundtrip() { - use std::net::{Ipv4Addr, Ipv6Addr}; - let hosts = vec![ - SocketAddr::new(Ipv4Addr::new(192, 168, 1, 1).into(), 4433), - SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 8080), - ]; - let encoded = encode_hostlist(&hosts); - let decoded = decode_hostlist(&encoded); - assert_eq!(decoded, hosts); - } - #[test] fn test_parse_request_line() { let req = b"GET /p/abc123 HTTP/1.1\r\nHost: example.com\r\n\r\n"; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index b4cea44..6710dca 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -15,7 +15,7 @@ use crate::protocol::{ read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload, MessageType, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, - ALPN_V2, + ALPN, }; use crate::storage::StoragePool; use crate::types::{ @@ -95,7 +95,6 @@ impl Network { secret_key: iroh::SecretKey, storage: Arc, bind_addr: Option, - secret_seed: [u8; 32], blob_store: Arc, profile: DeviceProfile, activity_log: Arc>, @@ -103,7 +102,7 @@ impl Network { let mut builder = iroh::Endpoint::builder() .secret_key(secret_key) .relay_mode(iroh::RelayMode::Disabled) - .alpns(vec![ALPN_V2.to_vec()]) + .alpns(vec![ALPN.to_vec()]) .clear_address_lookup(); // Remove default pkarr + DNS (no dns.iroh.link publishing) // mDNS LAN discovery only: enables automatic peer discovery on local network @@ -236,7 +235,6 @@ impl Network { Arc::clone(&storage), our_node_id, Arc::clone(&is_anchor), - secret_seed, blob_store, profile, Arc::clone(&activity_log), @@ -877,7 +875,6 @@ impl Network { let peers = self.conn_handle.connected_peers().await; let mut total_posts = 0; - let mut total_vis = 0; let mut success = 0; for peer_id in peers { @@ -886,7 +883,6 @@ impl Network { match result { Ok(stats) => { total_posts += stats.posts_received; - total_vis += stats.visibility_updates; success += 1; // Also fetch engagement data let _ = self.conn_handle.fetch_engagement_from_peer(&peer_id).await; @@ -911,7 +907,6 @@ impl Network { Ok(PullStats { peers_pulled: success, posts_received: total_posts, - visibility_updates: total_vis, }) } @@ -995,12 +990,11 @@ impl Network { /// Push a profile update to all audience members (ephemeral-capable). pub async fn push_profile(&self, profile: &PublicProfile) -> usize { - // v0.6.1: profiles broadcast on the wire are keyed by the network - // NodeId. They carry ONLY routing metadata (anchors, recent_peers, - // preferred_peers) — no display name / bio / avatar. Attaching a - // human-readable name to the network id would correlate the QUIC - // endpoint to a specific person. Persona-level display data will - // travel via signed posts from v0.6.2 onward. + // Profiles broadcast on the wire are keyed by the network NodeId. + // They carry ONLY routing metadata (anchors, recent_peers) — no + // display name / bio / avatar. Attaching a human-readable name to + // the network id would correlate the QUIC endpoint to a specific + // person. Persona-level display data travels via signed posts. let push_profile = profile.sanitized_for_network_broadcast(); let payload = ProfileUpdatePayload { profiles: vec![push_profile], @@ -1050,32 +1044,6 @@ impl Network { sent } -/// Push a visibility update to all connected peers. - /// Gets connections snapshot, sends I/O outside the lock. - pub async fn push_visibility(&self, update: &crate::types::VisibilityUpdate) -> usize { - use crate::protocol::{VisibilityUpdatePayload, write_typed_message, MessageType}; - - let conns = self.conn_handle.get_connection_map().await; - let payload = VisibilityUpdatePayload { - updates: vec![update.clone()], - }; - let mut sent = 0; - for (peer_id, conn, _, _) in &conns { - let result = async { - let mut send = conn.open_uni().await?; - write_typed_message(&mut send, MessageType::VisibilityUpdate, &payload).await?; - send.finish()?; - anyhow::Ok(()) - }.await; - if result.is_ok() { - sent += 1; - } else { - debug!(peer = hex::encode(peer_id), "Failed to push visibility update"); - } - } - sent - } - /// Push an updated manifest to all known holders of the file (flat set, /// up to 5 most-recent). Replaces the legacy downstream-tree push. pub async fn push_manifest_to_downstream( @@ -1694,7 +1662,6 @@ impl Network { MessageType::PullSyncRequest, &PullSyncRequestPayload { follows: query_list, - have_post_ids: vec![], // v4: empty, using since_ms instead since_ms: follows_sync, }, ) @@ -1711,7 +1678,6 @@ impl Network { .as_millis() as u64; let storage = self.storage.get().await; let mut posts_received = 0; - let mut vis_updates = 0; for sp in &response.posts { if !storage.is_deleted(&sp.id)? && verify_post_id(&sp.id, &sp.post) { if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { @@ -1721,19 +1687,9 @@ impl Network { let _ = storage.update_follow_last_sync(&sp.post.author, now_ms); } } - for vu in response.visibility_updates { - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - if storage.update_post_visibility(&vu.post_id, &vu.visibility)? { - vis_updates += 1; - } - } - } - } Ok(PullStats { peers_pulled: 1, posts_received, - visibility_updates: vis_updates, }) } @@ -1864,7 +1820,7 @@ impl Network { if let Some(addr) = addr { match tokio::time::timeout( std::time::Duration::from_secs(5), - self.endpoint.connect(addr, ALPN_V2), + self.endpoint.connect(addr, ALPN), ).await { Ok(Ok(conn)) => { self.conn_handle.mark_reachable(peer_id); @@ -2314,7 +2270,6 @@ impl Network { pub struct PullStats { pub peers_pulled: usize, pub posts_received: usize, - pub visibility_updates: usize, } /// Decide whether a post should be sent to a requesting peer. diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 858cbbb..aa33950 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -31,6 +31,11 @@ use crate::types::{ /// with "UnknownIssuer" because they pin the wrong cert identity. const DEFAULT_ANCHOR: &str = "ab2b7258ef0b75b2c6ee8bf6595232055f6199d584d3c0fc10b15a1ed549aa13@itsgoin.net:4433"; +/// Cooldown between relay-introduction attempts toward the same target (5 min) +const RELAY_COOLDOWN_MS: i64 = 300_000; +/// Timeout for a single relay-introduction round trip +const RELAY_INTRO_TIMEOUT_SECS: u64 = 15; + /// A distsoc node: ties together identity, storage, and networking pub struct Node { pub data_dir: PathBuf, @@ -342,7 +347,7 @@ impl Node { // Load or generate identity key (network secret — QUIC endpoint only, // never used as content author under the v0.6.1+ clean model). let key_path = data_dir.join("identity.key"); - let (mut secret_key, mut secret_seed) = if key_path.exists() { + let (mut secret_key, secret_seed) = if key_path.exists() { let key_bytes = std::fs::read(&key_path)?; let bytes: [u8; 32] = key_bytes .try_into() @@ -415,7 +420,6 @@ impl Node { std::fs::write(&key_path, new_seed)?; info!("v0.6.1 migration: rotated network key to decouple from default posting key"); secret_key = new_key; - secret_seed = new_seed; } } } @@ -429,9 +433,9 @@ impl Node { let last_rebalance_ms = Arc::new(AtomicU64::new(0)); let last_anchor_register_ms = Arc::new(AtomicU64::new(0)); - // Start network (v2: single ALPN, connection manager) + // Start network (single ALPN, connection manager) let network = Arc::new( - Network::new(secret_key, Arc::clone(&storage), bind_addr, secret_seed, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, + Network::new(secret_key, Arc::clone(&storage), bind_addr, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, ); let node_id = network.node_id_bytes(); @@ -492,7 +496,7 @@ impl Node { // NETWORK id may still carry persona fields from the unified-id // era. Strip them in place so no code path can ever re-broadcast // persona data bound to the device network id. Topology fields - // (anchors/recent_peers/preferred_peers) are preserved. + // (anchors/recent_peers) are preserved. // // (b) The manifest signature digest dropped author_addresses, so // rows signed by pre-v0.8 builds no longer verify. Re-sign our @@ -1302,7 +1306,7 @@ impl Node { continue; } match kind { - PeerSlotKind::Preferred | PeerSlotKind::Local => social.push(nid), + PeerSlotKind::Local => social.push(nid), PeerSlotKind::Wide => wide.push(nid), } } @@ -1982,7 +1986,6 @@ impl Node { let peer_addresses = storage.build_peer_addresses_for(node_id)?; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_millis() as u64; - let preferred_tree = storage.build_preferred_tree_for(node_id).unwrap_or_default(); storage.upsert_social_route(&SocialRouteEntry { node_id: *node_id, addresses, @@ -1992,7 +1995,6 @@ impl Node { last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, - preferred_tree, })?; Ok(()) @@ -2161,7 +2163,6 @@ impl Node { updated_at: timestamp_ms, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid, }) @@ -2183,10 +2184,9 @@ impl Node { let recent_peers = self.current_recent_peers().await; let profile = { let storage = self.storage.get().await; - let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); // v0.8: the network-id-keyed profile row is TOPOLOGY ONLY - // (anchors, recent_peers, preferred_peers). Persona fields + // (anchors, recent_peers). Persona fields // (display_name, bio, avatar_cid, public_visible) live // exclusively on posting-id-keyed rows written by profile // posts — never copy them onto the network row, or a legacy @@ -2198,7 +2198,6 @@ impl Node { updated_at: now, anchors, recent_peers, - preferred_peers, public_visible: true, avatar_cid: None, }; @@ -3438,7 +3437,6 @@ impl Node { updated_at: now, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: visible, avatar_cid: None, }, @@ -3630,8 +3628,7 @@ impl Node { let now = control_post.timestamp_ms; // Clean up blob storage local-side. Blobs in remote holders become - // orphans and get evicted naturally via LRU — BlobDeleteNotice is - // gone in v0.6.2. + // orphans and get evicted naturally via LRU. let blob_cids = { let storage = self.storage.get().await; let cids = storage.delete_blobs_for_post(post_id)?; @@ -3805,7 +3802,7 @@ impl Node { storage.store_post_with_visibility(&new_post_id, &new_post, &new_vis)?; } - // delete_post already pushes the DeleteRecord. + // delete_post propagates the deletion as a signed control post. // Replacement post propagates via the CDN to remaining recipients. self.delete_post(post_id).await?; @@ -4021,7 +4018,7 @@ impl Node { { let on_cooldown = { let storage = self.storage.get().await; - storage.is_relay_cooldown(&peer_id, 300_000).unwrap_or(false) + storage.is_relay_cooldown(&peer_id, RELAY_COOLDOWN_MS).unwrap_or(false) }; if !on_cooldown { @@ -4041,7 +4038,7 @@ impl Node { ); let intro_result = tokio::time::timeout( - std::time::Duration::from_secs(15), + std::time::Duration::from_secs(RELAY_INTRO_TIMEOUT_SECS), self.network.send_relay_introduce_standalone(relay_peer, &peer_id, *ttl), ).await; @@ -4278,7 +4275,7 @@ impl Node { /// Start pull cycle: Protocol v4 tiered pull — 60s ticks, full pull on first tick, /// then only pull for stale authors (last_sync_ms > 4 hours old). - pub fn start_pull_cycle(self: &Arc, _interval_secs: u64) -> tokio::task::JoinHandle<()> { + pub fn start_pull_cycle(self: &Arc) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { let mut interval = @@ -4404,7 +4401,7 @@ impl Node { }) } - /// Start recovery loop: triggered when mesh drops below 2 connections. + /// Start recovery loop: triggered when the mesh empties completely. /// Immediately reconnects to anchors and requests referrals. pub fn start_recovery_loop(&self) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); @@ -5029,18 +5026,6 @@ impl Node { }) } - /// No-op since v0.7.2: the `portmapper::Client` held inside the active - /// `PortMapping` auto-renews internally in its own background service. - /// Retained for API compatibility with callers in CLI and Tauri until - /// they're cleaned up. Returns `None`. - /// - /// TODO(v0.7.x): wire a watcher on `mapping.watch_external()` to clear - /// anchor mode if the external address stays `None` for more than ~5min - /// (parity with the old "3 renewal failures" behavior). - pub fn start_upnp_renewal_cycle(&self) -> Option> { - None - } - // --- HTTP Post Delivery --- /// Start the HTTP server for serving public posts to browsers. @@ -5056,9 +5041,6 @@ impl Node { } let storage = Arc::clone(&self.storage); let blob_store = Arc::clone(&self.blob_store); - let downstream_addrs = Arc::new(tokio::sync::Mutex::new( - std::collections::HashMap::<[u8; 32], Vec>::new(), - )); // Advertise HTTP capability to peers let http_addr = self.network.http_addr(); @@ -5076,7 +5058,7 @@ impl Node { info!("Starting HTTP server on TCP port {}", port); Some(tokio::spawn(async move { - if let Err(e) = crate::http::run_http_server(port, storage, blob_store, downstream_addrs).await { + if let Err(e) = crate::http::run_http_server(port, storage, blob_store).await { warn!("HTTP server stopped: {}", e); } })) @@ -6963,7 +6945,8 @@ pub fn compute_blob_priority_standalone( impl Node { /// Start the active replication cycle: periodically ask peers to hold our - /// under-replicated recent content. Only Available/Persistent devices initiate. + /// under-replicated recent content. All devices initiate — phones need + /// their content replicated before they go to sleep. pub fn start_replication_cycle(self: &Arc, interval_secs: u64) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs index d5f4e2f..2617e25 100644 --- a/crates/core/src/profile.rs +++ b/crates/core/src/profile.rs @@ -68,7 +68,6 @@ pub fn apply_profile_post_if_applicable( updated_at: content.timestamp_ms, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid: content.avatar_cid, }; diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 05af5eb..ea48075 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -2,21 +2,14 @@ use serde::{Deserialize, Serialize}; use crate::types::{ BlobHeaderDiffOp, CdnManifest, DeleteRecord, GroupEpoch, GroupId, NodeId, - PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, VisibilityUpdate, WormId, + PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, WormId, }; -/// Single ALPN for Discovery Protocol v3 (N1/N2/N3 architecture) -/// -/// DEPLOY GATE (v0.8): the manifest wire format + signature digest changed -/// incompatibly in this tree (AuthorManifest.author_addresses removed from -/// struct AND digest; CdnManifest reduced to 2 fields; GroupKeyRequest -/// 0xA1/0xA2 removed). Mixed-version nodes half-interop: old-signed -/// manifests fail the new digest check, new CdnManifest JSON fails old -/// deserialization. Scott ruled ALPN -> b"itsgoin/4" for v0.8 (2026-07-29, -/// scheduled Iteration B). Bump it BEFORE any build/deploy of this change -/// set so mixed generations refuse cleanly at handshake — ask Scott first -/// (feedback_ask_before_release). -pub const ALPN_V2: &[u8] = b"itsgoin/3"; +/// Wire-protocol ALPN. One protocol version per build, no negotiation — +/// mismatched versions are refused at the QUIC handshake. v0.8 = itsgoin/4: +/// clean break from <=v0.7.x (zero-users ruling, design.html Appendix D +/// item 3). +pub const ALPN: &[u8] = b"itsgoin/4"; /// A post bundled with its visibility metadata for sync #[derive(Debug, Serialize, Deserialize)] @@ -25,9 +18,8 @@ pub struct SyncPost { pub post: Post, pub visibility: PostVisibility, /// Optional originator's intent, so receivers can filter control posts - /// out of the feed and process their ControlOp payload. Absent on - /// pre-v0.6.2 senders; receivers treat as "unknown"/regular post. - #[serde(default)] + /// out of the feed and process their ControlOp payload. None = regular + /// post. pub intent: Option, } @@ -42,16 +34,10 @@ pub enum MessageType { RefuseRedirect = 0x05, PullSyncRequest = 0x40, PullSyncResponse = 0x41, - // 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), - // 0x45 (AudienceResponse) retired in v0.6.2: persona-signed direct pushes - // are gone. Public posts propagate via the CDN; encrypted posts via pull. ProfileUpdate = 0x50, - DeleteRecord = 0x51, - VisibilityUpdate = 0x52, WormQuery = 0x60, WormResponse = 0x61, SocialAddressUpdate = 0x70, - SocialDisconnectNotice = 0x71, SocialCheckin = 0x72, // 0x80-0x81 reserved BlobRequest = 0x90, @@ -59,13 +45,9 @@ 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. RelayIntroduce = 0xB0, RelayIntroduceResult = 0xB1, SessionRelay = 0xB2, - MeshPrefer = 0xB3, CircleProfileUpdate = 0xB4, AnchorRegister = 0xC0, AnchorReferralRequest = 0xC1, @@ -99,12 +81,9 @@ impl MessageType { 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), @@ -114,7 +93,6 @@ impl MessageType { 0xB0 => Some(Self::RelayIntroduce), 0xB1 => Some(Self::RelayIntroduceResult), 0xB2 => Some(Self::SessionRelay), - 0xB3 => Some(Self::MeshPrefer), 0xB4 => Some(Self::CircleProfileUpdate), 0xC0 => Some(Self::AnchorRegister), 0xC1 => Some(Self::AnchorReferralRequest), @@ -160,34 +138,24 @@ pub struct InitialExchangePayload { /// Our post IDs (for replica tracking) pub post_ids: Vec, /// Our N+10:Addresses (connected peers with addresses) for social routing - #[serde(default)] pub peer_addresses: Vec, /// If sender is an anchor, their stable advertised address (e.g. "174.127.120.52:4433") - #[serde(default)] pub anchor_addr: Option, /// What the sender sees as the receiver's address (STUN-like observed addr) - #[serde(default)] pub your_observed_addr: Option, /// Sender's NAT type ("public", "easy", "hard", "unknown") - #[serde(default)] pub nat_type: Option, /// Sender's NAT mapping behavior ("eim", "edm", "unknown") - #[serde(default)] pub nat_mapping: Option, /// Sender's NAT filtering behavior ("open", "port_restricted", "unknown") - #[serde(default)] pub nat_filtering: Option, /// Whether the sender is running an HTTP server for direct browser access - #[serde(default)] pub http_capable: bool, /// External HTTP address if known (e.g. "1.2.3.4:4433") - #[serde(default)] pub http_addr: Option, /// CDN replication device role: "intermittent", "available", "persistent" - #[serde(default)] pub device_role: Option, /// CDN cache pressure: 0-255 availability score (255 = lots of capacity) - #[serde(default)] pub cache_pressure: Option, /// Set by anchor when it detects this NodeId is already connected from a different address #[serde(default, skip_serializing_if = "Option::is_none")] @@ -209,11 +177,7 @@ pub struct NodeListUpdatePayload { pub struct PullSyncRequestPayload { /// Our follows (for the responder to filter) pub follows: Vec, - /// Post IDs we already have (backward compat — empty for v4 senders) - #[serde(default)] - pub have_post_ids: Vec, - /// Protocol v4: per-author timestamps (Vec of tuples for serde compat) - #[serde(default)] + /// Per-author last-sync timestamps (Vec of tuples for serde compat) pub since_ms: Vec<(NodeId, u64)>, } @@ -221,7 +185,6 @@ pub struct PullSyncRequestPayload { #[derive(Debug, Serialize, Deserialize)] pub struct PullSyncResponsePayload { pub posts: Vec, - pub visibility_updates: Vec, } /// Profile update (pushed via uni-stream) @@ -230,18 +193,6 @@ pub struct ProfileUpdatePayload { pub profiles: Vec, } -/// Delete record (pushed via uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct DeleteRecordPayload { - pub records: Vec, -} - -/// Visibility update (pushed via uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct VisibilityUpdatePayload { - pub updates: Vec, -} - /// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address) #[derive(Debug, Serialize, Deserialize)] pub struct AddressRequestPayload { @@ -254,10 +205,8 @@ pub struct AddressResponsePayload { pub target: NodeId, pub address: Option, /// Set when the target is known-disconnected (requester registered as watcher) - #[serde(default)] pub disconnected_at: Option, /// Target's N+10:Addresses if known - #[serde(default)] pub peer_addresses: Vec, } @@ -274,15 +223,12 @@ pub struct WormQueryPayload { pub worm_id: WormId, pub target: NodeId, /// Additional IDs to search for (up to 10 recent_peers of target) - #[serde(default)] pub needle_peers: Vec, pub ttl: u8, pub visited: Vec, /// Optional: also search for a specific post by ID - #[serde(default)] pub post_id: Option, /// Optional: also search for a specific blob by CID - #[serde(default)] pub blob_id: Option<[u8; 32]>, } @@ -292,19 +238,15 @@ pub struct WormResponsePayload { pub worm_id: WormId, pub found: bool, /// Which needle was actually found (target or one of its recent_peers) - #[serde(default)] pub found_id: Option, pub addresses: Vec, pub reporter: Option, pub hop: Option, /// One random wide-peer referral: (node_id, address) for bloom round - #[serde(default)] pub wide_referral: Option<(NodeId, String)>, /// Node that holds the requested post (may differ from found_id) - #[serde(default)] pub post_holder: Option, /// Node that holds the requested blob - #[serde(default)] pub blob_holder: Option, } @@ -318,12 +260,6 @@ pub struct SocialAddressUpdatePayload { pub peer_addresses: Vec, } -/// Disconnect notice: "peer X disconnected" -#[derive(Debug, Serialize, Deserialize)] -pub struct SocialDisconnectNoticePayload { - pub node_id: NodeId, -} - /// Lightweight keepalive checkin (bidirectional) #[derive(Debug, Serialize, Deserialize)] pub struct SocialCheckinPayload { @@ -339,7 +275,6 @@ pub struct SocialCheckinPayload { pub struct BlobRequestPayload { pub cid: [u8; 32], /// Requester's addresses so the host can record downstream - #[serde(default)] pub requester_addresses: Vec, } @@ -349,16 +284,12 @@ pub struct BlobResponsePayload { pub cid: [u8; 32], pub found: bool, /// Base64-encoded blob bytes (empty if not found) - #[serde(default)] pub data_b64: String, /// Author manifest + host info (if available) - #[serde(default)] pub manifest: Option, /// Whether host accepted requester as downstream - #[serde(default)] pub cdn_registered: bool, /// If not registered (host full), try these peers - #[serde(default)] pub cdn_redirect_peers: Vec, } @@ -442,18 +373,6 @@ pub struct SessionRelayPayload { pub target: NodeId, } -/// Mesh preference negotiation (bi-stream: request + response) -#[derive(Debug, Serialize, Deserialize)] -pub struct MeshPreferPayload { - /// true = "I want us to be preferred peers" (request) - pub requesting: bool, - /// true = "I agree to be preferred peers" (response only) - pub accepted: bool, - /// Reason for rejection (response only, when accepted=false) - #[serde(default)] - pub reject_reason: Option, -} - /// Circle profile update: encrypted profile variant for a circle (uni-stream push) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CircleProfileUpdatePayload { @@ -522,7 +441,9 @@ pub struct AnchorProbeResultPayload { pub observed_addr: Option, } -/// Port scan heartbeat during scanning hole punch (informational) +/// Port scan heartbeat during scanning hole punch (informational). +/// Reserved wire surface for the EDM scanner (`edm_port_scan_disabled_v0_7_3` +/// in connection.rs) — no sender/handler until the raw-UDP scan refactor. #[derive(Debug, Serialize, Deserialize)] pub struct PortScanHeartbeatPayload { pub peer: NodeId, @@ -573,7 +494,6 @@ pub struct BlobHeaderResponsePayload { /// True if the sender has a newer header than requested pub updated: bool, /// JSON-serialized BlobHeader (if updated) - #[serde(default)] pub header_json: Option, } @@ -722,12 +642,9 @@ mod tests { MessageType::PullSyncRequest, MessageType::PullSyncResponse, MessageType::ProfileUpdate, - MessageType::DeleteRecord, - MessageType::VisibilityUpdate, MessageType::WormQuery, MessageType::WormResponse, MessageType::SocialAddressUpdate, - MessageType::SocialDisconnectNotice, MessageType::SocialCheckin, MessageType::BlobRequest, MessageType::BlobResponse, @@ -737,7 +654,6 @@ mod tests { MessageType::RelayIntroduce, MessageType::RelayIntroduceResult, MessageType::SessionRelay, - MessageType::MeshPrefer, MessageType::CircleProfileUpdate, MessageType::AnchorRegister, MessageType::AnchorReferralRequest, @@ -828,43 +744,6 @@ mod tests { assert_eq!(decoded2.reject_reason.unwrap(), "target not reachable"); } - #[test] - fn mesh_prefer_payload_roundtrip() { - // Request - let request = MeshPreferPayload { - requesting: true, - accepted: false, - reject_reason: None, - }; - let json = serde_json::to_string(&request).unwrap(); - let decoded: MeshPreferPayload = serde_json::from_str(&json).unwrap(); - assert!(decoded.requesting); - assert!(!decoded.accepted); - assert!(decoded.reject_reason.is_none()); - - // Accepted response - let accept = MeshPreferPayload { - requesting: false, - accepted: true, - reject_reason: None, - }; - let json2 = serde_json::to_string(&accept).unwrap(); - let decoded2: MeshPreferPayload = serde_json::from_str(&json2).unwrap(); - assert!(!decoded2.requesting); - assert!(decoded2.accepted); - - // Rejected response - let reject = MeshPreferPayload { - requesting: false, - accepted: false, - reject_reason: Some("slots full".to_string()), - }; - let json3 = serde_json::to_string(&reject).unwrap(); - let decoded3: MeshPreferPayload = serde_json::from_str(&json3).unwrap(); - assert!(!decoded3.accepted); - assert_eq!(decoded3.reject_reason.unwrap(), "slots full"); - } - #[test] fn session_relay_payload_roundtrip() { let payload = SessionRelayPayload { diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index f01229c..6fd2ae6 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -308,10 +308,6 @@ impl Storage { target_id BLOB PRIMARY KEY, failed_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS preferred_peers ( - node_id BLOB PRIMARY KEY, - agreed_at INTEGER NOT NULL - ); CREATE TABLE IF NOT EXISTS circle_profiles ( author BLOB NOT NULL, circle_name TEXT NOT NULL, @@ -707,25 +703,10 @@ impl Storage { )?; } - // Add preferred_peers column to profiles if missing (Preferred Mesh Peers migration) - let has_preferred_peers = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('profiles') WHERE name='preferred_peers'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_preferred_peers == 0 { - self.conn.execute_batch( - "ALTER TABLE profiles ADD COLUMN preferred_peers TEXT NOT NULL DEFAULT '[]';" - )?; - } - - // Add preferred_tree column to social_routes if missing (Preferred Tree migration) - let has_pref_tree = self.conn.prepare( - "SELECT COUNT(*) FROM pragma_table_info('social_routes') WHERE name='preferred_tree'" - )?.query_row([], |row| row.get::<_, i64>(0))?; - if has_pref_tree == 0 { - self.conn.execute_batch( - "ALTER TABLE social_routes ADD COLUMN preferred_tree TEXT NOT NULL DEFAULT '[]';" - )?; - } + // v0.8: preferred-peer tier removed — drop the agreement table. + // (The unused profiles.preferred_peers / social_routes.preferred_tree + // columns are left in place on old dirs; no code reads them.) + self.conn.execute_batch("DROP TABLE IF EXISTS preferred_peers;")?; // Add public_visible column to profiles if missing (Phase D-4 migration) let has_public_visible = self.conn.prepare( @@ -851,65 +832,6 @@ impl Storage { "CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);" )?; - // v0.6.2 import bugfix: the import pipeline up through f b0e293 - // filed EVERY encrypted post as VisibilityIntent::Friends, including - // DMs. The Messages tab in the UI only shows posts whose intent is - // `Direct` (or `unknown` with the right visibility shape), so DMs - // imported from an "everything" bundle silently disappeared. - // - // This one-time migration reclassifies short-recipient encrypted - // posts filed as Friends back to Direct, using the same small-list - // heuristic as the import code (recipients <= 3 → Direct). A guard - // flag in the settings kv ensures this sweep runs once per DB. - let imported_dm_fixup_done = self.get_setting("mig_import_dm_fixup_v1")?.is_some(); - if !imported_dm_fixup_done { - let mut fixed = 0i64; - let mut stmt = self.conn.prepare( - "SELECT id, visibility FROM posts - WHERE visibility_intent = '\"Friends\"' - AND visibility LIKE '%Encrypted%'" - )?; - let rows: Vec<(Vec, String)> = stmt - .query_map([], |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)))? - .filter_map(|r| r.ok()) - .collect(); - drop(stmt); - for (id_bytes, vis_json) in rows { - // Parse out the recipients array from the Encrypted - // visibility. Short recipient list (≤ 3) = likely DM. - let vis: Result = serde_json::from_str(&vis_json); - let should_fix = match vis { - Ok(PostVisibility::Encrypted { recipients }) => recipients.len() <= 3, - _ => false, - }; - if !should_fix { continue; } - // Build a Direct-intent value with the recipient list recovered - // from the visibility — semantically equivalent to what the - // user's original send-side intent would have been. - let vis_parsed: PostVisibility = match serde_json::from_str(&vis_json) { - Ok(v) => v, - Err(_) => continue, - }; - let recipients: Vec = match vis_parsed { - PostVisibility::Encrypted { recipients } => { - recipients.iter().map(|wk| wk.recipient).collect() - } - _ => continue, - }; - let intent = crate::types::VisibilityIntent::Direct(recipients); - let intent_json = serde_json::to_string(&intent).unwrap_or_default(); - let n = self.conn.execute( - "UPDATE posts SET visibility_intent = ?1 WHERE id = ?2", - params![intent_json, id_bytes], - )?; - fixed += n as i64; - } - self.set_setting("mig_import_dm_fixup_v1", &fixed.to_string())?; - if fixed > 0 { - tracing::info!(count = fixed, "Migrated imported DMs from Friends-intent to Direct-intent"); - } - } - // Add device_role column to peers if missing (Active CDN replication) let has_device_role = self.conn.prepare( "SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'" @@ -942,22 +864,6 @@ impl Storage { )?; } - // 0.6.1-beta: seed file_holders from legacy upstream/downstream tables - // before they're dropped. Idempotent — only fires on an empty - // file_holders table. - self.seed_file_holders_from_legacy()?; - - // 0.6.1-beta: drop legacy directional tables — replaced by file_holders. - self.conn.execute_batch( - "DROP TABLE IF EXISTS blob_upstream; - DROP TABLE IF EXISTS blob_downstream; - DROP TABLE IF EXISTS post_upstream; - DROP TABLE IF EXISTS post_downstream;", - )?; - - // 0.6.2-beta: seed post_recipients index from existing encrypted posts. - self.seed_post_recipients_from_posts()?; - // FoF Layer 2: add comment columns for pub_x_index / group_sig / // encrypted_payload. Old DBs have NULL → deserializes to None. let has_comment_pub_x = self.conn.prepare( @@ -1584,7 +1490,7 @@ impl Storage { pub fn list_discoverable_profiles(&self, self_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( "SELECT p.node_id, p.display_name, p.bio, p.updated_at, - p.anchors, p.recent_peers, p.preferred_peers, + p.anchors, p.recent_peers, p.public_visible, p.avatar_cid FROM profiles p WHERE p.display_name != '' @@ -1603,9 +1509,8 @@ impl Storage { let updated_at = row.get::<_, i64>(3)? as u64; let anchors = parse_anchors_json(&row.get::<_, String>(4).unwrap_or_default()); let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_default()); - let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_default()); - let public_visible: i64 = row.get(7).unwrap_or(1); - let avatar_cid: Option> = row.get(8).ok(); + let public_visible: i64 = row.get(6).unwrap_or(1); + let avatar_cid: Option> = row.get(7).ok(); let avatar_cid = avatar_cid.and_then(|v| if v.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&v); Some(arr) } else { None }); @@ -1616,7 +1521,6 @@ impl Storage { updated_at, anchors, recent_peers, - preferred_peers, public_visible: public_visible != 0, avatar_cid, }); @@ -2009,12 +1913,9 @@ impl Storage { let recent_peers_json = serde_json::to_string( &profile.recent_peers.iter().map(hex::encode).collect::>() )?; - let preferred_peers_json = serde_json::to_string( - &profile.preferred_peers.iter().map(hex::encode).collect::>() - )?; let avatar_cid_slice = profile.avatar_cid.as_ref().map(|c| c.as_slice()); self.conn.execute( - "INSERT OR REPLACE INTO profiles (node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT OR REPLACE INTO profiles (node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ profile.node_id.as_slice(), profile.display_name, @@ -2022,7 +1923,6 @@ impl Storage { profile.updated_at as i64, anchors_json, recent_peers_json, - preferred_peers_json, profile.public_visible as i64, avatar_cid_slice, ], @@ -2161,15 +2061,14 @@ impl Storage { /// Get a profile by node ID pub fn get_profile(&self, node_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid FROM profiles WHERE node_id = ?1", + "SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid FROM profiles WHERE node_id = ?1", )?; let mut rows = stmt.query(params![node_id.as_slice()])?; if let Some(row) = rows.next()? { let anchors = parse_anchors_json(&row.get::<_, String>(4)?); let recent_peers = parse_anchors_json(&row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string())); - let preferred_peers = parse_anchors_json(&row.get::<_, String>(6).unwrap_or_else(|_| "[]".to_string())); - let public_visible = row.get::<_, i64>(7).unwrap_or(1) != 0; - let avatar_cid = row.get::<_, Option>>(8).unwrap_or(None) + let public_visible = row.get::<_, i64>(6).unwrap_or(1) != 0; + let avatar_cid = row.get::<_, Option>>(7).unwrap_or(None) .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); Ok(Some(PublicProfile { node_id: blob_to_nodeid(row.get(0)?)?, @@ -2178,7 +2077,6 @@ impl Storage { updated_at: row.get::<_, i64>(3)? as u64, anchors, recent_peers, - preferred_peers, public_visible, avatar_cid, })) @@ -2191,7 +2089,7 @@ impl Storage { pub fn list_profiles(&self) -> anyhow::Result> { let mut stmt = self .conn - .prepare("SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, preferred_peers, public_visible, avatar_cid FROM profiles")?; + .prepare("SELECT node_id, display_name, bio, updated_at, anchors, recent_peers, public_visible, avatar_cid FROM profiles")?; let rows = stmt.query_map([], |row| { let node_id_bytes: Vec = row.get(0)?; let display_name: String = row.get(1)?; @@ -2199,14 +2097,13 @@ impl Storage { let updated_at: i64 = row.get(3)?; let anchors_json: String = row.get(4)?; let recent_peers_json: String = row.get::<_, String>(5).unwrap_or_else(|_| "[]".to_string()); - let preferred_peers_json: String = row.get::<_, String>(6).unwrap_or_else(|_| "[]".to_string()); - let public_visible: i64 = row.get::<_, i64>(7).unwrap_or(1); - let avatar_cid_bytes: Option> = row.get::<_, Option>>(8).unwrap_or(None); - Ok((node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, preferred_peers_json, public_visible, avatar_cid_bytes)) + let public_visible: i64 = row.get::<_, i64>(6).unwrap_or(1); + let avatar_cid_bytes: Option> = row.get::<_, Option>>(7).unwrap_or(None); + Ok((node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, public_visible, avatar_cid_bytes)) })?; let mut profiles = Vec::new(); for row in rows { - let (node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, preferred_peers_json, public_visible, avatar_cid_bytes) = row?; + let (node_id_bytes, display_name, bio, updated_at, anchors_json, recent_peers_json, public_visible, avatar_cid_bytes) = row?; let avatar_cid = avatar_cid_bytes.and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()); profiles.push(PublicProfile { node_id: blob_to_nodeid(node_id_bytes)?, @@ -2215,7 +2112,6 @@ impl Storage { updated_at: updated_at as u64, anchors: parse_anchors_json(&anchors_json), recent_peers: parse_anchors_json(&recent_peers_json), - preferred_peers: parse_anchors_json(&preferred_peers_json), public_visible: public_visible != 0, avatar_cid, }); @@ -3862,105 +3758,6 @@ impl Storage { Ok(()) } - // ---- Preferred Peers ---- - - /// Add a bilateral preferred peer agreement. - pub fn add_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { - let now = now_ms(); - self.conn.execute( - "INSERT OR REPLACE INTO preferred_peers (node_id, agreed_at) VALUES (?1, ?2)", - params![node_id.as_slice(), now], - )?; - Ok(()) - } - - /// Remove a preferred peer agreement. - pub fn remove_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { - self.conn.execute( - "DELETE FROM preferred_peers WHERE node_id = ?1", - params![node_id.as_slice()], - )?; - Ok(()) - } - - /// List all preferred peers. - pub fn list_preferred_peers(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT node_id FROM preferred_peers ORDER BY agreed_at DESC", - )?; - let mut result = Vec::new(); - let mut rows = stmt.query([])?; - while let Some(row) = rows.next()? { - result.push(blob_to_nodeid(row.get(0)?)?); - } - Ok(result) - } - - /// Check if a peer is a preferred peer. - pub fn is_preferred_peer(&self, node_id: &NodeId) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM preferred_peers WHERE node_id = ?1", - params![node_id.as_slice()], - |row| row.get(0), - )?; - Ok(count > 0) - } - - /// Count preferred peers. - pub fn count_preferred_peers(&self) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM preferred_peers", - [], - |row| row.get(0), - )?; - Ok(count as usize) - } - - // ---- Preferred Tree ---- - - /// Build 2-layer preferred peer tree from stored profiles. - /// Layer 0: target. Layer 1: target's preferred_peers. Layer 2: each L1 peer's preferred_peers. - /// Returns ~100 unique NodeIds. - pub fn build_preferred_tree_for(&self, target: &NodeId) -> anyhow::Result> { - let mut tree = std::collections::HashSet::new(); - - // Layer 0: target itself - tree.insert(*target); - - // Layer 1: target's preferred peers from their profile - let l1_peers = match self.get_profile(target)? { - Some(profile) => profile.preferred_peers, - None => return Ok(tree.into_iter().collect()), - }; - - for pp in &l1_peers { - tree.insert(*pp); - } - - // Layer 2: each L1 peer's preferred peers - for pp in &l1_peers { - if let Some(profile) = self.get_profile(pp)? { - for pp2 in &profile.preferred_peers { - tree.insert(*pp2); - } - } - } - - Ok(tree.into_iter().collect()) - } - - /// Update the preferred_tree JSON for a social route. - pub fn update_social_route_preferred_tree(&self, node_id: &NodeId, tree: &[NodeId]) -> anyhow::Result<()> { - let json = serde_json::to_string( - &tree.iter().map(hex::encode).collect::>() - )?; - self.conn.execute( - "UPDATE social_routes SET preferred_tree = ?1 WHERE node_id = ?2", - params![json, node_id.as_slice()], - )?; - Ok(()) - } - // ---- Social Routes ---- /// Insert or update a social route entry. @@ -3969,17 +3766,14 @@ impl Storage { &entry.addresses.iter().map(|a| a.to_string()).collect::>() )?; let peer_addrs_json = serde_json::to_string(&entry.peer_addresses)?; - let pref_tree_json = serde_json::to_string( - &entry.preferred_tree.iter().map(hex::encode).collect::>() - )?; self.conn.execute( - "INSERT INTO social_routes (node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "INSERT INTO social_routes (node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(node_id) DO UPDATE SET addresses = ?2, peer_addresses = ?3, relation = ?4, status = ?5, last_connected_ms = MAX(social_routes.last_connected_ms, ?6), last_seen_ms = MAX(social_routes.last_seen_ms, ?7), - reach_method = ?8, preferred_tree = ?9", + reach_method = ?8", params![ entry.node_id.as_slice(), addrs_json, @@ -3989,7 +3783,6 @@ impl Storage { entry.last_connected_ms as i64, entry.last_seen_ms as i64, entry.reach_method.to_string(), - pref_tree_json, ], )?; Ok(()) @@ -3998,7 +3791,7 @@ impl Storage { /// Get a single social route entry. pub fn get_social_route(&self, node_id: &NodeId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method FROM social_routes WHERE node_id = ?1", )?; let mut rows = stmt.query(params![node_id.as_slice()])?; @@ -4079,7 +3872,7 @@ impl Storage { /// List all social routes, sorted by last_seen DESC. pub fn list_social_routes(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method FROM social_routes ORDER BY last_seen_ms DESC", )?; let mut entries = Vec::new(); @@ -4094,7 +3887,7 @@ impl Storage { pub fn list_stale_social_routes(&self, max_age_ms: u64) -> anyhow::Result> { let cutoff = now_ms() - max_age_ms as i64; let mut stmt = self.conn.prepare( - "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method, preferred_tree + "SELECT node_id, addresses, peer_addresses, relation, status, last_connected_ms, last_seen_ms, reach_method FROM social_routes WHERE last_seen_ms < ?1 ORDER BY last_seen_ms ASC", )?; let mut entries = Vec::new(); @@ -4137,9 +3930,6 @@ impl Storage { // Build peer_addresses from the contact's profile recent_peers let peer_addresses = self.build_peer_addresses_for(&nid)?; - // Build preferred peer tree - let preferred_tree = self.build_preferred_tree_for(&nid).unwrap_or_default(); - // Only insert if not already present (don't overwrite runtime state) if !self.has_social_route(&nid)? { self.upsert_social_route(&SocialRouteEntry { @@ -4151,12 +3941,8 @@ impl Storage { last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, - preferred_tree, })?; count += 1; - } else { - // Update the preferred tree for existing routes - self.update_social_route_preferred_tree(&nid, &preferred_tree)?; } } @@ -4780,36 +4566,6 @@ impl Storage { Ok(out) } - /// Seed the post_recipients index from existing encrypted posts. - /// One-time idempotent migration for users upgrading from pre-0.6.2. - pub fn seed_post_recipients_from_posts(&self) -> anyhow::Result<()> { - let existing: i64 = self.conn.prepare("SELECT COUNT(*) FROM post_recipients")? - .query_row([], |row| row.get(0))?; - if existing > 0 { - return Ok(()); - } - // Scan all posts, parse visibility, index recipients. - let mut stmt = self.conn.prepare("SELECT id, visibility FROM posts")?; - let rows = stmt.query_map([], |row| { - let id_bytes: Vec = row.get(0)?; - let vis_json: String = row.get(1)?; - Ok((id_bytes, vis_json)) - })?; - let entries: Vec<([u8; 32], PostVisibility)> = rows - .filter_map(|r| r.ok()) - .filter_map(|(id_bytes, vis_json)| { - let pid = <[u8; 32]>::try_from(id_bytes.as_slice()).ok()?; - let vis: PostVisibility = serde_json::from_str(&vis_json).ok()?; - Some((pid, vis)) - }) - .collect(); - drop(stmt); - for (pid, vis) in entries { - self.index_post_recipients(&pid, &vis)?; - } - Ok(()) - } - // --- Posting identities (multi-persona plumbing) --- pub fn upsert_posting_identity(&self, id: &PostingIdentity) -> anyhow::Result<()> { @@ -5840,55 +5596,6 @@ impl Storage { Ok(()) } - /// One-time migration: seed file_holders from the legacy upstream/downstream - /// tables so a user upgrading from pre-0.6.1 doesn't start with empty holder - /// sets. Idempotent — inserts use ON CONFLICT DO NOTHING semantics via the - /// PRIMARY KEY. Skips tables that don't exist on fresh installs. - pub fn seed_file_holders_from_legacy(&self) -> anyhow::Result<()> { - // Skip if file_holders already populated (idempotent re-run protection). - let existing: i64 = self.conn.prepare("SELECT COUNT(*) FROM file_holders")? - .query_row([], |row| row.get(0))?; - if existing > 0 { - return Ok(()); - } - let now = now_ms() as i64; - let table_exists = |name: &str| -> anyhow::Result { - let count: i64 = self.conn.prepare( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - )?.query_row(params![name], |row| row.get(0))?; - Ok(count > 0) - }; - if table_exists("post_upstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT post_id, peer_node_id, '[]', ?1, 'received' FROM post_upstream", - params![now], - )?; - } - if table_exists("post_downstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT post_id, peer_node_id, '[]', ?1, 'sent' FROM post_downstream", - params![now], - )?; - } - if table_exists("blob_upstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT cid, source_node_id, source_addresses, ?1, 'received' FROM blob_upstream", - params![now], - )?; - } - if table_exists("blob_downstream")? { - self.conn.execute( - "INSERT OR IGNORE INTO file_holders (file_id, peer_id, peer_addresses, last_interaction_ms, direction) - SELECT cid, peer_node_id, peer_addresses, ?1, 'sent' FROM blob_downstream", - params![now], - )?; - } - Ok(()) - } - // --- Engagement: reactions --- /// Store a reaction (upsert by reactor+post_id+emoji). @@ -6659,8 +6366,6 @@ fn row_to_social_route(row: &rusqlite::Row) -> anyhow::Result let last_seen_ms = row.get::<_, i64>(6)? as u64; let method_str: String = row.get(7)?; let reach_method: ReachMethod = method_str.parse().unwrap_or(ReachMethod::Direct); - let pref_tree_json: String = row.get::<_, String>(8).unwrap_or_else(|_| "[]".to_string()); - let preferred_tree = parse_anchors_json(&pref_tree_json); Ok(SocialRouteEntry { node_id, @@ -6671,7 +6376,6 @@ fn row_to_social_route(row: &rusqlite::Row) -> anyhow::Result last_connected_ms, last_seen_ms, reach_method, - preferred_tree, }) } @@ -6899,7 +6603,6 @@ mod tests { last_connected_ms: 0, last_seen_ms: 1000, reach_method: ReachMethod::Direct, - preferred_tree: vec![], }).unwrap(); // Disconnected routes should NOT be in N1 share @@ -6994,7 +6697,6 @@ mod tests { last_connected_ms: 1000, last_seen_ms: 2000, reach_method: ReachMethod::Direct, - preferred_tree: vec![], }; s.upsert_social_route(&entry).unwrap(); @@ -7054,7 +6756,6 @@ mod tests { last_connected_ms: 0, last_seen_ms: 0, reach_method: ReachMethod::Direct, - preferred_tree: vec![], }; s.upsert_social_route(&entry).unwrap(); @@ -7396,237 +7097,20 @@ mod tests { assert_eq!(got_pubkey, expected_pubkey); } - // ---- Preferred peers tests ---- - #[test] - fn preferred_peers_crud() { - let s = temp_storage(); - let peer_a = make_node_id(1); - let peer_b = make_node_id(2); - - assert_eq!(s.count_preferred_peers().unwrap(), 0); - assert!(!s.is_preferred_peer(&peer_a).unwrap()); - - s.add_preferred_peer(&peer_a).unwrap(); - s.add_preferred_peer(&peer_b).unwrap(); - - assert_eq!(s.count_preferred_peers().unwrap(), 2); - assert!(s.is_preferred_peer(&peer_a).unwrap()); - assert!(s.is_preferred_peer(&peer_b).unwrap()); - - let list = s.list_preferred_peers().unwrap(); - assert_eq!(list.len(), 2); - assert!(list.contains(&peer_a)); - assert!(list.contains(&peer_b)); - - s.remove_preferred_peer(&peer_a).unwrap(); - assert!(!s.is_preferred_peer(&peer_a).unwrap()); - assert_eq!(s.count_preferred_peers().unwrap(), 1); - } - - #[test] - fn preferred_peers_idempotent() { - let s = temp_storage(); - let peer = make_node_id(1); - - s.add_preferred_peer(&peer).unwrap(); - s.add_preferred_peer(&peer).unwrap(); // no error on duplicate - assert_eq!(s.count_preferred_peers().unwrap(), 1); - } - - #[test] - fn profile_stores_preferred_peers() { - let s = temp_storage(); - let nid = make_node_id(1); - let pref_a = make_node_id(10); - let pref_b = make_node_id(11); - - let profile = PublicProfile { - node_id: nid, - display_name: "test".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![pref_a, pref_b], - public_visible: true, - avatar_cid: None, - }; - s.store_profile(&profile).unwrap(); - - let got = s.get_profile(&nid).unwrap().unwrap(); - assert_eq!(got.preferred_peers.len(), 2); - assert!(got.preferred_peers.contains(&pref_a)); - assert!(got.preferred_peers.contains(&pref_b)); - assert!(got.public_visible); - assert!(got.avatar_cid.is_none()); - } - - #[test] - fn preferred_slot_counts() { - assert_eq!(DeviceProfile::Desktop.preferred_slots(), 10); + fn slot_counts() { assert_eq!(DeviceProfile::Desktop.local_slots(), 71); - assert_eq!(DeviceProfile::Mobile.preferred_slots(), 3); assert_eq!(DeviceProfile::Mobile.local_slots(), 7); - // Total unchanged assert_eq!( - DeviceProfile::Desktop.preferred_slots() + DeviceProfile::Desktop.local_slots() + DeviceProfile::Desktop.wide_slots(), - 101 + DeviceProfile::Desktop.local_slots() + DeviceProfile::Desktop.wide_slots(), + 91 ); assert_eq!( - DeviceProfile::Mobile.preferred_slots() + DeviceProfile::Mobile.local_slots() + DeviceProfile::Mobile.wide_slots(), - 15 + DeviceProfile::Mobile.local_slots() + DeviceProfile::Mobile.wide_slots(), + 12 ); } - #[test] - fn peer_slot_kind_preferred_roundtrip() { - let kind: PeerSlotKind = "preferred".parse().unwrap(); - assert_eq!(kind, PeerSlotKind::Preferred); - assert_eq!(kind.to_string(), "preferred"); - } - - // ---- Preferred tree tests ---- - - #[test] - fn build_preferred_tree_empty() { - let s = temp_storage(); - let target = make_node_id(1); - // No profile stored — tree should just contain the target - let tree = s.build_preferred_tree_for(&target).unwrap(); - assert_eq!(tree.len(), 1); - assert!(tree.contains(&target)); - } - - #[test] - fn build_preferred_tree_two_layers() { - let s = temp_storage(); - let target = make_node_id(1); - let l1_a = make_node_id(10); - let l1_b = make_node_id(11); - let l2_a1 = make_node_id(20); - let l2_a2 = make_node_id(21); - let l2_b1 = make_node_id(30); - - // Target's profile with 2 preferred peers - s.store_profile(&PublicProfile { - node_id: target, - display_name: "target".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l1_a, l1_b], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - // L1 peer A's profile with 2 preferred peers - s.store_profile(&PublicProfile { - node_id: l1_a, - display_name: "l1a".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l2_a1, l2_a2], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - // L1 peer B's profile with 1 preferred peer - s.store_profile(&PublicProfile { - node_id: l1_b, - display_name: "l1b".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l2_b1], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - let tree = s.build_preferred_tree_for(&target).unwrap(); - // Should contain: target, l1_a, l1_b, l2_a1, l2_a2, l2_b1 = 6 unique nodes - assert_eq!(tree.len(), 6); - assert!(tree.contains(&target)); - assert!(tree.contains(&l1_a)); - assert!(tree.contains(&l1_b)); - assert!(tree.contains(&l2_a1)); - assert!(tree.contains(&l2_a2)); - assert!(tree.contains(&l2_b1)); - } - - #[test] - fn build_preferred_tree_deduplicates() { - let s = temp_storage(); - let target = make_node_id(1); - let shared = make_node_id(10); - let l1_a = make_node_id(11); - - // Target's preferred peers include shared - s.store_profile(&PublicProfile { - node_id: target, - display_name: "target".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![l1_a, shared], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - // L1 peer's preferred peers also include shared - s.store_profile(&PublicProfile { - node_id: l1_a, - display_name: "l1a".to_string(), - bio: "".to_string(), - updated_at: 1000, - anchors: vec![], - recent_peers: vec![], - preferred_peers: vec![shared, target], - public_visible: true, - avatar_cid: None, - }).unwrap(); - - let tree = s.build_preferred_tree_for(&target).unwrap(); - // Should contain: target, l1_a, shared = 3 unique nodes (no duplicates) - assert_eq!(tree.len(), 3); - } - - #[test] - fn social_route_preferred_tree_roundtrip() { - use crate::types::{ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus}; - let s = temp_storage(); - let nid = make_node_id(1); - let tree_node = make_node_id(10); - - let entry = SocialRouteEntry { - node_id: nid, - addresses: vec![], - peer_addresses: vec![], - relation: SocialRelation::Follow, - status: SocialStatus::Online, - last_connected_ms: 0, - last_seen_ms: 1000, - reach_method: ReachMethod::Direct, - preferred_tree: vec![tree_node], - }; - s.upsert_social_route(&entry).unwrap(); - - let got = s.get_social_route(&nid).unwrap().unwrap(); - assert_eq!(got.preferred_tree.len(), 1); - assert!(got.preferred_tree.contains(&tree_node)); - - // Update preferred tree - let new_tree = vec![make_node_id(20), make_node_id(21)]; - s.update_social_route_preferred_tree(&nid, &new_tree).unwrap(); - let got2 = s.get_social_route(&nid).unwrap().unwrap(); - assert_eq!(got2.preferred_tree.len(), 2); - } - // ---- Circle Profile tests ---- #[test] @@ -7688,7 +7172,6 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid: None, }).unwrap(); @@ -7735,7 +7218,6 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: false, avatar_cid: None, }).unwrap(); @@ -7760,7 +7242,6 @@ mod tests { updated_at: 1000, anchors: vec![], recent_peers: vec![], - preferred_peers: vec![], public_visible: true, avatar_cid: None, }).unwrap(); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 9b951e3..3518413 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -71,8 +71,8 @@ pub struct Attachment { /// 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, -/// preferred_peers); persona fields stay empty. They travel via +/// - 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, @@ -99,9 +99,6 @@ pub struct PublicProfile { /// Up to 10 currently-connected peer NodeIds (for 11-needle worm search) #[serde(default)] pub recent_peers: Vec, - /// Bilateral preferred peer NodeIds (stable relay hubs) - #[serde(default)] - pub preferred_peers: Vec, /// Whether display_name/bio are visible to non-circle peers #[serde(default = "default_true")] pub public_visible: bool, @@ -113,7 +110,7 @@ pub struct PublicProfile { impl PublicProfile { /// Return a copy with persona-level display data (display_name, bio, /// avatar_cid) stripped, leaving only the routing metadata (anchors, - /// recent_peers, preferred_peers). v0.6.1 broadcasts the profile under + /// recent_peers). v0.6.1 broadcasts the profile under /// the network NodeId; attaching a human-readable name to that key would /// correlate the network endpoint to a specific person. Persona display /// data will travel via signed posts from v0.6.2 onward. @@ -465,14 +462,6 @@ pub struct DeleteRecord { pub signature: Vec, } -/// An update to a post's visibility (new wrapped keys after revocation) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VisibilityUpdate { - pub post_id: PostId, - pub author: NodeId, - pub visibility: PostVisibility, -} - /// How to handle revoking a recipient's access to past encrypted posts #[derive(Debug, Clone, Copy)] pub enum RevocationMode { @@ -662,20 +651,13 @@ impl NatProfile { /// Device profile — determines connection slot budget #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeviceProfile { - /// Desktop: 81 local + 20 wide = 101 mesh connections + /// Desktop: 71 local + 20 wide = 91 mesh connections Desktop, - /// Mobile: 10 local + 5 wide = 15 mesh connections + /// Mobile: 7 local + 5 wide = 12 mesh connections Mobile, } impl DeviceProfile { - pub fn preferred_slots(&self) -> usize { - match self { - DeviceProfile::Desktop => 10, - DeviceProfile::Mobile => 3, - } - } - pub fn local_slots(&self) -> usize { match self { DeviceProfile::Desktop => 71, @@ -729,36 +711,21 @@ impl std::fmt::Display for SessionReachMethod { /// Slot kind for mesh connections #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PeerSlotKind { - /// Bilateral preferred connections (Desktop: 10, Mobile: 3) - Preferred, /// Diverse local connections (Desktop: 71, Mobile: 7) Local, - /// Bloom-sourced random distant connections (20 slots) + /// Bloom-sourced random distant connections (Desktop: 20, Mobile: 5) Wide, } impl std::fmt::Display for PeerSlotKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PeerSlotKind::Preferred => write!(f, "preferred"), PeerSlotKind::Local => write!(f, "local"), PeerSlotKind::Wide => write!(f, "wide"), } } } -impl std::str::FromStr for PeerSlotKind { - type Err = anyhow::Error; - fn from_str(s: &str) -> Result { - match s { - "preferred" => Ok(PeerSlotKind::Preferred), - "local" | "social" => Ok(PeerSlotKind::Local), - "wide" => Ok(PeerSlotKind::Wide), - _ => Err(anyhow::anyhow!("unknown slot kind: {}", s)), - } - } -} - // --- Social Routing Cache --- /// How we last reached a social contact @@ -906,8 +873,6 @@ pub struct SocialRouteEntry { pub last_connected_ms: u64, pub last_seen_ms: u64, pub reach_method: ReachMethod, - /// 2-layer preferred peer tree (~100 nodes) for fast relay candidate search - pub preferred_tree: Vec, } // --- Engagement System --- diff --git a/crates/core/src/web.rs b/crates/core/src/web.rs index c946abb..e1efe0e 100644 --- a/crates/core/src/web.rs +++ b/crates/core/src/web.rs @@ -3,8 +3,9 @@ //! renders HTML, serves blobs. No permanent storage of fetched content. //! //! Routes (behind Apache reverse proxy): -//! GET /p// → render post HTML (fetched on-demand) -//! GET /b/ → serve blob (images/videos) +//! GET /p/ → render post HTML (fetched on-demand; anchor +//! resolves holders itself) +//! GET /b/ → serve blob (images/videos) use std::net::SocketAddr; use std::sync::Arc; @@ -89,7 +90,7 @@ fn extract_header<'a>(buf: &'a [u8], name: &str) -> Option<&'a str> { None } -/// Handle GET /p// +/// Handle GET /p/ /// /// Three-tier serving: /// 1. Redirect to a CDN holder with a public/punchable HTTP server @@ -112,26 +113,11 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc, browse _ => return, }; - // Parse optional author_id (after the slash) - let author_id: Option = if rest.len() > 65 { - let author_hex = &rest[65..]; - if author_hex.len() == 64 && author_hex.chars().all(|c| c.is_ascii_hexdigit()) { - hex::decode(author_hex).ok().and_then(|b| b.try_into().ok()) - } else { - None - } - } else { - None - }; - // Single lock: gather holders, local post, AND author name if local let (holders, local_post, local_author_name) = { let store = node.storage.get().await; let mut holders = Vec::new(); - if let Some(author) = author_id { - holders.push(author); - } if let Ok(file_holders) = store.get_file_holders(&post_id) { for (peer, _addrs) in file_holders { if !holders.contains(&peer) { @@ -177,8 +163,9 @@ async fn serve_post(stream: &mut TcpStream, path: &str, node: &Arc, browse } } - // Fetch via content search + PostFetch - let author = author_id.unwrap_or([0u8; 32]); + // Fetch via content search + PostFetch (no author hint — holders resolved + // from file_holders / worm search) + let author: NodeId = [0u8; 32]; info!("Web: proxying post {} via QUIC (no redirect candidate found)", post_hex); let search_result = tokio::time::timeout( diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 87bbc13..85991a9 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -2493,7 +2493,6 @@ async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Resul #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct NetworkSummaryDto { - preferred_count: usize, local_count: usize, wide_count: usize, total_connections: usize, @@ -2508,12 +2507,10 @@ struct NetworkSummaryDto { async fn get_network_summary(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let conns = node.list_connections().await; - let mut preferred = 0usize; let mut local = 0usize; let mut wide = 0usize; for (_nid, slot_kind, _at) in &conns { match slot_kind { - PeerSlotKind::Preferred => preferred += 1, PeerSlotKind::Local => local += 1, PeerSlotKind::Wide => wide += 1, } @@ -2525,7 +2522,6 @@ async fn get_network_summary(state: State<'_, AppNode>) -> Result) -> Result<(), String> { node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed); // Start the sync tasks that were skipped during bootstrap node.start_accept_loop(); - node.start_pull_cycle(300); + node.start_pull_cycle(); node.start_diff_cycle(120); node.start_rebalance_cycle(600); node.start_growth_loop(); node.start_recovery_loop(); node.start_social_checkin_cycle(3600); node.start_anchor_register_cycle(600); - node.start_upnp_renewal_cycle(); node.start_upnp_tcp_renewal_cycle(); node.start_http_server(); node.start_bootstrap_connectivity_check(); @@ -3575,14 +3569,13 @@ pub fn run() { // Start all background networking tasks boot_node.start_accept_loop(); - boot_node.start_pull_cycle(300); + boot_node.start_pull_cycle(); boot_node.start_diff_cycle(120); boot_node.start_rebalance_cycle(600); boot_node.start_growth_loop(); boot_node.start_recovery_loop(); boot_node.start_social_checkin_cycle(3600); boot_node.start_anchor_register_cycle(600); - boot_node.start_upnp_renewal_cycle(); boot_node.start_upnp_tcp_renewal_cycle(); boot_node.start_http_server(); boot_node.start_bootstrap_connectivity_check(); diff --git a/website/design.html b/website/design.html index d1ee279..a21631c 100644 --- a/website/design.html +++ b/website/design.html @@ -121,7 +121,7 @@

Mesh target Rework

The mesh target is ~20 peers (plus +4–10 temporary referral slots that carry no knowledge exchange — see connections). Knowledge reach comes from depth (uniques known to 4 bounces, N1–N4), not from connection count.

- v0.8 change: Replaces the 101-peer mesh (10 preferred / 71 local / 20 wide on desktop). That width was designed for the pre-CDN push-event world; per-client overhead was too high, and the CDN now carries depth. Current code still runs the 101-slot model. + v0.8 change: Replaces the 101-peer mesh (10 preferred / 71 local / 20 wide on desktop). That width was designed for the pre-CDN push-event world; per-client overhead was too high, and the CDN now carries depth. The preferred tier was deleted in Iteration B; current code runs 71 local + 20 wide (91 slots desktop, 12 mobile). Narrowing to the single ~20-slot pool remains target (Roadmap item 4).

Startup cycles

@@ -130,7 +130,7 @@ CycleIntervalPurposeStatus Update cadence schedulerDescending scale (minutes → days) per author, keyed on freshness × relationship tier, jitteredCDN update checks + uniques-list refresh; timing shaped to look like uniform network overhead. See update cadence.Rework — today: 60s pull tick with a 4h stale-author threshold Routing diff120s (2 min)Announce network-knowledge changes to mesh peers (target: uniques-list diffs; today: N1/N2 share-list diffs)Rework - Rebalance600s (10 min)Clean dead connections, signal growthRework — today's cycle still runs the preferred-peer Priority 0 + Rebalance600s (10 min)Clean dead connections, signal growthRework — preferred-peer Priority 0 deleted (Iteration B); remaining priorities still target the wide model Growth loopReactive (signal-driven on knowledge receipt)Fill mesh slots toward 20 with the most diverse candidates; secondary peer-finding path alongside convectionRework — mechanism is live; targets 101 today and only counts local slots Convection triggerStochastic, per-disconnectOn each mesh disconnect, random choice: do nothing / ask a random known anchor for an introduction / ask a mesh peer. See anchors.Planned Recovery loopReactive (mesh drops below 2)Emergency reconnect: mine retained uniques pools for anchor addresses, then known_anchors cache, then any connected anchor peersRework — today's loop still sends AnchorRegister @@ -477,7 +477,7 @@ - +
CodeNameStreamPurpose
0x70SocialAddressUpdateUniSent when a social contact's address changes or they reconnect
0x71SocialDisconnectNoticeUniSent when a social contact disconnects
0x71SocialDisconnectNoticeUniRemoved in v0.8 — zero senders existed; social-route staleness is inferred from checkin timeouts
0x72SocialCheckinBiKeepalive with address + known-peer referral updates
@@ -508,7 +508,7 @@
  • Deep uniques reporters: With N1–N4 knowledge (see Network Knowledge), the uniques index names which mesh peer "can help reach" the target even when the target sits three or four bounces out. The introduction chains along the announce path, decrementing TTL at each hop (max TTL=2).
  • - v0.8 change: the two preferred-tree candidate tiers (intersecting the target's ~100-node preferred_tree with our connections) are removed along with preferred peers. Current code still runs them first (connection.rs find_relays_for); the target keeps only knowledge-layer reporters. + v0.8 change: the two preferred-tree candidate tiers (intersecting the target's ~100-node preferred_tree with our connections) are removed along with preferred peers. Done (Iteration B): the tiers were deleted from find_relays_for (connection.rs); code now keeps only knowledge-layer reporters.

    RelayIntroduce flow (0xB0/0xB1)

    @@ -1002,12 +1002,12 @@

    Wire format Implemented

    [1 byte: MessageType] [4 bytes: length (big-endian)] [length bytes: JSON payload]
    -

    Max payload: 64 MB. ALPN: itsgoin/3. Every message travels on its own QUIC stream — uni-directional for fire-and-forget pushes, bi-directional for request/response pairs. Unknown message-type bytes fail the stream; unknown fields inside known payloads are ignored by serde.

    +

    Max payload: 64 MB. ALPN: itsgoin/4. Every message travels on its own QUIC stream — uni-directional for fire-and-forget pushes, bi-directional for request/response pairs. Unknown message-type bytes fail the stream; unknown fields inside known payloads are ignored by serde.

    - Decided: the v0.8 break bumps the ALPN to itsgoin/4, so pre-v0.8 nodes are refused cleanly at the QUIC handshake rather than failing mid-conversation. (The constant also gets renamed — it is currently named ALPN_V2 while holding itsgoin/3.) + Done (Iteration B): the v0.8 break bumped the ALPN to itsgoin/4, so pre-v0.8 nodes are refused cleanly at the QUIC handshake rather than failing mid-conversation. The constant is now named plain ALPN (the versioned name ALPN_V2 had rotted against its value).
    -

    Message types (46 total) Implemented

    +

    Message types (40 total; rows marked “Removed in v0.8” are kept for history) Implemented

    @@ -1023,7 +1023,7 @@ - + @@ -1037,7 +1037,7 @@ - + @@ -1689,7 +1689,7 @@
  • Self-cleaning: registration comments carry a fixed 30-day TTL (unlike ordinary comments' randomized TTL — that randomness serves anonymity, which a deliberately-public registration doesn't want). Staying listed means re-signing a fresh entry; holders keep one active entry per persona ID (newest wins). The chain self-cleans and is never more than 30 days stale.
  • Delete / modify: a delete request signed by the same persona key is honored by every holder (verifiable from the entry itself — the network agreement is checkable, not honor-system). Modify = delete + re-register; no separate primitive needed.
  • Moderation hook: the registry post's author retains the standard per-post RevocationEntry power — manual spam removal for the alpha era, at the acknowledged cost that the author could censor entries. Acceptable while the author is the project's own anchor; the trust layer (see Directory Trust Layer) is the eventual replacement.
  • -
  • Anti-bot pricing: registrations carry the slot-declared PoW stamp (a few CPU-seconds), fit a single small size bucket, and are rate-capped per holder. This prices flooding without preventing it — a bot willing to mint personas and burn CPU can still pollute the chain.
  • +
  • Flood limits: registrations fit a single small size bucket and are rate-capped per holder. Proof-of-work was considered and rejected: its cost lands inverted (a phone registrant pays seconds of battery; a botnet rig pays effectively nothing), so it taxes the users we want while barely inconveniencing the adversary we fear. The registry's real defenses are the bounded blast radius and vouch gating below.
  • Honest threat model: personas are deliberately near-free in this system (throwaway IDs are a feature), so no per-key or proof-of-work measure changes the spam economics against a motivated adversary — and this network must expect motivated adversaries with an interest in making decentralized social feel broken. The alpha registry survives casual spam only. Two things make that acceptable: (1) bounded blast radius — searches are local and the registry is an opt-in convenience layer, so a fully-flooded registry blocks only new stranger discovery while every existing relationship, follow, vouch, and sync continues untouched; griefing spends real resources to inconvenience only people who haven't met anyone yet. (2) Vouch-gated registration (see Directory Trust Layer) is the durable fix once utilization supports vouch introductions — the registry post's format survives that transition, only the acceptance rule tightens. Sharding is deliberately deferred to the same moment: by the time volume demands shards, registration demands vouches. @@ -1707,10 +1707,10 @@

    Spam controls

    HexNameStreamPurpose
    0x01NodeListUpdateUniIncremental knowledge-layer diff broadcast
    0x60WormQueryBiPoint query for nodes, posts, or blobs beyond local knowledge (worm search)
    0x61WormResponseBiWorm search reply (node + post_holder + blob_holder)
    0x70SocialAddressUpdateUniSocial contact address changed
    0x71SocialDisconnectNoticeUniSocial contact disconnected
    0x71SocialDisconnectNoticeUniSocial contact disconnected. Removed in v0.8 — zero senders existed; checkin timeouts carry the signal.
    0x72SocialCheckinBiKeepalive + address + peer-address update
    0x90BlobRequestBiFetch blob by CID
    0x91BlobResponseBiBlob data + CDN manifest + file header
    0xB2SessionRelayBiSplice bi-streams (opt-in; relay.session_relay_enabled OFF by default on every role, including anchors — see Relay & NAT Traversal)
    0xB3MeshPreferBiPreferred-peer negotiation. Removed in v0.8 — the preferred tier is eliminated.
    0xB4CircleProfileUpdateUniEncrypted circle profile variant
    0xC0AnchorRegisterUniRegister with anchor. Removed in v0.8 — connection convection replaces the register loop (Anchors).
    0xC0AnchorRegisterUniRegister with anchor. Still live; retires when anchor convection replaces the register loop (Roadmap item 5, Anchors).
    0xC1AnchorReferralRequestBiRequest peer referrals (v0.8: the convection call)
    0xC2AnchorReferralResponseBiReferral list reply
    0xC3AnchorProbeRequestBiA → B → C: test cold reachability of address
    - + - +
    ControlMechanism
    Opt-inNo greeting slot in the bio → no greetings possible. Default is off.
    Active choiceNo greeting slot in the bio → no greetings possible. The choice is made visibly at first profile publish — pre-checked YES with opt-out before publishing (alpha default; revisit for public release). Revocable per persona at any time.
    Size bucketGreetings fit a single small padded bucket — no attachments, no long-form spam payloads.
    Holder-side count capsNodes holding a bio cap how many unexpired greetings they store and forward per bio.
    PoW stamp (optional)The greeting slot may declare a proof-of-work difficulty a valid greeting must carry — the BitMessage lesson: make bulk greeting-spam computationally expensive.
    Rate capsHolder-side per-bio limits on unexpired greetings stored and forwarded. (Proof-of-work stamps were considered and rejected — inverted cost: phones pay, botnets don't.)
    Known limit: the greeting slot's pub_x_index is observable, so greetings are identifiable as a class (never by sender). Mitigation: friends can route innocuous chatter through the open greeting key as padding, so greeting traffic on a bio is not automatically stranger traffic. @@ -1718,7 +1718,10 @@

    The composed flow

    -

    Find a persona via registry search (or encounter their author ID through the graph) → greet with a sealed greeting comment on their bio post, real identity and return path inside → the author unseals it, decides, and vouches back via the normal bio-post HPKE wrapper batch → the relationship moves onto standard FoF rails (readable posts, gated comments, DMs). The throwaway greeting ID expires with its comment; nothing permanent links the approach to the relationship that followed.

    +

    Find a persona via registry search (or encounter their author ID through the graph) → greet with a sealed greeting comment on their bio post — real identity and a return path ("where I'll watch for a response") inside → the author unseals it and replies with a sealed message to that return path, from their own fresh temp ID. The conversation continues over rotating temporary IDs — each message names the next rendezvous, no two messages share an outer identity, and observers see only never-before-seen IDs dropping ciphertext on unrelated open-slot posts. No vouch is ever required to talk. Vouching is fully disconnected from messaging: it is a relationship act that lives in the People tab — granting FoF readership via the normal bio-post wrapper batch — and deliberately has no control on any messaging surface, so vouching someone is always a considered decision, never a reflex mid-conversation. Inbox actions: Reply (primary), Dismiss. People you message surface in People for relationship management. Every temp ID expires with its comment's TTL; nothing permanent links the approach, the conversation, or the relationship that followed.

    +
    +
    + v1 scope: return paths start as the participants' bio posts; full rendezvous-hopping across arbitrary open-slot posts follows once an open-slot post index exists. The anonymity set for hopping is exactly the set of posts accepting open-slot comments — made large by the default-checked greeting choice at first publish.
    @@ -1983,8 +1986,8 @@ AreaStatus Mesh & knowledge - Single ~20-slot mesh pool (tiers eliminated)Rework — code runs Preferred/Local/Wide, 101 desktop / 15 mobile - Preferred-peer elimination (slots, MeshPrefer, prunes, watchers)Rework — deletion pending (Roadmap item 2) + Single ~20-slot mesh pool (tiers eliminated)Rework — code runs Local/Wide, 91 desktop / 12 mobile (preferred tier deleted, Iteration B) + Preferred-peer elimination (slots, MeshPrefer, prunes, watchers)Implemented — Done (Iteration B, Roadmap item 3) Temp referral slots (+4..+10, no knowledge exchange)Planned Uniques announce, payload N1–N3 (N4 stored but terminates, dedup at store + announce)Rework — code exchanges N1–N3 share lists Pull as uniques-index exchange (distributed search index)Rework — code pulls posts via since_ms @@ -2024,7 +2027,7 @@ FoF visibility Layers 1–5 (vouch, gated comments, FoFClosed, rotation/burn, unlock cache)Implemented Sync & content - Wire protocol (length-prefixed JSON, 64 MB payload)Rework — 46 types today; six (0x51/0x52/0xA1/0xA2/0xB3/0xC0) deleted in the clean-break purge (Roadmap item 2) + Wire protocol (length-prefixed JSON, 64 MB payload)Rework — 40 types today; six (0x51/0x52/0x71/0xA1/0xA2/0xB3) deleted in the clean-break purge (Roadmap item 3); 0xC0 AnchorRegister stays live until anchor convection (Roadmap item 5) Merged pull with recipient matching (all posting identities)Implemented — payload semantics change with the uniques redefinition Manifest machinery (push, refresh, header diffs)Implemented AuthorManifest privacy (no device addresses, 2–5 neighborhood)Rework — code ships addresses + 10+10 neighborhood @@ -2065,11 +2068,11 @@

    2. Registry + greeting comments — tester-critical

    -

    The app is eminently useful only if people can find each other — this is what makes it usable for testers, so it jumps the queue. Ship the “registrations here” post (open-slot signed registration comments, fixed 30-day TTL, one entry per persona, self-certifying signed deletes, PoW stamp) and greeting comments (HPKE-sealed first contact via the published greeting-slot key) — one open-slot comment implementation serves both. Prerequisite folded in: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.

    +

    The app is eminently useful only if people can find each other — this is what makes it usable for testers, so it jumps the queue. Ship the “registrations here” post (open-slot signed registration comments, fixed 30-day TTL, one entry per persona, self-certifying signed deletes, holder-side rate/size caps — no PoW) and greeting comments (HPKE-sealed first contact via the published greeting-slot key, messaging-first with Reply/Vouch/Dismiss) — one open-slot comment implementation serves both. Prerequisite folded in: remove device addresses from AuthorManifest first (registered author IDs must be location-anonymous). Known-bounded: survives until deliberate bot-flooding; the trust layer (item 10) is the durable fix.

    3. Cruft purge

    -

    The zero-users ruling removes all wire-compat obligations. Delete: v0.6.x receive-only compat handlers (0x51/0x52) and the dual pull-matching path; the preferred-peer subsystem (slots, MeshPrefer negotiation, 7-day prune, 30-day watcher plumbing); dead senders and constants (hostlist encoder, no-op renewal cycle, orphaned GroupKeyRequest/Response); and legacy tree fields in wire structs. Clean protocol break — bump ALPN to itsgoin/4 here.

    +

    Done (Iteration B) — landed with the itsgoin/4 ALPN bump; 0xC0 AnchorRegister deliberately kept until anchor convection (item 5). The zero-users ruling removes all wire-compat obligations. Delete: v0.6.x receive-only compat handlers (0x51/0x52), the sender-less 0x71 SocialDisconnectNotice, and the dual pull-matching path; the preferred-peer subsystem (slots, MeshPrefer negotiation, 7-day prune, 30-day watcher plumbing); dead senders and constants (hostlist encoder, no-op renewal cycle, orphaned GroupKeyRequest/Response); and legacy tree fields in wire structs. Clean protocol break — bump ALPN to itsgoin/4 here.

    4. N1–N4 uniques knowledge layer

    @@ -2141,12 +2144,12 @@ seen_engagement/seen_messages, group_keys/group_member_keys/ group_seeds, known_anchors, ignored_peers, relay_cooldowns, worm_cooldowns, settings, post_recipients, posting_identities, - preferred_peers (retires in v0.8), + 10 vouch/FoF tables + preferred_peers (dropped in v0.8 Iteration B), + 10 vouch/FoF tables (vouch_keys_own/received, vouch_bio_scan_cache, own_vouch_targets, fof_revocations, own_post_slot_provenance, vouch_unlock_cache, vouch_unreadable_posts, own_fof_post_ceks, fof_key_burns). post/blob_upstream+downstream tables dropped in v0.6.1-beta. - protocol.rs — MessageType enum (46 types), ALPN (itsgoin/3; bumps to itsgoin/4 in v0.8), + protocol.rs — MessageType enum, ALPN (itsgoin/4), length-prefixed JSON framing, InitialExchangePayload connection.rs — ConnectionManager + ConnHandle/ConnectionActor (actor pattern): mesh + session slots, initial exchange, N1/N2 diff broadcast, diff --git a/website/tech.html b/website/tech.html index 6ddade0..4ad4925 100644 --- a/website/tech.html +++ b/website/tech.html @@ -124,8 +124,8 @@ wrapped_key[i] = X25519_DH(author_ed25519, recipient_ed25519[i]) XOR CEK<
    -

    Sync protocol (v3)

    -

    The protocol uses a single ALPN (itsgoin/3) with 37+ message types multiplexed over QUIC bi-streams and uni-streams.

    +

    Sync protocol (v4)

    +

    The protocol uses a single ALPN (itsgoin/4) with 40 message types multiplexed over QUIC bi-streams and uni-streams.

    Pull-based sync