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

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