From 4da6a8dc85325ba17a66923afdb7eb22fc803061 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 22 Apr 2026 20:29:47 -0400 Subject: [PATCH 01/10] Fix merged-pull query: include posting identities, not network id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 merged-pull was shipped when network_id == posting_id (pre- v0.6.1), so adding our_node_id to the pull query's follows list was enough to trigger recipient-match on DMs. v0.6.1 broke that: - Fresh installs generate independent network + posting keys; DMs are encrypted to posting id, but the pull query carried network id. Recipient-match never fired. Non-follower DMs never reached. - Upgraders rotated the network key; DMs addressed to the old key (now the default posting id) never matched either. Fix: pull-request builders now include every entry from list_posting_identities() in query_list. Network id is omitted — it is never an author and never a wrapped_key.recipient, so adding it would only leak the boundary without ever matching. Four call sites fixed: Network::pull_from_peer, and ConnectionManager::{pull_from_peer, pull_from_peer_unlocked, and the post-notification-triggered pull path}. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/core/src/connection.rs | 43 +++++++++++++++++++++++------------ crates/core/src/network.rs | 14 ++++++++---- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 1724055..61a3607 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1346,18 +1346,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 { @@ -1911,18 +1918,21 @@ impl ConnectionManager { }, }; - 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 (mut send, mut recv) = pull_conn.open_bi().await?; @@ -2009,18 +2019,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 { diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index cf87b52..8bdd73f 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -1713,17 +1713,23 @@ impl Network { /// Pull posts from a peer (persistent if available, ephemeral otherwise). pub async fn pull_from_peer(&self, peer_id: &NodeId) -> anyhow::Result { let conn = self.get_connection(peer_id).await?; - let (our_follows, follows_sync) = { + 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( From 36b6a466d213ee5c99f8f9748ebaa58efe62c7b5 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 22 Apr 2026 21:17:34 -0400 Subject: [PATCH 02/10] Phase 2b: control-post flow (delete/visibility) + remove BlobDeleteNotice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces two persona-signed direct pushes with CDN-propagated control posts: a single `VisibilityIntent::Control` post type whose content is a signed `ControlOp` the receiver verifies and applies. Deletes and visibility updates now flow through the same neighbor-manifest CDN path as regular content — no direct recipient push needed for persona-signed ops. Core pieces: - `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants. - `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed by the target post's author over op-specific byte strings). - `crypto::{sign,verify}_control_{delete,visibility}` signing primitives. - `control::build_delete_control_post` + `build_visibility_control_post` for authors to construct control posts. - `control::receive_post` — unified incoming-post path used by all 6 receive sites. Verifies control signatures BEFORE storing, so bogus controls never enter storage and can't be re-propagated via neighbor-manifest diffs. - `control::apply_control_post_if_applicable` — executes the op under the same storage guard as the insert. Feed filter: - Feeds (`get_feed`, `get_feed_page`, `list_posts_page`, `list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so they propagate + tombstone without surfacing. - Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered query so control posts still propagate via CDN. Wire protocol: - `SyncPost` carries `intent: Option` so control posts arrive with their intent preserved. - `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict naturally via LRU rather than via a persona-signed push. Code path, payload, sender, tests, and `delete_blob_with_cdn_notify` all gone. Tests: control delete roundtrip (apply + tombstone) and wrong-author rejection (not stored, not applied). 112/112 core tests pass. --- crates/core/src/connection.rs | 153 +++++++++++---------- crates/core/src/control.rs | 247 ++++++++++++++++++++++++++++++++++ crates/core/src/crypto.rs | 72 ++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/network.rs | 25 +--- crates/core/src/node.rs | 147 ++++++++++++-------- crates/core/src/protocol.rs | 48 +------ crates/core/src/storage.rs | 49 ++++++- crates/core/src/types.rs | 37 ++++- crates/tauri-app/src/lib.rs | 2 + 10 files changed, 585 insertions(+), 196 deletions(-) create mode 100644 crates/core/src/control.rs diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index 61a3607..9d9cc79 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -1395,11 +1395,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); } } } @@ -1961,11 +1969,17 @@ impl ConnectionManager { 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; + match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { + Ok(_) => { + new_post_ids.push(sp.id); + synced_authors.insert(sp.post.author); + if sp.id == notification.post_id { + stored = true; + } + } + Err(e) => { + warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); + } } } } @@ -2069,11 +2083,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); } } } @@ -2294,12 +2316,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) }; @@ -4943,24 +4968,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 = @@ -5014,22 +5026,30 @@ impl ConnectionManager { && 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( + match crate::control::receive_post( + &storage, &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" - ); + push.post.intent.as_ref(), + ) { + Ok(_) => { + 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" + ); + } + Err(e) => { + warn!(post_id = hex::encode(push.post.id), error = %e, "rejecting pushed post"); + } + } } } } @@ -5237,7 +5257,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, @@ -5327,24 +5354,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; @@ -5675,11 +5684,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 } } @@ -6195,7 +6206,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, diff --git a/crates/core/src/control.rs b/crates/core/src/control.rs new file mode 100644 index 0000000..ec68dcc --- /dev/null +++ b/crates/core/src/control.rs @@ -0,0 +1,247 @@ +//! 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 { + if matches!(intent, Some(VisibilityIntent::Control)) { + // Verify the ControlOp signature before storing. A bogus control post + // with an invalid signature should never enter storage. + 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"); + } + } + } + } + + 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)?; + } + 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..d94c945 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -289,6 +289,78 @@ 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() +} + /// 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 { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0948e66..65d82ca 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod activity; pub mod blob; pub mod connection; pub mod content; +pub mod control; pub mod crypto; pub mod http; pub mod export; diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 8bdd73f..5b0e9bd 100644 --- a/crates/core/src/network.rs +++ b/crates/core/src/network.rs @@ -1049,29 +1049,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, @@ -1136,6 +1114,7 @@ impl Network { id: *post_id, post: post.clone(), visibility: visibility.clone(), + intent: None, // PostPush is only for public posts; no intent carried }, }; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index db40498..aa423df 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -1074,6 +1074,9 @@ impl Node { VisibilityIntent::Friends => storage.list_public_follows(), VisibilityIntent::Circle(name) => storage.get_circle_members(name), VisibilityIntent::Direct(ids) => Ok(ids.clone()), + // Control and Profile posts are always Public on the wire; they + // never go through encryption recipient resolution. + VisibilityIntent::Control | VisibilityIntent::Profile => Ok(vec![]), } } @@ -2155,39 +2158,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 +2198,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 +2288,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 => { @@ -3477,25 +3521,14 @@ impl Node { compute_blob_priority_standalone(candidate, &self.node_id, follows, audience_members, 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(()) @@ -3542,7 +3575,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; } diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 245d9fc..8d84cd9 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 @@ -45,7 +50,7 @@ pub enum MessageType { ManifestRefreshRequest = 0x92, ManifestRefreshResponse = 0x93, ManifestPush = 0x94, - BlobDeleteNotice = 0x95, + // 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU. GroupKeyDistribute = 0xA0, GroupKeyRequest = 0xA1, GroupKeyResponse = 0xA2, @@ -102,7 +107,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), @@ -411,15 +415,6 @@ 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) @@ -792,7 +787,6 @@ mod tests { MessageType::ManifestRefreshRequest, MessageType::ManifestRefreshResponse, MessageType::ManifestPush, - MessageType::BlobDeleteNotice, MessageType::GroupKeyDistribute, MessageType::GroupKeyRequest, MessageType::GroupKeyResponse, @@ -835,36 +829,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..68c3f02 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -833,7 +833,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 +871,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 +908,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 +929,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 +1060,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 ---- diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a6598d3..49d8533 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -251,7 +251,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 +269,33 @@ pub enum VisibilityIntent { Circle(String), /// Specific recipients Direct(Vec), + /// Protocol-control post (delete / visibility change). + Control, + /// Persona profile post (display_name, bio, avatar). + Profile, +} + +/// 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 diff --git a/crates/tauri-app/src/lib.rs b/crates/tauri-app/src/lib.rs index 142907e..f9b8984 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -238,6 +238,8 @@ 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(), }, _ => "unknown".to_string(), } From eabdb7ba4f04ec4f914afb3bb0ef8280a1438ec7 Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 22 Apr 2026 22:20:02 -0400 Subject: [PATCH 03/10] Phase 2c: remove audience + PostPush + PostNotification + AudienceRequest/Response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts propagate only through the CDN (pull + header-diff neighbor propagation). Encrypted posts propagate only through pull with merged author-or-recipient match. There is no remaining sender→recipient traffic correlation signal on the wire for content. Protocol (network-breaking): - Retire MessageType 0x42 (PostNotification), 0x43 (PostPush), 0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are deleted along with the handlers and senders. - SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender functions targeting audience are deleted; the existing handlers stay (both already dead code on the send side). Core removals: - `push_to_audience`, `notify_post`, `push_delete`, `push_disconnect_to_audience`, `push_address_update_to_audience`, `send_audience_request`, `send_audience_response`, `send_to_audience` — all gone from network.rs. - `handle_post_notification` removed from connection.rs. - `request_audience`, `approve_audience`, `deny_audience`, `remove_audience`, `list_audience_members`, `list_audience` removed from Node. - `audience_pushed` step removed from post creation. - `AudienceDirection`, `AudienceStatus`, `AudienceRecord`, `AudienceApprovalMode` removed from types. - Storage: `store_audience`, `list_audience`, `list_audience_members`, `remove_audience`, `row_to_audience_record`, `audience_crud` test, the `audience` CREATE TABLE, and the audience-dependent social route rebuild branch all removed. Upgraded DBs retain the orphan `audience` table; nothing touches it. Follow-on cleanups: - `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`. The Display/FromStr impl accepts legacy "audience"/"mutual" strings from pre-v0.6.2 DBs and maps them to Follow. - Blob-eviction priority function drops the audience factor; relationship is now own-author vs followed vs other. Tests updated accordingly. - `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the author's public follows (`list_public_follows`) rather than a separate audience table. `ModerationMode::AudienceOnly` similarly renamed. - Follow/unfollow routines simplified: no audience downgrade logic; unfollow removes the social route entirely. UI: - CLI: `audience*` commands removed. - Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`, `request_audience`, `approve_audience`, `remove_audience` commands removed from invoke_handler. Frontend: audience panel and audience/mutual badges removed; compose permission dropdown shows "Followers" instead of "Audience"; `loadAudience` is a no-op stub that hides any leftover DOM. Tests: 111 / 111 core tests pass. Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete propagation, visibility updates, direct post push, post notifications, or audience requests. Upgrade both ends. --- crates/cli/src/main.rs | 90 ------------ crates/core/src/connection.rs | 260 ++-------------------------------- crates/core/src/network.rs | 128 ++--------------- crates/core/src/node.rs | 195 +++++-------------------- crates/core/src/protocol.rs | 41 +----- crates/core/src/storage.rs | 219 +++------------------------- crates/core/src/types.rs | 57 ++------ crates/tauri-app/src/lib.rs | 112 +-------------- frontend/app.js | 123 ++-------------- frontend/index.html | 13 +- 10 files changed, 98 insertions(+), 1140 deletions(-) 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/src/connection.rs b/crates/core/src/connection.rs index 9d9cc79..f764b1a 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, GroupKeyResponsePayload, InitialExchangePayload, MeshPreferPayload, MessageType, NodeListUpdatePayload, PostDownstreamRegisterPayload, - PostNotificationPayload, PostPushPayload, ProfileUpdatePayload, PullSyncRequestPayload, PullSyncResponsePayload, RefuseRedirectPayload, RelayIntroducePayload, RelayIntroduceResultPayload, SessionRelayPayload, SocialAddressUpdatePayload, SocialCheckinPayload, SocialDisconnectNoticePayload, @@ -1894,138 +1893,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, our_personas) = { - let storage = self.storage.get().await; - ( - storage.list_follows()?, - storage.get_follows_with_last_sync().unwrap_or_default(), - storage.list_posting_identities().unwrap_or_default(), - ) - }; - - // Merged pull: include every posting identity so DMs match recipient. - let mut query_list = our_follows; - for pi in &our_personas { - if !query_list.contains(&pi.node_id) { - query_list.push(pi.node_id); - } - } - - let (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)? { - match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) { - Ok(_) => { - new_post_ids.push(sp.id); - synced_authors.insert(sp.post.author); - if sp.id == notification.post_id { - stored = true; - } - } - Err(e) => { - warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post"); - } - } - } - } - } - // 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 @@ -4987,110 +4854,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) - { - match crate::control::receive_post( - &storage, - &push.post.id, - &push.post.post, - &push.post.visibility, - push.post.intent.as_ref(), - ) { - Ok(_) => { - 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" - ); - } - Err(e) => { - warn!(post_id = hex::encode(push.post.id), error = %e, "rejecting pushed post"); - } - } - } - } - } - 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; @@ -6280,18 +6043,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, @@ -6303,12 +6066,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; @@ -6344,8 +6104,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; } } diff --git a/crates/core/src/network.rs b/crates/core/src/network.rs index 5b0e9bd..4b54188 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}; @@ -1074,61 +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(), - intent: None, // PostPush is only for public posts; no intent carried - }, - }; - - 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). +/// Push a group key to a specific peer (uni-stream). pub async fn push_group_key( &self, peer: &NodeId, @@ -1672,24 +1577,7 @@ 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, our_personas) = { diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index aa423df..f0b4937 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)) } @@ -1186,9 +1184,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)?; @@ -1199,7 +1195,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, @@ -1213,19 +1209,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(()) } @@ -2086,12 +2071,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() { @@ -2111,7 +2095,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; @@ -3416,98 +3400,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. @@ -3515,10 +3407,9 @@ 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 locally. BlobDeleteNotice was removed in v0.6.2; remote @@ -3552,18 +3443,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)); @@ -4455,7 +4345,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 }; @@ -4470,14 +4359,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 }; @@ -4669,39 +4555,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] @@ -4710,7 +4593,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, @@ -4718,10 +4600,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); } @@ -4729,26 +4610,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/protocol.rs b/crates/core/src/protocol.rs index 8d84cd9..fa3f5cf 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -32,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, @@ -90,10 +89,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), @@ -241,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 { @@ -770,10 +739,6 @@ mod tests { MessageType::RefuseRedirect, MessageType::PullSyncRequest, MessageType::PullSyncResponse, - MessageType::PostNotification, - MessageType::PostPush, - MessageType::AudienceRequest, - MessageType::AudienceResponse, MessageType::ProfileUpdate, MessageType::DeleteRecord, MessageType::VisibilityUpdate, diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index 68c3f02..a5605c6 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 @@ -2856,111 +2850,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). @@ -3607,32 +3496,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 @@ -4900,30 +4775,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)?; @@ -5282,30 +5133,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() { @@ -5354,28 +5182,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] @@ -6252,7 +6073,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)], @@ -6260,7 +6081,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 49d8533..5a9c680 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) @@ -647,20 +611,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"), } } } @@ -670,8 +631,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)), } } @@ -822,8 +783,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, } @@ -858,8 +820,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/src/lib.rs b/crates/tauri-app/src/lib.rs index f9b8984..154bcf2 100644 --- a/crates/tauri-app/src/lib.rs +++ b/crates/tauri-app/src/lib.rs @@ -1356,111 +1356,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 { @@ -2401,7 +2296,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, }; @@ -3011,11 +2906,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/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 @@