Phase 2b: control-post flow (delete/visibility) + remove BlobDeleteNotice

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<VisibilityIntent>` 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.
This commit is contained in:
Scott Reimers 2026-04-22 21:17:34 -04:00
parent 4da6a8dc85
commit 36b6a466d2
10 changed files with 585 additions and 196 deletions

View file

@ -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<SyncPost> = 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,

247
crates/core/src/control.rs Normal file
View file

@ -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<bool> {
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());
}
}

View file

@ -289,6 +289,78 @@ pub fn sign_delete(seed: &[u8; 32], post_id: &PostId) -> Vec<u8> {
sig.to_bytes().to_vec()
}
/// Canonical bytes for a ControlOp::DeletePost signature.
fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec<u8> {
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(&timestamp_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<u8> {
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<u8> {
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(&timestamp_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<u8> {
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 {

View file

@ -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;

View file

@ -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<String>)],
_legacy_upstream: Option<&(NodeId, Vec<String>)>,
) -> 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
},
};

View file

@ -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<String>)>, Option<(NodeId, Vec<String>)>)> = {
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<String>)>)
}).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;
}

View file

@ -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<crate::types::VisibilityIntent>,
}
/// 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<PeerWithAddress>,
}
// --- 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 {

View file

@ -833,7 +833,9 @@ impl Storage {
/// All posts, newest first (with visibility)
pub fn list_posts_reverse_chron(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
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<u8> = 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<u64>, limit: usize) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
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<Vec<(PostId, Post, PostVisibility)>> {
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<u8> = row.get(0)?;
let author_bytes: Vec<u8> = 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<Attachment> = 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 ----

View file

@ -251,7 +251,15 @@ pub struct WrappedKey {
pub wrapped_cek: Vec<u8>,
}
/// 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<NodeId>),
/// 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<u8>,
},
/// 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<u8>,
},
}
/// A named group of recipients

View file

@ -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(),
}