use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; use std::sync::Arc; use tracing::{debug, info, warn}; use crate::activity::{ActivityCategory, ActivityEvent, ActivityLevel, ActivityLog}; use crate::blob::BlobStore; use crate::content::compute_post_id; use crate::crypto; use crate::network::Network; use crate::storage::StoragePool; use crate::types::{ Attachment, Circle, DeviceProfile, DeviceRole, NodeId, PeerRecord, MeshSlot, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, ReachMethod, RevocationMode, SessionReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, VisibilityIntent, WormResult, }; /// Built-in default anchor — always available as a bootstrap fallback. /// Bootstrap anchor connect string. The NodeId here is the anchor's CURRENT /// network identity (used for QUIC handshake / cert verification). It was /// rotated from `17af14...` to `ab2b72...` by v0.6.1's upgrade path on the /// anchor host at 2026-04-22 22:57 UTC. The old key became the anchor's /// posting identity (see `DEFAULT_ANCHOR_POSTING_ID` in lib.rs) and is /// used to verify signed announcements; it is NOT used for connection /// verification. /// /// Clients compiled against the pre-rotation value fail the TLS handshake /// with "UnknownIssuer" because they pin the wrong cert identity. const DEFAULT_ANCHOR: &str = "ab2b7258ef0b75b2c6ee8bf6595232055f6199d584d3c0fc10b15a1ed549aa13@itsgoin.net:4433"; /// Cooldown between relay-introduction attempts toward the same target (5 min) const RELAY_COOLDOWN_MS: i64 = 300_000; /// Timeout for a single relay-introduction round trip const RELAY_INTRO_TIMEOUT_SECS: u64 = 15; /// A distsoc node: ties together identity, storage, and networking pub struct Node { pub data_dir: PathBuf, pub storage: Arc, pub network: Arc, /// Network identity — used for QUIC connections / routing. Stays hidden /// from peers after the posting-key split ships end-to-end. pub node_id: NodeId, pub blob_store: Arc, /// Active default posting identity's public NodeId. Used as `author` on /// content signed by this device. pub default_posting_id: NodeId, /// Active default posting identity's secret seed. Used to sign content /// (posts, manifests, reactions, comments, deletes) and to wrap/unwrap /// encryption keys. default_posting_secret: [u8; 32], bootstrap_anchors: tokio::sync::Mutex>, /// True if an anchor reported another instance of this identity is already active pub duplicate_detected: Arc, profile: DeviceProfile, pub activity_log: Arc>, pub last_rebalance_ms: Arc, /// Last time the convection loop acted (replaces the retired anchor /// register cycle's timer). pub last_convection_ms: Arc, /// CDN replication budget: bytes remaining we're willing to pull and cache this hour replication_budget_remaining: Arc, /// CDN delivery budget: bytes remaining we're willing to serve this hour delivery_budget_remaining: Arc, /// Last budget reset timestamp (ms) budget_last_reset_ms: Arc, } /// FoF Layer 1: generate a fresh 32B `V_me` and insert it as the /// persona's current epoch (epoch=1). Idempotent if the persona already /// has a current key — does nothing in that case. fn generate_and_store_initial_v_me( storage: &crate::storage::Storage, persona_id: &NodeId, now_ms: u64, ) -> anyhow::Result<()> { use rand::RngCore; if storage.current_own_vouch_key(persona_id)?.is_some() { return Ok(()); } let mut key = [0u8; 32]; rand::rng().fill_bytes(&mut key); storage.insert_own_vouch_key(persona_id, 1, &key, now_ms)?; Ok(()) } /// v0.8 (A3): the CommentPolicy stored for every FoF-gated post at /// creation time. Fixes the historic dead gate — nothing ever set /// `CommentPermission::FriendsOfFriends`, so receivers' policy-based /// arm was unreachable. (The receive gate itself keys on /// `post.fof_gating.is_some()`; this is belt-and-suspenders.) fn fof_comment_policy() -> crate::types::CommentPolicy { crate::types::CommentPolicy { allow_comments: crate::types::CommentPermission::FriendsOfFriends, ..Default::default() } } /// v0.8 (A3): plaintext bucket for sealed greeting bodies on bio posts. pub const GREETING_BODY_BUCKET: u16 = 1024; /// v0.8 (A3): FoFRevocation reason code used when a persona withdraws /// greeting consent — the open-slot pub_x of every prior bio is revoked /// so holders stop accepting (and purge) greetings on superseded bios. pub const GREETING_CONSENT_REVOKE_REASON: u8 = 2; /// v0.8 (A3): per-persona greeting consent. PRE-CHECKED default (round /// 8): unset = ON. The UI presents the checkbox as an active choice at /// first profile publish; unchecking sets the key to "0" and republishes /// the bio without a greeting slot. pub fn greetings_open_setting_key(posting_id: &NodeId) -> String { format!("greetings_open.{}", hex::encode(posting_id)) } fn greetings_open_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> bool { storage .get_setting(&greetings_open_setting_key(posting_id)) .ok() .flatten() .map(|v| v != "0") .unwrap_or(true) } /// v0.8: per-persona LIMIT on how many live stranger greetings the bio's /// open slot will accept. Unset = the holder default /// (`MAX_GREETINGS_PER_BIO`). The value is baked into the bio post's /// `OpenSlotDecl` at publish time, so every holder enforces it — the /// author cannot change it after the fact without republishing the bio. /// No UI yet; the mechanism and plumbing are here for it. pub fn greetings_max_setting_key(posting_id: &NodeId) -> String { format!("greetings_max.{}", hex::encode(posting_id)) } fn greetings_max_setting(storage: &crate::storage::Storage, posting_id: &NodeId) -> Option { storage .get_setting(&greetings_max_setting_key(posting_id)) .ok() .flatten() .and_then(|v| v.parse::().ok()) // 0 is not "refuse" — refusal is `greetings_open = 0`, which // publishes a bio with no open slot at all. .filter(|n| *n > 0) } /// Build the bio's Greeting open-slot spec from the persona's settings. fn greeting_open_slot_spec( storage: &crate::storage::Storage, posting_id: &NodeId, ) -> crate::fof::OpenSlotSpec { crate::fof::OpenSlotSpec::new(crate::types::OpenSlotKind::Greeting, GREETING_BODY_BUCKET) .with_limit(greetings_max_setting(storage, posting_id)) } /// v0.8 (A3): persist the author-side state of a freshly-published /// gated post: slot provenance (cascade revocation), cached CEK /// (author-direct decrypt), and the FriendsOfFriends comment policy. fn persist_gated_post_author_state( storage: &crate::storage::Storage, author_persona_id: &NodeId, post_id: &PostId, built: &crate::fof::FoFCommentGatingBuilt, ) { for entry in &built.real_slot_provenance { let _ = storage.record_post_slot_provenance( author_persona_id, post_id, entry.slot_index, &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, ); } let _ = storage.cache_own_fof_post_cek( author_persona_id, post_id, &built.cek, &built.slot_binder_nonce, ); let _ = storage.set_comment_policy(post_id, &fof_comment_policy()); } /// Async wrapper used by `Node::create_posting_identity`. Acquires the /// storage handle and delegates to the sync helper. async fn ensure_initial_v_me( storage: &StoragePool, persona_id: &NodeId, now_ms: u64, ) -> anyhow::Result<()> { let s = storage.get().await; generate_and_store_initial_v_me(&s, persona_id, now_ms) } /// Probe a list of anchors with batched parallelism, returning the first /// successful NodeId. Remaining probes continue in background tasks after /// first success and naturally register additional mesh connections. /// /// **Parameters fixed in v0.7.3:** /// - 3 anchors in flight at a time /// - 2-second stagger between batch dispatches /// - 10s per-anchor connect timeout /// - Failed probes to anchors with `last_seen_ms` older than 3 days /// auto-delete from `known_anchors` (self-healing pruning) /// /// Returns `None` only when every probe completed without success. async fn probe_anchors_batched( anchors: Vec<(NodeId, Vec)>, network: Arc, storage: Arc, self_node_id: NodeId, label: &'static str, ) -> Option { use std::sync::atomic::{AtomicUsize, Ordering}; const BATCH_SIZE: usize = 3; const BATCH_STAGGER_SECS: u64 = 2; const PER_ANCHOR_TIMEOUT_SECS: u64 = 10; const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000; let total = anchors.len(); if total == 0 { return None; } let (success_tx, success_rx) = tokio::sync::oneshot::channel::(); let success_tx = Arc::new(tokio::sync::Mutex::new(Some(success_tx))); let completed = Arc::new(AtomicUsize::new(0)); let all_done = Arc::new(tokio::sync::Notify::new()); // Dispatcher: spawns per-anchor tasks in batches of BATCH_SIZE, // sleeping BATCH_STAGGER_SECS between batches. The per-anchor tasks // continue running after the dispatcher exits. let dispatcher = { let network = Arc::clone(&network); let storage = Arc::clone(&storage); let success_tx = Arc::clone(&success_tx); let completed = Arc::clone(&completed); let all_done = Arc::clone(&all_done); tokio::spawn(async move { let mut iter = anchors.into_iter(); loop { let batch: Vec<_> = (&mut iter).take(BATCH_SIZE).collect(); if batch.is_empty() { break; } let more = iter.size_hint().0 > 0; for (nid, addrs) in batch { let network = Arc::clone(&network); let storage = Arc::clone(&storage); let success_tx = Arc::clone(&success_tx); let completed = Arc::clone(&completed); let all_done = Arc::clone(&all_done); tokio::spawn(async move { let result = probe_one_anchor(&network, &storage, nid, addrs, self_node_id, label).await; if let Some(nid) = result { let mut guard = success_tx.lock().await; if let Some(sender) = guard.take() { let _ = sender.send(nid); } } let prev = completed.fetch_add(1, Ordering::SeqCst); if prev + 1 == total { all_done.notify_one(); } }); } if more { tokio::time::sleep(std::time::Duration::from_secs(BATCH_STAGGER_SECS)).await; } } }) }; // Race: first success vs all probes complete unsuccessfully. let result = tokio::select! { Ok(nid) = success_rx => Some(nid), _ = all_done.notified() => None, }; // Detach the dispatcher; in-flight per-anchor tasks continue. drop(dispatcher); let _ = BATCH_STAGGER_SECS; // silence unused-const if compiler is picky let _ = PER_ANCHOR_TIMEOUT_SECS; let _ = STALE_THRESHOLD_MS; result } /// Gather anchor candidates, POOL FIRST (round-4 ruling). /// /// Phase 0: anchor-flagged entries mined out of the uniques pools. Anchor /// entries are the only address-bearing rows in the index, so the /// pools double as the anchor directory — including the retained /// pools of peers that have since disconnected (slot knowledge is /// overwritten memory, wiped when a new handshake takes the slot, /// not when a peer leaves). /// Phase 1: `known_anchors` — DEMOTED to a bootstrap cache. Its `success_count` /// ordering was a v0.7.x scarcity artifact and no longer means /// anything now that pools supply anchors in bulk. /// Phase 2: peers flagged `is_anchor`. /// /// Anchors currently in the refusal penalty box are pushed to the back rather /// than dropped: a refusal is a load signal, not a blacklist. async fn gather_anchor_candidates( storage: &Arc, network: &crate::network::Network, self_node_id: NodeId, limit: usize, ) -> Vec<(NodeId, Vec)> { let mut out: Vec<(NodeId, Vec)> = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); seen.insert(self_node_id); { let s = storage.get().await; // Phase 0 — pool-mined. for (nid, addrs) in s.list_pool_anchors(limit).unwrap_or_default() { if !seen.insert(nid) { continue; } let socks: Vec = addrs.iter().filter_map(|a| a.parse().ok()).collect(); if !socks.is_empty() { out.push((nid, socks)); } } // Phase 1 — bootstrap cache. for (nid, addrs) in s.list_known_anchors().unwrap_or_default() { if seen.insert(nid) && !addrs.is_empty() { out.push((nid, addrs)); } } // Phase 2 — anchor-flagged peers. for r in s.list_anchor_peers().unwrap_or_default() { if seen.insert(r.node_id) && !r.addresses.is_empty() { out.push((r.node_id, r.addresses)); } } } // De-prioritise (do not drop) anchors that recently refused us. let mut penalized = Vec::new(); let mut fresh = Vec::new(); for entry in out { if network.conn_handle().is_anchor_penalized(&entry.0).await { penalized.push(entry); } else { fresh.push(entry); } } fresh.extend(penalized); fresh.truncate(limit); fresh } /// One convection exchange against one anchor: connect if needed, ask, act. /// /// Replaces the four hand-rolled `request_anchor_referrals` → `connect_to_peer` /// → `connect_via_introduction` blocks (bootstrap x2, recovery, register cycle) /// that had drifted apart. Returns how many peer connections it produced. async fn run_convection( network: &Arc, anchor_nid: NodeId, anchor_addrs: &[std::net::SocketAddr], class: crate::protocol::ConvectionClass, self_node_id: NodeId, ) -> usize { if anchor_nid == self_node_id { return 0; } if !network.is_peer_connected_or_session(&anchor_nid).await { let endpoint_id = match iroh::EndpointId::from_bytes(&anchor_nid) { Ok(eid) => eid, Err(_) => return 0, }; let mut addr = iroh::EndpointAddr::from(endpoint_id); for sa in anchor_addrs { addr = addr.with_ip_addr(*sa); } if let Err(e) = network.connect_to_anchor(anchor_nid, addr).await { debug!(error = %e, anchor = hex::encode(anchor_nid), "Convection: anchor connect failed"); return 0; } } // A refusal is one small message — the 10s ceiling is for the connect leg, // never for the refusal itself. let response = match tokio::time::timeout( std::time::Duration::from_secs(10), network.request_convection(&anchor_nid, class), ).await { Ok(Ok(r)) => r, Ok(Err(e)) => { debug!(error = %e, anchor = hex::encode(anchor_nid), "Convection request failed"); return 0; } Err(_) => { debug!(anchor = hex::encode(anchor_nid), "Convection request timed out"); return 0; } }; if response.refused { // Feedback already recorded inside request_convection. return 0; } network.act_on_convection(&anchor_nid, &response).await } async fn probe_one_anchor( network: &crate::network::Network, storage: &Arc, nid: NodeId, addrs: Vec, self_node_id: NodeId, label: &'static str, ) -> Option { const PER_ANCHOR_TIMEOUT_SECS: u64 = 10; const STALE_THRESHOLD_MS: u64 = 3 * 86_400 * 1000; if nid == self_node_id || network.is_peer_connected_or_session(&nid).await { return None; } let endpoint_id = match iroh::EndpointId::from_bytes(&nid) { Ok(eid) => eid, Err(_) => return None, }; let mut addr = iroh::EndpointAddr::from(endpoint_id); for sa in &addrs { addr = addr.with_ip_addr(*sa); } info!(peer = hex::encode(&nid), label, "Trying anchor"); let result = tokio::time::timeout( std::time::Duration::from_secs(PER_ANCHOR_TIMEOUT_SECS), network.connect_to_anchor(nid, addr), ).await; match result { Ok(Ok(())) => { info!(peer = hex::encode(&nid), label, "Connected to anchor"); Some(nid) } Ok(Err(e)) => { debug!(error = %e, peer = hex::encode(&nid), label, "Anchor connect failed"); maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await; None } Err(_) => { debug!(peer = hex::encode(&nid), label, "Anchor connect timed out"); maybe_prune_stale_anchor(storage, &nid, STALE_THRESHOLD_MS).await; None } } } /// If the anchor's last successful contact was more than `threshold_ms` /// ago, delete it from `known_anchors`. Future startups won't waste a /// probe slot on it. Anchors that were recently successful are preserved /// even when they fail a single probe (likely transient). async fn maybe_prune_stale_anchor( storage: &Arc, nid: &NodeId, threshold_ms: u64, ) { let s = storage.get().await; let last_seen_ms = match s.get_known_anchor_last_seen(nid) { Ok(Some(ms)) => ms, _ => return, }; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); if now_ms > last_seen_ms && now_ms - last_seen_ms > threshold_ms { let _ = s.delete_known_anchor(nid); debug!( peer = hex::encode(nid), age_ms = now_ms - last_seen_ms, "Pruned stale anchor (>3 days since last success + failed probe)" ); } } impl Node { /// Create or open a node in the given data directory (Desktop profile) pub async fn open(data_dir: impl AsRef) -> anyhow::Result { Self::open_with_bind(data_dir, None, DeviceProfile::Desktop).await } /// Create or open a mobile node in the given data directory pub async fn open_mobile(data_dir: impl AsRef) -> anyhow::Result { Self::open_with_bind(data_dir, None, DeviceProfile::Mobile).await } /// Create or open a node, optionally binding to a specific address pub async fn open_with_bind( data_dir: impl AsRef, bind_addr: Option, profile: DeviceProfile, ) -> anyhow::Result { let data_dir = data_dir.as_ref().to_path_buf(); std::fs::create_dir_all(&data_dir)?; // Load or generate identity key (network secret — QUIC endpoint only, // never used as content author under the v0.6.1+ clean model). let key_path = data_dir.join("identity.key"); let (mut secret_key, secret_seed) = if key_path.exists() { let key_bytes = std::fs::read(&key_path)?; let bytes: [u8; 32] = key_bytes .try_into() .map_err(|_| anyhow::anyhow!("invalid key file"))?; (iroh::SecretKey::from_bytes(&bytes), bytes) } else { let key = iroh::SecretKey::generate(&mut rand::rng()); let seed = key.to_bytes(); std::fs::write(&key_path, seed)?; info!("Generated new network identity key"); (key, seed) }; // Open storage let db_path = data_dir.join("itsgoin.db"); let storage = Arc::new(StoragePool::open(&db_path)?); // Startup sweep: clear stale N2/N3 and mesh_peers from prior session { let s = storage.get().await; let n_cleared = s.clear_all_reach().unwrap_or(0); let m_cleared = s.clear_all_mesh_peers().unwrap_or(0); if n_cleared > 0 || m_cleared > 0 { info!(n2_n3 = n_cleared, mesh_peers = m_cleared, "Startup sweep: cleared stale entries"); } } // Ensure a default posting identity exists, INDEPENDENT of the network // key. On a fresh install we generate a new random ed25519 key as the // default persona. Peers who see our posts never learn our network key. { let s = storage.get().await; if s.count_posting_identities()? == 0 { let pk = iroh::SecretKey::generate(&mut rand::rng()); let seed = pk.to_bytes(); let nid: NodeId = *pk.public().as_bytes(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; s.upsert_posting_identity(&crate::types::PostingIdentity { node_id: nid, secret_seed: seed, display_name: String::new(), created_at: now, })?; s.set_default_posting_id(&nid)?; // FoF Layer 1: auto-gen V_me epoch 1 for this fresh persona. generate_and_store_initial_v_me(&s, &nid, now)?; // Mark this as the disposable auto-gen persona from the // fresh-install flow. If the user subsequently imports, we // prune this id iff it's still pristine (no name, no posts, // no engagement). See `try_prune_first_run_auto_persona`. let _ = s.set_setting("first_run_auto_persona_id", &hex::encode(nid)); info!(posting_id = %hex::encode(nid), "Generated initial posting identity (independent of network key)"); } } // v0.6.0 → v0.6.1 migration: if the default posting key equals the // network key (which is what the Phase 4 migration did on upgrade from // v0.5), rotate the network key so they become independent. The old // key stays as the default posting identity — peers keep seeing the // same author; only the QUIC NodeId changes. { let s = storage.get().await; if let Some(default_id) = s.get_default_posting_id()? { if let Some(default_pi) = s.get_posting_identity(&default_id)? { if default_pi.secret_seed == secret_seed { let new_key = iroh::SecretKey::generate(&mut rand::rng()); let new_seed = new_key.to_bytes(); std::fs::write(&key_path, new_seed)?; info!("v0.6.1 migration: rotated network key to decouple from default posting key"); secret_key = new_key; } } } } // Open blob store let blob_store = Arc::new(BlobStore::open(&data_dir)?); // Activity log let activity_log = Arc::new(std::sync::Mutex::new(ActivityLog::new())); // Start network (single ALPN, connection manager) let network = Arc::new( Network::new(secret_key, Arc::clone(&storage), bind_addr, Arc::clone(&blob_store), profile, Arc::clone(&activity_log)).await?, ); let node_id = network.node_id_bytes(); // Resolve default posting identity (now guaranteed to exist). let (default_posting_id, default_posting_secret) = { let s = storage.get().await; let default_id = s.get_default_posting_id()? .ok_or_else(|| anyhow::anyhow!("default posting identity missing after initialization"))?; let pi = s.get_posting_identity(&default_id)? .ok_or_else(|| anyhow::anyhow!("default posting identity row missing"))?; (pi.node_id, pi.secret_seed) }; // Auto-follow our default posting identity so our own posts show in // the feed. The network NodeId is not followed — it's never an author. { let s = storage.get().await; s.add_follow(&default_posting_id)?; } // Build the node (fast path — no network I/O beyond endpoint creation) let activity_log_ref = Arc::clone(&activity_log); let last_rebalance_ms = Arc::new(AtomicU64::new(0)); let last_convection_ms = Arc::new(AtomicU64::new(0)); let role = network.device_role(); let (replication_budget, delivery_budget) = (role.replication_limit(), role.delivery_limit()); let replication_budget_remaining = Arc::new(AtomicU64::new(replication_budget)); let delivery_budget_remaining = Arc::new(AtomicU64::new(delivery_budget)); let budget_last_reset_ms = Arc::new(AtomicU64::new( std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_millis() as u64 )); blob_store.set_delivery_budget(delivery_budget); let node = Self { data_dir: data_dir.clone(), storage: Arc::clone(&storage), network: Arc::clone(&network), node_id, blob_store, default_posting_id, default_posting_secret, bootstrap_anchors: tokio::sync::Mutex::new(Vec::new()), duplicate_detected: Arc::new(AtomicBool::new(false)), profile, activity_log: activity_log_ref, last_rebalance_ms, last_convection_ms, replication_budget_remaining, delivery_budget_remaining, budget_last_reset_ms, }; // v0.8 one-time migrations (best-effort; never block init): // // (a) Legacy (pre-posting/network split) profile rows keyed by the // NETWORK id may still carry persona fields from the unified-id // era. Strip them in place so no code path can ever re-broadcast // persona data bound to the device network id. Topology fields // (anchors/recent_peers) are preserved. // // (b) The manifest signature digest dropped author_addresses, so // rows signed by pre-v0.8 builds no longer verify. Re-sign our // own manifests with the matching persona key; purge cached // foreign manifests that can never verify again (they'd sit // silently un-propagatable otherwise). Guarded by a settings // flag so it runs once per data dir. { let s = storage.get().await; // (a) strip persona fields from the network-id row if let Ok(Some(p)) = s.get_profile(&node.node_id) { if !p.display_name.is_empty() || !p.bio.is_empty() || p.avatar_cid.is_some() { let mut stripped = p; stripped.display_name = String::new(); stripped.bio = String::new(); stripped.avatar_cid = None; if s.store_profile(&stripped).is_ok() { info!("v0.8 migration: stripped persona fields from network-id profile row"); } } } // (b) re-sign own / purge stale-foreign CDN manifests if s.get_setting("v08_manifest_resign_done").ok().flatten().is_none() { let personas: std::collections::HashMap = s.list_posting_identities() .unwrap_or_default() .into_iter() .map(|pi| (pi.node_id, pi.secret_seed)) .collect(); let mut resigned = 0usize; let mut purged = 0usize; for (cid, json) in s.list_all_cdn_manifests().unwrap_or_default() { let Ok(mut m) = serde_json::from_str::(&json) else { let _ = s.delete_cdn_manifest(&cid); purged += 1; continue; }; if crypto::verify_manifest_signature(&m) { continue; // already valid under the v0.8 digest } if let Some(seed) = personas.get(&m.author) { m.signature = crypto::sign_manifest(seed, &m); if let Ok(updated_json) = serde_json::to_string(&m) { let _ = s.store_cdn_manifest(&cid, &updated_json, &m.author, m.updated_at); resigned += 1; } } else { // Foreign manifest signed under the pre-v0.8 digest — // can never verify again; drop it so it isn't re-served. let _ = s.delete_cdn_manifest(&cid); purged += 1; } } let _ = s.set_setting("v08_manifest_resign_done", "1"); if resigned > 0 || purged > 0 { info!(resigned, purged, "v0.8 migration: manifest re-sign/purge complete"); } } } // Startup backfill: any named persona without a profile post gets // one synthesized at its own `created_at`. Makes legacy / imported // named personas Discover-able without requiring a manual rename. // Swallow errors — backfill is best-effort; no reason to block init. if let Err(e) = node.backfill_profile_posts_for_named_personas().await { warn!(error = %e, "Profile-post backfill failed; continuing init"); } // v0.8 (A3): self-materialize the registry post (store-if-absent) // so every node can hold/serve the registration chain — no fetch // needed, and the existing engagement-check cadence keeps its // comment chain refreshing. { let s = node.storage.get().await; match crate::registry::materialize_registry_post(&s) { Ok(true) => info!( post_id = hex::encode(crate::registry::REGISTRY_POST_ID), "Registry post self-materialized" ), Ok(false) => {} Err(e) => warn!(error = %e, "Registry post materialization failed"), } } Ok(node) } /// Bootstrap: connect to anchors, pull initial data, NAT probe, referrals. /// Can be called during open_with_bind (blocking startup) or deferred to background. /// /// v0.7.3: anchor probing is batched (3 in flight, 2s stagger between batches, /// 10s per-anchor timeout, first success unblocks downstream, remaining probes /// continue in background and naturally fill peer connections). Failed probes /// to anchors >3 days stale auto-prune from `known_anchors`. pub async fn run_bootstrap(&self, data_dir: &Path) -> anyhow::Result<()> { let storage = &self.storage; let network = &self.network; let node_id = self.node_id; // Bootstrap: if peers table is empty, try bootstrap.json then default anchor { let s = storage.get().await; let has_peers = s.has_peers()?; drop(s); if !has_peers { let mut entries = Vec::new(); let bootstrap_path = data_dir.join("bootstrap.json"); if bootstrap_path.exists() { info!("Loading bootstrap peers from {:?}", bootstrap_path); if let Ok(data) = std::fs::read_to_string(&bootstrap_path) { if let Ok(file_entries) = serde_json::from_str::>(&data) { entries.extend(file_entries); } } } let default = DEFAULT_ANCHOR.to_string(); if !entries.contains(&default) { entries.push(default); } for entry in entries { match crate::parse_connect_string(&entry) { Ok((nid, addr)) => { if nid == node_id { continue; } info!(peer = hex::encode(nid), "Bootstrap: connecting to peer"); let ip_addrs: Vec<_> = addr.ip_addrs().copied().collect(); { let s = storage.get().await; if ip_addrs.is_empty() { let _ = s.add_peer(&nid); } else { let _ = s.upsert_peer(&nid, &ip_addrs, None); } // Mark as anchor — bootstrap peers are infrastructure, not social follows let _ = s.set_peer_anchor(&nid, true); } // Connect persistently match network.connect_to_peer(nid, addr).await { Ok(()) => { info!(peer = hex::encode(nid), "Bootstrap: connected"); // Pull posts from the bootstrap peer match network.content_sync_all().await { Ok(stats) => { info!( "Bootstrap pull: {} posts from {} peers", stats.posts_received, stats.peers_pulled ); } Err(e) => warn!(error = %e, "Bootstrap pull failed"), } // Always store anchor in known_anchors (even before referrals) // so the periodic cycle can re-register and request referrals later { let s = storage.get().await; let anchor_addrs: Vec = s.get_peer_record(&nid) .ok().flatten() .map(|r| r.addresses).unwrap_or_default(); if !anchor_addrs.is_empty() { let _ = s.upsert_known_anchor(&nid, &anchor_addrs); } else if !ip_addrs.is_empty() { let _ = s.upsert_known_anchor(&nid, &ip_addrs); } } // Convection, ENTRY class — we have no mesh // yet, so this is always served. Spawned so // startup isn't blocked on peer connects. { let net = Arc::clone(&network); let my_id = node_id; let anchor = nid; let anchor_addrs = ip_addrs.clone(); tokio::spawn(async move { let n = run_convection( &net, anchor, &anchor_addrs, crate::protocol::ConvectionClass::Entry, my_id, ).await; info!(connected = n, "Bootstrap: convection complete"); net.notify_growth().await; }); } break; } Err(e) => { warn!(error = %e, "Bootstrap peer failed, trying next"); } } } Err(e) => { warn!(entry = %entry, error = %e, "Invalid bootstrap entry"); } } } } } // Load bootstrap anchors: anchors.json + built-in default let mut bootstrap_anchors = Vec::new(); let mut anchor_ids = std::collections::HashSet::new(); let anchors_path = data_dir.join("anchors.json"); if anchors_path.exists() { if let Ok(data) = std::fs::read_to_string(&anchors_path) { if let Ok(entries) = serde_json::from_str::>(&data) { for entry in entries { match crate::parse_connect_string(&entry) { Ok((nid, addr)) => { info!(peer = hex::encode(nid), "Loaded bootstrap anchor"); anchor_ids.insert(nid); bootstrap_anchors.push((nid, addr)); } Err(e) => { warn!(entry = %entry, error = %e, "Invalid bootstrap anchor entry"); } } } } } } if let Ok((nid, addr)) = crate::parse_connect_string(DEFAULT_ANCHOR) { if nid != node_id && !anchor_ids.contains(&nid) { info!("Including built-in default anchor"); bootstrap_anchors.push((nid, addr)); } } // Collect bootstrap anchor node IDs so we can deprioritize them let bootstrap_anchor_ids: std::collections::HashSet = bootstrap_anchors.iter().map(|(nid, _)| *nid).collect(); // Update known_anchors + peers with freshly DNS-resolved bootstrap addresses. // Without this, stale IPv6 addresses from previous sessions can block reconnection // on devices without IPv6 connectivity (see bugs-fixed.md #1). { let s = storage.get().await; for (nid, addr) in &bootstrap_anchors { let ip_addrs: Vec = addr.ip_addrs().copied().collect(); if !ip_addrs.is_empty() { let _ = s.upsert_known_anchor(nid, &ip_addrs); let _ = s.upsert_peer(nid, &ip_addrs, None); } } } // Rebuild social routes from follows + audience { let s = storage.get().await; match s.rebuild_social_routes() { Ok(count) if count > 0 => info!(count, "Rebuilt social routes on startup"), _ => {} } } // Startup connection: try discovered anchors FIRST, bootstrap anchors LAST. // This keeps load off bootstrap anchors — they're only needed when nothing else works. // Order: known non-bootstrap anchors → mDNS (via iroh) → bootstrap anchors { let conn_count = network.connection_count().await; if conn_count < 5 { // Pool-mined anchors FIRST (round-4), then the known_anchors // bootstrap cache, then anchor-flagged peers. let known = gather_anchor_candidates(storage, network, node_id, 32).await; // Split into discovered anchors (priority) and bootstrap anchors (fallback) let (discovered, bootstrap_known): (Vec<_>, Vec<_>) = known.into_iter() .partition(|(nid, _)| !bootstrap_anchor_ids.contains(nid)); // Phase 1: probe discovered (non-bootstrap) anchors in batches. // First success returns immediately; remaining probes continue in // background. Failed probes to anchors >3 days stale auto-prune. let mut connected_anchor = probe_anchors_batched( discovered.clone(), network.clone(), Arc::clone(storage), node_id, "discovered", ).await; // Phase 2: bootstrap anchors as fallback — only fires if every // Phase 1 entry failed. Preserves the load-distribution intent // (don't smash the central anchor when discovered anchors work). if connected_anchor.is_none() { connected_anchor = probe_anchors_batched( bootstrap_known.clone(), network.clone(), Arc::clone(storage), node_id, "bootstrap", ).await; } // Phase 3: NAT probe + referrals from whichever anchor we connected to if let Some(anchor_nid) = connected_anchor { match tokio::time::timeout( std::time::Duration::from_secs(15), network.request_nat_filter_probe(&anchor_nid), ).await { Ok(Ok(())) => info!("NAT filter probe completed during bootstrap"), Ok(Err(e)) => warn!(error = %e, "NAT filter probe failed during bootstrap"), Err(_) => warn!("NAT filter probe timed out during bootstrap"), } // Convection, ENTRY class — same single code path as // bootstrap and recovery. { let net = Arc::clone(&network); let my_id = node_id; let anchor = anchor_nid; tokio::spawn(async move { let n = run_convection( &net, anchor, &[], crate::protocol::ConvectionClass::Entry, my_id, ).await; info!(connected = n, "Startup: convection complete"); net.notify_growth().await; }); } } } } // Store bootstrap anchors on the node *self.bootstrap_anchors.lock().await = bootstrap_anchors; Ok(()) } /// Get recent activity events (for diagnostics UI). pub fn get_activity_log(&self, limit: usize) -> Vec { self.activity_log.lock().unwrap().recent(limit) } /// Get timer state: (last_rebalance_ms, last_convection_ms). pub fn timer_state(&self) -> (u64, u64) { ( self.last_rebalance_ms.load(AtomicOrdering::Relaxed), self.last_convection_ms.load(AtomicOrdering::Relaxed), ) } /// Get the secret seed bytes (for crypto operations by consumers like Tauri) pub fn secret_seed_bytes(&self) -> [u8; 32] { self.default_posting_secret } // --- CDN Replication Budget --- /// Reset budgets if an hour has elapsed since last reset. fn maybe_reset_budgets(&self) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let last = self.budget_last_reset_ms.load(AtomicOrdering::Relaxed); if now.saturating_sub(last) >= 3_600_000 { let role = self.network.device_role(); self.replication_budget_remaining.store(role.replication_limit(), AtomicOrdering::Relaxed); self.delivery_budget_remaining.store(role.delivery_limit(), AtomicOrdering::Relaxed); self.budget_last_reset_ms.store(now, AtomicOrdering::Relaxed); debug!(role = %role, "CDN budgets reset for new hour"); } } /// Try to consume replication budget. Returns true if within budget. pub fn consume_replication_budget(&self, bytes: u64) -> bool { self.maybe_reset_budgets(); let prev = self.replication_budget_remaining.fetch_update( AtomicOrdering::Relaxed, AtomicOrdering::Relaxed, |current| { if current >= bytes { Some(current - bytes) } else { None } }, ); prev.is_ok() } /// Try to consume delivery budget. Returns true if within budget. pub fn consume_delivery_budget(&self, bytes: u64) -> bool { self.maybe_reset_budgets(); let prev = self.delivery_budget_remaining.fetch_update( AtomicOrdering::Relaxed, AtomicOrdering::Relaxed, |current| { if current >= bytes { Some(current - bytes) } else { None } }, ); prev.is_ok() } /// Get remaining replication budget bytes. pub fn replication_budget_remaining(&self) -> u64 { self.maybe_reset_budgets(); self.replication_budget_remaining.load(AtomicOrdering::Relaxed) } /// Get remaining delivery budget bytes. pub fn delivery_budget_remaining(&self) -> u64 { self.maybe_reset_budgets(); self.delivery_budget_remaining.load(AtomicOrdering::Relaxed) } // ---- Posting identities (multi-persona) ---- /// List all posting identities held by this device. pub async fn list_posting_identities(&self) -> anyhow::Result> { let s = self.storage.get().await; s.list_posting_identities() } /// Create a new posting identity with a fresh ed25519 key. Auto-follows /// the new identity so its own posts show in the merged feed. /// /// `greetings_open` is the persona's greeting-consent choice (round 8: /// an ACTIVE pre-checked choice, never silently defaulted on the /// wire). It is persisted BEFORE the initial bio publish so an /// opted-out persona never ships a greeting slot at all. `None` /// keeps the pre-checked default (ON). pub async fn create_posting_identity( &self, display_name: String, greetings_open: Option, ) -> anyhow::Result { let key = iroh::SecretKey::generate(&mut rand::rng()); let seed: [u8; 32] = key.to_bytes(); let node_id: NodeId = *key.public().as_bytes(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let identity = crate::types::PostingIdentity { node_id, secret_seed: seed, display_name: display_name.clone(), created_at: now, }; { let s = self.storage.get().await; s.upsert_posting_identity(&identity)?; // Auto-follow this persona so its own posts reach its own feed. s.add_follow(&node_id)?; // Record greeting consent BEFORE the initial bio publish below // reads it — an opt-out persona must never ship a slot. if let Some(open) = greetings_open { s.set_setting( &greetings_open_setting_key(&node_id), if open { "1" } else { "0" }, )?; } } // If the user supplied a non-empty display name at creation time, // emit a signed profile post immediately. This makes the persona // Discover-able by other nodes even before the user posts anything // under it. `publish_profile_post_as` signs with the persona's own // secret (not the default posting secret) and propagates via the // normal neighbor-manifest CDN path. if !display_name.is_empty() { if let Err(e) = self.publish_profile_post_as(&node_id, &seed, &display_name, "", None).await { warn!(persona = hex::encode(node_id), error = %e, "Failed to emit initial profile post for new persona"); } } // FoF Layer 1: every persona owns its own V_me (symmetric 32B key). // Auto-generate epoch 1 at creation. Stored in vouch_keys_own with // is_current=1. Per Layer 4, rotations append new epochs; this row // is never deleted automatically. ensure_initial_v_me(&self.storage, &node_id, now).await?; Ok(identity) } /// Build + store + propagate a `VisibilityIntent::Profile` post authored /// by the given persona (not the default posting identity). Extracted so /// both `create_posting_identity` and the startup backfill can use it. async fn publish_profile_post_as( &self, posting_id: &NodeId, posting_secret: &[u8; 32], display_name: &str, bio: &str, avatar_cid: Option<[u8; 32]>, ) -> anyhow::Result<()> { // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump the bio_epoch. // v0.8 (A3): when the persona's `greetings_open` consent is on, // the bio carries FoF gating with a Greeting open slot. let (vouch_grants, bio_epoch, gating_built) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, posting_id)?; let epoch = storage.next_bio_epoch_for(posting_id)?; let gating = if greetings_open_setting(&storage, posting_id) { crate::fof::build_fof_comment_gating( &*storage, posting_id, Some(greeting_open_slot_spec(&storage, posting_id)), )? } else { None }; (batch, epoch, gating) }; let profile_post = crate::profile::build_profile_post( posting_id, posting_secret, display_name, bio, avatar_cid, vouch_grants, bio_epoch, gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; { let storage = self.storage.get().await; storage.store_post_with_intent( &profile_post_id, &profile_post, &PostVisibility::Public, &VisibilityIntent::Profile, )?; crate::profile::apply_profile_post_if_applicable( &*storage, &profile_post, Some(&VisibilityIntent::Profile), )?; if let Some(built) = &gating_built { persist_gated_post_author_state(&storage, posting_id, &profile_post_id, built); } } self.update_neighbor_manifests_as( posting_id, posting_secret, &profile_post_id, timestamp_ms, ).await; Ok(()) } /// Backfill: for every posting identity with a non-empty display_name /// that doesn't already have a `VisibilityIntent::Profile` post, /// synthesize one so the persona becomes Discover-able. Uses the /// persona's `created_at` as the post timestamp so chronology matches /// the persona's history. /// /// Called once from `Node::open_with_bind` after all migrations. Safe to /// re-run: the `has_profile_post_by_author` check makes it idempotent. async fn backfill_profile_posts_for_named_personas(&self) -> anyhow::Result { let personas = { let storage = self.storage.get().await; storage.list_posting_identities()? }; let mut backfilled = 0usize; for pi in personas { if pi.display_name.is_empty() { continue; } { let storage = self.storage.get().await; if storage.has_profile_post_by_author(&pi.node_id)? { continue; } } // Build a profile post whose internal timestamp equals the // persona's created_at. This stops the backfilled post from // later losing a monotonicity check against a real profile // update the user authors in the future. let signature = crate::crypto::sign_profile( &pi.secret_seed, &pi.display_name, "", &None, pi.created_at, ); let content = crate::types::ProfilePostContent { display_name: pi.display_name.clone(), bio: String::new(), avatar_cid: None, timestamp_ms: pi.created_at, signature, vouch_grants: None, bio_epoch: 0, }; let post = Post { author: pi.node_id, content: serde_json::to_string(&content).unwrap_or_default(), attachments: vec![], timestamp_ms: pi.created_at, fof_gating: None, supersedes_post_id: None, comment_ttl: None, }; let post_id = crate::content::compute_post_id(&post); { let storage = self.storage.get().await; storage.store_post_with_intent( &post_id, &post, &PostVisibility::Public, &VisibilityIntent::Profile, )?; crate::profile::apply_profile_post_if_applicable( &*storage, &post, Some(&VisibilityIntent::Profile), )?; } self.update_neighbor_manifests_as( &pi.node_id, &pi.secret_seed, &post_id, pi.created_at, ).await; backfilled += 1; } if backfilled > 0 { info!(count = backfilled, "Backfilled profile posts for named personas without one"); } Ok(backfilled) } /// If the fresh-install auto-gen persona is still pristine (no name, no /// posts, no engagement, not the current default), delete it. Called at /// the end of `import_as_personas` so an "import as persona" flow /// doesn't leave an orphan blank persona around. /// /// Any of four sticky conditions prevents deletion: /// - the user set a display_name /// - the user authored a post under this persona /// - the user authored a reaction or comment under this persona /// - this persona is still the current default (no imported identity /// replaced it) pub async fn try_prune_first_run_auto_persona(&self) -> anyhow::Result { let (marker_hex, current_default) = { let s = self.storage.get().await; let m = s.get_setting("first_run_auto_persona_id")?; let d = s.get_default_posting_id()?; (m, d) }; let Some(hex_str) = marker_hex else { return Ok(false); }; let Ok(marker_id) = crate::parse_node_id_hex(&hex_str) else { // Corrupt marker — clear and move on. let s = self.storage.get().await; let _ = s.delete_setting("first_run_auto_persona_id"); return Ok(false); }; let storage = self.storage.get().await; // Still the default? Import didn't replace it — keep. if current_default == Some(marker_id) { let _ = storage.delete_setting("first_run_auto_persona_id"); return Ok(false); } // Persona still exists? let Some(pi) = storage.get_posting_identity(&marker_id)? else { let _ = storage.delete_setting("first_run_auto_persona_id"); return Ok(false); }; // User named it? Keep. if !pi.display_name.is_empty() { let _ = storage.delete_setting("first_run_auto_persona_id"); return Ok(false); } // User authored anything under it? Keep. if storage.has_any_post_by_author(&marker_id)? { let _ = storage.delete_setting("first_run_auto_persona_id"); return Ok(false); } if storage.has_any_engagement_by_author(&marker_id)? { let _ = storage.delete_setting("first_run_auto_persona_id"); return Ok(false); } // All gates passed — persona is definitively pristine and no longer // the default. Safe to drop. storage.delete_posting_identity(&marker_id)?; let _ = storage.remove_follow(&marker_id); let _ = storage.delete_setting("first_run_auto_persona_id"); info!(persona = %hex_str, "Pruned pristine fresh-install persona after import"); Ok(true) } /// Delete a posting identity. Refuses to delete the currently default /// posting identity unless the caller has already switched the default. pub async fn delete_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> { let s = self.storage.get().await; if let Some(default) = s.get_default_posting_id()? { if default == *node_id { anyhow::bail!("cannot delete the default posting identity; set a different default first"); } } s.delete_posting_identity(node_id)?; // Best-effort: remove the auto-follow row for this persona. let _ = s.remove_follow(node_id); Ok(()) } /// Switch the default posting identity. Takes effect on next restart for /// the Node's cached fields, but new posts created via create_post_as can /// already use the new identity immediately. pub async fn set_default_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> { let s = self.storage.get().await; if s.get_posting_identity(node_id)?.is_none() { anyhow::bail!("unknown posting identity"); } s.set_default_posting_id(node_id)?; Ok(()) } // ---- Identity export/import ---- pub fn secret_seed(&self) -> [u8; 32] { self.default_posting_secret } pub fn export_identity_hex(&self) -> anyhow::Result { let key_path = self.data_dir.join("identity.key"); let key_bytes = std::fs::read(&key_path)?; Ok(hex::encode(key_bytes)) } pub fn import_identity(data_dir: &Path, hex_key: &str) -> anyhow::Result<()> { std::fs::create_dir_all(data_dir)?; let key_path = data_dir.join("identity.key"); if key_path.exists() { anyhow::bail!("identity.key already exists in {:?} — refusing to overwrite", data_dir); } let bytes = hex::decode(hex_key)?; if bytes.len() != 32 { anyhow::bail!("key must be exactly 32 bytes (64 hex chars), got {} bytes", bytes.len()); } std::fs::write(&key_path, &bytes)?; Ok(()) } /// Get up to 10 currently-connected peer NodeIds (for recent_peers in profile). /// Prefers social peers, then wide. async fn current_recent_peers(&self) -> Vec { let conns = self.network.connection_info().await; // v0.8: one mesh pool. Temp referral slots are excluded — a peer we // hold only provisionally should not be advertised as our neighborhood. let mut result: Vec = conns .into_iter() .filter(|(nid, slot, _)| *nid != self.node_id && slot.is_mesh()) .map(|(nid, _, _)| nid) .collect(); result.truncate(10); result } // ---- Posts ---- pub async fn create_post(&self, content: String) -> anyhow::Result<(PostId, Post)> { let (id, post, _vis) = self .create_post_with_visibility(content, VisibilityIntent::Public, vec![]) .await?; Ok((id, post)) } pub async fn create_post_with_visibility( &self, content: String, intent: VisibilityIntent, attachment_data: Vec<(Vec, String)>, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { self.create_post_inner( &self.default_posting_id, &self.default_posting_secret, content, intent, attachment_data, None, ).await } /// Create a post authored by a specific posting identity held by this /// device. Looks up the posting secret and routes through the same post /// creation pipeline as the default. pub async fn create_post_as( &self, posting_id: &NodeId, content: String, intent: VisibilityIntent, attachment_data: Vec<(Vec, String)>, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { let identity = { let s = self.storage.get().await; s.get_posting_identity(posting_id)? .ok_or_else(|| anyhow::anyhow!("unknown posting identity"))? }; self.create_post_inner( &identity.node_id, &identity.secret_seed, content, intent, attachment_data, None, ).await } /// FoF Layer 2: create a Mode 2 post (public body, FoF-gated /// comments). Intent is Public; the FoF gating block is built /// from the default persona's keyring and embedded in /// `Post.fof_gating`. The author retains the per-post CEK locally /// for decrypting their own comments later. /// /// Returns `(post_id, post, visibility, cek)`. `visibility` is /// always Public for Mode 2. pub async fn create_post_with_fof_comments( &self, content: String, attachment_data: Vec<(Vec, String)>, ) -> anyhow::Result<(PostId, Post, PostVisibility, [u8; 32])> { // Build the gating block from the default persona's keyring. let built = { let storage = self.storage.get().await; crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? }; let cek = built.cek; let provenance = built.real_slot_provenance.clone(); let (post_id, post, visibility) = self.create_post_inner( &self.default_posting_id, &self.default_posting_secret, content, VisibilityIntent::Public, attachment_data, Some(built.gating), ).await?; // FoF Layer 4: persist provenance so cascade-revocation can // resolve "which pub_x's on which of my posts were sealed // under V_me epoch N" later. // FoF Layer 5: cache the CEK + slot_binder_nonce for author- // direct decrypt without trial-unlocking on read. { let storage = self.storage.get().await; for entry in &provenance { let _ = storage.record_post_slot_provenance( &self.default_posting_id, &post_id, entry.slot_index, &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, ); } // Recover slot_binder_nonce via the Post we just built — it // lives inside fof_gating. if let Some(gating) = post.fof_gating.as_ref() { let _ = storage.cache_own_fof_post_cek( &self.default_posting_id, &post_id, &cek, &gating.slot_binder_nonce, ); } // v0.8 (A3): FIX THE DEAD GATE — store the FriendsOfFriends // policy at creation for every gated post (belt-and- // suspenders; the receive gate keys on fof_gating presence). let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } Ok((post_id, post, visibility, cek)) } /// FoF Layer 3: read the decrypted body of a FoFClosed post if any /// of this device's personas can unlock it. Returns `Ok(None)` for /// non-FoFClosed posts and for FoFClosed posts not reachable via /// any held V_x. Errors only on storage/crypto faults. pub async fn read_fof_closed_body( &self, post_id: &PostId, ) -> anyhow::Result> { use base64::Engine; let storage = self.storage.get().await; let (post, visibility) = match storage.get_post_with_visibility(post_id)? { Some(pv) => pv, None => return Ok(None), }; if !matches!(visibility, PostVisibility::FoFClosed) { return Ok(None); } let gating = match post.fof_gating.as_ref() { Some(g) => g, None => return Ok(None), }; // FoF Layer 5: author-direct fast path. If this device authored // the post, the CEK was cached at publish time; skip the // wrap-slot trial entirely. let (cek, slot_binder_nonce) = if let Some((cek, nonce)) = storage.lookup_own_fof_post_cek(&post.author, post_id)? { (cek, nonce) } else { let unlock = match crate::fof::find_unlock_for_post(&*storage, &post)? { Some(u) => u, None => return Ok(None), }; (unlock.cek, gating.slot_binder_nonce) }; drop(storage); let body_ct = base64::engine::general_purpose::STANDARD .decode(post.content.as_bytes()) .map_err(|e| anyhow::anyhow!("FoFClosed body base64 decode: {}", e))?; let plaintext = crate::fof::decrypt_fof_body(&body_ct, &cek, &slot_binder_nonce)?; Ok(Some(plaintext)) } /// FoF Layer 3: create a Mode 1 post (FoFClosed). The body is /// encrypted under the gating CEK before storage; only readers /// who can unlock a wrap_slot can decrypt it. Comments are also /// FoF-gated, inheriting Layer 2's path. /// /// Returns `(post_id, post, visibility, cek)`. pub async fn create_post_fof_closed( &self, content: String, ) -> anyhow::Result<(PostId, Post, [u8; 32])> { let built = { let storage = self.storage.get().await; crate::fof::build_fof_comment_gating(&*storage, &self.default_posting_id, None)? .ok_or_else(|| anyhow::anyhow!( "default persona has no V_me; rotate or recreate before FoF posts" ))? }; let cek = built.cek; let slot_binder_nonce = built.slot_binder_nonce; let provenance = built.real_slot_provenance.clone(); // Encrypt + pad body under the gating CEK. Output is base64'd // so it can live in Post.content (which is a String). let encrypted_body = crate::fof::encrypt_fof_body(&content, &cek, &slot_binder_nonce)?; let body_b64 = { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(&encrypted_body) }; // Build + store + propagate. Visibility is FoFClosed (tag); // gating lives in Post.fof_gating. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let post = Post { author: self.default_posting_id, content: body_b64, attachments: vec![], timestamp_ms: now, fof_gating: Some(built.gating), supersedes_post_id: None, comment_ttl: None, }; let post_id = crate::content::compute_post_id(&post); { let storage = self.storage.get().await; storage.store_post_with_intent( &post_id, &post, &PostVisibility::FoFClosed, &VisibilityIntent::Public, )?; // FoF Layer 4: persist provenance for cascade-revoke. for entry in &provenance { let _ = storage.record_post_slot_provenance( &self.default_posting_id, &post_id, entry.slot_index, &entry.v_x_owner, entry.v_x_epoch, &entry.pub_x, ); } // FoF Layer 5: cache CEK for author-direct decrypt. let _ = storage.cache_own_fof_post_cek( &self.default_posting_id, &post_id, &cek, &slot_binder_nonce, ); // v0.8 (A3): store FriendsOfFriends policy at creation. let _ = storage.set_comment_policy(&post_id, &fof_comment_policy()); } self.update_neighbor_manifests_as( &self.default_posting_id, &self.default_posting_secret, &post_id, now, ).await; Ok((post_id, post, cek)) } async fn create_post_inner( &self, posting_id: &NodeId, posting_secret: &[u8; 32], content: String, intent: VisibilityIntent, attachment_data: Vec<(Vec, String)>, fof_gating: Option, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { // Validate attachments if attachment_data.len() > 4 { anyhow::bail!("max 4 attachments per post"); } for (data, _) in &attachment_data { if data.len() > 10 * 1024 * 1024 { anyhow::bail!("attachment exceeds 10MB limit"); } } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // Determine encryption parameters and generate CEK if needed. // The CEK is generated BEFORE both content and blob encryption so they share the same key. enum EncryptionMode { Public, Recipient { cek: [u8; 32], recipients: Vec }, Group { cek: [u8; 32], group_id: [u8; 32], epoch: u64, group_seed: [u8; 32], group_pubkey: [u8; 32] }, } let mode = match &intent { VisibilityIntent::Public => EncryptionMode::Public, VisibilityIntent::Circle(circle_name) => { // Try group encryption first let group_info = { let storage = self.storage.get().await; storage.get_group_key_by_circle(circle_name)? .and_then(|gk| { storage.get_group_seed(&gk.group_id, gk.epoch).ok().flatten() .map(|seed| (gk.group_id, gk.epoch, seed, gk.group_public_key)) }) }; if let Some((group_id, epoch, group_seed, group_pubkey)) = group_info { let mut cek = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek); EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey } } else { let recipients = self.resolve_recipients(&intent).await?; if recipients.is_empty() { anyhow::bail!("no recipients resolved for this visibility"); } let mut cek = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek); EncryptionMode::Recipient { cek, recipients } } } _ => { let recipients = self.resolve_recipients(&intent).await?; if recipients.is_empty() { anyhow::bail!("no recipients resolved for this visibility"); } let mut cek = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek); EncryptionMode::Recipient { cek, recipients } } }; // Store blob files — for encrypted posts, encrypt each blob with the shared CEK. // CID is computed on the ciphertext so peers can verify what they store. let mut attachments = Vec::with_capacity(attachment_data.len()); for (data, mime) in &attachment_data { let (store_data, size) = match &mode { EncryptionMode::Public => { (data.clone(), data.len() as u64) } EncryptionMode::Recipient { cek, .. } | EncryptionMode::Group { cek, .. } => { let encrypted = crypto::encrypt_bytes_with_cek(data, cek)?; let sz = encrypted.len() as u64; (encrypted, sz) } }; let cid = crate::blob::compute_blob_id(&store_data); self.blob_store.store(&cid, &store_data)?; attachments.push(Attachment { cid, mime_type: mime.clone(), size_bytes: size, }); } // Encrypt content and build visibility let (final_content, visibility) = match mode { EncryptionMode::Public => (content, PostVisibility::Public), EncryptionMode::Recipient { cek, recipients } => { let (encrypted, wrapped_keys) = crypto::encrypt_post_with_cek(&content, &cek, posting_secret, posting_id, &recipients)?; ( encrypted, PostVisibility::Encrypted { recipients: wrapped_keys, }, ) } EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey } => { let (encrypted, wrapped_cek) = crypto::encrypt_post_for_group_with_cek(&content, &cek, &group_seed, &group_pubkey)?; ( encrypted, PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek, }, ) } }; let post = Post { author: *posting_id, content: final_content, attachments, timestamp_ms: now, fof_gating, supersedes_post_id: None, comment_ttl: None, }; let post_id = compute_post_id(&post); { let storage = self.storage.get().await; storage.store_post_with_intent(&post_id, &post, &visibility, &intent)?; for att in &post.attachments { storage.record_blob(&att.cid, &post_id, posting_id, att.size_bytes, &att.mime_type, now)?; // Auto-pin own blobs so they're never evicted before foreign content let _ = storage.pin_blob(&att.cid); } // Initialize encrypted receipt + comment slots for non-public posts. // FoFClosed posts use the FoF wrap_slots mechanism for both // reads and comments — they don't use the legacy receipt/ // comment slot path. Skip init for FoFClosed. if !matches!(visibility, PostVisibility::Public) && !matches!(visibility, PostVisibility::FoFClosed) { let participant_count = match &visibility { PostVisibility::Encrypted { recipients } => recipients.len(), PostVisibility::GroupEncrypted { .. } => { // For group posts, we don't know exact member count at creation time; // use a reasonable default (the circle members count, resolved earlier) match &intent { VisibilityIntent::Circle(circle_name) => { storage.get_circle_members(circle_name) .map(|m| m.len() + 1) // +1 for author .unwrap_or(2) } _ => 2, } } PostVisibility::Public | PostVisibility::FoFClosed => unreachable!(), }; let receipt_slots: Vec> = (0..participant_count) .map(|_| crypto::random_slot_noise(64)) .collect(); let comment_slot_count = (participant_count + 2) / 3; // ceil(participants / 3) let comment_slots: Vec> = (0..comment_slot_count) .map(|_| crypto::random_slot_noise(256)) .collect(); let blob_header = crate::types::BlobHeader { post_id, author: *posting_id, reactions: vec![], comments: vec![], policy: Default::default(), updated_at: now, thread_splits: vec![], receipt_slots, comment_slots, prior_author: None, }; let header_json = serde_json::to_string(&blob_header)?; storage.store_blob_header(&post_id, posting_id, &header_json, now)?; } } // Build and store CDN manifests for blobs if !post.attachments.is_empty() { let storage = self.storage.get().await; let (previous, _following) = storage.get_author_post_neighborhood(posting_id, now, 10)?; drop(storage); let manifest = crate::types::AuthorManifest { post_id, author: *posting_id, created_at: now, updated_at: now, previous_posts: previous, following_posts: vec![], signature: vec![], }; let sig = crypto::sign_manifest(posting_secret, &manifest); let mut manifest = manifest; manifest.signature = sig; let manifest_json = serde_json::to_string(&manifest)?; { let storage = self.storage.get().await; for att in &post.attachments { storage.store_cdn_manifest(&att.cid, &manifest_json, posting_id, now)?; } } // Update previous posts' manifests to include this new post as a following_post self.update_neighbor_manifests_as(posting_id, posting_secret, &post_id, now).await; // Push updated manifests to downstream peers let manifests_to_push = { let storage = self.storage.get().await; storage.get_manifests_for_author_blobs(posting_id).unwrap_or_default() }; for (push_cid, push_json) in &manifests_to_push { if let Ok(author_manifest) = serde_json::from_str::(push_json) { // v0.8: no device addresses ride the manifest — receivers // learn the holder from the QUIC-authenticated connection. let cdn_manifest = crate::types::CdnManifest { author_manifest, host: self.node_id, }; self.network.push_manifest_to_downstream(push_cid, &cdn_manifest).await; } } } // 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)) } /// Update the manifests of recent prior posts to include a newly created post /// in their following_posts list. Re-signs each updated manifest. async fn update_neighbor_manifests_as( &self, posting_id: &NodeId, posting_secret: &[u8; 32], new_post_id: &PostId, new_timestamp_ms: u64, ) { let storage = self.storage.get().await; let manifests = match storage.get_manifests_for_author_blobs(posting_id) { Ok(m) => m, Err(e) => { warn!("Failed to get manifests for neighbor update: {}", e); return; } }; drop(storage); let new_entry = crate::types::ManifestEntry { post_id: *new_post_id, timestamp_ms: new_timestamp_ms, has_attachments: true, }; for (cid, json) in manifests { let mut manifest: crate::types::AuthorManifest = match serde_json::from_str(&json) { Ok(m) => m, Err(_) => continue, }; // Only update if this manifest's post was created before the new post if manifest.created_at >= new_timestamp_ms { continue; } // Don't add duplicate if manifest.following_posts.iter().any(|e| e.post_id == *new_post_id) { continue; } // Keep max 10 following_posts if manifest.following_posts.len() >= 10 { continue; } manifest.following_posts.push(new_entry.clone()); manifest.updated_at = new_timestamp_ms; manifest.signature = crypto::sign_manifest(posting_secret, &manifest); let updated_json = match serde_json::to_string(&manifest) { Ok(j) => j, Err(_) => continue, }; let storage = self.storage.get().await; let _ = storage.store_cdn_manifest(&cid, &updated_json, posting_id, new_timestamp_ms); drop(storage); } } async fn resolve_recipients(&self, intent: &VisibilityIntent) -> anyhow::Result> { let storage = self.storage.get().await; match intent { VisibilityIntent::Public => Ok(vec![]), VisibilityIntent::Friends => storage.list_public_follows(), VisibilityIntent::Circle(name) => storage.get_circle_members(name), VisibilityIntent::Direct(ids) => Ok(ids.clone()), // Control / Profile / Announcement posts are always Public on // the wire; GroupKeyDistribute posts build their own recipient // list in `group_key_distribution::build_distribution_post`. // None of these use the standard resolver. VisibilityIntent::Control | VisibilityIntent::Profile | VisibilityIntent::GroupKeyDistribute | VisibilityIntent::Announcement => Ok(vec![]), } } pub async fn get_feed( &self, ) -> anyhow::Result)>> { let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.get_feed()?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds, personas) }; Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } pub async fn get_all_posts( &self, ) -> anyhow::Result)>> { let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.list_posts_reverse_chron()?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds, personas) }; Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } pub async fn get_feed_page( &self, before_ms: Option, limit: usize, ) -> anyhow::Result)>> { let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.get_feed_page(before_ms, limit)?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds, personas) }; Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } pub async fn get_all_posts_page( &self, before_ms: Option, limit: usize, ) -> anyhow::Result)>> { let (raw, group_seeds, personas) = { let storage = self.storage.get().await; let posts = storage.list_posts_page(before_ms, limit)?; let seeds = storage.get_all_group_seeds_map().unwrap_or_default(); let personas = storage.list_posting_identities().unwrap_or_default(); (posts, seeds, personas) }; Ok(Self::decrypt_posts(raw, &group_seeds, &personas)) } /// Attempt to decrypt each post using all held posting identities as /// candidate recipients. The first persona whose secret matches a /// wrapped_key recipient wins; if none match, the post remains opaque. fn decrypt_posts( posts: Vec<(PostId, Post, PostVisibility)>, group_seeds: &std::collections::HashMap<(crate::types::GroupId, crate::types::GroupEpoch), ([u8; 32], [u8; 32])>, personas: &[crate::types::PostingIdentity], ) -> Vec<(PostId, Post, PostVisibility, Option)> { posts .into_iter() .map(|(id, post, vis)| { let decrypted = match &vis { PostVisibility::Public => None, PostVisibility::Encrypted { recipients } => { personas.iter().find_map(|pi| { crypto::decrypt_post( &post.content, &pi.secret_seed, &pi.node_id, &post.author, recipients, ) .ok() .flatten() }) } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { group_seeds.get(&(*group_id, *epoch)) .and_then(|(seed, pubkey)| { crypto::decrypt_group_post( &post.content, seed, pubkey, wrapped_cek, ).ok() }) } // FoF Layer 3: FoFClosed body decrypt requires // trial-unlocking via the post's wrap_slots against // every persona's received-vouch keyring — which is // an async storage lookup, not available in this // sync helper. Feed rendering for FoFClosed posts // goes through a dedicated async path that resolves // the unlock + decrypts; this helper returns None // and lets the caller fall back. PostVisibility::FoFClosed => None, }; (id, post, vis, decrypted) }) .collect() } // ---- Follows ---- pub async fn follow(&self, node_id: &NodeId) -> anyhow::Result<()> { let connected = self.network.is_connected(node_id).await; let storage = self.storage.get().await; storage.add_follow(node_id)?; // 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)?; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) .unwrap_or_default().as_millis() as u64; storage.upsert_social_route(&SocialRouteEntry { node_id: *node_id, addresses, peer_addresses, relation: SocialRelation::Follow, status: if connected { SocialStatus::Online } else { SocialStatus::Disconnected }, last_connected_ms: 0, last_seen_ms: now, reach_method: ReachMethod::Direct, })?; Ok(()) } pub async fn unfollow(&self, node_id: &NodeId) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.remove_follow(node_id)?; // v0.6.2: audience removed; unfollow drops the social route entirely. storage.remove_social_route(node_id)?; Ok(()) } pub async fn list_follows(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_follows() } /// Batch: for each followed author, return the last-post timestamp we /// hold locally. Used by the Following UI to sort by recency (which /// replaces the broken "online" indicator since the network/posting /// key split anonymized presence). pub async fn last_activity_for_follows(&self) -> anyhow::Result> { let storage = self.storage.get().await; let follows = storage.list_follows()?; storage.last_activity_for_authors(&follows) } // ---- Ignored peers ---- pub async fn ignore_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.add_ignored_peer(node_id)?; // If the peer was in follows, also drop them — ignoring implies // no-longer-following. Best-effort; errors are logged by callers. let _ = storage.remove_follow(node_id); let _ = storage.remove_social_route(node_id); Ok(()) } pub async fn unignore_peer(&self, node_id: &NodeId) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.remove_ignored_peer(node_id) } pub async fn list_ignored_peers(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_ignored_peers() } // ---- Discover ---- /// Named peers we aren't following and haven't ignored — driven entirely /// by signed profile posts we've received through the CDN. pub async fn list_discoverable_profiles(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_discoverable_profiles(&self.default_posting_id) } // ---- Profiles ---- /// Set the default posting identity's profile (display_name, bio, /// preserving any existing avatar). Creates a signed /// `VisibilityIntent::Profile` post authored by the posting identity and /// propagates it via the normal neighbor-manifest CDN path. The locally /// stored profile row is keyed by the posting identity — peers who pull /// the profile post apply the same update on their side. pub async fn set_profile(&self, display_name: String, bio: String) -> anyhow::Result { let posting_id = self.default_posting_id; let posting_secret = self.default_posting_secret; // Preserve existing avatar if present. let avatar_cid = { let storage = self.storage.get().await; storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid) }; // FoF Layer 1: build the vouch-grant batch (if this persona has // any current vouch targets) + bump bio_epoch. // v0.8 (A3): attach the Greeting open slot when consent is on. let (vouch_grants, bio_epoch, gating_built) = { let storage = self.storage.get().await; let batch = crate::profile::build_vouch_grant_batch(&*storage, &posting_id)?; let epoch = storage.next_bio_epoch_for(&posting_id)?; let gating = if greetings_open_setting(&storage, &posting_id) { crate::fof::build_fof_comment_gating( &*storage, &posting_id, Some(greeting_open_slot_spec(&storage, &posting_id)), )? } else { None }; (batch, epoch, gating) }; let profile_post = crate::profile::build_profile_post( &posting_id, &posting_secret, &display_name, &bio, avatar_cid, vouch_grants, bio_epoch, gating_built.as_ref().map(|b| b.gating.clone()), ); let profile_post_id = crate::content::compute_post_id(&profile_post); let timestamp_ms = profile_post.timestamp_ms; // Store post with VisibilityIntent::Profile + apply (upserts profile row). // If naming the fresh-install auto-gen persona with a non-empty name, // clear the disposability marker — user has claimed this persona. { let storage = self.storage.get().await; storage.store_post_with_intent( &profile_post_id, &profile_post, &PostVisibility::Public, &VisibilityIntent::Profile, )?; crate::profile::apply_profile_post_if_applicable( &*storage, &profile_post, Some(&VisibilityIntent::Profile), )?; if let Some(built) = &gating_built { persist_gated_post_author_state(&storage, &posting_id, &profile_post_id, built); } if !display_name.is_empty() { if let Ok(Some(marker)) = storage.get_setting("first_run_auto_persona_id") { if marker == hex::encode(posting_id) { let _ = storage.delete_setting("first_run_auto_persona_id"); } } } // Keep posting_identities.display_name in sync with the // profile post so the Personas list and any UI reading // PostingIdentity sees the current name (not the original // empty/auto-gen one). The upsert preserves the persona's // secret_seed / created_at; only display_name changes. if let Ok(Some(existing)) = storage.get_posting_identity(&posting_id) { let updated = crate::types::PostingIdentity { node_id: existing.node_id, secret_seed: existing.secret_seed, display_name: display_name.clone(), created_at: existing.created_at, }; let _ = storage.upsert_posting_identity(&updated); } } // Propagate via neighbor-manifest header diffs like any other post. self.update_neighbor_manifests_as( &posting_id, &posting_secret, &profile_post_id, timestamp_ms, ).await; let profile = { let storage = self.storage.get().await; storage.get_profile(&posting_id)? .unwrap_or_else(|| PublicProfile { node_id: posting_id, display_name: display_name.clone(), bio: bio.clone(), updated_at: timestamp_ms, anchors: vec![], recent_peers: vec![], public_visible: true, avatar_cid, }) }; info!( posting_id = hex::encode(posting_id), profile_post_id = hex::encode(profile_post_id), "Published profile post" ); Ok(profile) } pub async fn set_anchors(&self, anchors: Vec) -> anyhow::Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let recent_peers = self.current_recent_peers().await; let profile = { let storage = self.storage.get().await; // v0.8: the network-id-keyed profile row is TOPOLOGY ONLY // (anchors, recent_peers). Persona fields // (display_name, bio, avatar_cid, public_visible) live // exclusively on posting-id-keyed rows written by profile // posts — never copy them onto the network row, or a legacy // unified-id row would keep re-linking persona to device. let profile = PublicProfile { node_id: self.node_id, display_name: String::new(), bio: String::new(), updated_at: now, anchors, recent_peers, public_visible: true, avatar_cid: None, }; storage.store_profile(&profile)?; profile }; let pushed = self.network.push_profile(&profile).await; if pushed > 0 { info!(pushed, "Pushed anchor update to peers"); } Ok(profile) } pub async fn get_peer_anchors(&self, node_id: &NodeId) -> anyhow::Result> { let storage = self.storage.get().await; storage.get_peer_anchors(node_id) } pub async fn get_profile(&self, node_id: &NodeId) -> anyhow::Result> { let storage = self.storage.get().await; storage.get_profile(node_id) } /// v0.6.2: the user's own display profile lives under the default /// posting identity (published as a signed Profile post), not the /// network NodeId. pub async fn my_profile(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.get_profile(&self.default_posting_id) } pub async fn has_profile(&self) -> anyhow::Result { let storage = self.storage.get().await; Ok(storage.get_profile(&self.default_posting_id)?.is_some()) } pub async fn get_display_name(&self, node_id: &NodeId) -> anyhow::Result> { let storage = self.storage.get().await; storage.get_display_name(node_id) } // ---- FoF Layer 1: Vouches ---- /// Vouch for a persona from the current default posting identity. /// Inserts into `own_vouch_targets` and republishes the bio post so /// the recipient sees the vouch on their next scan. pub async fn vouch_for_peer(&self, target: &NodeId) -> anyhow::Result<()> { let (default_id, display_name, bio, avatar_cid, posting_secret) = { let storage = self.storage.get().await; let Some(default_id) = storage.get_default_posting_id()? else { anyhow::bail!("no default posting identity"); }; let pi = storage.get_posting_identity(&default_id)? .ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?; let profile = storage.get_profile(&default_id)?; let (name, bio, avatar) = match profile { Some(p) => (p.display_name, p.bio, p.avatar_cid), None => (pi.display_name.clone(), String::new(), None), }; (default_id, name, bio, avatar, pi.secret_seed) }; // Convert the target's ed25519 NodeId to its X25519 pubkey via // the same Montgomery derivation receivers use. let target_x25519_pub = crate::crypto::ed25519_pubkey_to_x25519_public(target)?; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; { let storage = self.storage.get().await; storage.upsert_vouch_target(&default_id, target, &target_x25519_pub, now_ms, true)?; } // Republish bio post so the new vouch_grants batch propagates. self.publish_profile_post_as( &default_id, &posting_secret, &display_name, &bio, avatar_cid, ).await?; Ok(()) } /// FoF Layer 4: pure V_me rotation. Generates a new V_me epoch for /// the default persona without revoking any vouchee. Republishes /// the persona's bio post under the new key for every current /// target. Used for periodic refresh or leak response (combined /// with `cascade_revoke_v_me_epoch` for old-content cleanup + /// `key_burn_post` for leaked-key scenarios). /// /// Returns the new epoch number. pub async fn rotate_v_me(&self) -> anyhow::Result { use rand::RngCore; let (default_id, display_name, bio, avatar_cid, posting_secret, new_epoch) = { let storage = self.storage.get().await; let Some(default_id) = storage.get_default_posting_id()? else { anyhow::bail!("no default posting identity"); }; let pi = storage.get_posting_identity(&default_id)? .ok_or_else(|| anyhow::anyhow!("default posting identity missing"))?; let profile = storage.get_profile(&default_id)?; let (name, bio, avatar) = match profile { Some(p) => (p.display_name, p.bio, p.avatar_cid), None => (pi.display_name.clone(), String::new(), None), }; let next_epoch = storage.current_own_vouch_key(&default_id)? .map(|(e, _)| e + 1) .unwrap_or(1); let mut new_key = [0u8; 32]; rand::rng().fill_bytes(&mut new_key); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?; (default_id, name, bio, avatar, pi.secret_seed, next_epoch) }; // Republish bio so existing vouch targets receive the new key. self.publish_profile_post_as( &default_id, &posting_secret, &display_name, &bio, avatar_cid, ).await?; Ok(new_epoch) } /// FoF Layer 4: cascade revocation. For every FoF post authored by /// the default persona where slots were sealed under V_me at /// `retired_epoch`, publish a per-pub_x revocation diff. Existing /// stored comments by those pub_x's are cascade-deleted via the /// standard apply_fof_revocation path. /// /// Returns the number of post-level revocations published. /// Typically called after `rotate_v_me` when the user wants to /// retire access for vouchees they no longer want commenting on /// old posts. Optional — by default rotation grandfathers old /// posts. pub async fn cascade_revoke_v_me_epoch( &self, retired_epoch: u32, reason_code: u8, ) -> anyhow::Result { // Look up all (post_id, pub_x) pairs sealed under (self, retired_epoch). let pairs = { let storage = self.storage.get().await; storage.list_provenance_for_v_x_epoch( &self.default_posting_id, &self.default_posting_id, retired_epoch, )? }; let mut published = 0usize; for (post_id, _pub_x, slot_index) in pairs { // Use the existing per-post revocation helper. It signs + // applies locally + propagates. if self.revoke_fof_commenter(post_id, slot_index, reason_code).await.is_ok() { published += 1; } } Ok(published) } /// Revoke a vouch + rotate V_me. Per Scott's design: revocation IS /// the rotation primitive. The new V_me_epoch is generated and the /// bio post is republished with wrappers for every remaining target /// (current=1); the revoked persona only ever held the old V_me, so /// they're frozen out of future content but retain access to old /// content (grandfathered) per Layer 4. pub async fn revoke_vouch_and_rotate(&self, target: &NodeId) -> anyhow::Result<()> { use rand::RngCore; let (default_id, display_name, bio, avatar_cid, posting_secret) = { let storage = self.storage.get().await; let Some(default_id) = storage.get_default_posting_id()? else { anyhow::bail!("no default posting identity"); }; let pi = storage.get_posting_identity(&default_id)? .ok_or_else(|| anyhow::anyhow!("default posting identity not in storage"))?; let profile = storage.get_profile(&default_id)?; let (name, bio, avatar) = match profile { Some(p) => (p.display_name, p.bio, p.avatar_cid), None => (pi.display_name.clone(), String::new(), None), }; (default_id, name, bio, avatar, pi.secret_seed) }; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; { let storage = self.storage.get().await; // Soft-revoke: drop from current set (row retained for the // audit trail + cascade-pickup later if needed). storage.revoke_vouch_target(&default_id, target)?; // Rotate V_me: pick the next epoch, insert as current. Prior // epoch retained (Layer 4 receiver-chain model). let next_epoch = storage.current_own_vouch_key(&default_id)? .map(|(e, _)| e + 1) .unwrap_or(1); let mut new_key = [0u8; 32]; rand::rng().fill_bytes(&mut new_key); storage.insert_own_vouch_key(&default_id, next_epoch, &new_key, now_ms)?; } // Republish bio post — new V_me wrapped to every still-current target. self.publish_profile_post_as( &default_id, &posting_secret, &display_name, &bio, avatar_cid, ).await?; Ok(()) } /// List vouches the default persona has issued. Returns /// `(target_node_id, display_name, granted_at_ms)` tuples. pub async fn list_vouches_given(&self) -> anyhow::Result> { let (default_id, targets) = { let storage = self.storage.get().await; let Some(default_id) = storage.get_default_posting_id()? else { return Ok(Vec::new()); }; let targets = storage.list_current_vouch_targets(&default_id)?; (default_id, targets) }; let _ = default_id; let mut out = Vec::with_capacity(targets.len()); for (tid, _xpub, at) in targets { let display = match self.resolve_display_name(&tid).await { Ok((name, _, _)) if !name.is_empty() => name, _ => String::new(), }; out.push((tid, display, at)); } Ok(out) } /// List vouches received by the default persona. Returns /// `(voucher_node_id, display_name, latest_epoch, latest_received_at_ms)`. pub async fn list_vouches_received(&self) -> anyhow::Result> { let (default_id, vouchers) = { let storage = self.storage.get().await; let Some(default_id) = storage.get_default_posting_id()? else { return Ok(Vec::new()); }; let vouchers = storage.list_vouchers_for(&default_id)?; (default_id, vouchers) }; let _ = default_id; let mut out = Vec::with_capacity(vouchers.len()); for (owner, epoch, at) in vouchers { let display = match self.resolve_display_name(&owner).await { Ok((name, _, _)) if !name.is_empty() => name, _ => String::new(), }; out.push((owner, display, epoch, at)); } Ok(out) } // ---- Blobs ---- /// Get a blob by CID from local store. pub async fn get_blob(&self, cid: &[u8; 32]) -> anyhow::Result>> { let data = self.blob_store.get(cid)?; if data.is_some() { let storage = self.storage.get().await; let _ = storage.touch_blob_access(cid); } Ok(data) } /// Decrypt a blob in the context of a post's visibility. /// Public posts pass through unchanged. Encrypted/group-encrypted posts decrypt with the CEK. fn decrypt_blob_for_post( &self, data: Vec, post: &Post, visibility: &PostVisibility, group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>, personas: &[crate::types::PostingIdentity], ) -> anyhow::Result>> { match visibility { PostVisibility::Public => Ok(Some(data)), PostVisibility::Encrypted { recipients } => { // Recipients are POSTING ids — try every persona's (id, seed) // pair, same as decrypt_posts does for post bodies. let cek = personas.iter().find_map(|pi| { crypto::unwrap_cek_for_recipient( &pi.secret_seed, &pi.node_id, &post.author, recipients, ) .ok() .flatten() }); match cek { Some(cek) => { let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; Ok(Some(plaintext)) } None => Ok(None), } } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) { let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?; let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?; Ok(Some(plaintext)) } else { Ok(None) } } // FoF Layer 3: blob decryption for FoFClosed posts requires // the CEK recovered via wrap_slots. This sync helper doesn't // have storage access for the keyring trial-unlock; the // async caller path goes through get_blob_for_post which // can perform the unlock. For now return None — blob // decryption for FoF posts is wired in the receive/render // slice. (v0 ships with FoF body decryption only; binary // attachments arrive in a follow-up.) PostVisibility::FoFClosed => Ok(None), } } /// Get a blob by CID, decrypting it in the context of the given post. /// For public posts, returns raw blob data. For encrypted posts, decrypts with the post's CEK. pub async fn get_blob_for_post( &self, cid: &[u8; 32], post_id: &PostId, ) -> anyhow::Result>> { // Get raw blob data (local — no lock needed) let raw_data = match self.blob_store.get(cid)? { Some(d) => d, None => return Ok(None), }; // Single lock acquisition for all DB reads let (post, visibility, group_seeds, personas) = { let storage = self.storage.get().await; let _ = storage.touch_blob_access(cid); match storage.get_post_with_visibility(post_id)? { Some((post, vis)) => { let seeds = if matches!(vis, PostVisibility::GroupEncrypted { .. }) { storage.get_all_group_seeds_map().unwrap_or_default() } else { std::collections::HashMap::new() }; let personas = if matches!(vis, PostVisibility::Encrypted { .. }) { storage.list_posting_identities().unwrap_or_default() } else { Vec::new() }; (post, vis, seeds, personas) } None => return Ok(Some(raw_data)), // No post context — return raw } }; // Lock released — decrypt without lock match &visibility { PostVisibility::Public => Ok(Some(raw_data)), _ => self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds, &personas), } } /// Prefetch blobs for recently synced posts from a peer. /// Scans recent posts (newest first) for missing blobs, caps at 20 per cycle. /// Runs outside any locks. const MAX_PREFETCH_PER_CYCLE: usize = 20; pub async fn prefetch_blobs_from_peer(&self, peer_id: &NodeId) { // Brief lock: get post IDs and their attachment info let posts_with_atts: Vec<(PostId, NodeId, Vec)> = { let storage = self.storage.get().await; let post_ids = storage.list_post_ids().unwrap_or_default(); let mut result = Vec::new(); for pid in post_ids { if result.len() >= Self::MAX_PREFETCH_PER_CYCLE { break; } if let Ok(Some(post)) = storage.get_post(&pid) { if !post.attachments.is_empty() { result.push((pid, post.author, post.attachments.clone())); } } } result }; // Lock released — check blob store and filter without lock let mut missing: Vec<(PostId, NodeId, Vec)> = Vec::new(); let mut total_missing = 0usize; for (pid, author, atts) in posts_with_atts { if total_missing >= Self::MAX_PREFETCH_PER_CYCLE { break; } let missing_atts: Vec<_> = atts.into_iter() .filter(|a| !self.blob_store.has(&a.cid)) .collect(); if !missing_atts.is_empty() { total_missing += missing_atts.len(); missing.push((pid, author, missing_atts)); } } if missing.is_empty() { return; } let mut fetched = 0usize; for (post_id, author, attachments) in &missing { for att in attachments { if fetched >= Self::MAX_PREFETCH_PER_CYCLE { break; } match self.fetch_blob_with_fallback( &att.cid, post_id, author, &att.mime_type, 0, ).await { Ok(Some(_)) => { fetched += 1; } Ok(None) => {} Err(e) => { tracing::debug!( cid = hex::encode(att.cid), error = %e, "Blob prefetch failed" ); } } } } if fetched > 0 { tracing::info!(fetched, peer = hex::encode(peer_id), "Prefetched blobs after sync"); } } /// Check if a blob exists locally. pub fn has_blob(&self, cid: &[u8; 32]) -> bool { self.blob_store.has(cid) } /// Fetch a blob from a peer, storing it locally and recording CDN metadata. pub async fn fetch_blob_from_peer( &self, cid: &[u8; 32], from_peer: &NodeId, post_id: &PostId, author: &NodeId, mime_type: &str, created_at: u64, ) -> anyhow::Result>> { // Check local first if let Some(data) = self.blob_store.get(cid)? { return Ok(Some(data)); } // Fetch with CDN metadata let (data, response) = self.network.fetch_blob_full(cid, from_peer).await?; if let Some(ref data) = data { // Store blob locally self.blob_store.store(cid, data)?; let storage = self.storage.get().await; storage.record_blob(cid, post_id, author, data.len() as u64, mime_type, created_at)?; // Store AuthorManifest if provided (extract from CdnManifest wrapper) if let Some(ref cdn_manifest) = response.manifest { if crypto::verify_manifest_signature(&cdn_manifest.author_manifest) { let author_json = serde_json::to_string(&cdn_manifest.author_manifest).unwrap_or_default(); let _ = storage.store_cdn_manifest( cid, &author_json, &cdn_manifest.author_manifest.author, cdn_manifest.author_manifest.updated_at, ); } } // Record upstream source. v0.8: manifests carry no addresses; // the holder's address is already known from the live connection // (peers table) — record the holder id with no manifest addrs. let _ = storage.touch_file_holder( cid, from_peer, &[], crate::storage::HolderDirection::Received, ); } Ok(data) } /// Fetch a blob with CDN-aware cascade, preferring non-anchor sources to save anchor /// delivery budget: /// 1. Local → 2. Existing upstream → 3. Lateral peers (non-anchor first) /// → 4. Replicas → 5. Author → 6. Redirect peers /// Anchors are deprioritized at each step via storage-level ordering. pub async fn fetch_blob_with_fallback( &self, cid: &[u8; 32], post_id: &PostId, author: &NodeId, mime_type: &str, created_at: u64, ) -> anyhow::Result>> { // 1. Check local if let Some(data) = self.blob_store.get(cid)? { let storage = self.storage.get().await; let _ = storage.touch_blob_access(cid); return Ok(Some(data)); } // Collect redirect peers from responses in case we need them later let mut redirect_peers: Vec = Vec::new(); // 2. Try known holders (up to 5 most-recent peers we've interacted // with about this file). let known_holders = { let storage = self.storage.get().await; storage.get_file_holders(cid).unwrap_or_default() }; for (holder_nid, _addrs) in &known_holders { match self.fetch_blob_from_peer(cid, holder_nid, post_id, author, mime_type, created_at).await { Ok(Some(data)) => return Ok(Some(data)), Ok(None) => {} Err(e) => warn!(error = %e, "blob fetch from known holder failed"), } } // 3. Lateral N0-N2: mesh peers + N2 peers who have the author's posts // (sorted by get_lateral_blob_sources: non-anchors first) let lateral_sources = { let storage = self.storage.get().await; storage.get_lateral_blob_sources(author, post_id).unwrap_or_default() }; for lateral in lateral_sources { if lateral == *author { continue; // Author tried separately below } match self.network.fetch_blob_full(cid, &lateral).await { Ok((Some(data), response)) => { self.blob_store.store(cid, &data)?; let storage = self.storage.get().await; storage.record_blob(cid, post_id, author, data.len() as u64, mime_type, created_at)?; if let Some(ref cdn_manifest) = response.manifest { if crypto::verify_manifest_signature(&cdn_manifest.author_manifest) { let author_json = serde_json::to_string(&cdn_manifest.author_manifest).unwrap_or_default(); let _ = storage.store_cdn_manifest(cid, &author_json, &cdn_manifest.author_manifest.author, cdn_manifest.author_manifest.updated_at); } } let _ = storage.touch_file_holder( cid, &lateral, &[], crate::storage::HolderDirection::Received, ); return Ok(Some(data)); } Ok((None, response)) => { redirect_peers.extend(response.cdn_redirect_peers); } Err(e) => warn!(peer = hex::encode(lateral), error = %e, "lateral blob fetch failed"), } } // 4. Try replica peers (before author — replicas are often closer/cheaper) let replicas = { let storage = self.storage.get().await; storage.get_replica_peers(post_id, 3_600_000)? }; for replica in replicas { match self.fetch_blob_from_peer(cid, &replica, post_id, author, mime_type, created_at).await { Ok(Some(data)) => return Ok(Some(data)), Ok(None) => {} Err(e) => warn!(peer = hex::encode(replica), error = %e, "blob fetch from replica failed"), } } // 5. Try author match self.fetch_blob_from_peer(cid, author, post_id, author, mime_type, created_at).await { Ok(Some(data)) => return Ok(Some(data)), Ok(None) => {} Err(e) => warn!(error = %e, "blob fetch from author failed"), } // 6. Try redirect peers (from any step that returned cdn_redirect_peers) for rp in &redirect_peers { if let Ok(nid_bytes) = hex::decode(&rp.n) { if let Ok(nid) = <[u8; 32]>::try_from(nid_bytes.as_slice()) { match self.fetch_blob_from_peer(cid, &nid, post_id, author, mime_type, created_at).await { Ok(Some(data)) => return Ok(Some(data)), Ok(None) => {} Err(e) => warn!(peer = &rp.n, error = %e, "redirect blob fetch failed"), } } } } Ok(None) } // ---- Circles ---- pub async fn create_circle(&self, name: String) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.create_circle(&name)?; drop(storage); self.create_group_key_for_circle(&name).await?; Ok(()) } pub async fn delete_circle(&self, name: String) -> anyhow::Result<()> { let storage = self.storage.get().await; // Delete group key and associated data if let Ok(Some(gk)) = storage.get_group_key_by_circle(&name) { let _ = storage.delete_group_key(&gk.group_id); } storage.delete_circle(&name) } pub async fn add_to_circle(&self, circle_name: String, node_id: NodeId) -> anyhow::Result<()> { { let storage = self.storage.get().await; storage.add_circle_member(&circle_name, &node_id)?; } // v0.6.2: distribute the seed via an encrypted key-distribution // post (CDN-propagated), replacing the direct GroupKeyDistribute // push. Only the admin (holder of the group seed) does this. let post_to_propagate: Option<(PostId, u64, NodeId, [u8; 32])> = { let storage = self.storage.get().await; if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) { // "Am I the admin?" = admin ∈ ALL my posting identities; use // the MATCHED persona's (id, seed) pair for all crypto below. if let Ok(Some(admin_persona)) = storage.get_posting_identity(&gk.admin) { if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) { // Record our own wrapped member key locally (so we // still track membership in group_member_keys for // rotation math). if let Ok(wrapped_new) = crypto::wrap_group_key_for_member( &admin_persona.secret_seed, &node_id, &seed, ) { let _ = storage.store_group_member_key( &gk.group_id, &crate::types::GroupMemberKey { member: node_id, epoch: gk.epoch, wrapped_group_key: wrapped_new, }, ); } match crate::group_key_distribution::build_distribution_post( &admin_persona.node_id, &admin_persona.secret_seed, &gk, &seed, &[node_id], ) { Ok((post_id, post, visibility)) => { storage.store_post_with_intent( &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, )?; Some((post_id, post.timestamp_ms, admin_persona.node_id, admin_persona.secret_seed)) } Err(e) => { warn!(error = %e, "failed to build key-distribution post"); None } } } else { None } } else { None } } else { None } }; if let Some((post_id, ts, posting_id, posting_secret)) = post_to_propagate { self.update_neighbor_manifests_as(&posting_id, &posting_secret, &post_id, ts).await; } Ok(()) } pub async fn remove_from_circle( &self, circle_name: String, node_id: NodeId, ) -> anyhow::Result<()> { { let storage = self.storage.get().await; storage.remove_circle_member(&circle_name, &node_id)?; } // Rotate group key if we're the admin self.rotate_group_key(&circle_name).await; Ok(()) } /// Create a group key for a circle (called on circle creation). async fn create_group_key_for_circle(&self, circle_name: &str) -> anyhow::Result<()> { self.create_group_key_inner(circle_name, None).await } // ---- Groups (v0.6.2) ---- /// Create a new group anchored at `root_post_id`. Unlike circles, groups /// are many-way: every member can post to the group once they've /// received the wrapped group seed. Returns the `(GroupId, circle_name)` /// pair used internally; the circle_name is synthesised from the root /// post id so there's no user-visible naming step. pub async fn create_group_from_post( &self, root_post_id: PostId, initial_members: Vec, ) -> anyhow::Result<(crate::types::GroupId, String)> { let circle_name = format!("group:{}", hex::encode(&root_post_id[..6])); // Create the backing circle row + initialize group key with // canonical_root_post_id set, then add each initial member (which // wraps + distributes the key). { let storage = self.storage.get().await; storage.create_circle(&circle_name)?; } self.create_group_key_inner(&circle_name, Some(root_post_id)).await?; // initial_members are posting ids — skip ALL of our own personas, // not the network NodeId (which never appears in member lists). let own_posting_ids: Vec = { let storage = self.storage.get().await; storage.list_posting_identities()? .into_iter().map(|p| p.node_id).collect() }; for member in initial_members { if own_posting_ids.contains(&member) { continue; } if let Err(e) = self.add_to_circle(circle_name.clone(), member).await { warn!(member = hex::encode(member), error = %e, "failed to add group member"); } } let group_id = { let storage = self.storage.get().await; storage.get_group_key_by_circle(&circle_name)? .map(|gk| gk.group_id) .ok_or_else(|| anyhow::anyhow!("group key missing after creation"))? }; info!( root = hex::encode(root_post_id), group_id = hex::encode(group_id), circle_name = %circle_name, "Created group from post" ); Ok((group_id, circle_name)) } /// Post to a group anchored at `root_post_id`. Any member holding the /// group seed can call this. Encrypts the content with the group key and /// records a `ThreadMeta` link from the new post back to the root so /// `list_group_posts_by_root` can later cluster all contributions. pub async fn post_to_group( &self, root_post_id: PostId, content: String, attachment_data: Vec<(Vec, String)>, ) -> anyhow::Result<(PostId, Post, PostVisibility)> { let circle_name = { let storage = self.storage.get().await; storage.get_group_by_canonical_root(&root_post_id)? .map(|gk| gk.circle_name) .ok_or_else(|| anyhow::anyhow!("no group found for canonical root post"))? }; let result = self.create_post_with_visibility( content, VisibilityIntent::Circle(circle_name), attachment_data, ).await?; // Link the new post back to the canonical root so the group can be // reconstructed by `list_group_posts_by_root`. { let storage = self.storage.get().await; storage.store_thread_meta(&crate::types::ThreadMeta { post_id: result.0, parent_post_id: root_post_id, })?; } Ok(result) } /// List all posts that belong to the group rooted at `root_post_id`. /// Reads the ThreadMeta parent index + returns the full posts. Callers /// decrypt as needed (same as any other GroupEncrypted content). pub async fn list_group_posts_by_root( &self, root_post_id: PostId, ) -> anyhow::Result> { let storage = self.storage.get().await; let child_ids = storage.get_thread_children(&root_post_id)?; let mut out = Vec::with_capacity(child_ids.len()); for pid in child_ids { if let Some((post, vis)) = storage.get_post_with_visibility(&pid)? { out.push((pid, post, vis)); } } Ok(out) } // ---- end Groups ---- // ---- Announcements ---- /// Publish a signed network-wide announcement. Only succeeds when run /// on the bootstrap anchor — the default posting identity must be /// `DEFAULT_ANCHOR_POSTING_ID`. Called from `itsgoin announce` during /// release deploys. pub async fn publish_announcement( &self, category: String, title: String, body: String, release: Option, ) -> anyhow::Result { if self.default_posting_id != crate::DEFAULT_ANCHOR_POSTING_ID { anyhow::bail!( "refusing to publish announcement: default posting identity is not the bootstrap anchor" ); } let post = crate::announcement::build_announcement_post( &self.default_posting_id, &self.default_posting_secret, &category, &title, &body, release, ); let post_id = crate::content::compute_post_id(&post); let timestamp_ms = post.timestamp_ms; { let storage = self.storage.get().await; storage.store_post_with_intent( &post_id, &post, &PostVisibility::Public, &VisibilityIntent::Announcement, )?; crate::announcement::apply_announcement_if_applicable( &*storage, &post, Some(&VisibilityIntent::Announcement), )?; } self.update_neighbor_manifests_as( &self.default_posting_id, &self.default_posting_secret, &post_id, timestamp_ms, ).await; info!( post_id = hex::encode(post_id), category = %category, "Published network-wide announcement" ); Ok(post_id) } /// Return the latest stored release announcement for the given channel /// (\"stable\" or \"beta\"), or `None` if none is known yet. pub async fn latest_release_announcement( &self, channel: &str, ) -> anyhow::Result> { let storage = self.storage.get().await; crate::announcement::latest_release(&*storage, channel) } /// Scan any newly-received `VisibilityIntent::GroupKeyDistribute` posts /// and apply ones we can decrypt with one of our posting identities. /// Intended to run after a sync pass so group seeds propagate to members /// without a direct push. Returns the count of applied distributions. pub async fn process_group_key_distributions(&self) -> anyhow::Result { let storage = self.storage.get().await; let personas = storage.list_posting_identities()?; crate::group_key_distribution::process_pending(&*storage, &personas) } /// Shared group-key creation used by both circles (canonical_root=None) /// and groups (canonical_root=Some). async fn create_group_key_inner( &self, circle_name: &str, canonical_root_post_id: Option, ) -> anyhow::Result<()> { let (seed, pubkey) = crypto::generate_group_keypair(); let group_id = crypto::compute_group_id(&pubkey); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // The admin of a group/circle is a POSTING identity (persona), never // the network NodeId: the wire format ships admin == post.author // (a posting id) and receivers verify exactly that // (group_key_distribution.rs). Creation currently always acts as the // default persona. let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.to_string(), epoch: 1, group_public_key: pubkey, admin: self.default_posting_id, created_at: now, canonical_root_post_id, }; let storage = self.storage.get().await; storage.create_group_key(&record, Some(&seed))?; storage.store_group_seed(&group_id, 1, &seed)?; // Wrap for ourselves (as the admin persona — member rows are keyed // by posting ids so the wrapped key is actually unwrappable). let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.default_posting_id, &seed)?; let self_mk = crate::types::GroupMemberKey { member: self.default_posting_id, epoch: 1, wrapped_group_key: self_wrapped, }; storage.store_group_member_key(&group_id, &self_mk)?; // Wrap for existing circle members (if any) and distribute the seed // via a single encrypted key-distribution post. v0.6.2 replaces the // per-member uni-stream GroupKeyDistribute push with this // CDN-propagated post (one post per epoch, recipients = all non-self // members). Circle members are posting ids — strip ALL our personas. let own_posting_ids: Vec = storage.list_posting_identities()? .into_iter().map(|p| p.node_id).collect(); let other_members: Vec = storage.get_circle_members(circle_name)? .into_iter() .filter(|m| !own_posting_ids.contains(m)) .collect(); for member in &other_members { if let Ok(wrapped) = crypto::wrap_group_key_for_member( &self.default_posting_secret, member, &seed, ) { let _ = storage.store_group_member_key( &group_id, &crate::types::GroupMemberKey { member: *member, epoch: 1, wrapped_group_key: wrapped, }, ); } } drop(storage); if !other_members.is_empty() { match crate::group_key_distribution::build_distribution_post( &self.default_posting_id, &self.default_posting_secret, &record, &seed, &other_members, ) { Ok((post_id, post, visibility)) => { let ts = post.timestamp_ms; { let storage = self.storage.get().await; storage.store_post_with_intent( &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, )?; } self.update_neighbor_manifests_as( &self.default_posting_id, &self.default_posting_secret, &post_id, ts, ).await; } Err(e) => { warn!(error = %e, "failed to build key-distribution post"); } } } info!(circle = %circle_name, group_id = hex::encode(group_id), "Created group key for circle"); Ok(()) } /// Rotate the group key for a circle (called on member removal). async fn rotate_group_key(&self, circle_name: &str) { let rotate_result = { let storage = self.storage.get().await; let gk = match storage.get_group_key_by_circle(circle_name) { Ok(Some(gk)) => gk, _ => return, }; // "Am I the admin?" = admin ∈ my posting identities. Use the // matched persona's (id, seed) for wrapping + signing. let admin_persona = match storage.get_posting_identity(&gk.admin) { Ok(Some(p)) => p, _ => return, }; let remaining_members = match storage.get_circle_members(circle_name) { Ok(m) => m, Err(_) => return, }; // Always include ourselves — as the admin PERSONA (member sets // hold posting ids; the network NodeId must never leak into a // CDN-propagated key-distribution post). let mut all_members = remaining_members; if !all_members.contains(&admin_persona.node_id) { all_members.push(admin_persona.node_id); } match crypto::rotate_group_key(&admin_persona.secret_seed, gk.epoch, &all_members) { Ok((new_seed, new_pubkey, new_epoch, member_keys)) => { Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id, admin_persona)) } Err(e) => { warn!(error = %e, "Failed to rotate group key"); None } } }; if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root, admin_persona)) = rotate_result { // Update storage let own_posting_ids: Vec = { let storage = self.storage.get().await; let _ = storage.update_group_epoch(&group_id, new_epoch, &new_pubkey, Some(&new_seed)); let _ = storage.store_group_seed(&group_id, new_epoch, &new_seed); for mk in &member_keys { let _ = storage.store_group_member_key(&group_id, mk); } storage.list_posting_identities() .unwrap_or_default() .into_iter().map(|p| p.node_id).collect() }; // v0.6.2: distribute the new seed via an encrypted // key-distribution post instead of per-member unicast pushes. // Strip ALL our personas (never just the default one) so no // self-addressed wrapped CEK rides a propagated post. let recipients: Vec = member_keys .iter() .map(|mk| mk.member) .filter(|m| !own_posting_ids.contains(m)) .collect(); if !recipients.is_empty() { let record = crate::types::GroupKeyRecord { group_id, circle_name: circle_name.clone(), epoch: new_epoch, group_public_key: new_pubkey, admin: admin_persona.node_id, created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0), canonical_root_post_id: canonical_root, }; match crate::group_key_distribution::build_distribution_post( &admin_persona.node_id, &admin_persona.secret_seed, &record, &new_seed, &recipients, ) { Ok((post_id, post, visibility)) => { let ts = post.timestamp_ms; { let storage = self.storage.get().await; let _ = storage.store_post_with_intent( &post_id, &post, &visibility, &VisibilityIntent::GroupKeyDistribute, ); } self.update_neighbor_manifests_as( &admin_persona.node_id, &admin_persona.secret_seed, &post_id, ts, ).await; } Err(e) => { warn!(error = %e, "failed to build rotate distribution post"); } } } info!(circle = %circle_name, epoch = new_epoch, "Rotated group key"); } } pub async fn list_circles(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_circles() } // ---- Circle Profiles ---- /// Set a circle profile: store locally, encrypt with group key, push to connected peers. pub async fn set_circle_profile( &self, circle_name: String, display_name: String, bio: String, avatar_cid: Option<[u8; 32]>, ) -> anyhow::Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // Get group key for this circle let (cp, encrypted_payload, wrapped_cek, group_id, epoch) = { let storage = self.storage.get().await; // Verify circle exists let circles = storage.list_circles()?; if !circles.iter().any(|c| c.name == circle_name) { anyhow::bail!("circle '{}' does not exist", circle_name); } let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; // Admin is a posting identity — check membership across ALL our // personas and author the profile as the MATCHED persona so the // local row key matches what receivers store (cp.author). let admin_persona = storage.get_posting_identity(&gk.admin)? .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; let cp = crate::types::CircleProfile { author: admin_persona.node_id, circle_name: circle_name.clone(), display_name, bio, avatar_cid, updated_at: now, }; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found for circle '{}'", circle_name))?; // Encrypt circle profile as JSON let json = serde_json::to_string(&cp)?; let (encrypted, wrapped) = crypto::encrypt_post_for_group(&json, &seed, &gk.group_public_key)?; // Store plaintext + encrypted form, both keyed by the authoring // persona id (same key class as the pushed payload/remote rows). storage.set_circle_profile(&cp)?; storage.store_remote_circle_profile( &admin_persona.node_id, &circle_name, &cp, &encrypted, &wrapped, &gk.group_id, gk.epoch, )?; (cp, encrypted, wrapped, gk.group_id, gk.epoch) }; // Push to all connected mesh peers let payload = crate::protocol::CircleProfileUpdatePayload { author: cp.author, circle_name, group_id, epoch, encrypted_payload, wrapped_cek, updated_at: now, }; let pushed = self.network.push_circle_profile(&payload).await; if pushed > 0 { info!(pushed, "Pushed circle profile update to peers"); } Ok(cp) } /// Delete a circle profile and push tombstone. pub async fn delete_circle_profile(&self, circle_name: String) -> anyhow::Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let payload = { let storage = self.storage.get().await; let gk = storage.get_group_key_by_circle(&circle_name)? .ok_or_else(|| anyhow::anyhow!("no group key for circle '{}'", circle_name))?; // Admin ∈ our posting identities; the local row is keyed by that // persona id (matches set_circle_profile), so delete that row. let admin_persona = storage.get_posting_identity(&gk.admin)? .ok_or_else(|| anyhow::anyhow!("not admin of circle '{}'", circle_name))?; let seed = storage.get_group_seed(&gk.group_id, gk.epoch)? .ok_or_else(|| anyhow::anyhow!("group seed not found"))?; // Encrypt empty string as tombstone let (encrypted, wrapped) = crypto::encrypt_post_for_group("", &seed, &gk.group_public_key)?; storage.delete_circle_profile(&admin_persona.node_id, &circle_name)?; crate::protocol::CircleProfileUpdatePayload { author: admin_persona.node_id, circle_name, group_id: gk.group_id, epoch: gk.epoch, encrypted_payload: encrypted, wrapped_cek: wrapped, updated_at: now, } }; self.network.push_circle_profile(&payload).await; Ok(()) } /// Set public_visible flag on our own persona profile. /// /// v0.8: public_visible is a persona-class flag (it gates persona display /// fields), so it lives on the posting-id-keyed profile row — matching /// publish_profile / my_profile — NOT the network-id row. No wire push: /// the ProfileUpdate receive path blind-REPLACEs rows, so pushing a /// sanitized (persona-free) posting-id profile would wipe receivers' /// stored display fields for this persona. The flag propagates locally; /// carrying it in ProfilePostContent is tracked future work. pub async fn set_public_visible(&self, visible: bool) -> anyhow::Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let storage = self.storage.get().await; let pid = self.default_posting_id; let profile = match storage.get_profile(&pid)? { Some(mut p) => { p.public_visible = visible; p.updated_at = now; p } None => PublicProfile { node_id: pid, display_name: String::new(), bio: String::new(), updated_at: now, anchors: vec![], recent_peers: vec![], public_visible: visible, avatar_cid: None, }, }; storage.store_profile(&profile)?; Ok(()) } /// Resolve display info for any peer, taking circle profiles into account. pub async fn resolve_display_name( &self, author: &NodeId, ) -> anyhow::Result<(String, String, Option<[u8; 32]>)> { let storage = self.storage.get().await; // Viewer identity for circle-profile resolution = ALL our posting // identities (circle membership is posting-class, never network id). let viewers: Vec = storage.list_posting_identities() .unwrap_or_default() .into_iter().map(|p| p.node_id).collect(); storage.resolve_display_for_peer(author, &viewers) } /// Get our own circle profile for a given circle. Own circle-profile rows /// are keyed by the authoring persona (the circle's admin posting id). pub async fn get_circle_profile( &self, circle_name: &str, ) -> anyhow::Result> { let storage = self.storage.get().await; let gk = match storage.get_group_key_by_circle(circle_name)? { Some(gk) => gk, None => return Ok(None), }; // Own circles only: the admin must be one of OUR posting identities. // Group-key records for circles we merely belong to (received via // key-distribution posts) store the REMOTE admin's id; returning that // row here would surface a foreign circle profile as "our own" in the // edit dialog. Mirrors the set/delete_circle_profile gate. if storage.get_posting_identity(&gk.admin)?.is_none() { return Ok(None); } storage.get_circle_profile(&gk.admin, circle_name) } /// Get the public_visible setting for our own persona profile. /// v0.8: keyed by the default posting id (see set_public_visible). pub async fn get_public_visible(&self) -> anyhow::Result { let storage = self.storage.get().await; Ok(storage .get_profile(&self.default_posting_id)? .map(|p| p.public_visible) .unwrap_or(true)) } // ---- Settings ---- /// Get a setting value by key. pub async fn get_setting(&self, key: &str) -> anyhow::Result> { let storage = self.storage.get().await; storage.get_setting(key) } /// Set a setting value (upsert). pub async fn set_setting(&self, key: &str, value: &str) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.set_setting(key, value) } // ---- Cache stats & pressure ---- /// Get cache statistics: (used_bytes, max_bytes, blob_count). /// max_bytes comes from the `cache_size_bytes` setting (default 1 GB, 0 = unlimited). pub async fn get_cache_stats(&self) -> anyhow::Result<(u64, u64, u64)> { let storage = self.storage.get().await; let used = storage.total_blob_bytes()?; let count = storage.count_blobs()?; let max_str = storage.get_setting("cache_size_bytes")?.unwrap_or_default(); let max: u64 = max_str.parse().unwrap_or(1_073_741_824); Ok((used, max, count)) } /// Compute cache pressure score (0-255). /// 0 = no pressure (plenty of room or cache empty). /// 255 = maximum pressure (lowest-priority blob is >72 h old). /// Scales linearly: 0 h → 0, 36 h → 128, 72 h → 255. pub async fn compute_cache_pressure(&self) -> anyhow::Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let staleness_ms = 3600 * 1000; let (candidates, follows, own_ids) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); let own_ids: Vec = storage.list_posting_identities() .unwrap_or_default() .into_iter().map(|p| p.node_id).collect(); (candidates, follows, own_ids) }; if candidates.is_empty() { return Ok(255); // Empty cache = max willingness to accept } // Filter to non-elevated blobs (not pinned, not own content, not followed // author). Own content = authored by ANY of our posting identities. let non_elevated: Vec<_> = candidates.iter().filter(|c| { !c.pinned && !own_ids.contains(&c.author) && !follows.contains(&c.author) }).collect(); if non_elevated.is_empty() { return Ok(255); // All blobs are elevated — plenty of room for new content } // Find the lowest priority (oldest/least-valuable) blob 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, &own_ids, &follows, now); if priority < min_priority { min_priority = priority; min_created_at = c.created_at; } } // Scale based on age of the oldest non-elevated blob let age_hours = now.saturating_sub(min_created_at) as f64 / (3600.0 * 1000.0); let pressure = if age_hours >= 72.0 { 255 } else { ((age_hours / 72.0) * 255.0) as u8 }; Ok(pressure) } // ---- Seen engagement tracking ---- /// Get seen engagement counts for a post. pub async fn get_seen_engagement(&self, post_id: &PostId) -> anyhow::Result<(u32, u32)> { let storage = self.storage.get().await; storage.get_seen_engagement(post_id) } /// Mark a post's engagement as seen (upsert). pub async fn set_seen_engagement(&self, post_id: &PostId, react_count: u32, comment_count: u32) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.set_seen_engagement(post_id, react_count, comment_count) } /// Get last-read timestamp for a conversation partner. pub async fn get_last_read_message(&self, partner_id: &NodeId) -> anyhow::Result { let storage = self.storage.get().await; storage.get_last_read_message(partner_id) } /// Mark a conversation as read up to the given timestamp. pub async fn set_last_read_message(&self, partner_id: &NodeId, timestamp_ms: u64) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.set_last_read_message(partner_id, timestamp_ms) } // ---- Delete / Revocation ---- pub async fn delete_post(&self, post_id: &PostId) -> anyhow::Result<()> { // 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; let post = storage .get_post(post_id)? .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) }; // 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 blob storage local-side. Blobs in remote holders become // orphans and get evicted naturally via LRU. let blob_cids = { let storage = self.storage.get().await; let cids = storage.delete_blobs_for_post(post_id)?; for cid in &cids { let _ = storage.cleanup_cdn_for_blob(cid); } cids }; for cid in &blob_cids { if let Err(e) = self.blob_store.delete(cid) { warn!(cid = hex::encode(cid), error = %e, "Failed to delete blob file"); } } // 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_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), )?; } // 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; 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(()) } pub async fn revoke_post_access( &self, post_id: &PostId, revoked: &NodeId, mode: RevocationMode, ) -> anyhow::Result> { let (post, visibility) = { let storage = self.storage.get().await; storage .get_post_with_visibility(post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))? }; // Posts are authored by POSTING identities (personas), never the // network NodeId. "Is this mine?" = author ∈ all my posting identities; // remember the matched persona so crypto below uses its (id, seed) pair. let author_persona = { let storage = self.storage.get().await; storage.get_posting_identity(&post.author)? }; let author_persona = match author_persona { Some(p) => p, None => anyhow::bail!("cannot revoke: you are not the author"), }; let existing_recipients = match &visibility { PostVisibility::Public => anyhow::bail!("cannot revoke access on a public post"), PostVisibility::Encrypted { recipients } => recipients, PostVisibility::GroupEncrypted { .. } => { anyhow::bail!("cannot revoke individual access on a group-encrypted post; remove from circle instead") } PostVisibility::FoFClosed => { anyhow::bail!("cannot revoke individual access on a FoF-gated post via this path; use revoke_fof_commenter (Layer 2) or grant_fof_access (Layer 3)") } }; let new_recipient_ids: Vec = existing_recipients .iter() .map(|wk| wk.recipient) .filter(|r| r != revoked) .collect(); if new_recipient_ids.len() == existing_recipients.len() { anyhow::bail!("revoked node was not a recipient of this post"); } match mode { RevocationMode::SyncAccessList => { let new_wrapped = crypto::rewrap_visibility( &author_persona.secret_seed, &author_persona.node_id, existing_recipients, &new_recipient_ids, )?; let new_vis = PostVisibility::Encrypted { recipients: new_wrapped, }; { let storage = self.storage.get().await; storage.update_post_visibility(post_id, &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 = author_persona.secret_seed; 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 => { let (new_content, new_wrapped) = crypto::re_encrypt_post( &post.content, &author_persona.secret_seed, &author_persona.node_id, existing_recipients, &new_recipient_ids, )?; let new_vis = PostVisibility::Encrypted { recipients: new_wrapped, }; let new_post = Post { // Keep the ORIGINAL persona as author — the replacement // must not migrate content to the default persona. author: post.author, content: new_content, attachments: post.attachments.clone(), timestamp_ms: post.timestamp_ms, fof_gating: None, supersedes_post_id: None, comment_ttl: None, }; let new_post_id = compute_post_id(&new_post); { let storage = self.storage.get().await; storage.store_post_with_visibility(&new_post_id, &new_post, &new_vis)?; } // delete_post propagates the deletion as a signed control post. // Replacement post propagates via the CDN to remaining recipients. self.delete_post(post_id).await?; info!( old_id = hex::encode(post_id), new_id = hex::encode(new_post_id), "Re-encrypted post (revoke)" ); Ok(Some(new_post_id)) } } } pub async fn revoke_circle_access( &self, circle_name: &str, revoked: &NodeId, mode: RevocationMode, ) -> anyhow::Result { // Posts are authored by posting identities — query every persona, // not the network NodeId (which never authors posts). let posts = { let storage = self.storage.get().await; let mut all = Vec::new(); for persona in storage.list_posting_identities()? { all.extend(storage.find_posts_by_circle_intent(circle_name, &persona.node_id)?); } all }; let mut count = 0; for (post_id, _post, vis) in &posts { if let PostVisibility::Encrypted { recipients } = vis { if recipients.iter().any(|wk| &wk.recipient == revoked) { match self.revoke_post_access(post_id, revoked, mode).await { Ok(_) => count += 1, Err(e) => { warn!( post_id = hex::encode(post_id), error = %e, "Failed to revoke post access" ); } } } } } info!(circle = circle_name, count, "Revoked circle access"); Ok(count) } pub async fn get_redundancy_summary(&self) -> anyhow::Result<(usize, usize, usize, usize)> { let storage = self.storage.get().await; // Posts are authored by posting identities (personas), not the // network NodeId. Use every persona on this device so the // summary counts all of my posts across personas. let author_ids: Vec = storage.list_posting_identities()? .into_iter().map(|p| p.node_id).collect(); storage.get_redundancy_summary(&author_ids, 3_600_000) } // ---- Networking ---- pub fn endpoint_addr(&self) -> iroh::EndpointAddr { self.network.endpoint_addr() } /// Connect to a peer by node ID using address resolution: /// 0. Already connected or has session → done /// 1. Social route cache → try cached address /// 2. Peers table → connect directly /// 3. N2/N3 lookup → ask tagged reporter for address /// 4. Worm lookup → fan-out search beyond N3 /// 5. Relay introduction → coordinate hole punch via relay peer /// 6. Session relay fallback → pipe through intermediary pub async fn connect_by_node_id(&self, peer_id: NodeId) -> anyhow::Result<()> { if self.network.is_connected(&peer_id).await { return Ok(()); } // Check if we already have a session connection if self.network.conn_handle().has_session(&peer_id).await { return Ok(()); } // Check if this peer is known to be behind NAT / unreachable directly let skip_direct = self.network.conn_handle().is_likely_unreachable(&peer_id).await; // Step 0: Try social route cache (skipped for known-unreachable peers) if !skip_direct { let storage = self.storage.get().await; if let Some(route) = storage.get_social_route(&peer_id)? { // Try cached addresses directly for addr in &route.addresses { let endpoint_id = match iroh::EndpointId::from_bytes(&peer_id) { Ok(eid) => eid, Err(_) => continue, }; let ep_addr = iroh::EndpointAddr::from(endpoint_id).with_ip_addr(*addr); drop(storage); if self.network.connect_to_peer(peer_id, ep_addr).await.is_ok() { info!(peer = hex::encode(peer_id), "Connected via social route cache"); return Ok(()); } // Re-acquire lock for next iteration break; // Only try first address from route directly } // Try peer_addresses: connect to their known peers and ask for target for pa in &route.peer_addresses { if let Ok(pa_nid) = crate::parse_node_id_hex(&pa.n) { if self.network.is_connected(&pa_nid).await { // Already connected to this peer — ask them let resolved = self.network.conn_handle().resolve_address(&peer_id).await.unwrap_or(None); if let Some(addr_str) = resolved { if let Ok((_nid, ep_addr)) = crate::parse_connect_string( &format!("{}@{}", hex::encode(peer_id), addr_str) ) { if self.network.connect_to_peer(peer_id, ep_addr).await.is_ok() { info!(peer = hex::encode(peer_id), via = &pa.n[..12], "Connected via social route peer referral"); return Ok(()); } } } } else if let Some(pa_addr_str) = pa.a.first() { // Try connecting to the peer first, then ask if let Ok(pa_sock) = pa_addr_str.parse::() { let pa_eid = match iroh::EndpointId::from_bytes(&pa_nid) { Ok(eid) => eid, Err(_) => continue, }; let pa_ep = iroh::EndpointAddr::from(pa_eid).with_ip_addr(pa_sock); if self.network.connect_to_peer(pa_nid, pa_ep).await.is_ok() { let resolved = self.network.conn_handle().resolve_address(&peer_id).await.unwrap_or(None); if let Some(addr_str) = resolved { if let Ok((_nid, ep_addr)) = crate::parse_connect_string( &format!("{}@{}", hex::encode(peer_id), addr_str) ) { if self.network.connect_to_peer(peer_id, ep_addr).await.is_ok() { info!(peer = hex::encode(peer_id), via = &pa.n[..12], "Connected via social route peer referral (new conn)"); return Ok(()); } } } } } } } } } } // Steps 1-4: Direct connection attempts (skipped for known-unreachable peers) if !skip_direct { // Step 1: Try direct address from peers table if let Some(addr) = self.network.addr_from_storage(&peer_id).await { if self.network.connect_to_peer(peer_id, addr).await.is_ok() { return Ok(()); } } // Step 2-3: Try address resolution via N2/N3 let resolved = self.network.conn_handle().resolve_address(&peer_id).await.unwrap_or(None); if let Some(addr_str) = resolved { if let Ok(addr) = crate::parse_connect_string(&format!("{}@{}", hex::encode(peer_id), addr_str)) { if self.network.connect_to_peer(peer_id, addr.1).await.is_ok() { return Ok(()); } } } // Step 4: Try worm lookup (fan-out search beyond N3) info!(peer = hex::encode(peer_id), "Trying worm lookup..."); if let Ok(Some(wr)) = self.network.worm_lookup(&peer_id).await { if wr.node_id == peer_id { if let Some(addr_str) = wr.addresses.first() { if let Ok(addr) = crate::parse_connect_string(&format!("{}@{}", hex::encode(peer_id), addr_str)) { if self.network.connect_to_peer(peer_id, addr.1).await.is_ok() { return Ok(()); } } } } else { info!( target = hex::encode(peer_id), found_via = hex::encode(wr.node_id), "Worm found target via recent peer" ); if let Some(addr_str) = wr.addresses.first() { if let Ok(needle_addr) = crate::parse_connect_string(&format!("{}@{}", hex::encode(wr.node_id), addr_str)) { if self.network.connect_to_peer(wr.node_id, needle_addr.1).await.is_ok() { let resolved = self.network.conn_handle().resolve_address(&peer_id).await.unwrap_or(None); if let Some(target_addr_str) = resolved { if let Ok(target_addr) = crate::parse_connect_string(&format!("{}@{}", hex::encode(peer_id), target_addr_str)) { if self.network.connect_to_peer(peer_id, target_addr.1).await.is_ok() { return Ok(()); } } } } } } } } // All direct attempts failed — mark peer as likely unreachable self.network.conn_handle().mark_unreachable(&peer_id); } // Step 6: Relay introduction — find relay peer(s) and request introduction { let on_cooldown = { let storage = self.storage.get().await; storage.is_relay_cooldown(&peer_id, RELAY_COOLDOWN_MS).unwrap_or(false) }; if !on_cooldown { let relay_candidates = self.network.conn_handle().find_relays_for(&peer_id).await; let mut had_capacity_reject = false; let mut last_intro_id: Option = None; let mut last_relay_peer: Option = None; let mut last_relay_available = false; for (relay_peer, ttl) in &relay_candidates { info!( target = hex::encode(peer_id), relay = hex::encode(relay_peer), ttl, "Attempting relay introduction" ); let intro_result = tokio::time::timeout( std::time::Duration::from_secs(RELAY_INTRO_TIMEOUT_SECS), self.network.send_relay_introduce_standalone(relay_peer, &peer_id, *ttl), ).await; match intro_result { Ok(Ok(result)) if result.accepted => { info!( target = hex::encode(peer_id), addrs = ?result.target_addresses, relay_available = result.relay_available, "Relay introduction accepted, attempting hole punch" ); // Save for potential session relay fallback last_intro_id = Some(result.intro_id); last_relay_peer = Some(*relay_peer); last_relay_available = result.relay_available; // Try direct connection to target's addresses (hole punch with scanning) let our_profile = self.network.conn_handle().our_nat_profile().await; let peer_profile = { let s = self.storage.get().await; s.get_peer_nat_profile(&peer_id) }; if let Some(conn) = crate::connection::hole_punch_with_scanning( self.network.endpoint(), &peer_id, &result.target_addresses, our_profile, peer_profile, ).await { self.network.conn_handle().add_session(peer_id, conn, SessionReachMethod::HolePunch, None).await; self.network.conn_handle().mark_reachable(&peer_id); info!(peer = hex::encode(peer_id), "Connected via hole punch"); return Ok(()); } // Intro accepted but hole punch failed — try session relay below break; } Ok(Ok(result)) => { let reason = result.reject_reason.as_deref().unwrap_or("unknown"); if reason.contains("capacity") { debug!( relay = hex::encode(relay_peer), "Relay at capacity, trying next candidate" ); had_capacity_reject = true; continue; // Try next relay candidate } debug!( target = hex::encode(peer_id), reason, "Relay introduction rejected" ); // Target explicitly rejected — don't try more relays break; } Ok(Err(e)) => { debug!(error = %e, "Relay introduction failed, trying next candidate"); continue; // Network error — try next relay } Err(_) => { debug!("Relay introduction timed out, trying next candidate"); continue; // Timeout — try next relay } } } // Step 7: Session relay fallback — only if BOTH the introducer // signaled relay availability AND this node has opted in to // using session relay (`relay.session_relay_enabled`). Default // is opt-out: hole-punch failure does NOT silently fall back // to byte-relaying through a third party. if !self.network.conn_handle().is_session_relay_enabled().await { debug!(target = hex::encode(peer_id), "Session relay opt-out — skipping relay fallback"); } else if let (Some(intro_id), Some(relay_peer)) = (last_intro_id, last_relay_peer) { if last_relay_available { info!( target = hex::encode(peer_id), relay = hex::encode(relay_peer), "Hole punch failed, attempting session relay" ); match self.attempt_session_relay(&relay_peer, &peer_id, &intro_id).await { Ok(()) => { info!(peer = hex::encode(peer_id), "Connected via session relay"); return Ok(()); } Err(e) => { debug!(error = %e, "Session relay failed"); } } } } // Record cooldown on failure (skip if all rejections were capacity-related) if !relay_candidates.is_empty() && !had_capacity_reject { let storage = self.storage.get().await; let _ = storage.record_relay_miss(&peer_id); } } } anyhow::bail!( "cannot resolve address for peer {} (tried social routes, peers table, N2/N3, worm lookup, and relay introduction)", hex::encode(peer_id) ) } /// Attempt to establish a session relay through an intermediary. async fn attempt_session_relay( &self, relay_peer: &NodeId, target: &NodeId, intro_id: &crate::connection::IntroId, ) -> anyhow::Result<()> { use crate::protocol::{ write_typed_message, MessageType, SessionRelayPayload, }; let relay_conn = self.network.conn_handle().get_connection(relay_peer).await .ok_or_else(|| anyhow::anyhow!("relay peer disconnected"))?; let (mut send, _recv) = relay_conn.open_bi().await?; let payload = SessionRelayPayload { intro_id: *intro_id, target: *target, }; write_typed_message(&mut send, MessageType::SessionRelay, &payload).await?; self.network.conn_handle().add_session(*target, relay_conn, SessionReachMethod::Relayed, None).await; Ok(()) } /// Worm lookup: fan-out search for a peer beyond the 3-hop discovery map. pub async fn worm_lookup(&self, target: &NodeId) -> anyhow::Result> { self.network.worm_lookup(target).await } /// Connect to a peer and establish a mesh connection pub async fn sync_with(&self, peer_id: NodeId) -> anyhow::Result<()> { self.connect_by_node_id(peer_id).await?; // Reset last_sync_ms for this author so the responder sends ALL posts, // not just posts newer than our last sync timestamp. { let storage = self.storage.get().await; let _ = storage.update_follow_last_sync(&peer_id, 0); } let stats = self.network.conn_handle().content_sync_from_peer(&peer_id).await?; // Also fetch engagement data (reactions, comments) for posts we hold let engagement = self.network.conn_handle().fetch_engagement_from_peer(&peer_id).await.unwrap_or(0); info!( peer = hex::encode(peer_id), posts = stats.posts_received, engagement_headers = engagement, "Sync complete" ); // Prefetch blobs for posts we just received if stats.posts_received > 0 { self.prefetch_blobs_from_peer(&peer_id).await; } Ok(()) } /// Connect to a peer using full address pub async fn sync_with_addr(&self, addr: iroh::EndpointAddr) -> anyhow::Result<()> { let peer_id = *addr.id.as_bytes(); self.network.connect_to_peer(peer_id, addr).await?; let stats = self.network.conn_handle().content_sync_from_peer(&peer_id).await?; info!( peer = hex::encode(peer_id), posts = stats.posts_received, "Sync complete" ); Ok(()) } /// Pull from all connected peers pub async fn sync_all(&self) -> anyhow::Result<()> { let stats = self.network.content_sync_all().await?; info!( "Pull complete: {} posts from {} peers", stats.posts_received, stats.peers_pulled ); // v0.6.2: apply any newly-received key-distribution posts so group // seeds propagate automatically after sync. if let Ok(n) = self.process_group_key_distributions().await { if n > 0 { info!(applied = n, "Applied group key distributions"); } } Ok(()) } pub async fn add_peer(&self, peer_id: NodeId) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.add_peer(&peer_id)?; Ok(()) } pub async fn list_peers(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_peers() } pub async fn list_peer_records(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_peer_records() } pub async fn list_bootstrap_anchors(&self) -> Vec<(NodeId, iroh::EndpointAddr)> { self.bootstrap_anchors.lock().await.clone() } /// This device's slot/depth budget. pub fn device_profile(&self) -> DeviceProfile { self.profile } /// Get connection info for display: (node_id, slot, connected_at) pub async fn list_connections(&self) -> Vec<(NodeId, MeshSlot, u64)> { self.network.connection_info().await } pub async fn stats(&self) -> anyhow::Result { let storage = self.storage.get().await; Ok(NodeStats { post_count: storage.post_count()?, peer_count: storage.list_peers()?.len(), follow_count: storage.list_follows()?.len(), }) } /// Start the accept loop (run in background) pub fn start_accept_loop(&self) -> tokio::task::JoinHandle> { let network = Arc::clone(&self.network); tokio::spawn(async move { network.run_accept_loop().await }) } /// Start the sync cycle — two cadences on one 60s tick. /// /// (1) THE PULL, i.e. the uniques-index exchange (0x40/0x41). design.html /// §sync: a pull is not a post transfer, it is "if you want these IDs, /// talk to me and I'll help you find them". Runs on the slow tick /// because the pools are large and mostly static; the push-side /// announce (0x01) already covers fast changes. /// /// (2) TRANSITIONAL content sync (0x46/0x47) for stale authors. Folds into /// the update-cadence scheduler + CDN replication in Iteration D. /// Until then it is the only carrier for non-public visibilities. pub fn start_sync_cycle(self: &Arc) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); let mut tick: u64 = 0; loop { interval.tick().await; tick += 1; if tick == 1 { // Startup: full content sync + engagement fetch, then // prefetch blobs for what arrived. let _ = node.network.content_sync_all().await; let peers = node.network.conn_handle().connected_peers().await; for peer_id in peers { node.prefetch_blobs_from_peer(&peer_id).await; } let n = node.network.uniques_pull_all().await; tracing::debug!(peers = n, "Startup uniques-index exchange"); continue; } // (1) Uniques-index exchange every 5 minutes. if tick % 5 == 0 { let n = node.network.uniques_pull_all().await; if n > 0 { tracing::debug!(peers = n, "Uniques-index exchange"); } } // (2) Tiered content sync: only when some author is stale. let stale_authors = { let storage = node.storage.get().await; storage.get_stale_follows(4 * 3600 * 1000).unwrap_or_default() }; if stale_authors.is_empty() { continue; // Most ticks skip — no stale authors } // Every connected peer, not just `peers.first()`: one arbitrary // peer is very unlikely to hold a given stale author's posts, // so the tiered tick silently did almost nothing. let peers = node.network.conn_handle().connected_peers().await; for peer_id in &peers { match node.network.conn_handle().content_sync_from_peer(peer_id).await { Ok(stats) if stats.posts_received > 0 => { tracing::debug!( peer = hex::encode(peer_id), posts = stats.posts_received, "Tiered content sync complete" ); node.prefetch_blobs_from_peer(peer_id).await; } Ok(_) => {} Err(e) => tracing::debug!(error = %e, "Tiered content sync failed"), } } } }) } /// Start diff cycle: every interval_secs, broadcast N1/N2 changes to connected peers. pub fn start_diff_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let full_sync_interval = (4 * 60 * 60) / interval_secs; // every 4 hours tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); let mut tick_count: u64 = 0; loop { interval.tick().await; tick_count += 1; if tick_count % full_sync_interval == 0 { // Full state re-broadcast every 4 hours to catch missed diffs match network.broadcast_full_state().await { Ok(count) => { if count > 0 { tracing::info!(count, "Full N1/N2 state broadcast (4h cycle)"); } } Err(e) => { tracing::debug!(error = %e, "Full state broadcast failed"); } } } else { match network.broadcast_uniques().await { Ok(count) => { if count > 0 { tracing::debug!(count, "Broadcast routing diff"); } } Err(e) => { tracing::debug!(error = %e, "Routing diff broadcast failed"); } } } } }) } /// Start rebalance cycle: every interval_secs, rebalance connection slots. pub fn start_rebalance_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let timer = Arc::clone(&self.last_rebalance_ms); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; timer.store(now, AtomicOrdering::Relaxed); if let Err(e) = network.rebalance().await { tracing::debug!(error = %e, "Rebalance failed"); } } }) } /// Start the reactive growth loop: wakes on signal, sequentially fills local /// slots with the most diverse N2 candidates. Each connection updates N2/N3 /// knowledge before picking the next candidate. pub fn start_growth_loop(&self) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let (tx, rx) = tokio::sync::mpsc::channel(1); tokio::spawn(async move { network.set_growth_tx(tx.clone()).await; // Initial kick: bootstrap may have already populated N2 before this started let _ = tx.try_send(()); network.run_growth_loop(rx).await; }) } /// Start recovery loop: triggered when the mesh drops below 2 peers. /// /// Recovery is deliberately NOT stochastic (round-4 ruling). A node with /// fewer than 2 mesh peers cannot function, so it always acts immediately; /// only *growth* rolls dice. Anchors are gathered pool-first, then the /// bootstrap cache. pub fn start_recovery_loop(&self) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let storage = Arc::clone(&self.storage); let node_id = self.node_id; let alog = Arc::clone(&self.activity_log); let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1); tokio::spawn(async move { let log_evt = |level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option| { if let Ok(mut log) = alog.try_lock() { log.log(level, cat, msg, peer); } }; network.set_recovery_tx(tx).await; while rx.recv().await.is_some() { tracing::info!("Recovery triggered: reconnecting to anchors"); log_evt(ActivityLevel::Warn, ActivityCategory::Recovery, "Recovery triggered: mesh below 2".into(), None); // Debounce: wait briefly for more disconnects to settle tokio::time::sleep(std::time::Duration::from_secs(2)).await; // Drain any queued signals while rx.try_recv().is_ok() {} let anchors = gather_anchor_candidates(&storage, &network, node_id, 8).await; let mut connected = 0usize; for (anchor_nid, anchor_addrs) in &anchors { // ENTRY class: always served, by definition of the ruling. // There is no separate registration — the request enrols us. connected += run_convection( &network, *anchor_nid, anchor_addrs, crate::protocol::ConvectionClass::Entry, node_id, ).await; if network.conn_handle().mesh_count().await >= 2 { break; } } if connected > 0 { log_evt(ActivityLevel::Info, ActivityCategory::Recovery, format!("Convection produced {} connections", connected), None); } let conn_count = network.connection_count().await; tracing::info!(connections = conn_count, "Recovery complete"); log_evt(ActivityLevel::Info, ActivityCategory::Recovery, format!("Recovery complete, {} connections", conn_count), None); } }) } /// Run one convection exchange against a specific anchor, on demand. /// /// Diagnostics + integration testing: the automatic paths pick the anchor /// themselves (recovery pool-first, the stochastic arm at random), which is /// correct but untestable. Returns `(peers_connected, refused, elapsed_ms)` /// — the elapsed time is the point of the cheap-refusal contract. pub async fn convection_request(&self, anchor: NodeId) -> anyhow::Result<(usize, bool, u128)> { let started = std::time::Instant::now(); let mesh = self.network.conn_handle().mesh_count().await; let class = crate::protocol::ConvectionClass::for_mesh_count(mesh); let addrs: Vec = { let s = self.storage.get().await; s.get_peer_record(&anchor).ok().flatten().map(|r| r.addresses).unwrap_or_default() }; if !self.network.is_peer_connected_or_session(&anchor).await { let eid = iroh::EndpointId::from_bytes(&anchor)?; let mut ea = iroh::EndpointAddr::from(eid); for sa in &addrs { ea = ea.with_ip_addr(*sa); } self.network.connect_to_anchor(anchor, ea).await?; } let response = self.network.request_convection(&anchor, class).await?; let refused = response.refused; let connected = if refused { 0 } else { self.network.act_on_convection(&anchor, &response).await }; Ok((connected, refused, started.elapsed().as_millis())) } /// Run the uniques-index exchange (the v0.8 "pull") against every mesh peer. pub async fn uniques_pull(&self) -> usize { self.network.uniques_pull_all().await } /// Start the convection loop: the "ask a random known anchor" arm of the /// per-disconnect stochastic action (round-4/5). /// /// The dice are rolled inside `disconnect_peer` under the conn_mgr lock /// (pure state read + RNG); this loop is where the resulting network I/O /// happens, so nothing blocks a teardown. It also carries the anchor /// self-verification probe, which lost its home when the register cycle /// retired. pub fn start_convection_loop(&self) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let storage = Arc::clone(&self.storage); let node_id = self.node_id; let alog = Arc::clone(&self.activity_log); let timer = Arc::clone(&self.last_convection_ms); let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1); tokio::spawn(async move { let log_evt = |level: ActivityLevel, cat: ActivityCategory, msg: String, peer: Option| { if let Ok(mut log) = alog.try_lock() { log.log(level, cat, msg, peer); } }; network.conn_handle().set_convection_tx(tx).await; // Slow maintenance tick: the anchor self-verification probe used to // hang off the register cycle. let mut probe_tick = tokio::time::interval(std::time::Duration::from_secs(600)); probe_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { signal = rx.recv() => { if signal.is_none() { break; } // Coalesce a burst of disconnects into one action. while rx.try_recv().is_ok() {} let mesh = network.conn_handle().mesh_count().await; let class = crate::protocol::ConvectionClass::for_mesh_count(mesh); let mut anchors = gather_anchor_candidates(&storage, &network, node_id, 12).await; if anchors.is_empty() { // No anchor to ask — fall through to the mesh arm // rather than doing nothing. network.notify_growth().await; continue; } // "a RANDOM known anchor" — not the best-ranked one. // Ranking anchors was a scarcity artifact; spreading // load is what keeps convection windows fresh. use rand::seq::SliceRandom; anchors.shuffle(&mut rand::rng()); let (anchor_nid, anchor_addrs) = anchors.remove(0); timer.store( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64, AtomicOrdering::Relaxed, ); let n = run_convection(&network, anchor_nid, &anchor_addrs, class, node_id).await; if n > 0 { log_evt(ActivityLevel::Info, ActivityCategory::Anchor, format!("Convection: {} new peers", n), Some(anchor_nid)); } else { // Nothing came back — let the mesh arm try. network.notify_growth().await; } } _ = probe_tick.tick() => { if network.conn_handle().probe_due().await { log_evt(ActivityLevel::Info, ActivityCategory::Anchor, "Initiating anchor self-verification probe".into(), None); if let Err(e) = network.conn_handle().initiate_anchor_probe().await { tracing::debug!(error = %e, "Anchor probe error"); } } } } } }) } /// Start social checkin cycle: every interval_secs, refresh stale social routes. /// Uses ephemeral connections if not persistently connected. pub fn start_social_checkin_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let storage = Arc::clone(&self.storage); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; let stale = { let s = storage.get().await; s.list_stale_social_routes(interval_secs as u64 * 1000).unwrap_or_default() }; for route in stale { let our_addrs: Vec = network.endpoint_addr().ip_addrs() .map(|s| s.to_string()).collect(); let result = network.send_social_checkin( &route.node_id, &our_addrs, &[], ).await; match result { Ok(reply) => { let s = storage.get().await; let addrs: Vec = reply.addresses.iter() .filter_map(|a| a.parse().ok()).collect(); let _ = s.touch_social_route_connect( &reply.node_id, &addrs, ReachMethod::Direct, ); let _ = s.update_social_route_peer_addrs( &reply.node_id, &reply.peer_addresses, ); } Err(e) => { tracing::debug!( peer = hex::encode(route.node_id), error = %e, "Social checkin failed" ); } } } } }) } /// Start bootstrap connectivity check: 24 hours after startup, verify the bootstrap /// anchor is within our network knowledge (N1/N2/N3). If not, we may be in an isolated /// segment — reconnect to bootstrap and request referrals to bridge back. pub fn start_bootstrap_connectivity_check(self: &Arc) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { // Wait 24 hours before first check tokio::time::sleep(std::time::Duration::from_secs(24 * 60 * 60)).await; let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 60 * 60)); loop { interval.tick().await; // Parse bootstrap anchor NodeId let bootstrap_nid = match crate::parse_connect_string(DEFAULT_ANCHOR) { Ok((nid, _)) => nid, Err(_) => continue, }; // Skip if we ARE the bootstrap if bootstrap_nid == node.node_id { continue; } // Is the bootstrap anywhere in our N1-N4 horizon? N4 counts: // it is used for search and resolution, it is only never // re-announced. let is_reachable = { let connected = node.network.is_connected(&bootstrap_nid).await; if connected { true } else { let storage = node.storage.get().await; storage.find_any_reachable(std::slice::from_ref(&bootstrap_nid)) .map(|r| !r.is_empty()) .unwrap_or(false) } }; if is_reachable { tracing::debug!("Bootstrap connectivity check: bootstrap in reach, network OK"); continue; } // Bootstrap not in N1/N2/N3 — we may be isolated tracing::info!("Bootstrap connectivity check: bootstrap not in reach, reconnecting"); // Connect to bootstrap and request referrals if let Err(e) = node.connect_by_node_id(bootstrap_nid).await { tracing::warn!(error = %e, "Bootstrap connectivity: failed to connect"); continue; } // ENTRY class: an isolated segment is exactly the case the // always-served class exists for. let n = run_convection( &node.network, bootstrap_nid, &[], crate::protocol::ConvectionClass::Entry, node.node_id, ).await; tracing::info!(connected = n, "Bootstrap connectivity: convection complete"); } }) } /// Start CDN manifest refresh cycle: periodically ask upstream for newer manifests. /// Manifests older than `max_age_ms` are refreshed from their upstream source. pub fn start_manifest_refresh_cycle(&self, interval_secs: u64, max_age_ms: u64) -> tokio::task::JoinHandle<()> { let network = Arc::clone(&self.network); let storage = Arc::clone(&self.storage); tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; let cutoff = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 - max_age_ms; let stale_cids = { let s = storage.get().await; s.get_stale_manifest_cids(cutoff).unwrap_or_default() }; for cid in &stale_cids { // Get current updated_at + pick a holder to refresh from let (current_updated_at, refresh_source) = { let s = storage.get().await; let updated_at = s.get_cdn_manifest(cid).ok().flatten() .and_then(|json| serde_json::from_str::(&json).ok()) .map(|m| m.updated_at) .unwrap_or(0); let source = s.get_file_holders(cid) .unwrap_or_default() .into_iter() .next() .map(|(nid, _)| nid); (updated_at, source) }; let Some(upstream_nid) = refresh_source else { continue; }; match network.request_manifest_refresh(cid, &upstream_nid, current_updated_at).await { Ok(Some(cdn_manifest)) => { if crypto::verify_manifest_signature(&cdn_manifest.author_manifest) { let author_json = serde_json::to_string(&cdn_manifest.author_manifest).unwrap_or_default(); let s = storage.get().await; let _ = s.store_cdn_manifest( cid, &author_json, &cdn_manifest.author_manifest.author, cdn_manifest.author_manifest.updated_at, ); // Relay to known holders (flat set) let holders = s.get_file_holders(cid).unwrap_or_default(); drop(s); if !holders.is_empty() { network.push_manifest_to_downstream(cid, &cdn_manifest).await; } tracing::debug!( cid = hex::encode(cid), "Refreshed stale manifest from upstream" ); } } Ok(None) => {} // No update available Err(e) => { tracing::debug!( cid = hex::encode(cid), upstream = hex::encode(&upstream_nid), error = %e, "Manifest refresh from upstream failed" ); } } } } }) } /// Build our N+10:Addresses (our connected peers with their addresses). pub async fn build_peer_addresses(&self) -> Vec { let conns = self.network.connection_info().await; let storage = self.storage.get().await; let mut result = Vec::new(); for (nid, kind, _) in conns { if nid == self.node_id { continue; } // Temp referral slots are never advertised as part of our // neighborhood. if !kind.is_mesh() { continue; } let addrs: Vec = storage.get_peer_record(&nid) .ok() .flatten() .map(|r| r.addresses.iter().map(|a| a.to_string()).collect()) .unwrap_or_default(); result.push(PeerWithAddress { n: hex::encode(nid), a: addrs, }); if result.len() >= 10 { break; } } result } /// List all social routes (for CLI/Tauri display). pub async fn list_social_routes(&self) -> anyhow::Result> { let storage = self.storage.get().await; storage.list_social_routes() } // ---- Blob Eviction ---- /// Compute priority score for a blob. Higher score = keep longer. /// `own_author_ids` = ALL of this node's posting identities (blob authors /// are posting ids, never the network NodeId). pub fn compute_blob_priority( &self, candidate: &crate::storage::EvictionCandidate, own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { compute_blob_priority_standalone(candidate, own_author_ids, follows, now_ms) } /// 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)?; } let _ = self.blob_store.delete(cid); Ok(()) } /// Evict lowest-priority blobs until total storage is under max_bytes. pub async fn evict_blobs(&self, max_bytes: u64) -> anyhow::Result { let total = { let storage = self.storage.get().await; storage.total_blob_bytes()? }; if total <= max_bytes { return Ok(0); } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // 1-hour staleness for replica counts let staleness_ms = 3600 * 1000; let (candidates, follows, own_ids) = { let storage = self.storage.get().await; let candidates = storage.get_eviction_candidates(staleness_ms)?; let follows = storage.list_follows().unwrap_or_default(); let own_ids: Vec = storage.list_posting_identities() .unwrap_or_default() .into_iter().map(|p| p.node_id).collect(); (candidates, follows, own_ids) }; // Score and sort ascending (lowest priority first) let mut scored: Vec<(f64, &crate::storage::EvictionCandidate)> = candidates .iter() .map(|c| (self.compute_blob_priority(c, &own_ids, &follows, now), c)) .collect(); scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let mut bytes_freed: u64 = 0; let target_free = total - max_bytes; let mut evicted = 0; for (score, candidate) in &scored { if bytes_freed >= target_free { break; } if let Err(e) = self.delete_blob_local(&candidate.cid).await { warn!(cid = hex::encode(candidate.cid), error = %e, "Failed to evict blob"); continue; } bytes_freed += candidate.size_bytes; evicted += 1; info!( cid = hex::encode(candidate.cid), score = score, size = candidate.size_bytes, "Evicted blob" ); } info!(evicted, bytes_freed, "Blob eviction complete"); Ok(evicted) } /// Start a periodic eviction cycle. pub fn start_eviction_cycle( node: Arc, interval_secs: u64, max_bytes: u64, ) -> tokio::task::JoinHandle<()> where Self: Send + Sync + 'static, { tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; match node.evict_blobs(max_bytes).await { Ok(0) => {} Ok(n) => info!(evicted = n, "Eviction cycle complete"), Err(e) => warn!(error = %e, "Eviction cycle failed"), } // v0.8 (A3): comment-TTL sweep piggybacks the storage- // hygiene loop. Hard delete — expiry is the forgetting // mechanism; 300s ticks are far inside 30–365d TTLs. { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let s = node.storage.get().await; match s.expire_comments(now) { Ok(0) | Err(_) => {} Ok(n) => info!(expired = n, "Expired comments swept"), } } // v0.8 (A3): registry auto-renew — while "Listed" is // checked, re-sign a fresh 30d entry when the current one // expires within 5 days (~every 25 days). if let Err(e) = node.renew_registry_entries_if_due().await { debug!(error = %e, "Registry auto-renew pass failed"); } } }) } // --- HTTP Post Delivery --- /// Start the HTTP server for serving public posts to browsers. /// Only starts if this node is publicly TCP-reachable. pub fn start_http_server(&self) -> Option> { if !self.network.is_http_capable() { debug!("HTTP server not started: node is not publicly TCP-reachable"); return None; } let port = self.network.bound_port(); if port == 0 { return None; } let storage = Arc::clone(&self.storage); let blob_store = Arc::clone(&self.blob_store); // Advertise HTTP capability to peers let http_addr = self.network.http_addr(); self.network.conn_handle().set_http_info(true, http_addr.clone()); // Also update the ConnectionManager's fields for payload construction { let rt = tokio::runtime::Handle::current(); let conn_mgr = Arc::clone(&self.network.conn_mgr_arc()); rt.spawn(async move { let mut cm = conn_mgr.lock().await; cm.http_capable = true; cm.http_addr = http_addr; }); } info!("Starting HTTP server on TCP port {}", port); Some(tokio::spawn(async move { if let Err(e) = crate::http::run_http_server(port, storage, blob_store).await { warn!("HTTP server stopped: {}", e); } })) } /// Start the web redirect handler (itsgoin.net share link resolution). pub fn start_web_handler(self: &Arc, port: u16) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); info!("Starting web redirect handler on port {}", port); tokio::spawn(async move { if let Err(e) = crate::web::run_web_handler(port, node).await { warn!("Web redirect handler stopped: {}", e); } }) } /// No-op since v0.7.2 — the TCP `portmapper::Client` auto-renews internally. pub fn start_upnp_tcp_renewal_cycle(&self) -> Option> { None } /// Generate a share link URL for a public post. /// Returns None if post is not public or not found. /// /// URL Phase 1 (v0.7.2): the link contains only the post ID — no author /// hex, no node addresses. The receiving anchor (itsgoin.net) does the /// holder lookup itself and serves via redirect or QUIC-proxy fallback. /// Older URLs with `/{post_hex}/{author_hex}` continue to work — the /// web handler parses the author hex as optional. pub async fn generate_share_link(&self, post_id: &PostId) -> anyhow::Result> { let (_post, visibility) = { let store = self.storage.get().await; match store.get_post_with_visibility(post_id)? { Some(pv) => pv, None => return Ok(None), } }; if !matches!(visibility, PostVisibility::Public) { return Ok(None); } let post_hex = hex::encode(post_id); Ok(Some(format!("https://itsgoin.net/p/{}", post_hex))) } // --- Engagement API --- /// React to a post with an emoji. If `private`, encrypts payload for post author only. pub async fn react_to_post( &self, post_id: PostId, emoji: String, private: bool, ) -> anyhow::Result { let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // For private reactions, look up the post author and encrypt let encrypted_payload = if private { let storage = self.storage.get().await; let post = storage.get_post(&post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))?; drop(storage); let seed = self.default_posting_secret; let payload_json = serde_json::json!({ "emoji": emoji, "reactor": hex::encode(our_node_id), "timestamp_ms": now, }).to_string(); Some(crate::crypto::encrypt_private_reaction(&seed, &post.author, &payload_json)?) } else { None }; let signature = crate::crypto::sign_reaction(&self.default_posting_secret, &our_node_id, &post_id, &emoji, now); let reaction = crate::types::Reaction { reactor: our_node_id, emoji: emoji.clone(), post_id, timestamp_ms: now, encrypted_payload, deleted_at: None, signature, }; // Store locally let storage = self.storage.get().await; storage.store_reaction(&reaction)?; drop(storage); // Propagate via BlobHeaderDiff to downstream + upstream { let network = &self.network; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: our_node_id, ops: vec![crate::types::BlobHeaderDiffOp::AddReaction(reaction.clone())], timestamp_ms: now, }; // propagate_engagement_diff targets all file_holders (flat set, max 5) // which already subsumes what used to be upstream + downstream. network.propagate_engagement_diff( &post_id, &diff, // exclude_peer is NETWORK-class (file_holders hold device // ids) — pass the network NodeId, not a posting id. &self.node_id, ).await; } Ok(reaction) } /// Remove a reaction from a post. pub async fn remove_reaction(&self, post_id: PostId, emoji: String) -> anyhow::Result<()> { let our_node_id = self.default_posting_id; let storage = self.storage.get().await; storage.remove_reaction(&our_node_id, &post_id, &emoji)?; drop(storage); // Propagate removal { let network = &self.network; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: our_node_id, ops: vec![crate::types::BlobHeaderDiffOp::RemoveReaction { reactor: our_node_id, emoji, post_id, }], timestamp_ms: now, }; network.propagate_engagement_diff( &post_id, &diff, // exclude_peer is NETWORK-class (file_holders hold device // ids) — pass the network NodeId, not a posting id. &self.node_id, ).await; } Ok(()) } /// Get all reactions for a post. Decrypts private reactions if we're the post author. pub async fn get_reactions(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; let reactions = storage.get_reactions(&post_id)?; let post_info = storage.get_post(&post_id)?; drop(storage); let our_node_id = self.default_posting_id; // If we're the author, decrypt private reactions if let Some(post) = post_info { if post.author == our_node_id { let seed = self.default_posting_secret; return Ok(reactions.into_iter().map(|mut r| { if let Some(ref enc) = r.encrypted_payload { if let Ok(decrypted) = crate::crypto::decrypt_private_reaction(&seed, &r.reactor, enc) { r.encrypted_payload = Some(decrypted); } } r }).collect()); } } Ok(reactions) } /// Get reaction counts grouped by emoji for a post. "Mine" = a reaction /// from ANY of our posting identities. pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; let our_ids: Vec = storage.list_posting_identities() .unwrap_or_default() .into_iter().map(|p| p.node_id).collect(); let counts = storage.get_reaction_counts(&post_id, &our_ids)?; Ok(counts) } /// Add a plain inline comment to a post (signed with our posting key). /// The comment's `content` is the full text; `ref_post_id` is None. pub async fn comment_on_post( &self, post_id: PostId, content: String, ) -> anyhow::Result { // FoF Layer 2: if the post carries fof_gating, route through // the FoF comment path so the comment is encrypted under // CEK_comments + signed under priv_x. The CDN four-check accept // rule on receivers will then validate the comment. let is_fof_gated = { let storage = self.storage.get().await; storage.get_post(&post_id) .ok() .flatten() .and_then(|p| p.fof_gating) .is_some() }; if is_fof_gated { return self.comment_on_fof_post(post_id, content).await; } self.comment_on_post_inner(post_id, content, None).await } /// Add a rich comment: the full body lives in `ref_post_id` (typically a /// newly-created public post by the commenter that carries attachments /// or a long body). The inline `preview` text appears in the parent /// post's header-diff and is what most clients render by default; the /// expanded view fetches the referenced post. Signature binds the /// preview + ref_post_id so a peer can't rewrite either independently. pub async fn comment_on_post_with_ref( &self, post_id: PostId, preview: String, ref_post_id: PostId, ) -> anyhow::Result { self.comment_on_post_inner(post_id, preview, Some(ref_post_id)).await } async fn comment_on_post_inner( &self, post_id: PostId, content: String, ref_post_id: Option, ) -> anyhow::Result { let our_node_id = self.default_posting_id; let seed = self.default_posting_secret; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // v0.8: TTL drawn BEFORE signing — it's inside the digest. // Ordinary public comment: NO expiry unless the parent post // declares a retention policy (registry posts do; a future // author-set TTL would land in the same field). let expires_at_ms = { let storage = self.storage.get().await; let parent = storage.get_post(&post_id).ok().flatten(); drop(storage); crate::comment_ttl::draw_expiry( crate::comment_ttl::rule_for( parent.as_ref(), crate::comment_ttl::CommentClass::Public, ), now, ) }; let signature = crate::crypto::sign_comment( &seed, &our_node_id, &post_id, &content, now, ref_post_id.as_ref(), expires_at_ms, ); let comment = crate::types::InlineComment { author: our_node_id, post_id, content, timestamp_ms: now, signature, deleted_at: None, ref_post_id, pub_x_index: None, group_sig: None, encrypted_payload: None, expires_at_ms, }; let storage = self.storage.get().await; // `store_own_comment`, not `store_comment`: WE authored this, so its // author (our persona) must stay out of our own uniques announce. storage.store_own_comment(&comment)?; // v0.8 (A3): refresh the aggregated header so pulls serve this // comment without waiting for a diff roundtrip. let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff to the target post's known holders. { let network = &self.network; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: our_node_id, ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; network.propagate_engagement_diff( &post_id, &diff, // exclude_peer is NETWORK-class (file_holders hold device // ids) — pass the network NodeId, not a posting id. &self.node_id, ).await; } Ok(comment) } /// Edit one of your own comments on a post. pub async fn edit_comment( &self, post_id: PostId, timestamp_ms: u64, new_content: String, ) -> anyhow::Result<()> { let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let storage = self.storage.get().await; storage.edit_comment(&our_node_id, &post_id, timestamp_ms, &new_content)?; let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // Propagate via BlobHeaderDiff { let network = &self.network; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: our_node_id, ops: vec![crate::types::BlobHeaderDiffOp::EditComment { author: our_node_id, post_id, timestamp_ms, new_content, }], timestamp_ms: now, }; network.propagate_engagement_diff( &post_id, &diff, // exclude_peer is NETWORK-class (file_holders hold device // ids) — pass the network NodeId, not a posting id. &self.node_id, ).await; } Ok(()) } /// Delete one of your own comments on a post. pub async fn delete_comment( &self, post_id: PostId, timestamp_ms: u64, ) -> anyhow::Result<()> { let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let storage = self.storage.get().await; storage.delete_comment(&our_node_id, &post_id, timestamp_ms)?; let _ = storage.rebuild_blob_header_from_db(&post_id, &our_node_id, now); drop(storage); // v0.8 (A3): self-certifying delete signature — holders that // never met this persona can verify it from the op alone. let delete_sig = crate::crypto::sign_comment_delete( &self.default_posting_secret, &our_node_id, &post_id, timestamp_ms, ); // Propagate via BlobHeaderDiff { let network = &self.network; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: our_node_id, ops: vec![crate::types::BlobHeaderDiffOp::DeleteComment { author: our_node_id, post_id, timestamp_ms, signature: delete_sig, }], timestamp_ms: now, }; network.propagate_engagement_diff( &post_id, &diff, // exclude_peer is NETWORK-class (file_holders hold device // ids) — pass the network NodeId, not a posting id. &self.node_id, ).await; } Ok(()) } /// Get all comments for a post. pub async fn get_comments(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; let comments = storage.get_comments(&post_id)?; Ok(comments) } /// Set the comment/reaction policy for a post (author-only). pub async fn set_comment_policy( &self, post_id: PostId, policy: crate::types::CommentPolicy, ) -> anyhow::Result<()> { let storage = self.storage.get().await; storage.set_comment_policy(&post_id, &policy)?; drop(storage); // Propagate policy change { let network = &self.network; let our_node_id = self.default_posting_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: our_node_id, ops: vec![crate::types::BlobHeaderDiffOp::SetPolicy(policy)], timestamp_ms: now, }; network.propagate_engagement_diff( &post_id, &diff, // exclude_peer is NETWORK-class (file_holders hold device // ids) — pass the network NodeId, not a posting id. &self.node_id, ).await; } Ok(()) } /// FoF Layer 2: revoke a specific pub_x from a FoF-gated post the /// caller authored. Builds a signed FoFRevocation diff, applies it /// locally (record + cascade delete), and propagates via the /// standard engagement-diff path. Idempotent. /// /// Caller passes the `pub_x_index` (from a stored comment they want /// to revoke). The pub_x bytes are resolved via the post's /// pub_post_set; if the post or index is missing, returns Err. pub async fn revoke_fof_commenter( &self, post_id: PostId, pub_x_index: u32, reason_code: u8, ) -> anyhow::Result<()> { // Resolve pub_x bytes + confirm we authored the post. let (post_author, posting_secret, revoked_pub_x) = { let storage = self.storage.get().await; let post = storage.get_post(&post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))?; let gating = post.fof_gating.as_ref() .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; let pub_x = gating.pub_post_set.get(pub_x_index as usize).copied() .ok_or_else(|| anyhow::anyhow!("pub_x_index out of bounds"))?; let identity = storage.get_posting_identity(&post.author)? .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; (post.author, identity.secret_seed, pub_x) }; let revoked_at_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let author_sig = crate::fof::sign_fof_revocation( &posting_secret, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, ); // Apply locally first so the author's UI updates immediately. { let storage = self.storage.get().await; let _ = crate::fof::apply_fof_revocation_locally( &*storage, &post_id, &revoked_pub_x, revoked_at_ms, reason_code, &author_sig, ); } // Propagate the diff. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: post_author, ops: vec![crate::types::BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x, revoked_at_ms, reason_code, author_sig, }], timestamp_ms: now, }; // exclude_peer is NETWORK-class — pass our device NodeId, not the // posting-class author id. self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } /// FoF Layer 2: author a comment on a FoF-gated post. Finds the /// caller's unlock (any held V_x that matches one of the post's /// slots), encrypts the body under CEK_comments, signs with the /// per-V_x priv_x, attaches pub_x_index, stores locally, and /// propagates via the standard engagement-diff path. /// /// Returns the constructed InlineComment. Errors if the post /// isn't FoF-gated, or if no held V_x admits the caller. pub async fn comment_on_fof_post( &self, post_id: PostId, body: String, ) -> anyhow::Result { let (unlock, slot_binder_nonce, commenter_id, commenter_secret, post_author, ttl_rule) = { let storage = self.storage.get().await; let post = storage.get_post(&post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))?; let gating = post.fof_gating.as_ref() .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; let slot_binder_nonce = gating.slot_binder_nonce; let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? .ok_or_else(|| anyhow::anyhow!("no held V_x unlocks this post — not in FoF set"))?; let identity = storage.get_posting_identity(&unlock.persona_id)? .ok_or_else(|| anyhow::anyhow!("unlocking persona not on device"))?; // Post-key-signed (member) comment: NO expiry unless the // post itself declares a retention policy. let rule = crate::comment_ttl::rule_for( Some(&post), crate::comment_ttl::CommentClass::PostKeySigned, ); (unlock, slot_binder_nonce, identity.node_id, identity.secret_seed, post.author, rule) }; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // v0.8 (A3): expiry rides the outer plaintext fields so // non-member holders can expire the comment too. let expires_at_ms = crate::comment_ttl::draw_expiry(ttl_rule, now); let comment = crate::fof::build_fof_comment( &post_id, &unlock, &slot_binder_nonce, &commenter_id, &commenter_secret, &body, None, now, expires_at_ms, )?; // Store locally. { let storage = self.storage.get().await; storage.store_own_comment(&comment)?; let _ = storage.rebuild_blob_header_from_db(&post_id, &post_author, now); } // Propagate via engagement-diff path. let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: post_author, ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment.clone())], timestamp_ms: now, }; // exclude_peer is NETWORK-class — pass our device NodeId, not the // posting-class author id. self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(comment) } /// FoF Layer 2: retroactively widen read+comment access on a /// FoF-gated post the caller authored by sealing a fresh wrap slot /// under the given V_x and appending it to the post's gating. /// Propagates as a `FoFAccessGrant` engagement-diff. pub async fn grant_fof_access( &self, post_id: PostId, new_v_x: &[u8; 32], ) -> anyhow::Result<()> { use ed25519_dalek::SigningKey; use rand::RngCore; // Resolve post + author + cached CEK + slot_binder_nonce. The // author must be on this device. let (post_author, posting_secret, cek, slot_binder_nonce) = { let storage = self.storage.get().await; let post = storage.get_post(&post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))?; let gating = post.fof_gating.as_ref() .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; let identity = storage.get_posting_identity(&post.author)? .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; // Recover the CEK: try every V_x in the author persona's // keyring against the post's slots. The author's own slot // will unwrap and yield CEK. let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? .ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?; (post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce) }; // Generate a fresh (priv_x, pub_x) keypair, seal a wrap slot // under the new V_x with the same CEK + slot_binder_nonce. let mut seed = [0u8; 32]; rand::rng().fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let new_pub_x = *signing_key.verifying_key().as_bytes(); let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?; let new_wrap_slot = crate::types::WrapSlot { prefilter_tag: sealed.prefilter_tag, read_ciphertext: sealed.read_ciphertext, sign_ciphertext: sealed.sign_ciphertext, }; let granted_at_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let author_sig = crate::fof::sign_fof_access_grant( &posting_secret, &post_id, &new_pub_x, &new_wrap_slot, granted_at_ms, ); // Apply locally first. { let storage = self.storage.get().await; let _ = crate::fof::apply_fof_access_grant_locally( &*storage, &post_id, &new_pub_x, &new_wrap_slot, ); } // Propagate. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: post_author, ops: vec![crate::types::BlobHeaderDiffOp::FoFAccessGrant { post_id, new_pub_x, new_wrap_slot, granted_at_ms, author_sig, }], timestamp_ms: now, }; // exclude_peer is NETWORK-class — pass our device NodeId, not the // posting-class author id. self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } /// FoF Layer 4: in-place wrap-slot replacement for leaked-V_me /// scenarios. Re-seals the slot at `slot_index` under `new_v_x` /// (typically a freshly-rotated V_me), publishes a signed /// FoFKeyBurn diff. Local stored copy of the post mutates to /// replace the slot. Post body remains encrypted under the /// existing CEK (CEK isn't rotated by this op). pub async fn key_burn_post_slot( &self, post_id: PostId, slot_index: u32, new_v_x: &[u8; 32], ) -> anyhow::Result<()> { use ed25519_dalek::SigningKey; use rand::RngCore; let (post_author, posting_secret, cek, slot_binder_nonce) = { let storage = self.storage.get().await; let post = storage.get_post(&post_id)? .ok_or_else(|| anyhow::anyhow!("post not found"))?; let gating = post.fof_gating.as_ref() .ok_or_else(|| anyhow::anyhow!("post is not FoF-gated"))?; if slot_index as usize >= gating.wrap_slots.len() { anyhow::bail!("slot_index out of bounds"); } let identity = storage.get_posting_identity(&post.author)? .ok_or_else(|| anyhow::anyhow!("post author not on this device"))?; // Recover CEK by trial-unlocking the author's own slot. let unlock = crate::fof::find_unlock_for_post(&*storage, &post)? .ok_or_else(|| anyhow::anyhow!("could not recover CEK for own post"))?; (post.author, identity.secret_seed, unlock.cek, gating.slot_binder_nonce) }; // Generate fresh per-V_x keypair, seal a new slot under new_v_x. let mut seed = [0u8; 32]; rand::rng().fill_bytes(&mut seed); let signing_key = SigningKey::from_bytes(&seed); let new_pub_x = *signing_key.verifying_key().as_bytes(); let sealed = crate::crypto::seal_wrap_slot(new_v_x, &slot_binder_nonce, &cek, &seed)?; let new_wrap_slot = crate::types::WrapSlot { prefilter_tag: sealed.prefilter_tag, read_ciphertext: sealed.read_ciphertext, sign_ciphertext: sealed.sign_ciphertext, }; let burned_at_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let author_sig = crate::fof::sign_fof_key_burn( &posting_secret, &post_id, slot_index, &new_pub_x, &new_wrap_slot, burned_at_ms, ); // Apply locally for immediate UI update. { let storage = self.storage.get().await; let _ = crate::fof::apply_fof_key_burn_locally( &*storage, &post_id, slot_index, &new_pub_x, &new_wrap_slot, burned_at_ms, ); } // Propagate. let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: post_author, ops: vec![crate::types::BlobHeaderDiffOp::FoFKeyBurn { post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms, author_sig, }], timestamp_ms: now, }; // exclude_peer is NETWORK-class — pass our device NodeId, not the // posting-class author id. self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } /// Get the comment policy for a post. pub async fn get_comment_policy(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; let policy = storage.get_comment_policy(&post_id)?; Ok(policy) } /// Get the full comment thread for a post (inline comments + split posts merged). pub async fn get_comment_thread(&self, post_id: PostId) -> anyhow::Result> { let storage = self.storage.get().await; // 1. Inline comments let mut comments = storage.get_comments(&post_id)?; // 2. Split posts (thread children) let children = storage.get_thread_children(&post_id)?; for child_id in children { if let Ok(Some(child_post)) = storage.get_post(&child_id) { // Split posts store comments as JSON in content if let Ok(split_comments) = serde_json::from_str::>(&child_post.content) { comments.extend(split_comments); } } } // Dedup by (author, timestamp_ms) and sort let mut seen = std::collections::HashSet::new(); comments.retain(|c| seen.insert((c.author, c.timestamp_ms))); comments.sort_by_key(|c| c.timestamp_ms); Ok(comments) } // --- Encrypted receipt/comment slot methods --- /// Unwrap the CEK for a post we are a participant of, returning /// (cek, sorted_participants, our_participant_id) where /// `our_participant_id` is the POSTING identity of ours that actually /// matched the participant set (participants are posting ids — the /// network NodeId never appears in them). /// Returns None if this is a public post or we cannot decrypt. async fn get_post_cek_and_participants( &self, post_id: &PostId, ) -> anyhow::Result, NodeId)>> { let storage = self.storage.get().await; let (post, visibility) = match storage.get_post_with_visibility(post_id)? { Some(pv) => pv, None => return Ok(None), }; let personas = storage.list_posting_identities().unwrap_or_default(); drop(storage); match &visibility { PostVisibility::Encrypted { recipients } => { // Try every persona; remember WHICH one unwrapped the CEK. let matched = personas.iter().find_map(|pi| { crypto::unwrap_cek_for_recipient( &pi.secret_seed, &pi.node_id, &post.author, recipients, ) .ok() .flatten() .map(|cek| (cek, pi.node_id)) }); match matched { Some((cek, our_id)) => { let mut participants: Vec = recipients.iter().map(|wk| wk.recipient).collect(); participants.sort(); participants.dedup(); Ok(Some((cek, participants, our_id))) } None => Ok(None), } } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { let storage = self.storage.get().await; let group_seeds = storage.get_all_group_seeds_map().unwrap_or_default(); let group_key_record = storage.get_group_key(group_id)?; let members = if let Some(ref gk) = group_key_record { storage.get_circle_members(&gk.circle_name).unwrap_or_default() } else { vec![] }; drop(storage); if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) { let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?; let mut participants: Vec = members; // Ensure the author is included if !participants.contains(&post.author) { participants.push(post.author); } participants.sort(); participants.dedup(); // Our participant identity = whichever of our personas is // in the set (admin/member), falling back to the default // persona if none is listed. let our_id = personas.iter() .map(|p| p.node_id) .find(|id| participants.contains(id)) .unwrap_or(self.default_posting_id); Ok(Some((cek, participants, our_id))) } else { Ok(None) } } PostVisibility::Public => Ok(None), // FoF Layer 3: FoFClosed posts don't use the legacy // receipt/comment slot mechanism — they use the FoF gating's // CEK_comments. This helper isn't used for FoF posts; // return None so callers fall back to the FoF-specific path. PostVisibility::FoFClosed => Ok(None), } } /// Write our receipt slot for an encrypted post. /// `state` is the receipt state, `emoji` is optional (only used when state == Reacted). pub async fn write_receipt_slot( &self, post_id: PostId, state: crate::types::ReceiptState, emoji: Option, ) -> anyhow::Result<()> { let (cek, participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); // Find our slot index (sorted participant position) — participants // are posting ids, so search for the persona that matched the CEK. let our_slot = participants.iter().position(|nid| nid == &our_participant_id) .ok_or_else(|| anyhow::anyhow!("our posting id not found in participants"))?; // Build plaintext: [1 byte state][8 bytes timestamp_ms][23 bytes emoji+padding] let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let mut plaintext = [0u8; 32]; plaintext[0] = state as u8; plaintext[1..9].copy_from_slice(&now.to_le_bytes()); if let Some(ref emoji_str) = emoji { let emoji_bytes = emoji_str.as_bytes(); let copy_len = emoji_bytes.len().min(23); plaintext[9..9 + copy_len].copy_from_slice(&emoji_bytes[..copy_len]); } let encrypted = crypto::encrypt_slot(&plaintext, &slot_key)?; // Update the BlobHeader let storage = self.storage.get().await; let header = storage.get_blob_header(&post_id)?; let mut blob_header = if let Some((json, _ts)) = header { serde_json::from_str::(&json) .unwrap_or_else(|_| crate::types::BlobHeader { post_id, author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), updated_at: now, thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], prior_author: None, }) } else { crate::types::BlobHeader { post_id, author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), updated_at: now, thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], prior_author: None, } }; // Ensure enough slots exist while blob_header.receipt_slots.len() <= our_slot { blob_header.receipt_slots.push(crypto::random_slot_noise(64)); } blob_header.receipt_slots[our_slot] = encrypted.clone(); blob_header.updated_at = now; let header_json = serde_json::to_string(&blob_header)?; storage.store_blob_header(&post_id, &blob_header.author, &header_json, now)?; drop(storage); // Propagate via BlobHeaderDiff let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: self.default_posting_id, ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot { post_id, slot_index: our_slot as u32, data: encrypted, }], timestamp_ms: now, }; self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } /// Write a private comment to an encrypted post's comment slot. pub async fn write_comment_slot( &self, post_id: PostId, content: String, ) -> anyhow::Result<()> { let (cek, _participants, our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // Build plaintext: [32 bytes author_posting_id][8 bytes timestamp_ms][216 bytes content+padding] // Slot authorship is the matched POSTING identity — never the network // NodeId (mis-attribution + a persona↔device linkage leak to all // participants). let mut plaintext = [0u8; 256]; plaintext[..32].copy_from_slice(&our_participant_id); plaintext[32..40].copy_from_slice(&now.to_le_bytes()); let content_bytes = content.as_bytes(); let copy_len = content_bytes.len().min(216); plaintext[40..40 + copy_len].copy_from_slice(&content_bytes[..copy_len]); let encrypted = crypto::encrypt_slot(&plaintext, &slot_key)?; // Find first available comment slot or add new ones let storage = self.storage.get().await; let header = storage.get_blob_header(&post_id)?; let mut blob_header = if let Some((json, _ts)) = header { serde_json::from_str::(&json) .unwrap_or_else(|_| crate::types::BlobHeader { post_id, author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), updated_at: now, thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], prior_author: None, }) } else { crate::types::BlobHeader { post_id, author: self.default_posting_id, reactions: vec![], comments: vec![], policy: Default::default(), updated_at: now, thread_splits: vec![], receipt_slots: vec![], comment_slots: vec![], prior_author: None, } }; // Try to find an empty slot by attempting decryption let mut target_index = None; for (i, slot) in blob_header.comment_slots.iter().enumerate() { if let Ok(decrypted) = crypto::decrypt_slot(slot, &slot_key) { // Check if all 256 plaintext bytes are zero (empty) if decrypted.len() == 256 && decrypted.iter().all(|&b| b == 0) { target_index = Some(i); break; } } else { // Cannot decrypt — could be random noise (empty), use it target_index = Some(i); break; } } let (slot_index, add_new) = if let Some(idx) = target_index { (idx, false) } else { // No available slots — add one let idx = blob_header.comment_slots.len(); blob_header.comment_slots.push(crypto::random_slot_noise(256)); (idx, true) }; blob_header.comment_slots[slot_index] = encrypted.clone(); blob_header.updated_at = now; let header_json = serde_json::to_string(&blob_header)?; storage.store_blob_header(&post_id, &blob_header.author, &header_json, now)?; drop(storage); // Propagate let op = if add_new { crate::types::BlobHeaderDiffOp::AddCommentSlots { post_id, count: 1, slots: vec![encrypted], } } else { crate::types::BlobHeaderDiffOp::WriteCommentSlot { post_id, slot_index: slot_index as u32, data: encrypted, } }; let diff = crate::protocol::BlobHeaderDiffPayload { post_id, author: self.default_posting_id, ops: vec![op], timestamp_ms: now, }; self.network.propagate_engagement_diff(&post_id, &diff, &self.node_id).await; Ok(()) } /// Read and decrypt all receipt slots for an encrypted post. pub async fn read_receipt_slots( &self, post_id: PostId, ) -> anyhow::Result> { let (cek, participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); let storage = self.storage.get().await; let header = storage.get_blob_header(&post_id)?; drop(storage); let blob_header = match header { Some((json, _ts)) => serde_json::from_str::(&json)?, None => return Ok(vec![]), }; let mut results = Vec::new(); for (i, slot) in blob_header.receipt_slots.iter().enumerate() { let participant_id = participants.get(i).copied(); match crypto::decrypt_slot(slot, &slot_key) { Ok(plaintext) if plaintext.len() >= 9 => { let state = crate::types::ReceiptState::from_u8(plaintext[0]); let timestamp_ms = u64::from_le_bytes( plaintext[1..9].try_into().unwrap_or([0u8; 8]), ); let emoji = if state == crate::types::ReceiptState::Reacted && plaintext.len() >= 32 { let emoji_bytes = &plaintext[9..32]; let end = emoji_bytes.iter().position(|&b| b == 0).unwrap_or(23); if end > 0 { String::from_utf8(emoji_bytes[..end].to_vec()).ok() } else { None } } else { None }; results.push(crate::types::ReceiptSlotData { slot_index: i as u32, node_id: participant_id, state, timestamp_ms, emoji, }); } _ => { // Could not decrypt — noise/uninitialized slot results.push(crate::types::ReceiptSlotData { slot_index: i as u32, node_id: participant_id, state: crate::types::ReceiptState::Empty, timestamp_ms: 0, emoji: None, }); } } } Ok(results) } /// Read and decrypt all comment slots for an encrypted post. pub async fn read_comment_slots( &self, post_id: PostId, ) -> anyhow::Result> { let (cek, _participants, _our_participant_id) = self.get_post_cek_and_participants(&post_id).await? .ok_or_else(|| anyhow::anyhow!("not a participant of this encrypted post"))?; let slot_key = crypto::derive_slot_key(&cek); let storage = self.storage.get().await; let header = storage.get_blob_header(&post_id)?; drop(storage); let blob_header = match header { Some((json, _ts)) => serde_json::from_str::(&json)?, None => return Ok(vec![]), }; let mut results = Vec::new(); for (i, slot) in blob_header.comment_slots.iter().enumerate() { match crypto::decrypt_slot(slot, &slot_key) { Ok(plaintext) if plaintext.len() >= 40 => { // Check if it's an empty slot (all zeros) if plaintext.iter().all(|&b| b == 0) { continue; } let mut author = [0u8; 32]; author.copy_from_slice(&plaintext[..32]); // Skip if author is all zeros (empty) if author == [0u8; 32] { continue; } let timestamp_ms = u64::from_le_bytes( plaintext[32..40].try_into().unwrap_or([0u8; 8]), ); let content_bytes = &plaintext[40..]; let end = content_bytes.iter().position(|&b| b == 0).unwrap_or(content_bytes.len()); let content = String::from_utf8_lossy(&content_bytes[..end]).to_string(); results.push(crate::types::CommentSlotData { slot_index: i as u32, author, timestamp_ms, content, }); } _ => { // Cannot decrypt or too short — skip } } } results.sort_by_key(|c| c.timestamp_ms); Ok(results) } } /// v0.8 (A3): a received greeting (or reply), unsealed for the inbox. /// The `(comment_author, post_id, timestamp_ms)` triple is the comment /// key used by `reply_to_greeting` / `dismiss_greeting`. #[derive(Debug, Clone)] pub struct GreetingRecord { /// Throwaway outer comment identity (comment key part 1). pub comment_author: NodeId, /// The bio/return-path post the comment sits on (comment key part 2). pub post_id: PostId, /// Comment timestamp (comment key part 3). pub timestamp_ms: u64, /// The sender's REAL persona id (recovered from inside the seal). pub sender_persona: NodeId, pub sender_name: String, pub text: String, /// Post whose Greeting open slot a reply goes into (inside the seal). pub return_path: PostId, /// Fresh per-greeting x25519 pubkey replies must be sealed to. pub reply_pubkey: [u8; 32], } // --- v0.8 (A3): registry + greetings API --- impl Node { /// Per-persona greeting consent toggle. Republishes the persona's bio /// so the greeting slot appears/disappears on the wire. /// /// Turning consent OFF also revokes the Greeting open-slot pub_x of /// every previously published bio: old bios never expire, and each /// carries its own valid open slot — without revocation, holders keep /// accepting greetings on superseded bios indefinitely (each with its /// own 64-greeting cap). `revoke_fof_commenter` is the designated /// global off-switch (spec §1.7): the RevocationEntry propagates on /// the standard rails and holders cascade-purge stored greetings. pub async fn set_greetings_open(&self, posting_id: &NodeId, open: bool) -> anyhow::Result<()> { let (secret, display_name, bio, avatar, greeting_slots) = { let s = self.storage.get().await; s.set_setting( &greetings_open_setting_key(posting_id), if open { "1" } else { "0" }, )?; let identity = s.get_posting_identity(posting_id)? .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; let profile = s.get_profile(posting_id)?; // When closing: collect every prior post by this persona that // declares a Greeting open slot, so its pub_x can be revoked. let mut slots: Vec<(PostId, u32)> = Vec::new(); if !open { for (post_id, post) in s.list_gated_posts_by_author(posting_id)? { if let Some(decl) = post .fof_gating .as_ref() .and_then(|g| g.open_slot.as_ref()) { if decl.kind == crate::types::OpenSlotKind::Greeting { slots.push((post_id, decl.slot_index)); } } } } ( identity.secret_seed, profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(), profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(), profile.as_ref().and_then(|p| p.avatar_cid), slots, ) }; // Republish the bio with the new consent state. self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?; // Off-switch: revoke the greeting slot on every prior bio so // holders stop accepting (and purge) greetings on them. for (post_id, slot_index) in greeting_slots { if let Err(e) = self .revoke_fof_commenter(post_id, slot_index, GREETING_CONSENT_REVOKE_REASON) .await { warn!( post = hex::encode(post_id), slot = slot_index, error = %e, "Failed to revoke greeting slot on prior bio" ); } } Ok(()) } /// Current greeting-consent state (unset = ON, the pre-checked default). pub async fn get_greetings_open(&self, posting_id: &NodeId) -> anyhow::Result { let s = self.storage.get().await; Ok(greetings_open_setting(&s, posting_id)) } /// Ruling #6: the author-declarable LIMIT on live stranger greetings /// for this persona's bio. `None` (or 0) = the holder default /// ([`crate::connection::MAX_GREETINGS_PER_BIO`]); refusal is the /// separate, structural case ([`Self::set_greetings_open`] with /// `false`, which publishes a bio with no open slot at all). /// /// The limit is baked into the bio post's signed `OpenSlotDecl`, so /// — exactly like the consent flag — it only reaches holders when /// the bio is republished. Writing the setting alone (e.g. through /// the generic `set_setting` command) would have no observable /// effect until some unrelated republish happened to pick it up. pub async fn set_greetings_max( &self, posting_id: &NodeId, max_comments: Option, ) -> anyhow::Result<()> { let (secret, display_name, bio, avatar) = { let s = self.storage.get().await; let key = greetings_max_setting_key(posting_id); match max_comments.filter(|n| *n > 0) { Some(n) => s.set_setting(&key, &n.to_string())?, None => s.set_setting(&key, "")?, } let identity = s .get_posting_identity(posting_id)? .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; let profile = s.get_profile(posting_id)?; ( identity.secret_seed, profile.as_ref().map(|p| p.display_name.clone()).unwrap_or_default(), profile.as_ref().map(|p| p.bio.clone()).unwrap_or_default(), profile.as_ref().and_then(|p| p.avatar_cid), ) }; // Republish so the new `OpenSlotDecl.max_comments` enters the // signed post every holder enforces. self.publish_profile_post_as(posting_id, &secret, &display_name, &bio, avatar).await?; Ok(()) } /// Current author-declared greeting limit (`None` = holder default). pub async fn get_greetings_max(&self, posting_id: &NodeId) -> anyhow::Result> { let s = self.storage.get().await; Ok(greetings_max_setting(&s, posting_id)) } /// Best-effort network fetch of a post we don't hold: content-search /// worm by post id, then PostFetch from the reported holders, stored /// through the standard receive path. async fn fetch_post_best_effort(&self, post_id: &PostId) -> anyhow::Result> { let search = self .network .content_search(&[0u8; 32], Some(*post_id), None) .await .ok() .flatten(); if let Some(result) = search { let holders: Vec = [result.post_holder, Some(result.node_id)] .into_iter() .flatten() .collect(); for holder in holders { let _ = self.connect_by_node_id(holder).await; if let Ok(Some(sp)) = self.network.post_fetch(&holder, post_id).await { let s = self.storage.get().await; let _ = crate::control::receive_post( &s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref(), ); return Ok(s.get_post(post_id)?); } } } Ok(None) } /// Shared greeting/reply sender: seal `text` to `recipient_x25519_pub` /// and drop it into `target_post_id`'s Greeting open slot under a /// freshly-minted throwaway outer identity. The sealed body carries /// the ACTING persona's identity + next-hop return path (that /// persona's bio post) + a fresh reply key. `acting_persona` must be /// a local posting identity — passing the wrong persona here would /// disclose an unrelated persona's real posting key inside the seal, /// silently linking personas the architecture keeps unlinkable. async fn send_sealed_via_open_slot( &self, acting_persona: &NodeId, target_post_id: PostId, recipient_x25519_pub: [u8; 32], text: &str, ) -> anyhow::Result<()> { if text.chars().count() > 600 { anyhow::bail!("greeting text over 600 chars"); } // Load the target post (fetch if absent), require a Greeting slot. let target_post = { let s = self.storage.get().await; s.get_post(&target_post_id)? }; let target_post = match target_post { Some(p) => p, None => self .fetch_post_best_effort(&target_post_id) .await? .ok_or_else(|| anyhow::anyhow!("target post not held and not fetchable"))?, }; let decl = target_post .fof_gating .as_ref() .and_then(|g| g.open_slot.as_ref()) .ok_or_else(|| anyhow::anyhow!("post declares no open slot"))? .clone(); if decl.kind != crate::types::OpenSlotKind::Greeting { anyhow::bail!("post's open slot is not a Greeting slot"); } let slot_binder_nonce = target_post.fof_gating.as_ref().unwrap().slot_binder_nonce; // The acting persona's return path + display name + fresh reply // keypair. Everything inside the seal is per-persona: identity, // name, and return-path bio must all belong to `acting_persona`. let (return_path, sender_name) = { let s = self.storage.get().await; // Refuse to seal anything if the persona isn't local — a // wrong id here would misattribute the message. s.get_posting_identity(acting_persona)? .ok_or_else(|| anyhow::anyhow!("acting persona not on this device"))?; let rp = s .get_latest_profile_post_id_by_author(acting_persona)? .ok_or_else(|| anyhow::anyhow!( "no bio post to use as return path — set a profile first (`name `)" ))?; let name = s .get_profile(acting_persona)? .map(|p| p.display_name) .unwrap_or_default(); (rp, name) }; let (reply_priv, reply_pub) = crypto::generate_x25519_keypair(); { let s = self.storage.get().await; s.store_greeting_reply_key(&reply_pub, &reply_priv, &return_path)?; } // Sealed body: real (acting) persona + return path + fresh reply key. let body = crate::types::GreetingBody { v: 1, sender_persona: hex::encode(acting_persona), sender_name: sender_name.chars().take(64).collect(), text: text.to_string(), return_path: hex::encode(return_path), reply_pubkey: hex::encode(reply_pub), }; let plaintext = serde_json::to_vec(&body)?; let sealed = crypto::seal_greeting_body( &recipient_x25519_pub, &target_post_id, &plaintext, decl.body_bucket as usize, )?; let sealed_b64 = { use base64::Engine; base64::engine::general_purpose::STANDARD.encode(&sealed) }; // Throwaway outer identity — minted per greeting, never reused. // It exists in the network only through this comment and vanishes // when the comment expires (§20 identity hygiene). let throwaway_key = iroh::SecretKey::generate(&mut rand::rng()); let throwaway_seed: [u8; 32] = throwaway_key.to_bytes(); let throwaway_id: NodeId = *throwaway_key.public().as_bytes(); let unlock = crate::fof::derive_open_slot_unlock(&target_post, &throwaway_id) .ok_or_else(|| anyhow::anyhow!("open slot did not unlock (revoked or malformed)"))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // Open-slot stranger channel: the ONE class with an automatic // TTL — randomized 30–365d throwaway-ID retirement, unless the // post author declared their own retention policy. let expires_at_ms = crate::comment_ttl::draw_expiry( crate::comment_ttl::rule_for( Some(&target_post), crate::comment_ttl::CommentClass::OpenSlot, ), now, ); let comment = crate::fof::build_fof_comment( &target_post_id, &unlock, &slot_binder_nonce, &throwaway_id, &throwaway_seed, &sealed_b64, None, now, expires_at_ms, )?; { let s = self.storage.get().await; // Greeting: the author is a per-greeting THROWAWAY id. We are the // first node in the network that could announce it, at bounce 1, // the moment it is created — first-announcer identifies the // greeter. `store_own_comment` keeps it out of our announce. s.store_own_comment(&comment)?; let _ = s.rebuild_blob_header_from_db(&target_post_id, &target_post.author, now); } // Propagate on the existing engagement rail. let diff = crate::protocol::BlobHeaderDiffPayload { post_id: target_post_id, author: target_post.author, ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], timestamp_ms: now, }; self.network.propagate_engagement_diff(&target_post_id, &diff, &self.node_id).await; Ok(()) } /// Send a sealed first-contact greeting to a bio post's author. /// Messaging-first: no vouch is involved anywhere in this flow. pub async fn send_greeting(&self, bio_post_id: PostId, text: String) -> anyhow::Result<()> { // Recipient key: the bio author's posting key, converted to x25519. let author = { let s = self.storage.get().await; s.get_post(&bio_post_id)?.map(|p| p.author) }; let author = match author { Some(a) => a, None => self .fetch_post_best_effort(&bio_post_id) .await? .map(|p| p.author) .ok_or_else(|| anyhow::anyhow!("bio post not held and not fetchable"))?, }; let recipient = crypto::ed25519_pubkey_to_x25519_public(&author)?; // Outbound first-contact greetings act as the default persona. let acting = self.default_posting_id; self.send_sealed_via_open_slot(&acting, bio_post_id, recipient, &text).await } /// Reply to a received greeting: sealed to the greeting's fresh /// `reply_pubkey` (never a long-term key), dropped into the sender's /// declared `return_path` post through its Greeting open slot, under /// a fresh throwaway outer identity. Each reply carries OUR next-hop /// return path + fresh reply key. pub async fn reply_to_greeting( &self, comment_author: NodeId, post_id: PostId, timestamp_ms: u64, text: String, ) -> anyhow::Result<()> { let greeting = self .list_greetings() .await? .into_iter() .find(|g| { g.comment_author == comment_author && g.post_id == post_id && g.timestamp_ms == timestamp_ms }) .ok_or_else(|| anyhow::anyhow!("greeting not found (expired or dismissed?)"))?; // Act as the persona the greeting was ADDRESSED TO — the author // of the bio post it arrived on. Hardcoding the default persona // here would leak the default persona's real posting key + bio // into the seal, silently linking two personas (and answering as // someone the counterparty never greeted). let acting = { let s = self.storage.get().await; let bio_author = s .get_post(&greeting.post_id)? .map(|p| p.author) .ok_or_else(|| anyhow::anyhow!("greeting's bio post no longer held"))?; s.get_posting_identity(&bio_author)? .ok_or_else(|| anyhow::anyhow!( "persona the greeting was addressed to is not on this device" ))? .node_id }; self.send_sealed_via_open_slot(&acting, greeting.return_path, greeting.reply_pubkey, &text) .await } /// Unseal + list greetings (and replies) on all of our personas' bio /// posts. Original greetings open with the persona's long-term key; /// replies open with the stored per-greeting reply private keys. /// Dismissed rows are skipped. pub async fn list_greetings(&self) -> anyhow::Result> { use base64::Engine; let s = self.storage.get().await; let personas = s.list_posting_identities()?; let reply_keys = s.list_greeting_reply_keys()?; let mut out: Vec = Vec::new(); for persona in &personas { let persona_priv = crypto::ed25519_seed_to_x25519_private(&persona.secret_seed); for (post_id, post) in s.list_gated_posts_by_author(&persona.node_id)? { let Some(gating) = post.fof_gating.as_ref() else { continue }; let Some(decl) = gating.open_slot.as_ref() else { continue }; if decl.kind != crate::types::OpenSlotKind::Greeting { continue; } for c in s.get_comments(&post_id)? { if c.pub_x_index != Some(decl.slot_index) { continue; } if s.is_greeting_dismissed(&c.author, &post_id, c.timestamp_ms)? { continue; } // Outer layer: the CEK is public (derivable open slot). let Some(unlock) = crate::fof::derive_open_slot_unlock(&post, &c.author) else { continue }; let Ok(payload) = crate::fof::decrypt_fof_comment_payload( &c, &unlock.cek, &gating.slot_binder_nonce, ) else { continue }; let Ok(sealed) = base64::engine::general_purpose::STANDARD.decode(payload.body.as_bytes()) else { continue }; // Inner seal: long-term persona key (original // greetings) or a stored fresh reply key (replies). let plain = crypto::open_greeting_body(&persona_priv, &post_id, &sealed) .or_else(|| { reply_keys.iter().find_map(|(privkey, _rp)| { crypto::open_greeting_body(privkey, &post_id, &sealed) }) }); let Some(plain) = plain else { continue }; let Ok(body) = serde_json::from_slice::(&plain) else { continue }; let Ok(sender_persona) = crate::parse_node_id_hex(&body.sender_persona) else { continue }; let Ok(return_path) = crate::parse_node_id_hex(&body.return_path) else { continue }; let Ok(reply_pubkey) = crate::parse_node_id_hex(&body.reply_pubkey) else { continue }; out.push(GreetingRecord { comment_author: c.author, post_id, timestamp_ms: c.timestamp_ms, sender_persona, sender_name: body.sender_name, text: body.text, return_path, reply_pubkey, }); } } } out.sort_by_key(|g| std::cmp::Reverse(g.timestamp_ms)); Ok(out) } /// Dismiss a greeting (local only — nothing propagates). pub async fn dismiss_greeting( &self, comment_author: NodeId, post_id: PostId, timestamp_ms: u64, ) -> anyhow::Result<()> { let s = self.storage.get().await; s.add_greeting_dismissal(&comment_author, &post_id, timestamp_ms) } /// Register a persona in the network registry: a plaintext, /// self-certifying entry {name, keywords} signed by the persona's /// REAL posting key, fixed 30-day TTL, newest-wins per persona. /// Re-running renews. Sets the "Listed" flag for auto-renew. pub async fn register_persona( &self, posting_id: &NodeId, name: &str, keywords: &[String], ) -> anyhow::Result<()> { let entry = crate::registry::RegistrationEntry { v: 1, name: name.to_string(), keywords: keywords.to_vec(), }; let entry_json = serde_json::to_string(&entry)?; // Enforce shape limits locally before signing anything. crate::registry::parse_registration(&entry_json)?; let (secret_seed, registry_post) = { let s = self.storage.get().await; let identity = s.get_posting_identity(posting_id)? .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; let _ = crate::registry::materialize_registry_post(&s); let post = s.get_post(&crate::registry::REGISTRY_POST_ID)? .ok_or_else(|| anyhow::anyhow!("registry post missing after materialization"))?; (identity.secret_seed, post) }; let unlock = crate::fof::derive_open_slot_unlock(®istry_post, posting_id) .ok_or_else(|| anyhow::anyhow!("registry open slot did not unlock"))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; // The 30-day registration TTL now comes FROM THE POST's own // retention policy (flat 30d), not from a registration-specific // constant at the call site — same path a future author-set TTL // would take. let expires_at_ms = crate::comment_ttl::draw_expiry( crate::comment_ttl::rule_for( Some(®istry_post), crate::comment_ttl::CommentClass::OpenSlot, ), now, ); // group_sig over (content_bytes || post_id || pub_x_index_le) — // the plaintext-open-slot form of the standard scheme. let group_sig = { use ed25519_dalek::{Signer, SigningKey}; let signer = SigningKey::from_bytes(&unlock.priv_x_seed); let mut to_sign = Vec::with_capacity(entry_json.len() + 32 + 4); to_sign.extend_from_slice(entry_json.as_bytes()); to_sign.extend_from_slice(&crate::registry::REGISTRY_POST_ID); to_sign.extend_from_slice(&unlock.slot_index.to_le_bytes()); signer.sign(&to_sign).to_bytes().to_vec() }; // The standard comment signature IS the self-certification: the // entry names its persona in `author`, verified against that key. let signature = crypto::sign_comment( &secret_seed, posting_id, &crate::registry::REGISTRY_POST_ID, &entry_json, now, None, expires_at_ms, ); let comment = crate::types::InlineComment { author: *posting_id, post_id: crate::registry::REGISTRY_POST_ID, content: entry_json, timestamp_ms: now, signature, deleted_at: None, ref_post_id: None, pub_x_index: Some(unlock.slot_index), group_sig: Some(group_sig), encrypted_payload: None, expires_at_ms, }; { let s = self.storage.get().await; // Newest-wins locally (deletes our older entries). let _ = s.upsert_registry_entry_newest_wins( &crate::registry::REGISTRY_POST_ID, posting_id, now, ); s.store_own_comment(&comment)?; let _ = s.rebuild_blob_header_from_db( &crate::registry::REGISTRY_POST_ID, ®istry_post.author, now, ); let id_hex = hex::encode(posting_id); s.set_setting(&format!("registry_listed.{}", id_hex), "1")?; s.set_setting(&format!("registry_name.{}", id_hex), name)?; s.set_setting(&format!("registry_keywords.{}", id_hex), &keywords.join(","))?; } let diff = crate::protocol::BlobHeaderDiffPayload { post_id: crate::registry::REGISTRY_POST_ID, author: registry_post.author, ops: vec![crate::types::BlobHeaderDiffOp::AddComment(comment)], timestamp_ms: now, }; self.network .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) .await; Ok(()) } /// Remove a persona's registry entry via a self-certifying signed /// DeleteComment (honored by holders that never met the persona). /// Clears the "Listed" flag. pub async fn unregister_persona(&self, posting_id: &NodeId) -> anyhow::Result<()> { let (secret_seed, newest) = { let s = self.storage.get().await; let identity = s.get_posting_identity(posting_id)? .ok_or_else(|| anyhow::anyhow!("persona not on this device"))?; let newest = s.get_newest_registry_entry( &crate::registry::REGISTRY_POST_ID, posting_id, )?; s.set_setting(&format!("registry_listed.{}", hex::encode(posting_id)), "0")?; (identity.secret_seed, newest) }; let Some((entry_ts, _exp)) = newest else { return Ok(()); // nothing listed — flag cleared, done }; let delete_sig = crypto::sign_comment_delete( &secret_seed, posting_id, &crate::registry::REGISTRY_POST_ID, entry_ts, ); { let s = self.storage.get().await; let _ = s.delete_comment(posting_id, &crate::registry::REGISTRY_POST_ID, entry_ts); let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let _ = s.rebuild_blob_header_from_db( &crate::registry::REGISTRY_POST_ID, &crate::DEFAULT_ANCHOR_POSTING_ID, now_ms, ); } let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let diff = crate::protocol::BlobHeaderDiffPayload { post_id: crate::registry::REGISTRY_POST_ID, author: crate::DEFAULT_ANCHOR_POSTING_ID, ops: vec![crate::types::BlobHeaderDiffOp::DeleteComment { author: *posting_id, post_id: crate::registry::REGISTRY_POST_ID, timestamp_ms: entry_ts, signature: delete_sig, }], timestamp_ms: now, }; self.network .propagate_engagement_diff(&crate::registry::REGISTRY_POST_ID, &diff, &self.node_id) .await; Ok(()) } /// Search the registry: refresh the chain from up to 3 connected /// peers (BlobHeaderRequest via the existing engagement-fetch rail), /// then query locally. Search cost lands on the searcher (design §27). pub async fn search_registry( &self, query: &str, ) -> anyhow::Result> { // Mark the registry post due so the engagement fetch includes it. { let s = self.storage.get().await; let _ = crate::registry::materialize_registry_post(&s); let _ = s.update_post_last_check(&crate::registry::REGISTRY_POST_ID, 0); } let peers = self.list_connections().await; for (peer, _slot, _ts) in peers.into_iter().take(3) { let _ = self.network.conn_handle().fetch_engagement_from_peer(&peer).await; } let s = self.storage.get().await; crate::registry::search_entries(&s, query) } /// Auto-renew (round 8, DECIDED): while the "Listed" flag is set, /// re-sign a fresh 30d entry when the current one expires within 5 /// days (~every 25 days). Piggybacked on the eviction cycle. pub async fn renew_registry_entries_if_due(&self) -> anyhow::Result { const RENEW_WINDOW_MS: u64 = 5 * 24 * 3600 * 1000; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; let due: Vec<(NodeId, String, Vec)> = { let s = self.storage.get().await; // What retention does the registry post itself impose on its // comments? Under a Window rule (the flat 30d) a stored // expiry of 0 is not a legal state — `get_newest_registry_entry` // maps a NULL `expires_at` column to 0, so it cannot tell // "never expires" from "no expiry recorded". Treating such a // row as never-renew would silently drop the persona off the // registry: remote holders still enforce the 30 days and // delete the entry, while our local DB shows it as listed // forever. Only a `Never` rule makes 0 mean never. let registry_rule = crate::comment_ttl::rule_for( s.get_post(&crate::registry::REGISTRY_POST_ID)?.as_ref(), crate::comment_ttl::CommentClass::OpenSlot, ); let zero_expiry_is_permanent = matches!(registry_rule, crate::comment_ttl::CommentTtlRule::Never); let mut due = Vec::new(); for persona in s.list_posting_identities()? { let id_hex = hex::encode(persona.node_id); let listed = s .get_setting(&format!("registry_listed.{}", id_hex))? .map(|v| v == "1") .unwrap_or(false); if !listed { continue; } let newest = s.get_newest_registry_entry( &crate::registry::REGISTRY_POST_ID, &persona.node_id, )?; let needs_renew = match newest { // `exp == 0`: never-expires sentinel ONLY when the // registry post declares no TTL policy (a future // shard could). Under a Window rule it means the row // has no expiry recorded — renew it. Some((_ts, 0)) => !zero_expiry_is_permanent, Some((_ts, exp)) => exp <= now + RENEW_WINDOW_MS, None => true, }; if !needs_renew { continue; } let name = s .get_setting(&format!("registry_name.{}", id_hex))? .unwrap_or_else(|| persona.display_name.clone()); let keywords: Vec = s .get_setting(&format!("registry_keywords.{}", id_hex))? .unwrap_or_default() .split(',') .filter(|k| !k.is_empty()) .map(|k| k.to_string()) .collect(); due.push((persona.node_id, name, keywords)); } due }; let mut renewed = 0usize; for (persona_id, name, keywords) in due { if self.register_persona(&persona_id, &name, &keywords).await.is_ok() { renewed += 1; } } Ok(renewed) } /// One-shot genesis publish of the registry post (`--publish-registry`). /// Refuses unless the default posting identity is the bootstrap /// anchor's (mirrors `publish_announcement`). Debug builds may bypass /// via `ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS=1` for multi-node tests. pub async fn publish_registry_genesis(&self) -> anyhow::Result { #[allow(unused_mut)] let mut allowed = self.default_posting_id == crate::DEFAULT_ANCHOR_POSTING_ID; #[cfg(debug_assertions)] { if std::env::var("ITSGOIN_TEST_ALLOW_REGISTRY_GENESIS").as_deref() == Ok("1") { allowed = true; } } if !allowed { anyhow::bail!( "refusing to publish registry genesis: default posting identity is not the bootstrap anchor" ); } { let s = self.storage.get().await; let _ = crate::registry::materialize_registry_post(&s)?; } self.update_neighbor_manifests_as( &self.default_posting_id, &self.default_posting_secret, &crate::registry::REGISTRY_POST_ID, crate::registry::REGISTRY_GENESIS_TIMESTAMP_MS, ).await; info!( post_id = hex::encode(crate::registry::REGISTRY_POST_ID), "Registry genesis published" ); Ok(crate::registry::REGISTRY_POST_ID) } } pub struct NodeStats { pub post_count: usize, pub peer_count: usize, pub follow_count: usize, } /// Standalone priority scoring for testing. /// score = pin_boost + (relationship × heart_recency × freshness / (peer_copies + 1)) pub fn compute_blob_priority_standalone( candidate: &crate::storage::EvictionCandidate, own_author_ids: &[NodeId], follows: &[NodeId], now_ms: u64, ) -> f64 { let pin_boost = if candidate.pinned { 1000.0 } else { 0.0 }; // Share-link popularity boost: high downstream count indicates the blob // has been shared via share links and is actively being served to others. let share_boost = if candidate.downstream_count >= 3 { 100.0 } else if candidate.downstream_count >= 1 { 50.0 * candidate.downstream_count as f64 / 3.0 } else { 0.0 }; // v0.6.2: audience removed. Relationship is author-of-ours vs followed vs other. // Authors are posting identities — check against ALL of our personas. let relationship = if own_author_ids.contains(&candidate.author) { 5.0 } else if follows.contains(&candidate.author) { 2.0 } else { 0.1 }; let thirty_days_ms = 30u64 * 24 * 3600 * 1000; let access_age_ms = now_ms.saturating_sub(candidate.last_accessed_at); let heart_recency = (1.0 - (access_age_ms as f64 / thirty_days_ms as f64)).max(0.0); let post_age_days = now_ms.saturating_sub(candidate.created_at) as f64 / (24.0 * 3600.0 * 1000.0); let freshness = 1.0 / (1.0 + post_age_days); let copies_factor = 1.0 / (candidate.peer_copies as f64 + 1.0); pin_boost + share_boost + (relationship * heart_recency * freshness * copies_factor) } // --- Active Replication Cycle --- impl Node { /// Start the active replication cycle: periodically ask peers to hold our /// under-replicated recent content. All devices initiate — phones need /// their content replicated before they go to sleep. pub fn start_replication_cycle(self: &Arc, interval_secs: u64) -> tokio::task::JoinHandle<()> { let node = Arc::clone(self); tokio::spawn(async move { // Wait 2 minutes before first cycle (let connections establish) tokio::time::sleep(std::time::Duration::from_secs(120)).await; let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs)); loop { interval.tick().await; node.run_replication_check().await; } }) } /// Single replication check iteration. async fn run_replication_check(&self) { // All devices initiate replication — phones need their content replicated // before they go to sleep. // 1. Get own posts < 72h old let seventy_two_hours_ms = 72u64 * 3600 * 1000; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; let since_ms = now_ms.saturating_sub(seventy_two_hours_ms); // Get connected peers first (no storage lock needed) let connected = self.network.connected_peers().await; if connected.is_empty() { debug!("No peers for replication"); return; } // Priority: Available (desktops) > Persistent (anchors) > Intermittent (phones) let role_priority = |role: &DeviceRole| -> u16 { match role { DeviceRole::Available => 300, // desktops — best replication targets DeviceRole::Persistent => 200, // anchors — good but save for web DeviceRole::Intermittent => 100, // phones — last resort but still useful } }; // Single lock: get under-replicated posts AND peer roles/pressure let (under_replicated, suitable_peers) = { let storage = self.storage.get().await; // Own posts are authored by posting identities (personas), never // the network NodeId — union recent posts across all personas. let personas = storage.list_posting_identities().unwrap_or_default(); let mut recent_ids: Vec = Vec::new(); for persona in &personas { match storage.get_own_recent_post_ids(&persona.node_id, since_ms) { Ok(ids) => recent_ids.extend(ids), Err(e) => { debug!(error = %e, "Replication: failed to get own recent posts"); return; } } } // Filter to under-replicated (< 2 holders) let mut needs_replication = Vec::new(); for pid in &recent_ids { match storage.get_file_holder_count(pid) { Ok(count) if count < 2 => { needs_replication.push(*pid); } _ => {} } } // Get peer roles + cache pressure in same lock let mut candidates = Vec::new(); for peer_id in &connected { if *peer_id == self.node_id { continue; } let role_str = storage.get_peer_device_role(peer_id) .ok() .flatten() .unwrap_or_default(); let role = DeviceRole::from_str_label(&role_str); let pressure = storage.get_peer_cache_pressure(peer_id) .ok() .flatten() .unwrap_or(128) as u16; // Combined score: role priority + cache pressure let score = role_priority(&role) + pressure; candidates.push((*peer_id, score)); } (needs_replication, candidates) }; // If none need replication, skip silently if under_replicated.is_empty() { return; } if suitable_peers.is_empty() { debug!("No peers available for replication"); return; } // Pick best candidate (highest combined score) let best_peer = suitable_peers .iter() .max_by_key(|(_, score)| *score) .map(|(id, _)| *id) .unwrap(); // 7. Cap at 20 post IDs per request, one request per cycle let batch: Vec = under_replicated.into_iter().take(20).collect(); let batch_len = batch.len(); // 8. Send ReplicationRequest match self.network.send_replication_request(&best_peer, batch, 128).await { Ok(accepted) => { if accepted.is_empty() { debug!( peer = hex::encode(best_peer), "Replication: peer rejected all posts" ); } else { debug!( peer = hex::encode(best_peer), accepted = accepted.len(), requested = batch_len, "Replication: peer accepted posts" ); } } Err(e) => { debug!( peer = hex::encode(best_peer), error = %e, "Replication: request failed" ); } } } } #[cfg(test)] mod tests { use super::*; use crate::storage::EvictionCandidate; fn make_node_id(byte: u8) -> NodeId { [byte; 32] } fn make_candidate( author: NodeId, pinned: bool, created_at: u64, last_accessed_at: u64, peer_copies: u32, ) -> EvictionCandidate { EvictionCandidate { cid: [0u8; 32], post_id: [0u8; 32], author, size_bytes: 1000, created_at, last_accessed_at, pinned, peer_copies, downstream_count: 0, } } #[test] fn own_pinned_scores_highest() { let our_id = make_node_id(1); let now = 10_000_000_000u64; // ~115 days in ms let candidate = make_candidate(our_id, true, now - 86400_000, now, 0); let score = compute_blob_priority_standalone( &candidate, &[our_id], &[], now, ); assert!(score > 1000.0, "own pinned should score >1000, got {}", score); } #[test] fn follow_recent_scores_higher_than_stranger_stale() { let our_id = make_node_id(1); let follow_id = make_node_id(2); let stranger_id = make_node_id(3); let now = 10_000_000_000u64; 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, ); let stranger_candidate = make_candidate( stranger_id, false, now - 10 * 86400_000, now - 20 * 86400_000, 5, ); let stranger_score = compute_blob_priority_standalone( &stranger_candidate, &[our_id], &[], now, ); assert!(follow_score > stranger_score, "follow recent ({}) should score higher than stranger stale ({})", follow_score, stranger_score); } #[test] fn no_relationship_scores_near_zero() { let our_id = make_node_id(1); let stranger = make_node_id(99); let now = 10_000_000_000u64; let candidate = make_candidate( stranger, false, now - 30 * 86400_000, now - 30 * 86400_000, 10, ); let score = compute_blob_priority_standalone( &candidate, &[our_id], &[], now, ); assert!(score < 0.01, "stranger stale should score near 0, got {}", score); } #[test] fn priority_ordering() { let our_id = make_node_id(1); let follow_id = make_node_id(2); let stranger_id = make_node_id(4); let now = 10_000_000_000u64; let own = make_candidate(our_id, true, now - 86400_000, now, 0); let follow = make_candidate(follow_id, false, now - 86400_000, now, 0); 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], 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 > stranger_score, "follow ({}) > stranger ({})", follow_score, stranger_score); } /// A1 (bug 4): blobs authored by ANY of our posting identities get the /// own-content 5.0 tier — including non-default personas — and the /// network NodeId never matches (posting authors only). #[test] fn second_persona_blob_scores_as_own() { let persona1 = make_node_id(1); let persona2 = make_node_id(2); let network_id = make_node_id(9); let now = 10_000_000_000u64; let by_second = make_candidate(persona2, false, now - 86400_000, now, 0); let by_network = make_candidate(network_id, false, now - 86400_000, now, 0); let own_ids = [persona1, persona2]; let second_score = compute_blob_priority_standalone(&by_second, &own_ids, &[], now); let network_score = compute_blob_priority_standalone(&by_network, &own_ids, &[], now); // Identical candidates → the only difference is the relationship // tier: 5.0 (own persona) vs 0.1 (stranger). Ratio must reflect it. assert!(second_score > network_score * 10.0, "persona2 blob ({}) must be own-tier vs network-authored ({})", second_score, network_score); } }