diff --git a/Cargo.lock b/Cargo.lock index 8fdd327..a895881 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "itsgoin-cli" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "hex", @@ -2744,7 +2744,7 @@ dependencies = [ [[package]] name = "itsgoin-core" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "base64 0.22.1", @@ -2767,7 +2767,7 @@ dependencies = [ [[package]] name = "itsgoin-desktop" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4e58bf6..478c1d2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-cli" -version = "0.6.1" +version = "0.6.2" edition = "2021" [[bin]] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d52655e..d74eceb 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -138,11 +138,6 @@ async fn main() -> anyhow::Result<()> { println!(" revoke [mode] Revoke access (mode: sync|reencrypt)"); println!(" revoke-circle [m] Revoke circle access for a node"); println!(" redundancy Show replica counts for your posts"); - println!(" audience List audience members"); - println!(" audience-request Request to join peer's audience"); - println!(" audience-pending Show pending audience requests"); - println!(" audience-approve Approve audience request"); - println!(" audience-remove Remove from audience"); println!(" worm Worm lookup (find peer beyond 3-hop map)"); println!(" connections Show mesh connections"); println!(" social-routes Show social routing cache"); @@ -713,91 +708,6 @@ async fn main() -> anyhow::Result<()> { } } - "audience" => { - match node.list_audience_members().await { - Ok(members) => { - if members.is_empty() { - println!("(no audience members)"); - } else { - println!("Audience members ({}):", members.len()); - for nid in members { - let name = node.get_display_name(&nid).await.unwrap_or(None); - let label = name.unwrap_or_else(|| hex::encode(&nid)[..12].to_string()); - println!(" {}", label); - } - } - } - Err(e) => println!("Error: {}", e), - } - } - - "audience-request" => { - if let Some(id_hex) = arg { - match itsgoin_core::parse_node_id_hex(id_hex) { - Ok(nid) => { - match node.request_audience(&nid).await { - Ok(()) => println!("Audience request sent"), - Err(e) => println!("Error: {}", e), - } - } - Err(e) => println!("Invalid node ID: {}", e), - } - } else { - println!("Usage: audience-request "); - } - } - - "audience-pending" => { - use itsgoin_core::types::{AudienceDirection, AudienceStatus}; - match node.list_audience(AudienceDirection::Inbound, Some(AudienceStatus::Pending)).await { - Ok(records) => { - if records.is_empty() { - println!("(no pending audience requests)"); - } else { - println!("Pending audience requests ({}):", records.len()); - for rec in records { - let name = node.get_display_name(&rec.node_id).await.unwrap_or(None); - let label = name.unwrap_or_else(|| hex::encode(&rec.node_id)[..12].to_string()); - println!(" {}", label); - } - } - } - Err(e) => println!("Error: {}", e), - } - } - - "audience-approve" => { - if let Some(id_hex) = arg { - match itsgoin_core::parse_node_id_hex(id_hex) { - Ok(nid) => { - match node.approve_audience(&nid).await { - Ok(()) => println!("Approved audience member"), - Err(e) => println!("Error: {}", e), - } - } - Err(e) => println!("Invalid node ID: {}", e), - } - } else { - println!("Usage: audience-approve "); - } - } - - "audience-remove" => { - if let Some(id_hex) = arg { - match itsgoin_core::parse_node_id_hex(id_hex) { - Ok(nid) => { - match node.remove_audience(&nid).await { - Ok(()) => println!("Removed from audience"), - Err(e) => println!("Error: {}", e), - } - } - Err(e) => println!("Invalid node ID: {}", e), - } - } else { - println!("Usage: audience-remove "); - } - } - "worm" => { if let Some(id_hex) = arg { match itsgoin_core::parse_node_id_hex(id_hex) { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 0b7c6a0..a894f92 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-core" -version = "0.6.1" +version = "0.6.2" edition = "2021" [dependencies] diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 1724055..1d4917b 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -13,12 +13,11 @@ use crate::crypto; use crate::protocol::{ read_message_type, read_payload, write_typed_message, AnchorReferral, AnchorReferralRequestPayload, AnchorReferralResponsePayload, AnchorRegisterPayload, - AudienceRequestPayload, AudienceResponsePayload, BlobHeaderDiffPayload, + BlobHeaderDiffPayload, BlobHeaderRequestPayload, BlobHeaderResponsePayload, BlobRequestPayload, BlobResponsePayload, - CircleProfileUpdatePayload, GroupKeyDistributePayload, GroupKeyRequestPayload, + CircleProfileUpdatePayload, GroupKeyRequestPayload, GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, - PostNotificationPayload, PostPushPayload, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload, @@ -156,6 +155,20 @@ const SCAN_PUNCH_INTERVAL_SECS: u64 = 2; /// Maximum scan duration (seconds) — accept the cost for otherwise-impossible connections const SCAN_MAX_DURATION_SECS: u64 = 300; // 5 minutes +/// Global cap on concurrent port-scan hole punches. Each scanner fires +/// ~100 QUIC ClientHellos/sec for up to `SCAN_MAX_DURATION_SECS`, which +/// is ~1 Mbps per active scanner. Without a cap, multiple parallel +/// referrals (growth loop, anchor referrals, replication) can spawn +/// several scanners at once and drive sustained multi-Mbps upload — +/// especially pathological on obfuscated VPNs where every probe stalls +/// at proxy timeouts. A permit is acquired before the scanning loop +/// starts and held until the scanner returns; extra callers fall back +/// to the cheaper `hole_punch_parallel`. +fn scanner_semaphore() -> &'static tokio::sync::Semaphore { + static SEM: std::sync::OnceLock = std::sync::OnceLock::new(); + SEM.get_or_init(|| tokio::sync::Semaphore::new(1)) +} + /// Advanced hole punch with port scanning fallback for EDM/port-restricted NAT. /// /// **Role-based behavior** (each side calls this independently): @@ -189,6 +202,21 @@ pub(crate) async fn hole_punch_with_scanning( return hole_punch_parallel(endpoint, target, addresses).await; } + // v0.6.2: cap to one concurrent port scanner per node. Additional + // callers fall back to the cheaper `hole_punch_parallel` instead of + // spawning another 100-probes-per-second scanner. The permit is held + // for the lifetime of the scanner loop below (dropped on return). + let _scan_permit = match scanner_semaphore().try_acquire() { + Ok(p) => p, + Err(_) => { + tracing::debug!( + peer = hex::encode(target), + "another port scan already in progress — falling back to parallel punch" + ); + return hole_punch_parallel(endpoint, target, addresses).await; + } + }; + // Filter to reachable families, then use observed address (first in list, injected by relay) let reachable = filter_reachable_families(endpoint, addresses); let observed_addr = reachable.first() @@ -638,6 +666,31 @@ pub struct ConnectionManager { /// Sticky N1 entries: NodeIds to report in N1 share until expiry (ms). /// Used to advertise the bootstrap anchor for 24h after isolation recovery. sticky_n1: HashMap, + /// NodeIds with an outgoing connect attempt currently in flight. + /// Used by `try_begin_connect` to suppress duplicate concurrent outgoing + /// connects from racing paths (auto-reconnect, rebalance, relay + /// introduction target-side) against the same peer. Held in a sync + /// Mutex because every operation is a single O(1) hash insert/remove — + /// never held across an await. + pending_connects: Arc>>, +} + +/// RAII guard for a pending-outgoing-connect entry. Returned by +/// `ConnectionManager::try_begin_connect`. The NodeId is inserted into +/// `pending_connects` at construction and removed on drop, so a second +/// call to `try_begin_connect` for the same peer returns `None` for as +/// long as this guard is alive. +pub struct PendingConnectGuard { + peer_id: NodeId, + set: Arc>>, +} + +impl Drop for PendingConnectGuard { + fn drop(&mut self) { + if let Ok(mut s) = self.set.lock() { + s.remove(&self.peer_id); + } + } } impl ConnectionManager { @@ -700,9 +753,36 @@ impl ConnectionManager { http_capable: false, http_addr: None, sticky_n1: HashMap::new(), + pending_connects: Arc::new(std::sync::Mutex::new(HashSet::new())), } } + /// Reserve an outgoing-connect slot for `peer`. Returns `Some(guard)` + /// if no other outgoing connect to this peer is already in flight and + /// we aren't already connected. The guard is held by the caller for + /// the duration of the connect attempt — subsequent calls for the + /// same peer return `None` until the guard drops. + /// + /// Only gates outgoing duplicates; has no effect on incoming + /// connections from the peer, which are accepted normally. + pub fn try_begin_connect(&self, peer: NodeId) -> Option { + if self.connections.contains_key(&peer) || self.sessions.contains_key(&peer) { + return None; + } + let mut set = match self.pending_connects.lock() { + Ok(g) => g, + Err(_) => return None, // Poisoned — fail closed rather than risk a racing connect. + }; + if set.contains(&peer) { + return None; + } + set.insert(peer); + Some(PendingConnectGuard { + peer_id: peer, + set: self.pending_connects.clone(), + }) + } + /// Our detected NAT type pub fn nat_type(&self) -> crate::types::NatType { self.nat_type @@ -1346,18 +1426,25 @@ impl ConnectionManager { conn: iroh::endpoint::Connection, storage: &Arc, peer_id: &NodeId, - our_node_id: NodeId, + _our_node_id: NodeId, ) -> anyhow::Result { - let (our_follows, follows_sync) = { + let (our_follows, follows_sync, our_personas) = { let s = storage.get().await; - (s.list_follows()?, s.get_follows_with_last_sync().unwrap_or_default()) + ( + s.list_follows()?, + s.get_follows_with_last_sync().unwrap_or_default(), + s.list_posting_identities().unwrap_or_default(), + ) }; - // Merged pull: include our own NodeId in the query so the peer returns - // posts where we're either a followed author OR a recipient (DM). + // Merged pull: include every posting identity we hold so DMs to any + // of our personas match via wrapped_key.recipient. Network NodeId is + // never an author or recipient and would never match. let mut query_list = our_follows; - if !query_list.contains(&our_node_id) { - query_list.push(our_node_id); + for pi in &our_personas { + if !query_list.contains(&pi.node_id) { + query_list.push(pi.node_id); + } } let request = PullSyncRequestPayload { @@ -1388,11 +1475,19 @@ impl ConnectionManager { for sp in &response.posts { if s.is_deleted(&sp.id)? { continue; } if verify_post_id(&sp.id, &sp.post) { - if s.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { - new_post_ids.push(sp.id); - posts_received += 1; + match crate::control::receive_post(&s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(true) => { + new_post_ids.push(sp.id); + posts_received += 1; + synced_authors.insert(sp.post.author); + } + Ok(false) => { + synced_authors.insert(sp.post.author); + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } } - synced_authors.insert(sp.post.author); } } } @@ -1879,129 +1974,6 @@ impl ConnectionManager { sent } - /// Handle an incoming post notification: if we follow the author, pull the post. - /// `conn` is a fallback connection for ephemeral callers (not persistently connected). - pub async fn handle_post_notification( - &self, - from: &NodeId, - notification: PostNotificationPayload, - conn: Option<&iroh::endpoint::Connection>, - ) -> anyhow::Result { - let dominated = { - let storage = self.storage.get().await; - // Already have this post? - if storage.get_post(¬ification.post_id)?.is_some() { - return Ok(false); - } - // Do we follow the author? - let follows = storage.list_follows()?; - follows.contains(¬ification.author) - }; - - if !dominated { - return Ok(false); - } - - // We follow the author and don't have the post — pull it from the notifier - let pull_conn = match self.connections.get(from) { - Some(pc) => pc.connection.clone(), - None => match conn { - Some(c) => c.clone(), - None => return Ok(false), - }, - }; - - let (our_follows, follows_sync) = { - let storage = self.storage.get().await; - ( - storage.list_follows()?, - storage.get_follows_with_last_sync().unwrap_or_default(), - ) - }; - - // Merged pull: include our own NodeId in the query list. - let mut query_list = our_follows; - if !query_list.contains(&self.our_node_id) { - query_list.push(self.our_node_id); - } - - let (mut send, mut recv) = pull_conn.open_bi().await?; - let request = PullSyncRequestPayload { - follows: query_list, - have_post_ids: vec![], // v4: empty, using since_ms instead - since_ms: follows_sync, - }; - write_typed_message(&mut send, MessageType::PullSyncRequest, &request).await?; - send.finish()?; - - let _resp_type = read_message_type(&mut recv).await?; - let response: PullSyncResponsePayload = - read_payload(&mut recv, MAX_PAYLOAD).await?; - - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - let mut stored = false; - let mut new_post_ids: Vec = Vec::new(); - let mut synced_authors: HashSet = HashSet::new(); - - // Brief lock 1: store posts - { - let storage = self.storage.get().await; - for sp in &response.posts { - if verify_post_id(&sp.id, &sp.post) && !storage.is_deleted(&sp.id)? { - let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility); - new_post_ids.push(sp.id); - synced_authors.insert(sp.post.author); - if sp.id == notification.post_id { - stored = true; - } - } - } - } - // Lock RELEASED - - // Brief lock 2: upstream + last_sync + visibility updates - { - let storage = self.storage.get().await; - for pid in &new_post_ids { - let _ = storage.touch_file_holder( - pid, - from, - &[], - crate::storage::HolderDirection::Received, - ); - } - for author in &synced_authors { - let _ = storage.update_follow_last_sync(author, now_ms); - } - for vu in &response.visibility_updates { - if let Some(post) = storage.get_post(&vu.post_id)? { - if post.author == vu.author { - let _ = storage.update_post_visibility(&vu.post_id, &vu.visibility); - } - } - } - } - - // Register as downstream for new posts (cap at 50 to avoid flooding) - if !new_post_ids.is_empty() { - let reg_conn = pull_conn.clone(); - tokio::spawn(async move { - for post_id in new_post_ids.into_iter().take(50) { - let payload = PostDownstreamRegisterPayload { post_id }; - if let Ok(mut send) = reg_conn.open_uni().await { - let _ = write_typed_message(&mut send, MessageType::PostDownstreamRegister, &payload).await; - let _ = send.finish(); - } - } - }); - } - - Ok(stored) - } - /// Pull posts from a connected peer. pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { let pc = self @@ -2009,18 +1981,21 @@ impl ConnectionManager { .get(peer_id) .ok_or_else(|| anyhow::anyhow!("not connected to {}", hex::encode(peer_id)))?; - let (our_follows, follows_sync) = { + let (our_follows, follows_sync, our_personas) = { let storage = self.storage.get().await; ( storage.list_follows()?, storage.get_follows_with_last_sync().unwrap_or_default(), + storage.list_posting_identities().unwrap_or_default(), ) }; - // Merged pull: include our own NodeId in the query list. + // Merged pull: include every posting identity so DMs match recipient. let mut query_list = our_follows; - if !query_list.contains(&self.our_node_id) { - query_list.push(self.our_node_id); + for pi in &our_personas { + if !query_list.contains(&pi.node_id) { + query_list.push(pi.node_id); + } } let request = PullSyncRequestPayload { @@ -2056,11 +2031,19 @@ impl ConnectionManager { continue; } if verify_post_id(&sp.id, &sp.post) { - if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? { - new_post_ids.push(sp.id); - posts_received += 1; + match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(true) => { + new_post_ids.push(sp.id); + posts_received += 1; + synced_authors.insert(sp.post.author); + } + Ok(false) => { + synced_authors.insert(sp.post.author); + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } } - synced_authors.insert(sp.post.author); } } } @@ -2281,12 +2264,15 @@ impl ConnectionManager { } } - // Phase 3: Brief re-lock for is_deleted checks on filtered posts + // Phase 3: Brief re-lock for is_deleted checks + intent fetch on filtered posts let (posts, vis_updates) = { let s = storage.get().await; let posts_to_send: Vec = candidates_to_send.into_iter() .filter(|(id, _, _)| !s.is_deleted(id).unwrap_or(false)) - .map(|(id, post, visibility)| SyncPost { id, post, visibility }) + .map(|(id, post, visibility)| { + let intent = s.get_post_intent(&id).ok().flatten(); + SyncPost { id, post, visibility, intent } + }) .collect(); (posts_to_send, vis_updates_to_send) }; @@ -3957,6 +3943,16 @@ impl ConnectionManager { s.get_peer_nat_profile(&requester) }; tokio::spawn(async move { + // Reserve the outgoing-connect slot for this requester so + // rebalance / auto-reconnect can't fire a parallel connect + // to the same peer while our hole-punch is in flight. + let _connect_guard = { + let cm = conn_mgr_arc.lock().await; + match cm.try_begin_connect(requester) { + Some(g) => g, + None => return, // Already connected or connect in flight. + } + }; if let Some(conn) = hole_punch_with_scanning(&endpoint, &requester, &requester_addrs, our_nat_profile, peer_nat_profile).await { // Register as session with the peer's address for relay introduction let remote_sock = requester_addrs.iter() @@ -4506,13 +4502,16 @@ impl ConnectionManager { tokio::spawn(async move { // Brief delay to let the disconnect settle and avoid reconnect storms tokio::time::sleep(std::time::Duration::from_secs(3)).await; - // Check if already reconnected (by the other side or growth loop) - { + // Reserve the outgoing-connect slot for this peer. If + // another path (rebalance, relay-introduction) is + // already connecting to them, skip. + let _connect_guard = { let cm = cm_arc.lock().await; - if cm.connections.contains_key(&remote_node_id) || cm.sessions.contains_key(&remote_node_id) { - return; // Already reconnected + match cm.try_begin_connect(remote_node_id) { + Some(g) => g, + None => return, // Already connected or connect in flight. } - } + }; if let Ok(eid) = iroh::EndpointId::from_bytes(&remote_node_id) { let ep_addr = iroh::EndpointAddr::from(eid).with_ip_addr(addr); let endpoint = { @@ -4930,24 +4929,11 @@ impl ConnectionManager { } } - // Gather connections for CDN delete notices under lock, then send outside - let mut delete_notices: Vec<(iroh::endpoint::Connection, crate::protocol::BlobDeleteNoticePayload)> = Vec::new(); - for (cid, holders) in &blob_cleanup { - let payload = crate::protocol::BlobDeleteNoticePayload { cid: *cid, upstream_node: None }; - for (peer, _addrs) in holders { - if let Some(pc) = cm.connections_ref().get(peer) { - delete_notices.push((pc.connection.clone(), payload.clone())); - } - } - } drop(cm); - // Send outside lock - for (conn, payload) in &delete_notices { - if let Ok(mut send) = conn.open_uni().await { - let _ = write_typed_message(&mut send, MessageType::BlobDeleteNotice, payload).await; - let _ = send.finish(); - } - } + // 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 = @@ -4962,102 +4948,6 @@ impl ConnectionManager { } } } - MessageType::PostNotification => { - let notification: PostNotificationPayload = - read_payload(recv, MAX_PAYLOAD).await?; - info!( - peer = hex::encode(remote_node_id), - post_id = hex::encode(notification.post_id), - author = hex::encode(notification.author), - "Received post notification" - ); - let cm = conn_mgr.lock().await; - match cm.handle_post_notification(&remote_node_id, notification, None).await { - Ok(true) => { - info!(peer = hex::encode(remote_node_id), "Pulled post from notification"); - } - Ok(false) => { - info!(peer = hex::encode(remote_node_id), "Post notification ignored (not following or already have)"); - } - Err(e) => { - warn!(peer = hex::encode(remote_node_id), error = %e, "Post notification pull failed"); - } - } - } - MessageType::PostPush => { - let push: PostPushPayload = read_payload(recv, MAX_PAYLOAD).await?; - // Encrypted posts are no longer accepted via direct push — they propagate - // via the CDN to eliminate the sender→recipient traffic signal. - if !matches!(push.post.visibility, crate::types::PostVisibility::Public) { - debug!( - peer = hex::encode(remote_node_id), - post_id = hex::encode(push.post.id), - "Ignoring non-public PostPush" - ); - } else { - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - if !storage.is_deleted(&push.post.id)? - && storage.get_post(&push.post.id)?.is_none() - && crate::content::verify_post_id(&push.post.id, &push.post.post) - { - let _ = storage.store_post_with_visibility( - &push.post.id, - &push.post.post, - &push.post.visibility, - ); - let _ = storage.touch_file_holder( - &push.post.id, - &remote_node_id, - &[], - crate::storage::HolderDirection::Received, - ); - info!( - peer = hex::encode(remote_node_id), - post_id = hex::encode(push.post.id), - "Received direct post push" - ); - } - } - } - MessageType::AudienceRequest => { - let req: AudienceRequestPayload = read_payload(recv, MAX_PAYLOAD).await?; - info!( - peer = hex::encode(remote_node_id), - requester = hex::encode(req.requester), - "Received audience request" - ); - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - // Store as inbound pending request - let _ = storage.store_audience( - &req.requester, - crate::types::AudienceDirection::Inbound, - crate::types::AudienceStatus::Pending, - ); - } - MessageType::AudienceResponse => { - let resp: AudienceResponsePayload = read_payload(recv, MAX_PAYLOAD).await?; - let status = if resp.approved { "approved" } else { "denied" }; - info!( - peer = hex::encode(remote_node_id), - responder = hex::encode(resp.responder), - status, - "Received audience response" - ); - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - let new_status = if resp.approved { - crate::types::AudienceStatus::Approved - } else { - crate::types::AudienceStatus::Denied - }; - let _ = storage.store_audience( - &resp.responder, - crate::types::AudienceDirection::Outbound, - new_status, - ); - } MessageType::SocialAddressUpdate => { let payload: SocialAddressUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5224,7 +5114,14 @@ impl ConnectionManager { let stored = { let cm = cm_arc.lock().await; let storage = cm.storage.get().await; - if storage.store_post_with_visibility(&sync_post.id, &sync_post.post, &sync_post.visibility).unwrap_or(false) { + let newly_stored = crate::control::receive_post( + &storage, + &sync_post.id, + &sync_post.post, + &sync_post.visibility, + sync_post.intent.as_ref(), + ).unwrap_or(false); + if newly_stored { let _ = storage.touch_file_holder( &sync_post.id, &sender_id, @@ -5314,68 +5211,6 @@ impl ConnectionManager { "Received social disconnect notice" ); } - MessageType::BlobDeleteNotice => { - let payload: crate::protocol::BlobDeleteNoticePayload = - read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - let storage = cm.storage.get().await; - let cid = payload.cid; - - // Flat-holder model: drop the sender as a holder of this file. - // The author's DeleteRecord (separate signed message) is what - // triggers the actual blob removal for followers. - let _ = storage.remove_file_holder(&cid, &remote_node_id); - - info!( - peer = hex::encode(remote_node_id), - cid = hex::encode(cid), - "Received blob delete notice" - ); - } - MessageType::GroupKeyDistribute => { - let payload: GroupKeyDistributePayload = read_payload(recv, MAX_PAYLOAD).await?; - let cm = conn_mgr.lock().await; - - // Verify the sender is the admin - if payload.admin != remote_node_id { - warn!(peer = hex::encode(remote_node_id), "GroupKeyDistribute from non-admin, ignoring"); - } else { - let storage = cm.storage.get().await; - let record = crate::types::GroupKeyRecord { - group_id: payload.group_id, - circle_name: payload.circle_name.clone(), - epoch: payload.epoch, - group_public_key: payload.group_public_key, - admin: payload.admin, - created_at: now_ms(), - }; - let _ = storage.create_group_key(&record, None); - - // Find our wrapped key and unwrap the group seed - for mk in &payload.member_keys { - let _ = storage.store_group_member_key(&payload.group_id, mk); - if mk.member == cm.our_node_id { - match crypto::unwrap_group_key( - &cm.secret_seed, - &payload.admin, - &mk.wrapped_group_key, - ) { - Ok(seed) => { - let _ = storage.store_group_seed(&payload.group_id, payload.epoch, &seed); - info!( - circle = %payload.circle_name, - epoch = payload.epoch, - "Received and unwrapped group key" - ); - } - Err(e) => { - warn!(error = %e, "Failed to unwrap group key"); - } - } - } - } - } - } MessageType::CircleProfileUpdate => { let payload: CircleProfileUpdatePayload = read_payload(recv, MAX_PAYLOAD).await?; let cm = conn_mgr.lock().await; @@ -5662,11 +5497,13 @@ impl ConnectionManager { }; let result = { let store = storage.get().await; - store.get_post_with_visibility(&payload.post_id).ok().flatten() + let pv = store.get_post_with_visibility(&payload.post_id).ok().flatten(); + let intent = store.get_post_intent(&payload.post_id).ok().flatten(); + pv.map(|(p, v)| (p, v, intent)) }; - let resp = if let Some((post, visibility)) = result { + let resp = if let Some((post, visibility, intent)) = result { if matches!(visibility, PostVisibility::Public) { - crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: true, post: Some(SyncPost { id: payload.post_id, post, visibility }) } + crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: true, post: Some(SyncPost { id: payload.post_id, post, visibility, intent }) } } else { crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: false, post: None } } @@ -5954,6 +5791,17 @@ impl ConnectionManager { .cloned().collect(); let requester = payload.requester; tokio::spawn(async move { + // Reserve the outgoing-connect slot for this + // requester so we don't race with rebalance / + // auto-reconnect paths firing their own + // outgoing connect to the same peer. + let _connect_guard = { + let cm = cm_arc.lock().await; + match cm.try_begin_connect(requester) { + Some(g) => g, + None => return, // Already connected or connect in flight. + } + }; if let Some(conn) = hole_punch_with_scanning(&endpoint, &requester, &routable_addrs, our_nat_profile, peer_nat_profile).await { let remote_sock = routable_addrs.iter().filter_map(|a| a.parse::().ok()).find(|s| crate::network::is_shareable_addr(s)); let mut cm = cm_arc.lock().await; @@ -6182,7 +6030,13 @@ impl ConnectionManager { let post_author = sp.post.author; let cm = cm_arc.lock().await; let storage = cm.storage.get().await; - let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility); + let _ = crate::control::receive_post( + &storage, + &sp.id, + &sp.post, + &sp.visibility, + sp.intent.as_ref(), + ); let _ = storage.touch_file_holder( &sp.id, &sender, @@ -6250,18 +6104,18 @@ impl ConnectionManager { async fn handle_blob_header_diff(&self, payload: BlobHeaderDiffPayload, sender: NodeId) { use crate::types::BlobHeaderDiffOp; - // Gather policy + audience data + holders, then drop lock immediately. + // Gather policy + followers set + holders, then drop lock immediately. // Remote peer clearly holds this post — record them as a holder. - let (policy, approved_audience, holders) = { + // v0.6.2: `AudienceOnly` → `FollowersOnly`; checked against our public + // follows list rather than a separate audience table. + let (policy, followers_set, holders) = { let storage = self.storage.get().await; let policy = storage.get_comment_policy(&payload.post_id) .ok() .flatten() .unwrap_or_default(); - let approved = storage.list_audience( - crate::types::AudienceDirection::Inbound, - Some(crate::types::AudienceStatus::Approved), - ).unwrap_or_default(); + let follows: std::collections::HashSet = + storage.list_public_follows().unwrap_or_default().into_iter().collect(); let _ = storage.touch_file_holder( &payload.post_id, &sender, @@ -6273,12 +6127,9 @@ impl ConnectionManager { .into_iter() .map(|(nid, _addrs)| nid) .collect(); - (policy, approved, holders) + (policy, follows, holders) }; - // Filter ops using gathered data (no lock held) - let audience_set: std::collections::HashSet = approved_audience.iter().map(|a| a.node_id).collect(); - // Apply ops in a short lock acquisition { let storage = self.storage.get().await; @@ -6314,8 +6165,8 @@ impl ConnectionManager { } match policy.allow_comments { crate::types::CommentPermission::None => continue, - crate::types::CommentPermission::AudienceOnly => { - if !audience_set.contains(&comment.author) { + crate::types::CommentPermission::FollowersOnly => { + if !followers_set.contains(&comment.author) { continue; } } @@ -6327,6 +6178,7 @@ impl ConnectionManager { &comment.content, comment.timestamp_ms, &comment.signature, + comment.ref_post_id.as_ref(), ) { continue; // Skip forged comments } @@ -8219,8 +8071,21 @@ impl ConnectionActor { let mut cm = self.cm.lock().await; cm.rebalance_slots().await.unwrap_or_default() }; - // Connect outside the lock — no 15s hold + // Connect outside the lock — no 15s hold. Reserve an + // outgoing-connect slot per peer so we don't race with + // auto-reconnect / relay-introduction paths for the same + // target; skip peers already mid-connect. for (peer_id, addr, _addr_s, slot_kind) in pending_connects { + let _connect_guard = { + let cm = self.cm.lock().await; + match cm.try_begin_connect(peer_id) { + Some(g) => g, + None => { + debug!(peer = hex::encode(peer_id), "rebalance: skipping — connect already in flight"); + continue; + } + } + }; let addrs: Vec = addr.ip_addrs().copied().collect(); if !addrs.is_empty() { let s = storage.get().await; @@ -8632,3 +8497,61 @@ fn now_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use super::{scanner_semaphore, PendingConnectGuard}; + use crate::types::NodeId; + use std::collections::HashSet; + use std::sync::{Arc, Mutex as StdMutex}; + + #[test] + fn scanner_semaphore_caps_concurrent_scans_at_one() { + let sem = scanner_semaphore(); + // Fresh — one permit should be available. + let p1 = sem.try_acquire().expect("first scan should acquire"); + // Second concurrent caller must be rejected. + assert!(sem.try_acquire().is_err(), "second scan must not acquire while first holds permit"); + // Dropping the first permit returns it to the pool. + drop(p1); + let p2 = sem.try_acquire().expect("after release, next scan should acquire"); + drop(p2); + } + + /// Construct a guard directly against a test-owned set, bypassing + /// ConnectionManager. This verifies the guard's state-machine + /// (insert on acquire, remove on drop) without needing a full CM. + fn try_begin(set: &Arc>>, peer: NodeId) -> Option { + let mut s = set.lock().ok()?; + if s.contains(&peer) { return None; } + s.insert(peer); + Some(PendingConnectGuard { peer_id: peer, set: Arc::clone(set) }) + } + + #[test] + fn pending_connect_guard_gates_same_peer_and_releases_on_drop() { + let set: Arc>> = Arc::new(StdMutex::new(HashSet::new())); + let peer_a: NodeId = [1u8; 32]; + let peer_b: NodeId = [2u8; 32]; + + // First acquire for A succeeds. + let g_a = try_begin(&set, peer_a).expect("first guard should acquire for peer A"); + // Second concurrent acquire for A is rejected. + assert!(try_begin(&set, peer_a).is_none(), "second concurrent guard for A must be refused"); + // A different peer is unaffected. + let g_b = try_begin(&set, peer_b).expect("guard for peer B should acquire independently"); + + // Dropping A's guard releases the slot. + drop(g_a); + assert!(!set.lock().unwrap().contains(&peer_a), "peer A should be removed from pending_connects on drop"); + // A new acquire for A now succeeds. + let g_a2 = try_begin(&set, peer_a).expect("after release, new guard for A should acquire"); + + // B's guard still active — independent. + assert!(set.lock().unwrap().contains(&peer_b)); + + drop(g_a2); + drop(g_b); + assert!(set.lock().unwrap().is_empty(), "all guards dropped — set should be empty"); + } +} diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs new file mode 100644 index 0000000..c6e15f7 --- /dev/null +++ b/crates/core/src/control.rs @@ -0,0 +1,254 @@ +//! Control posts: signed protocol operations carried as public posts that +//! receivers apply to local state (delete, update visibility) without +//! rendering in feeds. +//! +//! Wire flow: +//! 1. Author creates a `Post { author, content = ControlOp JSON, ... }` with +//! `VisibilityIntent::Control`. +//! 2. Post propagates via CDN like any other post (header-diffs on neighbor +//! posts ship the reference; receivers pull the control post). +//! 3. On receive, callers invoke `apply_control_post_if_applicable` to +//! decode, verify the ControlOp's signature against the post's author, +//! confirm the target post's author matches, and apply. +//! +//! Control posts themselves are stored with `VisibilityIntent::Control`; feed +//! queries exclude them. They remain in storage as tombstones so we can +//! re-propagate them to peers and so future arrivals of the target post are +//! rejected via the delete tombstone. + +use crate::crypto; +use crate::storage::Storage; +use crate::types::{ControlOp, DeleteRecord, NodeId, Post, PostId, PostVisibility, VisibilityIntent}; + +/// Parse the post's content as a `ControlOp`, verify its signature against +/// the post's author, verify target ownership, and apply to local storage. +/// No-op (returns Ok) if the post is not a control post. Returns an error +/// on a control post with an invalid signature or mismatched target author. +/// Callers pass an existing storage guard so the apply happens under the +/// same lock as the post-store that triggered the call. +pub fn apply_control_post_if_applicable( + s: &Storage, + post: &Post, + intent: Option<&VisibilityIntent>, +) -> anyhow::Result<()> { + if !matches!(intent, Some(VisibilityIntent::Control)) { + return Ok(()); + } + let op: ControlOp = serde_json::from_str(&post.content) + .map_err(|e| anyhow::anyhow!("control post content is not a valid ControlOp: {}", e))?; + match op { + ControlOp::DeletePost { post_id, timestamp_ms, signature } => { + if !crypto::verify_control_delete(&post.author, &post_id, timestamp_ms, &signature) { + anyhow::bail!("invalid control-delete signature"); + } + if let Some(target) = s.get_post(&post_id)? { + if target.author != post.author { + anyhow::bail!("control-delete author does not match target post's author"); + } + } + let record = DeleteRecord { + post_id, + author: post.author, + timestamp_ms, + signature: signature.clone(), + }; + let _ = s.store_delete(&record); + let _ = s.apply_delete(&record); + Ok(()) + } + ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => { + if !crypto::verify_control_visibility(&post.author, &post_id, &new_visibility, timestamp_ms, &signature) { + anyhow::bail!("invalid control-visibility signature"); + } + if let Some(target) = s.get_post(&post_id)? { + if target.author != post.author { + anyhow::bail!("control-visibility author does not match target post's author"); + } + let _ = s.update_post_visibility(&post_id, &new_visibility); + } + let _ = (timestamp_ms, new_visibility); + Ok(()) + } + } +} + +/// Unified receive path: for every incoming post, call this instead of +/// `store_post_with_visibility` / `store_post_with_intent`. If the post is a +/// control post, the op is verified and applied atomically under the same +/// storage guard; if verification fails the post is NOT stored (so we don't +/// propagate bogus controls to other peers via neighbor-manifest diffs). +/// +/// Returns Ok(true) if the post was newly stored, Ok(false) if already known, +/// and an error for control posts with invalid signatures or mismatched +/// target authors. +pub fn receive_post( + s: &Storage, + id: &PostId, + post: &Post, + visibility: &PostVisibility, + intent: Option<&VisibilityIntent>, +) -> anyhow::Result { + // Verify signed intent posts BEFORE storing. Bogus signed posts must + // never enter storage and get re-propagated via neighbor-manifest diffs. + match intent { + Some(VisibilityIntent::Control) => { + let op: ControlOp = serde_json::from_str(&post.content).map_err(|e| { + anyhow::anyhow!("control post content is not a valid ControlOp: {}", e) + })?; + match &op { + ControlOp::DeletePost { post_id, timestamp_ms, signature } => { + if !crypto::verify_control_delete(&post.author, post_id, *timestamp_ms, signature) { + anyhow::bail!("invalid control-delete signature"); + } + } + ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => { + if !crypto::verify_control_visibility(&post.author, post_id, new_visibility, *timestamp_ms, signature) { + anyhow::bail!("invalid control-visibility signature"); + } + } + } + } + Some(VisibilityIntent::Profile) => { + crate::profile::verify_profile_post(post)?; + } + _ => {} + } + + let stored = if let Some(intent) = intent { + s.store_post_with_intent(id, post, visibility, intent)? + } else { + s.store_post_with_visibility(id, post, visibility)? + }; + if stored { + apply_control_post_if_applicable(s, post, intent)?; + crate::profile::apply_profile_post_if_applicable(s, post, intent)?; + } + Ok(stored) +} + +/// Build a Post representing a control-delete operation. Caller is +/// responsible for storing and propagating it. +pub fn build_delete_control_post( + author: &NodeId, + author_secret: &[u8; 32], + target_post_id: &crate::types::PostId, +) -> Post { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let signature = crypto::sign_control_delete(author_secret, target_post_id, timestamp_ms); + let op = ControlOp::DeletePost { + post_id: *target_post_id, + timestamp_ms, + signature, + }; + Post { + author: *author, + content: serde_json::to_string(&op).unwrap_or_default(), + attachments: vec![], + timestamp_ms, + } +} + +/// Build a Post representing a control-update-visibility operation. Caller +/// is responsible for storing and propagating it. +pub fn build_visibility_control_post( + author: &NodeId, + author_secret: &[u8; 32], + target_post_id: &crate::types::PostId, + new_visibility: &PostVisibility, +) -> Post { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let signature = crypto::sign_control_visibility(author_secret, target_post_id, new_visibility, timestamp_ms); + let op = ControlOp::UpdateVisibility { + post_id: *target_post_id, + new_visibility: new_visibility.clone(), + timestamp_ms, + signature, + }; + Post { + author: *author, + content: serde_json::to_string(&op).unwrap_or_default(), + attachments: vec![], + timestamp_ms, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + use ed25519_dalek::SigningKey; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) { + let seed = [seed_byte; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let public = signing_key.verifying_key(); + (seed, *public.as_bytes()) + } + + #[test] + fn control_delete_roundtrip_verifies_and_applies() { + let s = temp_storage(); + let (author_sec, author_pub) = make_keypair(7); + + let post = Post { + author: author_pub, + content: "hello".to_string(), + attachments: vec![], + timestamp_ms: 1000, + }; + let post_id = crate::content::compute_post_id(&post); + s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); + + let control = build_delete_control_post(&author_pub, &author_sec, &post_id); + let control_id = crate::content::compute_post_id(&control); + let stored = receive_post( + &s, + &control_id, + &control, + &PostVisibility::Public, + Some(&VisibilityIntent::Control), + ).unwrap(); + assert!(stored); + assert!(s.is_deleted(&post_id).unwrap()); + } + + #[test] + fn control_delete_rejects_wrong_author() { + let s = temp_storage(); + let (_author_sec, author_pub) = make_keypair(7); + let (other_sec, _other_pub) = make_keypair(9); + + let post = Post { + author: author_pub, + content: "hello".to_string(), + attachments: vec![], + timestamp_ms: 1000, + }; + let post_id = crate::content::compute_post_id(&post); + s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap(); + + // Sign with wrong secret → invalid signature for `author_pub`. + let control = build_delete_control_post(&author_pub, &other_sec, &post_id); + let control_id = crate::content::compute_post_id(&control); + let res = receive_post( + &s, + &control_id, + &control, + &PostVisibility::Public, + Some(&VisibilityIntent::Control), + ); + assert!(res.is_err()); + assert!(s.get_post(&control_id).unwrap().is_none()); + assert!(!s.is_deleted(&post_id).unwrap()); + } +} diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index ef48683..e19033e 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -289,6 +289,128 @@ pub fn sign_delete(seed: &[u8; 32], post_id: &PostId) -> Vec { sig.to_bytes().to_vec() } +/// Canonical bytes for a ControlOp::DeletePost signature. +fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec { + let mut buf = Vec::with_capacity(12 + 32 + 8); + buf.extend_from_slice(b"ctrl:delete:"); + buf.extend_from_slice(post_id); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf +} + +/// Sign a control-post DeletePost operation. +pub fn sign_control_delete(seed: &[u8; 32], post_id: &PostId, timestamp_ms: u64) -> Vec { + let signing_key = SigningKey::from_bytes(seed); + let sig = signing_key.sign(&control_delete_bytes(post_id, timestamp_ms)); + sig.to_bytes().to_vec() +} + +pub fn verify_control_delete( + author: &NodeId, + post_id: &PostId, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + if signature.len() != 64 { return false; } + let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false }; + let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); + let Ok(vk) = VerifyingKey::from_bytes(author) else { return false }; + vk.verify_strict(&control_delete_bytes(post_id, timestamp_ms), &sig).is_ok() +} + +/// Canonical bytes for a ControlOp::UpdateVisibility signature. Uses JSON +/// round-trip on the visibility payload because PostVisibility is an enum +/// with variable shape; callers must pass the exact same bytes when verifying. +fn control_visibility_bytes( + post_id: &PostId, + new_visibility_canonical: &[u8], + timestamp_ms: u64, +) -> Vec { + let mut buf = Vec::with_capacity(10 + 32 + new_visibility_canonical.len() + 8); + buf.extend_from_slice(b"ctrl:vis:"); + buf.extend_from_slice(post_id); + buf.extend_from_slice(new_visibility_canonical); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf +} + +pub fn sign_control_visibility( + seed: &[u8; 32], + post_id: &PostId, + new_visibility: &crate::types::PostVisibility, + timestamp_ms: u64, +) -> Vec { + let canon = serde_json::to_vec(new_visibility).unwrap_or_default(); + let signing_key = SigningKey::from_bytes(seed); + let sig = signing_key.sign(&control_visibility_bytes(post_id, &canon, timestamp_ms)); + sig.to_bytes().to_vec() +} + +pub fn verify_control_visibility( + author: &NodeId, + post_id: &PostId, + new_visibility: &crate::types::PostVisibility, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + if signature.len() != 64 { return false; } + let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false }; + let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); + let Ok(vk) = VerifyingKey::from_bytes(author) else { return false }; + let canon = match serde_json::to_vec(new_visibility) { Ok(v) => v, Err(_) => return false }; + vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok() +} + +/// Canonical bytes for a Profile-post signature: length-prefixed display_name +/// and bio, 32-byte avatar_cid (or zeros), then timestamp_ms. Length prefixes +/// prevent extension/reordering attacks. +fn profile_post_bytes( + display_name: &str, + bio: &str, + avatar_cid: &Option<[u8; 32]>, + timestamp_ms: u64, +) -> Vec { + let dn = display_name.as_bytes(); + let bio_bytes = bio.as_bytes(); + let mut buf = Vec::with_capacity(5 + 8 + dn.len() + 8 + bio_bytes.len() + 32 + 8); + buf.extend_from_slice(b"prof:"); + buf.extend_from_slice(&(dn.len() as u64).to_le_bytes()); + buf.extend_from_slice(dn); + buf.extend_from_slice(&(bio_bytes.len() as u64).to_le_bytes()); + buf.extend_from_slice(bio_bytes); + let avatar = avatar_cid.unwrap_or([0u8; 32]); + buf.extend_from_slice(&avatar); + buf.extend_from_slice(×tamp_ms.to_le_bytes()); + buf +} + +pub fn sign_profile( + seed: &[u8; 32], + display_name: &str, + bio: &str, + avatar_cid: &Option<[u8; 32]>, + timestamp_ms: u64, +) -> Vec { + let signing_key = SigningKey::from_bytes(seed); + let sig = signing_key.sign(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms)); + sig.to_bytes().to_vec() +} + +pub fn verify_profile( + author: &NodeId, + display_name: &str, + bio: &str, + avatar_cid: &Option<[u8; 32]>, + timestamp_ms: u64, + signature: &[u8], +) -> bool { + if signature.len() != 64 { return false; } + let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false }; + let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes); + let Ok(vk) = VerifyingKey::from_bytes(author) else { return false }; + vk.verify_strict(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms), &sig).is_ok() +} + /// Verify an ed25519 delete signature: the author's public key signed the post_id. pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool { if signature.len() != 64 { @@ -606,20 +728,37 @@ pub fn decrypt_private_reaction( } /// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms). +fn comment_digest( + author: &NodeId, + post_id: &PostId, + content: &str, + timestamp_ms: u64, + ref_post_id: Option<&PostId>, +) -> blake3::Hash { + let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); + hasher.update(author); + hasher.update(post_id); + hasher.update(content.as_bytes()); + hasher.update(×tamp_ms.to_le_bytes()); + // Domain-separated append: `None` yields the same digest as the v0.6.1 + // scheme, so plain comments keep verifying; `Some(ref)` adds the ref id. + if let Some(rid) = ref_post_id { + hasher.update(b"ref:"); + hasher.update(rid); + } + hasher.finalize() +} + pub fn sign_comment( seed: &[u8; 32], author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, + ref_post_id: Option<&PostId>, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); - let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); - hasher.update(author); - hasher.update(post_id); - hasher.update(content.as_bytes()); - hasher.update(×tamp_ms.to_le_bytes()); - let digest = hasher.finalize(); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } @@ -630,6 +769,7 @@ pub fn verify_comment_signature( content: &str, timestamp_ms: u64, signature: &[u8], + ref_post_id: Option<&PostId>, ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; @@ -637,12 +777,7 @@ pub fn verify_comment_signature( let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; - let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); - hasher.update(author); - hasher.update(post_id); - hasher.update(content.as_bytes()); - hasher.update(×tamp_ms.to_le_bytes()); - let digest = hasher.finalize(); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } @@ -877,6 +1012,29 @@ mod tests { } } + #[test] + fn comment_signature_binds_ref_post_id() { + let (seed, nid) = make_keypair(7); + let post_id = [1u8; 32]; + let ref_post = [2u8; 32]; + let content = "preview"; + let ts = 1000u64; + + // Signature including ref_post_id. + let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post)); + // Verifies only when the ref is supplied. + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post))); + // Same signature must NOT verify when the ref is dropped (binding). + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None)); + // Nor when the ref is swapped. + let other_ref = [3u8; 32]; + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref))); + + // Plain-comment signature still works (backward compat with v0.6.1). + let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None)); + } + #[test] fn test_sign_verify_manifest() { use crate::types::{AuthorManifest, ManifestEntry}; diff --git a/crates/core/src/group_key_distribution.rs b/crates/core/src/group_key_distribution.rs new file mode 100644 index 0000000..f23335e --- /dev/null +++ b/crates/core/src/group_key_distribution.rs @@ -0,0 +1,296 @@ +//! Group-key distribution as an encrypted post. +//! +//! v0.6.2 replaces the v0.6.1 `GroupKeyDistribute` wire push (admin → +//! member, uni-stream) with a standard public post that carries the group +//! seed inside `PostVisibility::Encrypted`. Each member is a recipient; the +//! post's CEK is wrapped per member using the admin's posting key. Members +//! receive the post via normal CDN / pull paths, decrypt with their posting +//! secret, and recover the seed + metadata. +//! +//! Removing the direct push eliminates the wire-level signal that a given +//! network endpoint is coordinating group membership with another specific +//! endpoint. +//! +//! Note: Members are identified by their **posting** NodeIds (the +//! author/recipient namespace since the v0.6.1 identity split), not network +//! NodeIds. The admin wraps the CEK using their default_posting_secret; the +//! receiver unwraps using one of their posting identity secrets. + +use crate::content::compute_post_id; +use crate::crypto; +use crate::storage::Storage; +use crate::types::{ + GroupKeyDistributionContent, GroupKeyRecord, GroupMemberKey, NodeId, Post, PostId, + PostVisibility, PostingIdentity, VisibilityIntent, +}; + +/// Build an encrypted key-distribution post. Authored by the admin's +/// posting identity; recipients are the member posting NodeIds. Returns +/// `(PostId, Post, PostVisibility)` — caller stores with intent= +/// `GroupKeyDistribute` and propagates via the normal neighbor-manifest CDN +/// path. +pub fn build_distribution_post( + admin: &NodeId, + admin_secret: &[u8; 32], + record: &GroupKeyRecord, + group_seed: &[u8; 32], + members: &[NodeId], +) -> anyhow::Result<(PostId, Post, PostVisibility)> { + let content = GroupKeyDistributionContent { + group_id: record.group_id, + circle_name: record.circle_name.clone(), + epoch: record.epoch, + group_public_key: record.group_public_key, + admin: *admin, + canonical_root_post_id: record.canonical_root_post_id, + group_seed: *group_seed, + }; + let plaintext = serde_json::to_string(&content)?; + + // Wrap the CEK to each member (their posting pubkey). + let (ciphertext_b64, wrapped_keys) = + crypto::encrypt_post(&plaintext, admin_secret, admin, members)?; + + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let post = Post { + author: *admin, + content: ciphertext_b64, + attachments: vec![], + timestamp_ms, + }; + let post_id = compute_post_id(&post); + let visibility = PostVisibility::Encrypted { recipients: wrapped_keys }; + Ok((post_id, post, visibility)) +} + +/// Attempt to decrypt + apply a stored GroupKeyDistribute post using each +/// posting identity's secret in turn. Returns `Ok(true)` on successful +/// apply, `Ok(false)` if none of our personas were recipients (or content +/// was malformed, or the seed had already been stored), `Err` on hard +/// errors during storage. +pub fn try_apply_distribution_post( + s: &Storage, + post: &Post, + visibility: &PostVisibility, + our_personas: &[PostingIdentity], +) -> anyhow::Result { + let wrapped_keys = match visibility { + PostVisibility::Encrypted { recipients } => recipients, + _ => return Ok(false), // Only Encrypted posts can carry seeds. + }; + + for persona in our_personas { + match crypto::decrypt_post( + &post.content, + &persona.secret_seed, + &persona.node_id, + &post.author, + wrapped_keys, + ) { + Ok(Some(plaintext)) => { + let content: GroupKeyDistributionContent = match serde_json::from_str(&plaintext) { + Ok(c) => c, + Err(_) => continue, // Bad payload — try next persona. + }; + // Critical: the `admin` claimed inside the decrypted + // payload must match the post author. Without this, any + // peer who knows a member's posting id and the group's + // group_id could craft an encrypted post claiming to be + // from the admin and overwrite the member's stored group + // key (create_group_key uses INSERT OR REPLACE). + if content.admin != post.author { + tracing::warn!( + post_author = hex::encode(post.author), + claimed_admin = hex::encode(content.admin), + group_id = hex::encode(content.group_id), + "rejecting group-key-distribution post: claimed admin != post author" + ); + continue; + } + apply_content(s, &content)?; + return Ok(true); + } + Ok(None) | Err(_) => continue, + } + } + Ok(false) +} + +fn apply_content(s: &Storage, content: &GroupKeyDistributionContent) -> anyhow::Result<()> { + let record = GroupKeyRecord { + group_id: content.group_id, + circle_name: content.circle_name.clone(), + epoch: content.epoch, + group_public_key: content.group_public_key, + admin: content.admin, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + canonical_root_post_id: content.canonical_root_post_id, + }; + s.create_group_key(&record, Some(&content.group_seed))?; + s.store_group_seed(&content.group_id, content.epoch, &content.group_seed)?; + Ok(()) +} + +/// Scan stored posts with `VisibilityIntent::GroupKeyDistribute` and apply +/// any that one of our posting identities can decrypt. Intended to run +/// after a pull-sync so newly-received distribution posts take effect +/// immediately. +pub fn process_pending( + s: &Storage, + our_personas: &[PostingIdentity], +) -> anyhow::Result { + // Cheap scan: iterate all posts, filter by intent. The table is small + // in practice (few groups × few epochs). + let all = s.list_posts_with_visibility()?; + let mut applied = 0; + for (id, post, visibility) in all { + let intent = s.get_post_intent(&id)?; + if !matches!(intent, Some(VisibilityIntent::GroupKeyDistribute)) { + continue; + } + if try_apply_distribution_post(s, &post, &visibility, our_personas)? { + applied += 1; + } + } + Ok(applied) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + use ed25519_dalek::SigningKey; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) { + let seed = [seed_byte; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let public = signing_key.verifying_key(); + (seed, *public.as_bytes()) + } + + fn mk_persona(seed: [u8; 32], node_id: NodeId) -> PostingIdentity { + PostingIdentity { + node_id, + secret_seed: seed, + display_name: String::new(), + created_at: 0, + } + } + + #[test] + fn forged_admin_is_rejected() { + // Scenario: an attacker knows the victim's posting pubkey and the + // target group_id. They craft an encrypted distribution post + // addressed to the victim, claiming themselves as the group admin. + // Without the author-vs-admin check the victim would overwrite + // their legitimate group key record. + let s = temp_storage(); + let (real_admin_sec, real_admin_id) = make_keypair(1); + let (attacker_sec, attacker_id) = make_keypair(9); + let (victim_sec, victim_id) = make_keypair(2); + + // Seed the victim with a legitimate group record so we can + // verify it isn't overwritten by the forgery. + let group_id = [77u8; 32]; + let real_pubkey = [1u8; 32]; + let real_seed = [42u8; 32]; + let real_record = GroupKeyRecord { + group_id, + circle_name: "real".to_string(), + epoch: 1, + group_public_key: real_pubkey, + admin: real_admin_id, + created_at: 100, + canonical_root_post_id: None, + }; + let (_, real_post, real_vis) = build_distribution_post( + &real_admin_id, &real_admin_sec, &real_record, &real_seed, &[victim_id], + ).unwrap(); + let victim_personas = vec![mk_persona(victim_sec, victim_id)]; + assert!(try_apply_distribution_post(&s, &real_post, &real_vis, &victim_personas).unwrap()); + assert_eq!(s.get_group_key(&group_id).unwrap().unwrap().admin, real_admin_id); + + // Attacker authors a forgery: post.author is attacker, but the + // inner `admin` field claims to be the real admin. + let forged_content = GroupKeyDistributionContent { + group_id, + circle_name: "real".to_string(), + epoch: 2, + group_public_key: [255u8; 32], + admin: real_admin_id, // lies inside the encrypted payload + canonical_root_post_id: None, + group_seed: [0xFFu8; 32], + }; + let plaintext = serde_json::to_string(&forged_content).unwrap(); + let (ciphertext, wrapped) = crate::crypto::encrypt_post( + &plaintext, &attacker_sec, &attacker_id, &[victim_id], + ).unwrap(); + let forged_post = Post { + author: attacker_id, // real author — attacker, not admin + content: ciphertext, + attachments: vec![], + timestamp_ms: 200, + }; + let forged_vis = PostVisibility::Encrypted { recipients: wrapped }; + + let applied = try_apply_distribution_post(&s, &forged_post, &forged_vis, &victim_personas).unwrap(); + assert!(!applied, "forged distribution post must not be applied"); + + // Legitimate group key must be untouched. + let stored = s.get_group_key(&group_id).unwrap().unwrap(); + assert_eq!(stored.admin, real_admin_id); + assert_eq!(stored.group_public_key, real_pubkey); + } + + #[test] + fn member_decrypts_and_applies() { + let s = temp_storage(); + let (admin_sec, admin_id) = make_keypair(1); + let (member_sec, member_id) = make_keypair(2); + let (nonmember_sec, nonmember_id) = make_keypair(3); + + let group_id = [42u8; 32]; + let group_pubkey = [7u8; 32]; + let group_seed = [9u8; 32]; + let record = GroupKeyRecord { + group_id, + circle_name: "fam".to_string(), + epoch: 1, + group_public_key: group_pubkey, + admin: admin_id, + created_at: 100, + canonical_root_post_id: None, + }; + + let (_pid, post, visibility) = build_distribution_post( + &admin_id, &admin_sec, &record, &group_seed, &[member_id], + ).unwrap(); + + // Member applies successfully. + let member_personas = vec![mk_persona(member_sec, member_id)]; + let applied = try_apply_distribution_post(&s, &post, &visibility, &member_personas).unwrap(); + assert!(applied); + let stored = s.get_group_key(&group_id).unwrap().unwrap(); + assert_eq!(stored.circle_name, "fam"); + let seed = s.get_group_seed(&group_id, 1).unwrap().unwrap(); + assert_eq!(seed, group_seed); + + // Non-member can't. + let s2 = temp_storage(); + let nonmember_personas = vec![mk_persona(nonmember_sec, nonmember_id)]; + let applied2 = try_apply_distribution_post(&s2, &post, &visibility, &nonmember_personas).unwrap(); + assert!(!applied2); + assert!(s2.get_group_key(&group_id).unwrap().is_none()); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0948e66..6fca83f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,13 +2,16 @@ pub mod activity; pub mod blob; pub mod connection; pub mod content; +pub mod control; pub mod crypto; +pub mod group_key_distribution; pub mod http; pub mod export; pub mod identity; pub mod import; pub mod network; pub mod node; +pub mod profile; pub mod protocol; pub mod storage; pub mod stun; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index cf87b52..114ab41 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -12,15 +12,14 @@ use crate::blob::BlobStore; use crate::connection::{initial_exchange_accept, initial_exchange_connect, ConnHandle, ConnectionActor, ConnectionManager, ExchangeResult}; use crate::content::verify_post_id; use crate::protocol::{ - read_message_type, read_payload, write_typed_message, AudienceRequestPayload, - AudienceResponsePayload, BlobRequestPayload, BlobResponsePayload, DeleteRecordPayload, - MessageType, PostNotificationPayload, PostPushPayload, ProfileUpdatePayload, + read_message_type, read_payload, write_typed_message, BlobRequestPayload, BlobResponsePayload, + MessageType, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, - SocialAddressUpdatePayload, SocialDisconnectNoticePayload, SyncPost, ALPN_V2, + ALPN_V2, }; use crate::storage::StoragePool; use crate::types::{ - DeleteRecord, DeviceProfile, DeviceRole, NodeId, PeerSlotKind, PeerWithAddress, Post, PostId, + DeviceProfile, DeviceRole, NodeId, PeerSlotKind, Post, PostId, PostVisibility, PublicProfile, SessionReachMethod, WormResult, }; @@ -893,16 +892,7 @@ impl Network { Ok(sent) } - /// Send a post notification to all audience members (ephemeral-capable). - pub async fn notify_post(&self, post_id: &crate::types::PostId, author: &NodeId) -> usize { - let payload = PostNotificationPayload { - post_id: *post_id, - author: *author, - }; - self.send_to_audience(MessageType::PostNotification, &payload).await - } - - /// Push a profile update to all audience members (ephemeral-capable). +/// 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, @@ -959,38 +949,7 @@ impl Network { sent } - /// Push a delete record to all audience members (ephemeral-capable). - pub async fn push_delete(&self, record: &DeleteRecord) -> usize { - let payload = DeleteRecordPayload { - records: vec![record.clone()], - }; - self.send_to_audience(MessageType::DeleteRecord, &payload).await - } - - /// Push a disconnect notice to all audience members (ephemeral-capable). - pub async fn push_disconnect_to_audience(&self, disconnected_peer: &NodeId) -> usize { - let payload = SocialDisconnectNoticePayload { - node_id: *disconnected_peer, - }; - self.send_to_audience(MessageType::SocialDisconnectNotice, &payload).await - } - - /// Push a social address update to all audience members (ephemeral-capable). - pub async fn push_address_update_to_audience( - &self, - node_id: &NodeId, - addresses: &[String], - peer_addresses: &[PeerWithAddress], - ) -> usize { - let payload = SocialAddressUpdatePayload { - node_id: *node_id, - addresses: addresses.to_vec(), - peer_addresses: peer_addresses.to_vec(), - }; - self.send_to_audience(MessageType::SocialAddressUpdate, &payload).await - } - - /// Push a visibility update to all connected peers. +/// 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}; @@ -1049,29 +1008,7 @@ impl Network { sent } - /// Send blob delete notices to all known holders of a file. - /// Second argument kept as Option for signature stability; flat-holder - /// model doesn't need separate upstream handling. - pub async fn send_blob_delete_notices( - &self, - cid: &[u8; 32], - holders: &[(NodeId, Vec)], - _legacy_upstream: Option<&(NodeId, Vec)>, - ) -> usize { - let payload = crate::protocol::BlobDeleteNoticePayload { - cid: *cid, - upstream_node: None, - }; - let mut sent = 0; - for (peer, _addrs) in holders { - if self.send_to_peer_uni(peer, MessageType::BlobDeleteNotice, &payload).await.is_ok() { - sent += 1; - } - } - sent - } - - /// Request a manifest refresh from the upstream peer for a blob CID. +/// Request a manifest refresh from the upstream peer for a blob CID. /// Returns the updated manifest if the upstream has a newer version. pub async fn request_manifest_refresh( &self, @@ -1096,71 +1033,7 @@ impl Network { } } - /// Send an audience request to a peer (persistent if available, ephemeral otherwise). - pub async fn send_audience_request(&self, target: &NodeId) -> anyhow::Result<()> { - let payload = AudienceRequestPayload { - requester: self.our_node_id, - }; - self.send_to_peer_uni(target, MessageType::AudienceRequest, &payload).await - } - - /// Send an audience response to a peer (persistent if available, ephemeral otherwise). - pub async fn send_audience_response(&self, target: &NodeId, approved: bool) -> anyhow::Result<()> { - let payload = AudienceResponsePayload { - responder: self.our_node_id, - approved, - }; - self.send_to_peer_uni(target, MessageType::AudienceResponse, &payload).await - } - - /// Push a public post to audience members (persistent if available, ephemeral otherwise). - pub async fn push_to_audience( - &self, - post_id: &crate::types::PostId, - post: &Post, - visibility: &PostVisibility, - ) -> usize { - if !matches!(visibility, PostVisibility::Public) { - return 0; - } - - let audience_members: Vec = { - match self.storage.get().await.list_audience_members() { - Ok(m) => m, - Err(_) => return 0, - } - }; - - let payload = PostPushPayload { - post: SyncPost { - id: *post_id, - post: post.clone(), - visibility: visibility.clone(), - }, - }; - - let mut pushed = 0; - for member in &audience_members { - if self.send_to_peer_uni(member, MessageType::PostPush, &payload).await.is_ok() { - pushed += 1; - } - } - - pushed - } - - /// Push a group key to a specific peer (uni-stream). - pub async fn push_group_key( - &self, - peer: &NodeId, - payload: &crate::protocol::GroupKeyDistributePayload, - ) -> bool { - self.send_to_peer_uni(peer, MessageType::GroupKeyDistribute, payload) - .await - .is_ok() - } - - /// Send a social checkin to a peer (persistent if available, ephemeral otherwise). +/// Send a social checkin to a peer (persistent if available, ephemeral otherwise). pub async fn send_social_checkin( &self, peer_id: &NodeId, @@ -1693,37 +1566,26 @@ impl Network { } } - // ---- Audience-targeted + ephemeral helpers ---- - - /// Send a uni-stream message to all audience members (persistent if available, ephemeral otherwise). - async fn send_to_audience(&self, msg_type: MessageType, payload: &T) -> usize { - let audience: Vec = match self.storage.get().await.list_audience_members() { - Ok(m) => m, - Err(_) => return 0, - }; - let mut sent = 0; - for member in &audience { - if self.send_to_peer_uni(member, msg_type, payload).await.is_ok() { - sent += 1; - } - } - sent - } - - /// Pull posts from a peer (persistent if available, ephemeral otherwise). +/// Pull posts from a peer (persistent if available, ephemeral otherwise). pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { let conn = self.get_connection(peer_id).await?; - let (our_follows, follows_sync) = { + let (our_follows, follows_sync, our_personas) = { let storage = self.storage.get().await; ( storage.list_follows()?, storage.get_follows_with_last_sync().unwrap_or_default(), + storage.list_posting_identities().unwrap_or_default(), ) }; - // Merged pull: include our own NodeId so DMs addressed to us match. + // Merged pull: include every posting identity we hold so DMs addressed + // to any of our personas match on recipient. Our network NodeId is + // never an author nor a wrapped_key recipient — including it would + // never match and would leak the network↔posting boundary. let mut query_list = our_follows; - if !query_list.contains(&self.our_node_id) { - query_list.push(self.our_node_id); + for pi in &our_personas { + if !query_list.contains(&pi.node_id) { + query_list.push(pi.node_id); + } } let (mut send, mut recv) = conn.open_bi().await?; write_typed_message( diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index db40498..363402b 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -12,10 +12,10 @@ use crate::crypto; use crate::network::Network; use crate::storage::StoragePool; use crate::types::{ - Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, DeleteRecord, + Attachment, Circle, DeviceProfile, DeviceRole, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, ReachMethod, RevocationMode, SessionReachMethod, SocialRelation, - SocialRouteEntry, SocialStatus, VisibilityIntent, VisibilityUpdate, WormResult, + SocialRouteEntry, SocialStatus, VisibilityIntent, WormResult, }; /// Built-in default anchor — always available as a bootstrap fallback. @@ -1002,12 +1002,10 @@ impl Node { } } - // For public posts, push to audience members. Encrypted posts propagate - // via the CDN (ManifestPush + header-diff) to eliminate the sender→recipient - // traffic signal. - let audience_pushed = self.network.push_to_audience(&post_id, &post, &visibility).await; - - info!(post_id = hex::encode(post_id), audience_pushed, "Created new post"); + // v0.6.2: posts propagate ONLY via the CDN (pull + header-diff + // neighbor propagation). Persona-signed direct pushes (PostPush, + // PostNotification) are gone — they exposed sender→recipient traffic. + info!(post_id = hex::encode(post_id), "Created new post"); Ok((post_id, post, visibility)) } @@ -1074,6 +1072,13 @@ impl Node { VisibilityIntent::Friends => storage.list_public_follows(), VisibilityIntent::Circle(name) => storage.get_circle_members(name), VisibilityIntent::Direct(ids) => Ok(ids.clone()), + // Control / Profile posts are always Public on the wire; + // GroupKeyDistribute posts build their own recipient list in + // `group_key_distribution::build_distribution_post`. None of + // the three use this resolver. + VisibilityIntent::Control + | VisibilityIntent::Profile + | VisibilityIntent::GroupKeyDistribute => Ok(vec![]), } } @@ -1183,9 +1188,7 @@ impl Node { let storage = self.storage.get().await; storage.add_follow(node_id)?; - // Upsert social route - let is_audience = storage.list_audience_members()?.contains(node_id); - let relation = if is_audience { SocialRelation::Mutual } else { SocialRelation::Follow }; + // Upsert social route. v0.6.2: audience removed; only Follow exists. let addresses = storage.get_peer_record(node_id)? .map(|r| r.addresses).unwrap_or_default(); let peer_addresses = storage.build_peer_addresses_for(node_id)?; @@ -1196,7 +1199,7 @@ impl Node { node_id: *node_id, addresses, peer_addresses, - relation, + relation: SocialRelation::Follow, status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected }, last_connected_ms: 0, last_seen_ms: now, @@ -1210,19 +1213,8 @@ impl Node { pub async fn unfollow(&self, node_id: &NodeId) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.remove_follow(node_id)?; - - // Downgrade or remove social route - let is_audience = storage.list_audience_members()?.contains(node_id); - if is_audience { - // Downgrade from Mutual to Audience - if let Some(mut route) = storage.get_social_route(node_id)? { - route.relation = SocialRelation::Audience; - storage.upsert_social_route(&route)?; - } - } else { - storage.remove_social_route(node_id)?; - } - + // v0.6.2: audience removed; unfollow drops the social route entirely. + storage.remove_social_route(node_id)?; Ok(()) } @@ -1233,48 +1225,77 @@ impl Node { // ---- Profiles ---- + /// Set the default posting identity's profile (display_name, bio, + /// preserving any existing avatar). Creates a signed + /// `VisibilityIntent::Profile` post authored by the posting identity and + /// propagates it via the normal neighbor-manifest CDN path. The locally + /// stored profile row is keyed by the posting identity — peers who pull + /// the profile post apply the same update on their side. pub async fn set_profile(&self, display_name: String, bio: String) -> anyhow::Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; + let posting_id = self.default_posting_id; + let posting_secret = self.default_posting_secret; - let recent_peers = self.current_recent_peers().await; - // Profile is keyed by the network NodeId — that's how peers route to - // us. Broadcasts strip display_name / bio / avatar before going on - // the wire (see Network::push_profile). The locally stored profile - // retains the name for the user's own UI. - let profile = { + // Preserve existing avatar if present. + let avatar_cid = { let storage = self.storage.get().await; - let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default(); - let preferred_peers = storage.list_preferred_peers().unwrap_or_default(); - - let (existing_visible, existing_avatar) = storage.get_profile(&self.node_id) - .ok() - .flatten() - .map(|p| (p.public_visible, p.avatar_cid)) - .unwrap_or((true, None)); - - let profile = PublicProfile { - node_id: self.node_id, - display_name, - bio, - updated_at: now, - anchors: existing_anchors, - recent_peers, - preferred_peers, - public_visible: existing_visible, - avatar_cid: existing_avatar, - }; - - storage.store_profile(&profile)?; - profile + storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid) }; - let pushed = self.network.push_profile(&profile).await; - if pushed > 0 { - info!(pushed, "Pushed profile update to peers"); + let profile_post = crate::profile::build_profile_post( + &posting_id, + &posting_secret, + &display_name, + &bio, + avatar_cid, + ); + let profile_post_id = crate::content::compute_post_id(&profile_post); + let timestamp_ms = profile_post.timestamp_ms; + + // Store post with VisibilityIntent::Profile + apply (upserts profile row). + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &profile_post_id, + &profile_post, + &PostVisibility::Public, + &VisibilityIntent::Profile, + )?; + crate::profile::apply_profile_post_if_applicable( + &*storage, + &profile_post, + Some(&VisibilityIntent::Profile), + )?; } + // Propagate via neighbor-manifest header diffs like any other post. + self.update_neighbor_manifests_as( + &posting_id, + &posting_secret, + &profile_post_id, + timestamp_ms, + ).await; + + let profile = { + let storage = self.storage.get().await; + storage.get_profile(&posting_id)? + .unwrap_or_else(|| PublicProfile { + node_id: posting_id, + display_name: display_name.clone(), + bio: bio.clone(), + updated_at: timestamp_ms, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![], + public_visible: true, + avatar_cid, + }) + }; + + info!( + posting_id = hex::encode(posting_id), + profile_post_id = hex::encode(profile_post_id), + "Published profile post" + ); Ok(profile) } @@ -1327,14 +1348,17 @@ impl Node { storage.get_profile(node_id) } + /// v0.6.2: the user's own display profile lives under the default + /// posting identity (published as a signed Profile post), not the + /// network NodeId. pub async fn my_profile(&self) -> anyhow::Result> { let storage = self.storage.get().await; - storage.get_profile(&self.node_id) + storage.get_profile(&self.default_posting_id) } pub async fn has_profile(&self) -> anyhow::Result { let storage = self.storage.get().await; - Ok(storage.get_profile(&self.node_id)?.is_some()) + Ok(storage.get_profile(&self.default_posting_id)?.is_some()) } pub async fn get_display_name(&self, node_id: &NodeId) -> anyhow::Result> { @@ -1680,31 +1704,48 @@ impl Node { storage.add_circle_member(&circle_name, &node_id)?; } - // Wrap current group key for new member and distribute - let distribute_payload = { + // v0.6.2: distribute the seed via an encrypted key-distribution + // post (CDN-propagated), replacing the direct GroupKeyDistribute + // push. Only the admin (holder of the group seed) does this. + let post_to_propagate: Option<(PostId, u64, NodeId, [u8; 32])> = { let storage = self.storage.get().await; if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { - if gk.admin == self.node_id { + if gk.admin == self.default_posting_id { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { - match crypto::wrap_group_key_for_member(&self.default_posting_secret, &node_id, &seed) { - Ok(wrapped) => { - let mk = crate::types::GroupMemberKey { + // Record our own wrapped member key locally (so we + // still track membership in group_member_keys for + // rotation math). + if let Ok(wrapped_new) = crypto::wrap_group_key_for_member( + &self.default_posting_secret, &node_id, &seed, + ) { + let _ = storage.store_group_member_key( + &gk.group_id, + &crate::types::GroupMemberKey { member: node_id, epoch: gk.epoch, - wrapped_group_key: wrapped, - }; - let _ = storage.store_group_member_key(&gk.group_id, &mk); - Some(crate::protocol::GroupKeyDistributePayload { - group_id: gk.group_id, - circle_name: circle_name.clone(), - epoch: gk.epoch, - group_public_key: gk.group_public_key, - admin: self.node_id, - member_keys: vec![mk], - }) + wrapped_group_key: wrapped_new, + }, + ); + } + + match crate::group_key_distribution::build_distribution_post( + &self.default_posting_id, + &self.default_posting_secret, + &gk, + &seed, + &[node_id], + ) { + Ok((post_id, post, visibility)) => { + storage.store_post_with_intent( + &post_id, + &post, + &visibility, + &VisibilityIntent::GroupKeyDistribute, + )?; + Some((post_id, post.timestamp_ms, self.default_posting_id, self.default_posting_secret)) } Err(e) => { - warn!(error = %e, "Failed to wrap group key for new member"); + warn!(error = %e, "failed to build key-distribution post"); None } } @@ -1713,8 +1754,8 @@ impl Node { } else { None } }; - if let Some(payload) = distribute_payload { - self.network.push_group_key(&node_id, &payload).await; + if let Some((post_id, ts, posting_id, posting_secret)) = post_to_propagate { + self.update_neighbor_manifests_as(&posting_id, &posting_secret, &post_id, ts).await; } Ok(()) @@ -1738,6 +1779,130 @@ impl Node { /// Create a group key for a circle (called on circle creation). async fn create_group_key_for_circle(&self, circle_name: &str) -> anyhow::Result<()> { + self.create_group_key_inner(circle_name, None).await + } + + // ---- Groups (v0.6.2) ---- + + /// Create a new group anchored at `root_post_id`. Unlike circles, groups + /// are many-way: every member can post to the group once they've + /// received the wrapped group seed. Returns the `(GroupId, circle_name)` + /// pair used internally; the circle_name is synthesised from the root + /// post id so there's no user-visible naming step. + pub async fn create_group_from_post( + &self, + root_post_id: PostId, + initial_members: Vec, + ) -> anyhow::Result<(crate::types::GroupId, String)> { + let circle_name = format!("group:{}", hex::encode(&root_post_id[..6])); + + // Create the backing circle row + initialize group key with + // canonical_root_post_id set, then add each initial member (which + // wraps + distributes the key). + { + let storage = self.storage.get().await; + storage.create_circle(&circle_name)?; + } + self.create_group_key_inner(&circle_name, Some(root_post_id)).await?; + + for member in initial_members { + if member == self.node_id { + continue; + } + if let Err(e) = self.add_to_circle(circle_name.clone(), member).await { + warn!(member = hex::encode(member), error = %e, "failed to add group member"); + } + } + + let group_id = { + let storage = self.storage.get().await; + storage.get_group_key_by_circle(&circle_name)? + .map(|gk| gk.group_id) + .ok_or_else(|| anyhow::anyhow!("group key missing after creation"))? + }; + + info!( + root = hex::encode(root_post_id), + group_id = hex::encode(group_id), + circle_name = %circle_name, + "Created group from post" + ); + Ok((group_id, circle_name)) + } + + /// Post to a group anchored at `root_post_id`. Any member holding the + /// group seed can call this. Encrypts the content with the group key and + /// records a `ThreadMeta` link from the new post back to the root so + /// `list_group_posts_by_root` can later cluster all contributions. + pub async fn post_to_group( + &self, + root_post_id: PostId, + content: String, + attachment_data: Vec<(Vec, String)>, + ) -> anyhow::Result<(PostId, Post, PostVisibility)> { + let circle_name = { + let storage = self.storage.get().await; + storage.get_group_by_canonical_root(&root_post_id)? + .map(|gk| gk.circle_name) + .ok_or_else(|| anyhow::anyhow!("no group found for canonical root post"))? + }; + + let result = self.create_post_with_visibility( + content, + VisibilityIntent::Circle(circle_name), + attachment_data, + ).await?; + + // Link the new post back to the canonical root so the group can be + // reconstructed by `list_group_posts_by_root`. + { + let storage = self.storage.get().await; + storage.store_thread_meta(&crate::types::ThreadMeta { + post_id: result.0, + parent_post_id: root_post_id, + })?; + } + + Ok(result) + } + + /// List all posts that belong to the group rooted at `root_post_id`. + /// Reads the ThreadMeta parent index + returns the full posts. Callers + /// decrypt as needed (same as any other GroupEncrypted content). + pub async fn list_group_posts_by_root( + &self, + root_post_id: PostId, + ) -> anyhow::Result> { + let storage = self.storage.get().await; + let child_ids = storage.get_thread_children(&root_post_id)?; + let mut out = Vec::with_capacity(child_ids.len()); + for pid in child_ids { + if let Some((post, vis)) = storage.get_post_with_visibility(&pid)? { + out.push((pid, post, vis)); + } + } + Ok(out) + } + + // ---- end Groups ---- + + /// Scan any newly-received `VisibilityIntent::GroupKeyDistribute` posts + /// and apply ones we can decrypt with one of our posting identities. + /// Intended to run after a sync pass so group seeds propagate to members + /// without a direct push. Returns the count of applied distributions. + pub async fn process_group_key_distributions(&self) -> anyhow::Result { + let storage = self.storage.get().await; + let personas = storage.list_posting_identities()?; + crate::group_key_distribution::process_pending(&*storage, &personas) + } + + /// Shared group-key creation used by both circles (canonical_root=None) + /// and groups (canonical_root=Some). + async fn create_group_key_inner( + &self, + circle_name: &str, + canonical_root_post_id: Option, + ) -> anyhow::Result<()> { let (seed, pubkey) = crypto::generate_group_keypair(); let group_id = crypto::compute_group_id(&pubkey); let now = std::time::SystemTime::now() @@ -1751,6 +1916,7 @@ impl Node { group_public_key: pubkey, admin: self.node_id, created_at: now, + canonical_root_post_id, }; let storage = self.storage.get().await; @@ -1766,37 +1932,54 @@ impl Node { }; storage.store_group_member_key(&group_id, &self_mk)?; - // Wrap for existing circle members and distribute - let members = storage.get_circle_members(circle_name)?; - drop(storage); + // Wrap for existing circle members (if any) and distribute the seed + // via a single encrypted key-distribution post. v0.6.2 replaces the + // per-member uni-stream GroupKeyDistribute push with this + // CDN-propagated post (one post per epoch, recipients = all non-self + // members). + let other_members: Vec = storage.get_circle_members(circle_name)? + .into_iter() + .filter(|m| *m != self.node_id) + .collect(); - for member in &members { - if *member == self.node_id { - continue; - } - match crypto::wrap_group_key_for_member(&self.default_posting_secret, member, &seed) { - Ok(wrapped) => { - let mk = crate::types::GroupMemberKey { + for member in &other_members { + if let Ok(wrapped) = crypto::wrap_group_key_for_member( + &self.default_posting_secret, member, &seed, + ) { + let _ = storage.store_group_member_key( + &group_id, + &crate::types::GroupMemberKey { member: *member, epoch: 1, wrapped_group_key: wrapped, - }; + }, + ); + } + } + drop(storage); + + if !other_members.is_empty() { + match crate::group_key_distribution::build_distribution_post( + &self.default_posting_id, + &self.default_posting_secret, + &record, + &seed, + &other_members, + ) { + Ok((post_id, post, visibility)) => { + let ts = post.timestamp_ms; { let storage = self.storage.get().await; - let _ = storage.store_group_member_key(&group_id, &mk); + storage.store_post_with_intent( + &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, + )?; } - let payload = crate::protocol::GroupKeyDistributePayload { - group_id, - circle_name: circle_name.to_string(), - epoch: 1, - group_public_key: pubkey, - admin: self.node_id, - member_keys: vec![mk], - }; - self.network.push_group_key(member, &payload).await; + self.update_neighbor_manifests_as( + &self.default_posting_id, &self.default_posting_secret, &post_id, ts, + ).await; } Err(e) => { - warn!(member = hex::encode(member), error = %e, "Failed to wrap group key for member"); + warn!(error = %e, "failed to build key-distribution post"); } } } @@ -1824,7 +2007,7 @@ impl Node { } match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { - Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string())) + Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id)) } Err(e) => { warn!(error = %e, "Failed to rotate group key"); @@ -1833,7 +2016,7 @@ impl Node { } }; - if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name)) = rotate_result { + if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root)) = rotate_result { // Update storage { let storage = self.storage.get().await; @@ -1844,20 +2027,50 @@ impl Node { } } - // Distribute to each member - for mk in &member_keys { - if mk.member == self.node_id { - continue; - } - let payload = crate::protocol::GroupKeyDistributePayload { + // v0.6.2: distribute the new seed via an encrypted + // key-distribution post instead of per-member unicast pushes. + let recipients: Vec = member_keys + .iter() + .map(|mk| mk.member) + .filter(|m| *m != self.default_posting_id) + .collect(); + + if !recipients.is_empty() { + let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.clone(), epoch: new_epoch, group_public_key: new_pubkey, - admin: self.node_id, - member_keys: vec![mk.clone()], + admin: self.default_posting_id, + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0), + canonical_root_post_id: canonical_root, }; - self.network.push_group_key(&mk.member, &payload).await; + match crate::group_key_distribution::build_distribution_post( + &self.default_posting_id, + &self.default_posting_secret, + &record, + &new_seed, + &recipients, + ) { + Ok((post_id, post, visibility)) => { + let ts = post.timestamp_ms; + { + let storage = self.storage.get().await; + let _ = storage.store_post_with_intent( + &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, + ); + } + self.update_neighbor_manifests_as( + &self.default_posting_id, &self.default_posting_secret, &post_id, ts, + ).await; + } + Err(e) => { + warn!(error = %e, "failed to build rotate distribution post"); + } + } } info!(circle = %circle_name, epoch = new_epoch, "Rotated group key"); @@ -2083,12 +2296,11 @@ impl Node { let staleness_ms = 3600 * 1000; - let (candidates, follows, audience_members) = { + let (candidates, follows) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - let audience = storage.list_audience_members().unwrap_or_default(); - (candidates, follows, audience) + (candidates, follows) }; if candidates.is_empty() { @@ -2108,7 +2320,7 @@ impl Node { let mut min_priority = f64::MAX; let mut min_created_at = u64::MAX; for c in &non_elevated { - let priority = self.compute_blob_priority(c, &follows, &audience_members, now); + let priority = self.compute_blob_priority(c, &follows, now); if priority < min_priority { min_priority = priority; min_created_at = c.created_at; @@ -2155,39 +2367,32 @@ impl Node { // ---- Delete / Revocation ---- pub async fn delete_post(&self, post_id: &PostId) -> anyhow::Result<()> { - let post = { + // Load the target post and the posting identity of its author. Only + // the author can delete their own content, so the signing key must be + // one we hold in posting_identities. + let (target_author, author_secret) = { let storage = self.storage.get().await; - storage + let post = storage .get_post(post_id)? - .ok_or_else(|| anyhow::anyhow!("post not found"))? - }; - if post.author != self.node_id { - anyhow::bail!("cannot delete: you are not the author"); - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis() as u64; - let signature = crypto::sign_delete(&self.default_posting_secret, post_id); - - let record = DeleteRecord { - post_id: *post_id, - author: self.default_posting_id, - timestamp_ms: now, - signature, + .ok_or_else(|| anyhow::anyhow!("post not found"))?; + let pi = storage + .get_posting_identity(&post.author)? + .ok_or_else(|| anyhow::anyhow!("cannot delete: not authored by a persona on this device"))?; + (pi.node_id, pi.secret_seed) }; - // Collect blob CIDs + known holders before cleanup (for delete notices) - let blob_cdn_info: Vec<([u8; 32], Vec<(NodeId, Vec)>, Option<(NodeId, Vec)>)> = { - let storage = self.storage.get().await; - let cids = storage.get_blobs_for_post(post_id).unwrap_or_default(); - cids.into_iter().map(|cid| { - let holders = storage.get_file_holders(&cid).unwrap_or_default(); - (cid, holders, None::<(NodeId, Vec)>) - }).collect() - }; + // Build the control-delete post signed by the target's author. + let control_post = crate::control::build_delete_control_post( + &target_author, + &author_secret, + post_id, + ); + let control_post_id = crate::content::compute_post_id(&control_post); + let now = control_post.timestamp_ms; - // Clean up blobs (DB metadata + CDN metadata + filesystem) + // 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. let blob_cids = { let storage = self.storage.get().await; let cids = storage.delete_blobs_for_post(post_id)?; @@ -2202,19 +2407,42 @@ impl Node { } } + // Store the control post locally with VisibilityIntent::Control so + // feeds filter it and propagation queries find it. Apply the op under + // the same guard so delete recording + target cleanup happen with the + // control-post insert atomically. { let storage = self.storage.get().await; - storage.store_delete(&record)?; - storage.apply_delete(&record)?; + storage.store_post_with_intent( + &control_post_id, + &control_post, + &PostVisibility::Public, + &VisibilityIntent::Control, + )?; + crate::control::apply_control_post_if_applicable( + &*storage, + &control_post, + Some(&VisibilityIntent::Control), + )?; } - // Send CDN delete notices for each blob - for (cid, downstream, upstream) in &blob_cdn_info { - self.network.send_blob_delete_notices(cid, downstream, upstream.as_ref()).await; - } + // Propagate via the normal neighbor-manifest CDN path: include the + // control post in the author's other posts' `following_posts` lists + // and push manifest diffs to their file_holders. Peers who follow + // any of the author's posts pick up the control post and apply it. + self.update_neighbor_manifests_as( + &target_author, + &author_secret, + &control_post_id, + now, + ).await; - let pushed = self.network.push_delete(&record).await; - info!(post_id = hex::encode(post_id), pushed, blobs_removed = blob_cids.len(), "Deleted post"); + info!( + post_id = hex::encode(post_id), + control_post_id = hex::encode(control_post_id), + blobs_removed = blob_cids.len(), + "Deleted post via control post", + ); Ok(()) } @@ -2269,13 +2497,38 @@ impl Node { storage.update_post_visibility(post_id, &new_vis)?; } - let update = VisibilityUpdate { - post_id: *post_id, - author: self.default_posting_id, - visibility: new_vis, + // Propagate via a signed control-visibility post rather than a + // direct push. Only the target's author can make such a post. + let author_secret = { + let s = self.storage.get().await; + s.get_posting_identity(&post.author)? + .map(|pi| pi.secret_seed) + .ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))? }; - let pushed = self.network.push_visibility(&update).await; - info!(post_id = hex::encode(post_id), pushed, "Revoked access (sync mode)"); + let control_post = crate::control::build_visibility_control_post( + &post.author, + &author_secret, + post_id, + &new_vis, + ); + let control_post_id = crate::content::compute_post_id(&control_post); + let now = control_post.timestamp_ms; + { + let storage = self.storage.get().await; + storage.store_post_with_intent( + &control_post_id, + &control_post, + &PostVisibility::Public, + &VisibilityIntent::Control, + )?; + } + self.update_neighbor_manifests_as( + &post.author, + &author_secret, + &control_post_id, + now, + ).await; + info!(post_id = hex::encode(post_id), control_post_id = hex::encode(control_post_id), "Revoked access (sync mode) via control post"); Ok(None) } RevocationMode::ReEncrypt => { @@ -2709,6 +2962,11 @@ impl Node { "Pull complete: {} posts from {} peers", stats.posts_received, stats.peers_pulled ); + // v0.6.2: apply any newly-received key-distribution posts so group + // seeds propagate automatically after sync. + if let Ok(n) = self.process_group_key_distributions().await { + if n > 0 { info!(applied = n, "Applied group key distributions"); } + } Ok(()) } @@ -3372,98 +3630,6 @@ impl Node { storage.list_social_routes() } - // ---- Audience ---- - - pub async fn request_audience(&self, node_id: &NodeId) -> anyhow::Result<()> { - { - let storage = self.storage.get().await; - storage.store_audience(node_id, AudienceDirection::Outbound, AudienceStatus::Pending)?; - } - - // Send the request (persistent if available, ephemeral otherwise) - if let Err(e) = self.network.send_audience_request(node_id).await { - warn!(peer = hex::encode(node_id), error = %e, "Failed to send audience request"); - } - - info!(peer = hex::encode(node_id), "Requested audience membership"); - Ok(()) - } - - pub async fn approve_audience(&self, node_id: &NodeId) -> anyhow::Result<()> { - let connected = self.network.is_connected(node_id).await; - { - let storage = self.storage.get().await; - storage.store_audience(node_id, AudienceDirection::Inbound, AudienceStatus::Approved)?; - - // Upsert social route (Audience or Mutual) - let is_follow = storage.list_follows()?.contains(node_id); - let relation = if is_follow { SocialRelation::Mutual } else { SocialRelation::Audience }; - let addresses = storage.get_peer_record(node_id)? - .map(|r| r.addresses).unwrap_or_default(); - 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, - peer_addresses, - relation, - status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected }, - last_connected_ms: 0, - last_seen_ms: now, - reach_method: ReachMethod::Direct, - preferred_tree, - })?; - } - - // Send approval response (persistent if available, ephemeral otherwise) - if let Err(e) = self.network.send_audience_response(node_id, true).await { - warn!(peer = hex::encode(node_id), error = %e, "Failed to send audience approval"); - } - - info!(peer = hex::encode(node_id), "Approved audience request"); - Ok(()) - } - - pub async fn deny_audience(&self, node_id: &NodeId) -> anyhow::Result<()> { - let storage = self.storage.get().await; - storage.store_audience(node_id, AudienceDirection::Inbound, AudienceStatus::Denied)?; - Ok(()) - } - - pub async fn remove_audience(&self, node_id: &NodeId) -> anyhow::Result<()> { - let storage = self.storage.get().await; - storage.remove_audience(node_id, AudienceDirection::Inbound)?; - - // Downgrade or remove social route - let is_follow = storage.list_follows()?.contains(node_id); - if is_follow { - if let Some(mut route) = storage.get_social_route(node_id)? { - route.relation = SocialRelation::Follow; - storage.upsert_social_route(&route)?; - } - } else { - storage.remove_social_route(node_id)?; - } - - Ok(()) - } - - pub async fn list_audience_members(&self) -> anyhow::Result> { - let storage = self.storage.get().await; - storage.list_audience_members() - } - - pub async fn list_audience( - &self, - direction: AudienceDirection, - status: Option, - ) -> anyhow::Result> { - let storage = self.storage.get().await; - storage.list_audience(direction, status) - } - // ---- Blob Eviction ---- /// Compute priority score for a blob. Higher score = keep longer. @@ -3471,31 +3637,19 @@ impl Node { &self, candidate: &crate::storage::EvictionCandidate, follows: &[NodeId], - audience_members: &[NodeId], now_ms: u64, ) -> f64 { - compute_blob_priority_standalone(candidate, &self.node_id, follows, audience_members, now_ms) + compute_blob_priority_standalone(candidate, &self.node_id, follows, now_ms) } - /// Delete a blob with CDN notifications to known holders. - pub async fn delete_blob_with_cdn_notify(&self, cid: &[u8; 32]) -> anyhow::Result<()> { - // Gather known holders before cleanup - let holders = { - let storage = self.storage.get().await; - storage.get_file_holders(cid).unwrap_or_default() - }; - - // Send CDN delete notices to all holders - self.network.send_blob_delete_notices(cid, &holders, None).await; - - // Clean up local storage + /// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote + /// holders notice eviction via their own LRU / replica-miss handling. + pub async fn delete_blob_local(&self, cid: &[u8; 32]) -> anyhow::Result<()> { { let storage = self.storage.get().await; storage.cleanup_cdn_for_blob(cid)?; storage.remove_blob(cid)?; } - - // Delete from filesystem let _ = self.blob_store.delete(cid); Ok(()) @@ -3519,18 +3673,17 @@ impl Node { // 1-hour staleness for replica counts let staleness_ms = 3600 * 1000; - let (candidates, follows, audience_members) = { + let (candidates, follows) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); - let audience = storage.list_audience_members().unwrap_or_default(); - (candidates, follows, audience) + (candidates, follows) }; // Score and sort ascending (lowest priority first) let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates .iter() - .map(|c| (self.compute_blob_priority(c, &follows, &audience_members, now), c)) + .map(|c| (self.compute_blob_priority(c, &follows, now), c)) .collect(); scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); @@ -3542,7 +3695,7 @@ impl Node { if bytes_freed >= target_free { break; } - if let Err(e) = self.delete_blob_with_cdn_notify(&candidate.cid).await { + if let Err(e) = self.delete_blob_local(&candidate.cid).await { warn!(cid = hex::encode(candidate.cid), error = %e, "Failed to evict blob"); continue; } @@ -3846,11 +3999,36 @@ impl Node { Ok(counts) } - /// Add a comment to a post (signed with our key). + /// Add a plain inline comment to a post (signed with our posting key). + /// The comment's `content` is the full text; `ref_post_id` is None. pub async fn comment_on_post( &self, post_id: PostId, content: String, + ) -> anyhow::Result { + self.comment_on_post_inner(post_id, content, None).await + } + + /// Add a rich comment: the full body lives in `ref_post_id` (typically a + /// newly-created public post by the commenter that carries attachments + /// or a long body). The inline `preview` text appears in the parent + /// post's header-diff and is what most clients render by default; the + /// expanded view fetches the referenced post. Signature binds the + /// preview + ref_post_id so a peer can't rewrite either independently. + pub async fn comment_on_post_with_ref( + &self, + post_id: PostId, + preview: String, + ref_post_id: PostId, + ) -> anyhow::Result { + self.comment_on_post_inner(post_id, preview, Some(ref_post_id)).await + } + + async fn comment_on_post_inner( + &self, + post_id: PostId, + content: String, + ref_post_id: Option, ) -> anyhow::Result { let our_node_id = self.default_posting_id; let seed = self.default_posting_secret; @@ -3858,7 +4036,14 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let signature = crate::crypto::sign_comment(&seed, &our_node_id, &post_id, &content, now); + let signature = crate::crypto::sign_comment( + &seed, + &our_node_id, + &post_id, + &content, + now, + ref_post_id.as_ref(), + ); let comment = crate::types::InlineComment { author: our_node_id, @@ -3867,13 +4052,14 @@ impl Node { timestamp_ms: now, signature, deleted_at: None, + ref_post_id, }; let storage = self.storage.get().await; storage.store_comment(&comment)?; drop(storage); - // Propagate via BlobHeaderDiff to downstream + upstream + // Propagate via BlobHeaderDiff to the target post's known holders. { let network = &self.network; let diff = crate::protocol::BlobHeaderDiffPayload { @@ -4422,7 +4608,6 @@ pub fn compute_blob_priority_standalone( candidate: &crate::storage::EvictionCandidate, our_node_id: &NodeId, follows: &[NodeId], - audience_members: &[NodeId], now_ms: u64, ) -> f64 { let pin_boost = if candidate.pinned { 1000.0 } else { 0.0 }; @@ -4437,14 +4622,11 @@ pub fn compute_blob_priority_standalone( 0.0 }; + // v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other. let relationship = if candidate.author == *our_node_id { 5.0 - } else if follows.contains(&candidate.author) && audience_members.contains(&candidate.author) { - 3.0 } else if follows.contains(&candidate.author) { 2.0 - } else if audience_members.contains(&candidate.author) { - 1.0 } else { 0.1 }; @@ -4636,39 +4818,36 @@ mod tests { let candidate = make_candidate(our_id, true, now - 86400_000, now, 0); let score = compute_blob_priority_standalone( - &candidate, &our_id, &[], &[], now, + &candidate, &our_id, &[], now, ); - // Should be 1000 + (5.0 * 1.0 * ~0.5 * 1.0) = ~1002.5 assert!(score > 1000.0, "own pinned should score >1000, got {}", score); } #[test] - fn follow_recent_scores_higher_than_audience_stale() { + fn follow_recent_scores_higher_than_stranger_stale() { let our_id = make_node_id(1); let follow_id = make_node_id(2); - let audience_id = make_node_id(3); + let stranger_id = make_node_id(3); let now = 10_000_000_000u64; - // Follow: recently accessed, 1-day-old post let follow_candidate = make_candidate(follow_id, false, now - 86400_000, now, 0); let follow_score = compute_blob_priority_standalone( - &follow_candidate, &our_id, &[follow_id], &[], now, + &follow_candidate, &our_id, &[follow_id], now, ); - // Audience: stale (20 days no access), 10-day-old post, 5 copies - let audience_candidate = make_candidate( - audience_id, false, + let stranger_candidate = make_candidate( + stranger_id, false, now - 10 * 86400_000, now - 20 * 86400_000, 5, ); - let audience_score = compute_blob_priority_standalone( - &audience_candidate, &our_id, &[], &[audience_id], now, + let stranger_score = compute_blob_priority_standalone( + &stranger_candidate, &our_id, &[], now, ); - assert!(follow_score > audience_score, - "follow recent ({}) should score higher than audience stale ({})", - follow_score, audience_score); + assert!(follow_score > stranger_score, + "follow recent ({}) should score higher than stranger stale ({})", + follow_score, stranger_score); } #[test] @@ -4677,7 +4856,6 @@ mod tests { let stranger = make_node_id(99); let now = 10_000_000_000u64; - // Stale stranger post with many copies let candidate = make_candidate( stranger, false, now - 30 * 86400_000, @@ -4685,10 +4863,9 @@ mod tests { 10, ); let score = compute_blob_priority_standalone( - &candidate, &our_id, &[], &[], now, + &candidate, &our_id, &[], now, ); - // 0.1 relationship * 0.0 heart_recency * ~0.03 freshness / 11 = ~0 assert!(score < 0.01, "stranger stale should score near 0, got {}", score); } @@ -4696,26 +4873,18 @@ mod tests { fn priority_ordering() { let our_id = make_node_id(1); let follow_id = make_node_id(2); - let audience_id = make_node_id(3); let stranger_id = make_node_id(4); let now = 10_000_000_000u64; - // Own pinned (highest) let own = make_candidate(our_id, true, now - 86400_000, now, 0); - // Follow recent let follow = make_candidate(follow_id, false, now - 86400_000, now, 0); - // Audience stale - let audience = make_candidate(audience_id, false, now - 10 * 86400_000, now - 20 * 86400_000, 5); - // Stranger let stranger = make_candidate(stranger_id, false, now - 30 * 86400_000, now - 30 * 86400_000, 10); - let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], &[audience_id], now); - let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], &[audience_id], now); - let audience_score = compute_blob_priority_standalone(&audience, &our_id, &[follow_id], &[audience_id], now); - let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], &[audience_id], now); + let own_score = compute_blob_priority_standalone(&own, &our_id, &[follow_id], now); + let follow_score = compute_blob_priority_standalone(&follow, &our_id, &[follow_id], now); + let stranger_score = compute_blob_priority_standalone(&stranger, &our_id, &[follow_id], now); assert!(own_score > follow_score, "own ({}) > follow ({})", own_score, follow_score); - assert!(follow_score > audience_score, "follow ({}) > audience ({})", follow_score, audience_score); - assert!(audience_score > stranger_score, "audience ({}) > stranger ({})", audience_score, stranger_score); + assert!(follow_score > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score); } } diff --git a/crates/core/src/profile.rs b/crates/core/src/profile.rs new file mode 100644 index 0000000..7d1b66b --- /dev/null +++ b/crates/core/src/profile.rs @@ -0,0 +1,181 @@ +//! Profile posts: persona display metadata (display_name, bio, avatar_cid) +//! carried as a signed public post with `VisibilityIntent::Profile`. +//! +//! The post's `author` is the posting identity; the signature inside +//! `ProfilePostContent` is by that identity's secret. Profile posts propagate +//! via the normal CDN path (pull + header-diff). Receivers verify the +//! signature, then upsert a row in the `profiles` table keyed by the post's +//! author (= posting identity) with the new display fields. +//! +//! Profile posts are never rendered in feeds — the feed filter excludes +//! `VisibilityIntent::Profile` posts (see `Storage::get_feed*`). + +use crate::crypto; +use crate::storage::Storage; +use crate::types::{NodeId, Post, PostId, PostVisibility, ProfilePostContent, PublicProfile, VisibilityIntent}; + +/// Verify a profile-post signature without any other side effects. Used by +/// receive paths before storing, so bogus profile posts with invalid +/// signatures never enter storage and can't be re-propagated. +pub fn verify_profile_post(post: &Post) -> anyhow::Result { + let content: ProfilePostContent = serde_json::from_str(&post.content) + .map_err(|e| anyhow::anyhow!("profile post content is not a valid ProfilePostContent: {}", e))?; + if !crypto::verify_profile( + &post.author, + &content.display_name, + &content.bio, + &content.avatar_cid, + content.timestamp_ms, + &content.signature, + ) { + anyhow::bail!("invalid profile-post signature"); + } + Ok(content) +} + +/// If the post is a Profile post, verify + apply by upserting the +/// `profiles` row keyed by the post's author (= posting identity). Only +/// applied if newer than the existing row's `updated_at`. +pub fn apply_profile_post_if_applicable( + s: &Storage, + post: &Post, + intent: Option<&VisibilityIntent>, +) -> anyhow::Result<()> { + if !matches!(intent, Some(VisibilityIntent::Profile)) { + return Ok(()); + } + let content = verify_profile_post(post)?; + + // Only apply if newer than the stored row (last-writer-wins by timestamp). + if let Some(existing) = s.get_profile(&post.author)? { + if existing.updated_at >= content.timestamp_ms { + return Ok(()); + } + } + + let profile = PublicProfile { + node_id: post.author, + display_name: content.display_name, + bio: content.bio, + updated_at: content.timestamp_ms, + anchors: vec![], + recent_peers: vec![], + preferred_peers: vec![], + public_visible: true, + avatar_cid: content.avatar_cid, + }; + s.store_profile(&profile)?; + Ok(()) +} + +/// Build a Profile post signed by the posting identity. Caller is +/// responsible for storing and propagating it. +pub fn build_profile_post( + author: &NodeId, + author_secret: &[u8; 32], + display_name: &str, + bio: &str, + avatar_cid: Option<[u8; 32]>, +) -> Post { + let timestamp_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let signature = crypto::sign_profile(author_secret, display_name, bio, &avatar_cid, timestamp_ms); + let content = ProfilePostContent { + display_name: display_name.to_string(), + bio: bio.to_string(), + avatar_cid, + timestamp_ms, + signature, + }; + Post { + author: *author, + content: serde_json::to_string(&content).unwrap_or_default(), + attachments: vec![], + timestamp_ms, + } +} + +/// Profile-post visibility is always Public on the wire: the signature binds +/// the content to the posting identity and no recipient targeting is needed. +pub fn profile_post_visibility() -> PostVisibility { + PostVisibility::Public +} + +/// Compute the `PostId` for a freshly-built profile post. +pub fn profile_post_id(post: &Post) -> PostId { + crate::content::compute_post_id(post) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::Storage; + use ed25519_dalek::SigningKey; + + fn temp_storage() -> Storage { + Storage::open(":memory:").unwrap() + } + + fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) { + let seed = [seed_byte; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let public = signing_key.verifying_key(); + (seed, *public.as_bytes()) + } + + #[test] + fn profile_roundtrip_verifies_and_stores() { + let s = temp_storage(); + let (sec, pub_id) = make_keypair(11); + + let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None); + apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap(); + + let stored = s.get_profile(&pub_id).unwrap().expect("profile stored"); + assert_eq!(stored.display_name, "Alice"); + assert_eq!(stored.bio, "hello world"); + } + + #[test] + fn profile_rejects_wrong_author_signature() { + let s = temp_storage(); + let (_sec_a, pub_a) = make_keypair(1); + let (sec_b, _pub_b) = make_keypair(2); + + // Build a post claiming `pub_a` but signing with `sec_b`. + let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None); + let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)); + assert!(res.is_err()); + assert!(s.get_profile(&pub_a).unwrap().is_none()); + } + + #[test] + fn profile_ignores_older_timestamp() { + let s = temp_storage(); + let (sec, pub_id) = make_keypair(3); + + // Seed with a newer profile. + let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None); + // Hack the timestamp to make it clearly newer. + let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap(); + content.timestamp_ms = 10_000; + content.signature = crypto::sign_profile(&sec, &content.display_name, &content.bio, &content.avatar_cid, content.timestamp_ms); + newer.content = serde_json::to_string(&content).unwrap(); + newer.timestamp_ms = 10_000; + apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap(); + + // Apply an older profile — should be ignored. + let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None); + let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap(); + content_o.timestamp_ms = 5_000; + content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms); + older.content = serde_json::to_string(&content_o).unwrap(); + older.timestamp_ms = 5_000; + apply_profile_post_if_applicable(&s, &older, Some(&VisibilityIntent::Profile)).unwrap(); + + let stored = s.get_profile(&pub_id).unwrap().unwrap(); + assert_eq!(stored.display_name, "NewName"); + } +} diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 245d9fc..0221c3c 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -14,6 +14,11 @@ pub struct SyncPost { pub id: PostId, 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)] + pub intent: Option, } /// Message type byte for stream multiplexing @@ -27,10 +32,9 @@ pub enum MessageType { RefuseRedirect = 0x05, PullSyncRequest = 0x40, PullSyncResponse = 0x41, - PostNotification = 0x42, - PostPush = 0x43, - AudienceRequest = 0x44, - AudienceResponse = 0x45, + // 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, @@ -45,8 +49,9 @@ pub enum MessageType { ManifestRefreshRequest = 0x92, ManifestRefreshResponse = 0x93, ManifestPush = 0x94, - BlobDeleteNotice = 0x95, - GroupKeyDistribute = 0xA0, + // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. + // 0xA0 (GroupKeyDistribute) retired in v0.6.2 — group seeds now travel + // as encrypted posts via the CDN. See `group_key_distribution` module. GroupKeyRequest = 0xA1, GroupKeyResponse = 0xA2, RelayIntroduce = 0xB0, @@ -85,10 +90,6 @@ impl MessageType { 0x05 => Some(Self::RefuseRedirect), 0x40 => Some(Self::PullSyncRequest), 0x41 => Some(Self::PullSyncResponse), - 0x42 => Some(Self::PostNotification), - 0x43 => Some(Self::PostPush), - 0x44 => Some(Self::AudienceRequest), - 0x45 => Some(Self::AudienceResponse), 0x50 => Some(Self::ProfileUpdate), 0x51 => Some(Self::DeleteRecord), 0x52 => Some(Self::VisibilityUpdate), @@ -102,8 +103,6 @@ impl MessageType { 0x92 => Some(Self::ManifestRefreshRequest), 0x93 => Some(Self::ManifestRefreshResponse), 0x94 => Some(Self::ManifestPush), - 0x95 => Some(Self::BlobDeleteNotice), - 0xA0 => Some(Self::GroupKeyDistribute), 0xA1 => Some(Self::GroupKeyRequest), 0xA2 => Some(Self::GroupKeyResponse), 0xB0 => Some(Self::RelayIntroduce), @@ -237,32 +236,6 @@ pub struct VisibilityUpdatePayload { pub updates: Vec, } -/// Post notification: lightweight push when a new post is created -#[derive(Debug, Serialize, Deserialize)] -pub struct PostNotificationPayload { - pub post_id: PostId, - pub author: NodeId, -} - -/// Audience request: ask a peer to join their audience -#[derive(Debug, Serialize, Deserialize)] -pub struct AudienceRequestPayload { - pub requester: NodeId, -} - -/// Audience response: approve or deny an audience request -#[derive(Debug, Serialize, Deserialize)] -pub struct AudienceResponsePayload { - pub responder: NodeId, - pub approved: bool, -} - -/// Post push: full post content pushed directly to a recipient -#[derive(Debug, Serialize, Deserialize)] -pub struct PostPushPayload { - pub post: SyncPost, -} - /// Address resolution request (bi-stream: ask reporter for a hop-2 peer's address) #[derive(Debug, Serialize, Deserialize)] pub struct AddressRequestPayload { @@ -411,27 +384,11 @@ pub struct ManifestPushEntry { pub manifest: CdnManifest, } -/// Notify upstream/downstream that a blob has been deleted (uni-stream) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlobDeleteNoticePayload { - pub cid: [u8; 32], - /// If sender was upstream and is providing their own upstream for tree healing - #[serde(default)] - pub upstream_node: Option, -} - // --- Group key distribution payloads --- -/// Admin pushes wrapped group key to a member (uni-stream) -#[derive(Debug, Serialize, Deserialize)] -pub struct GroupKeyDistributePayload { - pub group_id: GroupId, - pub circle_name: String, - pub epoch: GroupEpoch, - pub group_public_key: [u8; 32], - pub admin: NodeId, - pub member_keys: Vec, -} +// GroupKeyDistributePayload (v0.6.1) retired: group seeds now travel as +// encrypted posts (`VisibilityIntent::GroupKeyDistribute`). See +// `crate::group_key_distribution` and `types::GroupKeyDistributionContent`. /// Member requests current group key (bi-stream request) #[derive(Debug, Serialize, Deserialize)] @@ -775,10 +732,6 @@ mod tests { MessageType::RefuseRedirect, MessageType::PullSyncRequest, MessageType::PullSyncResponse, - MessageType::PostNotification, - MessageType::PostPush, - MessageType::AudienceRequest, - MessageType::AudienceResponse, MessageType::ProfileUpdate, MessageType::DeleteRecord, MessageType::VisibilityUpdate, @@ -792,8 +745,6 @@ mod tests { MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, - MessageType::BlobDeleteNotice, - MessageType::GroupKeyDistribute, MessageType::GroupKeyRequest, MessageType::GroupKeyResponse, MessageType::RelayIntroduce, @@ -835,36 +786,6 @@ mod tests { assert!(MessageType::from_byte(0x06).is_none()); } - #[test] - fn blob_delete_notice_payload_roundtrip() { - use crate::types::PeerWithAddress; - - // Without upstream - let payload = BlobDeleteNoticePayload { - cid: [42u8; 32], - upstream_node: None, - }; - let json = serde_json::to_string(&payload).unwrap(); - let decoded: BlobDeleteNoticePayload = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.cid, [42u8; 32]); - assert!(decoded.upstream_node.is_none()); - - // With upstream - let payload_with_up = BlobDeleteNoticePayload { - cid: [99u8; 32], - upstream_node: Some(PeerWithAddress { - n: hex::encode([1u8; 32]), - a: vec!["10.0.0.1:4433".to_string()], - }), - }; - let json2 = serde_json::to_string(&payload_with_up).unwrap(); - let decoded2: BlobDeleteNoticePayload = serde_json::from_str(&json2).unwrap(); - assert_eq!(decoded2.cid, [99u8; 32]); - assert!(decoded2.upstream_node.is_some()); - let up = decoded2.upstream_node.unwrap(); - assert_eq!(up.a, vec!["10.0.0.1:4433".to_string()]); - } - #[test] fn relay_introduce_payload_roundtrip() { let payload = RelayIntroducePayload { diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index c68f094..171758c 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -4,7 +4,7 @@ use std::path::Path; use rusqlite::{params, Connection}; use crate::types::{ - Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile, + Attachment, Circle, CircleProfile, CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId, GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord, PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity, @@ -212,14 +212,8 @@ impl Storage { PRIMARY KEY (peer_id, neighbor_id) ); CREATE INDEX IF NOT EXISTS idx_peer_neighbors_neighbor ON peer_neighbors(neighbor_id); - CREATE TABLE IF NOT EXISTS audience ( - node_id BLOB NOT NULL, - direction TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - requested_at INTEGER NOT NULL, - approved_at INTEGER, - PRIMARY KEY (node_id, direction) - ); + -- v0.6.2: audience table removed. Upgraded DBs still have the + -- orphan table; it's untouched by new code. New DBs don't get it. CREATE TABLE IF NOT EXISTS worm_cooldowns ( target_id BLOB PRIMARY KEY, failed_at INTEGER NOT NULL @@ -289,9 +283,11 @@ impl Storage { group_public_key BLOB NOT NULL, group_seed BLOB, admin BLOB NOT NULL, - created_at INTEGER NOT NULL + created_at INTEGER NOT NULL, + canonical_root_post_id BLOB ); CREATE INDEX IF NOT EXISTS idx_group_keys_circle ON group_keys(circle_name); + CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id); CREATE TABLE IF NOT EXISTS group_member_keys ( group_id BLOB NOT NULL, member BLOB NOT NULL, @@ -353,6 +349,7 @@ impl Storage { content TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, signature BLOB NOT NULL, + ref_post_id BLOB, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); @@ -642,6 +639,30 @@ impl Storage { )?; } + // v0.6.2: add ref_post_id for rich comments (preview-inline, + // full-body-in-referenced-post). NULL for plain comments. + let has_ref_post_id = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='ref_post_id'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_ref_post_id == 0 { + self.conn.execute_batch( + "ALTER TABLE comments ADD COLUMN ref_post_id BLOB DEFAULT NULL;" + )?; + } + + // v0.6.2: add canonical_root_post_id to group_keys. When set, the + // record is a group (many-way, anchored at a public root post); + // when NULL it's a traditional circle (one-way, admin-only). + let has_canonical_root = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('group_keys') WHERE name='canonical_root_post_id'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_canonical_root == 0 { + self.conn.execute_batch( + "ALTER TABLE group_keys ADD COLUMN canonical_root_post_id BLOB DEFAULT NULL; + CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);" + )?; + } + // 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'" @@ -833,7 +854,9 @@ impl Storage { /// All posts, newest first (with visibility) pub fn list_posts_reverse_chron(&self) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC", + "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts + WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"')) + ORDER BY timestamp_ms DESC", )?; let rows = stmt.query_map([], |row| { let id_bytes: Vec = row.get(0)?; @@ -869,6 +892,7 @@ impl Storage { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id + WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"')) ORDER BY p.timestamp_ms DESC", )?; let rows = stmt.query_map([], |row| { @@ -905,10 +929,12 @@ impl Storage { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id WHERE p.timestamp_ms < ?1 + AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"')) ORDER BY p.timestamp_ms DESC LIMIT ?2" } else { "SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility FROM posts p INNER JOIN follows f ON p.author = f.node_id + WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"')) ORDER BY p.timestamp_ms DESC LIMIT ?2" }; let mut stmt = self.conn.prepare(sql)?; @@ -924,11 +950,15 @@ impl Storage { pub fn list_posts_page(&self, before_ms: Option, limit: usize) -> anyhow::Result> { let sql = if before_ms.is_some() { "SELECT id, author, content, attachments, timestamp_ms, visibility - FROM posts WHERE timestamp_ms < ?1 + FROM posts + WHERE timestamp_ms < ?1 + AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"')) ORDER BY timestamp_ms DESC LIMIT ?2" } else { "SELECT id, author, content, attachments, timestamp_ms, visibility - FROM posts ORDER BY timestamp_ms DESC LIMIT ?2" + FROM posts + WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"')) + ORDER BY timestamp_ms DESC LIMIT ?2" }; let mut stmt = self.conn.prepare(sql)?; let rows = if let Some(bms) = before_ms { @@ -1051,9 +1081,39 @@ impl Storage { Ok(posts) } - /// All posts with visibility (for sync protocol) + /// All posts with visibility (for sync protocol and export). + /// Includes control/profile posts — they need to propagate through the + /// CDN like any other post. pub fn list_posts_with_visibility(&self) -> anyhow::Result> { - self.list_posts_reverse_chron() + let mut stmt = self.conn.prepare( + "SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC", + )?; + let rows = stmt.query_map([], |row| { + let id_bytes: Vec = row.get(0)?; + let author_bytes: Vec = row.get(1)?; + let content: String = row.get(2)?; + let attachments_json: String = row.get(3)?; + let timestamp_ms: i64 = row.get(4)?; + let vis_json: String = row.get(5)?; + Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json)) + })?; + let mut posts = Vec::new(); + for row in rows { + let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?; + let attachments: Vec = serde_json::from_str(&attachments_json).unwrap_or_default(); + let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default(); + posts.push(( + blob_to_postid(id_bytes)?, + Post { + author: blob_to_nodeid(author_bytes)?, + content, + attachments, + timestamp_ms: timestamp_ms as u64, + }, + visibility, + )); + } + Ok(posts) } // ---- Follows ---- @@ -2104,7 +2164,7 @@ impl Storage { pub fn create_group_key(&self, record: &GroupKeyRecord, group_seed: Option<&[u8; 32]>) -> anyhow::Result<()> { self.conn.execute( - "INSERT OR REPLACE INTO group_keys (group_id, circle_name, epoch, group_public_key, group_seed, admin, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT OR REPLACE INTO group_keys (group_id, circle_name, epoch, group_public_key, group_seed, admin, created_at, canonical_root_post_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ record.group_id.as_slice(), record.circle_name, @@ -2113,14 +2173,40 @@ impl Storage { group_seed.map(|s| s.as_slice()), record.admin.as_slice(), record.created_at as i64, + record.canonical_root_post_id.as_ref().map(|r| r.as_slice()), ], )?; Ok(()) } + fn row_to_group_key( + gid: Vec, + circle_name: String, + epoch: i64, + gpk: Vec, + admin: Vec, + created_at: i64, + canonical_root: Option>, + ) -> anyhow::Result { + let canonical_root_post_id = match canonical_root { + Some(b) => Some(blob_to_postid(b)?), + None => None, + }; + Ok(GroupKeyRecord { + group_id: blob_to_nodeid(gid)?, + circle_name, + epoch: epoch as u64, + group_public_key: <[u8; 32]>::try_from(gpk.as_slice()) + .map_err(|_| anyhow::anyhow!("invalid group public key"))?, + admin: blob_to_nodeid(admin)?, + created_at: created_at as u64, + canonical_root_post_id, + }) + } + pub fn get_group_key(&self, group_id: &GroupId) -> anyhow::Result> { let result = self.conn.query_row( - "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at FROM group_keys WHERE group_id = ?1", + "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE group_id = ?1", params![group_id.as_slice()], |row| { let gid: Vec = row.get(0)?; @@ -2129,20 +2215,13 @@ impl Storage { let gpk: Vec = row.get(3)?; let admin: Vec = row.get(4)?; let created_at: i64 = row.get(5)?; - Ok((gid, circle_name, epoch, gpk, admin, created_at)) + let canonical_root: Option> = row.get(6)?; + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) }, ); match result { - Ok((gid, circle_name, epoch, gpk, admin, created_at)) => { - Ok(Some(GroupKeyRecord { - group_id: blob_to_nodeid(gid)?, - circle_name, - epoch: epoch as u64, - group_public_key: <[u8; 32]>::try_from(gpk.as_slice()) - .map_err(|_| anyhow::anyhow!("invalid group public key"))?, - admin: blob_to_nodeid(admin)?, - created_at: created_at as u64, - })) + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => { + Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?)) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), @@ -2151,7 +2230,7 @@ impl Storage { pub fn get_group_key_by_circle(&self, circle_name: &str) -> anyhow::Result> { let result = self.conn.query_row( - "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at FROM group_keys WHERE circle_name = ?1", + "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE circle_name = ?1", params![circle_name], |row| { let gid: Vec = row.get(0)?; @@ -2160,20 +2239,39 @@ impl Storage { let gpk: Vec = row.get(3)?; let admin: Vec = row.get(4)?; let created_at: i64 = row.get(5)?; - Ok((gid, circle_name, epoch, gpk, admin, created_at)) + let canonical_root: Option> = row.get(6)?; + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) }, ); match result { - Ok((gid, circle_name, epoch, gpk, admin, created_at)) => { - Ok(Some(GroupKeyRecord { - group_id: blob_to_nodeid(gid)?, - circle_name, - epoch: epoch as u64, - group_public_key: <[u8; 32]>::try_from(gpk.as_slice()) - .map_err(|_| anyhow::anyhow!("invalid group public key"))?, - admin: blob_to_nodeid(admin)?, - created_at: created_at as u64, - })) + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => { + Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?)) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + /// Look up a group by its canonical root post id. Returns None if the + /// record has no canonical_root_post_id (i.e. it's a circle). + pub fn get_group_by_canonical_root(&self, root_post_id: &PostId) -> anyhow::Result> { + let result = self.conn.query_row( + "SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE canonical_root_post_id = ?1", + params![root_post_id.as_slice()], + |row| { + let gid: Vec = row.get(0)?; + let circle_name: String = row.get(1)?; + let epoch: i64 = row.get(2)?; + let gpk: Vec = row.get(3)?; + let admin: Vec = row.get(4)?; + let created_at: i64 = row.get(5)?; + let canonical_root: Option> = row.get(6)?; + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) + }, + ); + match result { + Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => { + Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?)) } Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e.into()), @@ -2817,111 +2915,6 @@ impl Storage { Ok(count > 0) } - // ---- Audience ---- - - /// Store an audience relationship. - pub fn store_audience( - &self, - node_id: &NodeId, - direction: AudienceDirection, - status: AudienceStatus, - ) -> anyhow::Result<()> { - let now = now_ms(); - let dir_str = match direction { - AudienceDirection::Inbound => "inbound", - AudienceDirection::Outbound => "outbound", - }; - let status_str = match status { - AudienceStatus::Pending => "pending", - AudienceStatus::Approved => "approved", - AudienceStatus::Denied => "denied", - }; - let approved_at = if status == AudienceStatus::Approved { - Some(now) - } else { - None - }; - self.conn.execute( - "INSERT INTO audience (node_id, direction, status, requested_at, approved_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(node_id, direction) DO UPDATE SET - status = ?3, approved_at = COALESCE(?5, audience.approved_at)", - params![node_id.as_slice(), dir_str, status_str, now, approved_at], - )?; - Ok(()) - } - - /// Get audience members by direction and status. - pub fn list_audience( - &self, - direction: AudienceDirection, - status: Option, - ) -> anyhow::Result> { - let dir_str = match direction { - AudienceDirection::Inbound => "inbound", - AudienceDirection::Outbound => "outbound", - }; - let (query, bind_status) = match status { - Some(s) => { - let s_str = match s { - AudienceStatus::Pending => "pending", - AudienceStatus::Approved => "approved", - AudienceStatus::Denied => "denied", - }; - ( - "SELECT node_id, direction, status, requested_at, approved_at FROM audience WHERE direction = ?1 AND status = ?2", - Some(s_str), - ) - } - None => ( - "SELECT node_id, direction, status, requested_at, approved_at FROM audience WHERE direction = ?1", - None, - ), - }; - - let mut records = Vec::new(); - if let Some(status_str) = bind_status { - let mut stmt = self.conn.prepare(query)?; - let mut rows = stmt.query(params![dir_str, status_str])?; - while let Some(row) = rows.next()? { - records.push(row_to_audience_record(row)?); - } - } else { - let mut stmt = self.conn.prepare(query)?; - let mut rows = stmt.query(params![dir_str])?; - while let Some(row) = rows.next()? { - records.push(row_to_audience_record(row)?); - } - } - Ok(records) - } - - /// Get approved inbound audience members (nodes we push posts to). - pub fn list_audience_members(&self) -> anyhow::Result> { - let records = self.list_audience( - AudienceDirection::Inbound, - Some(AudienceStatus::Approved), - )?; - Ok(records.into_iter().map(|r| r.node_id).collect()) - } - - /// Remove an audience relationship. - pub fn remove_audience( - &self, - node_id: &NodeId, - direction: AudienceDirection, - ) -> anyhow::Result<()> { - let dir_str = match direction { - AudienceDirection::Inbound => "inbound", - AudienceDirection::Outbound => "outbound", - }; - self.conn.execute( - "DELETE FROM audience WHERE node_id = ?1 AND direction = ?2", - params![node_id.as_slice(), dir_str], - )?; - Ok(()) - } - // ---- Reach: N2/N3 ---- /// Replace a peer's entire N1 set in reachable_n2 (their N1 share → our N2). @@ -3568,32 +3561,18 @@ impl Storage { Ok(count > 0) } - /// Bulk-populate social_routes from follows + audience + peers. + /// Bulk-populate social_routes from follows + peers. /// Returns the number of routes created/updated. pub fn rebuild_social_routes(&self) -> anyhow::Result { let now = now_ms() as u64; let mut count = 0; - // Collect follows + // v0.6.2: audience removed; social routes are built purely from follows. let follows: std::collections::HashSet = self.list_follows()?.into_iter().collect(); - // Collect approved audience members (inbound = they are in our audience) - let audience_members: std::collections::HashSet = - self.list_audience_members()?.into_iter().collect(); - - // Union of all social contacts - let mut all_contacts: std::collections::HashSet = std::collections::HashSet::new(); - all_contacts.extend(&follows); - all_contacts.extend(&audience_members); - - for nid in all_contacts { - let relation = match (follows.contains(&nid), audience_members.contains(&nid)) { - (true, true) => SocialRelation::Mutual, - (true, false) => SocialRelation::Follow, - (false, true) => SocialRelation::Audience, - (false, false) => continue, - }; + for nid in follows { + let relation = SocialRelation::Follow; // Look up addresses from peers table let addresses: Vec = self @@ -4627,11 +4606,12 @@ impl Storage { /// deleted_at tombstone, store it so the tombstone propagates. pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> { self.conn.execute( - "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, - deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END", + deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, + ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id)", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -4639,6 +4619,7 @@ impl Storage { comment.timestamp_ms as i64, comment.signature, comment.deleted_at.map(|v| v as i64), + comment.ref_post_id.as_ref().map(|r| r.as_slice()), ], )?; Ok(()) @@ -4665,7 +4646,7 @@ impl Storage { /// Get live (non-tombstoned) comments for a post. Used for UI display. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature + "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" )?; let rows = stmt.query_map(params![post_id.as_slice()], |row| { @@ -4674,13 +4655,18 @@ impl Storage { let content: String = row.get(2)?; let ts: i64 = row.get(3)?; let sig: Vec = row.get(4)?; - Ok((author, pid, content, ts, sig)) + let ref_post: Option> = row.get(5)?; + Ok((author, pid, content, ts, sig, ref_post)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; + let ref_post_id = match ref_post { + Some(b) => Some(blob_to_postid(b)?), + None => None, + }; result.push(InlineComment { author, post_id, @@ -4688,6 +4674,7 @@ impl Storage { timestamp_ms: ts as u64, signature: sig, deleted_at: None, + ref_post_id, }); } Ok(result) @@ -4697,7 +4684,7 @@ impl Storage { /// so tombstones propagate through pull-based sync. pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, deleted_at + "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC" )?; let rows = stmt.query_map(params![post_id.as_slice()], |row| { @@ -4707,13 +4694,18 @@ impl Storage { let ts: i64 = row.get(3)?; let sig: Vec = row.get(4)?; let del: Option = row.get(5)?; - Ok((author, pid, content, ts, sig, del)) + let ref_post: Option> = row.get(6)?; + Ok((author, pid, content, ts, sig, del, ref_post)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, del) = row?; + let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; + let ref_post_id = match ref_post { + Some(b) => Some(blob_to_postid(b)?), + None => None, + }; result.push(InlineComment { author, post_id, @@ -4721,6 +4713,7 @@ impl Storage { timestamp_ms: ts as u64, signature: sig, deleted_at: del.map(|v| v as u64), + ref_post_id, }); } Ok(result) @@ -4861,30 +4854,6 @@ fn now_ms() -> i64 { .as_millis() as i64 } -fn row_to_audience_record(row: &rusqlite::Row) -> anyhow::Result { - let node_id = blob_to_nodeid(row.get(0)?)?; - let dir_str: String = row.get(1)?; - let status_str: String = row.get(2)?; - let requested_at = row.get::<_, i64>(3)? as u64; - let approved_at: Option = row.get(4)?; - let direction = match dir_str.as_str() { - "inbound" => AudienceDirection::Inbound, - _ => AudienceDirection::Outbound, - }; - let status = match status_str.as_str() { - "approved" => AudienceStatus::Approved, - "denied" => AudienceStatus::Denied, - _ => AudienceStatus::Pending, - }; - Ok(AudienceRecord { - node_id, - direction, - status, - requested_at, - approved_at: approved_at.map(|v| v as u64), - }) -} - fn row_to_peer_record(row: &rusqlite::Row) -> anyhow::Result { let node_id = blob_to_nodeid(row.get(0)?)?; let addrs_json: String = row.get(1)?; @@ -5243,30 +5212,7 @@ mod tests { assert_eq!(s.count_mesh_peers_by_kind(PeerSlotKind::Local).unwrap(), 0); } - #[test] - fn audience_crud() { - use crate::types::{AudienceDirection, AudienceStatus}; - let s = temp_storage(); - let nid = make_node_id(1); - - s.store_audience(&nid, AudienceDirection::Inbound, AudienceStatus::Pending).unwrap(); - let pending = s.list_audience(AudienceDirection::Inbound, Some(AudienceStatus::Pending)).unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].status, AudienceStatus::Pending); - - // Approve - s.store_audience(&nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap(); - let members = s.list_audience_members().unwrap(); - assert_eq!(members.len(), 1); - assert_eq!(members[0], nid); - - // Remove - s.remove_audience(&nid, AudienceDirection::Inbound).unwrap(); - let members = s.list_audience_members().unwrap(); - assert!(members.is_empty()); - } - - // ---- Social routes tests ---- +// ---- Social routes tests ---- #[test] fn social_route_crud() { @@ -5315,28 +5261,21 @@ mod tests { #[test] fn social_route_rebuild() { - use crate::types::{AudienceDirection, AudienceStatus, SocialRelation}; + use crate::types::SocialRelation; let s = temp_storage(); - let follow_nid = make_node_id(1); - let audience_nid = make_node_id(2); - let mutual_nid = make_node_id(3); + let follow_a = make_node_id(1); + let follow_b = make_node_id(2); - s.add_follow(&follow_nid).unwrap(); - s.add_follow(&mutual_nid).unwrap(); - s.store_audience(&audience_nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap(); - s.store_audience(&mutual_nid, AudienceDirection::Inbound, AudienceStatus::Approved).unwrap(); + s.add_follow(&follow_a).unwrap(); + s.add_follow(&follow_b).unwrap(); let count = s.rebuild_social_routes().unwrap(); - assert_eq!(count, 3); + assert_eq!(count, 2); - let follow_route = s.get_social_route(&follow_nid).unwrap().unwrap(); - assert_eq!(follow_route.relation, SocialRelation::Follow); - - let audience_route = s.get_social_route(&audience_nid).unwrap().unwrap(); - assert_eq!(audience_route.relation, SocialRelation::Audience); - - let mutual_route = s.get_social_route(&mutual_nid).unwrap().unwrap(); - assert_eq!(mutual_route.relation, SocialRelation::Mutual); + let route_a = s.get_social_route(&follow_a).unwrap().unwrap(); + assert_eq!(route_a.relation, SocialRelation::Follow); + let route_b = s.get_social_route(&follow_b).unwrap().unwrap(); + assert_eq!(route_b.relation, SocialRelation::Follow); } #[test] @@ -5564,6 +5503,7 @@ mod tests { group_public_key: pubkey, admin, created_at: 1000, + canonical_root_post_id: None, }; s.create_group_key(&record, Some(&seed)).unwrap(); @@ -5621,6 +5561,49 @@ mod tests { assert!(s.get_group_seed(&group_id, 1).unwrap().is_none()); } + #[test] + fn group_lookup_by_canonical_root() { + let s = temp_storage(); + let admin = make_node_id(1); + let group_id = [43u8; 32]; + let pubkey = [100u8; 32]; + let root = make_post_id(99); + + let record = crate::types::GroupKeyRecord { + group_id, + circle_name: "group:test".to_string(), + epoch: 1, + group_public_key: pubkey, + admin, + created_at: 1000, + canonical_root_post_id: Some(root), + }; + s.create_group_key(&record, None).unwrap(); + + // Lookup by root returns the group. + let got = s.get_group_by_canonical_root(&root).unwrap().unwrap(); + assert_eq!(got.group_id, group_id); + assert_eq!(got.canonical_root_post_id, Some(root)); + + // A different root returns None. + let other = make_post_id(7); + assert!(s.get_group_by_canonical_root(&other).unwrap().is_none()); + + // A circle (no canonical_root) is not returned when looking up by root. + let circle_record = crate::types::GroupKeyRecord { + group_id: [44u8; 32], + circle_name: "friends".to_string(), + epoch: 1, + group_public_key: [101u8; 32], + admin, + created_at: 1000, + canonical_root_post_id: None, + }; + s.create_group_key(&circle_record, None).unwrap(); + // The circle has no root, so it's invisible to the root lookup. + assert!(s.get_group_by_canonical_root(&make_post_id(0)).unwrap().is_none()); + } + #[test] fn group_seeds_map() { let s = temp_storage(); @@ -5636,6 +5619,7 @@ mod tests { group_public_key: pubkey, admin, created_at: 1000, + canonical_root_post_id: None, }; s.create_group_key(&record, Some(&seed)).unwrap(); s.store_group_seed(&group_id, 1, &seed).unwrap(); @@ -6185,6 +6169,7 @@ mod tests { timestamp_ms: 1000, signature: vec![0u8; 64], deleted_at: None, + ref_post_id: None, }).unwrap(); s.store_comment(&InlineComment { @@ -6194,6 +6179,7 @@ mod tests { timestamp_ms: 1001, signature: vec![1u8; 64], deleted_at: None, + ref_post_id: None, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -6203,6 +6189,33 @@ mod tests { assert_eq!(s.get_comment_count(&post_id).unwrap(), 2); } + #[test] + fn rich_comment_ref_post_id_roundtrip() { + use crate::types::InlineComment; + let s = temp_storage(); + let post_id = make_post_id(1); + let author = make_node_id(5); + let ref_post = make_post_id(42); + + s.store_comment(&InlineComment { + author, + post_id, + content: "(preview of a long body)".to_string(), + timestamp_ms: 2000, + signature: vec![9u8; 64], + deleted_at: None, + ref_post_id: Some(ref_post), + }).unwrap(); + + let live = s.get_comments(&post_id).unwrap(); + assert_eq!(live.len(), 1); + assert_eq!(live[0].ref_post_id, Some(ref_post)); + + let all = s.get_comments_with_tombstones(&post_id).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].ref_post_id, Some(ref_post)); + } + #[test] fn comment_policy_crud() { use crate::types::{CommentPermission, CommentPolicy, ModerationMode, ReactPermission}; @@ -6213,7 +6226,7 @@ mod tests { assert!(s.get_comment_policy(&post_id).unwrap().is_none()); let policy = CommentPolicy { - allow_comments: CommentPermission::AudienceOnly, + allow_comments: CommentPermission::FollowersOnly, allow_reacts: ReactPermission::Public, moderation: ModerationMode::AuthorBlocklist, blocklist: vec![make_node_id(99)], @@ -6221,7 +6234,7 @@ mod tests { s.set_comment_policy(&post_id, &policy).unwrap(); let loaded = s.get_comment_policy(&post_id).unwrap().unwrap(); - assert_eq!(loaded.allow_comments, CommentPermission::AudienceOnly); + assert_eq!(loaded.allow_comments, CommentPermission::FollowersOnly); assert_eq!(loaded.allow_reacts, ReactPermission::Public); assert_eq!(loaded.blocklist.len(), 1); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a6598d3..ece69f4 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -157,42 +157,6 @@ pub struct WormResult { pub blob_holder: Option, } -/// Audience relationship direction -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AudienceDirection { - /// They are in our audience (we push to them) - Inbound, - /// We are in their audience (they push to us) - Outbound, -} - -/// Audience membership status -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AudienceStatus { - Pending, - Approved, - Denied, -} - -/// An audience membership record -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AudienceRecord { - pub node_id: NodeId, - pub direction: AudienceDirection, - pub status: AudienceStatus, - pub requested_at: u64, - pub approved_at: Option, -} - -/// Audience approval mode setting -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AudienceApprovalMode { - /// Auto-accept all audience join requests - PublicApprove, - /// Queue requests for manual review - ApprovalQueue, -} - // --- Encryption / Circles --- /// Circle name (unique per node) @@ -213,7 +177,13 @@ pub struct GroupMemberKey { pub wrapped_group_key: Vec, } -/// A group key record (circle ↔ group key binding) +/// A group key record (circle ↔ group key binding). +/// +/// v0.6.2: `canonical_root_post_id` distinguishes **groups** (many-way, +/// anchored at a public root post; any member can post) from **circles** +/// (one-way, admin-only, `None`). The encryption primitives are identical; +/// the flag is a UX + query hint so UIs can cluster group posts under +/// their root. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GroupKeyRecord { pub group_id: GroupId, @@ -222,6 +192,10 @@ pub struct GroupKeyRecord { pub group_public_key: [u8; 32], pub admin: NodeId, pub created_at: u64, + /// When set, this record represents a group rooted at the given public + /// post. When `None`, the record is a traditional circle. + #[serde(default)] + pub canonical_root_post_id: Option, } /// Visibility of a post — separate from Post struct so it doesn't affect PostId @@ -251,7 +225,15 @@ pub struct WrappedKey { pub wrapped_cek: Vec, } -/// User-facing intent for post visibility (resolved to recipients before encryption) +/// User-facing intent for post visibility (resolved to recipients before encryption). +/// +/// A few variants exist for structural distinctions rather than visibility: +/// - `Control` — the post carries a signed operation (delete / visibility +/// update) that receivers apply. Wire visibility is Public; the post is +/// filtered out of feeds and rendered nowhere. +/// - `Profile` — the post carries persona display metadata (display_name, +/// bio, avatar). Wire visibility is Public; the post is not shown in the +/// feed but consulted when rendering the author's name on other posts. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum VisibilityIntent { Public, @@ -261,6 +243,74 @@ pub enum VisibilityIntent { Circle(String), /// Specific recipients Direct(Vec), + /// Protocol-control post (delete / visibility change). + Control, + /// Persona profile post (display_name, bio, avatar). + Profile, + /// Encrypted distribution of a group/circle seed to that group's + /// members. Replaces the v0.6.1 `GroupKeyDistribute` wire push with a + /// standard encrypted post that propagates via the CDN. Members + /// decrypt with their posting secret to recover the seed. + GroupKeyDistribute, +} + +/// Content payload of a `VisibilityIntent::Profile` post — persona display +/// metadata (display_name, bio, avatar_cid) signed by the posting identity. +/// The post's `author` IS the posting identity; `signature` is an ed25519 +/// signature by that identity's secret over the fields (see `crypto::sign_profile`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfilePostContent { + pub display_name: String, + #[serde(default)] + pub bio: String, + #[serde(default)] + pub avatar_cid: Option<[u8; 32]>, + pub timestamp_ms: u64, + /// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte + /// layout signed by the posting identity. + pub signature: Vec, +} + +/// Content payload of a `VisibilityIntent::GroupKeyDistribute` post. +/// Wrapped inside a standard `PostVisibility::Encrypted` envelope — members +/// decrypt via `crypto::decrypt_post` with their posting secret, then parse +/// this struct to recover the group seed and metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupKeyDistributionContent { + pub group_id: GroupId, + pub circle_name: String, + pub epoch: GroupEpoch, + pub group_public_key: [u8; 32], + pub admin: NodeId, + #[serde(default)] + pub canonical_root_post_id: Option, + /// The raw group seed (32 bytes). This is the sensitive field — its + /// confidentiality is protected by the enclosing encrypted post, which + /// is wrapped to each member's posting public key. + pub group_seed: [u8; 32], +} + +/// Content payload of a `VisibilityIntent::Control` post, serialized as JSON +/// into the post's content field. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum ControlOp { + /// Delete post `post_id`. Signature is over `b"ctrl:delete:" || post_id + /// || timestamp_ms (LE)`, by the target post's author. + DeletePost { + post_id: PostId, + timestamp_ms: u64, + /// 64-byte ed25519 signature + signature: Vec, + }, + /// Update post `post_id` visibility. Signature is over + /// `b"ctrl:vis:" || post_id || canonical(new_visibility) || timestamp_ms (LE)`. + UpdateVisibility { + post_id: PostId, + new_visibility: PostVisibility, + timestamp_ms: u64, + signature: Vec, + }, } /// A named group of recipients @@ -612,20 +662,17 @@ impl std::str::FromStr for ReachMethod { } } -/// Social relationship type +/// Social relationship type. v0.6.2: audience removed; only `Follow` remains. +/// Kept as an enum for forward compatibility (future persona-level relations). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SocialRelation { Follow, - Audience, - Mutual, } impl std::fmt::Display for SocialRelation { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SocialRelation::Follow => write!(f, "follow"), - SocialRelation::Audience => write!(f, "audience"), - SocialRelation::Mutual => write!(f, "mutual"), } } } @@ -635,8 +682,8 @@ impl std::str::FromStr for SocialRelation { fn from_str(s: &str) -> Result { match s { "follow" => Ok(SocialRelation::Follow), - "audience" => Ok(SocialRelation::Audience), - "mutual" => Ok(SocialRelation::Mutual), + // Legacy DB values from v0.6.1 and earlier — map to Follow. + "audience" | "mutual" => Ok(SocialRelation::Follow), _ => Err(anyhow::anyhow!("unknown social relation: {}", s)), } } @@ -764,22 +811,36 @@ pub struct Reaction { pub signature: Vec, } -/// An inline comment on a post +/// An inline comment on a post. +/// +/// v0.6.2 adds `ref_post_id`: when present, `content` is a short preview +/// string and the full comment body (long text, attachments, rich formatting) +/// lives in a separate referenced Post authored by the commenter. Clients +/// pull the referenced post lazily when rendering the expanded view. +/// When `ref_post_id` is `None`, `content` is the complete comment text +/// (the v0.6.1 shape). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InlineComment { /// Comment author pub author: NodeId, /// Which post this comment is on pub post_id: PostId, - /// Comment text + /// Either the full comment text (short comments) or a short preview of + /// the referenced post (when `ref_post_id` is set). pub content: String, /// When the comment was created (ms) pub timestamp_ms: u64, - /// ed25519 signature over BLAKE3(author || post_id || content || timestamp_ms) + /// ed25519 signature. Binds author/post_id/content/timestamp_ms, plus + /// `ref_post_id` when present. See `crypto::sign_comment`. pub signature: Vec, /// Tombstone timestamp — if set, this comment has been soft-deleted #[serde(default)] pub deleted_at: Option, + /// Optional reference to a full-content Post (long body + attachments). + /// When set, `content` is a preview; readers fetch the referenced post + /// for the expanded view. + #[serde(default)] + pub ref_post_id: Option, } /// Permission level for comments on a post @@ -787,8 +848,9 @@ pub struct InlineComment { pub enum CommentPermission { /// Anyone can comment Public, - /// Only people in author's audience can comment - AudienceOnly, + /// Only people the author follows publicly can comment. + /// Renamed from `AudienceOnly` in v0.6.2 when audience was removed. + FollowersOnly, /// Comments disabled None, } @@ -823,8 +885,9 @@ impl Default for ReactPermission { pub enum ModerationMode { /// Author maintains a blocklist of users AuthorBlocklist, - /// Only audience members can engage - AudienceOnly, + /// Only people the author follows publicly can engage. + /// Renamed from `AudienceOnly` in v0.6.2. + FollowersOnly, } impl Default for ModerationMode { diff --git a/crates/tauri-app/Cargo.toml b/crates/tauri-app/Cargo.toml index 87f1f4d..17975ed 100644 --- a/crates/tauri-app/Cargo.toml +++ b/crates/tauri-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "itsgoin-desktop" -version = "0.6.1" +version = "0.6.2" edition = "2021" [lib] diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 142907e..7f2368d 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -238,6 +238,9 @@ async fn post_to_dto( VisibilityIntent::Friends => "friends".to_string(), VisibilityIntent::Circle(_) => "circle".to_string(), VisibilityIntent::Direct(_) => "direct".to_string(), + VisibilityIntent::Control => "control".to_string(), + VisibilityIntent::Profile => "profile".to_string(), + VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(), }, _ => "unknown".to_string(), } @@ -1354,111 +1357,6 @@ async fn list_known_anchors(state: State<'_, AppNode>) -> Result, - direction: String, - status: String, - requested_at: u64, - approved_at: Option, -} - -#[tauri::command] -async fn list_audience(state: State<'_, AppNode>) -> Result, String> { - let node = get_node(&state).await; - let records = node - .list_audience( - itsgoin_core::types::AudienceDirection::Inbound, - None, - ) - .await - .map_err(|e| e.to_string())?; - let mut dtos = Vec::with_capacity(records.len()); - for r in &records { - let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None); - let direction = match r.direction { - itsgoin_core::types::AudienceDirection::Inbound => "inbound", - itsgoin_core::types::AudienceDirection::Outbound => "outbound", - }; - let status = match r.status { - itsgoin_core::types::AudienceStatus::Pending => "pending", - itsgoin_core::types::AudienceStatus::Approved => "approved", - itsgoin_core::types::AudienceStatus::Denied => "denied", - }; - dtos.push(AudienceDto { - node_id: hex::encode(r.node_id), - display_name, - direction: direction.to_string(), - status: status.to_string(), - requested_at: r.requested_at, - approved_at: r.approved_at, - }); - } - Ok(dtos) -} - -#[tauri::command] -async fn list_audience_outbound(state: State<'_, AppNode>) -> Result, String> { - let node = get_node(&state).await; - let records = node - .list_audience( - itsgoin_core::types::AudienceDirection::Outbound, - None, - ) - .await - .map_err(|e| e.to_string())?; - let mut dtos = Vec::with_capacity(records.len()); - for r in &records { - let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None); - let status = match r.status { - itsgoin_core::types::AudienceStatus::Pending => "pending", - itsgoin_core::types::AudienceStatus::Approved => "approved", - itsgoin_core::types::AudienceStatus::Denied => "denied", - }; - dtos.push(AudienceDto { - node_id: hex::encode(r.node_id), - display_name, - direction: "outbound".to_string(), - status: status.to_string(), - requested_at: r.requested_at, - approved_at: r.approved_at, - }); - } - Ok(dtos) -} - -#[tauri::command] -async fn request_audience( - state: State<'_, AppNode>, - node_id_hex: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let nid = parse_node_id(&node_id_hex)?; - node.request_audience(&nid).await.map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn approve_audience( - state: State<'_, AppNode>, - node_id_hex: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let nid = parse_node_id(&node_id_hex)?; - node.approve_audience(&nid).await.map_err(|e| e.to_string()) -} - -#[tauri::command] -async fn remove_audience( - state: State<'_, AppNode>, - node_id_hex: String, -) -> Result<(), String> { - let node = get_node(&state).await; - let nid = parse_node_id(&node_id_hex)?; - node.remove_audience(&nid).await.map_err(|e| e.to_string()) -} - #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct WormResultDto { @@ -2399,7 +2297,7 @@ async fn set_comment_policy( let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let comment_perm = match allow_comments.as_str() { - "audience_only" => itsgoin_core::types::CommentPermission::AudienceOnly, + "followers_only" | "audience_only" => itsgoin_core::types::CommentPermission::FollowersOnly, "none" => itsgoin_core::types::CommentPermission::None, _ => itsgoin_core::types::CommentPermission::Public, }; @@ -3009,11 +2907,6 @@ pub fn run() { set_anchors, list_anchor_peers, list_known_anchors, - list_audience, - list_audience_outbound, - request_audience, - approve_audience, - remove_audience, list_connections, worm_lookup, list_social_routes, diff --git a/crates/tauri-app/tauri.conf.json b/crates/tauri-app/tauri.conf.json index 4bf316a..19dc5b3 100644 --- a/crates/tauri-app/tauri.conf.json +++ b/crates/tauri-app/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "itsgoin", - "version": "0.6.1", + "version": "0.6.2", "identifier": "com.itsgoin.app", "build": { "frontendDist": "../../frontend", diff --git a/frontend/app.js b/frontend/app.js index 64b8aab..65ad834 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1422,14 +1422,8 @@ async function loadPeerBios(container) { async function loadFollows() { try { - const [follows, outbound, inbound] = await Promise.all([ - invoke('list_follows'), - invoke('list_audience_outbound'), - invoke('list_audience'), - ]); - const outboundSet = new Set(outbound.map(r => r.nodeId)); - const approvedSet = new Set(outbound.filter(r => r.status === 'approved').map(r => r.nodeId)); - const inboundApprovedSet = new Set(inbound.filter(r => r.status === 'approved').map(r => r.nodeId)); + // v0.6.2: audience removed. No more audience/mutual badges or request flow. + const follows = await invoke('list_follows'); // Filter out self before rendering const others = follows.filter(f => f.nodeId !== myNodeId); @@ -1443,34 +1437,21 @@ async function loadFollows() { const label = escapeHtml(peerLabel(f.nodeId, f.displayName)); const isSelf = f.nodeId === myNodeId; - let audienceBadge = ''; - let mutualBadge = ''; let lastSeenHtml = ''; let actions = ''; if (isSelf) { actions = '(you)'; } else { - if (inboundApprovedSet.has(f.nodeId)) { - mutualBadge = 'mutual'; - } - if (approvedSet.has(f.nodeId)) { - audienceBadge = 'audience'; - } else if (outboundSet.has(f.nodeId)) { - audienceBadge = 'requested'; - } if (!f.isOnline && f.lastActivityMs > 0) { lastSeenHtml = `Last online: ${formatTimeAgo(f.lastActivityMs)}`; } - const audienceBtn = !approvedSet.has(f.nodeId) && !outboundSet.has(f.nodeId) - ? `` - : ''; const syncBtn = ``; const msgBtn = ``; const unfollowBtn = ``; - actions = `${audienceBtn} ${syncBtn} ${msgBtn} ${unfollowBtn}`; + actions = `${syncBtn} ${msgBtn} ${unfollowBtn}`; } return `
-
${icon} ${label} ${mutualBadge} ${audienceBadge}
+
${icon} ${label}
${lastSeenHtml ? `
${lastSeenHtml}
` : ''}
${actions}
@@ -1562,22 +1543,6 @@ async function loadFollows() { }); }); - // Attach audience request handlers - followsList.querySelectorAll('.request-audience-btn').forEach(btn => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - await invoke('request_audience', { nodeIdHex: btn.dataset.nodeId }); - toast('Audience request sent!'); - loadFollows(); - } catch (e) { - toast('Error: ' + e); - } finally { - btn.disabled = false; - } - }); - }); - // Lazy-load bios loadPeerBios(followsList); } @@ -1702,81 +1667,13 @@ async function loadRedundancy() { } } -// --- Audience management --- +// v0.6.2: audience removed. loadAudience is a no-op kept so existing call +// sites don't break; DOM panels (if still in markup) are hidden. async function loadAudience() { - try { - const records = await invoke('list_audience'); - const pending = records.filter(r => r.status === 'pending'); - const approved = records.filter(r => r.status === 'approved'); - - if (pending.length === 0) { - audiencePendingList.innerHTML = '

No pending requests

'; - } else { - audiencePendingList.innerHTML = pending.map(r => { - const label = escapeHtml(peerLabel(r.nodeId, r.displayName)); - const icon = generateIdenticon(r.nodeId, 18); - return `
-
${icon} ${label}
-
${relativeTime(r.requestedAt)}
-
- - -
-
`; - }).join(''); - - audiencePendingList.querySelectorAll('.approve-audience-btn').forEach(btn => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - await invoke('approve_audience', { nodeIdHex: btn.dataset.nodeId }); - toast('Audience approved'); - loadAudience(); - } catch (e) { toast('Error: ' + e); } - }); - }); - audiencePendingList.querySelectorAll('.deny-audience-btn').forEach(btn => { - btn.addEventListener('click', async () => { - btn.disabled = true; - try { - await invoke('remove_audience', { nodeIdHex: btn.dataset.nodeId }); - toast('Audience denied'); - loadAudience(); - } catch (e) { toast('Error: ' + e); } - }); - }); - } - - if (approved.length === 0) { - audienceApprovedList.innerHTML = '

No approved audience members

'; - } else { - audienceApprovedList.innerHTML = approved.map(r => { - const label = escapeHtml(peerLabel(r.nodeId, r.displayName)); - const icon = generateIdenticon(r.nodeId, 18); - return `
-
${icon} ${label}
-
Approved ${r.approvedAt ? relativeTime(r.approvedAt) : ''}
-
- -
-
`; - }).join(''); - - audienceApprovedList.querySelectorAll('.remove-audience-btn').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Remove this audience member?')) return; - btn.disabled = true; - try { - await invoke('remove_audience', { nodeIdHex: btn.dataset.nodeId }); - toast('Audience member removed'); - loadAudience(); - } catch (e) { toast('Error: ' + e); } - }); - }); - } - } catch (e) { - audiencePendingList.innerHTML = `

Error: ${e}

`; - } + if (audiencePendingList) audiencePendingList.style.display = 'none'; + if (audienceApprovedList) audienceApprovedList.style.display = 'none'; + const headings = document.querySelectorAll('.audience-section, #audience-section'); + headings.forEach(el => { el.style.display = 'none'; }); } // --- Network diagnostics --- diff --git a/frontend/index.html b/frontend/index.html index aede0b9..8abc84b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -96,7 +96,7 @@