itsgoin/crates/core/src/node.rs
Scott Reimers 8b042f6598 fix: comment retention taxonomy — TTL by (post policy x signing class)
Retention was keyed on comment type, so public and post-key-signed comments
carried a randomized 30-365d expiry inside their signed digest. Under the
ruling neither should ever expire; only the private, non-post-key-signed
(open-slot/greeting) channel gets an automatic TTL, since that is the
throwaway-identity retirement mechanism.

New crates/core/src/comment_ttl.rs is the single authority: CommentClass
(Public / PostKeySigned / OpenSlot / Unverifiable) x CommentTtlRule
(Never | Window | UnknownParent), with draw_expiry (writer) and ttl_ok
(holder). expires_at_ms == 0 is the never-expires sentinel, honored by the
sweep, the ingest gate, and the store_comment upsert.

- Post.comment_ttl: Option<CommentTtlPolicy> — a GENERIC per-post policy, so
  an author-set TTL is a future config surface, not a redesign. Registry posts
  declare a flat 30d policy that binds EVERY comment on them (registrations,
  duplicate reports, anything else), replacing the registration-only rule.
- OpenSlotDecl.max_comments: author-declarable cap on private PK-unsigned
  comments, enforced holder-side (clamped to the holder default), replacing
  the hardcoded per-bio greeting cap. Refusal remains "declare no slot".
  Node::set_greetings_max + `greetings-max` CLI command to write it.
- Holder enforcement rejects TTLs contradicting the parent's policy in both
  directions; a comment naming a different post than its envelope is rejected.
- UnknownParent rule: bounded TTLs accepted from unheld parents (self-heal),
  never-expires refused — permanence is not granted on unseen evidence.

Also fixed while here: five Post-reconstructing queries silently dropped
comment_ttl AND the pre-existing fof_gating (shipped in v0.8.0-alpha), so any
gated or policy-carrying post failed BLAKE3 verification on sync/export and
was discarded with no diagnostic. All hydration now goes through one
POST_COLUMNS/post_from_row path; export/import round-trips the policy.

Registry frozen bytes regenerated for the policy field; REGISTRY_POST_ID is
now 10a1be3383efb2977607fe45c4a7b3f1b5e626e81d0ac1af9c0f3d7eb9864d32.
design.html section 21 rewritten to the corrected taxonomy.

250 core tests (was 228); a3 integration 12/12 (new step 6 asserts registry
comments hold exactly 30d while greetings randomize); c_topology 33/33.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-08-03 15:33:44 -04:00

7338 lines
307 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<StoragePool>,
pub network: Arc<Network>,
/// 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<BlobStore>,
/// 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<Vec<(NodeId, iroh::EndpointAddr)>>,
/// True if an anchor reported another instance of this identity is already active
pub duplicate_detected: Arc<AtomicBool>,
profile: DeviceProfile,
pub activity_log: Arc<std::sync::Mutex<ActivityLog>>,
pub last_rebalance_ms: Arc<AtomicU64>,
/// Last time the convection loop acted (replaces the retired anchor
/// register cycle's timer).
pub last_convection_ms: Arc<AtomicU64>,
/// CDN replication budget: bytes remaining we're willing to pull and cache this hour
replication_budget_remaining: Arc<AtomicU64>,
/// CDN delivery budget: bytes remaining we're willing to serve this hour
delivery_budget_remaining: Arc<AtomicU64>,
/// Last budget reset timestamp (ms)
budget_last_reset_ms: Arc<AtomicU64>,
}
/// 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<u32> {
storage
.get_setting(&greetings_max_setting_key(posting_id))
.ok()
.flatten()
.and_then(|v| v.parse::<u32>().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<std::net::SocketAddr>)>,
network: Arc<crate::network::Network>,
storage: Arc<StoragePool>,
self_node_id: NodeId,
label: &'static str,
) -> Option<NodeId> {
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::<NodeId>();
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<StoragePool>,
network: &crate::network::Network,
self_node_id: NodeId,
limit: usize,
) -> Vec<(NodeId, Vec<std::net::SocketAddr>)> {
let mut out: Vec<(NodeId, Vec<std::net::SocketAddr>)> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = 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<std::net::SocketAddr> =
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<crate::network::Network>,
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<StoragePool>,
nid: NodeId,
addrs: Vec<std::net::SocketAddr>,
self_node_id: NodeId,
label: &'static str,
) -> Option<NodeId> {
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<StoragePool>,
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<Path>) -> anyhow::Result<Self> {
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<Path>) -> anyhow::Result<Self> {
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<Path>,
bind_addr: Option<SocketAddr>,
profile: DeviceProfile,
) -> anyhow::Result<Self> {
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<NodeId, [u8; 32]> =
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::<crate::types::AuthorManifest>(&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::<Vec<String>>(&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<std::net::SocketAddr> = 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::<Vec<String>>(&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<NodeId> =
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<std::net::SocketAddr> = 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<ActivityEvent> {
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<Vec<crate::types::PostingIdentity>> {
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<bool>,
) -> anyhow::Result<crate::types::PostingIdentity> {
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<usize> {
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<bool> {
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<String> {
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<NodeId> {
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<NodeId> = 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<u8>, 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<u8>, 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<u8>, 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<Option<String>> {
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<u8>, String)>,
fof_gating: Option<crate::types::FoFCommentGating>,
) -> 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<NodeId> },
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<Vec<u8>> = (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<Vec<u8>> = (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::<crate::types::AuthorManifest>(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<Vec<NodeId>> {
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<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<u64>,
limit: usize,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<u64>,
limit: usize,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility, Option<String>)>> {
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<String>)> {
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<Vec<NodeId>> {
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<std::collections::HashMap<NodeId, u64>> {
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<Vec<NodeId>> {
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<Vec<PublicProfile>> {
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<PublicProfile> {
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<NodeId>) -> anyhow::Result<PublicProfile> {
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<Vec<NodeId>> {
let storage = self.storage.get().await;
storage.get_peer_anchors(node_id)
}
pub async fn get_profile(&self, node_id: &NodeId) -> anyhow::Result<Option<PublicProfile>> {
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<Option<PublicProfile>> {
let storage = self.storage.get().await;
storage.get_profile(&self.default_posting_id)
}
pub async fn has_profile(&self) -> anyhow::Result<bool> {
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<Option<String>> {
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<u32> {
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<usize> {
// 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<Vec<(NodeId, String, u64)>> {
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<Vec<(NodeId, String, u32, u64)>> {
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<Option<Vec<u8>>> {
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<u8>,
post: &Post,
visibility: &PostVisibility,
group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>,
personas: &[crate::types::PostingIdentity],
) -> anyhow::Result<Option<Vec<u8>>> {
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<Option<Vec<u8>>> {
// 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<crate::types::Attachment>)> = {
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<crate::types::Attachment>)> = 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<Option<Vec<u8>>> {
// 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<Option<Vec<u8>>> {
// 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<crate::types::PeerWithAddress> = 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<NodeId>,
) -> 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<NodeId> = {
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<u8>, 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<Vec<(PostId, Post, PostVisibility)>> {
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<crate::types::ReleaseAnnouncement>,
) -> anyhow::Result<PostId> {
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<Option<crate::announcement::StoredAnnouncement>> {
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<usize> {
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<PostId>,
) -> 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<NodeId> = storage.list_posting_identities()?
.into_iter().map(|p| p.node_id).collect();
let other_members: Vec<NodeId> = 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<NodeId> = {
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<NodeId> = 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<Vec<Circle>> {
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<crate::types::CircleProfile> {
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<NodeId> = 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<Option<crate::types::CircleProfile>> {
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<bool> {
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<Option<String>> {
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<u8> {
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<NodeId> = 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<u64> {
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<Option<PostId>> {
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<NodeId> = 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<usize> {
// 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<NodeId> = 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::<std::net::SocketAddr>() {
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<crate::connection::IntroId> = None;
let mut last_relay_peer: Option<NodeId> = 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<Option<WormResult>> {
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<Vec<NodeId>> {
let storage = self.storage.get().await;
storage.list_peers()
}
pub async fn list_peer_records(&self) -> anyhow::Result<Vec<PeerRecord>> {
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<NodeStats> {
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<anyhow::Result<()>> {
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<Self>) -> 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<NodeId>| {
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<std::net::SocketAddr> = {
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<NodeId>| {
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<String> = 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<std::net::SocketAddr> = 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<Self>) -> 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::<crate::types::AuthorManifest>(&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<PeerWithAddress> {
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<String> = 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<Vec<SocialRouteEntry>> {
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<usize> {
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<NodeId> = 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<Self>,
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 30365d 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<tokio::task::JoinHandle<()>> {
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<Self>, 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<tokio::task::JoinHandle<()>> {
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<Option<String>> {
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<crate::types::Reaction> {
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<Vec<crate::types::Reaction>> {
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<Vec<(String, u64, bool)>> {
let storage = self.storage.get().await;
let our_ids: Vec<NodeId> = 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<crate::types::InlineComment> {
// 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<crate::types::InlineComment> {
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<PostId>,
) -> anyhow::Result<crate::types::InlineComment> {
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<Vec<crate::types::InlineComment>> {
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<crate::types::InlineComment> {
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<Option<crate::types::CommentPolicy>> {
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<Vec<crate::types::InlineComment>> {
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::<Vec<crate::types::InlineComment>>(&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<Option<([u8; 32], Vec<NodeId>, 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<NodeId> = 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<NodeId> = 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<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);
// 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::<crate::types::BlobHeader>(&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::<crate::types::BlobHeader>(&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<Vec<crate::types::ReceiptSlotData>> {
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::<crate::types::BlobHeader>(&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<Vec<crate::types::CommentSlotData>> {
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::<crate::types::BlobHeader>(&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<bool> {
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<u32>,
) -> 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<Option<u32>> {
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<Option<Post>> {
let search = self
.network
.content_search(&[0u8; 32], Some(*post_id), None)
.await
.ok()
.flatten();
if let Some(result) = search {
let holders: Vec<NodeId> = [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 <display_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 30365d 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<Vec<GreetingRecord>> {
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<GreetingRecord> = 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::<crate::types::GreetingBody>(&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(&registry_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(&registry_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, &registry_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<Vec<crate::registry::RegistryMatch>> {
// 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<usize> {
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<String>)> = {
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<String> = 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<PostId> {
#[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<Self>, 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<PostId> = 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<PostId> = 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);
}
}